Compare commits

...
Author SHA1 Message Date
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
15 changed files with 908 additions and 10 deletions
+86
View File
@@ -0,0 +1,86 @@
# 网关台账前端 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/routes/refresh` | 无入参 → `data={loaded,targets}` | 一致(返回内存实时条数:28/7) |
前端请求带 `/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/theme.ts`、`src/components/ThemeSwitcher.vue`、`src/utils/avatar.ts`、`src/components/UserAvatar.vue` 由 YschTheme 下发(consumers.json 已登记 open-gateway 条目)。⚠️ `theme.ts` 顶部有本仓加的 `// @ts-nocheck`(上游为松类型产物)——**下次 `npm run sync` 覆盖后需补回该行**,否则 `npm run typecheck` 报错。
## 实现备注
- 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
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",
+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);
});
+13 -1
View File
@@ -6,11 +6,13 @@ import { LayoutData } from "./types/Basics";
const isRouterAlive = ref(true), isLogin = ref(false);
/** 网关无菜单接口:三个治理页写死为静态菜单(与 notice/userbase 的 LeftMenu 同形) */
/** 网关无菜单接口:治理页写死为静态菜单(与 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({
@@ -63,11 +65,21 @@ 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;
}
</style>
+4
View File
@@ -11,6 +11,10 @@ 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'],
'GatewayAdmin.proxyTest': ['POST', '/api/internal/admin/proxy-test'],
};
+136
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 内) ============================ */
/**
@@ -375,6 +502,15 @@ export async function handleGatewayMockRequest(req: IncomingMessage, res: Server
send(res, ok({ loaded: routes.length, targets: targets.length }));
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) {
+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);
+1 -1
View File
@@ -30,7 +30,7 @@ async function reloadRegistry() {
reloading.value = true;
try {
const d: any = await Apis.GatewayAdmin.refresh({});
Message.success(`已重载:路由 ${d?.loaded ?? "-"} 条 / 目标 ${d?.targets ?? "-"} 条`);
Message.success(`已重载:路由 ${d?.loaded ?? "-"} 条 / 目标 ${d?.targets ?? "-"} 条 / 应用 ${d?.apps ?? "-"} 条`);
lastReloadTime.value = Utils.dateFormat(new Date(), "HH:mm:ss");
} catch (e: any) {
// 错误提示由 alova 全局 onBizError 弹出,这里不重复弹
+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;
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20260930-1248-4e12e6d),勿手改。
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20261009-1107-1688389),勿手改。
* 分发方式:YschTheme `npm run sync` 拷进各端 src/utils/theme.ts(与 ThemeSwitcher.vue 配套,MD5 对齐报告)。
* 运行时档位类名由 /static/js/yh-theme-init.js 在 <head> 内先行挂好,本模块只负责读写与切换。
*/
+281
View File
@@ -0,0 +1,281 @@
<template>
<div class="main-wrapper">
<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"
:tableHeight="tableHeight">
<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>
<template #action="{ row }">
<Button type="primary" size="small" :loading="savingId === row.id" @click="save(row)">保存</Button>
</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 useCurrentInstance from '@/utils/hook/useCurrentInstance';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import Utils from '@/utils';
defineOptions({ name: 'AppList' });
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.apps({ 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: '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 },
{ title: '操作', slot: 'action', width: 90, align: 'center', fixed: 'right' as const },
];
/** 服务端 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>
+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>
+11 -3
View File
@@ -2,7 +2,7 @@
<div class="main-wrapper">
<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>
@@ -31,6 +31,12 @@
<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>
@@ -72,8 +78,8 @@ 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 },
@@ -95,6 +101,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,
},
+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"],
+6 -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