Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a036f39713 | ||
|
|
3ed3af3962 | ||
|
|
3acbc79008 | ||
|
|
85a7f563f6 | ||
|
|
bed01788fd | ||
|
|
0dac2d3b6e | ||
|
|
449ad16244 | ||
|
|
77060325ef | ||
|
|
8a21d85036 | ||
|
|
ff0f79a473 | ||
|
|
5b886f4fc2 | ||
|
|
1db85be875 |
@@ -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` 快照已删除)。
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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'],
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 弹出,这里不重复弹
|
||||
|
||||
@@ -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
@@ -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> 内先行挂好,本模块只负责读写与切换。
|
||||
*/
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user