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:
+3
-1
@@ -6,11 +6,13 @@ import { LayoutData } from "./types/Basics";
|
|||||||
|
|
||||||
const isRouterAlive = ref(true), isLogin = ref(false);
|
const isRouterAlive = ref(true), isLogin = ref(false);
|
||||||
|
|
||||||
/** 网关无菜单接口:三个治理页写死为静态菜单(与 notice/userbase 的 LeftMenu 同形) */
|
/** 网关无菜单接口:治理页写死为静态菜单(与 notice/userbase 的 LeftMenu 同形) */
|
||||||
const modList = [
|
const modList = [
|
||||||
{ name: "LedgerList", title: "访问台账", page: "/ledger" },
|
{ name: "LedgerList", title: "访问台账", page: "/ledger" },
|
||||||
{ name: "RouteList", title: "路由注册表", page: "/routes" },
|
{ name: "RouteList", title: "路由注册表", page: "/routes" },
|
||||||
{ name: "TargetList", title: "目标地址", page: "/targets" },
|
{ name: "TargetList", title: "目标地址", page: "/targets" },
|
||||||
|
{ name: "AppList", title: "应用管理", page: "/apps" },
|
||||||
|
{ name: "ProxyTest", title: "代理测试", page: "/proxy-test" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const layoutData: LayoutData = reactive({
|
const layoutData: LayoutData = reactive({
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export default {
|
|||||||
'GatewayAdmin.updateRoute': ['PUT', '/api/internal/admin/routes/{id}'],
|
'GatewayAdmin.updateRoute': ['PUT', '/api/internal/admin/routes/{id}'],
|
||||||
'GatewayAdmin.targets': ['GET', '/api/internal/admin/targets'],
|
'GatewayAdmin.targets': ['GET', '/api/internal/admin/targets'],
|
||||||
'GatewayAdmin.updateTarget': ['PUT', '/api/internal/admin/targets/{id}'],
|
'GatewayAdmin.updateTarget': ['PUT', '/api/internal/admin/targets/{id}'],
|
||||||
|
'GatewayAdmin.apps': ['GET', '/api/internal/admin/apps'],
|
||||||
|
'GatewayAdmin.createApp': ['POST', '/api/internal/admin/apps'],
|
||||||
|
'GatewayAdmin.updateApp': ['PUT', '/api/internal/admin/apps/{id}'],
|
||||||
'GatewayAdmin.logs': ['GET', '/api/internal/admin/logs'],
|
'GatewayAdmin.logs': ['GET', '/api/internal/admin/logs'],
|
||||||
'GatewayAdmin.refresh': ['POST', '/api/internal/routes/refresh'],
|
'GatewayAdmin.refresh': ['POST', '/api/internal/routes/refresh'],
|
||||||
|
'GatewayAdmin.proxyTest': ['POST', '/api/internal/admin/proxy-test'],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
* 由中间件调 resetGatewayMockState() 重置回种子;SPA 站内切换不重置。
|
* 由中间件调 resetGatewayMockState() 重置回种子;SPA 站内切换不重置。
|
||||||
*/
|
*/
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
|
||||||
/* ============================ 类型(对齐后端 OpenApi DTO) ============================ */
|
/* ============================ 类型(对齐后端 OpenApi DTO) ============================ */
|
||||||
|
|
||||||
@@ -344,6 +345,132 @@ function updateTarget(id: number, body: any): { code: number; msg: string; data:
|
|||||||
return ok({ targets: targets.length });
|
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 内) ============================ */
|
/* ============================ 中间件入口(vite dev server 内) ============================ */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -375,6 +502,15 @@ export async function handleGatewayMockRequest(req: IncomingMessage, res: Server
|
|||||||
send(res, ok({ loaded: routes.length, targets: targets.length }));
|
send(res, ok({ loaded: routes.length, targets: targets.length }));
|
||||||
return true;
|
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);
|
let m = /^\/api\/internal\/admin\/routes\/(\d+)$/.exec(api);
|
||||||
if (m) {
|
if (m) {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ async function reloadRegistry() {
|
|||||||
reloading.value = true;
|
reloading.value = true;
|
||||||
try {
|
try {
|
||||||
const d: any = await Apis.GatewayAdmin.refresh({});
|
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");
|
lastReloadTime.value = Utils.dateFormat(new Date(), "HH:mm:ss");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
// 错误提示由 alova 全局 onBizError 弹出,这里不重复弹
|
// 错误提示由 alova 全局 onBizError 弹出,这里不重复弹
|
||||||
|
|||||||
@@ -24,6 +24,18 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/target/TargetList.vue"),
|
component: () => import("@/views/target/TargetList.vue"),
|
||||||
meta: { title: "目标地址" },
|
meta: { title: "目标地址" },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/apps",
|
||||||
|
name: "AppList",
|
||||||
|
component: () => import("@/views/app/AppList.vue"),
|
||||||
|
meta: { title: "应用管理" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/proxy-test",
|
||||||
|
name: "ProxyTest",
|
||||||
|
component: () => import("@/views/proxytest/ProxyTest.vue"),
|
||||||
|
meta: { title: "代理测试" },
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default routes;
|
export default routes;
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
产物(readThemeOverrides 内部实现过不了 strict vue-tsc);Notice/UserBase/UserHome 均不跑
|
产物(readThemeOverrides 内部实现过不了 strict vue-tsc);Notice/UserBase/UserHome 均不跑
|
||||||
typecheck,本仓是首个严格检查端。等 YschTheme 上游补齐类型后移除本行。 */
|
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 对齐报告)。
|
* 分发方式:YschTheme `npm run sync` 拷进各端 src/utils/theme.ts(与 ThemeSwitcher.vue 配套,MD5 对齐报告)。
|
||||||
* 运行时档位类名由 /static/js/yh-theme-init.js 在 <head> 内先行挂好,本模块只负责读写与切换。
|
* 运行时档位类名由 /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-wrapper">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<span class="tip">路由注册表(open_api_route):治理旋钮行内编辑,保存后网关热重载,无须重启。</span>
|
<span class="tip">路由注册表(open_api_route):治理旋钮行内编辑,保存后网关热重载,无须重启。qps>0 时按 调用方×本行 限流(超限裸 429,0=不限);timeout/bodyLimit 立即生效。</span>
|
||||||
<Button type="primary" class="fr" @click="refresh">刷新</Button>
|
<Button type="primary" class="fr" @click="refresh">刷新</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,6 +31,12 @@
|
|||||||
<template #rateLimitQps="{ row }">
|
<template #rateLimitQps="{ row }">
|
||||||
<Input v-model="row.rateLimitQps" type="number" size="small" style="width: 80px" />
|
<Input v-model="row.rateLimitQps" type="number" size="small" style="width: 80px" />
|
||||||
</template>
|
</template>
|
||||||
|
<template #timeoutMs="{ row }">
|
||||||
|
<Input v-model="row.timeoutMs" type="number" size="small" style="width: 90px" placeholder="1000-120000" />
|
||||||
|
</template>
|
||||||
|
<template #requestBodyLimitMb="{ row }">
|
||||||
|
<Input v-model="row.requestBodyLimitMb" type="number" size="small" style="width: 90px" placeholder="1-100" />
|
||||||
|
</template>
|
||||||
<template #enabled="{ row }">
|
<template #enabled="{ row }">
|
||||||
<i-switch v-model="row.enabled" size="small" />
|
<i-switch v-model="row.enabled" size="small" />
|
||||||
</template>
|
</template>
|
||||||
@@ -72,8 +78,8 @@ const columns = [
|
|||||||
{ title: 'cacheTtl(s)', slot: 'cacheTtlSeconds', width: 105 },
|
{ title: 'cacheTtl(s)', slot: 'cacheTtlSeconds', width: 105 },
|
||||||
{ title: 'cacheScope', slot: 'cacheScope', width: 120 },
|
{ title: 'cacheScope', slot: 'cacheScope', width: 120 },
|
||||||
{ title: 'qps', slot: 'rateLimitQps', width: 90 },
|
{ title: 'qps', slot: 'rateLimitQps', width: 90 },
|
||||||
{ title: 'timeout(ms)', key: 'timeoutMs', width: 105, align: 'right' },
|
{ title: 'timeout(ms)', slot: 'timeoutMs', width: 115, align: 'right' },
|
||||||
{ title: 'bodyLimit(mb)', key: 'requestBodyLimitMb', width: 115, align: 'right' },
|
{ title: 'bodyLimit(mb)', slot: 'requestBodyLimitMb', width: 125, align: 'right' },
|
||||||
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
|
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
|
||||||
{ title: '备注', slot: 'remark', minWidth: 160 },
|
{ title: '备注', slot: 'remark', minWidth: 160 },
|
||||||
{ title: '操作', slot: 'action', width: 90, align: 'center', fixed: 'right' as const },
|
{ 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,
|
cacheTtlSeconds: Number(row.cacheTtlSeconds) || 0,
|
||||||
cacheScope: row.cacheScope,
|
cacheScope: row.cacheScope,
|
||||||
rateLimitQps: Number(row.rateLimitQps) || 0,
|
rateLimitQps: Number(row.rateLimitQps) || 0,
|
||||||
|
timeoutMs: Number(row.timeoutMs) || 30000,
|
||||||
|
requestBodyLimitMb: Number(row.requestBodyLimitMb) || 10,
|
||||||
enabled: row.enabled,
|
enabled: row.enabled,
|
||||||
remark: row.remark,
|
remark: row.remark,
|
||||||
},
|
},
|
||||||
|
|||||||
+6
-1
@@ -121,10 +121,15 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
|||||||
port: 8131,
|
port: 8131,
|
||||||
// 本地联调:/open-gateway/api/* → base 域(Caddy 剥前缀后转发到网关容器网内 10013)
|
// 本地联调:/open-gateway/api/* → base 域(Caddy 剥前缀后转发到网关容器网内 10013)
|
||||||
// 走代理而非跨源直连:网关无 CORS,且 10013 仅绑容器网内 127.0.0.1
|
// 走代理而非跨源直连:网关无 CORS,且 10013 仅绑容器网内 127.0.0.1
|
||||||
|
// 设 YH_GW_PROXY_TARGET(如 http://localhost:10013)= 不经 base 域直打本地网关,
|
||||||
|
// 此时剥掉 /open-gateway 前缀(原本由 Caddy 剥);不设则行为与线上联调零变化
|
||||||
proxy: {
|
proxy: {
|
||||||
"/open-gateway/api": {
|
"/open-gateway/api": {
|
||||||
target: "http://base.yunhaixingtong.com",
|
target: process.env.YH_GW_PROXY_TARGET || "http://base.yunhaixingtong.com",
|
||||||
changeOrigin: true,
|
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,
|
// 主题通道本地联调(照 Notice 配方):index.html loader 走同源相对 /static/js/yh-theme-init.js,
|
||||||
// manifest/CSS 经此代理到中央通道;base 域不可达(无 VPN)时由 npm run dev:mock
|
// manifest/CSS 经此代理到中央通道;base 域不可达(无 VPN)时由 npm run dev:mock
|
||||||
|
|||||||
Reference in New Issue
Block a user