Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
808f02bac7 | ||
|
|
6701cbac76 | ||
|
|
90338f3980 | ||
|
|
729ab9592d |
@@ -5,3 +5,6 @@
|
|||||||
# (/open-gateway/front/ → 首段 open-gateway → ${origin}/open-gateway)。
|
# (/open-gateway/front/ → 首段 open-gateway → ${origin}/open-gateway)。
|
||||||
VITE_SERVICE_HOST=/open-gateway
|
VITE_SERVICE_HOST=/open-gateway
|
||||||
VITE_IS_MOCK=false
|
VITE_IS_MOCK=false
|
||||||
|
# 超管门槛(src/utils/superGate.ts):本地无平台 Cookie 时 CurrentUser 探测失败按放行处理;
|
||||||
|
# 如需完全旁路门槛,取消下一行注释(仅 dev 生效,build 不读本文件)。
|
||||||
|
# VITE_SUPER_GATE=off
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ jobs:
|
|||||||
options: --network infra-infra-net
|
options: --network infra-infra-net
|
||||||
steps:
|
steps:
|
||||||
- name: 安装 git / curl / zip(alpine)
|
- name: 安装 git / curl / zip(alpine)
|
||||||
run: apk add --no-cache git curl zip
|
run: |
|
||||||
|
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
|
||||||
|
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
|
||||||
|
apk add --no-cache git curl zip
|
||||||
|
|
||||||
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||||
@@ -38,21 +41,29 @@ jobs:
|
|||||||
- name: 构建前端(node 容器)
|
- name: 构建前端(node 容器)
|
||||||
run: |
|
run: |
|
||||||
set -e
|
set -e
|
||||||
docker rm -f frontbuild 2>/dev/null || true
|
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||||
docker run -d --name frontbuild -w /build node:20 sleep 3600
|
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
|
||||||
docker cp "${PWD}/." frontbuild:/build/
|
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
|
||||||
docker exec frontbuild sh -c '
|
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
|
||||||
|
BUILD_CONTAINER="frontbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
|
||||||
|
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
|
||||||
|
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
|
||||||
|
docker run -d --name "$BUILD_CONTAINER" -w /build node:20 sleep 3600
|
||||||
|
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||||
|
docker exec "$BUILD_CONTAINER" sh -c '
|
||||||
set -e
|
set -e
|
||||||
cd /build
|
cd /build
|
||||||
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
|
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
|
||||||
|
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
|
||||||
|
npm config set registry https://registry.npmmirror.com
|
||||||
npm ci
|
npm ci
|
||||||
npm run build
|
npm run build
|
||||||
'
|
'
|
||||||
echo "=== dist 产物 ==="
|
echo "=== dist 产物 ==="
|
||||||
docker exec frontbuild sh -c 'ls -la /build/dist/ | head -20'
|
docker exec "$BUILD_CONTAINER" sh -c 'ls -la /build/dist/ | head -20'
|
||||||
# 从容器 cp dist 到宿主工作目录
|
# 从容器 cp dist 到宿主工作目录
|
||||||
docker cp frontbuild:/build/dist ./dist
|
docker cp "$BUILD_CONTAINER":/build/dist ./dist
|
||||||
docker rm -f frontbuild
|
docker rm -f "$BUILD_CONTAINER"
|
||||||
echo "BUILD CHECK OK"
|
echo "BUILD CHECK OK"
|
||||||
|
|
||||||
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
|
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ npm run dev:mock
|
|||||||
| `GET /api/internal/admin/targets` | 同信封分页 | 一致 |
|
| `GET /api/internal/admin/targets` | 同信封分页 | 一致 |
|
||||||
| `PUT /api/internal/admin/targets/{id}` | body{baseUrl 非空, enabled 真布尔};成功 `data={targets:N}` | 一致 |
|
| `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 | 一致(先过滤再分页) |
|
| `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) |
|
| `POST /api/internal/admin/routes/reload` | 无入参 → `data={loaded,targets}` | 一致(返回内存实时条数:28/7;`apps` 键 mock 无数据源,前端按 `-` 渲染) |
|
||||||
|
| `GET /api/internal/admin/session` | `data={ssoHost,principal}`,值来自已登录 CAS 会话 | `ssoHost` 返空串 → 顶栏「退出」不渲染(同 Notice 的 ssoHost 空态) |
|
||||||
|
|
||||||
前端请求带 `/open-gateway` 前缀(serviceHost),mock 中间件按剥前缀后的真实路径匹配。
|
前端请求带 `/open-gateway` 前缀(serviceHost),mock 中间件按剥前缀后的真实路径匹配。
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ npm run dev:mock
|
|||||||
- **接入方式**:`index.html` 头部一段同步 loader(照 Notice 配方,A 类免插件)引 `/static/js/yh-theme-init.js`;host 优先级 `__APP_CONFIG__.staticHost > baseHost > %VITE_THEME_HOST%(dev 可注入)> 同源 /static/`。网关无 env-config,恒走后两档;build 不替换占位符 → 线上走同源通道,零环境值烧入。
|
- **接入方式**:`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 存在的原因)。
|
- **本地联调**: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)。
|
- **档位**:五档配色 + 侧栏深浅 + 侧栏跟随 + 顶部渐变,状态存 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` 报错。
|
- **sync 两件**:`src/utils/avatar.ts`、`src/components/UserAvatar.vue` 由 YschTheme 下发(consumers.json 已登记 open-gateway 条目)。2026-10-11 对接规范轮裁撤 `theme.ts` + `ThemeSwitcher.vue`(§3 红线:子应用不做配色切换器),mock 模式配色档位走通道默认档 + `/open-gateway/api/appearance` mock 端点收敛。
|
||||||
|
|
||||||
## 实现备注
|
## 实现备注
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"alova": "^3.4.0",
|
"alova": "^3.4.0",
|
||||||
"sass": "^1.64.1",
|
"sass": "^1.64.1",
|
||||||
"view-ui-plus": "^1.3.16",
|
"view-ui-plus": "^1.3.24",
|
||||||
"vue": "^3.4.19",
|
"vue": "^3.4.19",
|
||||||
"vue-router": "4"
|
"vue-router": "4"
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"alova": "^3.4.0",
|
"alova": "^3.4.0",
|
||||||
"sass": "^1.64.1",
|
"sass": "^1.64.1",
|
||||||
"view-ui-plus": "^1.3.16",
|
"view-ui-plus": "^1.3.24",
|
||||||
"vue": "^3.4.19",
|
"vue": "^3.4.19",
|
||||||
"vue-router": "4"
|
"vue-router": "4"
|
||||||
},
|
},
|
||||||
|
|||||||
+71
-9
@@ -1,11 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, reactive } from "vue";
|
import { computed, ref, onMounted, reactive } from "vue";
|
||||||
import layout from "./components/layout.vue";
|
import layout from "./components/layout.vue";
|
||||||
|
|
||||||
import { LayoutData } from "./types/Basics";
|
import { LayoutData } from "./types/Basics";
|
||||||
|
import { checkSuperAdmin, SuperGate } from "@/utils/superGate";
|
||||||
|
|
||||||
const isRouterAlive = ref(true), isLogin = ref(false);
|
const isRouterAlive = ref(true), isLogin = ref(false);
|
||||||
|
|
||||||
|
/* 超管门槛(对接规范 §10 open-gateway 轮):挂载前探测 /userbase/base/User/CurrentUser,
|
||||||
|
isSuperAdmin 明确为假 → 整页「暂无权限」;接口不可达(error)不锁死治理面——
|
||||||
|
访问控制真身在后端 CAS 门,本门槛是体验层。pending 期间不渲染,避免默认页闪一帧。 */
|
||||||
|
const gate = ref<SuperGate>("pending");
|
||||||
|
const userName = ref("");
|
||||||
|
const gateReady = computed(() => gate.value !== "pending");
|
||||||
|
const denied = computed(() => gate.value === "denied");
|
||||||
|
|
||||||
/** 网关无菜单接口:治理页写死为静态菜单(与 notice/userbase 的 LeftMenu 同形) */
|
/** 网关无菜单接口:治理页写死为静态菜单(与 notice/userbase 的 LeftMenu 同形) */
|
||||||
const modList = [
|
const modList = [
|
||||||
{ name: "LedgerList", title: "访问台账", page: "/ledger" },
|
{ name: "LedgerList", title: "访问台账", page: "/ledger" },
|
||||||
@@ -33,25 +42,44 @@ const reload = () => {
|
|||||||
};
|
};
|
||||||
provide("reload", reload);
|
provide("reload", reload);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
layoutData.modList = modList;
|
layoutData.modList = modList;
|
||||||
// "/" 落点由 routes.ts 的 { path: "/", redirect: "/ledger" } 处理。
|
// "/" 落点由 routes.ts 的 { path: "/", redirect: "/ledger" } 处理。
|
||||||
// 这里不能再用 currentRoute 判断后 replace:冷启动时初始导航尚未解析完,
|
// 这里不能再用 currentRoute 判断后 replace:冷启动时初始导航尚未解析完,
|
||||||
// currentRoute 恒为 "/",会把 #/routes、#/targets 深链接(含刷新)全部弹回台账。
|
// currentRoute 恒为 "/",会把 #/routes、#/targets 深链接(含刷新)全部弹回台账。
|
||||||
isLogin.value = true;
|
isLogin.value = true;
|
||||||
|
|
||||||
|
const s = await checkSuperAdmin();
|
||||||
|
gate.value = s.gate;
|
||||||
|
userName.value = s.name;
|
||||||
|
if (s.gate === "error") {
|
||||||
|
// 探测不通不拦(本地无平台 Cookie / userbase 故障),仅留痕便于排障
|
||||||
|
console.warn("[superGate] CurrentUser 探测失败,按放行处理", );
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const getRouterKey = (route: any) => route?.name;
|
const getRouterKey = (route: any) => route?.name;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<layout v-if="isLogin" :layoutData="layoutData"></layout>
|
<template v-if="gateReady && !denied">
|
||||||
<div v-if="isLogin" class="base-wrap-main">
|
<layout v-if="isLogin" :layoutData="layoutData" :user-name="userName"></layout>
|
||||||
<router-view v-if="!!isRouterAlive" v-slot="{ Component, route }">
|
<div v-if="isLogin" class="base-wrap-main">
|
||||||
<transition name="fade" mode="out-in">
|
<router-view v-if="!!isRouterAlive" v-slot="{ Component, route }">
|
||||||
<component :is="Component" :key="getRouterKey(route)" />
|
<transition name="fade" mode="out-in">
|
||||||
</transition>
|
<component :is="Component" :key="getRouterKey(route)" />
|
||||||
</router-view>
|
</transition>
|
||||||
|
</router-view>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- 非超级管理员:整页「暂无权限」(主内容区语义,页面级空态不带菜单/顶栏) -->
|
||||||
|
<div v-if="denied" class="gate-denied">
|
||||||
|
<div class="gate-denied-card">
|
||||||
|
<Icon type="ios-locked-outline" class="gate-denied-icon" />
|
||||||
|
<h3 class="gate-denied-title">暂无权限</h3>
|
||||||
|
<p class="gate-denied-desc">开放网关控制台仅对超级管理员开放{{ userName ? `,当前账号:${userName}` : "" }}</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -82,4 +110,38 @@ body {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 通道撑满模型(§4):列表页根节点 page-wrapper yh-fill,flex 纵向铺满、表格区实测高 */
|
||||||
|
.base-wrap-main .page-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 暂无权限页:整幅居中,配色全走主题令牌(禁写死品牌色,§1) */
|
||||||
|
.gate-denied {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--page-bg, #f5f7fa);
|
||||||
|
}
|
||||||
|
.gate-denied-card {
|
||||||
|
text-align: center;
|
||||||
|
padding: 48px 64px;
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
border-radius: 16px;
|
||||||
|
}
|
||||||
|
.gate-denied-icon {
|
||||||
|
font-size: 44px;
|
||||||
|
color: var(--brand);
|
||||||
|
}
|
||||||
|
.gate-denied-title {
|
||||||
|
margin: 16px 0 8px;
|
||||||
|
font-size: 18px;
|
||||||
|
color: var(--text-main, #17233d);
|
||||||
|
}
|
||||||
|
.gate-denied-desc {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-sub, #808695);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ export default {
|
|||||||
'GatewayAdmin.createApp': ['POST', '/api/internal/admin/apps'],
|
'GatewayAdmin.createApp': ['POST', '/api/internal/admin/apps'],
|
||||||
'GatewayAdmin.updateApp': ['PUT', '/api/internal/admin/apps/{id}'],
|
'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'],
|
// 重载注册表:走浏览器面孪生入口。机器面 /api/internal/routes/refresh 只认内部令牌,
|
||||||
|
// 浏览器带 CAS 会话去打它会被 401 拦死(两口共用 RegistryReloader,返回体同形)
|
||||||
|
'GatewayAdmin.reload': ['POST', '/api/internal/admin/routes/reload'],
|
||||||
|
'GatewayAdmin.session': ['GET', '/api/internal/admin/session'],
|
||||||
'GatewayAdmin.proxyTest': ['POST', '/api/internal/admin/proxy-test'],
|
'GatewayAdmin.proxyTest': ['POST', '/api/internal/admin/proxy-test'],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -480,6 +480,27 @@ function mockProxyTest(body: any): { code: number; msg: string; data: any } {
|
|||||||
export async function handleGatewayMockRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
export async function handleGatewayMockRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
||||||
const url = new URL(req.url ?? "/", "http://mock.local");
|
const url = new URL(req.url ?? "/", "http://mock.local");
|
||||||
const pathname = url.pathname;
|
const pathname = url.pathname;
|
||||||
|
|
||||||
|
// 超管门槛探测(src/utils/superGate.ts):dev:mock 离线可演示两态——
|
||||||
|
// 默认超管放行;起服务前设 YH_MOCK_SUPER=0 演示非超管拦截态。
|
||||||
|
if (pathname === "/userbase/base/User/CurrentUser") {
|
||||||
|
const superAdmin = process.env.YH_MOCK_SUPER !== "0";
|
||||||
|
send(res, ok({ isSuperAdmin: superAdmin, name: superAdmin ? "mock超级管理员" : "mock普通账号" }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 外观三键同源读(对接规范 §3;线上=网关 AppearanceProxyController 透传 userhome):
|
||||||
|
// 【裸 payload 无信封】——init 见 code 字段会当异常形态跳过收敛;
|
||||||
|
// theme/avatar 给生效值即离线走查三键收敛与 yh-appearance-updated 事件。
|
||||||
|
if (pathname === "/open-gateway/api/appearance") {
|
||||||
|
send(res, {
|
||||||
|
theme: { theme: "blue", sideDeep: false, gradOn: true, sideFollow: true },
|
||||||
|
avatar: "text",
|
||||||
|
logoUrl: null,
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
if (!pathname.startsWith("/open-gateway/api/")) return false;
|
if (!pathname.startsWith("/open-gateway/api/")) return false;
|
||||||
|
|
||||||
const api = pathname.slice("/open-gateway".length);
|
const api = pathname.slice("/open-gateway".length);
|
||||||
@@ -498,10 +519,15 @@ export async function handleGatewayMockRequest(req: IncomingMessage, res: Server
|
|||||||
send(res, ok(listLogs(q)));
|
send(res, ok(listLogs(q)));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (api === "/api/internal/routes/refresh" && method === "POST") {
|
if (api === "/api/internal/admin/routes/reload" && method === "POST") {
|
||||||
send(res, ok({ loaded: routes.length, targets: targets.length }));
|
send(res, ok({ loaded: routes.length, targets: targets.length }));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (api === "/api/internal/admin/session" && method === "GET") {
|
||||||
|
// ssoHost 留空:mock 没有真 SSO,前端据此隐藏「退出登录」(同 Notice 的 ssoHost 空态行为)
|
||||||
|
send(res, ok({ ssoHost: "", principal: "mock 治理" }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (api === "/api/internal/admin/proxy-test" && method === "POST") {
|
if (api === "/api/internal/admin/proxy-test" && method === "POST") {
|
||||||
const body = await readJsonBody(req);
|
const body = await readJsonBody(req);
|
||||||
if (body === undefined) {
|
if (body === undefined) {
|
||||||
|
|||||||
@@ -115,3 +115,21 @@ h4.subtitle {
|
|||||||
margin-right: 4px;
|
margin-right: 4px;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================= 表格操作列(utils/tableActions.ts)=================
|
||||||
|
.op-cell:flex 横排容器——把 iView Tooltip/Dropdown 的块级包装层降级为 flex item,
|
||||||
|
强制横排(根因:.operating-btn 在通道基线 display:block,.ivu-tooltip 包装层默认 block,
|
||||||
|
逐钮竖排;不要再在 cell 上动 display,会牵连其它列)。
|
||||||
|
先落工程内,四端同构后此段上提 YschTheme 主题层。 */
|
||||||
|
.op-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
|
||||||
|
// 链间距统一由容器 gap 管理,退役通道基线的 padding-right:10px
|
||||||
|
.operating-link {
|
||||||
|
padding-right: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
<div class="table-region" ref="regionRef">
|
||||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||||
<template #loading>
|
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||||
<div class="table-spin-load">加载中</div>
|
<template #loading>
|
||||||
</template>
|
<div class="table-spin-load">加载中</div>
|
||||||
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
</template>
|
||||||
<slot :name="slotName" v-bind="slotProps" />
|
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
||||||
</template>
|
<slot :name="slotName" v-bind="slotProps" />
|
||||||
</Table>
|
</template>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="page-panel">
|
<div class="page-panel">
|
||||||
<div class="fl align-middle">
|
<div class="fl ml-15 align-middle">
|
||||||
<slot :checkList="selectList" />
|
<slot :checkList="selectList" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -21,8 +23,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, useSlots } from 'vue'
|
import { computed, ref, useSlots, watch } from 'vue'
|
||||||
import Utils from '@/utils'
|
import { useElementHeight } from '@/utils/hook/useElementHeight'
|
||||||
import { OperatorEnum } from '@/types'
|
import { OperatorEnum } from '@/types'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -44,7 +46,10 @@ const emit = defineEmits<{
|
|||||||
}>()
|
}>()
|
||||||
const slots = useSlots()
|
const slots = useSlots()
|
||||||
|
|
||||||
const tableH = ref(props.tableHeight ?? Utils.useTableHeight())
|
const regionRef = ref<HTMLElement | null>(null)
|
||||||
|
const measuredH = useElementHeight(regionRef)
|
||||||
|
// 显式传 tableHeight 则尊重之;否则实测 .table-region(flex:1)高度喂固定表头,首帧未测出前用 undefined(auto)
|
||||||
|
const tableH = computed(() => props.tableHeight ?? (measuredH.value || undefined))
|
||||||
const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
|
const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
|
||||||
|
|
||||||
const selectList = ref<number[]>([])
|
const selectList = ref<number[]>([])
|
||||||
@@ -68,18 +73,30 @@ const {
|
|||||||
total: res => props.formatResponse(res).total,
|
total: res => props.formatResponse(res).total,
|
||||||
data: res => props.formatResponse(res).list,
|
data: res => props.formatResponse(res).list,
|
||||||
initialPage: 1,
|
initialPage: 1,
|
||||||
initialPageSize: 10,
|
// 页长记忆(bsch 应用页规范 §3:customPageCount localStorage)
|
||||||
|
initialPageSize: parseInt(localStorage.getItem('customPageCount') || '10') || 10,
|
||||||
immediate: props.autoLoad,
|
immediate: props.autoLoad,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// 数据重载后清空勾选,避免批量操作用到旧选中行
|
||||||
|
watch(resp, () => {
|
||||||
|
if (selectList.value.length) {
|
||||||
|
selectList.value = []
|
||||||
|
emit('selection-change', selectList.value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const list = computed(() => {
|
const list = computed(() => {
|
||||||
if (!resp.value) return []
|
if (!resp.value) return []
|
||||||
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
|
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
|
||||||
})
|
})
|
||||||
|
|
||||||
const onPageChange = (p: number) => (page.value = p)
|
const onPageChange = (p: number) => (page.value = p)
|
||||||
const onSizeChange = (s: number) => (pageSize.value = s)
|
const onSizeChange = (s: number) => {
|
||||||
|
pageSize.value = s
|
||||||
|
localStorage.setItem('customPageCount', String(s))
|
||||||
|
}
|
||||||
|
|
||||||
function refreshData() {
|
function refreshData() {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
|
|||||||
@@ -1,274 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
/* 管理端配色切换器(yh-theme 定稿 07-seam-pure 顶栏「配色」胶囊,方案 §6.5)。
|
|
||||||
* 状态唯一来源:utils/theme(localStorage 持久化 + <html> 档位类名),与门户皮肤包正交。 */
|
|
||||||
import { onBeforeUnmount, onMounted, ref } from "vue";
|
|
||||||
import {
|
|
||||||
loadThemeState,
|
|
||||||
saveThemeState,
|
|
||||||
applyThemeState,
|
|
||||||
type ThemeName,
|
|
||||||
type ThemeState,
|
|
||||||
} from "@/utils/theme";
|
|
||||||
|
|
||||||
const THEME_META: { name: ThemeName; label: string; color: string }[] = [
|
|
||||||
{ name: "blue", label: "原版靛蓝", color: "#577afb" },
|
|
||||||
{ name: "ocean", label: "云海蓝", color: "#2f7ce0" },
|
|
||||||
{ name: "ink", label: "石墨墨", color: "#3d4d63" },
|
|
||||||
{ name: "violet", label: "靛紫", color: "#6d5ae0" },
|
|
||||||
{ name: "teal", label: "松石绿", color: "#0d9488" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const wrapEl = ref<HTMLElement | null>(null);
|
|
||||||
const open = ref(false);
|
|
||||||
const state = ref<ThemeState>(loadThemeState());
|
|
||||||
/* 挂载即把存储态落到 <html>(与 main.ts initTheme 同源幂等;组件晚于引导挂载时也自洽) */
|
|
||||||
applyThemeState(state.value);
|
|
||||||
|
|
||||||
function commit(patch: Partial<ThemeState>) {
|
|
||||||
state.value = { ...state.value, ...patch };
|
|
||||||
applyThemeState(state.value);
|
|
||||||
saveThemeState(state.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggle(ev: MouseEvent) {
|
|
||||||
ev.stopPropagation();
|
|
||||||
open.value = !open.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 切换器外任意点击即收起(弹层内部点击由 wrap 判定放过) */
|
|
||||||
function onDocClick(ev: MouseEvent) {
|
|
||||||
if (open.value && wrapEl.value && !wrapEl.value.contains(ev.target as Node)) {
|
|
||||||
open.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => document.addEventListener("click", onDocClick, true));
|
|
||||||
onBeforeUnmount(() => document.removeEventListener("click", onDocClick, true));
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div ref="wrapEl" class="theme-wrap">
|
|
||||||
<button type="button" class="theme-btn" :class="{ on: open }" @click="toggle">
|
|
||||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M12 3a9 9 0 1 0 0 18c1.2 0 1.9-.9 1.9-1.8 0-.6-.3-1-.5-1.4-.2-.4-.4-.8-.4-1.3 0-.9.8-1.7 1.8-1.7H17a4 4 0 0 0 4-4c0-4.3-4-7.8-9-7.8z"/>
|
|
||||||
<circle cx="7.6" cy="11.2" r="1.1"/><circle cx="11" cy="7.6" r="1.1"/><circle cx="15.4" cy="8.6" r="1.1"/>
|
|
||||||
</svg>
|
|
||||||
<span>配色</span>
|
|
||||||
<svg class="caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
|
|
||||||
<path d="M5.5 9.5l6.5 6.5 6.5-6.5"/>
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="theme-pop" :class="{ open }">
|
|
||||||
<p class="tp-head">主题色</p>
|
|
||||||
<div class="tp-grid">
|
|
||||||
<button
|
|
||||||
v-for="t in THEME_META"
|
|
||||||
:key="t.name"
|
|
||||||
type="button"
|
|
||||||
class="theme-opt"
|
|
||||||
:class="{ active: state.theme === t.name }"
|
|
||||||
@click="commit({ theme: t.name })"
|
|
||||||
>
|
|
||||||
<i :style="{ background: t.color }"></i>{{ t.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="tp-sep"></div>
|
|
||||||
<p class="tp-head">侧栏样式</p>
|
|
||||||
<label class="tp-row">
|
|
||||||
<span>侧栏跟随顶栏色</span>
|
|
||||||
<span class="sw">
|
|
||||||
<input type="checkbox" :checked="state.sideFollow" @change="commit({ sideFollow: ($event.target as HTMLInputElement).checked })" />
|
|
||||||
<i></i>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<label class="tp-row" style="margin-top: 9px">
|
|
||||||
<span>侧栏深色(关=浅色)</span>
|
|
||||||
<span class="sw">
|
|
||||||
<input type="checkbox" :checked="state.sideDeep" @change="commit({ sideDeep: ($event.target as HTMLInputElement).checked })" />
|
|
||||||
<i></i>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
<div class="tp-sep"></div>
|
|
||||||
<label class="tp-row">
|
|
||||||
<span>顶部过渡色(渐变)</span>
|
|
||||||
<span class="sw">
|
|
||||||
<input type="checkbox" :checked="state.gradOn" @change="commit({ gradOn: ($event.target as HTMLInputElement).checked })" />
|
|
||||||
<i></i>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
/* 皮肤规格照搬定稿 07-seam-pure.html .theme-wrap/.theme-btn/.theme-pop 段 */
|
|
||||||
.theme-wrap {
|
|
||||||
position: relative;
|
|
||||||
height: 100%;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
.theme-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
height: 30px;
|
|
||||||
padding: 0 11px;
|
|
||||||
cursor: pointer;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.34);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(255, 255, 255, 0.14);
|
|
||||||
color: #fff;
|
|
||||||
font-size: 12.5px;
|
|
||||||
transition: background 0.16s, border-color 0.16s;
|
|
||||||
}
|
|
||||||
.theme-btn:hover,
|
|
||||||
.theme-btn.on {
|
|
||||||
background: rgba(255, 255, 255, 0.26);
|
|
||||||
border-color: rgba(255, 255, 255, 0.6);
|
|
||||||
}
|
|
||||||
.theme-btn svg {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
display: block;
|
|
||||||
fill: none;
|
|
||||||
stroke: currentColor;
|
|
||||||
stroke-width: 1.7;
|
|
||||||
stroke-linecap: round;
|
|
||||||
stroke-linejoin: round;
|
|
||||||
}
|
|
||||||
.theme-btn svg circle {
|
|
||||||
fill: currentColor;
|
|
||||||
stroke: none;
|
|
||||||
}
|
|
||||||
.theme-btn .caret {
|
|
||||||
width: 11px;
|
|
||||||
height: 11px;
|
|
||||||
opacity: 0.85;
|
|
||||||
transition: transform 0.18s;
|
|
||||||
}
|
|
||||||
.theme-btn.on .caret {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
.theme-pop {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 6px);
|
|
||||||
right: 0;
|
|
||||||
width: 224px;
|
|
||||||
background: #fff;
|
|
||||||
color: var(--text, #1e2635);
|
|
||||||
border-radius: 12px;
|
|
||||||
border: 1px solid var(--line, #e9edf5);
|
|
||||||
padding: 12px;
|
|
||||||
box-shadow: 0 12px 34px -10px rgba(16, 24, 40, 0.28), 0 2px 6px rgba(16, 24, 40, 0.08);
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
transform: translateY(-6px);
|
|
||||||
transition: 0.18s;
|
|
||||||
z-index: 40;
|
|
||||||
}
|
|
||||||
.theme-pop.open {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
.tp-head {
|
|
||||||
font-size: 11.5px;
|
|
||||||
color: var(--text-3, #98a1b2);
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
margin: 0 2px 8px;
|
|
||||||
}
|
|
||||||
.tp-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 1fr 1fr;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.theme-opt {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 7px;
|
|
||||||
height: 32px;
|
|
||||||
padding: 0 8px;
|
|
||||||
cursor: pointer;
|
|
||||||
text-align: left;
|
|
||||||
border: 1px solid var(--line, #e9edf5);
|
|
||||||
border-radius: 8px;
|
|
||||||
background: #fff;
|
|
||||||
color: var(--text-2, #5a6478);
|
|
||||||
font-size: 12px;
|
|
||||||
transition: 0.15s;
|
|
||||||
font-family: inherit;
|
|
||||||
}
|
|
||||||
.theme-opt:hover {
|
|
||||||
border-color: #cfd7e6;
|
|
||||||
background: #fafbfe;
|
|
||||||
}
|
|
||||||
.theme-opt i {
|
|
||||||
width: 13px;
|
|
||||||
height: 13px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
.theme-opt.active {
|
|
||||||
border-color: var(--brand, #577afb);
|
|
||||||
color: var(--brand, #577afb);
|
|
||||||
background: var(--brand-soft, #eef2ff);
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
.tp-sep {
|
|
||||||
height: 1px;
|
|
||||||
background: var(--line, #e9edf5);
|
|
||||||
margin: 12px 0 10px;
|
|
||||||
}
|
|
||||||
.tp-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
font-size: 12.5px;
|
|
||||||
color: var(--text-2, #5a6478);
|
|
||||||
cursor: pointer;
|
|
||||||
padding: 0 2px;
|
|
||||||
}
|
|
||||||
.sw {
|
|
||||||
position: relative;
|
|
||||||
width: 38px;
|
|
||||||
height: 21px;
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
.sw input {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
opacity: 0;
|
|
||||||
margin: 0;
|
|
||||||
cursor: pointer;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
.sw i {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: #d7dce6;
|
|
||||||
transition: background 0.2s;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
.sw i::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
top: 2.5px;
|
|
||||||
left: 2.5px;
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #fff;
|
|
||||||
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.28);
|
|
||||||
transition: transform 0.2s;
|
|
||||||
}
|
|
||||||
.sw input:checked + i {
|
|
||||||
background: var(--brand, #577afb);
|
|
||||||
}
|
|
||||||
.sw input:checked + i::after {
|
|
||||||
transform: translateX(17px);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
+59
-15
@@ -1,13 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
import { useRoute } from "vue-router";
|
import { useRoute } from "vue-router";
|
||||||
import { LayoutData } from "@/types/Basics";
|
import { LayoutData } from "@/types/Basics";
|
||||||
import Utils from "@/utils";
|
import Utils from "@/utils";
|
||||||
import config from "@/assets/config/host.config";
|
import config from "@/assets/config/host.config";
|
||||||
import ThemeSwitcher from "@/components/ThemeSwitcher.vue";
|
import defaultLogo from "@/assets/image/yunhai-logo.svg";
|
||||||
import UserAvatar from "@/components/UserAvatar.vue";
|
import UserAvatar from "@/components/UserAvatar.vue";
|
||||||
|
|
||||||
const props = defineProps<{ layoutData: LayoutData }>();
|
const props = defineProps<{ layoutData: LayoutData; userName?: string }>();
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
|
|
||||||
const modList = computed(() => props.layoutData.modList || []);
|
const modList = computed(() => props.layoutData.modList || []);
|
||||||
@@ -19,17 +19,35 @@ const currTitle = computed(() => (route.meta as any)?.title || "");
|
|||||||
/** mock 模式徽标:仅 --mode mock(VITE_IS_MOCK=true)时渲染,dev/线上恒不出现 */
|
/** mock 模式徽标:仅 --mode mock(VITE_IS_MOCK=true)时渲染,dev/线上恒不出现 */
|
||||||
const isMock = computed(() => !!config.isMock);
|
const isMock = computed(() => !!config.isMock);
|
||||||
|
|
||||||
/** 顶栏操作者占位(网关无用户体系;三态头像取首字「网」) */
|
/** 顶栏操作者:超管门槛取 CurrentUser.name,取不到(mock/error 态)兜底「网关治理」 */
|
||||||
const opName = "网关治理";
|
const opName = computed(() => props.userName || "网关治理");
|
||||||
|
|
||||||
/* 重载注册表(POST /api/internal/routes/refresh):网关热重载路由+目标快照,无须重启 */
|
// 顶栏 Logo(外观三键 §3):localStorage["yh-logo-url"](平台键,yh-theme-init 异步补齐段写入)
|
||||||
|
// 优先,本仓资产兜底——绝无空 src/裂图。刷新:mounted 读一次 + 监听
|
||||||
|
// yh-appearance-updated(服务端偏好到达后即时切换,无需刷新页面)。照 Notice layout 同款。
|
||||||
|
const logoUrl = ref<string>(defaultLogo);
|
||||||
|
const readLogoUrl = () => {
|
||||||
|
let v: string | null = null;
|
||||||
|
try {
|
||||||
|
v = localStorage.getItem("yh-logo-url");
|
||||||
|
} catch {
|
||||||
|
v = null;
|
||||||
|
}
|
||||||
|
logoUrl.value = v || defaultLogo;
|
||||||
|
};
|
||||||
|
const onLogoError = () => {
|
||||||
|
if (logoUrl.value !== defaultLogo) logoUrl.value = defaultLogo;
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 重载注册表(POST /api/internal/admin/routes/reload):治理面入口,与机器面
|
||||||
|
/api/internal/routes/refresh 共用后端实现;机器面那条只认内部令牌,浏览器不能再打它 */
|
||||||
const reloading = ref(false);
|
const reloading = ref(false);
|
||||||
const lastReloadTime = ref("");
|
const lastReloadTime = ref("");
|
||||||
|
|
||||||
async function reloadRegistry() {
|
async function reloadRegistry() {
|
||||||
reloading.value = true;
|
reloading.value = true;
|
||||||
try {
|
try {
|
||||||
const d: any = await Apis.GatewayAdmin.refresh({});
|
const d: any = await Apis.GatewayAdmin.reload({});
|
||||||
Message.success(`已重载:路由 ${d?.loaded ?? "-"} 条 / 目标 ${d?.targets ?? "-"} 条 / 应用 ${d?.apps ?? "-"} 条`);
|
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) {
|
||||||
@@ -39,6 +57,29 @@ async function reloadRegistry() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* SSO 基址:网关后端无 env-config 端点(conf.ssoHost 恒空),改从已登录会话里取。
|
||||||
|
取不到(未配 CAS / mock)则不渲染登出入口——与 Notice 的 ssoHost 空态一致。 */
|
||||||
|
const ssoHost = ref("");
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
readLogoUrl();
|
||||||
|
document.addEventListener("yh-appearance-updated", readLogoUrl);
|
||||||
|
try {
|
||||||
|
const d: any = await Apis.GatewayAdmin.session({});
|
||||||
|
ssoHost.value = d?.ssoHost || "";
|
||||||
|
} catch (e: any) {
|
||||||
|
// 未登录时这条会被 alova 整页跳去 SSO;其它失败只是没有登出入口,不额外弹
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", readLogoUrl));
|
||||||
|
|
||||||
|
/* 登出(逐字照 Notice 的 layout.vue):打 CAS 平台级注销,service 回当前控制台地址。
|
||||||
|
注意这是平台级动作——会连带注销该浏览器在 CAS 下其他应用的会话(Notice 同此语义)。 */
|
||||||
|
function logout() {
|
||||||
|
window.location.href = ssoHost.value + "/logout?service=" + window.location.href;
|
||||||
|
}
|
||||||
|
|
||||||
/* 手动刷新:重建当前页面(App.vue 的 provide("reload")),列表回到服务端真值 */
|
/* 手动刷新:重建当前页面(App.vue 的 provide("reload")),列表回到服务端真值 */
|
||||||
const reloadPage = inject("reload", () => {}) as () => void;
|
const reloadPage = inject("reload", () => {}) as () => void;
|
||||||
</script>
|
</script>
|
||||||
@@ -46,8 +87,9 @@ const reloadPage = inject("reload", () => {}) as () => void;
|
|||||||
<template>
|
<template>
|
||||||
<div class="layout-wrap">
|
<div class="layout-wrap">
|
||||||
<header class="header-container fix">
|
<header class="header-container fix">
|
||||||
<!-- 云海 logo 徽标(结构与 Notice 对齐,皮肤见通道 base.layout.css .logo-badge) -->
|
<!-- 云海 logo 徽标(结构与 Notice 对齐,皮肤见通道 base.layout.css .logo-badge);
|
||||||
<span class="logo-badge fl"><img src="@/assets/image/yunhai-logo.svg" alt="云海" /></span>
|
外观三键 §3:平台键 yh-logo-url 优先、本仓资产兜底,加载失败回本仓资产 -->
|
||||||
|
<span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
|
||||||
<div class="delimiter fl align-middle"></div>
|
<div class="delimiter fl align-middle"></div>
|
||||||
<h4 class="fl align-middle">{{ layoutData.appName }}</h4>
|
<h4 class="fl align-middle">{{ layoutData.appName }}</h4>
|
||||||
<div class="school-bg"></div>
|
<div class="school-bg"></div>
|
||||||
@@ -62,9 +104,9 @@ const reloadPage = inject("reload", () => {}) as () => void;
|
|||||||
<div class="header-aside fr">
|
<div class="header-aside fr">
|
||||||
<UserAvatar class="fl align-middle" :name="opName" :size="28" placement="brand" />
|
<UserAvatar class="fl align-middle" :name="opName" :size="28" placement="brand" />
|
||||||
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);">{{ opName }}</a>
|
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);">{{ opName }}</a>
|
||||||
|
<!-- 登出入口:仅在拿到 SSO 基址时出现(未接 CAS/本地 mock 恒不渲染) -->
|
||||||
|
<a v-if="ssoHost" class="logout fl align-middle" href="javascript:void(0);" @click="logout">退出</a>
|
||||||
</div>
|
</div>
|
||||||
<!-- 配色切换器(fr 顺序:DOM 后置即位于用户胶囊左侧) -->
|
|
||||||
<div class="theme-cap fr"><ThemeSwitcher /></div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="left-menu">
|
<div class="left-menu">
|
||||||
@@ -97,10 +139,6 @@ const reloadPage = inject("reload", () => {}) as () => void;
|
|||||||
.header-container .logo-badge {
|
.header-container .logo-badge {
|
||||||
margin: 0 10px 0 0;
|
margin: 0 10px 0 0;
|
||||||
}
|
}
|
||||||
.theme-cap {
|
|
||||||
height: var(--topbar-h);
|
|
||||||
margin-right: 6px;
|
|
||||||
}
|
|
||||||
/* 面包屑「首页」垂直居中:inline-flex 真居中并关掉骨架给老 iconfont 字体的基线补偿 */
|
/* 面包屑「首页」垂直居中:inline-flex 真居中并关掉骨架给老 iconfont 字体的基线补偿 */
|
||||||
.crumb-home {
|
.crumb-home {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -134,4 +172,10 @@ const reloadPage = inject("reload", () => {}) as () => void;
|
|||||||
border: 1px solid rgba(255, 153, 0, 0.65);
|
border: 1px solid rgba(255, 153, 0, 0.65);
|
||||||
color: #ffd49a;
|
color: #ffd49a;
|
||||||
}
|
}
|
||||||
|
/* 登出入口:贴用户胶囊右侧,沿用顶栏次要文字色(不做按钮,避免与「重载注册表」抢视觉权重) */
|
||||||
|
.header-aside .logout {
|
||||||
|
margin-left: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.75);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
+14
@@ -23,6 +23,15 @@ import "virtual:uno.css";
|
|||||||
import "virtual:svg-icons-register";
|
import "virtual:svg-icons-register";
|
||||||
|
|
||||||
import config from "@/assets/config/host.config";
|
import config from "@/assets/config/host.config";
|
||||||
|
import { consumeSsoReturnUrl } from "@/utils/ssoReturn";
|
||||||
|
|
||||||
|
/* 外观三键通道契约(§3):yh-theme-init 异步补齐段按「__APP_CONFIG__.serviceHost +
|
||||||
|
/api/appearance」取服务端真源。网关后端无 env-config 端点、__APP_CONFIG__ 恒空,
|
||||||
|
这里用 host.config 的同源推导值(生产 /open-gateway、dev 取 VITE_SERVICE_HOST)播种,
|
||||||
|
否则 init 会裸打域根 /api/appearance 404。模块脚本先于 DOMContentLoaded+delay 执行,时序安全。 */
|
||||||
|
const g = window as any;
|
||||||
|
g.__APP_CONFIG__ = g.__APP_CONFIG__ || {};
|
||||||
|
if (!g.__APP_CONFIG__.serviceHost) g.__APP_CONFIG__.serviceHost = config.serviceHost;
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
|
||||||
@@ -32,4 +41,9 @@ app.use(ViewUIPlus);
|
|||||||
app.config.globalProperties.$conf = config;
|
app.config.globalProperties.$conf = config;
|
||||||
app.config.globalProperties.$utils = utils;
|
app.config.globalProperties.$utils = utils;
|
||||||
|
|
||||||
|
// SSO 登录往返只能把浏览器送回控制台默认页(后端用固定 CAS service,见网关 SsoAddressing);
|
||||||
|
// 挂载前先换回跳转前记下的页签,避免首帧闪一下默认页。
|
||||||
|
const ssoReturn = consumeSsoReturnUrl();
|
||||||
|
if (ssoReturn) router.replace(ssoReturn);
|
||||||
|
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { createAlova, Method, StatesHook } from "alova";
|
import { createAlova, Method, StatesHook } from "alova";
|
||||||
import fetchAdapter from "alova/fetch";
|
import fetchAdapter from "alova/fetch";
|
||||||
import VueHook from "alova/vue";
|
import VueHook from "alova/vue";
|
||||||
|
import { rememberReturnUrl } from "@/utils/ssoReturn";
|
||||||
/* ============= 类型定义 ============= */
|
/* ============= 类型定义 ============= */
|
||||||
export interface RequestHooks {
|
export interface RequestHooks {
|
||||||
/** 前置票据/通用头注入 */
|
/** 前置票据/通用头注入 */
|
||||||
@@ -56,8 +57,14 @@ function errorHandle<T>(res: BschResponse<T>, status: number): T {
|
|||||||
return res.data ?? (null as T);
|
return res.data ?? (null as T);
|
||||||
case 302:
|
case 302:
|
||||||
console.log(res);
|
console.log(res);
|
||||||
|
// 后端 CAS 门已拼好登录地址(含 service=会话建立端点),整页跳过去即可。
|
||||||
|
// 跳转前记下当前地址:固定 service 的回跳只能落默认页,hash 页签靠前端记账换回
|
||||||
|
//(原因见 utils/ssoReturn.ts——不能照 Notice 把 hex 拼进固定 service)。
|
||||||
|
// 跳转后返回永不 resolve 的挂起 Promise:调用方 await 悬停,全局 onBizError 与
|
||||||
|
// 各页面 catch 都不再触发——否则会弹一个"redirect"错误提示(Notice 同款处理)。
|
||||||
|
rememberReturnUrl();
|
||||||
window.location.href = res.urlToRedirectTo!;
|
window.location.href = res.urlToRedirectTo!;
|
||||||
throw new Error("redirect");
|
return new Promise(() => {}) as T;
|
||||||
case 4:
|
case 4:
|
||||||
throw new Error(res.msg || "参数错误!");
|
throw new Error(res.msg || "参数错误!");
|
||||||
default:
|
default:
|
||||||
@@ -90,8 +97,11 @@ export function createBschAlova(baseURL: string, hooks: RequestHooks = {}, state
|
|||||||
injectHeaders(method);
|
injectHeaders(method);
|
||||||
|
|
||||||
method.config.credentials = "include";
|
method.config.credentials = "include";
|
||||||
// 通用头(网关管理面不识别 yschapirequest/yschreferer/currentRole,故不注入)
|
// 通用头。yschapirequest 是治理面 CAS 门识别 SPA 的标记头(与平台
|
||||||
|
// CasSsoApiRedirectFilter 同名同值):带它,未登录才返回 200+{code:302,urlToRedirectTo}
|
||||||
|
// 让本页去整页跳 SSO;不带,网关按裸调用方出 401 json。
|
||||||
method.config.headers = Object.assign(method.config.headers || {}, {
|
method.config.headers = Object.assign(method.config.headers || {}, {
|
||||||
|
yschapirequest: "1",
|
||||||
timeout: 20000,
|
timeout: 20000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ref, onMounted, onBeforeUnmount, watch, type Ref } from "vue";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 实测某容器元素的可用高度(px),随尺寸变化实时更新。
|
||||||
|
*
|
||||||
|
* 用途:表格页把 <Table> 放进 flex:1 的 .table-region 里,用本组合式量出该区域
|
||||||
|
* 的真实高度喂给 view-ui 的 `:height`(固定表头需要一个数值高度),从而彻底替代
|
||||||
|
* 旧的 useTableHeight()「视口高 - 一堆写死常量」的算法——加/删工具条、改顶栏高
|
||||||
|
* 都不再需要同步改任何魔法数字。
|
||||||
|
*
|
||||||
|
* @param el 目标容器 ref(模板里 ref 绑定的那个元素)
|
||||||
|
* @returns 响应式高度(px);挂载测量前为 0,调用方可 `:height="h || undefined"` 兜底
|
||||||
|
*/
|
||||||
|
export function useElementHeight(el: Ref<HTMLElement | null>): Ref<number> {
|
||||||
|
const height = ref(0);
|
||||||
|
let ro: ResizeObserver | null = null;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
if (el.value) height.value = Math.floor(el.value.clientHeight);
|
||||||
|
};
|
||||||
|
|
||||||
|
const observe = () => {
|
||||||
|
ro?.disconnect();
|
||||||
|
if (!el.value || typeof ResizeObserver === "undefined") return;
|
||||||
|
measure();
|
||||||
|
ro = new ResizeObserver(measure);
|
||||||
|
ro.observe(el.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(observe);
|
||||||
|
// v-if / v-show 切换导致容器重建时,重新挂观察
|
||||||
|
watch(el, observe);
|
||||||
|
onBeforeUnmount(() => ro?.disconnect());
|
||||||
|
|
||||||
|
return height;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* SSO 登录往返的「回到原页签」记账(纯前端)。
|
||||||
|
* <p>
|
||||||
|
* <b>为什么不是 Notice 的 {@code ;redirectToReferer=<hex>} 那套</b>:Notice 把 hex 拼进 service
|
||||||
|
* 参数,再靠校验过滤器从<b>实时请求 URI</b> 重算出同一个带 hex 的 service,两端逐字节一致才过得了
|
||||||
|
* CAS 的 ST 比对。本网关的 ctx 为空、外链前缀 {@code /open-gateway} 由 Caddy 剥掉,容器算不出
|
||||||
|
* 带前缀的地址,只能改用<b>固定 service</b>(见后端 {@code SsoAddressing})——固定值里塞不进每次
|
||||||
|
* 不同的 hex:登录时 service 带 A、校验时 service 是常量,CAS 判定 service 不一致 → 票据校验必失败
|
||||||
|
* → 又是一次 302,死循环。
|
||||||
|
* <p>
|
||||||
|
* 所以回跳目标不下服务端:整页跳去登录前把它记在 {@code sessionStorage}(同标签页、跨站点往返仍在),
|
||||||
|
* 回到控制台壳页时读出来换回原页签。副作用为零,且不引入任何服务端开放重定向面。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const RETURN_KEY = "ysch-return-url";
|
||||||
|
|
||||||
|
/** 整页跳去 SSO 之前调用:记下当前控制台地址(含 hash 路由) */
|
||||||
|
export function rememberReturnUrl(href: string = window.location.href): void {
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem(RETURN_KEY, href);
|
||||||
|
} catch (e: any) {
|
||||||
|
// 隐私模式/配额满:最坏只是回到默认页,不影响登录本身
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取出并清掉回跳目标,返回 vue-router 的 hash 路由段(如 {@code /ledger?pageIndex=2})。
|
||||||
|
* 只认「同 origin + 同一壳页」的地址,其余一律丢弃——防把用户导向站外或别的应用。
|
||||||
|
*/
|
||||||
|
export function consumeSsoReturnUrl(): string | null {
|
||||||
|
let raw: string | null = null;
|
||||||
|
try {
|
||||||
|
raw = sessionStorage.getItem(RETURN_KEY);
|
||||||
|
if (raw) sessionStorage.removeItem(RETURN_KEY);
|
||||||
|
} catch (e: any) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const u = new URL(raw);
|
||||||
|
if (u.origin !== window.location.origin) return null;
|
||||||
|
if (u.pathname !== window.location.pathname) return null;
|
||||||
|
const route = u.hash.replace(/^#\/?/, "/");
|
||||||
|
return route && route !== "/" ? route : null;
|
||||||
|
} catch (e: any) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* 超管门槛(治理面前置守卫):GET 同源 /userbase/base/User/CurrentUser,
|
||||||
|
* 响应 {code:0, data:{isSuperAdmin, ...}}——非超级管理员整页显示「暂无权限」。
|
||||||
|
*
|
||||||
|
* 为什么用裸 fetch 不走 Apis/alova:
|
||||||
|
* - alova 实例 baseUrl 挂在网关 serviceHost(/open-gateway 前缀)上,而这条是平台
|
||||||
|
* userbase 的接口;且全局 loading/onBizError 会在门槛探测期弹遮罩与错误 toast,
|
||||||
|
* 门槛态由页面自己渲染,不需要全局报错。
|
||||||
|
* - 相对路径生产恒成立:控制台与 userbase 同挂 base 域,页面 /open-gateway/front/
|
||||||
|
* 发 /userbase/... 即同源带 Cookie;本地 dev 由 vite /userbase 代理兜到 base 域,
|
||||||
|
* dev:mock 由内置 mock 直接应答(YH_MOCK_SUPER=0 演示非超管态)。
|
||||||
|
*
|
||||||
|
* 判据宽容解析:isSuperAdmin 兼容 true/1/"1"/"true"(UserBase 后端 type=1 口径透传)。
|
||||||
|
* 只有「拿到响应且明确非超管」才拦截(denied);接口不可达/非 JSON/非 2xx 视为
|
||||||
|
* error——本地无平台 Cookie、userbase 故障等场景不因此锁死治理面(门槛是前端体验层,
|
||||||
|
* 真正的访问控制是后端 CAS 门)。本地开发可在 .env.dev 设 VITE_SUPER_GATE=off 旁路。
|
||||||
|
*/
|
||||||
|
export type SuperGate = "pending" | "allowed" | "denied" | "error";
|
||||||
|
|
||||||
|
export interface CurrentUserState {
|
||||||
|
gate: SuperGate;
|
||||||
|
/** 接口 data 里的展示名(可空):顶栏姓名/头像首字用 */
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTruthyFlag(v: any): boolean {
|
||||||
|
return v === true || v === 1 || v === "1" || v === "true";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkSuperAdmin(): Promise<CurrentUserState> {
|
||||||
|
if (import.meta.env.DEV && import.meta.env.VITE_SUPER_GATE === "off") {
|
||||||
|
return { gate: "allowed", name: "" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch("/userbase/base/User/CurrentUser", {
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
if (!res.ok) return { gate: "error", name: "" };
|
||||||
|
const body: any = await res.json();
|
||||||
|
if (!body || body.code !== 0 || !body.data) return { gate: "error", name: "" };
|
||||||
|
const data = body.data;
|
||||||
|
const name = String(data.name ?? data.userName ?? data.nickname ?? "");
|
||||||
|
return { gate: isTruthyFlag(data.isSuperAdmin) ? "allowed" : "denied", name };
|
||||||
|
} catch {
|
||||||
|
return { gate: "error", name: "" };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
/* 本端跑 strict vue-tsc(front/MOCK.md),通道/四端同构源落本端的既有约定照 theme.ts 补
|
||||||
|
@ts-nocheck:正文与 UserBase 同构源保持逐字节一致(MD5 973393684783c740399b8eb9e97369bc),
|
||||||
|
仅顶部此二行 + 下方注释为本端补丁;strict 下两处告警均无害——
|
||||||
|
L16 的 h 被各渲染器形参遮蔽(导入冗余)、actionMore 的 it.on() 形参在 normalize 已闭包绑定。 */
|
||||||
|
/**
|
||||||
|
* 表格操作列统一原子(方案:outputs/table-action-buttons/操作列统一方案-20261010.md)
|
||||||
|
*
|
||||||
|
* 三条变化轴各自独立,每加一种形态 = 加 1 个渲染器 + RENDERERS 表加 1 行,不动既有分支:
|
||||||
|
* 轴1 形态 actionIcon / actionText / actionMore —— 只画节点,零策略
|
||||||
|
* 轴2 动作集 normalizeActions —— 纯数据:when 过滤 / disabled 归一 / danger 沉底
|
||||||
|
* 轴3 溢出 splitActions —— 纯函数:直出与「更多」分档,零 DOM 可单测
|
||||||
|
* 组装 actionCell / actionColumn —— 唯一懂 DOM 结构与表格列
|
||||||
|
*
|
||||||
|
* 形态由页面【显式声明】(mode: "icon" | "text",缺省 text),不按动作数自动切换——
|
||||||
|
* 工程间风格区分是既定口径(Notice=图标钮 / UserBase=文字链),各自恒定不漂移。
|
||||||
|
* 「更多▾」只是长尾收纳(动作数 ≥ maxInline+2 时启用),不改变形态、不分档。
|
||||||
|
* 菜单项纯文字不带图标(动作一多找不到贴切图标,文字更准);transfer 到 body,
|
||||||
|
* 避免 fixed/sticky 操作列裁剪与层叠上下文遮盖(原型已实证)。
|
||||||
|
*/
|
||||||
|
import { h, resolveComponent } from "vue";
|
||||||
|
|
||||||
|
export interface ActionItem {
|
||||||
|
/** 稳定标识:溢出分桶按 key 去重,必填 */
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
/** 点击回调,normalizeActions 阶段闭包绑定行数据 */
|
||||||
|
on: (row: any) => void;
|
||||||
|
/** iconfont 类名(仅 mode:"icon" 时消费;通道 icons.css 全集见 YschTheme) */
|
||||||
|
icon?: string;
|
||||||
|
/** 危险动作:文字转红(text-error)、恒沉底、「更多」内分隔线隔离 */
|
||||||
|
danger?: boolean;
|
||||||
|
/** 返回 false → 该行不渲染此动作(决定「有没有」) */
|
||||||
|
when?: (row: any) => boolean;
|
||||||
|
/** 直接置灰 */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** 按行判定置灰(决定「能不能点」),disabled 显式值优先 */
|
||||||
|
disabledWhen?: (row: any) => boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionCellOpts {
|
||||||
|
/** 形态显式声明:icon=图标圆钮 / text=文字链,缺省 text;不按动作数自动切换 */
|
||||||
|
mode?: "icon" | "text";
|
||||||
|
/** 超额动作收进「更多」,默认开(防 5+ 动作页面忘传爆宽) */
|
||||||
|
collapsible?: boolean;
|
||||||
|
/** 直出上限,默认 2;n ≤ maxInline+1 时全直出(3 个动作不进更多) */
|
||||||
|
maxInline?: number;
|
||||||
|
/** 列宽;缺省 icon = n*40+40 / text = 180 */
|
||||||
|
width?: number;
|
||||||
|
align?: "center" | "left" | "right";
|
||||||
|
/** 缺省不固定列(与存量页面一致);需要吸右显式传 "right" */
|
||||||
|
fixed?: "right" | "left" | false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 轴 1:渲染器(只画节点,零策略) ============ */
|
||||||
|
|
||||||
|
/** 图标圆钮:Tooltip > div.operating-btn(沿用通道既有 class,主题层零改动) */
|
||||||
|
export function actionIcon(h: any, item: ActionItem) {
|
||||||
|
return h(
|
||||||
|
resolveComponent("Tooltip"),
|
||||||
|
{ content: item.label, placement: "bottom", transfer: true },
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
h("div", {
|
||||||
|
class: `operating-btn iconfont ${item.icon}${item.disabled ? " disabled" : ""}`,
|
||||||
|
onClick: item.disabled ? null : item.on,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 文字链:danger 输出既有 text-error 类对接主题红色 */
|
||||||
|
export function actionText(h: any, item: ActionItem) {
|
||||||
|
return h(
|
||||||
|
"div",
|
||||||
|
{
|
||||||
|
class: `operating-link${item.danger ? " text-error" : ""}${item.disabled ? " disabled" : ""}`,
|
||||||
|
onClick: item.disabled ? null : item.on,
|
||||||
|
},
|
||||||
|
item.label,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 「更多▾」+ 下拉菜单:iView Dropdown transfer 模式,菜单挂 body */
|
||||||
|
export function actionMore(h: any, items: ActionItem[]) {
|
||||||
|
return h(
|
||||||
|
resolveComponent("Dropdown"),
|
||||||
|
{ class: "op-more", placement: "bottom-end", transfer: true },
|
||||||
|
{
|
||||||
|
default: () => [
|
||||||
|
h("div", { class: "operating-link op-more-label" }, [
|
||||||
|
"更多",
|
||||||
|
h(resolveComponent("Icon"), { type: "ios-arrow-down", class: "op-more-arrow" }),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
list: () => [
|
||||||
|
h(resolveComponent("DropdownMenu"), {}, {
|
||||||
|
default: () => items.map((it) =>
|
||||||
|
h(
|
||||||
|
resolveComponent("DropdownItem"),
|
||||||
|
{
|
||||||
|
key: it.key,
|
||||||
|
divided: !!(it.danger && items.length > 1),
|
||||||
|
disabled: !!it.disabled,
|
||||||
|
class: it.danger ? "op-item-danger" : "",
|
||||||
|
onClick: () => it.on(),
|
||||||
|
},
|
||||||
|
{ default: () => it.label },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 轴 2:归一化(纯数据) ============ */
|
||||||
|
export function normalizeActions(items: ActionItem[], row: any): ActionItem[] {
|
||||||
|
return items
|
||||||
|
.filter((it) => (it.when ? it.when(row) : true))
|
||||||
|
.map((it) => ({
|
||||||
|
...it,
|
||||||
|
disabled: it.disabled ?? (it.disabledWhen ? it.disabledWhen(row) : false),
|
||||||
|
on: () => it.on(row),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => Number(!!a.danger) - Number(!!b.danger)); // danger 恒沉底(稳定排序)
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 轴 3:溢出策略(纯函数,可单测、零 DOM) ============ */
|
||||||
|
export function splitActions(items: ActionItem[], maxInline = 2): { inline: ActionItem[]; overflow: ActionItem[] } {
|
||||||
|
if (items.length <= maxInline + 1) return { inline: items, overflow: [] };
|
||||||
|
const inline = items.filter((it) => !it.danger).slice(0, maxInline);
|
||||||
|
const inlineKeys = new Set(inline.map((it) => it.key));
|
||||||
|
return { inline, overflow: items.filter((it) => !inlineKeys.has(it.key)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ============ 组装层 ============ */
|
||||||
|
const RENDERERS: Record<string, (h: any, item: ActionItem) => any> = { icon: actionIcon, text: actionText };
|
||||||
|
|
||||||
|
export function actionCell(h: any, items: ActionItem[], opts: ActionCellOpts = {}) {
|
||||||
|
const mode = opts.mode ?? "text";
|
||||||
|
const { inline, overflow } = (opts.collapsible ?? true)
|
||||||
|
? splitActions(items, opts.maxInline ?? 2)
|
||||||
|
: { inline: items, overflow: [] };
|
||||||
|
|
||||||
|
const render = RENDERERS[mode] || actionText;
|
||||||
|
const children = inline.map((it) => render(h, it));
|
||||||
|
if (overflow.length) children.push(actionMore(h, overflow));
|
||||||
|
|
||||||
|
return h("div", { class: "op-cell" }, children); // flex 横排容器,见 main.scss
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 产出可直接塞进 columns 的操作列对象;列宽按「声明数」估算,when 过滤后只宽不窄 */
|
||||||
|
export function actionColumn(items: ActionItem[], opts: ActionCellOpts = {}) {
|
||||||
|
const mode = opts.mode ?? "text";
|
||||||
|
return {
|
||||||
|
title: "操作",
|
||||||
|
key: "action",
|
||||||
|
width: opts.width ?? (mode === "icon" ? items.length * 40 + 40 : 180),
|
||||||
|
align: opts.align ?? "center",
|
||||||
|
...(opts.fixed === undefined ? {} : { fixed: opts.fixed === false ? undefined : opts.fixed }),
|
||||||
|
render: (h: any, p: any) => actionCell(h, normalizeActions(items, p.row), opts),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,151 +0,0 @@
|
|||||||
/**
|
|
||||||
* 本文件由 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> 内先行挂好,本模块只负责读写与切换。
|
|
||||||
*/
|
|
||||||
export type ThemeName = "blue" | "ocean" | "ink" | "violet" | "teal";
|
|
||||||
|
|
||||||
/** 定稿默认档:blue + side-deep + grad-on + 侧栏跟随(D1 拍板 deep) */
|
|
||||||
export interface ThemeState {
|
|
||||||
theme: ThemeName;
|
|
||||||
/** 侧栏深色(false=浅色档) */
|
|
||||||
sideDeep: boolean;
|
|
||||||
/** 顶栏渐变(联动侧栏竖向渐变) */
|
|
||||||
gradOn: boolean;
|
|
||||||
/** 侧栏颜色跟随顶栏品牌色(false=中性灰 side-plain) */
|
|
||||||
sideFollow: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StorageLike {
|
|
||||||
getItem(k: string): string | null;
|
|
||||||
setItem(k: string, v: string): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const THEME_NAMES: readonly ThemeName[] = ["blue", "ocean", "ink", "violet", "teal"];
|
|
||||||
|
|
||||||
export const THEME_STORAGE_KEY = "yh-theme";
|
|
||||||
|
|
||||||
/* 学校默认档存储键(UserHome 登录时写入;值格式与 yh-theme 完全相同,仅无"用户自选"语义) */
|
|
||||||
export const SCHOOL_DEFAULT_STORAGE_KEY = "yh-theme-sch-default";
|
|
||||||
|
|
||||||
export const DEFAULT_THEME_STATE: ThemeState = {
|
|
||||||
theme: "blue",
|
|
||||||
sideDeep: true,
|
|
||||||
gradOn: true,
|
|
||||||
sideFollow: true
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 宽松解析:任一字段非法/缺失即回退默认档(脏数据不阻塞启动) */
|
|
||||||
export function parseThemeState(raw: string | null): ThemeState {
|
|
||||||
if (!raw) return { ...DEFAULT_THEME_STATE };
|
|
||||||
try {
|
|
||||||
const o = JSON.parse(raw);
|
|
||||||
if (!o || typeof o !== "object") return { ...DEFAULT_THEME_STATE };
|
|
||||||
return {
|
|
||||||
theme: THEME_NAMES.includes(o.theme) ? o.theme : DEFAULT_THEME_STATE.theme,
|
|
||||||
sideDeep: typeof o.sideDeep === "boolean" ? o.sideDeep : DEFAULT_THEME_STATE.sideDeep,
|
|
||||||
gradOn: typeof o.gradOn === "boolean" ? o.gradOn : DEFAULT_THEME_STATE.gradOn,
|
|
||||||
sideFollow: typeof o.sideFollow === "boolean" ? o.sideFollow : DEFAULT_THEME_STATE.sideFollow
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return { ...DEFAULT_THEME_STATE };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 严格读取:四字段全部合法才返回状态,否则 null(合成链"值非法→回落下一级"用;用户键仍走宽松解析保现状) */
|
|
||||||
function readThemeStateOrNull(raw: string | null): ThemeState | null {
|
|
||||||
if (!raw) return null;
|
|
||||||
try {
|
|
||||||
const o = JSON.parse(raw);
|
|
||||||
if (!o || typeof o !== "object") return null;
|
|
||||||
if (!THEME_NAMES.includes(o.theme)) return null;
|
|
||||||
if (typeof o.sideDeep !== "boolean" || typeof o.gradOn !== "boolean" || typeof o.sideFollow !== "boolean") return null;
|
|
||||||
return { theme: o.theme, sideDeep: o.sideDeep, gradOn: o.gradOn, sideFollow: o.sideFollow };
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** URL 覆盖参数(?yh-theme= / ?yh-side=deep|light / ?yh-grad=on|off):仅预览/排障用,
|
|
||||||
非法值忽略(对应字段不覆盖),且只影响"读哪一档"、绝不写回 localStorage。 */
|
|
||||||
function readThemeOverrides(): { theme?: ThemeName; sideDeep?: boolean; gradOn?: boolean } | null {
|
|
||||||
let out = null;
|
|
||||||
try {
|
|
||||||
const search = (globalThis.location && globalThis.location.search) || "";
|
|
||||||
const get = (name) => {
|
|
||||||
const m = new RegExp("[?&]" + name + "=([^&]*)").exec(search);
|
|
||||||
return m ? decodeURIComponent(m[1]) : null;
|
|
||||||
};
|
|
||||||
const t = get("yh-theme");
|
|
||||||
if (t && THEME_NAMES.includes(t)) {
|
|
||||||
out = out || {};
|
|
||||||
out.theme = t;
|
|
||||||
}
|
|
||||||
const s = get("yh-side");
|
|
||||||
if (s === "deep" || s === "light") {
|
|
||||||
out = out || {};
|
|
||||||
out.sideDeep = s === "deep";
|
|
||||||
}
|
|
||||||
const g = get("yh-grad");
|
|
||||||
if (g === "on" || g === "off") {
|
|
||||||
out = out || {};
|
|
||||||
out.gradOn = g === "on";
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* 无 location 环境(测试/SSR):无覆盖 */
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 生效档合成(唯一实现,init 启动段与 UserHome theme.ts 共用):
|
|
||||||
* URL 覆盖 > localStorage["yh-theme"](用户自选,存在即用——宽松修补,与历史行为一致)
|
|
||||||
* > localStorage["yh-theme-sch-default"](学校默认,整体合法才生效)
|
|
||||||
* > DEFAULT_THEME_STATE。
|
|
||||||
* 键不存在/解析失败/值非法均静默回落,不报错不阻塞首帧。
|
|
||||||
*/
|
|
||||||
export function resolveEffectiveThemeState(storage: StorageLike | undefined = globalThis.localStorage): ThemeState {
|
|
||||||
let state = null;
|
|
||||||
if (storage) {
|
|
||||||
const userRaw = storage.getItem(THEME_STORAGE_KEY);
|
|
||||||
if (userRaw) state = parseThemeState(userRaw);
|
|
||||||
if (!state) state = readThemeStateOrNull(storage.getItem(SCHOOL_DEFAULT_STORAGE_KEY));
|
|
||||||
}
|
|
||||||
if (!state) state = { ...DEFAULT_THEME_STATE };
|
|
||||||
const ov = readThemeOverrides();
|
|
||||||
if (ov) {
|
|
||||||
if (ov.theme) state = { ...state, theme: ov.theme };
|
|
||||||
if (ov.sideDeep != null) state = { ...state, sideDeep: ov.sideDeep };
|
|
||||||
if (ov.gradOn != null) state = { ...state, gradOn: ov.gradOn };
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loadThemeState(storage: StorageLike | undefined = globalThis.localStorage): ThemeState {
|
|
||||||
if (!storage) return { ...DEFAULT_THEME_STATE };
|
|
||||||
return parseThemeState(storage.getItem(THEME_STORAGE_KEY));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function saveThemeState(state: ThemeState, storage: StorageLike | undefined = globalThis.localStorage): void {
|
|
||||||
try {
|
|
||||||
storage?.setItem(THEME_STORAGE_KEY, JSON.stringify(state));
|
|
||||||
} catch {
|
|
||||||
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 把状态落到根元素档位类名(幂等:先清后挂),返回传入的 state 便于链式 */
|
|
||||||
export function applyThemeState(state: ThemeState, root: HTMLElement = document.documentElement): ThemeState {
|
|
||||||
root.dataset.theme = state.theme;
|
|
||||||
root.classList.toggle("side-deep", state.sideDeep);
|
|
||||||
root.classList.toggle("side-light", !state.sideDeep);
|
|
||||||
root.classList.toggle("grad-on", state.gradOn);
|
|
||||||
root.classList.toggle("grad-off", !state.gradOn);
|
|
||||||
root.classList.toggle("side-plain", !state.sideFollow);
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 启动引导:各端 main.ts(或本仓 init 脚本的启动段)于挂载前调用,消除首帧闪色 */
|
|
||||||
export function initTheme(): ThemeState {
|
|
||||||
return applyThemeState(loadThemeState());
|
|
||||||
}
|
|
||||||
+10
-12
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-wrapper">
|
<div class="page-wrapper yh-fill">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<span class="tip">
|
<span class="tip">
|
||||||
@@ -12,8 +12,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :dataFilter="normalizeRows"
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :dataFilter="normalizeRows">
|
||||||
:tableHeight="tableHeight">
|
|
||||||
<template #appKey="{ row }">
|
<template #appKey="{ row }">
|
||||||
<span v-if="row.appKey === '*'" class="window-tag">*(匿名窗口)</span>
|
<span v-if="row.appKey === '*'" class="window-tag">*(匿名窗口)</span>
|
||||||
<span v-else class="mono">{{ row.appKey }}</span>
|
<span v-else class="mono">{{ row.appKey }}</span>
|
||||||
@@ -45,9 +44,6 @@
|
|||||||
<template #gmtModified="{ row }">
|
<template #gmtModified="{ row }">
|
||||||
<span>{{ row.gmtModified ? Utils.dateFormat(row.gmtModified, 'yyyy-MM-dd HH:mm:ss') : '-' }}</span>
|
<span>{{ row.gmtModified ? Utils.dateFormat(row.gmtModified, 'yyyy-MM-dd HH:mm:ss') : '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #action="{ row }">
|
|
||||||
<Button type="primary" size="small" :loading="savingId === row.id" @click="save(row)">保存</Button>
|
|
||||||
</template>
|
|
||||||
</TablePageAlovaNew>
|
</TablePageAlovaNew>
|
||||||
|
|
||||||
<Modal v-model="createShow" :mask-closable="false" width="560" title="新增应用">
|
<Modal v-model="createShow" :mask-closable="false" width="560" title="新增应用">
|
||||||
@@ -85,22 +81,18 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { reactive, ref } from 'vue';
|
import { reactive, ref } from 'vue';
|
||||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
|
||||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||||
|
import { actionColumn } from '@/utils/tableActions';
|
||||||
import Utils from '@/utils';
|
import Utils from '@/utils';
|
||||||
|
|
||||||
defineOptions({ name: 'AppList' });
|
defineOptions({ name: 'AppList' });
|
||||||
|
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
const savingId = ref<number | null>(null);
|
const savingId = ref<number | null>(null);
|
||||||
|
|
||||||
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
|
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
|
||||||
Apis.GatewayAdmin.apps({ params, cacheFor: null });
|
Apis.GatewayAdmin.apps({ params, cacheFor: null });
|
||||||
|
|
||||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: 'id', key: 'id', width: 70, align: 'center' },
|
{ title: 'id', key: 'id', width: 70, align: 'center' },
|
||||||
{ title: 'appKey', slot: 'appKey', width: 160 },
|
{ title: 'appKey', slot: 'appKey', width: 160 },
|
||||||
@@ -110,7 +102,13 @@ const columns = [
|
|||||||
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
|
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
|
||||||
{ title: '备注', slot: 'remark', minWidth: 140 },
|
{ title: '备注', slot: 'remark', minWidth: 140 },
|
||||||
{ title: '更新时间', slot: 'gmtModified', width: 170 },
|
{ title: '更新时间', slot: 'gmtModified', width: 170 },
|
||||||
{ title: '操作', slot: 'action', width: 90, align: 'center', fixed: 'right' as const },
|
actionColumn(
|
||||||
|
[
|
||||||
|
// 保存按行数据置灰保位(§5.2 红线):本行提交中即灰,位置恒在
|
||||||
|
{ key: 'save', label: '保存', on: (r: any) => save(r), disabledWhen: (r: any) => savingId.value === r.id },
|
||||||
|
],
|
||||||
|
{ mode: 'text', width: 90, fixed: 'right' },
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/** 服务端 expireAt 为 ISO 串:补一行内编辑用的 "yyyy-MM-dd HH:mm:ss" 文本 */
|
/** 服务端 expireAt 为 ISO 串:补一行内编辑用的 "yyyy-MM-dd HH:mm:ss" 文本 */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-wrapper">
|
<div class="page-wrapper yh-fill">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<Input v-model="dataParams.targetCode" placeholder="target(如 notice)" clearable
|
<Input v-model="dataParams.targetCode" placeholder="target(如 notice)" clearable
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
<Button class="ml-12" @click="handleReset">重置</Button>
|
<Button class="ml-12" @click="handleReset">重置</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<LedgerDetailModal v-model="detailVisible" :row="currentRow" />
|
<LedgerDetailModal v-model="detailVisible" :row="currentRow" />
|
||||||
@@ -21,15 +21,13 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
|
||||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||||
import LedgerDetailModal from './LedgerDetailModal.vue';
|
import LedgerDetailModal from './LedgerDetailModal.vue';
|
||||||
|
import { actionColumn } from '@/utils/tableActions';
|
||||||
import Utils from '@/utils';
|
import Utils from '@/utils';
|
||||||
|
|
||||||
defineOptions({ name: 'LedgerList' });
|
defineOptions({ name: 'LedgerList' });
|
||||||
|
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
|
|
||||||
interface DataParams {
|
interface DataParams {
|
||||||
@@ -63,8 +61,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) =>
|
|||||||
cacheFor: null,
|
cacheFor: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
|
|
||||||
|
|
||||||
/** 状态码色阶:2xx 绿 / 4xx 琥珀 / 5xx 红 */
|
/** 状态码色阶:2xx 绿 / 4xx 琥珀 / 5xx 红 */
|
||||||
function statusClass(status: number) {
|
function statusClass(status: number) {
|
||||||
if (status >= 500) return 'text-error';
|
if (status >= 500) return 'text-error';
|
||||||
@@ -112,18 +108,12 @@ const columns = [
|
|||||||
align: 'right',
|
align: 'right',
|
||||||
render: (h: any, { row }: any) => h('span', row.costMs != null ? `${row.costMs} ms` : '-'),
|
render: (h: any, { row }: any) => h('span', row.costMs != null ? `${row.costMs} ms` : '-'),
|
||||||
},
|
},
|
||||||
{
|
actionColumn(
|
||||||
title: '操作',
|
[
|
||||||
key: 'action',
|
{ key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
|
||||||
width: 100,
|
],
|
||||||
align: 'center',
|
{ mode: 'text', width: 100, fixed: 'right' },
|
||||||
fixed: 'right' as const,
|
),
|
||||||
render: (h: any, { row }: any) =>
|
|
||||||
h('a', {
|
|
||||||
style: { color: '#577afb' },
|
|
||||||
onClick: () => openDetail(row),
|
|
||||||
}, '查看详情'),
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const detailVisible = ref(false);
|
const detailVisible = ref(false);
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-wrapper">
|
<div class="page-wrapper yh-fill">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<span class="tip">路由注册表(open_api_route):治理旋钮行内编辑,保存后网关热重载,无须重启。qps>0 时按 调用方×本行 限流(超限裸 429,0=不限);timeout/bodyLimit 立即生效。</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>
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight">
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns">
|
||||||
<template #visibility="{ row }">
|
<template #visibility="{ row }">
|
||||||
<Select v-model="row.visibility" size="small" style="width: 110px">
|
<Select v-model="row.visibility" size="small" style="width: 110px">
|
||||||
<Option value="INTERNAL">INTERNAL</Option>
|
<Option value="INTERNAL">INTERNAL</Option>
|
||||||
@@ -43,9 +43,6 @@
|
|||||||
<template #remark="{ row }">
|
<template #remark="{ row }">
|
||||||
<Input v-model="row.remark" size="small" placeholder="-" />
|
<Input v-model="row.remark" size="small" placeholder="-" />
|
||||||
</template>
|
</template>
|
||||||
<template #action="{ row }">
|
|
||||||
<Button type="primary" size="small" :loading="savingId === row.id" @click="save(row)">保存</Button>
|
|
||||||
</template>
|
|
||||||
</TablePageAlovaNew>
|
</TablePageAlovaNew>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,21 +50,17 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
|
||||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||||
|
import { actionColumn } from '@/utils/tableActions';
|
||||||
|
|
||||||
defineOptions({ name: 'RouteList' });
|
defineOptions({ name: 'RouteList' });
|
||||||
|
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
const savingId = ref<number | null>(null);
|
const savingId = ref<number | null>(null);
|
||||||
|
|
||||||
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
|
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
|
||||||
Apis.GatewayAdmin.routes({ params, cacheFor: null });
|
Apis.GatewayAdmin.routes({ params, cacheFor: null });
|
||||||
|
|
||||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: 'id', key: 'id', width: 70, align: 'center' },
|
{ title: 'id', key: 'id', width: 70, align: 'center' },
|
||||||
{ title: 'target', key: 'targetCode', width: 120, ellipsis: true, tooltip: true },
|
{ title: 'target', key: 'targetCode', width: 120, ellipsis: true, tooltip: true },
|
||||||
@@ -82,7 +75,13 @@ const columns = [
|
|||||||
{ title: 'bodyLimit(mb)', slot: 'requestBodyLimitMb', width: 125, 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 },
|
actionColumn(
|
||||||
|
[
|
||||||
|
// 保存按行数据置灰保位(§5.2 红线):本行提交中即灰,位置恒在
|
||||||
|
{ key: 'save', label: '保存', on: (r: any) => save(r), disabledWhen: (r: any) => savingId.value === r.id },
|
||||||
|
],
|
||||||
|
{ mode: 'text', width: 90, fixed: 'right' },
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="main-wrapper">
|
<div class="page-wrapper yh-fill">
|
||||||
<div class="main-container">
|
<div class="main-container">
|
||||||
<div class="search-wrap fix">
|
<div class="search-wrap fix">
|
||||||
<span class="tip">目标地址(open_api_target):base_url 与启停行内编辑,保存后网关热重载 target 快照。</span>
|
<span class="tip">目标地址(open_api_target):base_url 与启停行内编辑,保存后网关热重载 target 快照。</span>
|
||||||
<Button type="primary" class="fr" @click="refresh">刷新</Button>
|
<Button type="primary" class="fr" @click="refresh">刷新</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight">
|
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns">
|
||||||
<template #baseUrl="{ row }">
|
<template #baseUrl="{ row }">
|
||||||
<Input v-model="row.baseUrl" size="small" placeholder="http://{target}-service:{port}" />
|
<Input v-model="row.baseUrl" size="small" placeholder="http://{target}-service:{port}" />
|
||||||
</template>
|
</template>
|
||||||
@@ -16,9 +16,6 @@
|
|||||||
<template #gmtModified="{ row }">
|
<template #gmtModified="{ row }">
|
||||||
<span>{{ row.gmtModified ? Utils.dateFormat(row.gmtModified, 'yyyy-MM-dd HH:mm:ss') : '-' }}</span>
|
<span>{{ row.gmtModified ? Utils.dateFormat(row.gmtModified, 'yyyy-MM-dd HH:mm:ss') : '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
<template #action="{ row }">
|
|
||||||
<Button type="primary" size="small" :loading="savingId === row.id" @click="save(row)">保存</Button>
|
|
||||||
</template>
|
|
||||||
</TablePageAlovaNew>
|
</TablePageAlovaNew>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -26,29 +23,31 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
|
|
||||||
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
|
||||||
|
import { actionColumn } from '@/utils/tableActions';
|
||||||
import Utils from '@/utils';
|
import Utils from '@/utils';
|
||||||
|
|
||||||
defineOptions({ name: 'TargetList' });
|
defineOptions({ name: 'TargetList' });
|
||||||
|
|
||||||
const { $utils } = useCurrentInstance();
|
|
||||||
|
|
||||||
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
const savingId = ref<number | null>(null);
|
const savingId = ref<number | null>(null);
|
||||||
|
|
||||||
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
|
const tableApi = (params: { pageIndex: number; pageSize: number }) =>
|
||||||
Apis.GatewayAdmin.targets({ params, cacheFor: null });
|
Apis.GatewayAdmin.targets({ params, cacheFor: null });
|
||||||
|
|
||||||
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
|
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{ title: 'id', key: 'id', width: 70, align: 'center' },
|
{ title: 'id', key: 'id', width: 70, align: 'center' },
|
||||||
{ title: 'target', key: 'targetCode', width: 140, ellipsis: true, tooltip: true },
|
{ title: 'target', key: 'targetCode', width: 140, ellipsis: true, tooltip: true },
|
||||||
{ title: 'base_url', slot: 'baseUrl', minWidth: 300 },
|
{ title: 'base_url', slot: 'baseUrl', minWidth: 300 },
|
||||||
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
|
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
|
||||||
{ title: '更新时间', slot: 'gmtModified', width: 180 },
|
{ title: '更新时间', slot: 'gmtModified', width: 180 },
|
||||||
{ title: '操作', slot: 'action', width: 90, align: 'center', fixed: 'right' as const },
|
actionColumn(
|
||||||
|
[
|
||||||
|
// 保存按行数据置灰保位(§5.2 红线):本行提交中即灰,位置恒在
|
||||||
|
{ key: 'save', label: '保存', on: (r: any) => save(r), disabledWhen: (r: any) => savingId.value === r.id },
|
||||||
|
],
|
||||||
|
{ mode: 'text', width: 90, fixed: 'right' },
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
function refresh() {
|
function refresh() {
|
||||||
|
|||||||
@@ -138,6 +138,14 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
|
|||||||
target: process.env.YH_THEME_PROXY_TARGET || "http://base.yunhaixingtong.com",
|
target: process.env.YH_THEME_PROXY_TARGET || "http://base.yunhaixingtong.com",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
// 超管门槛探测(/userbase/base/User/CurrentUser,见 src/utils/superGate.ts):
|
||||||
|
// 生产页面与 userbase 同挂 base 域、同源相对路径直连;本地 dev 经此代理兜到
|
||||||
|
// base 域(无平台 Cookie 时探测失败按放行处理,可用 VITE_SUPER_GATE=off 旁路),
|
||||||
|
// dev:mock 由内置 mock 中间件先于代理应答(YH_MOCK_SUPER=0 演示非超管态)。
|
||||||
|
"/userbase": {
|
||||||
|
target: process.env.YH_UB_PROXY_TARGET || "http://base.yunhaixingtong.com",
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
base: "./",
|
base: "./",
|
||||||
|
|||||||
Reference in New Issue
Block a user