Compare commits
18
Commits
cded9030b5
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
808f02bac7 | ||
|
|
6701cbac76 | ||
|
|
90338f3980 | ||
|
|
729ab9592d | ||
|
|
a036f39713 | ||
|
|
3ed3af3962 | ||
|
|
3acbc79008 | ||
|
|
85a7f563f6 | ||
|
|
bed01788fd | ||
|
|
0dac2d3b6e | ||
|
|
449ad16244 | ||
|
|
77060325ef | ||
|
|
8a21d85036 | ||
|
|
ff0f79a473 | ||
|
|
5b886f4fc2 | ||
|
|
1db85be875 | ||
|
|
7d6560703d | ||
|
|
8c8147cd5b |
@@ -5,3 +5,6 @@
|
|||||||
# (/open-gateway/front/ → 首段 open-gateway → ${origin}/open-gateway)。
|
# (/open-gateway/front/ → 首段 open-gateway → ${origin}/open-gateway)。
|
||||||
VITE_SERVICE_HOST=/open-gateway
|
VITE_SERVICE_HOST=/open-gateway
|
||||||
VITE_IS_MOCK=false
|
VITE_IS_MOCK=false
|
||||||
|
# 超管门槛(src/utils/superGate.ts):本地无平台 Cookie 时 CurrentUser 探测失败按放行处理;
|
||||||
|
# 如需完全旁路门槛,取消下一行注释(仅 dev 生效,build 不读本文件)。
|
||||||
|
# VITE_SUPER_GATE=off
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# 本地纯前端演示模式:仅 vite --mode mock 加载(推荐入口 npm run dev:mock,
|
||||||
|
# 由 scripts/start-mock.mjs 一并拉起 9527 主题静态通道;直接 vite --mode mock 也可,
|
||||||
|
# 但无 VPN 时主题 CSS 取不到、只剩骨架)。build 产物不读任何 .env.*。
|
||||||
|
# 后端/VPN 不可达时,vite dev server 内置中间件按后端契约拦截 /open-gateway/api/internal/**
|
||||||
|
# (见 vite.config.ts gatewayAdminMockPlugin + src/assets/mock/gatewayApi.ts)。
|
||||||
|
VITE_SERVICE_HOST=/open-gateway
|
||||||
|
VITE_IS_MOCK=true
|
||||||
@@ -19,7 +19,10 @@ jobs:
|
|||||||
options: --network infra-infra-net
|
options: --network infra-infra-net
|
||||||
steps:
|
steps:
|
||||||
- name: 安装 git / curl / zip(alpine)
|
- name: 安装 git / curl / zip(alpine)
|
||||||
run: apk add --no-cache git curl zip
|
run: |
|
||||||
|
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
||||||
|
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||||
|
apk add --no-cache git curl zip
|
||||||
|
|
||||||
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||||
@@ -38,21 +41,29 @@ jobs:
|
|||||||
- name: 构建前端(node 容器)
|
- name: 构建前端(node 容器)
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
docker rm -f frontbuild 2>/dev/null || true
|
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||||
docker run -d --name frontbuild -w /build node:20 sleep 3600
|
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||||
docker cp "${PWD}/." frontbuild:/build/
|
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||||
docker exec frontbuild sh -c '
|
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||||
|
BUILD_CONTAINER="frontbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||||
|
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||||
|
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
|
||||||
|
docker run -d --name "$BUILD_CONTAINER" -w /build node:20 sleep 3600
|
||||||
|
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||||
|
docker exec "$BUILD_CONTAINER" sh -c '
|
||||||
set -e
|
set -e
|
||||||
cd /build
|
cd /build
|
||||||
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
|
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
|
||||||
|
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
|
||||||
|
npm config set registry https://registry.npmmirror.com
|
||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
'
|
'
|
||||||
echo "=== dist 产物 ==="
|
echo "=== dist 产物 ==="
|
||||||
docker exec frontbuild sh -c 'ls -la /build/dist/ | head -20'
|
docker exec "$BUILD_CONTAINER" sh -c 'ls -la /build/dist/ | head -20'
|
||||||
# 从容器 cp dist 到宿主工作目录
|
# 从容器 cp dist 到宿主工作目录
|
||||||
docker cp frontbuild:/build/dist ./dist
|
docker cp "$BUILD_CONTAINER":/build/dist ./dist
|
||||||
docker rm -f frontbuild
|
docker rm -f "$BUILD_CONTAINER"
|
||||||
echo "BUILD CHECK OK"
|
echo "BUILD CHECK OK"
|
||||||
|
|
||||||
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
|
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# 网关台账前端 Mock 演示指南
|
||||||
|
|
||||||
|
离线(无 VPN、无后端)一键起完整可演示环境:三个治理页全量可看可玩 + YschTheme 主题五档配色。
|
||||||
|
|
||||||
|
## 一条命令
|
||||||
|
|
||||||
|
```cmd
|
||||||
|
cd D:\SoftProject\yunhai\open-gateway\front
|
||||||
|
npm run dev:mock
|
||||||
|
```
|
||||||
|
|
||||||
|
然后浏览器打开 **http://localhost:8131**(自动落在「访问台账」页)。
|
||||||
|
|
||||||
|
前提:Node **20.19.4**(本机 nvm 路径 `D:\nvm\nvm\v20.19.4`,若 `node -v` 不对请先 `nvm use 20.19.4`),`front/node_modules` 已安装。
|
||||||
|
|
||||||
|
`dev:mock`(`scripts/start-mock.mjs`,零依赖)做三件事:
|
||||||
|
|
||||||
|
1. 拉起 **YschTheme 本地静态通道**:`YschTheme/scripts/dev-serve.mjs` 把 `YschTheme/dist/pc` 挂到 `http://localhost:9527/static/`(主题 CSS/JS 不拷进本仓——应用侧不持主题副本是主题仓硬原则);
|
||||||
|
2. 探活 `9527/static/manifest.json`;
|
||||||
|
3. 以 `YH_THEME_PROXY_TARGET=http://localhost:9527` 启动 `vite --mode mock`(8131),vite 的 `/static` 代理由此指向本地通道。
|
||||||
|
|
||||||
|
全程不触网。`Ctrl+C` 退出会一并收掉两个子进程;若 9527 被上次残留进程占用且可服务,脚本会直接复用。
|
||||||
|
|
||||||
|
### 切回真后端
|
||||||
|
|
||||||
|
装好 VPN 后照旧 `npm run dev`(走 `.env.dev`,经 `/open-gateway/api` 代理到 base 域),与 `dev:mock` 互不影响、互不覆盖。线上 build(`npm run build`)不读任何 `.env.*`,行为与 mock 无关。
|
||||||
|
|
||||||
|
## 每页看什么、玩什么
|
||||||
|
|
||||||
|
| 页面 | 看点 / 操作 |
|
||||||
|
| --- | --- |
|
||||||
|
| **/ledger 访问台账** | 233 条调用记录,时间倒序。翻页(24 页);`target` 与 `调用方 appKey` 精确筛选(试试 `notice` + `100001` → 共 5 条;`callerAppKey` 填 `anonymous` 可筛匿名调用,填了查不到的值=空列表);「查看详情」弹窗;状态码色阶:2xx 绿 / 4xx 琥珀 / 5xx 红;「后端码」恒 `-`(流式转发不解析业务 code) |
|
||||||
|
| **/routes 路由注册表** | 28 行路由。行内改 `visibility`(INTERNAL/EXTERNAL)、`authMode`(SIGNED/ANON)、缓存秒数/范围、qps、启停、备注 → 「保存」→ toast「已保存,注册表加载 N 条路由」;离开页面再进来,值保持(内存态);把缓存秒数改成负数保存 → 后端同款报错 toast(`cacheTtlSeconds 非法:-5(须为 ≥0 的整数)`) |
|
||||||
|
| **/targets 目标地址** | 7 个生产 ctx。行内改 `baseUrl` → 保存 → toast「已保存,目标表加载 7 条」;`baseUrl` 清空保存 → 报错 toast;启停开关 |
|
||||||
|
| **顶栏** | 「重载注册表」→ toast「已重载:路由 28 条 / 目标 7 条」+「最后更新 HH:mm:ss」;「手动刷新」重建当前页;MOCK 徽标(仅 mock 模式出现) |
|
||||||
|
| **右上「配色」胶囊** | 五档主题色(原版靛蓝/云海蓝/石墨墨/靛紫/松石绿)+ 侧栏深浅/侧栏跟随/顶部渐变三开关;选择持久化在 localStorage(`yh-theme`),刷新/重开浏览器保持 |
|
||||||
|
|
||||||
|
**Mock 数据的存活性**:PUT 改动只存在于 vite 进程内存——站内切换页面改动**保持**;**浏览器整页刷新/直达(F5)即重置**回静态种子;重启 `dev:mock` 也重置。台账 `ts` 以重置时刻为终点向过去铺 6.5 天,所以永远是"近期"。
|
||||||
|
|
||||||
|
## Mock 数据清单
|
||||||
|
|
||||||
|
数据源:`src/assets/mock/gatewayApi.ts`(种子 + 确定性 LCG,改种子改这里)。
|
||||||
|
|
||||||
|
- **targets(7)**:userbase、userclass、yschcms、svc-hall、flow-center、usermsg、notice;`baseUrl = http://{targetCode}-service:8080`(照 `service/docs/sql/03_target.sql` 语义)。
|
||||||
|
- **routes(28)**:
|
||||||
|
- 22 行 = `service/docs/sql/02_init_routes.sql` 全量种子(全保守档 INTERNAL×SIGNED、cacheTtl 0、qps 0,备注原样照抄);
|
||||||
|
- 6 行治理样本:id23 `yschcms /oapi/site/render/**`(EXTERNAL×ANON,GLOBAL 缓存 600s,qps 50)、id24 `svc-hall /oapi/svc/items/**`(EXTERNAL×ANON,CALLER 缓存 60s)、id25 `notice /oapi/notice/public/**`(EXTERNAL×ANON,GLOBAL 300s,qps 100)、id26 `userbase /oapi/user/batchAdd`(EXTERNAL×SIGNED,qps 5 熔断)、id27 `usermsg /oapi/msg/health`(INTERNAL×ANON,**空备注**样本)、id28 `flow-center /oapi/flow/diagram/**`(**停用**行样本)。
|
||||||
|
- 四种 visibility×authMode 组合、空备注、停用行、差异化 ttl/qps/remark 全覆盖。
|
||||||
|
- **logs(233)**:appKey 取实测值 100001/100007/100009/100010/100013(约六成行),`"anonymous"` 约 46 行,`callerAppKey=null`(未携带 appKey 被网关拒)约 51 行;status 覆盖 200/401/403/404/500(约 125/69/30/3/6);costMs 3~1602ms 快慢兼有;path 为该 target 路由形态(`/**` 或具体 id 段);backendCode 恒 null。
|
||||||
|
|
||||||
|
## 与真实接口的契约对照
|
||||||
|
|
||||||
|
统一信封 `{code:0, msg:"", data:X}`;业务非法一律 **HTTP 200 + `{code:2, msg:"…"}`**(前端 alova 层弹 msg);分页 data 形状 `{total, content, pageIndex, pageSize, totalPages, hasNext}`。
|
||||||
|
|
||||||
|
| 接口 | 契约 | mock 实现 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `GET /api/internal/admin/routes?pageIndex&pageSize` | 排序 targetCode,pathPattern;无 keyword 参数 | 一致(localeCompare 排序) |
|
||||||
|
| `PUT /api/internal/admin/routes/{id}` | body 只认 7 键:visibility/authMode/cacheTtlSeconds/cacheScope/rateLimitQps/enabled/remark;visibility∈{INTERNAL,EXTERNAL}、authMode∈{SIGNED,ANON}、cacheScope∈{CALLER,GLOBAL};两 int 须 Number 且 ≥0;**enabled 须真布尔——传字符串 `"true"` 一律按 false 落库(后端真实坑,照抄)**;成功 `data={loaded:路由总条数}`;非法→`{code:2,msg:"visibility 非法:XXX(合法 [EXTERNAL, INTERNAL])"}` | 全部一致,错误消息格式一字不差 |
|
||||||
|
| `GET /api/internal/admin/targets` | 同信封分页 | 一致 |
|
||||||
|
| `PUT /api/internal/admin/targets/{id}` | body{baseUrl 非空, enabled 真布尔};成功 `data={targets:N}` | 一致 |
|
||||||
|
| `GET /api/internal/admin/logs?pageIndex&pageSize&targetCode&callerAppKey` | pageSize 默认 50 上限 500;筛选**精确等值**、空串=不过滤;排序 id desc;`ts` ISO 字符串;backendCode 恒 null | 一致(先过滤再分页) |
|
||||||
|
| `POST /api/internal/admin/routes/reload` | 无入参 → `data={loaded,targets}` | 一致(返回内存实时条数:28/7;`apps` 键 mock 无数据源,前端按 `-` 渲染) |
|
||||||
|
| `GET /api/internal/admin/session` | `data={ssoHost,principal}`,值来自已登录 CAS 会话 | `ssoHost` 返空串 → 顶栏「退出」不渲染(同 Notice 的 ssoHost 空态) |
|
||||||
|
|
||||||
|
前端请求带 `/open-gateway` 前缀(serviceHost),mock 中间件按剥前缀后的真实路径匹配。
|
||||||
|
|
||||||
|
## 与真后端的差异(mock 不造假接口)
|
||||||
|
|
||||||
|
- **无登录/CAS**(后端本就没有,治理面靠 Caddy 白名单);顶栏用户区是静态占位「网关治理」。
|
||||||
|
- **无 keyword 模糊搜索**(routes 接口没有该参数,别造)。
|
||||||
|
- **无路由/目标的新增与删除**(后端只支持行内编辑)。
|
||||||
|
- **无统计、无时间过滤**(M1 范围,后端尚无接口)。
|
||||||
|
- **无 caller 管理**页(appKey 只出现在台账里)。
|
||||||
|
- 刷新/重启即重置;真实后端当然是持久化的。
|
||||||
|
|
||||||
|
## 主题说明(YschTheme 中央静态通道)
|
||||||
|
|
||||||
|
- **接入方式**:`index.html` 头部一段同步 loader(照 Notice 配方,A 类免插件)引 `/static/js/yh-theme-init.js`;host 优先级 `__APP_CONFIG__.staticHost > baseHost > %VITE_THEME_HOST%(dev 可注入)> 同源 /static/`。网关无 env-config,恒走后两档;build 不替换占位符 → 线上走同源通道,零环境值烧入。
|
||||||
|
- **本地联调**:vite `/static` 代理 → `YH_THEME_PROXY_TARGET`(dev:mock 指本地 9527;`npm run dev` 时指 base 域——**无 VPN 时 `npm run dev` 主题 CSS 取不到,页面剩骨架无皮肤**,这正是 dev:mock 存在的原因)。
|
||||||
|
- **档位**:五档配色 + 侧栏深浅 + 侧栏跟随 + 顶部渐变,状态存 localStorage(`yh-theme`),由 `/static/js/yh-theme-init.js` 在首帧前挂 `<html>` 档位类(防 FOUC)。
|
||||||
|
- **sync 两件**:`src/utils/avatar.ts`、`src/components/UserAvatar.vue` 由 YschTheme 下发(consumers.json 已登记 open-gateway 条目)。2026-10-11 对接规范轮裁撤 `theme.ts` + `ThemeSwitcher.vue`(§3 红线:子应用不做配色切换器),mock 模式配色档位走通道默认档 + `/open-gateway/api/appearance` mock 端点收敛。
|
||||||
|
|
||||||
|
## 实现备注
|
||||||
|
|
||||||
|
- mock 走 **vite.config.ts 手写 `configureServer` 中间件**(仅 `--mode mock` 注册,先于内部代理),而非常规 `vite-plugin-mock`:后者用 esbuild 独立打包 mock 文件,与 vite 配置是两个模块实例,无法实现「整页刷新重置内存态」;且零新依赖(红线:不引运行时新依赖)。
|
||||||
|
- `.env.mock` 仅两行:`VITE_SERVICE_HOST=/open-gateway` + `VITE_IS_MOCK=true`(后者驱动顶栏 MOCK 徽标,dev/线上恒不出现)。
|
||||||
|
- 非 mock 模式(`npm run dev` / `npm run build`)行为与改造前一致(唯一例外:布局样式改由主题通道下发,本地 `layout.css` 快照已删除)。
|
||||||
+16
@@ -4,6 +4,22 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<!-- 主题通道引导(A 类免插件接入,照 Notice 配方):唯一接入点,不装依赖、无需 env-config。
|
||||||
|
origin 零写死,优先级:__APP_CONFIG__.staticHost > baseHost > %VITE_THEME_HOST%(仅 dev 可注入)
|
||||||
|
> 同源 "/static/…"(生产常态:通道与应用同域;本地 dev/mock 由 vite /static 代理兜到通道,
|
||||||
|
dev:mock 时 scripts/start-mock.mjs 把代理目标指到本地 9527,无 VPN 离线可跑)。
|
||||||
|
网关后端无 env-config 端点,__APP_CONFIG__ 恒为空 → 实际恒走后两档。
|
||||||
|
document.write 保证 parser-blocking:档位挂载与 FOUC 门先于首帧;build 不解析内联脚本字符串,
|
||||||
|
占位符原样留字面量 → 判首字符 % 置空,产物零环境值烧入。 -->
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
var c = window.__APP_CONFIG__ || {};
|
||||||
|
var themeHost = "%VITE_THEME_HOST%";
|
||||||
|
if (themeHost.charAt(0) === "%") themeHost = "";
|
||||||
|
var host = String(c.staticHost || c.baseHost || themeHost).replace(/\/+$/, "");
|
||||||
|
document.write('<script src="' + host + '/static/js/yh-theme-init.js"><\/script>');
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<title><%- title %></title>
|
<title><%- title %></title>
|
||||||
<%- injectScript %>
|
<%- injectScript %>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Generated
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"alova": "^3.4.0",
|
"alova": "^3.4.0",
|
||||||
"sass": "^1.64.1",
|
"sass": "^1.64.1",
|
||||||
"view-ui-plus": "^1.3.16",
|
"view-ui-plus": "^1.3.24",
|
||||||
"vue": "^3.4.19",
|
"vue": "^3.4.19",
|
||||||
"vue-router": "4"
|
"vue-router": "4"
|
||||||
},
|
},
|
||||||
|
|||||||
+2
-1
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --mode dev",
|
"dev": "vite --mode dev",
|
||||||
|
"dev:mock": "node scripts/start-mock.mjs",
|
||||||
"typecheck": "vue-tsc --noEmit",
|
"typecheck": "vue-tsc --noEmit",
|
||||||
"build": "vite build --mode base",
|
"build": "vite build --mode base",
|
||||||
"build:base": "vite build --mode base",
|
"build:base": "vite build --mode base",
|
||||||
@@ -13,7 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"alova": "^3.4.0",
|
"alova": "^3.4.0",
|
||||||
"sass": "^1.64.1",
|
"sass": "^1.64.1",
|
||||||
"view-ui-plus": "^1.3.16",
|
"view-ui-plus": "^1.3.24",
|
||||||
"vue": "^3.4.19",
|
"vue": "^3.4.19",
|
||||||
"vue-router": "4"
|
"vue-router": "4"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* start-mock.mjs —— 无 VPN 一键离线演示(npm run dev:mock)。
|
||||||
|
*
|
||||||
|
* 做三件事(零第三方依赖,Node 20 自带 fetch):
|
||||||
|
* 1. 拉起 YschTheme 的本地静态通道 scripts/dev-serve.mjs(9527,/dist/pc 挂 /static/);
|
||||||
|
* 2. 探活 9527/static/manifest.json(若端口已被上次残留进程占用且可服务,同样放行);
|
||||||
|
* 3. 以 YH_THEME_PROXY_TARGET=http://localhost:9527 spawn vite --mode mock(8131)。
|
||||||
|
*
|
||||||
|
* 效果:浏览器访问 http://localhost:8131 —— 三页治理台账全量可演示(mock 数据),
|
||||||
|
* 主题 CSS/切换器经本地 9527 供给,全程不触网、不需要 VPN。
|
||||||
|
* 主题静态资源不拷进 front/public(应用侧不持主题副本是主题仓硬原则)。
|
||||||
|
* VPN 恢复后照旧 npm run dev 走真后端,两者互不影响。Ctrl+C 退出会一并收掉两个子进程。
|
||||||
|
*/
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const frontDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const themeDir = path.resolve(frontDir, "..", "..", "YschTheme");
|
||||||
|
const themeServeScript = path.join(themeDir, "scripts", "dev-serve.mjs");
|
||||||
|
const viteBin = path.join(frontDir, "node_modules", "vite", "bin", "vite.js");
|
||||||
|
const THEME_PORT = 9527;
|
||||||
|
|
||||||
|
const fail = (msg) => {
|
||||||
|
console.error(`[start-mock] ${msg}`);
|
||||||
|
process.exit(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!existsSync(themeServeScript)) {
|
||||||
|
fail(`找不到主题静态服务脚本 ${themeServeScript}(YschTheme 仓位置异常?)`);
|
||||||
|
}
|
||||||
|
if (!existsSync(viteBin)) {
|
||||||
|
fail(`找不到 ${viteBin}(先在 front 目录 npm install)`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 1. 本地静态通道(YschTheme 零依赖实现,serve dist/pc 于 /static/) */
|
||||||
|
const themeServer = spawn(process.execPath, [themeServeScript], {
|
||||||
|
cwd: themeDir,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
themeServer.stdout.on("data", (d) => process.stdout.write(`[theme] ${d}`));
|
||||||
|
themeServer.stderr.on("data", (d) => process.stderr.write(`[theme] ${d}`));
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
async function waitThemeReady(tries = 24) {
|
||||||
|
for (let i = 0; i < tries; i++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`http://localhost:${THEME_PORT}/static/manifest.json`);
|
||||||
|
if (res.ok) return true;
|
||||||
|
} catch {
|
||||||
|
/* 尚未就绪/端口被占(占位进程若可服务也会在上面返回 ok) */
|
||||||
|
}
|
||||||
|
await sleep(250);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2. 探活:主题通道就绪才起前端(演示强依赖主题 CSS) */
|
||||||
|
if (!(await waitThemeReady())) {
|
||||||
|
themeServer.kill();
|
||||||
|
fail(`9527 主题静态通道未就绪 —— 检查 ${themeServeScript} 与 YschTheme/dist/pc 是否存在(必要时先在 YschTheme 跑 npm run gen)`);
|
||||||
|
}
|
||||||
|
console.log(`[start-mock] 主题静态通道就绪 → http://localhost:${THEME_PORT}/static/`);
|
||||||
|
|
||||||
|
/* 3. vite --mode mock(dev server 8131;/static 代理由 YH_THEME_PROXY_TARGET 指向 9527) */
|
||||||
|
const vite = spawn(process.execPath, [viteBin, "--mode", "mock"], {
|
||||||
|
cwd: frontDir,
|
||||||
|
stdio: "inherit",
|
||||||
|
env: { ...process.env, YH_THEME_PROXY_TARGET: `http://localhost:${THEME_PORT}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
const shutdown = () => {
|
||||||
|
vite.kill();
|
||||||
|
themeServer.kill();
|
||||||
|
};
|
||||||
|
vite.on("exit", (code) => {
|
||||||
|
themeServer.kill();
|
||||||
|
process.exit(code ?? 0);
|
||||||
|
});
|
||||||
|
themeServer.on("exit", () => {
|
||||||
|
/* 9527 若由残留进程提供则本子进程会因端口占用退出,探活已放行,不必终止演示 */
|
||||||
|
});
|
||||||
|
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => {
|
||||||
|
shutdown();
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
+78
-4
@@ -1,16 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, reactive } from "vue";
|
import { computed, ref, onMounted, reactive } from "vue";
|
||||||
import layout from "./components/layout.vue";
|
import layout from "./components/layout.vue";
|
||||||
|
|
||||||
import { LayoutData } from "./types/Basics";
|
import { LayoutData } from "./types/Basics";
|
||||||
|
import { checkSuperAdmin, SuperGate } from "@/utils/superGate";
|
||||||
|
|
||||||
const isRouterAlive = ref(true), isLogin = ref(false);
|
const isRouterAlive = ref(true), isLogin = ref(false);
|
||||||
|
|
||||||
/** 网关无菜单接口:三个治理页写死为静态菜单(与 notice/userbase 的 LeftMenu 同形) */
|
/* 超管门槛(对接规范 §10 open-gateway 轮):挂载前探测 /userbase/base/User/CurrentUser,
|
||||||
|
isSuperAdmin 明确为假 → 整页「暂无权限」;接口不可达(error)不锁死治理面——
|
||||||
|
访问控制真身在后端 CAS 门,本门槛是体验层。pending 期间不渲染,避免默认页闪一帧。 */
|
||||||
|
const gate = ref<SuperGate>("pending");
|
||||||
|
const userName = ref("");
|
||||||
|
const gateReady = computed(() => gate.value !== "pending");
|
||||||
|
const denied = computed(() => gate.value === "denied");
|
||||||
|
|
||||||
|
/** 网关无菜单接口:治理页写死为静态菜单(与 notice/userbase 的 LeftMenu 同形) */
|
||||||
const modList = [
|
const modList = [
|
||||||
{ name: "LedgerList", title: "访问台账", page: "/ledger" },
|
{ name: "LedgerList", title: "访问台账", page: "/ledger" },
|
||||||
{ name: "RouteList", title: "路由注册表", page: "/routes" },
|
{ name: "RouteList", title: "路由注册表", page: "/routes" },
|
||||||
{ name: "TargetList", title: "目标地址", page: "/targets" },
|
{ name: "TargetList", title: "目标地址", page: "/targets" },
|
||||||
|
{ name: "AppList", title: "应用管理", page: "/apps" },
|
||||||
|
{ name: "ProxyTest", title: "代理测试", page: "/proxy-test" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const layoutData: LayoutData = reactive({
|
const layoutData: LayoutData = reactive({
|
||||||
@@ -31,19 +42,28 @@ const reload = () => {
|
|||||||
};
|
};
|
||||||
provide("reload", reload);
|
provide("reload", reload);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
layoutData.modList = modList;
|
layoutData.modList = modList;
|
||||||
// "/" 落点由 routes.ts 的 { path: "/", redirect: "/ledger" } 处理。
|
// "/" 落点由 routes.ts 的 { path: "/", redirect: "/ledger" } 处理。
|
||||||
// 这里不能再用 currentRoute 判断后 replace:冷启动时初始导航尚未解析完,
|
// 这里不能再用 currentRoute 判断后 replace:冷启动时初始导航尚未解析完,
|
||||||
// currentRoute 恒为 "/",会把 #/routes、#/targets 深链接(含刷新)全部弹回台账。
|
// currentRoute 恒为 "/",会把 #/routes、#/targets 深链接(含刷新)全部弹回台账。
|
||||||
isLogin.value = true;
|
isLogin.value = true;
|
||||||
|
|
||||||
|
const s = await checkSuperAdmin();
|
||||||
|
gate.value = s.gate;
|
||||||
|
userName.value = s.name;
|
||||||
|
if (s.gate === "error") {
|
||||||
|
// 探测不通不拦(本地无平台 Cookie / userbase 故障),仅留痕便于排障
|
||||||
|
console.warn("[superGate] CurrentUser 探测失败,按放行处理", );
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const getRouterKey = (route: any) => route?.name;
|
const getRouterKey = (route: any) => route?.name;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<layout v-if="isLogin" :layoutData="layoutData"></layout>
|
<template v-if="gateReady && !denied">
|
||||||
|
<layout v-if="isLogin" :layoutData="layoutData" :user-name="userName"></layout>
|
||||||
<div v-if="isLogin" class="base-wrap-main">
|
<div v-if="isLogin" class="base-wrap-main">
|
||||||
<router-view v-if="!!isRouterAlive" v-slot="{ Component, route }">
|
<router-view v-if="!!isRouterAlive" v-slot="{ Component, route }">
|
||||||
<transition name="fade" mode="out-in">
|
<transition name="fade" mode="out-in">
|
||||||
@@ -51,6 +71,16 @@ const getRouterKey = (route: any) => route?.name;
|
|||||||
</transition>
|
</transition>
|
||||||
</router-view>
|
</router-view>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 非超级管理员:整页「暂无权限」(主内容区语义,页面级空态不带菜单/顶栏) -->
|
||||||
|
<div v-if="denied" class="gate-denied">
|
||||||
|
<div class="gate-denied-card">
|
||||||
|
<Icon type="ios-locked-outline" class="gate-denied-icon" />
|
||||||
|
<h3 class="gate-denied-title">暂无权限</h3>
|
||||||
|
<p class="gate-denied-desc">开放网关控制台仅对超级管理员开放{{ userName ? `,当前账号:${userName}` : "" }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@@ -63,11 +93,55 @@ const getRouterKey = (route: any) => route?.name;
|
|||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 照 Notice 配方锁定视口:顶栏/侧栏/面包屑均 fixed,若 body 可滚,整页滚动会让内容
|
||||||
|
从 fixed 导航下方穿过(错位遮挡);改为 main-wrapper 内部滚动 */
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
#app {
|
#app {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.base-wrap-main .main-wrapper {
|
.base-wrap-main .main-wrapper {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通道撑满模型(§4):列表页根节点 page-wrapper yh-fill,flex 纵向铺满、表格区实测高 */
|
||||||
|
.base-wrap-main .page-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暂无权限页:整幅居中,配色全走主题令牌(禁写死品牌色,§1) */
|
||||||
|
.gate-denied {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--page-bg, #f5f7fa);
|
||||||
|
}
|
||||||
|
.gate-denied-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 64px;
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
.gate-denied-icon {
|
||||||
|
font-size: 44px;
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
.gate-denied-title {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-main, #17233d);
|
||||||
|
}
|
||||||
|
.gate-denied-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-sub, #808695);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ export default {
|
|||||||
'GatewayAdmin.updateRoute': ['PUT', '/api/internal/admin/routes/{id}'],
|
'GatewayAdmin.updateRoute': ['PUT', '/api/internal/admin/routes/{id}'],
|
||||||
'GatewayAdmin.targets': ['GET', '/api/internal/admin/targets'],
|
'GatewayAdmin.targets': ['GET', '/api/internal/admin/targets'],
|
||||||
'GatewayAdmin.updateTarget': ['PUT', '/api/internal/admin/targets/{id}'],
|
'GatewayAdmin.updateTarget': ['PUT', '/api/internal/admin/targets/{id}'],
|
||||||
|
'GatewayAdmin.apps': ['GET', '/api/internal/admin/apps'],
|
||||||
|
'GatewayAdmin.createApp': ['POST', '/api/internal/admin/apps'],
|
||||||
|
'GatewayAdmin.updateApp': ['PUT', '/api/internal/admin/apps/{id}'],
|
||||||
'GatewayAdmin.logs': ['GET', '/api/internal/admin/logs'],
|
'GatewayAdmin.logs': ['GET', '/api/internal/admin/logs'],
|
||||||
'GatewayAdmin.refresh': ['POST', '/api/internal/routes/refresh'],
|
// 重载注册表:走浏览器面孪生入口。机器面 /api/internal/routes/refresh 只认内部令牌,
|
||||||
|
// 浏览器带 CAS 会话去打它会被 401 拦死(两口共用 RegistryReloader,返回体同形)
|
||||||
|
'GatewayAdmin.reload': ['POST', '/api/internal/admin/routes/reload'],
|
||||||
|
'GatewayAdmin.session': ['GET', '/api/internal/admin/session'],
|
||||||
|
'GatewayAdmin.proxyTest': ['POST', '/api/internal/admin/proxy-test'],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<path fill="#4aaff0" d="M512,585 C415,455 330,398 215,395 C185,395 160,415 160,450 L160,770 C160,815 190,838 240,845 C340,856 430,872 512,888 C594,872 684,856 784,845 C834,838 864,815 864,770 L864,450 C864,415 839,395 809,395 C694,398 609,455 512,585 Z"/>
|
||||||
|
<path fill="#4aaff0" d="M218,312 C335,292 462,352 514,462 C452,404 334,354 218,378 Z"/>
|
||||||
|
<circle fill="#4aaff0" cx="218" cy="345" r="33"/>
|
||||||
|
<path fill="#80d976" d="M508,462 C455,310 525,145 700,105 C758,250 668,425 508,462 Z"/>
|
||||||
|
<path fill="none" stroke="#4aaff0" stroke-width="44" stroke-linecap="round" d="M210,935 Q285.5,881 361,935 T512,935 T663,935 T814,935"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 701 B |
@@ -0,0 +1,581 @@
|
|||||||
|
/**
|
||||||
|
* 网关治理管理面本地 Mock(vite --mode mock 专用,由 vite.config.ts 的 configureServer
|
||||||
|
* 中间件在 dev server 内拦截 /open-gateway/api/internal/**,零依赖、零新插件)。
|
||||||
|
*
|
||||||
|
* 背景:本机无内网 VPN 时 base 域不可达,三页治理台账全空。本文件按后端
|
||||||
|
* InternalAdminController / RouteRefreshController 的真实响应契约提供内存数据,
|
||||||
|
* 仅供前端离线演示/走查,字段结构与信封与线上后端保持一致:
|
||||||
|
* - 统一信封 {code:0, msg:"", data:X};业务非法一律 HTTP 200 + {code:2, msg:"…"};
|
||||||
|
* - 分页 data 形状 {total, content, pageIndex, pageSize, totalPages, hasNext};
|
||||||
|
* - PUT routes 只认 7 个治理键;enabled 必须真布尔(字符串 "true" 一律按 false 落库,
|
||||||
|
* 照抄后端真实坑);两个 int 字段须 Number 且 ≥0;
|
||||||
|
* - 后端没有的接口不 mock(登录/CAS、caller CRUD、统计、路由/目标增删均不存在)。
|
||||||
|
*
|
||||||
|
* 数据形态:静态种子 + 内存态——PUT 后再 GET 可见新值;整页刷新(HTML 文档请求)
|
||||||
|
* 由中间件调 resetGatewayMockState() 重置回种子;SPA 站内切换不重置。
|
||||||
|
*/
|
||||||
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
|
||||||
|
/* ============================ 类型(对齐后端 OpenApi DTO) ============================ */
|
||||||
|
|
||||||
|
type Visibility = "INTERNAL" | "EXTERNAL";
|
||||||
|
type AuthMode = "SIGNED" | "ANON";
|
||||||
|
type CacheScope = "CALLER" | "GLOBAL";
|
||||||
|
|
||||||
|
export interface OpenApiRoute {
|
||||||
|
id: number;
|
||||||
|
targetCode: string;
|
||||||
|
pathPattern: string;
|
||||||
|
httpMethod: string;
|
||||||
|
visibility: Visibility;
|
||||||
|
authMode: AuthMode;
|
||||||
|
cacheTtlSeconds: number;
|
||||||
|
cacheScope: CacheScope;
|
||||||
|
rateLimitQps: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
requestBodyLimitMb: number;
|
||||||
|
enabled: boolean;
|
||||||
|
remark: string;
|
||||||
|
gmtCreate: string;
|
||||||
|
gmtModified: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenApiTarget {
|
||||||
|
id: number;
|
||||||
|
targetCode: string;
|
||||||
|
baseUrl: string;
|
||||||
|
enabled: boolean;
|
||||||
|
gmtCreate: string;
|
||||||
|
gmtModified: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OpenApiLog {
|
||||||
|
id: number;
|
||||||
|
ts: string;
|
||||||
|
callerAppKey: string | null;
|
||||||
|
targetCode: string;
|
||||||
|
method: string;
|
||||||
|
path: string;
|
||||||
|
status: number;
|
||||||
|
backendCode: null;
|
||||||
|
costMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageResult<T> {
|
||||||
|
total: number;
|
||||||
|
content: T[];
|
||||||
|
pageIndex: number;
|
||||||
|
pageSize: number;
|
||||||
|
totalPages: number;
|
||||||
|
hasNext: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================ 静态种子 ============================ */
|
||||||
|
|
||||||
|
const GMT_BASE = "2026-09-12T02:00:00.000Z";
|
||||||
|
const GMT_TUNE = "2026-09-26T06:30:00.000Z";
|
||||||
|
|
||||||
|
/** 7 个生产 ctx(照 service/docs/sql/03_target.sql 语义:容器网内直连 base-url) */
|
||||||
|
const SEED_TARGETS: OpenApiTarget[] = [
|
||||||
|
{ id: 1, targetCode: "userbase", baseUrl: "http://userbase-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 2, targetCode: "userclass", baseUrl: "http://userclass-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 3, targetCode: "yschcms", baseUrl: "http://yschcms-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 4, targetCode: "svc-hall", baseUrl: "http://svc-hall-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 5, targetCode: "flow-center", baseUrl: "http://flow-center-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 6, targetCode: "usermsg", baseUrl: "http://usermsg-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 7, targetCode: "notice", baseUrl: "http://notice-service:8080", enabled: true, gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由种子:service/docs/sql/02_init_routes.sql 的 22 行全保守种子(INTERNAL×SIGNED)打底,
|
||||||
|
* 另补 6 行治理后的样本:EXTERNAL×ANON 豁免 ×3、EXTERNAL×SIGNED ×1、INTERNAL×ANON ×1、
|
||||||
|
* 停用行 ×1、空备注 ×1,覆盖全部枚举组合与差异化 cacheTtl/qps/remark。
|
||||||
|
*/
|
||||||
|
const SEED_ROUTES: OpenApiRoute[] = [
|
||||||
|
// ---- 02_init_routes.sql 种子(22 行,remark 原样照抄)----
|
||||||
|
{ id: 1, targetCode: "userbase", pathPattern: "/oapi/app/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "应用信息:byId/list", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 2, targetCode: "userbase", pathPattern: "/oapi/group/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "组织:tree/byId/list/users/add/edit/delete/batchAdd/move", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 3, targetCode: "userbase", pathPattern: "/oapi/identity/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "身份:current/available/switch", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 4, targetCode: "userbase", pathPattern: "/oapi/user/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "用户:byId/byLoginName/byPhone/modules/add/edit/delete/batchAdd 等 21 端点", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 5, targetCode: "userclass", pathPattern: "/oapi/DbClass/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:班级", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 6, targetCode: "userclass", pathPattern: "/oapi/Common/FamCom/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:家属", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 7, targetCode: "userclass", pathPattern: "/oapi/SchGroup/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:学校组织", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 8, targetCode: "userclass", pathPattern: "/oapi/SchInfo/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:学校信息", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 9, targetCode: "userclass", pathPattern: "/oapi/SubInfo/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:学科", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 10, targetCode: "userclass", pathPattern: "/oapi/Schoolen/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:学段", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 11, targetCode: "userclass", pathPattern: "/oapi/OAStu/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:学生信息(21 端点)", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 12, targetCode: "userclass", pathPattern: "/oapi/TeachInfo/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:任教信息", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 13, targetCode: "userclass", pathPattern: "/oapi/OATea/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:教师信息", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 14, targetCode: "userclass", pathPattern: "/oapi/UserInfo/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:用户信息", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 15, targetCode: "userclass", pathPattern: "/oapi/VSchClass/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "OA 兼容:虚拟班级(17 端点)", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 16, targetCode: "userclass", pathPattern: "/oapi/group/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "同步:组织(4 端点)", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 17, targetCode: "userclass", pathPattern: "/oapi/user/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "同步:用户(5 端点)", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 18, targetCode: "yschcms", pathPattern: "/oapi/site/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "门户(注册名 yschcms)", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 19, targetCode: "svc-hall", pathPattern: "/oapi/svc/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "事项:items/filters/detail/view/attachments/related/snapshot", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 20, targetCode: "flow-center", pathPattern: "/oapi/flow/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "流程:list/getFlow/apply/instance/progress/diagram/withdraw/callback-reg", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 21, targetCode: "usermsg", pathPattern: "/oapi/msg/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "消息:sendUserMsg/list/unreadCount/read/readAll + todo(inc/reset/list) + ws/ticket", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
{ id: 22, targetCode: "notice", pathPattern: "/oapi/notice/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "字典:getDict", gmtCreate: GMT_BASE, gmtModified: GMT_BASE },
|
||||||
|
// ---- 治理后补充样本(豁免/差异化旋钮/停用/空备注)----
|
||||||
|
{ id: 23, targetCode: "yschcms", pathPattern: "/oapi/site/render/**", httpMethod: "*", visibility: "EXTERNAL", authMode: "ANON", cacheTtlSeconds: 600, cacheScope: "GLOBAL", rateLimitQps: 50, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "门户站点渲染对外只读:豁免签名 + 全局缓存 10 分钟", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE },
|
||||||
|
{ id: 24, targetCode: "svc-hall", pathPattern: "/oapi/svc/items/**", httpMethod: "*", visibility: "EXTERNAL", authMode: "ANON", cacheTtlSeconds: 60, cacheScope: "CALLER", rateLimitQps: 20, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "办事大厅事项对外查询:豁免签名,按调用方隔离缓存", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE },
|
||||||
|
{ id: 25, targetCode: "notice", pathPattern: "/oapi/notice/public/**", httpMethod: "*", visibility: "EXTERNAL", authMode: "ANON", cacheTtlSeconds: 300, cacheScope: "GLOBAL", rateLimitQps: 100, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "通知公告对外只读镜像(旧门户拉取)", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE },
|
||||||
|
{ id: 26, targetCode: "userbase", pathPattern: "/oapi/user/batchAdd", httpMethod: "*", visibility: "EXTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 5, timeoutMs: 30000, requestBodyLimitMb: 10, enabled: true, remark: "批量建号对外(密钥强校验,低 qps 熔断)", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE },
|
||||||
|
{ id: 27, targetCode: "usermsg", pathPattern: "/oapi/msg/health", httpMethod: "*", visibility: "INTERNAL", authMode: "ANON", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 10000, requestBodyLimitMb: 1, enabled: true, remark: "", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE },
|
||||||
|
{ id: 28, targetCode: "flow-center", pathPattern: "/oapi/flow/diagram/**", httpMethod: "*", visibility: "INTERNAL", authMode: "SIGNED", cacheTtlSeconds: 0, cacheScope: "CALLER", rateLimitQps: 0, timeoutMs: 60000, requestBodyLimitMb: 10, enabled: false, remark: "流程图渲染内测:暂关", gmtCreate: GMT_BASE, gmtModified: GMT_TUNE },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 实测调用方 appKey(用于台账 callerAppKey 样本与筛选演示) */
|
||||||
|
const APP_KEYS = ["100001", "100007", "100009", "100010", "100013"] as const;
|
||||||
|
|
||||||
|
/** 台账时间跨度:以种子重置时刻为终点,向过去铺 6.5 天 */
|
||||||
|
const LOG_SPAN_DAYS = 6.5;
|
||||||
|
const LOG_COUNT = 233;
|
||||||
|
|
||||||
|
/** 确定性伪随机(LCG):同一次进程内种子稳定,刷新/重启后日志可复现同分布 */
|
||||||
|
function lcg(seed: number): () => number {
|
||||||
|
let s = seed >>> 0;
|
||||||
|
return () => {
|
||||||
|
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||||
|
return s / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 台账种子:按路由表推导 path 形态;覆盖 200/401/403/404/500、anonymous、无钥(null) 401、快慢耗时 */
|
||||||
|
function buildSeedLogs(routes: OpenApiRoute[], now: number): OpenApiLog[] {
|
||||||
|
const rnd = lcg(20261001);
|
||||||
|
const byTarget = new Map<string, { path: string; anon: boolean }[]>();
|
||||||
|
for (const r of routes) {
|
||||||
|
const arr = byTarget.get(r.targetCode) ?? [];
|
||||||
|
arr.push({ path: r.pathPattern, anon: r.authMode === "ANON" });
|
||||||
|
byTarget.set(r.targetCode, arr);
|
||||||
|
}
|
||||||
|
const targetCodes = [...byTarget.keys()];
|
||||||
|
const weight: Record<string, number> = {
|
||||||
|
userbase: 0.22, userclass: 0.14, yschcms: 0.06, "svc-hall": 0.14,
|
||||||
|
"flow-center": 0.12, usermsg: 0.14, notice: 0.18,
|
||||||
|
};
|
||||||
|
const pick = <T,>(arr: readonly T[]): T => arr[Math.floor(rnd() * arr.length)];
|
||||||
|
const pickTarget = (): string => {
|
||||||
|
let r = rnd();
|
||||||
|
for (const t of targetCodes) {
|
||||||
|
r -= weight[t] ?? 0;
|
||||||
|
if (r < 0) return t;
|
||||||
|
}
|
||||||
|
return targetCodes[targetCodes.length - 1];
|
||||||
|
};
|
||||||
|
|
||||||
|
const logs: OpenApiLog[] = [];
|
||||||
|
for (let i = 0; i < LOG_COUNT; i++) {
|
||||||
|
const ts = now - i * ((LOG_SPAN_DAYS * 24 * 3600 * 1000) / LOG_COUNT) - rnd() * 3600 * 1000;
|
||||||
|
const targetCode = pickTarget();
|
||||||
|
const rt = pick(byTarget.get(targetCode) as { path: string; anon: boolean }[]);
|
||||||
|
let path = rt.path;
|
||||||
|
if (rnd() < 0.45 && path.endsWith("/**")) {
|
||||||
|
path = path.slice(0, -3) + "/" + String(Math.floor(rnd() * 900) + 100);
|
||||||
|
}
|
||||||
|
const method = rnd() < 0.7 ? "GET" : "POST";
|
||||||
|
|
||||||
|
let callerAppKey: string | null;
|
||||||
|
let status: number;
|
||||||
|
const cr = rnd();
|
||||||
|
if (cr < 0.6) {
|
||||||
|
// 持钥调用:多数放行,少量鉴权/限流/NotFound/后端炸
|
||||||
|
callerAppKey = pick(APP_KEYS);
|
||||||
|
const sr = rnd();
|
||||||
|
status = sr < 0.78 ? 200 : sr < 0.84 ? 401 : sr < 0.9 ? 403 : sr < 0.95 ? 404 : 500;
|
||||||
|
} else if (cr < 0.8) {
|
||||||
|
// 匿名调用(后端记字符串 "anonymous"):命中豁免(ANON)路由才放行
|
||||||
|
callerAppKey = "anonymous";
|
||||||
|
const sr = rnd();
|
||||||
|
status = rt.anon ? (sr < 0.85 ? 200 : 500) : sr < 0.7 ? 401 : 403;
|
||||||
|
} else {
|
||||||
|
// 未携带 appKey:网关层直接拒(callerAppKey=null 的 401 行)
|
||||||
|
callerAppKey = null;
|
||||||
|
status = rnd() < 0.85 ? 401 : 403;
|
||||||
|
}
|
||||||
|
const costMs = status < 400 ? Math.floor(3 + Math.pow(rnd(), 2) * 1600) : Math.floor(1 + rnd() * 25);
|
||||||
|
logs.push({
|
||||||
|
id: 50000 + (LOG_COUNT - i),
|
||||||
|
ts: new Date(ts).toISOString(),
|
||||||
|
callerAppKey,
|
||||||
|
targetCode,
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
status,
|
||||||
|
backendCode: null,
|
||||||
|
costMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return logs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================ 内存态(PUT 可变,刷新重置) ============================ */
|
||||||
|
|
||||||
|
let routes: OpenApiRoute[] = [];
|
||||||
|
let targets: OpenApiTarget[] = [];
|
||||||
|
let logs: OpenApiLog[] = [];
|
||||||
|
|
||||||
|
/** 重置为静态种子(vite 中间件在整页文档请求时调用 → 浏览器刷新即还原) */
|
||||||
|
export function resetGatewayMockState(): void {
|
||||||
|
routes = SEED_ROUTES.map((r) => ({ ...r }));
|
||||||
|
targets = SEED_TARGETS.map((t) => ({ ...t }));
|
||||||
|
logs = buildSeedLogs(SEED_ROUTES, Date.now());
|
||||||
|
}
|
||||||
|
resetGatewayMockState();
|
||||||
|
|
||||||
|
/* ============================ 工具 ============================ */
|
||||||
|
|
||||||
|
function ok<T>(data: T): { code: number; msg: string; data: T } {
|
||||||
|
return { code: 0, msg: "", data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bizError(msg: string): { code: number; msg: string; data: null } {
|
||||||
|
return { code: 2, msg, data: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
function paginate<T>(list: T[], pageIndex: number, pageSize: number): PageResult<T> {
|
||||||
|
const total = list.length;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
content: list.slice((pageIndex - 1) * pageSize, pageIndex * pageSize),
|
||||||
|
pageIndex,
|
||||||
|
pageSize,
|
||||||
|
totalPages,
|
||||||
|
hasNext: pageIndex < totalPages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询参数取正整数:缺省/非法回落 def,上限 cap(logs pageSize 契约:默认 50、上限 500) */
|
||||||
|
function positiveInt(raw: string | null, def: number, cap: number): number {
|
||||||
|
const n = Number(raw);
|
||||||
|
if (!Number.isInteger(n) || n < 1) return def;
|
||||||
|
return Math.min(n, cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enumErrMsg(field: string, val: unknown, legal: readonly string[]): string {
|
||||||
|
return `${field} 非法:${val === undefined ? "(缺失)" : String(val)}(合法 [${legal.join(", ")}])`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function intErrMsg(field: string, val: unknown): string {
|
||||||
|
return `${field} 非法:${val === undefined ? "(缺失)" : String(val)}(须为 ≥0 的整数)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBody(req: IncomingMessage): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
req.on("data", (c: Buffer) => chunks.push(c));
|
||||||
|
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||||
|
req.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(res: ServerResponse, payload: unknown): void {
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
|
||||||
|
res.end(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================ 各端点实现 ============================ */
|
||||||
|
|
||||||
|
function listRoutes(q: URLSearchParams): PageResult<OpenApiRoute> {
|
||||||
|
const sorted = [...routes].sort(
|
||||||
|
(a, b) => a.targetCode.localeCompare(b.targetCode) || a.pathPattern.localeCompare(b.pathPattern)
|
||||||
|
);
|
||||||
|
return paginate(sorted, positiveInt(q.get("pageIndex"), 1, 10000), positiveInt(q.get("pageSize"), 10, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
function listTargets(q: URLSearchParams): PageResult<OpenApiTarget> {
|
||||||
|
const sorted = [...targets].sort((a, b) => a.targetCode.localeCompare(b.targetCode));
|
||||||
|
return paginate(sorted, positiveInt(q.get("pageIndex"), 1, 10000), positiveInt(q.get("pageSize"), 10, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
function listLogs(q: URLSearchParams): PageResult<OpenApiLog> {
|
||||||
|
const targetCode = (q.get("targetCode") ?? "").trim();
|
||||||
|
const callerAppKey = (q.get("callerAppKey") ?? "").trim();
|
||||||
|
// 筛选为精确等值(空串=不过滤),先过滤再分页,按 id 倒序(最新在前)
|
||||||
|
let rows = logs;
|
||||||
|
if (targetCode) rows = rows.filter((l) => l.targetCode === targetCode);
|
||||||
|
if (callerAppKey) rows = rows.filter((l) => l.callerAppKey === callerAppKey);
|
||||||
|
rows = [...rows].sort((a, b) => b.id - a.id);
|
||||||
|
return paginate(rows, positiveInt(q.get("pageIndex"), 1, 100000), positiveInt(q.get("pageSize"), 50, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
const VISIBILITIES = ["EXTERNAL", "INTERNAL"] as const;
|
||||||
|
const AUTH_MODES = ["SIGNED", "ANON"] as const;
|
||||||
|
const CACHE_SCOPES = ["CALLER", "GLOBAL"] as const;
|
||||||
|
|
||||||
|
/** PUT /routes/{id}:只认 7 个治理键;校验语义对齐后端(含 enabled 非真布尔按 false 落库的坑) */
|
||||||
|
function updateRoute(id: number, body: any): { code: number; msg: string; data: any } {
|
||||||
|
const row = routes.find((r) => r.id === id);
|
||||||
|
if (!row) return bizError(`路由不存在:id=${id}`);
|
||||||
|
|
||||||
|
if (!VISIBILITIES.includes(body?.visibility)) return bizError(enumErrMsg("visibility", body?.visibility, VISIBILITIES));
|
||||||
|
if (!AUTH_MODES.includes(body?.authMode)) return bizError(enumErrMsg("authMode", body?.authMode, AUTH_MODES));
|
||||||
|
if (!CACHE_SCOPES.includes(body?.cacheScope)) return bizError(enumErrMsg("cacheScope", body?.cacheScope, CACHE_SCOPES));
|
||||||
|
const ttl = body?.cacheTtlSeconds;
|
||||||
|
if (typeof ttl !== "number" || !Number.isInteger(ttl) || ttl < 0) return bizError(intErrMsg("cacheTtlSeconds", ttl));
|
||||||
|
const qps = body?.rateLimitQps;
|
||||||
|
if (typeof qps !== "number" || !Number.isInteger(qps) || qps < 0) return bizError(intErrMsg("rateLimitQps", qps));
|
||||||
|
|
||||||
|
row.visibility = body.visibility;
|
||||||
|
row.authMode = body.authMode;
|
||||||
|
row.cacheScope = body.cacheScope;
|
||||||
|
row.cacheTtlSeconds = ttl;
|
||||||
|
row.rateLimitQps = qps;
|
||||||
|
// enabled 必须真布尔:传字符串 "true" 一律按 false 处理(后端真实行为,勿修)
|
||||||
|
row.enabled = body.enabled === true;
|
||||||
|
row.remark = body.remark == null ? "" : String(body.remark);
|
||||||
|
row.gmtModified = new Date().toISOString();
|
||||||
|
return ok({ loaded: routes.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PUT /targets/{id}:baseUrl 非空 + enabled 真布尔(同款坑) */
|
||||||
|
function updateTarget(id: number, body: any): { code: number; msg: string; data: any } {
|
||||||
|
const row = targets.find((t) => t.id === id);
|
||||||
|
if (!row) return bizError(`目标不存在:id=${id}`);
|
||||||
|
|
||||||
|
const baseUrl = body?.baseUrl;
|
||||||
|
if (typeof baseUrl !== "string" || baseUrl.trim() === "") {
|
||||||
|
return bizError(`baseUrl 非法:${baseUrl === undefined ? "(缺失)" : String(baseUrl)}(须为非空 url)`);
|
||||||
|
}
|
||||||
|
row.baseUrl = baseUrl.trim();
|
||||||
|
row.enabled = body.enabled === true;
|
||||||
|
row.gmtModified = new Date().toISOString();
|
||||||
|
return ok({ targets: targets.length });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================ 代理测试(服务端签名回环的真实语义 mock) ============================ */
|
||||||
|
|
||||||
|
const MOCK_PROXY_SECRET = "mock-secret-0123456789";
|
||||||
|
|
||||||
|
/** 复刻后端 SignVerifyFilter.sortRawQuery:逐对解码、k=v 字典序、& 连接 */
|
||||||
|
function sortRawQuery(rawQuery: string | null): string {
|
||||||
|
if (!rawQuery) return "";
|
||||||
|
const pairs: string[] = [];
|
||||||
|
for (const part of rawQuery.split("&")) {
|
||||||
|
if (!part) continue;
|
||||||
|
const idx = part.indexOf("=");
|
||||||
|
const k = idx < 0 ? part : part.substring(0, idx);
|
||||||
|
const v = idx < 0 ? "" : part.substring(idx + 1);
|
||||||
|
pairs.push(urlDecode(k) + "=" + urlDecode(v));
|
||||||
|
}
|
||||||
|
pairs.sort();
|
||||||
|
return pairs.join("&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 复刻后端 URLDecoder.decode(UTF-8):+ 号解为空格;非法 %xx 转义回退原串 */
|
||||||
|
function urlDecode(s: string): string {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(s.replace(/\+/g, "%20"));
|
||||||
|
} catch {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 代理测试 mock:错误文案与后端 ProxyTestController 逐字一致,data 形状与真实响应同构。
|
||||||
|
* 校验顺序照后端真实链路:url/appKey → 容忍解析 → 密钥解析(fail-closed)→ 签名 → 路由命中。
|
||||||
|
*/
|
||||||
|
function mockProxyTest(body: any): { code: number; msg: string; data: any } {
|
||||||
|
const method = String(body?.method ?? "GET").trim().toUpperCase() || "GET";
|
||||||
|
const url = typeof body?.url === "string" ? body.url.trim() : "";
|
||||||
|
const appKey = typeof body?.appKey === "string" ? body.appKey.trim() : "";
|
||||||
|
const appSecret = typeof body?.appSecret === "string" ? body.appSecret.trim() : "";
|
||||||
|
if (!url) return bizError("url 不可为空(形如 http://网关域名/{target}/oapi/**)");
|
||||||
|
if (!appKey) return bizError("appKey 不可为空");
|
||||||
|
|
||||||
|
// 容忍解析:剥 scheme,取 path+query(回环只打网关自身,scheme/host 无意义)
|
||||||
|
let pathAndQuery: string;
|
||||||
|
const schemeEnd = url.indexOf("://");
|
||||||
|
if (schemeEnd >= 0) {
|
||||||
|
const pathStart = url.indexOf("/", schemeEnd + 3);
|
||||||
|
if (pathStart < 0) return bizError(`URL 缺少路径(形如 http://host/{target}/oapi/**):${url}`);
|
||||||
|
pathAndQuery = url.substring(pathStart);
|
||||||
|
} else if (url.startsWith("/")) {
|
||||||
|
pathAndQuery = url;
|
||||||
|
} else {
|
||||||
|
pathAndQuery = "/" + url;
|
||||||
|
}
|
||||||
|
const qIdx = pathAndQuery.indexOf("?");
|
||||||
|
const path = qIdx < 0 ? pathAndQuery : pathAndQuery.substring(0, qIdx);
|
||||||
|
const query = qIdx < 0 ? null : pathAndQuery.substring(qIdx + 1);
|
||||||
|
if (!path || path === "/") {
|
||||||
|
return bizError("路径须形如 /{target}/oapi/**(如 /userbase/oapi/user/byId)");
|
||||||
|
}
|
||||||
|
const second = path.indexOf("/", 1);
|
||||||
|
if (second < 0) {
|
||||||
|
return bizError(`路径第二段必须为 oapi(如 /userbase/oapi/user/byId),当前:${path}`);
|
||||||
|
}
|
||||||
|
const target = path.substring(1, second);
|
||||||
|
const rest = path.substring(second);
|
||||||
|
if (!target || target.includes("..")) {
|
||||||
|
return bizError(`target 非法:${target}`);
|
||||||
|
}
|
||||||
|
if (rest !== "/oapi" && !rest.startsWith("/oapi/")) {
|
||||||
|
return bizError(`路径第二段必须为 oapi(如 /userbase/oapi/user/byId),当前:${path}`);
|
||||||
|
}
|
||||||
|
const stripped = rest;
|
||||||
|
|
||||||
|
// 密钥解析(回环前,fail-closed——顺序与后端一致)
|
||||||
|
let secret: string;
|
||||||
|
let secretSource: "INPUT" | "REDIS";
|
||||||
|
if (appSecret) {
|
||||||
|
secret = appSecret;
|
||||||
|
secretSource = "INPUT";
|
||||||
|
} else if ((APP_KEYS as readonly string[]).includes(appKey)) {
|
||||||
|
secret = MOCK_PROXY_SECRET;
|
||||||
|
secretSource = "REDIS";
|
||||||
|
} else {
|
||||||
|
return bizError("appSecret 未填且网关解析不到该 appKey 的密钥(未注册/已禁用/密钥链不可用)");
|
||||||
|
}
|
||||||
|
if (secret.length < 16) {
|
||||||
|
return bizError("签名失败:签名密钥长度不能小于16位");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 签名(复刻 SignUtil.signRequest + buildCanonicalTarget:HMAC-SHA256 → base64)
|
||||||
|
const sorted = sortRawQuery(query);
|
||||||
|
const canonical = sorted ? `${stripped}?${sorted}` : stripped;
|
||||||
|
const timestamp = String(Date.now());
|
||||||
|
const signature = createHmac("sha256", secret).update(`${method}\n${canonical}\n${timestamp}`).digest("base64");
|
||||||
|
|
||||||
|
// 路由命中:targetCode 相等 + pathPattern(剥 /** 后)等于 stripped 或为其路径前缀
|
||||||
|
const hit = routes.find((r) => {
|
||||||
|
if (r.targetCode !== target) return false;
|
||||||
|
const prefix = r.pathPattern.replace(/\/\*\*$/, "");
|
||||||
|
return stripped === prefix || stripped.startsWith(prefix + "/");
|
||||||
|
});
|
||||||
|
|
||||||
|
const base = {
|
||||||
|
target,
|
||||||
|
method,
|
||||||
|
requestUrl: "http://mock-gateway" + pathAndQuery,
|
||||||
|
latencyMs: 20 + Math.floor(Math.random() * 281),
|
||||||
|
bodyTruncated: false,
|
||||||
|
signature: { appKey, timestamp, canonical, signature, secretSource },
|
||||||
|
error: null,
|
||||||
|
};
|
||||||
|
if (!hit) {
|
||||||
|
// 模拟网关裸 404
|
||||||
|
return ok({ ...base, status: 404, contentType: null, body: "" });
|
||||||
|
}
|
||||||
|
const echoBody = JSON.stringify({
|
||||||
|
code: 0,
|
||||||
|
msg: "mock 应答",
|
||||||
|
data: {
|
||||||
|
mock: true,
|
||||||
|
route: { pathPattern: hit.pathPattern, visibility: hit.visibility, authMode: hit.authMode },
|
||||||
|
echo: { method, url },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return ok({ ...base, status: 200, contentType: "application/json", body: echoBody });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============================ 中间件入口(vite dev server 内) ============================ */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 拦截带 serviceHost 前缀(/open-gateway)的治理面请求。命中并应答返回 true,
|
||||||
|
* 未命中返回 false(交回 vite 内部中间件/代理,非 mock 模式根本不会注册本处理)。
|
||||||
|
*/
|
||||||
|
export async function handleGatewayMockRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
||||||
|
const url = new URL(req.url ?? "/", "http://mock.local");
|
||||||
|
const pathname = url.pathname;
|
||||||
|
|
||||||
|
// 超管门槛探测(src/utils/superGate.ts):dev:mock 离线可演示两态——
|
||||||
|
// 默认超管放行;起服务前设 YH_MOCK_SUPER=0 演示非超管拦截态。
|
||||||
|
if (pathname === "/userbase/base/User/CurrentUser") {
|
||||||
|
const superAdmin = process.env.YH_MOCK_SUPER !== "0";
|
||||||
|
send(res, ok({ isSuperAdmin: superAdmin, name: superAdmin ? "mock超级管理员" : "mock普通账号" }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 外观三键同源读(对接规范 §3;线上=网关 AppearanceProxyController 透传 userhome):
|
||||||
|
// 【裸 payload 无信封】——init 见 code 字段会当异常形态跳过收敛;
|
||||||
|
// theme/avatar 给生效值即离线走查三键收敛与 yh-appearance-updated 事件。
|
||||||
|
if (pathname === "/open-gateway/api/appearance") {
|
||||||
|
send(res, {
|
||||||
|
theme: { theme: "blue", sideDeep: false, gradOn: true, sideFollow: true },
|
||||||
|
avatar: "text",
|
||||||
|
logoUrl: null,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pathname.startsWith("/open-gateway/api/")) return false;
|
||||||
|
|
||||||
|
const api = pathname.slice("/open-gateway".length);
|
||||||
|
const method = (req.method ?? "GET").toUpperCase();
|
||||||
|
const q = url.searchParams;
|
||||||
|
|
||||||
|
if (api === "/api/internal/admin/routes" && method === "GET") {
|
||||||
|
send(res, ok(listRoutes(q)));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (api === "/api/internal/admin/targets" && method === "GET") {
|
||||||
|
send(res, ok(listTargets(q)));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (api === "/api/internal/admin/logs" && method === "GET") {
|
||||||
|
send(res, ok(listLogs(q)));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (api === "/api/internal/admin/routes/reload" && method === "POST") {
|
||||||
|
send(res, ok({ loaded: routes.length, targets: targets.length }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (api === "/api/internal/admin/session" && method === "GET") {
|
||||||
|
// ssoHost 留空:mock 没有真 SSO,前端据此隐藏「退出登录」(同 Notice 的 ssoHost 空态行为)
|
||||||
|
send(res, ok({ ssoHost: "", principal: "mock 治理" }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (api === "/api/internal/admin/proxy-test" && method === "POST") {
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
if (body === undefined) {
|
||||||
|
send(res, bizError("请求体不是合法 JSON"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
send(res, mockProxyTest(body));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
let m = /^\/api\/internal\/admin\/routes\/(\d+)$/.exec(api);
|
||||||
|
if (m) {
|
||||||
|
if (method !== "PUT") {
|
||||||
|
send(res, bizError(`不支持的请求:${method} ${api}`));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
if (body === undefined) {
|
||||||
|
send(res, bizError("请求体不是合法 JSON"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
send(res, updateRoute(Number(m[1]), body));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
m = /^\/api\/internal\/admin\/targets\/(\d+)$/.exec(api);
|
||||||
|
if (m) {
|
||||||
|
if (method !== "PUT") {
|
||||||
|
send(res, bizError(`不支持的请求:${method} ${api}`));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const body = await readJsonBody(req);
|
||||||
|
if (body === undefined) {
|
||||||
|
send(res, bizError("请求体不是合法 JSON"));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
send(res, updateTarget(Number(m[1]), body));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读 PUT 的 JSON 请求体;空体按 {},解析失败返回 undefined(由调用方回业务错) */
|
||||||
|
async function readJsonBody(req: IncomingMessage): Promise<any | undefined> {
|
||||||
|
const raw = await readBody(req);
|
||||||
|
if (!raw.trim()) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -115,3 +115,21 @@ h4.subtitle {
|
|||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================= 表格操作列(utils/tableActions.ts)=================
|
||||||
|
.op-cell:flex 横排容器——把 iView Tooltip/Dropdown 的块级包装层降级为 flex item,
|
||||||
|
强制横排(根因:.operating-btn 在通道基线 display:block,.ivu-tooltip 包装层默认 block,
|
||||||
|
逐钮竖排;不要再在 cell 上动 display,会牵连其它列)。
|
||||||
|
先落工程内,四端同构后此段上提 YschTheme 主题层。 */
|
||||||
|
.op-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
// 链间距统一由容器 gap 管理,退役通道基线的 padding-right:10px
|
||||||
|
.operating-link {
|
||||||
|
padding-right: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
|
<div class="table-region" ref="regionRef">
|
||||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||||
<template #loading>
|
<template #loading>
|
||||||
@@ -8,9 +9,10 @@
|
|||||||
<slot :name="slotName" v-bind="slotProps" />
|
<slot :name="slotName" v-bind="slotProps" />
|
||||||
</template>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="page-panel">
|
<div class="page-panel">
|
||||||
<div class="fl align-middle">
|
<div class="fl ml-15 align-middle">
|
||||||
<slot :checkList="selectList" />
|
<slot :checkList="selectList" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,8 +23,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, useSlots } from 'vue'
|
import { computed, ref, useSlots, watch } from 'vue'
|
||||||
import Utils from '@/utils'
|
import { useElementHeight } from '@/utils/hook/useElementHeight'
|
||||||
import { OperatorEnum } from '@/types'
|
import { OperatorEnum } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -44,7 +46,10 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
|
|
||||||
const tableH = ref(props.tableHeight ?? Utils.useTableHeight())
|
const regionRef = ref<HTMLElement | null>(null)
|
||||||
|
const measuredH = useElementHeight(regionRef)
|
||||||
|
// 显式传 tableHeight 则尊重之;否则实测 .table-region(flex:1)高度喂固定表头,首帧未测出前用 undefined(auto)
|
||||||
|
const tableH = computed(() => props.tableHeight ?? (measuredH.value || undefined))
|
||||||
const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
|
const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
|
||||||
|
|
||||||
const selectList = ref<number[]>([])
|
const selectList = ref<number[]>([])
|
||||||
@@ -68,18 +73,30 @@ const {
|
|||||||
total: res => props.formatResponse(res).total,
|
total: res => props.formatResponse(res).total,
|
||||||
data: res => props.formatResponse(res).list,
|
data: res => props.formatResponse(res).list,
|
||||||
initialPage: 1,
|
initialPage: 1,
|
||||||
initialPageSize: 10,
|
// 页长记忆(bsch 应用页规范 §3:customPageCount localStorage)
|
||||||
|
initialPageSize: parseInt(localStorage.getItem('customPageCount') || '10') || 10,
|
||||||
immediate: props.autoLoad,
|
immediate: props.autoLoad,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 数据重载后清空勾选,避免批量操作用到旧选中行
|
||||||
|
watch(resp, () => {
|
||||||
|
if (selectList.value.length) {
|
||||||
|
selectList.value = []
|
||||||
|
emit('selection-change', selectList.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const list = computed(() => {
|
const list = computed(() => {
|
||||||
if (!resp.value) return []
|
if (!resp.value) return []
|
||||||
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
|
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const onPageChange = (p: number) => (page.value = p)
|
const onPageChange = (p: number) => (page.value = p)
|
||||||
const onSizeChange = (s: number) => (pageSize.value = s)
|
const onSizeChange = (s: number) => {
|
||||||
|
pageSize.value = s
|
||||||
|
localStorage.setItem('customPageCount', String(s))
|
||||||
|
}
|
||||||
|
|
||||||
function refreshData() {
|
function refreshData() {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
/* 默认头像三态组件(YschTheme 公共源,scripts/sync-apps.mjs 下发三端 src/components/UserAvatar.vue):
|
||||||
|
头像 URL 非空 → <img>;否则 style==="text" 且能取到字 → 首字;否则 → 剪影(纯 CSS/SVG,零资源文件)。
|
||||||
|
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
|
||||||
|
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
|
||||||
|
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
/** 头像 URL:非空直接 <img>(三态第一优先) */
|
||||||
|
src?: string | null;
|
||||||
|
/** 取字来源(首字态用):经 pickAvatarText 判定,取不到字回落剪影 */
|
||||||
|
name?: string | null;
|
||||||
|
/** 直径 px;font-size = round(size * 0.44) */
|
||||||
|
size?: number;
|
||||||
|
/** 配色场景:light=页面浅底(品牌实底+白内容);brand=header 品牌色底(剪影 S1 玻璃底 / 首字 S2 白底反色,§10.3 分档) */
|
||||||
|
placement?: "light" | "brand";
|
||||||
|
}>(),
|
||||||
|
{ src: null, name: null, size: 28, placement: "light" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
|
||||||
|
/* 外观偏好异步补齐(yh-theme-init 在挂载后拉服务端真源收敛 localStorage 键)会在
|
||||||
|
document 上派发 yh-appearance-updated;tick 自增让 style 依赖失效重读键,三态即时切换(无需刷新)。 */
|
||||||
|
const appearanceTick = ref(0);
|
||||||
|
const onAppearanceUpdated = () => { appearanceTick.value += 1; };
|
||||||
|
onMounted(() => document.addEventListener("yh-appearance-updated", onAppearanceUpdated));
|
||||||
|
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", onAppearanceUpdated));
|
||||||
|
const style = computed(() => {
|
||||||
|
appearanceTick.value;
|
||||||
|
return resolveAvatarStyle();
|
||||||
|
});
|
||||||
|
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
|
||||||
|
const showText = computed(() => style.value === "text" && !!text.value);
|
||||||
|
|
||||||
|
const rootClass = computed(() => [
|
||||||
|
"yh-avatar",
|
||||||
|
props.placement === "brand" ? "yh-avatar--brand" : "yh-avatar--light",
|
||||||
|
{ "is-text": showText.value },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rootStyle = computed(() => ({
|
||||||
|
width: `${props.size}px`,
|
||||||
|
height: `${props.size}px`,
|
||||||
|
fontSize: `${Math.round(props.size * 0.44)}px`,
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span :class="rootClass" :style="rootStyle">
|
||||||
|
<img v-if="src" class="yh-avatar__img" :src="src" alt="" />
|
||||||
|
<template v-else-if="showText">{{ text }}</template>
|
||||||
|
<svg v-else class="yh-avatar__sil" viewBox="0 0 1024 1024" aria-hidden="true">
|
||||||
|
<circle cx="512" cy="399" r="174" />
|
||||||
|
<ellipse cx="512" cy="946" rx="420" ry="240" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.yh-avatar {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.yh-avatar__img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
/* 剪影 SVG:容器圆 overflow:hidden 裁切肩部溢出(禁用 clipPath);overflow:visible 放行 viewBox 外形状 */
|
||||||
|
.yh-avatar__sil {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
/* 页面内(浅底)——品牌实底 + 白内容 */
|
||||||
|
.yh-avatar--light {
|
||||||
|
background: var(--brand, #577afb);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
/* header(品牌色底)——与页面内反向:剪影档 = S1 半透明白,首字档 = S2 纯白底 + 品牌色字 */
|
||||||
|
.yh-avatar--brand {
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.yh-avatar--brand.is-text {
|
||||||
|
background: #fff;
|
||||||
|
color: var(--brand, #577afb);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+133
-27
@@ -1,24 +1,54 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { LayoutData } from "@/types/Basics";
|
import { LayoutData } from "@/types/Basics";
|
||||||
import Utils from "@/utils";
|
import Utils from "@/utils";
|
||||||
|
import config from "@/assets/config/host.config";
|
||||||
|
import defaultLogo from "@/assets/image/yunhai-logo.svg";
|
||||||
|
import UserAvatar from "@/components/UserAvatar.vue";
|
||||||
|
|
||||||
const props = defineProps<{ layoutData: LayoutData }>();
|
const props = defineProps<{ layoutData: LayoutData; userName?: string }>();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const modList = computed(() => props.layoutData.modList || []);
|
const modList = computed(() => props.layoutData.modList || []);
|
||||||
const isActive = (m: any) => route.path === m.page;
|
const isActive = (m: any) => route.path === m.page;
|
||||||
|
|
||||||
/* 重载注册表(POST /api/internal/routes/refresh):网关热重载路由+目标快照,无须重启 */
|
/** 面包屑末段:当前路由 meta.title(三个治理页都带) */
|
||||||
|
const currTitle = computed(() => (route.meta as any)?.title || "");
|
||||||
|
|
||||||
|
/** mock 模式徽标:仅 --mode mock(VITE_IS_MOCK=true)时渲染,dev/线上恒不出现 */
|
||||||
|
const isMock = computed(() => !!config.isMock);
|
||||||
|
|
||||||
|
/** 顶栏操作者:超管门槛取 CurrentUser.name,取不到(mock/error 态)兜底「网关治理」 */
|
||||||
|
const opName = computed(() => props.userName || "网关治理");
|
||||||
|
|
||||||
|
// 顶栏 Logo(外观三键 §3):localStorage["yh-logo-url"](平台键,yh-theme-init 异步补齐段写入)
|
||||||
|
// 优先,本仓资产兜底——绝无空 src/裂图。刷新:mounted 读一次 + 监听
|
||||||
|
// yh-appearance-updated(服务端偏好到达后即时切换,无需刷新页面)。照 Notice layout 同款。
|
||||||
|
const logoUrl = ref<string>(defaultLogo);
|
||||||
|
const readLogoUrl = () => {
|
||||||
|
let v: string | null = null;
|
||||||
|
try {
|
||||||
|
v = localStorage.getItem("yh-logo-url");
|
||||||
|
} catch {
|
||||||
|
v = null;
|
||||||
|
}
|
||||||
|
logoUrl.value = v || defaultLogo;
|
||||||
|
};
|
||||||
|
const onLogoError = () => {
|
||||||
|
if (logoUrl.value !== defaultLogo) logoUrl.value = defaultLogo;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 重载注册表(POST /api/internal/admin/routes/reload):治理面入口,与机器面
|
||||||
|
/api/internal/routes/refresh 共用后端实现;机器面那条只认内部令牌,浏览器不能再打它 */
|
||||||
const reloading = ref(false);
|
const reloading = ref(false);
|
||||||
const lastReloadTime = ref("");
|
const lastReloadTime = ref("");
|
||||||
|
|
||||||
async function reloadRegistry() {
|
async function reloadRegistry() {
|
||||||
reloading.value = true;
|
reloading.value = true;
|
||||||
try {
|
try {
|
||||||
const d: any = await Apis.GatewayAdmin.refresh({});
|
const d: any = await Apis.GatewayAdmin.reload({});
|
||||||
Message.success(`已重载:路由 ${d?.loaded ?? "-"} 条 / 目标 ${d?.targets ?? "-"} 条`);
|
Message.success(`已重载:路由 ${d?.loaded ?? "-"} 条 / 目标 ${d?.targets ?? "-"} 条 / 应用 ${d?.apps ?? "-"} 条`);
|
||||||
lastReloadTime.value = Utils.dateFormat(new Date(), "HH:mm:ss");
|
lastReloadTime.value = Utils.dateFormat(new Date(), "HH:mm:ss");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// 错误提示由 alova 全局 onBizError 弹出,这里不重复弹
|
// 错误提示由 alova 全局 onBizError 弹出,这里不重复弹
|
||||||
@@ -27,49 +57,125 @@ async function reloadRegistry() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SSO 基址:网关后端无 env-config 端点(conf.ssoHost 恒空),改从已登录会话里取。
|
||||||
|
取不到(未配 CAS / mock)则不渲染登出入口——与 Notice 的 ssoHost 空态一致。 */
|
||||||
|
const ssoHost = ref("");
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
readLogoUrl();
|
||||||
|
document.addEventListener("yh-appearance-updated", readLogoUrl);
|
||||||
|
try {
|
||||||
|
const d: any = await Apis.GatewayAdmin.session({});
|
||||||
|
ssoHost.value = d?.ssoHost || "";
|
||||||
|
} catch (e: any) {
|
||||||
|
// 未登录时这条会被 alova 整页跳去 SSO;其它失败只是没有登出入口,不额外弹
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", readLogoUrl));
|
||||||
|
|
||||||
|
/* 登出(逐字照 Notice 的 layout.vue):打 CAS 平台级注销,service 回当前控制台地址。
|
||||||
|
注意这是平台级动作——会连带注销该浏览器在 CAS 下其他应用的会话(Notice 同此语义)。 */
|
||||||
|
function logout() {
|
||||||
|
window.location.href = ssoHost.value + "/logout?service=" + window.location.href;
|
||||||
|
}
|
||||||
|
|
||||||
/* 手动刷新:重建当前页面(App.vue 的 provide("reload")),列表回到服务端真值 */
|
/* 手动刷新:重建当前页面(App.vue 的 provide("reload")),列表回到服务端真值 */
|
||||||
const reloadPage = inject("reload", () => {}) as () => void;
|
const reloadPage = inject("reload", () => {}) as () => void;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<div class="layout-wrap">
|
||||||
<header class="header-container fix">
|
<header class="header-container fix">
|
||||||
<div class="icon-box fl">
|
<!-- 云海 logo 徽标(结构与 Notice 对齐,皮肤见通道 base.layout.css .logo-badge);
|
||||||
<h4 class="fl">{{ layoutData.appName }}</h4>
|
外观三键 §3:平台键 yh-logo-url 优先、本仓资产兜底,加载失败回本仓资产 -->
|
||||||
</div>
|
<span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
|
||||||
<div class="header-aside fr">
|
<div class="delimiter fl align-middle"></div>
|
||||||
<span v-if="lastReloadTime" class="last-reload fl">最后更新 {{ lastReloadTime }}</span>
|
<h4 class="fl align-middle">{{ layoutData.appName }}</h4>
|
||||||
<Button size="small" class="fl reload-btn" @click="reloadPage">手动刷新</Button>
|
<div class="school-bg"></div>
|
||||||
<Button type="primary" size="small" class="fl reload-btn" :loading="reloading" @click="reloadRegistry">
|
<div class="header-tools fr">
|
||||||
|
<span v-if="isMock" class="mock-badge" title="当前为本地 mock 数据,重启 dev:mock 后还原">MOCK 数据</span>
|
||||||
|
<span v-if="lastReloadTime" class="last-reload">最后更新 {{ lastReloadTime }}</span>
|
||||||
|
<Button size="small" class="reload-btn" @click="reloadPage">手动刷新</Button>
|
||||||
|
<Button type="primary" size="small" class="reload-btn" :loading="reloading" @click="reloadRegistry">
|
||||||
重载注册表
|
重载注册表
|
||||||
</Button>
|
</Button>
|
||||||
<span class="name fr">网关治理</span>
|
</div>
|
||||||
|
<div class="header-aside fr">
|
||||||
|
<UserAvatar class="fl align-middle" :name="opName" :size="28" placement="brand" />
|
||||||
|
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);">{{ opName }}</a>
|
||||||
|
<!-- 登出入口:仅在拿到 SSO 基址时出现(未接 CAS/本地 mock 恒不渲染) -->
|
||||||
|
<a v-if="ssoHost" class="logout fl align-middle" href="javascript:void(0);" @click="logout">退出</a>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<ul class="left-menu">
|
<div class="left-menu">
|
||||||
<li class="menu-top-box"></li>
|
<div class="menu-top-box"></div>
|
||||||
|
<ul>
|
||||||
<li v-for="m in modList" :key="m.page" :class="{ active: isActive(m) }">
|
<li v-for="m in modList" :key="m.page" :class="{ active: isActive(m) }">
|
||||||
<router-link :to="m.page">{{ m.title }}</router-link>
|
<router-link :to="m.page">{{ m.title }}</router-link>
|
||||||
</li>
|
</li>
|
||||||
<li class="clear-box"></li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
<div class="clear-box"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="path-nav">
|
||||||
|
<Breadcrumb separator=">">
|
||||||
|
<BreadcrumbItem>
|
||||||
|
<span class="crumb-home">
|
||||||
|
<i class="iconfont icon-shouye"></i>
|
||||||
|
首页
|
||||||
|
</span>
|
||||||
|
</BreadcrumbItem>
|
||||||
|
<BreadcrumbItem>{{ layoutData.appName }}</BreadcrumbItem>
|
||||||
|
<BreadcrumbItem v-if="currTitle">{{ currTitle }}</BreadcrumbItem>
|
||||||
|
</Breadcrumb>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.header-container .header-aside .name {
|
/* 仅覆盖/补充必要的动态样式,其余样式由中央静态通道的 base.layout.css(骨架 + 皮肤)提供 */
|
||||||
line-height: 56px;
|
.header-container .logo-badge {
|
||||||
font-size: 14px;
|
margin: 0 10px 0 0;
|
||||||
}
|
}
|
||||||
|
/* 面包屑「首页」垂直居中:inline-flex 真居中并关掉骨架给老 iconfont 字体的基线补偿 */
|
||||||
.header-container .header-aside .reload-btn {
|
.crumb-home {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.crumb-home .iconfont {
|
||||||
|
position: static;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
/* 顶栏操作区(重载按钮 + mock 徽标):皮肤把 header 改 flex 垂直居中,只留间距 */
|
||||||
|
.header-tools {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
margin-right: 12px;
|
margin-right: 12px;
|
||||||
margin-top: 13px;
|
|
||||||
}
|
}
|
||||||
|
.header-tools .reload-btn {
|
||||||
.header-container .header-aside .last-reload {
|
margin: 0;
|
||||||
line-height: 56px;
|
}
|
||||||
|
.header-tools .last-reload {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #848c98;
|
color: rgba(255, 255, 255, 0.75);
|
||||||
margin-right: 12px;
|
}
|
||||||
|
.header-tools .mock-badge {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(255, 153, 0, 0.22);
|
||||||
|
border: 1px solid rgba(255, 153, 0, 0.65);
|
||||||
|
color: #ffd49a;
|
||||||
|
}
|
||||||
|
/* 登出入口:贴用户胶囊右侧,沿用顶栏次要文字色(不做按钮,避免与「重载注册表」抢视觉权重) */
|
||||||
|
.header-aside .logout {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+17
-2
@@ -15,13 +15,23 @@ import ViewUIPlus from "view-ui-plus";
|
|||||||
import "view-ui-plus/dist/styles/viewuiplus.css";
|
import "view-ui-plus/dist/styles/viewuiplus.css";
|
||||||
|
|
||||||
import "@/assets/styles/main.scss";
|
import "@/assets/styles/main.scss";
|
||||||
// 本地化的布局/主题样式(原远程 base.layout.beta / _iview_override / default-theme 合并)
|
// 布局/主题/图标(base.layout.css + iview.override.css + icons.css)由中央静态通道下发:
|
||||||
import "@/assets/styles/layout.css";
|
// index.html 的 yh-theme-init.js 按 manifest 版本追加 <link>,天然排在本文件组件库样式之后
|
||||||
|
//(本地 layout.css 快照已随之退役,删防双份漂移)
|
||||||
|
|
||||||
import "virtual:uno.css";
|
import "virtual:uno.css";
|
||||||
import "virtual:svg-icons-register";
|
import "virtual:svg-icons-register";
|
||||||
|
|
||||||
import config from "@/assets/config/host.config";
|
import config from "@/assets/config/host.config";
|
||||||
|
import { consumeSsoReturnUrl } from "@/utils/ssoReturn";
|
||||||
|
|
||||||
|
/* 外观三键通道契约(§3):yh-theme-init 异步补齐段按「__APP_CONFIG__.serviceHost +
|
||||||
|
/api/appearance」取服务端真源。网关后端无 env-config 端点、__APP_CONFIG__ 恒空,
|
||||||
|
这里用 host.config 的同源推导值(生产 /open-gateway、dev 取 VITE_SERVICE_HOST)播种,
|
||||||
|
否则 init 会裸打域根 /api/appearance 404。模块脚本先于 DOMContentLoaded+delay 执行,时序安全。 */
|
||||||
|
const g = window as any;
|
||||||
|
g.__APP_CONFIG__ = g.__APP_CONFIG__ || {};
|
||||||
|
if (!g.__APP_CONFIG__.serviceHost) g.__APP_CONFIG__.serviceHost = config.serviceHost;
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
@@ -31,4 +41,9 @@ app.use(ViewUIPlus);
|
|||||||
app.config.globalProperties.$conf = config;
|
app.config.globalProperties.$conf = config;
|
||||||
app.config.globalProperties.$utils = utils;
|
app.config.globalProperties.$utils = utils;
|
||||||
|
|
||||||
|
// SSO 登录往返只能把浏览器送回控制台默认页(后端用固定 CAS service,见网关 SsoAddressing);
|
||||||
|
// 挂载前先换回跳转前记下的页签,避免首帧闪一下默认页。
|
||||||
|
const ssoReturn = consumeSsoReturnUrl();
|
||||||
|
if (ssoReturn) router.replace(ssoReturn);
|
||||||
|
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/target/TargetList.vue"),
|
component: () => import("@/views/target/TargetList.vue"),
|
||||||
meta: { title: "目标地址" },
|
meta: { title: "目标地址" },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/apps",
|
||||||
|
name: "AppList",
|
||||||
|
component: () => import("@/views/app/AppList.vue"),
|
||||||
|
meta: { title: "应用管理" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/proxy-test",
|
||||||
|
name: "ProxyTest",
|
||||||
|
component: () => import("@/views/proxytest/ProxyTest.vue"),
|
||||||
|
meta: { title: "代理测试" },
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default routes;
|
export default routes;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createAlova, Method, StatesHook } from "alova";
|
import { createAlova, Method, StatesHook } from "alova";
|
||||||
import fetchAdapter from "alova/fetch";
|
import fetchAdapter from "alova/fetch";
|
||||||
import VueHook from "alova/vue";
|
import VueHook from "alova/vue";
|
||||||
|
import { rememberReturnUrl } from "@/utils/ssoReturn";
|
||||||
/* ============= 类型定义 ============= */
|
/* ============= 类型定义 ============= */
|
||||||
export interface RequestHooks {
|
export interface RequestHooks {
|
||||||
/** 前置票据/通用头注入 */
|
/** 前置票据/通用头注入 */
|
||||||
@@ -56,8 +57,14 @@ function errorHandle<T>(res: BschResponse<T>, status: number): T {
|
|||||||
return res.data ?? (null as T);
|
return res.data ?? (null as T);
|
||||||
case 302:
|
case 302:
|
||||||
console.log(res);
|
console.log(res);
|
||||||
|
// 后端 CAS 门已拼好登录地址(含 service=会话建立端点),整页跳过去即可。
|
||||||
|
// 跳转前记下当前地址:固定 service 的回跳只能落默认页,hash 页签靠前端记账换回
|
||||||
|
//(原因见 utils/ssoReturn.ts——不能照 Notice 把 hex 拼进固定 service)。
|
||||||
|
// 跳转后返回永不 resolve 的挂起 Promise:调用方 await 悬停,全局 onBizError 与
|
||||||
|
// 各页面 catch 都不再触发——否则会弹一个"redirect"错误提示(Notice 同款处理)。
|
||||||
|
rememberReturnUrl();
|
||||||
window.location.href = res.urlToRedirectTo!;
|
window.location.href = res.urlToRedirectTo!;
|
||||||
throw new Error("redirect");
|
return new Promise(() => {}) as T;
|
||||||
case 4:
|
case 4:
|
||||||
throw new Error(res.msg || "参数错误!");
|
throw new Error(res.msg || "参数错误!");
|
||||||
default:
|
default:
|
||||||
@@ -90,8 +97,11 @@ export function createBschAlova(baseURL: string, hooks: RequestHooks = {}, state
|
|||||||
injectHeaders(method);
|
injectHeaders(method);
|
||||||
|
|
||||||
method.config.credentials = "include";
|
method.config.credentials = "include";
|
||||||
// 通用头(网关管理面不识别 yschapirequest/yschreferer/currentRole,故不注入)
|
// 通用头。yschapirequest 是治理面 CAS 门识别 SPA 的标记头(与平台
|
||||||
|
// CasSsoApiRedirectFilter 同名同值):带它,未登录才返回 200+{code:302,urlToRedirectTo}
|
||||||
|
// 让本页去整页跳 SSO;不带,网关按裸调用方出 401 json。
|
||||||
method.config.headers = Object.assign(method.config.headers || {}, {
|
method.config.headers = Object.assign(method.config.headers || {}, {
|
||||||
|
yschapirequest: "1",
|
||||||
timeout: 20000,
|
timeout: 20000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* 默认头像样式(首字 / 剪影)公共源(YschTheme,scripts/sync-apps.mjs 下发三端 src/utils/avatar.ts)。
|
||||||
|
* 取值链与主题同构(theme-core.js resolveEffectiveThemeState 的合成链写法):
|
||||||
|
* URL ?yh-avatar=text|silhouette(仅预览/排障覆盖:只影响读、绝不写回)
|
||||||
|
* > localStorage["yh-avatar"](用户自选,纯字符串)
|
||||||
|
* > localStorage["yh-avatar-sch-default"](学校默认,UserHome 登录后写入)
|
||||||
|
* > "text"(系统默认)
|
||||||
|
* 值域恒为 "text" | "silhouette",键缺失/值非法均静默回落下一级,不报错不阻塞渲染。
|
||||||
|
* 渲染三态与取字规则的唯一契约见 UserHome《设置页修复与默认头像落地方案》§10.1/§10.2。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type AvatarStyle = "text" | "silhouette";
|
||||||
|
|
||||||
|
interface StorageLike {
|
||||||
|
getItem(k: string): string | null;
|
||||||
|
setItem(k: string, v: string): void;
|
||||||
|
removeItem(k: string): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AVATAR_STORAGE_KEY = "yh-avatar";
|
||||||
|
|
||||||
|
/** 学校默认头像存储键(UserHome 登录时写入;值格式与用户键相同,仅无"用户自选"语义) */
|
||||||
|
export const SCHOOL_DEFAULT_AVATAR_STORAGE_KEY = "yh-avatar-sch-default";
|
||||||
|
|
||||||
|
export const AVATAR_STYLES: readonly AvatarStyle[] = ["text", "silhouette"];
|
||||||
|
|
||||||
|
export const DEFAULT_AVATAR_STYLE: AvatarStyle = "text";
|
||||||
|
|
||||||
|
/** 值合法才采用(用户键/学校键同一判据;脏数据不生效) */
|
||||||
|
function readAvatarStyleOrNull(raw: string | null): AvatarStyle | null {
|
||||||
|
return raw === "text" || raw === "silhouette" ? raw : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** URL 覆盖参数(?yh-avatar=text|silhouette):非法值忽略,且只影响"读哪一档"、绝不写回 localStorage */
|
||||||
|
function readAvatarOverride(): AvatarStyle | null {
|
||||||
|
try {
|
||||||
|
const search = (globalThis.location && globalThis.location.search) || "";
|
||||||
|
const m = new RegExp("[?&]yh-avatar=([^&]*)").exec(search);
|
||||||
|
return m ? readAvatarStyleOrNull(decodeURIComponent(m[1])) : null;
|
||||||
|
} catch {
|
||||||
|
/* 无 location 环境(测试/SSR):无覆盖 */
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生效样式合成(与 resolveEffectiveThemeState 同构):
|
||||||
|
* URL 覆盖 > localStorage["yh-avatar"](用户自选)> localStorage["yh-avatar-sch-default"](学校默认)
|
||||||
|
* > DEFAULT_AVATAR_STYLE。
|
||||||
|
*/
|
||||||
|
export function resolveAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): AvatarStyle {
|
||||||
|
let style: AvatarStyle | null = null;
|
||||||
|
if (storage) {
|
||||||
|
style = readAvatarStyleOrNull(storage.getItem(AVATAR_STORAGE_KEY));
|
||||||
|
if (!style) style = readAvatarStyleOrNull(storage.getItem(SCHOOL_DEFAULT_AVATAR_STORAGE_KEY));
|
||||||
|
}
|
||||||
|
if (!style) style = DEFAULT_AVATAR_STYLE;
|
||||||
|
return readAvatarOverride() || style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 保存用户自选(直写 localStorage,无响应式;调用方操作后自行刷新 UI) */
|
||||||
|
export function saveAvatarStyle(style: AvatarStyle, storage: StorageLike | undefined = globalThis.localStorage): void {
|
||||||
|
try {
|
||||||
|
storage?.setItem(AVATAR_STORAGE_KEY, style);
|
||||||
|
} catch {
|
||||||
|
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 跟随学校默认 = 删用户自选键(之后回落学校默认/系统默认) */
|
||||||
|
export function clearAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): void {
|
||||||
|
try {
|
||||||
|
storage?.removeItem(AVATAR_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
/* 隐私模式静默 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取字(顺序判定,先命中先返回):含 "@" 先截 "@" 前段(邮箱取首字母);
|
||||||
|
* 首字符必须用展开 [...s][0](禁用 charAt(0),避免代理对截半出乱码)。
|
||||||
|
* ASCII 字母转大写;纯数字回落 ""(剪影);符号/emoji/空白回落 ""(剪影);其余(CJK 等)原样。
|
||||||
|
*/
|
||||||
|
export function pickAvatarText(name: string | null | undefined): string {
|
||||||
|
let s = String(name ?? "");
|
||||||
|
const at = s.indexOf("@");
|
||||||
|
if (at > -1) s = s.slice(0, at);
|
||||||
|
const ch = [...s.trim()][0];
|
||||||
|
if (ch === undefined) return "";
|
||||||
|
if (/^[A-Za-z]$/.test(ch)) return ch.toUpperCase();
|
||||||
|
if (/^[0-9]$/.test(ch)) return "";
|
||||||
|
if (/^[\p{P}\p{S}\s]$/u.test(ch)) return "";
|
||||||
|
return ch;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ref, onMounted, onBeforeUnmount, watch, type Ref } from "vue";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实测某容器元素的可用高度(px),随尺寸变化实时更新。
|
||||||
|
*
|
||||||
|
* 用途:表格页把 <Table> 放进 flex:1 的 .table-region 里,用本组合式量出该区域
|
||||||
|
* 的真实高度喂给 view-ui 的 `:height`(固定表头需要一个数值高度),从而彻底替代
|
||||||
|
* 旧的 useTableHeight()「视口高 - 一堆写死常量」的算法——加/删工具条、改顶栏高
|
||||||
|
* 都不再需要同步改任何魔法数字。
|
||||||
|
*
|
||||||
|
* @param el 目标容器 ref(模板里 ref 绑定的那个元素)
|
||||||
|
* @returns 响应式高度(px);挂载测量前为 0,调用方可 `:height="h || undefined"` 兜底
|
||||||
|
*/
|
||||||
|
export function useElementHeight(el: Ref<HTMLElement | null>): Ref<number> {
|
||||||
|
const height = ref(0);
|
||||||
|
let ro: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
if (el.value) height.value = Math.floor(el.value.clientHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const observe = () => {
|
||||||
|
ro?.disconnect();
|
||||||
|
if (!el.value || typeof ResizeObserver === "undefined") return;
|
||||||
|
measure();
|
||||||
|
ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(observe);
|
||||||
|
// v-if / v-show 切换导致容器重建时,重新挂观察
|
||||||
|
watch(el, observe);
|
||||||
|
onBeforeUnmount(() => ro?.disconnect());
|
||||||
|
|
||||||
|
return height;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* SSO 登录往返的「回到原页签」记账(纯前端)。
|
||||||
|
* <p>
|
||||||
|
* <b>为什么不是 Notice 的 {@code ;redirectToReferer=<hex>} 那套</b>:Notice 把 hex 拼进 service
|
||||||
|
* 参数,再靠校验过滤器从<b>实时请求 URI</b> 重算出同一个带 hex 的 service,两端逐字节一致才过得了
|
||||||
|
* CAS 的 ST 比对。本网关的 ctx 为空、外链前缀 {@code /open-gateway} 由 Caddy 剥掉,容器算不出
|
||||||
|
* 带前缀的地址,只能改用<b>固定 service</b>(见后端 {@code SsoAddressing})——固定值里塞不进每次
|
||||||
|
* 不同的 hex:登录时 service 带 A、校验时 service 是常量,CAS 判定 service 不一致 → 票据校验必失败
|
||||||
|
* → 又是一次 302,死循环。
|
||||||
|
* <p>
|
||||||
|
* 所以回跳目标不下服务端:整页跳去登录前把它记在 {@code sessionStorage}(同标签页、跨站点往返仍在),
|
||||||
|
* 回到控制台壳页时读出来换回原页签。副作用为零,且不引入任何服务端开放重定向面。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RETURN_KEY = "ysch-return-url";
|
||||||
|
|
||||||
|
/** 整页跳去 SSO 之前调用:记下当前控制台地址(含 hash 路由) */
|
||||||
|
export function rememberReturnUrl(href: string = window.location.href): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(RETURN_KEY, href);
|
||||||
|
} catch (e: any) {
|
||||||
|
// 隐私模式/配额满:最坏只是回到默认页,不影响登录本身
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出并清掉回跳目标,返回 vue-router 的 hash 路由段(如 {@code /ledger?pageIndex=2})。
|
||||||
|
* 只认「同 origin + 同一壳页」的地址,其余一律丢弃——防把用户导向站外或别的应用。
|
||||||
|
*/
|
||||||
|
export function consumeSsoReturnUrl(): string | null {
|
||||||
|
let raw: string | null = null;
|
||||||
|
try {
|
||||||
|
raw = sessionStorage.getItem(RETURN_KEY);
|
||||||
|
if (raw) sessionStorage.removeItem(RETURN_KEY);
|
||||||
|
} catch (e: any) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const u = new URL(raw);
|
||||||
|
if (u.origin !== window.location.origin) return null;
|
||||||
|
if (u.pathname !== window.location.pathname) return null;
|
||||||
|
const route = u.hash.replace(/^#\/?/, "/");
|
||||||
|
return route && route !== "/" ? route : null;
|
||||||
|
} catch (e: any) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* 超管门槛(治理面前置守卫):GET 同源 /userbase/base/User/CurrentUser,
|
||||||
|
* 响应 {code:0, data:{isSuperAdmin, ...}}——非超级管理员整页显示「暂无权限」。
|
||||||
|
*
|
||||||
|
* 为什么用裸 fetch 不走 Apis/alova:
|
||||||
|
* - alova 实例 baseUrl 挂在网关 serviceHost(/open-gateway 前缀)上,而这条是平台
|
||||||
|
* userbase 的接口;且全局 loading/onBizError 会在门槛探测期弹遮罩与错误 toast,
|
||||||
|
* 门槛态由页面自己渲染,不需要全局报错。
|
||||||
|
* - 相对路径生产恒成立:控制台与 userbase 同挂 base 域,页面 /open-gateway/front/
|
||||||
|
* 发 /userbase/... 即同源带 Cookie;本地 dev 由 vite /userbase 代理兜到 base 域,
|
||||||
|
* dev:mock 由内置 mock 直接应答(YH_MOCK_SUPER=0 演示非超管态)。
|
||||||
|
*
|
||||||
|
* 判据宽容解析:isSuperAdmin 兼容 true/1/"1"/"true"(UserBase 后端 type=1 口径透传)。
|
||||||
|
* 只有「拿到响应且明确非超管」才拦截(denied);接口不可达/非 JSON/非 2xx 视为
|
||||||
|
* error——本地无平台 Cookie、userbase 故障等场景不因此锁死治理面(门槛是前端体验层,
|
||||||
|
* 真正的访问控制是后端 CAS 门)。本地开发可在 .env.dev 设 VITE_SUPER_GATE=off 旁路。
|
||||||
|
*/
|
||||||
|
export type SuperGate = "pending" | "allowed" | "denied" | "error";
|
||||||
|
|
||||||
|
export interface CurrentUserState {
|
||||||
|
gate: SuperGate;
|
||||||
|
/** 接口 data 里的展示名(可空):顶栏姓名/头像首字用 */
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTruthyFlag(v: any): boolean {
|
||||||
|
return v === true || v === 1 || v === "1" || v === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkSuperAdmin(): Promise<CurrentUserState> {
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_SUPER_GATE === "off") {
|
||||||
|
return { gate: "allowed", name: "" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch("/userbase/base/User/CurrentUser", {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!res.ok) return { gate: "error", name: "" };
|
||||||
|
const body: any = await res.json();
|
||||||
|
if (!body || body.code !== 0 || !body.data) return { gate: "error", name: "" };
|
||||||
|
const data = body.data;
|
||||||
|
const name = String(data.name ?? data.userName ?? data.nickname ?? "");
|
||||||
|
return { gate: isTruthyFlag(data.isSuperAdmin) ? "allowed" : "denied", name };
|
||||||
|
} catch {
|
||||||
|
return { gate: "error", name: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
/* 本端跑 strict vue-tsc(front/MOCK.md),通道/四端同构源落本端的既有约定照 theme.ts 补
|
||||||
|
@ts-nocheck:正文与 UserBase 同构源保持逐字节一致(MD5 973393684783c740399b8eb9e97369bc),
|
||||||
|
仅顶部此二行 + 下方注释为本端补丁;strict 下两处告警均无害——
|
||||||
|
L16 的 h 被各渲染器形参遮蔽(导入冗余)、actionMore 的 it.on() 形参在 normalize 已闭包绑定。 */
|
||||||
|
/**
|
||||||
|
* 表格操作列统一原子(方案:outputs/table-action-buttons/操作列统一方案-20261010.md)
|
||||||
|
*
|
||||||
|
* 三条变化轴各自独立,每加一种形态 = 加 1 个渲染器 + RENDERERS 表加 1 行,不动既有分支:
|
||||||
|
* 轴1 形态 actionIcon / actionText / actionMore —— 只画节点,零策略
|
||||||
|
* 轴2 动作集 normalizeActions —— 纯数据:when 过滤 / disabled 归一 / danger 沉底
|
||||||
|
* 轴3 溢出 splitActions —— 纯函数:直出与「更多」分档,零 DOM 可单测
|
||||||
|
* 组装 actionCell / actionColumn —— 唯一懂 DOM 结构与表格列
|
||||||
|
*
|
||||||
|
* 形态由页面【显式声明】(mode: "icon" | "text",缺省 text),不按动作数自动切换——
|
||||||
|
* 工程间风格区分是既定口径(Notice=图标钮 / UserBase=文字链),各自恒定不漂移。
|
||||||
|
* 「更多▾」只是长尾收纳(动作数 ≥ maxInline+2 时启用),不改变形态、不分档。
|
||||||
|
* 菜单项纯文字不带图标(动作一多找不到贴切图标,文字更准);transfer 到 body,
|
||||||
|
* 避免 fixed/sticky 操作列裁剪与层叠上下文遮盖(原型已实证)。
|
||||||
|
*/
|
||||||
|
import { h, resolveComponent } from "vue";
|
||||||
|
|
||||||
|
export interface ActionItem {
|
||||||
|
/** 稳定标识:溢出分桶按 key 去重,必填 */
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
/** 点击回调,normalizeActions 阶段闭包绑定行数据 */
|
||||||
|
on: (row: any) => void;
|
||||||
|
/** iconfont 类名(仅 mode:"icon" 时消费;通道 icons.css 全集见 YschTheme) */
|
||||||
|
icon?: string;
|
||||||
|
/** 危险动作:文字转红(text-error)、恒沉底、「更多」内分隔线隔离 */
|
||||||
|
danger?: boolean;
|
||||||
|
/** 返回 false → 该行不渲染此动作(决定「有没有」) */
|
||||||
|
when?: (row: any) => boolean;
|
||||||
|
/** 直接置灰 */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** 按行判定置灰(决定「能不能点」),disabled 显式值优先 */
|
||||||
|
disabledWhen?: (row: any) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionCellOpts {
|
||||||
|
/** 形态显式声明:icon=图标圆钮 / text=文字链,缺省 text;不按动作数自动切换 */
|
||||||
|
mode?: "icon" | "text";
|
||||||
|
/** 超额动作收进「更多」,默认开(防 5+ 动作页面忘传爆宽) */
|
||||||
|
collapsible?: boolean;
|
||||||
|
/** 直出上限,默认 2;n ≤ maxInline+1 时全直出(3 个动作不进更多) */
|
||||||
|
maxInline?: number;
|
||||||
|
/** 列宽;缺省 icon = n*40+40 / text = 180 */
|
||||||
|
width?: number;
|
||||||
|
align?: "center" | "left" | "right";
|
||||||
|
/** 缺省不固定列(与存量页面一致);需要吸右显式传 "right" */
|
||||||
|
fixed?: "right" | "left" | false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 轴 1:渲染器(只画节点,零策略) ============ */
|
||||||
|
|
||||||
|
/** 图标圆钮:Tooltip > div.operating-btn(沿用通道既有 class,主题层零改动) */
|
||||||
|
export function actionIcon(h: any, item: ActionItem) {
|
||||||
|
return h(
|
||||||
|
resolveComponent("Tooltip"),
|
||||||
|
{ content: item.label, placement: "bottom", transfer: true },
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
h("div", {
|
||||||
|
class: `operating-btn iconfont ${item.icon}${item.disabled ? " disabled" : ""}`,
|
||||||
|
onClick: item.disabled ? null : item.on,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文字链:danger 输出既有 text-error 类对接主题红色 */
|
||||||
|
export function actionText(h: any, item: ActionItem) {
|
||||||
|
return h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
class: `operating-link${item.danger ? " text-error" : ""}${item.disabled ? " disabled" : ""}`,
|
||||||
|
onClick: item.disabled ? null : item.on,
|
||||||
|
},
|
||||||
|
item.label,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「更多▾」+ 下拉菜单:iView Dropdown transfer 模式,菜单挂 body */
|
||||||
|
export function actionMore(h: any, items: ActionItem[]) {
|
||||||
|
return h(
|
||||||
|
resolveComponent("Dropdown"),
|
||||||
|
{ class: "op-more", placement: "bottom-end", transfer: true },
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
h("div", { class: "operating-link op-more-label" }, [
|
||||||
|
"更多",
|
||||||
|
h(resolveComponent("Icon"), { type: "ios-arrow-down", class: "op-more-arrow" }),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
list: () => [
|
||||||
|
h(resolveComponent("DropdownMenu"), {}, {
|
||||||
|
default: () => items.map((it) =>
|
||||||
|
h(
|
||||||
|
resolveComponent("DropdownItem"),
|
||||||
|
{
|
||||||
|
key: it.key,
|
||||||
|
divided: !!(it.danger && items.length > 1),
|
||||||
|
disabled: !!it.disabled,
|
||||||
|
class: it.danger ? "op-item-danger" : "",
|
||||||
|
onClick: () => it.on(),
|
||||||
|
},
|
||||||
|
{ default: () => it.label },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 轴 2:归一化(纯数据) ============ */
|
||||||
|
export function normalizeActions(items: ActionItem[], row: any): ActionItem[] {
|
||||||
|
return items
|
||||||
|
.filter((it) => (it.when ? it.when(row) : true))
|
||||||
|
.map((it) => ({
|
||||||
|
...it,
|
||||||
|
disabled: it.disabled ?? (it.disabledWhen ? it.disabledWhen(row) : false),
|
||||||
|
on: () => it.on(row),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => Number(!!a.danger) - Number(!!b.danger)); // danger 恒沉底(稳定排序)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 轴 3:溢出策略(纯函数,可单测、零 DOM) ============ */
|
||||||
|
export function splitActions(items: ActionItem[], maxInline = 2): { inline: ActionItem[]; overflow: ActionItem[] } {
|
||||||
|
if (items.length <= maxInline + 1) return { inline: items, overflow: [] };
|
||||||
|
const inline = items.filter((it) => !it.danger).slice(0, maxInline);
|
||||||
|
const inlineKeys = new Set(inline.map((it) => it.key));
|
||||||
|
return { inline, overflow: items.filter((it) => !inlineKeys.has(it.key)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 组装层 ============ */
|
||||||
|
const RENDERERS: Record<string, (h: any, item: ActionItem) => any> = { icon: actionIcon, text: actionText };
|
||||||
|
|
||||||
|
export function actionCell(h: any, items: ActionItem[], opts: ActionCellOpts = {}) {
|
||||||
|
const mode = opts.mode ?? "text";
|
||||||
|
const { inline, overflow } = (opts.collapsible ?? true)
|
||||||
|
? splitActions(items, opts.maxInline ?? 2)
|
||||||
|
: { inline: items, overflow: [] };
|
||||||
|
|
||||||
|
const render = RENDERERS[mode] || actionText;
|
||||||
|
const children = inline.map((it) => render(h, it));
|
||||||
|
if (overflow.length) children.push(actionMore(h, overflow));
|
||||||
|
|
||||||
|
return h("div", { class: "op-cell" }, children); // flex 横排容器,见 main.scss
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 产出可直接塞进 columns 的操作列对象;列宽按「声明数」估算,when 过滤后只宽不窄 */
|
||||||
|
export function actionColumn(items: ActionItem[], opts: ActionCellOpts = {}) {
|
||||||
|
const mode = opts.mode ?? "text";
|
||||||
|
return {
|
||||||
|
title: "操作",
|
||||||
|
key: "action",
|
||||||
|
width: opts.width ?? (mode === "icon" ? items.length * 40 + 40 : 180),
|
||||||
|
align: opts.align ?? "center",
|
||||||
|
...(opts.fixed === undefined ? {} : { fixed: opts.fixed === false ? undefined : opts.fixed }),
|
||||||
|
render: (h: any, p: any) => actionCell(h, normalizeActions(items, p.row), opts),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
<template>
|
||||||
|
<div class="page-wrapper yh-fill">
|
||||||
|
<div class="main-container">
|
||||||
|
<div class="search-wrap fix">
|
||||||
|
<span class="tip">
|
||||||
|
应用管理(open_api_app):ANON=仅内部入口免签,仍须带 X-App-Key;appKey 填 <b>*</b> =
|
||||||
|
匿名窗口(无票据放行,≤31 天强制到期);改回 SIGNED 前先确认密钥链有该键。
|
||||||
|
</span>
|
||||||
|
<div class="fr">
|
||||||
|
<Button type="primary" @click="openCreate">新增应用</Button>
|
||||||
|
<Button class="ml8" @click="refresh">刷新</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :dataFilter="normalizeRows">
|
||||||
|
<template #appKey="{ row }">
|
||||||
|
<span v-if="row.appKey === '*'" class="window-tag">*(匿名窗口)</span>
|
||||||
|
<span v-else class="mono">{{ row.appKey }}</span>
|
||||||
|
</template>
|
||||||
|
<template #appName="{ row }">
|
||||||
|
<Input v-model="row.appName" size="small" placeholder="-" />
|
||||||
|
</template>
|
||||||
|
<template #authMode="{ row }">
|
||||||
|
<Select v-model="row.authMode" size="small" style="width: 110px"
|
||||||
|
@on-change="onModeChange(row)">
|
||||||
|
<Option value="SIGNED">SIGNED</Option>
|
||||||
|
<Option value="ANON">ANON</Option>
|
||||||
|
</Select>
|
||||||
|
</template>
|
||||||
|
<template #expireAt="{ row }">
|
||||||
|
<template v-if="row.authMode === 'ANON'">
|
||||||
|
<Input v-model="row.expireAtText" size="small" placeholder="yyyy-MM-dd HH:mm:ss"
|
||||||
|
style="width: 170px" :class="expireClass(row)" />
|
||||||
|
<div :class="expireClass(row)" class="expire-hint">{{ expireHint(row) }}</div>
|
||||||
|
</template>
|
||||||
|
<span v-else class="expire-none">-</span>
|
||||||
|
</template>
|
||||||
|
<template #enabled="{ row }">
|
||||||
|
<i-switch v-model="row.enabled" size="small" />
|
||||||
|
</template>
|
||||||
|
<template #remark="{ row }">
|
||||||
|
<Input v-model="row.remark" size="small" placeholder="-" />
|
||||||
|
</template>
|
||||||
|
<template #gmtModified="{ row }">
|
||||||
|
<span>{{ row.gmtModified ? Utils.dateFormat(row.gmtModified, 'yyyy-MM-dd HH:mm:ss') : '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</TablePageAlovaNew>
|
||||||
|
|
||||||
|
<Modal v-model="createShow" :mask-closable="false" width="560" title="新增应用">
|
||||||
|
<Form :label-width="110" class="app-create-form">
|
||||||
|
<FormItem label="appKey:" required>
|
||||||
|
<Input v-model="createForm.appKey" placeholder="调用方 appKey(唯一;填 * = 匿名窗口行)" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="展示名:">
|
||||||
|
<Input v-model="createForm.appName" placeholder="可选" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="authMode:" required>
|
||||||
|
<Select v-model="createForm.authMode" style="width: 180px">
|
||||||
|
<Option value="SIGNED">SIGNED(验签,缺省)</Option>
|
||||||
|
<Option value="ANON">ANON(内部入口免签)</Option>
|
||||||
|
</Select>
|
||||||
|
</FormItem>
|
||||||
|
<FormItem v-if="createForm.authMode === 'ANON'" label="免签到期:" required>
|
||||||
|
<Input v-model="createForm.expireAt" placeholder="yyyy-MM-dd HH:mm:ss(必填,≤31 天)" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="备注:">
|
||||||
|
<Input v-model="createForm.remark" placeholder="可选" />
|
||||||
|
</FormItem>
|
||||||
|
<div class="form-tip" v-if="createForm.authMode === 'ANON'">
|
||||||
|
免签最长 31 天(每次保存重新起算),到期惰性恢复验签;ANON 应用不要求密钥链有键。
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
<template #footer>
|
||||||
|
<Button @click="createShow = false">取消</Button>
|
||||||
|
<Button type="primary" :loading="creating" @click="submitCreate">确定</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue';
|
||||||
|
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||||
|
import { actionColumn } from '@/utils/tableActions';
|
||||||
|
import Utils from '@/utils';
|
||||||
|
|
||||||
|
defineOptions({ name: 'AppList' });
|
||||||
|
|
||||||
|
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
|
const savingId = ref<number | null>(null);
|
||||||
|
|
||||||
|
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
|
||||||
|
Apis.GatewayAdmin.apps({ params, cacheFor: null });
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: 'id', key: 'id', width: 70, align: 'center' },
|
||||||
|
{ title: 'appKey', slot: 'appKey', width: 160 },
|
||||||
|
{ title: '展示名', slot: 'appName', width: 150 },
|
||||||
|
{ title: 'authMode', slot: 'authMode', width: 130 },
|
||||||
|
{ title: '免签到期', slot: 'expireAt', minWidth: 210 },
|
||||||
|
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
|
||||||
|
{ title: '备注', slot: 'remark', minWidth: 140 },
|
||||||
|
{ title: '更新时间', slot: 'gmtModified', width: 170 },
|
||||||
|
actionColumn(
|
||||||
|
[
|
||||||
|
// 保存按行数据置灰保位(§5.2 红线):本行提交中即灰,位置恒在
|
||||||
|
{ key: 'save', label: '保存', on: (r: any) => save(r), disabledWhen: (r: any) => savingId.value === r.id },
|
||||||
|
],
|
||||||
|
{ mode: 'text', width: 90, fixed: 'right' },
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** 服务端 expireAt 为 ISO 串:补一行内编辑用的 "yyyy-MM-dd HH:mm:ss" 文本 */
|
||||||
|
function normalizeRows(rows: any[]) {
|
||||||
|
return (rows || []).map((r: any) => ({
|
||||||
|
...r,
|
||||||
|
expireAtText: r.expireAt ? Utils.dateFormat(r.expireAt, 'yyyy-MM-dd HH:mm:ss') : '',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAY_MS = 24 * 3600 * 1000;
|
||||||
|
const MAX_TTL_DAYS = 31;
|
||||||
|
|
||||||
|
/** 到期可视化:已过期标红、临期(≤3 天)标橙 */
|
||||||
|
function expireState(row: any): 'expired' | 'soon' | 'ok' {
|
||||||
|
if (!row.expireAt) return 'expired';
|
||||||
|
const remain = new Date(String(row.expireAt).replace(/-/g, '/').replace(/T/, ' ')).getTime() - Date.now();
|
||||||
|
if (remain <= 0) return 'expired';
|
||||||
|
if (remain <= 3 * DAY_MS) return 'soon';
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
function expireClass(row: any) {
|
||||||
|
const s = expireState(row);
|
||||||
|
return { 'expire-danger': s === 'expired', 'expire-warning': s === 'soon' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expireHint(row: any) {
|
||||||
|
const s = expireState(row);
|
||||||
|
if (s === 'expired') return '已过期(免签失效,等同无行)';
|
||||||
|
if (s === 'soon') return '临期,续期请重新保存';
|
||||||
|
return `有效期 ≤${MAX_TTL_DAYS} 天,续期重新起算`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 切到 SIGNED 即视为放弃免签:清空行内到期文本(后端落库置 NULL) */
|
||||||
|
function onModeChange(row: any) {
|
||||||
|
if (row.authMode !== 'ANON') {
|
||||||
|
row.expireAtText = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
tableRef.value?.refreshData();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 行内编辑:只提交治理字段(appKey 是行身份,后端不接受改动);ANON 行连同到期一起提交 */
|
||||||
|
async function save(row: any) {
|
||||||
|
savingId.value = row.id;
|
||||||
|
try {
|
||||||
|
const data: Record<string, any> = {
|
||||||
|
appName: row.appName,
|
||||||
|
authMode: row.authMode,
|
||||||
|
enabled: row.enabled,
|
||||||
|
remark: row.remark,
|
||||||
|
};
|
||||||
|
if (row.authMode === 'ANON') {
|
||||||
|
data.expireAt = row.expireAtText;
|
||||||
|
}
|
||||||
|
const d: any = await Apis.GatewayAdmin.updateApp({
|
||||||
|
pathParams: { id: row.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
Message.success(`已保存,应用表加载 ${d?.apps ?? '-'} 条`);
|
||||||
|
} catch (e: any) {
|
||||||
|
// 错误提示由 alova 全局 onBizError 弹出,这里只做回滚,避免双 toast
|
||||||
|
tableRef.value?.refreshData();
|
||||||
|
} finally {
|
||||||
|
savingId.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 新增应用弹窗(O3:治理页闭环,不留"只有 UI 没有后端"断层) */
|
||||||
|
const createShow = ref(false);
|
||||||
|
const creating = ref(false);
|
||||||
|
const createForm = reactive({
|
||||||
|
appKey: '',
|
||||||
|
appName: '',
|
||||||
|
authMode: 'ANON',
|
||||||
|
expireAt: '',
|
||||||
|
remark: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
createForm.appKey = '';
|
||||||
|
createForm.appName = '';
|
||||||
|
createForm.authMode = 'ANON';
|
||||||
|
createForm.expireAt = '';
|
||||||
|
createForm.remark = '';
|
||||||
|
createShow.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitCreate() {
|
||||||
|
if (!createForm.appKey || !createForm.appKey.trim()) {
|
||||||
|
Message.warning('appKey 必填');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (createForm.authMode === 'ANON' && !createForm.expireAt.trim()) {
|
||||||
|
Message.warning('authMode=ANON 时免签到期必填(≤31 天)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
creating.value = true;
|
||||||
|
try {
|
||||||
|
const d: any = await Apis.GatewayAdmin.createApp({ data: { ...createForm } });
|
||||||
|
Message.success(`已创建,应用表加载 ${d?.apps ?? '-'} 条`);
|
||||||
|
createShow.value = false;
|
||||||
|
tableRef.value?.reloadData('add' as any);
|
||||||
|
} catch (e: any) {
|
||||||
|
// 错误提示由 alova 全局 onBizError 弹出
|
||||||
|
} finally {
|
||||||
|
creating.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.search-wrap {
|
||||||
|
line-height: 32px;
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
color: #848c98;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
b {
|
||||||
|
color: #ed4014;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ml8 {
|
||||||
|
margin-left: 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.mono {
|
||||||
|
font-family: Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.window-tag {
|
||||||
|
color: #ff9900;
|
||||||
|
font-family: Menlo, Consolas, monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expire-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expire-none {
|
||||||
|
color: #c5c8ce;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expire-warning,
|
||||||
|
.expire-warning.expire-hint {
|
||||||
|
color: #ff9900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expire-danger,
|
||||||
|
.expire-danger.expire-hint {
|
||||||
|
color: #ed4014;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-tip {
|
||||||
|
margin: -6px 0 0 110px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #848c98;
|
||||||
|
line-height: 18px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-wrapper">
|
<div class="page-wrapper yh-fill">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<Input v-model="dataParams.targetCode" placeholder="target(如 notice)" clearable
|
<Input v-model="dataParams.targetCode" placeholder="target(如 notice)" clearable
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<Button class="ml-12" @click="handleReset">重置</Button>
|
<Button class="ml-12" @click="handleReset">重置</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LedgerDetailModal v-model="detailVisible" :row="currentRow" />
|
<LedgerDetailModal v-model="detailVisible" :row="currentRow" />
|
||||||
@@ -21,15 +21,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
|
||||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||||
import LedgerDetailModal from './LedgerDetailModal.vue';
|
import LedgerDetailModal from './LedgerDetailModal.vue';
|
||||||
|
import { actionColumn } from '@/utils/tableActions';
|
||||||
import Utils from '@/utils';
|
import Utils from '@/utils';
|
||||||
|
|
||||||
defineOptions({ name: 'LedgerList' });
|
defineOptions({ name: 'LedgerList' });
|
||||||
|
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
|
|
||||||
interface DataParams {
|
interface DataParams {
|
||||||
@@ -63,8 +61,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) =>
|
|||||||
cacheFor: null,
|
cacheFor: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
|
|
||||||
|
|
||||||
/** 状态码色阶:2xx 绿 / 4xx 琥珀 / 5xx 红 */
|
/** 状态码色阶:2xx 绿 / 4xx 琥珀 / 5xx 红 */
|
||||||
function statusClass(status: number) {
|
function statusClass(status: number) {
|
||||||
if (status >= 500) return 'text-error';
|
if (status >= 500) return 'text-error';
|
||||||
@@ -112,18 +108,12 @@ const columns = [
|
|||||||
align: 'right',
|
align: 'right',
|
||||||
render: (h: any, { row }: any) => h('span', row.costMs != null ? `${row.costMs} ms` : '-'),
|
render: (h: any, { row }: any) => h('span', row.costMs != null ? `${row.costMs} ms` : '-'),
|
||||||
},
|
},
|
||||||
{
|
actionColumn(
|
||||||
title: '操作',
|
[
|
||||||
key: 'action',
|
{ key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
|
||||||
width: 100,
|
],
|
||||||
align: 'center',
|
{ mode: 'text', width: 100, fixed: 'right' },
|
||||||
fixed: 'right' as const,
|
),
|
||||||
render: (h: any, { row }: any) =>
|
|
||||||
h('a', {
|
|
||||||
style: { color: '#577afb' },
|
|
||||||
onClick: () => openDetail(row),
|
|
||||||
}, '查看详情'),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
<template>
|
||||||
|
<div class="main-wrapper">
|
||||||
|
<div class="main-container">
|
||||||
|
<div class="search-wrap fix">
|
||||||
|
<span class="tip">
|
||||||
|
代理测试:输入票据与 oapi 地址,网关服务端签名后回环打真实链路(内部入口口径,一律验签)。
|
||||||
|
observe=true 时 200 不代表验签通过,最终判定看「访问台账」。
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="proxy-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<Select v-model="form.method" style="width: 110px">
|
||||||
|
<Option v-for="m in METHODS" :key="m" :value="m">{{ m }}</Option>
|
||||||
|
</Select>
|
||||||
|
<Input v-model="form.url" class="grow" placeholder="http://网关地址/{target}/oapi/**,如 /userbase/oapi/user/byId?id=1" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<Input v-model="form.appKey" placeholder="appKey" style="width: 200px" />
|
||||||
|
<Input v-model="form.appSecret" type="password" placeholder="appSecret(留空 = 由网关密钥链解析)" style="width: 320px" />
|
||||||
|
<Input v-model="form.contentType" class="grow" placeholder="Content-Type" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row" v-if="hasBody">
|
||||||
|
<Input v-model="form.body" type="textarea" :rows="6" placeholder="JSON 请求体(可选)" />
|
||||||
|
</div>
|
||||||
|
<div class="form-row">
|
||||||
|
<Button type="primary" :loading="sending" @click="send">发送</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="result" v-if="result">
|
||||||
|
<div class="status-line">
|
||||||
|
<Tag :color="statusColor(result.status)">{{ result.status }}</Tag>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span>{{ result.latencyMs }}ms</span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span>target={{ result.target }}</span>
|
||||||
|
<span class="dot">·</span>
|
||||||
|
<span>{{ result.contentType || '-' }}</span>
|
||||||
|
<Tag v-if="result.bodyTruncated" color="warning">响应体超 256KB 已截断</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<code class="code-line request-url">{{ result.requestUrl }}</code>
|
||||||
|
|
||||||
|
<div v-if="result.error" class="error-line">{{ result.error }}</div>
|
||||||
|
|
||||||
|
<div class="sign-block">
|
||||||
|
<div class="block-title">签名调试</div>
|
||||||
|
<div class="kv-line">
|
||||||
|
<span class="k">appKey</span>
|
||||||
|
<span>{{ result.signature.appKey }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kv-line">
|
||||||
|
<span class="k">timestamp</span>
|
||||||
|
<span>{{ result.signature.timestamp }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kv-line">
|
||||||
|
<span class="k">secretSource</span>
|
||||||
|
<span>{{ result.signature.secretSource }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="kv-line">
|
||||||
|
<span class="k">canonical</span>
|
||||||
|
<code class="code-line grow">{{ result.signature.canonical }}</code>
|
||||||
|
<Button size="small" @click="copyText(result.signature.canonical)">复制</Button>
|
||||||
|
</div>
|
||||||
|
<div class="kv-line">
|
||||||
|
<span class="k">signature</span>
|
||||||
|
<code class="code-line grow">{{ result.signature.signature }}</code>
|
||||||
|
<Button size="small" @click="copyText(result.signature.signature)">复制</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="block-title">响应体</div>
|
||||||
|
<pre class="res-body">{{ prettyBody }}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, computed } from 'vue';
|
||||||
|
|
||||||
|
defineOptions({ name: 'ProxyTest' });
|
||||||
|
|
||||||
|
const METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
method: 'GET',
|
||||||
|
url: '',
|
||||||
|
appKey: '',
|
||||||
|
appSecret: '',
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasBody = computed(() => ['POST', 'PUT', 'DELETE', 'PATCH'].includes(form.method));
|
||||||
|
const sending = ref(false);
|
||||||
|
const result = ref<any>(null);
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
if (!form.url.trim() || !form.appKey.trim()) {
|
||||||
|
Message.warning('url 与 appKey 必填');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sending.value = true;
|
||||||
|
result.value = null;
|
||||||
|
try {
|
||||||
|
const d: any = await Apis.GatewayAdmin.proxyTest({
|
||||||
|
data: {
|
||||||
|
url: form.url,
|
||||||
|
method: form.method,
|
||||||
|
appKey: form.appKey,
|
||||||
|
appSecret: form.appSecret || undefined, // 空串不传 → 后端走 REDIS 解析
|
||||||
|
contentType: hasBody.value ? form.contentType : undefined,
|
||||||
|
body: hasBody.value && form.body.trim() ? form.body : undefined,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
result.value = d;
|
||||||
|
} catch (e) {
|
||||||
|
// code=2 业务错由 alova 全局 onBizError 统一弹 toast
|
||||||
|
} finally {
|
||||||
|
sending.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(s: number): string {
|
||||||
|
if (s >= 200 && s < 300) return 'green';
|
||||||
|
if (s >= 400 && s < 500) return 'warning';
|
||||||
|
if (s >= 500) return 'error';
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
const prettyBody = computed(() => {
|
||||||
|
const b = result.value?.body ?? '';
|
||||||
|
try {
|
||||||
|
return JSON.stringify(JSON.parse(b), null, 2);
|
||||||
|
} catch {
|
||||||
|
return b;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function copyText(t: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(t);
|
||||||
|
Message.success('已复制');
|
||||||
|
} catch {
|
||||||
|
Message.error('复制失败,请手动选中复制');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.search-wrap {
|
||||||
|
line-height: 32px;
|
||||||
|
|
||||||
|
.tip {
|
||||||
|
color: #848c98;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.proxy-form {
|
||||||
|
margin: 14px 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grow {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.result {
|
||||||
|
.status-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
|
||||||
|
.dot {
|
||||||
|
color: #c5c8ce;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-line {
|
||||||
|
display: block;
|
||||||
|
background: #f5f7f9;
|
||||||
|
border: 1px solid #e3e8ee;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-family: Consolas, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #2d8cf0;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-url {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-line {
|
||||||
|
color: #ed4014;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sign-block {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-title {
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kv-line {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
|
||||||
|
.k {
|
||||||
|
flex: none;
|
||||||
|
width: 90px;
|
||||||
|
color: #848c98;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-line {
|
||||||
|
flex: 1;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.res-body {
|
||||||
|
background: #f5f7f9;
|
||||||
|
border: 1px solid #e3e8ee;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: Consolas, Menlo, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-wrapper">
|
<div class="page-wrapper yh-fill">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<span class="tip">路由注册表(open_api_route):治理旋钮行内编辑,保存后网关热重载,无须重启。</span>
|
<span class="tip">路由注册表(open_api_route):治理旋钮行内编辑,保存后网关热重载,无须重启。qps>0 时按 调用方×本行 限流(超限裸 429,0=不限);timeout/bodyLimit 立即生效。</span>
|
||||||
<Button type="primary" class="fr" @click="refresh">刷新</Button>
|
<Button type="primary" class="fr" @click="refresh">刷新</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight">
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns">
|
||||||
<template #visibility="{ row }">
|
<template #visibility="{ row }">
|
||||||
<Select v-model="row.visibility" size="small" style="width: 110px">
|
<Select v-model="row.visibility" size="small" style="width: 110px">
|
||||||
<Option value="INTERNAL">INTERNAL</Option>
|
<Option value="INTERNAL">INTERNAL</Option>
|
||||||
@@ -31,15 +31,18 @@
|
|||||||
<template #rateLimitQps="{ row }">
|
<template #rateLimitQps="{ row }">
|
||||||
<Input v-model="row.rateLimitQps" type="number" size="small" style="width: 80px" />
|
<Input v-model="row.rateLimitQps" type="number" size="small" style="width: 80px" />
|
||||||
</template>
|
</template>
|
||||||
|
<template #timeoutMs="{ row }">
|
||||||
|
<Input v-model="row.timeoutMs" type="number" size="small" style="width: 90px" placeholder="1000-120000" />
|
||||||
|
</template>
|
||||||
|
<template #requestBodyLimitMb="{ row }">
|
||||||
|
<Input v-model="row.requestBodyLimitMb" type="number" size="small" style="width: 90px" placeholder="1-100" />
|
||||||
|
</template>
|
||||||
<template #enabled="{ row }">
|
<template #enabled="{ row }">
|
||||||
<i-switch v-model="row.enabled" size="small" />
|
<i-switch v-model="row.enabled" size="small" />
|
||||||
</template>
|
</template>
|
||||||
<template #remark="{ row }">
|
<template #remark="{ row }">
|
||||||
<Input v-model="row.remark" size="small" placeholder="-" />
|
<Input v-model="row.remark" size="small" placeholder="-" />
|
||||||
</template>
|
</template>
|
||||||
<template #action="{ row }">
|
|
||||||
<Button type="primary" size="small" :loading="savingId === row.id" @click="save(row)">保存</Button>
|
|
||||||
</template>
|
|
||||||
</TablePageAlovaNew>
|
</TablePageAlovaNew>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -47,21 +50,17 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
|
||||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||||
|
import { actionColumn } from '@/utils/tableActions';
|
||||||
|
|
||||||
defineOptions({ name: 'RouteList' });
|
defineOptions({ name: 'RouteList' });
|
||||||
|
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
const savingId = ref<number | null>(null);
|
const savingId = ref<number | null>(null);
|
||||||
|
|
||||||
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
|
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
|
||||||
Apis.GatewayAdmin.routes({ params, cacheFor: null });
|
Apis.GatewayAdmin.routes({ params, cacheFor: null });
|
||||||
|
|
||||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: 'id', key: 'id', width: 70, align: 'center' },
|
{ title: 'id', key: 'id', width: 70, align: 'center' },
|
||||||
{ title: 'target', key: 'targetCode', width: 120, ellipsis: true, tooltip: true },
|
{ title: 'target', key: 'targetCode', width: 120, ellipsis: true, tooltip: true },
|
||||||
@@ -72,11 +71,17 @@ const columns = [
|
|||||||
{ title: 'cacheTtl(s)', slot: 'cacheTtlSeconds', width: 105 },
|
{ title: 'cacheTtl(s)', slot: 'cacheTtlSeconds', width: 105 },
|
||||||
{ title: 'cacheScope', slot: 'cacheScope', width: 120 },
|
{ title: 'cacheScope', slot: 'cacheScope', width: 120 },
|
||||||
{ title: 'qps', slot: 'rateLimitQps', width: 90 },
|
{ title: 'qps', slot: 'rateLimitQps', width: 90 },
|
||||||
{ title: 'timeout(ms)', key: 'timeoutMs', width: 105, align: 'right' },
|
{ title: 'timeout(ms)', slot: 'timeoutMs', width: 115, align: 'right' },
|
||||||
{ title: 'bodyLimit(mb)', key: 'requestBodyLimitMb', width: 115, align: 'right' },
|
{ title: 'bodyLimit(mb)', slot: 'requestBodyLimitMb', width: 125, align: 'right' },
|
||||||
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
|
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
|
||||||
{ title: '备注', slot: 'remark', minWidth: 160 },
|
{ title: '备注', slot: 'remark', minWidth: 160 },
|
||||||
{ title: '操作', slot: 'action', width: 90, align: 'center', fixed: 'right' as const },
|
actionColumn(
|
||||||
|
[
|
||||||
|
// 保存按行数据置灰保位(§5.2 红线):本行提交中即灰,位置恒在
|
||||||
|
{ key: 'save', label: '保存', on: (r: any) => save(r), disabledWhen: (r: any) => savingId.value === r.id },
|
||||||
|
],
|
||||||
|
{ mode: 'text', width: 90, fixed: 'right' },
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
@@ -95,6 +100,8 @@ async function save(row: any) {
|
|||||||
cacheTtlSeconds: Number(row.cacheTtlSeconds) || 0,
|
cacheTtlSeconds: Number(row.cacheTtlSeconds) || 0,
|
||||||
cacheScope: row.cacheScope,
|
cacheScope: row.cacheScope,
|
||||||
rateLimitQps: Number(row.rateLimitQps) || 0,
|
rateLimitQps: Number(row.rateLimitQps) || 0,
|
||||||
|
timeoutMs: Number(row.timeoutMs) || 30000,
|
||||||
|
requestBodyLimitMb: Number(row.requestBodyLimitMb) || 10,
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
remark: row.remark,
|
remark: row.remark,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-wrapper">
|
<div class="page-wrapper yh-fill">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<span class="tip">目标地址(open_api_target):base_url 与启停行内编辑,保存后网关热重载 target 快照。</span>
|
<span class="tip">目标地址(open_api_target):base_url 与启停行内编辑,保存后网关热重载 target 快照。</span>
|
||||||
<Button type="primary" class="fr" @click="refresh">刷新</Button>
|
<Button type="primary" class="fr" @click="refresh">刷新</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight">
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns">
|
||||||
<template #baseUrl="{ row }">
|
<template #baseUrl="{ row }">
|
||||||
<Input v-model="row.baseUrl" size="small" placeholder="http://{target}-service:{port}" />
|
<Input v-model="row.baseUrl" size="small" placeholder="http://{target}-service:{port}" />
|
||||||
</template>
|
</template>
|
||||||
@@ -16,9 +16,6 @@
|
|||||||
<template #gmtModified="{ row }">
|
<template #gmtModified="{ row }">
|
||||||
<span>{{ row.gmtModified ? Utils.dateFormat(row.gmtModified, 'yyyy-MM-dd HH:mm:ss') : '-' }}</span>
|
<span>{{ row.gmtModified ? Utils.dateFormat(row.gmtModified, 'yyyy-MM-dd HH:mm:ss') : '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #action="{ row }">
|
|
||||||
<Button type="primary" size="small" :loading="savingId === row.id" @click="save(row)">保存</Button>
|
|
||||||
</template>
|
|
||||||
</TablePageAlovaNew>
|
</TablePageAlovaNew>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,29 +23,31 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
|
||||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||||
|
import { actionColumn } from '@/utils/tableActions';
|
||||||
import Utils from '@/utils';
|
import Utils from '@/utils';
|
||||||
|
|
||||||
defineOptions({ name: 'TargetList' });
|
defineOptions({ name: 'TargetList' });
|
||||||
|
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
const savingId = ref<number | null>(null);
|
const savingId = ref<number | null>(null);
|
||||||
|
|
||||||
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
|
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
|
||||||
Apis.GatewayAdmin.targets({ params, cacheFor: null });
|
Apis.GatewayAdmin.targets({ params, cacheFor: null });
|
||||||
|
|
||||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: 'id', key: 'id', width: 70, align: 'center' },
|
{ title: 'id', key: 'id', width: 70, align: 'center' },
|
||||||
{ title: 'target', key: 'targetCode', width: 140, ellipsis: true, tooltip: true },
|
{ title: 'target', key: 'targetCode', width: 140, ellipsis: true, tooltip: true },
|
||||||
{ title: 'base_url', slot: 'baseUrl', minWidth: 300 },
|
{ title: 'base_url', slot: 'baseUrl', minWidth: 300 },
|
||||||
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
|
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
|
||||||
{ title: '更新时间', slot: 'gmtModified', width: 180 },
|
{ title: '更新时间', slot: 'gmtModified', width: 180 },
|
||||||
{ title: '操作', slot: 'action', width: 90, align: 'center', fixed: 'right' as const },
|
actionColumn(
|
||||||
|
[
|
||||||
|
// 保存按行数据置灰保位(§5.2 红线):本行提交中即灰,位置恒在
|
||||||
|
{ key: 'save', label: '保存', on: (r: any) => save(r), disabledWhen: (r: any) => savingId.value === r.id },
|
||||||
|
],
|
||||||
|
{ mode: 'text', width: 90, fixed: 'right' },
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noFallthroughCasesInSwitch": true,
|
"noFallthroughCasesInSwitch": true,
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"types": ["vite/client"]
|
"types": ["vite/client", "node"]
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue","types"],
|
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue","types"],
|
||||||
|
|
||||||
|
|||||||
+53
-2
@@ -1,4 +1,4 @@
|
|||||||
import { ConfigEnv, UserConfig, defineConfig } from "vite";
|
import { ConfigEnv, UserConfig, defineConfig, type Plugin } from "vite";
|
||||||
import { createHtmlPlugin } from "vite-plugin-html";
|
import { createHtmlPlugin } from "vite-plugin-html";
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
import path, { resolve } from "path";
|
import path, { resolve } from "path";
|
||||||
@@ -9,6 +9,35 @@ import UnoCSS from "@unocss/vite";
|
|||||||
import presetUno from "@unocss/preset-uno";
|
import presetUno from "@unocss/preset-uno";
|
||||||
import presetRemToPx from "@unocss/preset-rem-to-px";
|
import presetRemToPx from "@unocss/preset-rem-to-px";
|
||||||
|
|
||||||
|
// mock 数据层(仅 --mode mock 注册,见下方 gatewayAdminMockPlugin;零依赖手写中间件而非
|
||||||
|
// vite-plugin-mock:后者独立打包 mock 文件为第二模块实例,无法实现"整页刷新重置内存态")
|
||||||
|
import { handleGatewayMockRequest, resetGatewayMockState } from "./src/assets/mock/gatewayApi";
|
||||||
|
|
||||||
|
/** 内置 mock(仅 serve + --mode mock):拦截 /open-gateway/api/internal/** 按后端契约应答。
|
||||||
|
* 直接注册在 configureServer(先于 vite 内部中间件,含 /open-gateway/api 代理);
|
||||||
|
* 整页文档请求(刷新/直达)触发内存态重置回静态种子,SPA 站内切换不重置。 */
|
||||||
|
function gatewayAdminMockPlugin(): Plugin {
|
||||||
|
return {
|
||||||
|
name: "gateway-admin-mock",
|
||||||
|
apply: "serve",
|
||||||
|
configureServer(server) {
|
||||||
|
server.middlewares.use((req, _res, next) => {
|
||||||
|
const accept = String(req.headers.accept ?? "");
|
||||||
|
if (accept.includes("text/html")) resetGatewayMockState();
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
server.middlewares.use(async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
if (!(await handleGatewayMockRequest(req, res))) next();
|
||||||
|
} catch (e) {
|
||||||
|
next(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 构建输出恒为 dist 根:CI 打包 zip 的根目录必须直接是 index.html(projman 前端发布校验 zip 根含 html)。
|
// 构建输出恒为 dist 根:CI 打包 zip 的根目录必须直接是 index.html(projman 前端发布校验 zip 根含 html)。
|
||||||
// 不再有 dist/base 中间层——outDir=dist/base 时 zip 打 dist 整层,zip 根只有 base/,发布会校验失败。
|
// 不再有 dist/base 中间层——outDir=dist/base 时 zip 打 dist 整层,zip 根只有 base/,发布会校验失败。
|
||||||
// dev(--mode dev)读 .env.dev,VITE_SERVICE_HOST 指向同源前缀 /open-gateway(经下方 proxy 打到 base 域)
|
// dev(--mode dev)读 .env.dev,VITE_SERVICE_HOST 指向同源前缀 /open-gateway(经下方 proxy 打到 base 域)
|
||||||
@@ -17,6 +46,8 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
plugins: [
|
plugins: [
|
||||||
|
// 仅 --mode mock 启用内置 mock;dev/联调/正式 build 行为零变化
|
||||||
|
...(mode === "mock" ? [gatewayAdminMockPlugin()] : []),
|
||||||
vue(),
|
vue(),
|
||||||
createSvgIconsPlugin({
|
createSvgIconsPlugin({
|
||||||
iconDirs: [resolve(process.cwd(), "src/assets/icons")],
|
iconDirs: [resolve(process.cwd(), "src/assets/icons")],
|
||||||
@@ -90,9 +121,29 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
|||||||
port: 8131,
|
port: 8131,
|
||||||
// 本地联调:/open-gateway/api/* → base 域(Caddy 剥前缀后转发到网关容器网内 10013)
|
// 本地联调:/open-gateway/api/* → base 域(Caddy 剥前缀后转发到网关容器网内 10013)
|
||||||
// 走代理而非跨源直连:网关无 CORS,且 10013 仅绑容器网内 127.0.0.1
|
// 走代理而非跨源直连:网关无 CORS,且 10013 仅绑容器网内 127.0.0.1
|
||||||
|
// 设 YH_GW_PROXY_TARGET(如 http://localhost:10013)= 不经 base 域直打本地网关,
|
||||||
|
// 此时剥掉 /open-gateway 前缀(原本由 Caddy 剥);不设则行为与线上联调零变化
|
||||||
proxy: {
|
proxy: {
|
||||||
"/open-gateway/api": {
|
"/open-gateway/api": {
|
||||||
target: "http://base.yunhaixingtong.com",
|
target: process.env.YH_GW_PROXY_TARGET || "http://base.yunhaixingtong.com",
|
||||||
|
changeOrigin: true,
|
||||||
|
...(process.env.YH_GW_PROXY_TARGET
|
||||||
|
? { rewrite: (p: string) => p.replace(/^\/open-gateway/, "") }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
// 主题通道本地联调(照 Notice 配方):index.html loader 走同源相对 /static/js/yh-theme-init.js,
|
||||||
|
// manifest/CSS 经此代理到中央通道;base 域不可达(无 VPN)时由 npm run dev:mock
|
||||||
|
// (scripts/start-mock.mjs)以 YH_THEME_PROXY_TARGET 把目标覆盖为本地 9527 静态服务。
|
||||||
|
"/static": {
|
||||||
|
target: process.env.YH_THEME_PROXY_TARGET || "http://base.yunhaixingtong.com",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
// 超管门槛探测(/userbase/base/User/CurrentUser,见 src/utils/superGate.ts):
|
||||||
|
// 生产页面与 userbase 同挂 base 域、同源相对路径直连;本地 dev 经此代理兜到
|
||||||
|
// base 域(无平台 Cookie 时探测失败按放行处理,可用 VITE_SUPER_GATE=off 旁路),
|
||||||
|
// dev:mock 由内置 mock 中间件先于代理应答(YH_MOCK_SUPER=0 演示非超管态)。
|
||||||
|
"/userbase": {
|
||||||
|
target: process.env.YH_UB_PROXY_TARGET || "http://base.yunhaixingtong.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user