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 零错误
This commit is contained in:
chenxing
2026-10-06 20:22:02 +08:00
parent 8a21d85036
commit 77060325ef
10 changed files with 711 additions and 7 deletions
+3 -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({
+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) {
+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
@@ -3,7 +3,7 @@
产物(readThemeOverrides 内部实现过不了 strict vue-tsc);Notice/UserBase/UserHome 均不跑
typecheck,本仓是首个严格检查端。等 YschTheme 上游补齐类型后移除本行。 */
/**
* 本文件由 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 生成(版本 20261006-2013-75c1d10),勿手改。
* 分发方式: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,
},