Compare commits

16 Commits
Author SHA1 Message Date
ops 808f02bac7 fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:28 +00:00
ops 6701cbac76 ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:36:59 +00:00
chenxing 90338f3980 对接规范落地:版本锁1.3.24/操作列actionColumn text/表格撑满新模型/撤配色切换器/超管门槛+外观三键
build / build (push) Successful in 2m54s
2026-10-11 21:58:08 +08:00
chenxing 729ab9592d feat(sso): 治理面接入 CAS 登录往返——标记头+code302 整页跳转+记账回页签
build / build (push) Successful in 1m32s
- alova-core 通用头补 yschapirequest:1:后端 CAS 门据此对 SPA 出 200+{code:302},
  errorHandle 的 case 302 整页跳登录,跳转前 rememberReturnUrl 记账、返回挂起
  Promise 不再触发全局错误弹窗(Notice 同款)
- ssoReturn.ts:回跳目标记 sessionStorage(同页签跨站往返仍在),只认同 origin
  同壳页的地址防开放重定向;不照 Notice 拼 hex 进 service——本网关 ctx 为空、
  外链前缀被 Caddy 剥离,固定 service 与每次不同的目标不可共存(会票据校验死循环)
- main.ts 挂载前 consumeSsoReturnUrl 换回原页签,避免回跳闪默认页
- 重载注册表按钮改走浏览器面孪生入口 /api/internal/admin/routes/reload
  (机器面 /routes/refresh 收口内部令牌后浏览器再打会被 401 拦死)
- 顶栏登出:ssoHost 取自已登录会话 /admin/session(后端无 env-config 端点),
  取不到不渲染;mock 同步补 reload/session 两端点
2026-10-11 12:50:23 +08:00
chenxing a036f39713 chore(theme): YschTheme sync 件更新(版本 20261009-1107-1688389)
build / build (push) Successful in 1m51s
2026-10-09 11:13:06 +08:00
chenxing 3ed3af3962 chore(theme): theme.ts 同步通道 1038 版本戳(LOGO文字与主题JSON化) 2026-10-09 10:49:42 +08:00
chenxing 3acbc79008 chore(theme): YschTheme sync 件更新(版本 20261009-1018-fb1c7f6) 2026-10-09 10:38:22 +08:00
chenxing 85a7f563f6 chore(theme): theme.ts 同步通道新版本戳(LOGO文字与主题JSON化) 2026-10-09 10:36:33 +08:00
chenxing bed01788fd chore(theme): YschTheme sync 件更新(版本 20261009-0054-5732ee2) 2026-10-09 00:54:50 +08:00
chenxing 0dac2d3b6e chore(theme): YschTheme sync 件更新(版本 20261009-0036-19f025a) 2026-10-09 00:36:34 +08:00
chenxing 449ad16244 chore(theme): YschTheme sync 件更新(版本 20261008-2326-f7fb534,oapi 代理轮) 2026-10-09 00:23:43 +08:00
chenxing 77060325ef feat(mock): mock 演进轮——应用列表/代理测试页 + 网关 mock 数据扩充
- views/app/AppList.vue(新)+ views/proxytest/ProxyTest.vue(新):应用列表与代理测试演示页
- router/routes + apiDefinitions + App.vue/layout:新页路由与菜单接线
- assets/mock/gatewayApi.ts:mock 数据扩充(+136 行)
- RouteList:列表页联动增强
- vite.config:mock 相关配置调整
- theme.ts:sync 刷新至 20261006-2013-75c1d10 并补回本仓唯一允许适配(顶部 // @ts-nocheck,strict vue-tsc 契约);typecheck 零错误
2026-10-06 20:22:02 +08:00
chenxing 8a21d85036 docs: MOCK.md——mock 演示指南(一条命令/走查点/数据清单/契约对照/差异/主题说明) 2026-10-01 23:45:33 +08:00
chenxing ff0f79a473 fix(app): 照 Notice 锁定视口——html/body overflow hidden + main-wrapper 内部滚动 2026-10-01 23:44:10 +08:00
chenxing 5b886f4fc2 fix(typecheck): strict vue-tsc 通过——types 加 node、theme.ts 顶部 @ts-nocheck 本地适配 2026-10-01 23:14:43 +08:00
chenxing 1db85be875 feat(scripts): dev:mock 一键离线演示(本地主题通道 9527 + vite mock 8131) 2026-10-01 23:10:36 +08:00
29 changed files with 1501 additions and 528 deletions
+3
View File
@@ -5,3 +5,6 @@
# (/open-gateway/front/ → 首段 open-gateway → ${origin}/open-gateway)。
VITE_SERVICE_HOST=/open-gateway
VITE_IS_MOCK=false
# 超管门槛(src/utils/superGate.ts):本地无平台 Cookie 时 CurrentUser 探测失败按放行处理;
# 如需完全旁路门槛,取消下一行注释(仅 dev 生效,build 不读本文件)。
# VITE_SUPER_GATE=off
+19 -8
View File
@@ -19,7 +19,10 @@ jobs:
options: --network infra-infra-net
steps:
- 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 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -38,21 +41,29 @@ jobs:
- name: 构建前端(node 容器)
run: |
set -e
docker rm -f frontbuild 2>/dev/null || true
docker run -d --name frontbuild -w /build node:20 sleep 3600
docker cp "${PWD}/." frontbuild:/build/
docker exec frontbuild sh -c '
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# 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
cd /build
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 run build
'
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 到宿主工作目录
docker cp frontbuild:/build/dist ./dist
docker rm -f frontbuild
docker cp "$BUILD_CONTAINER":/build/dist ./dist
docker rm -f "$BUILD_CONTAINER"
echo "BUILD CHECK OK"
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
+87
View File
@@ -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` 快照已删除)。
+1 -1
View File
@@ -10,7 +10,7 @@
"dependencies": {
"alova": "^3.4.0",
"sass": "^1.64.1",
"view-ui-plus": "^1.3.16",
"view-ui-plus": "^1.3.24",
"vue": "^3.4.19",
"vue-router": "4"
},
+2 -1
View File
@@ -5,6 +5,7 @@
"type": "module",
"scripts": {
"dev": "vite --mode dev",
"dev:mock": "node scripts/start-mock.mjs",
"typecheck": "vue-tsc --noEmit",
"build": "vite build --mode base",
"build:base": "vite build --mode base",
@@ -13,7 +14,7 @@
"dependencies": {
"alova": "^3.4.0",
"sass": "^1.64.1",
"view-ui-plus": "^1.3.16",
"view-ui-plus": "^1.3.24",
"vue": "^3.4.19",
"vue-router": "4"
},
+88
View File
@@ -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
View File
@@ -1,16 +1,27 @@
<script setup lang="ts">
import { ref, onMounted, reactive } from "vue";
import { computed, ref, onMounted, reactive } from "vue";
import layout from "./components/layout.vue";
import { LayoutData } from "./types/Basics";
import { checkSuperAdmin, SuperGate } from "@/utils/superGate";
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 = [
{ name: "LedgerList", title: "访问台账", page: "/ledger" },
{ name: "RouteList", title: "路由注册表", page: "/routes" },
{ name: "TargetList", title: "目标地址", page: "/targets" },
{ name: "AppList", title: "应用管理", page: "/apps" },
{ name: "ProxyTest", title: "代理测试", page: "/proxy-test" },
];
const layoutData: LayoutData = reactive({
@@ -31,19 +42,28 @@ const reload = () => {
};
provide("reload", reload);
onMounted(() => {
onMounted(async () => {
layoutData.modList = modList;
// "/" 落点由 routes.ts 的 { path: "/", redirect: "/ledger" } 处理。
// 这里不能再用 currentRoute 判断后 replace:冷启动时初始导航尚未解析完,
// currentRoute 恒为 "/",会把 #/routes、#/targets 深链接(含刷新)全部弹回台账。
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;
</script>
<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">
<router-view v-if="!!isRouterAlive" v-slot="{ Component, route }">
<transition name="fade" mode="out-in">
@@ -51,6 +71,16 @@ const getRouterKey = (route: any) => route?.name;
</transition>
</router-view>
</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>
<style lang="scss">
@@ -63,11 +93,55 @@ const getRouterKey = (route: any) => route?.name;
opacity: 0;
}
/* 照 Notice 配方锁定视口:顶栏/侧栏/面包屑均 fixed,若 body 可滚,整页滚动会让内容
从 fixed 导航下方穿过(错位遮挡);改为 main-wrapper 内部滚动 */
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
}
#app {
height: 100%;
}
.base-wrap-main .main-wrapper {
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>
+8 -1
View File
@@ -11,6 +11,13 @@ export default {
'GatewayAdmin.updateRoute': ['PUT', '/api/internal/admin/routes/{id}'],
'GatewayAdmin.targets': ['GET', '/api/internal/admin/targets'],
'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.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'],
};
+163 -1
View File
@@ -15,6 +15,7 @@
* 由中间件调 resetGatewayMockState() 重置回种子;SPA 站内切换不重置。
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import { createHmac } from "node:crypto";
/* ============================ 类型(对齐后端 OpenApi DTO) ============================ */
@@ -344,6 +345,132 @@ function updateTarget(id: number, body: any): { code: number; msg: string; data:
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 内) ============================ */
/**
@@ -353,6 +480,27 @@ function updateTarget(id: number, body: any): { code: number; msg: string; data:
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);
@@ -371,10 +519,24 @@ export async function handleGatewayMockRequest(req: IncomingMessage, res: Server
send(res, ok(listLogs(q)));
return true;
}
if (api === "/api/internal/routes/refresh" && method === "POST") {
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) {
+18
View File
@@ -115,3 +115,21 @@ h4.subtitle {
margin-right: 4px;
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;
}
}
+23 -6
View File
@@ -1,4 +1,5 @@
<template>
<div class="table-region" ref="regionRef">
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
class="link-table table-bottom-box" @on-selection-change="selectChange">
<template #loading>
@@ -8,9 +9,10 @@
<slot :name="slotName" v-bind="slotProps" />
</template>
</Table>
</div>
<div class="page-panel">
<div class="fl align-middle">
<div class="fl ml-15 align-middle">
<slot :checkList="selectList" />
</div>
@@ -21,8 +23,8 @@
</template>
<script setup lang="ts">
import { computed, ref, useSlots } from 'vue'
import Utils from '@/utils'
import { computed, ref, useSlots, watch } from 'vue'
import { useElementHeight } from '@/utils/hook/useElementHeight'
import { OperatorEnum } from '@/types'
interface Props {
@@ -44,7 +46,10 @@ const emit = defineEmits<{
}>()
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 selectList = ref<number[]>([])
@@ -68,18 +73,30 @@ const {
total: res => props.formatResponse(res).total,
data: res => props.formatResponse(res).list,
initialPage: 1,
initialPageSize: 10,
// 页长记忆(bsch 应用页规范 §3:customPageCount localStorage)
initialPageSize: parseInt(localStorage.getItem('customPageCount') || '10') || 10,
immediate: props.autoLoad,
}
)
// 数据重载后清空勾选,避免批量操作用到旧选中行
watch(resp, () => {
if (selectList.value.length) {
selectList.value = []
emit('selection-change', selectList.value)
}
})
const list = computed(() => {
if (!resp.value) return []
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
})
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() {
page.value = 1
-274
View File
@@ -1,274 +0,0 @@
<script setup lang="ts">
/* 管理端配色切换器(yh-theme 定稿 07-seam-pure 顶栏「配色」胶囊,方案 §6.5)。
* 状态唯一来源:utils/theme(localStorage 持久化 + <html> 档位类名),与门户皮肤包正交。 */
import { onBeforeUnmount, onMounted, ref } from "vue";
import {
loadThemeState,
saveThemeState,
applyThemeState,
type ThemeName,
type ThemeState,
} from "@/utils/theme";
const THEME_META: { name: ThemeName; label: string; color: string }[] = [
{ name: "blue", label: "原版靛蓝", color: "#577afb" },
{ name: "ocean", label: "云海蓝", color: "#2f7ce0" },
{ name: "ink", label: "石墨墨", color: "#3d4d63" },
{ name: "violet", label: "靛紫", color: "#6d5ae0" },
{ name: "teal", label: "松石绿", color: "#0d9488" },
];
const wrapEl = ref<HTMLElement | null>(null);
const open = ref(false);
const state = ref<ThemeState>(loadThemeState());
/* 挂载即把存储态落到 <html>(与 main.ts initTheme 同源幂等;组件晚于引导挂载时也自洽) */
applyThemeState(state.value);
function commit(patch: Partial<ThemeState>) {
state.value = { ...state.value, ...patch };
applyThemeState(state.value);
saveThemeState(state.value);
}
function toggle(ev: MouseEvent) {
ev.stopPropagation();
open.value = !open.value;
}
/** 切换器外任意点击即收起(弹层内部点击由 wrap 判定放过) */
function onDocClick(ev: MouseEvent) {
if (open.value && wrapEl.value && !wrapEl.value.contains(ev.target as Node)) {
open.value = false;
}
}
onMounted(() => document.addEventListener("click", onDocClick, true));
onBeforeUnmount(() => document.removeEventListener("click", onDocClick, true));
</script>
<template>
<div ref="wrapEl" class="theme-wrap">
<button type="button" class="theme-btn" :class="{ on: open }" @click="toggle">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a9 9 0 1 0 0 18c1.2 0 1.9-.9 1.9-1.8 0-.6-.3-1-.5-1.4-.2-.4-.4-.8-.4-1.3 0-.9.8-1.7 1.8-1.7H17a4 4 0 0 0 4-4c0-4.3-4-7.8-9-7.8z"/>
<circle cx="7.6" cy="11.2" r="1.1"/><circle cx="11" cy="7.6" r="1.1"/><circle cx="15.4" cy="8.6" r="1.1"/>
</svg>
<span>配色</span>
<svg class="caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M5.5 9.5l6.5 6.5 6.5-6.5"/>
</svg>
</button>
<div class="theme-pop" :class="{ open }">
<p class="tp-head">主题色</p>
<div class="tp-grid">
<button
v-for="t in THEME_META"
:key="t.name"
type="button"
class="theme-opt"
:class="{ active: state.theme === t.name }"
@click="commit({ theme: t.name })"
>
<i :style="{ background: t.color }"></i>{{ t.label }}
</button>
</div>
<div class="tp-sep"></div>
<p class="tp-head">侧栏样式</p>
<label class="tp-row">
<span>侧栏跟随顶栏色</span>
<span class="sw">
<input type="checkbox" :checked="state.sideFollow" @change="commit({ sideFollow: ($event.target as HTMLInputElement).checked })" />
<i></i>
</span>
</label>
<label class="tp-row" style="margin-top: 9px">
<span>侧栏深色(关=浅色)</span>
<span class="sw">
<input type="checkbox" :checked="state.sideDeep" @change="commit({ sideDeep: ($event.target as HTMLInputElement).checked })" />
<i></i>
</span>
</label>
<div class="tp-sep"></div>
<label class="tp-row">
<span>顶部过渡色(渐变)</span>
<span class="sw">
<input type="checkbox" :checked="state.gradOn" @change="commit({ gradOn: ($event.target as HTMLInputElement).checked })" />
<i></i>
</span>
</label>
</div>
</div>
</template>
<style scoped>
/* 皮肤规格照搬定稿 07-seam-pure.html .theme-wrap/.theme-btn/.theme-pop 段 */
.theme-wrap {
position: relative;
height: 100%;
display: flex;
align-items: center;
}
.theme-btn {
display: flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 11px;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.34);
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
font-size: 12.5px;
transition: background 0.16s, border-color 0.16s;
}
.theme-btn:hover,
.theme-btn.on {
background: rgba(255, 255, 255, 0.26);
border-color: rgba(255, 255, 255, 0.6);
}
.theme-btn svg {
width: 14px;
height: 14px;
display: block;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.theme-btn svg circle {
fill: currentColor;
stroke: none;
}
.theme-btn .caret {
width: 11px;
height: 11px;
opacity: 0.85;
transition: transform 0.18s;
}
.theme-btn.on .caret {
transform: rotate(180deg);
}
.theme-pop {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 224px;
background: #fff;
color: var(--text, #1e2635);
border-radius: 12px;
border: 1px solid var(--line, #e9edf5);
padding: 12px;
box-shadow: 0 12px 34px -10px rgba(16, 24, 40, 0.28), 0 2px 6px rgba(16, 24, 40, 0.08);
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
transition: 0.18s;
z-index: 40;
}
.theme-pop.open {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.tp-head {
font-size: 11.5px;
color: var(--text-3, #98a1b2);
letter-spacing: 0.5px;
margin: 0 2px 8px;
}
.tp-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.theme-opt {
display: flex;
align-items: center;
gap: 7px;
height: 32px;
padding: 0 8px;
cursor: pointer;
text-align: left;
border: 1px solid var(--line, #e9edf5);
border-radius: 8px;
background: #fff;
color: var(--text-2, #5a6478);
font-size: 12px;
transition: 0.15s;
font-family: inherit;
}
.theme-opt:hover {
border-color: #cfd7e6;
background: #fafbfe;
}
.theme-opt i {
width: 13px;
height: 13px;
border-radius: 50%;
flex: 0 0 auto;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.theme-opt.active {
border-color: var(--brand, #577afb);
color: var(--brand, #577afb);
background: var(--brand-soft, #eef2ff);
font-weight: 600;
}
.tp-sep {
height: 1px;
background: var(--line, #e9edf5);
margin: 12px 0 10px;
}
.tp-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12.5px;
color: var(--text-2, #5a6478);
cursor: pointer;
padding: 0 2px;
}
.sw {
position: relative;
width: 38px;
height: 21px;
flex: 0 0 auto;
}
.sw input {
position: absolute;
inset: 0;
opacity: 0;
margin: 0;
cursor: pointer;
z-index: 1;
}
.sw i {
position: absolute;
inset: 0;
border-radius: 999px;
background: #d7dce6;
transition: background 0.2s;
pointer-events: none;
}
.sw i::after {
content: "";
position: absolute;
top: 2.5px;
left: 2.5px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.28);
transition: transform 0.2s;
}
.sw input:checked + i {
background: var(--brand, #577afb);
}
.sw input:checked + i::after {
transform: translateX(17px);
}
</style>
+11 -2
View File
@@ -4,7 +4,7 @@
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
import { computed } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
const props = withDefaults(
@@ -22,7 +22,16 @@ const props = withDefaults(
);
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
const style = computed(() => resolveAvatarStyle());
/* 外观偏好异步补齐(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);
+60 -16
View File
@@ -1,13 +1,13 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { LayoutData } from "@/types/Basics";
import Utils from "@/utils";
import config from "@/assets/config/host.config";
import ThemeSwitcher from "@/components/ThemeSwitcher.vue";
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 modList = computed(() => props.layoutData.modList || []);
@@ -19,18 +19,36 @@ const currTitle = computed(() => (route.meta as any)?.title || "");
/** mock 模式徽标:仅 --mode mock(VITE_IS_MOCK=true)时渲染,dev/线上恒不出现 */
const isMock = computed(() => !!config.isMock);
/** 顶栏操作者占位(网关无用户体系;三态头像取首字「网」) */
const opName = "网关治理";
/** 顶栏操作者:超管门槛取 CurrentUser.name,取不到(mock/error 态)兜底「网关治理」 */
const opName = computed(() => props.userName || "网关治理");
/* 重载注册表(POST /api/internal/routes/refresh):网关热重载路由+目标快照,无须重启 */
// 顶栏 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 lastReloadTime = ref("");
async function reloadRegistry() {
reloading.value = true;
try {
const d: any = await Apis.GatewayAdmin.refresh({});
Message.success(`已重载:路由 ${d?.loaded ?? "-"} 条 / 目标 ${d?.targets ?? "-"} 条`);
const d: any = await Apis.GatewayAdmin.reload({});
Message.success(`已重载:路由 ${d?.loaded ?? "-"} 条 / 目标 ${d?.targets ?? "-"} 条 / 应用 ${d?.apps ?? "-"} 条`);
lastReloadTime.value = Utils.dateFormat(new Date(), "HH:mm:ss");
} catch (e: any) {
// 错误提示由 alova 全局 onBizError 弹出,这里不重复弹
@@ -39,6 +57,29 @@ 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")),列表回到服务端真值 */
const reloadPage = inject("reload", () => {}) as () => void;
</script>
@@ -46,8 +87,9 @@ const reloadPage = inject("reload", () => {}) as () => void;
<template>
<div class="layout-wrap">
<header class="header-container fix">
<!-- 云海 logo 徽标(结构与 Notice 对齐,皮肤见通道 base.layout.css .logo-badge) -->
<span class="logo-badge fl"><img src="@/assets/image/yunhai-logo.svg" alt="云海" /></span>
<!-- 云海 logo 徽标(结构与 Notice 对齐,皮肤见通道 base.layout.css .logo-badge);
外观三键 §3:平台键 yh-logo-url 优先、本仓资产兜底,加载失败回本仓资产 -->
<span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
<div class="delimiter fl align-middle"></div>
<h4 class="fl align-middle">{{ layoutData.appName }}</h4>
<div class="school-bg"></div>
@@ -62,9 +104,9 @@ const reloadPage = inject("reload", () => {}) as () => void;
<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>
<!-- 配色切换器(fr 顺序:DOM 后置即位于用户胶囊左侧) -->
<div class="theme-cap fr"><ThemeSwitcher /></div>
</header>
<div class="left-menu">
@@ -97,10 +139,6 @@ const reloadPage = inject("reload", () => {}) as () => void;
.header-container .logo-badge {
margin: 0 10px 0 0;
}
.theme-cap {
height: var(--topbar-h);
margin-right: 6px;
}
/* 面包屑「首页」垂直居中:inline-flex 真居中并关掉骨架给老 iconfont 字体的基线补偿 */
.crumb-home {
display: inline-flex;
@@ -134,4 +172,10 @@ const reloadPage = inject("reload", () => {}) as () => void;
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>
+14
View File
@@ -23,6 +23,15 @@ import "virtual:uno.css";
import "virtual:svg-icons-register";
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);
@@ -32,4 +41,9 @@ app.use(ViewUIPlus);
app.config.globalProperties.$conf = config;
app.config.globalProperties.$utils = utils;
// SSO 登录往返只能把浏览器送回控制台默认页(后端用固定 CAS service,见网关 SsoAddressing);
// 挂载前先换回跳转前记下的页签,避免首帧闪一下默认页。
const ssoReturn = consumeSsoReturnUrl();
if (ssoReturn) router.replace(ssoReturn);
app.mount("#app");
+12
View File
@@ -24,6 +24,18 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/target/TargetList.vue"),
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;
+12 -2
View File
@@ -1,6 +1,7 @@
import { createAlova, Method, StatesHook } from "alova";
import fetchAdapter from "alova/fetch";
import VueHook from "alova/vue";
import { rememberReturnUrl } from "@/utils/ssoReturn";
/* ============= 类型定义 ============= */
export interface RequestHooks {
/** 前置票据/通用头注入 */
@@ -56,8 +57,14 @@ function errorHandle<T>(res: BschResponse<T>, status: number): T {
return res.data ?? (null as T);
case 302:
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!;
throw new Error("redirect");
return new Promise(() => {}) as T;
case 4:
throw new Error(res.msg || "参数错误!");
default:
@@ -90,8 +97,11 @@ export function createBschAlova(baseURL: string, hooks: RequestHooks = {}, state
injectHeaders(method);
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 || {}, {
yschapirequest: "1",
timeout: 20000,
});
}
+36
View File
@@ -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;
}
+48
View File
@@ -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;
}
}
+48
View File
@@ -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: "" };
}
}
+165
View File
@@ -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),
};
}
-151
View File
@@ -1,151 +0,0 @@
/**
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20260930-1248-4e12e6d),勿手改。
* 分发方式:YschTheme `npm run sync` 拷进各端 src/utils/theme.ts(与 ThemeSwitcher.vue 配套,MD5 对齐报告)。
* 运行时档位类名由 /static/js/yh-theme-init.js 在 <head> 内先行挂好,本模块只负责读写与切换。
*/
export type ThemeName = "blue" | "ocean" | "ink" | "violet" | "teal";
/** 定稿默认档:blue + side-deep + grad-on + 侧栏跟随(D1 拍板 deep) */
export interface ThemeState {
theme: ThemeName;
/** 侧栏深色(false=浅色档) */
sideDeep: boolean;
/** 顶栏渐变(联动侧栏竖向渐变) */
gradOn: boolean;
/** 侧栏颜色跟随顶栏品牌色(false=中性灰 side-plain) */
sideFollow: boolean;
}
interface StorageLike {
getItem(k: string): string | null;
setItem(k: string, v: string): void;
}
export const THEME_NAMES: readonly ThemeName[] = ["blue", "ocean", "ink", "violet", "teal"];
export const THEME_STORAGE_KEY = "yh-theme";
/* 学校默认档存储键(UserHome 登录时写入;值格式与 yh-theme 完全相同,仅无"用户自选"语义) */
export const SCHOOL_DEFAULT_STORAGE_KEY = "yh-theme-sch-default";
export const DEFAULT_THEME_STATE: ThemeState = {
theme: "blue",
sideDeep: true,
gradOn: true,
sideFollow: true
};
/** 宽松解析:任一字段非法/缺失即回退默认档(脏数据不阻塞启动) */
export function parseThemeState(raw: string | null): ThemeState {
if (!raw) return { ...DEFAULT_THEME_STATE };
try {
const o = JSON.parse(raw);
if (!o || typeof o !== "object") return { ...DEFAULT_THEME_STATE };
return {
theme: THEME_NAMES.includes(o.theme) ? o.theme : DEFAULT_THEME_STATE.theme,
sideDeep: typeof o.sideDeep === "boolean" ? o.sideDeep : DEFAULT_THEME_STATE.sideDeep,
gradOn: typeof o.gradOn === "boolean" ? o.gradOn : DEFAULT_THEME_STATE.gradOn,
sideFollow: typeof o.sideFollow === "boolean" ? o.sideFollow : DEFAULT_THEME_STATE.sideFollow
};
} catch {
return { ...DEFAULT_THEME_STATE };
}
}
/** 严格读取:四字段全部合法才返回状态,否则 null(合成链"值非法→回落下一级"用;用户键仍走宽松解析保现状) */
function readThemeStateOrNull(raw: string | null): ThemeState | null {
if (!raw) return null;
try {
const o = JSON.parse(raw);
if (!o || typeof o !== "object") return null;
if (!THEME_NAMES.includes(o.theme)) return null;
if (typeof o.sideDeep !== "boolean" || typeof o.gradOn !== "boolean" || typeof o.sideFollow !== "boolean") return null;
return { theme: o.theme, sideDeep: o.sideDeep, gradOn: o.gradOn, sideFollow: o.sideFollow };
} catch {
return null;
}
}
/** URL 覆盖参数(?yh-theme= / ?yh-side=deep|light / ?yh-grad=on|off):仅预览/排障用,
非法值忽略(对应字段不覆盖),且只影响"读哪一档"、绝不写回 localStorage。 */
function readThemeOverrides(): { theme?: ThemeName; sideDeep?: boolean; gradOn?: boolean } | null {
let out = null;
try {
const search = (globalThis.location && globalThis.location.search) || "";
const get = (name) => {
const m = new RegExp("[?&]" + name + "=([^&]*)").exec(search);
return m ? decodeURIComponent(m[1]) : null;
};
const t = get("yh-theme");
if (t && THEME_NAMES.includes(t)) {
out = out || {};
out.theme = t;
}
const s = get("yh-side");
if (s === "deep" || s === "light") {
out = out || {};
out.sideDeep = s === "deep";
}
const g = get("yh-grad");
if (g === "on" || g === "off") {
out = out || {};
out.gradOn = g === "on";
}
} catch {
/* 无 location 环境(测试/SSR):无覆盖 */
}
return out;
}
/**
* 生效档合成(唯一实现,init 启动段与 UserHome theme.ts 共用):
* URL 覆盖 > localStorage["yh-theme"](用户自选,存在即用——宽松修补,与历史行为一致)
* > localStorage["yh-theme-sch-default"](学校默认,整体合法才生效)
* > DEFAULT_THEME_STATE。
* 键不存在/解析失败/值非法均静默回落,不报错不阻塞首帧。
*/
export function resolveEffectiveThemeState(storage: StorageLike | undefined = globalThis.localStorage): ThemeState {
let state = null;
if (storage) {
const userRaw = storage.getItem(THEME_STORAGE_KEY);
if (userRaw) state = parseThemeState(userRaw);
if (!state) state = readThemeStateOrNull(storage.getItem(SCHOOL_DEFAULT_STORAGE_KEY));
}
if (!state) state = { ...DEFAULT_THEME_STATE };
const ov = readThemeOverrides();
if (ov) {
if (ov.theme) state = { ...state, theme: ov.theme };
if (ov.sideDeep != null) state = { ...state, sideDeep: ov.sideDeep };
if (ov.gradOn != null) state = { ...state, gradOn: ov.gradOn };
}
return state;
}
export function loadThemeState(storage: StorageLike | undefined = globalThis.localStorage): ThemeState {
if (!storage) return { ...DEFAULT_THEME_STATE };
return parseThemeState(storage.getItem(THEME_STORAGE_KEY));
}
export function saveThemeState(state: ThemeState, storage: StorageLike | undefined = globalThis.localStorage): void {
try {
storage?.setItem(THEME_STORAGE_KEY, JSON.stringify(state));
} catch {
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
}
}
/** 把状态落到根元素档位类名(幂等:先清后挂),返回传入的 state 便于链式 */
export function applyThemeState(state: ThemeState, root: HTMLElement = document.documentElement): ThemeState {
root.dataset.theme = state.theme;
root.classList.toggle("side-deep", state.sideDeep);
root.classList.toggle("side-light", !state.sideDeep);
root.classList.toggle("grad-on", state.gradOn);
root.classList.toggle("grad-off", !state.gradOn);
root.classList.toggle("side-plain", !state.sideFollow);
return state;
}
/** 启动引导:各端 main.ts(或本仓 init 脚本的启动段)于挂载前调用,消除首帧闪色 */
export function initTheme(): ThemeState {
return applyThemeState(loadThemeState());
}
+279
View File
@@ -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>
+9 -19
View File
@@ -1,5 +1,5 @@
<template>
<div class="main-wrapper">
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="search-wrap fix">
<Input v-model="dataParams.targetCode" placeholder="target(如 notice)" clearable
@@ -12,7 +12,7 @@
<Button class="ml-12" @click="handleReset">重置</Button>
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
</div>
<LedgerDetailModal v-model="detailVisible" :row="currentRow" />
@@ -21,15 +21,13 @@
<script setup lang="ts">
import { ref } from 'vue';
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import LedgerDetailModal from './LedgerDetailModal.vue';
import { actionColumn } from '@/utils/tableActions';
import Utils from '@/utils';
defineOptions({ name: 'LedgerList' });
const { $utils } = useCurrentInstance();
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
interface DataParams {
@@ -63,8 +61,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) =>
cacheFor: null,
});
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
/** 状态码色阶:2xx 绿 / 4xx 琥珀 / 5xx 红 */
function statusClass(status: number) {
if (status >= 500) return 'text-error';
@@ -112,18 +108,12 @@ const columns = [
align: 'right',
render: (h: any, { row }: any) => h('span', row.costMs != null ? `${row.costMs} ms` : '-'),
},
{
title: '操作',
key: 'action',
width: 100,
align: 'center',
fixed: 'right' as const,
render: (h: any, { row }: any) =>
h('a', {
style: { color: '#577afb' },
onClick: () => openDetail(row),
}, '查看详情'),
},
actionColumn(
[
{ key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
],
{ mode: 'text', width: 100, fixed: 'right' },
),
];
const detailVisible = ref(false);
+256
View File
@@ -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>
+21 -14
View File
@@ -1,12 +1,12 @@
<template>
<div class="main-wrapper">
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="search-wrap fix">
<span class="tip">路由注册表(open_api_route):治理旋钮行内编辑,保存后网关热重载,无须重启。</span>
<span class="tip">路由注册表(open_api_route):治理旋钮行内编辑,保存后网关热重载,无须重启。qps&gt;0 时按 调用方×本行 限流(超限裸 429,0=不限);timeout/bodyLimit 立即生效。</span>
<Button type="primary" class="fr" @click="refresh">刷新</Button>
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight">
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns">
<template #visibility="{ row }">
<Select v-model="row.visibility" size="small" style="width: 110px">
<Option value="INTERNAL">INTERNAL</Option>
@@ -31,15 +31,18 @@
<template #rateLimitQps="{ row }">
<Input v-model="row.rateLimitQps" type="number" size="small" style="width: 80px" />
</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 }">
<i-switch v-model="row.enabled" size="small" />
</template>
<template #remark="{ row }">
<Input v-model="row.remark" size="small" placeholder="-" />
</template>
<template #action="{ row }">
<Button type="primary" size="small" :loading="savingId === row.id" @click="save(row)">保存</Button>
</template>
</TablePageAlovaNew>
</div>
</div>
@@ -47,21 +50,17 @@
<script setup lang="ts">
import { ref } from 'vue';
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import { actionColumn } from '@/utils/tableActions';
defineOptions({ name: 'RouteList' });
const { $utils } = useCurrentInstance();
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const savingId = ref<number | null>(null);
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
Apis.GatewayAdmin.routes({ params, cacheFor: null });
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
const columns = [
{ title: 'id', key: 'id', width: 70, align: 'center' },
{ title: 'target', key: 'targetCode', width: 120, ellipsis: true, tooltip: true },
@@ -72,11 +71,17 @@ const columns = [
{ title: 'cacheTtl(s)', slot: 'cacheTtlSeconds', width: 105 },
{ title: 'cacheScope', slot: 'cacheScope', width: 120 },
{ title: 'qps', slot: 'rateLimitQps', width: 90 },
{ title: 'timeout(ms)', key: 'timeoutMs', width: 105, align: 'right' },
{ title: 'bodyLimit(mb)', key: 'requestBodyLimitMb', width: 115, align: 'right' },
{ title: 'timeout(ms)', slot: 'timeoutMs', width: 115, align: 'right' },
{ title: 'bodyLimit(mb)', slot: 'requestBodyLimitMb', width: 125, align: 'right' },
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
{ 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() {
@@ -95,6 +100,8 @@ async function save(row: any) {
cacheTtlSeconds: Number(row.cacheTtlSeconds) || 0,
cacheScope: row.cacheScope,
rateLimitQps: Number(row.rateLimitQps) || 0,
timeoutMs: Number(row.timeoutMs) || 30000,
requestBodyLimitMb: Number(row.requestBodyLimitMb) || 10,
enabled: row.enabled,
remark: row.remark,
},
+10 -11
View File
@@ -1,12 +1,12 @@
<template>
<div class="main-wrapper">
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="search-wrap fix">
<span class="tip">目标地址(open_api_target):base_url 与启停行内编辑,保存后网关热重载 target 快照。</span>
<Button type="primary" class="fr" @click="refresh">刷新</Button>
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight">
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns">
<template #baseUrl="{ row }">
<Input v-model="row.baseUrl" size="small" placeholder="http://{target}-service:{port}" />
</template>
@@ -16,9 +16,6 @@
<template #gmtModified="{ row }">
<span>{{ row.gmtModified ? Utils.dateFormat(row.gmtModified, 'yyyy-MM-dd HH:mm:ss') : '-' }}</span>
</template>
<template #action="{ row }">
<Button type="primary" size="small" :loading="savingId === row.id" @click="save(row)">保存</Button>
</template>
</TablePageAlovaNew>
</div>
</div>
@@ -26,29 +23,31 @@
<script setup lang="ts">
import { ref } from 'vue';
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import { actionColumn } from '@/utils/tableActions';
import Utils from '@/utils';
defineOptions({ name: 'TargetList' });
const { $utils } = useCurrentInstance();
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
const savingId = ref<number | null>(null);
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
Apis.GatewayAdmin.targets({ params, cacheFor: null });
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
const columns = [
{ title: 'id', key: 'id', width: 70, align: 'center' },
{ title: 'target', key: 'targetCode', width: 140, ellipsis: true, tooltip: true },
{ title: 'base_url', slot: 'baseUrl', minWidth: 300 },
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
{ 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() {
+1 -1
View File
@@ -23,7 +23,7 @@
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"allowSyntheticDefaultImports": true,
"types": ["vite/client"]
"types": ["vite/client", "node"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue","types"],
+14 -1
View File
@@ -121,10 +121,15 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
port: 8131,
// 本地联调:/open-gateway/api/* → base 域(Caddy 剥前缀后转发到网关容器网内 10013)
// 走代理而非跨源直连:网关无 CORS,且 10013 仅绑容器网内 127.0.0.1
// 设 YH_GW_PROXY_TARGET(如 http://localhost:10013)= 不经 base 域直打本地网关,
// 此时剥掉 /open-gateway 前缀(原本由 Caddy 剥);不设则行为与线上联调零变化
proxy: {
"/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
@@ -133,6 +138,14 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
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,
},
},
},
base: "./",