对接规范落地:版本锁1.3.24/操作列actionColumn text/表格撑满新模型/撤配色切换器/超管门槛+外观三键
build / build (push) Successful in 2m54s

This commit is contained in:
chenxing
2026-10-11 21:58:08 +08:00
parent 729ab9592d
commit 90338f3980
20 changed files with 481 additions and 518 deletions
+3
View File
@@ -5,3 +5,6 @@
# (/open-gateway/front/ → 首段 open-gateway → ${origin}/open-gateway)。
VITE_SERVICE_HOST=/open-gateway
VITE_IS_MOCK=false
# 超管门槛(src/utils/superGate.ts):本地无平台 Cookie 时 CurrentUser 探测失败按放行处理;
# 如需完全旁路门槛,取消下一行注释(仅 dev 生效,build 不读本文件)。
# VITE_SUPER_GATE=off
+1 -1
View File
@@ -78,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 不替换占位符 → 线上走同源通道,零环境值烧入。
- **本地联调**:vite `/static` 代理 → `YH_THEME_PROXY_TARGET`(dev:mock 指本地 9527;`npm run dev` 时指 base 域——**无 VPN 时 `npm run dev` 主题 CSS 取不到,页面剩骨架无皮肤**,这正是 dev:mock 存在的原因)。
- **档位**:五档配色 + 侧栏深浅 + 侧栏跟随 + 顶部渐变,状态存 localStorage(`yh-theme`),由 `/static/js/yh-theme-init.js` 在首帧前挂 `<html>` 档位类(防 FOUC)。
- **sync 四件**:`src/utils/theme.ts`、`src/components/ThemeSwitcher.vue`、`src/utils/avatar.ts`、`src/components/UserAvatar.vue` 由 YschTheme 下发(consumers.json 已登记 open-gateway 条目)。⚠️ `theme.ts` 顶部有本仓加的 `// @ts-nocheck`(上游为松类型产物)——**下次 `npm run sync` 覆盖后需补回该行**,否则 `npm run typecheck` 报错。
- **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 端点收敛。
## 实现备注
+1 -1
View File
@@ -10,7 +10,7 @@
"dependencies": {
"alova": "^3.4.0",
"sass": "^1.64.1",
"view-ui-plus": "^1.3.16",
"view-ui-plus": "^1.3.24",
"vue": "^3.4.19",
"vue-router": "4"
},
+1 -1
View File
@@ -14,7 +14,7 @@
"dependencies": {
"alova": "^3.4.0",
"sass": "^1.64.1",
"view-ui-plus": "^1.3.16",
"view-ui-plus": "^1.3.24",
"vue": "^3.4.19",
"vue-router": "4"
},
+71 -9
View File
@@ -1,11 +1,20 @@
<script setup lang="ts">
import { ref, onMounted, reactive } from "vue";
import { computed, ref, onMounted, reactive } from "vue";
import layout from "./components/layout.vue";
import { LayoutData } from "./types/Basics";
import { checkSuperAdmin, SuperGate } from "@/utils/superGate";
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 同形) */
const modList = [
{ name: "LedgerList", title: "访问台账", page: "/ledger" },
@@ -33,25 +42,44 @@ const reload = () => {
};
provide("reload", reload);
onMounted(() => {
onMounted(async () => {
layoutData.modList = modList;
// "/" 落点由 routes.ts 的 { path: "/", redirect: "/ledger" } 处理。
// 这里不能再用 currentRoute 判断后 replace:冷启动时初始导航尚未解析完,
// currentRoute 恒为 "/",会把 #/routes、#/targets 深链接(含刷新)全部弹回台账。
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;
</script>
<template>
<layout v-if="isLogin" :layoutData="layoutData"></layout>
<div v-if="isLogin" class="base-wrap-main">
<router-view v-if="!!isRouterAlive" v-slot="{ Component, route }">
<transition name="fade" mode="out-in">
<component :is="Component" :key="getRouterKey(route)" />
</transition>
</router-view>
<template v-if="gateReady && !denied">
<layout v-if="isLogin" :layoutData="layoutData" :user-name="userName"></layout>
<div v-if="isLogin" class="base-wrap-main">
<router-view v-if="!!isRouterAlive" v-slot="{ Component, route }">
<transition name="fade" mode="out-in">
<component :is="Component" :key="getRouterKey(route)" />
</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>
</template>
@@ -82,4 +110,38 @@ body {
height: 100%;
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>
+21
View File
@@ -480,6 +480,27 @@ function mockProxyTest(body: any): { code: number; msg: string; data: any } {
export async function handleGatewayMockRequest(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://mock.local");
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;
const api = pathname.slice("/open-gateway".length);
+18
View File
@@ -115,3 +115,21 @@ h4.subtitle {
margin-right: 4px;
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;
}
}
+32 -15
View File
@@ -1,16 +1,18 @@
<template>
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
class="link-table table-bottom-box" @on-selection-change="selectChange">
<template #loading>
<div class="table-spin-load">加载中</div>
</template>
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</Table>
<div class="table-region" ref="regionRef">
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
class="link-table table-bottom-box" @on-selection-change="selectChange">
<template #loading>
<div class="table-spin-load">加载中</div>
</template>
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</Table>
</div>
<div class="page-panel">
<div class="fl align-middle">
<div class="fl ml-15 align-middle">
<slot :checkList="selectList" />
</div>
@@ -21,8 +23,8 @@
</template>
<script setup lang="ts">
import { computed, ref, useSlots } from 'vue'
import Utils from '@/utils'
import { computed, ref, useSlots, watch } from 'vue'
import { useElementHeight } from '@/utils/hook/useElementHeight'
import { OperatorEnum } from '@/types'
interface Props {
@@ -44,7 +46,10 @@ const emit = defineEmits<{
}>()
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 selectList = ref<number[]>([])
@@ -68,18 +73,30 @@ const {
total: res => props.formatResponse(res).total,
data: res => props.formatResponse(res).list,
initialPage: 1,
initialPageSize: 10,
// 页长记忆(bsch 应用页规范 §3:customPageCount localStorage)
initialPageSize: parseInt(localStorage.getItem('customPageCount') || '10') || 10,
immediate: props.autoLoad,
}
)
// 数据重载后清空勾选,避免批量操作用到旧选中行
watch(resp, () => {
if (selectList.value.length) {
selectList.value = []
emit('selection-change', selectList.value)
}
})
const list = computed(() => {
if (!resp.value) return []
return props.dataFilter ? props.dataFilter(resp.value) : resp.value
})
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() {
page.value = 1
-274
View File
@@ -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>
+29 -13
View File
@@ -1,13 +1,13 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { LayoutData } from "@/types/Basics";
import Utils from "@/utils";
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";
const props = defineProps<{ layoutData: LayoutData }>();
const props = defineProps<{ layoutData: LayoutData; userName?: string }>();
const route = useRoute();
const modList = computed(() => props.layoutData.modList || []);
@@ -19,8 +19,25 @@ const currTitle = computed(() => (route.meta as any)?.title || "");
/** mock 模式徽标:仅 --mode mock(VITE_IS_MOCK=true)时渲染,dev/线上恒不出现 */
const isMock = computed(() => !!config.isMock);
/** 顶栏操作者占位(网关无用户体系;三态头像取首字「网」) */
const opName = "网关治理";
/** 顶栏操作者:超管门槛取 CurrentUser.name,取不到(mock/error 态)兜底「网关治理」 */
const opName = computed(() => props.userName || "网关治理");
// 顶栏 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 共用后端实现;机器面那条只认内部令牌,浏览器不能再打它 */
@@ -45,6 +62,8 @@ async function reloadRegistry() {
const ssoHost = ref("");
onMounted(async () => {
readLogoUrl();
document.addEventListener("yh-appearance-updated", readLogoUrl);
try {
const d: any = await Apis.GatewayAdmin.session({});
ssoHost.value = d?.ssoHost || "";
@@ -53,6 +72,8 @@ onMounted(async () => {
}
});
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", readLogoUrl));
/* 登出(逐字照 Notice 的 layout.vue):打 CAS 平台级注销,service 回当前控制台地址。
注意这是平台级动作——会连带注销该浏览器在 CAS 下其他应用的会话(Notice 同此语义)。 */
function logout() {
@@ -66,8 +87,9 @@ const reloadPage = inject("reload", () => {}) as () => void;
<template>
<div class="layout-wrap">
<header class="header-container fix">
<!-- 云海 logo 徽标(结构与 Notice 对齐,皮肤见通道 base.layout.css .logo-badge) -->
<span class="logo-badge fl"><img src="@/assets/image/yunhai-logo.svg" alt="云海" /></span>
<!-- 云海 logo 徽标(结构与 Notice 对齐,皮肤见通道 base.layout.css .logo-badge);
外观三键 §3:平台键 yh-logo-url 优先、本仓资产兜底,加载失败回本仓资产 -->
<span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
<div class="delimiter fl align-middle"></div>
<h4 class="fl align-middle">{{ layoutData.appName }}</h4>
<div class="school-bg"></div>
@@ -85,8 +107,6 @@ const reloadPage = inject("reload", () => {}) as () => void;
<!-- 登出入口:仅在拿到 SSO 基址时出现(未接 CAS/本地 mock 恒不渲染) -->
<a v-if="ssoHost" class="logout fl align-middle" href="javascript:void(0);" @click="logout">退出</a>
</div>
<!-- 配色切换器(fr 顺序:DOM 后置即位于用户胶囊左侧) -->
<div class="theme-cap fr"><ThemeSwitcher /></div>
</header>
<div class="left-menu">
@@ -119,10 +139,6 @@ const reloadPage = inject("reload", () => {}) as () => void;
.header-container .logo-badge {
margin: 0 10px 0 0;
}
.theme-cap {
height: var(--topbar-h);
margin-right: 6px;
}
/* 面包屑「首页」垂直居中:inline-flex 真居中并关掉骨架给老 iconfont 字体的基线补偿 */
.crumb-home {
display: inline-flex;
+8
View File
@@ -25,6 +25,14 @@ import "virtual:svg-icons-register";
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);
app.use(router);
+36
View File
@@ -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;
}
+48
View File
@@ -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: "" };
}
}
+165
View File
@@ -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),
};
}
-151
View File
@@ -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
View File
@@ -1,5 +1,5 @@
<template>
<div class="main-wrapper">
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="search-wrap fix">
<span class="tip">
@@ -12,8 +12,7 @@
</div>
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :dataFilter="normalizeRows"
:tableHeight="tableHeight">
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :dataFilter="normalizeRows">
<template #appKey="{ row }">
<span v-if="row.appKey === '*'" class="window-tag">*(匿名窗口)</span>
<span v-else class="mono">{{ row.appKey }}</span>
@@ -45,9 +44,6 @@
<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="新增应用">
@@ -85,22 +81,18 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import { actionColumn } from '@/utils/tableActions';
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 },
@@ -110,7 +102,13 @@ const columns = [
{ 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 },
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" 文本 */
+9 -19
View File
@@ -1,5 +1,5 @@
<template>
<div class="main-wrapper">
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="search-wrap fix">
<Input v-model="dataParams.targetCode" placeholder="target(如 notice)" clearable
@@ -12,7 +12,7 @@
<Button class="ml-12" @click="handleReset">重置</Button>
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight" />
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" />
</div>
<LedgerDetailModal v-model="detailVisible" :row="currentRow" />
@@ -21,15 +21,13 @@
<script setup lang="ts">
import { ref } from 'vue';
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import LedgerDetailModal from './LedgerDetailModal.vue';
import { actionColumn } from '@/utils/tableActions';
import Utils from '@/utils';
defineOptions({ name: 'LedgerList' });
const { $utils } = useCurrentInstance();
const tableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
interface DataParams {
@@ -63,8 +61,6 @@ const tableApi = (params: { pageIndex: number; pageSize: number }) =>
cacheFor: null,
});
const tableHeight = ref($utils.useTableHeight({ isOperat: false, isPage: true, isSearch: true }));
/** 状态码色阶:2xx 绿 / 4xx 琥珀 / 5xx 红 */
function statusClass(status: number) {
if (status >= 500) return 'text-error';
@@ -112,18 +108,12 @@ const columns = [
align: 'right',
render: (h: any, { row }: any) => h('span', row.costMs != null ? `${row.costMs} ms` : '-'),
},
{
title: '操作',
key: 'action',
width: 100,
align: 'center',
fixed: 'right' as const,
render: (h: any, { row }: any) =>
h('a', {
style: { color: '#577afb' },
onClick: () => openDetail(row),
}, '查看详情'),
},
actionColumn(
[
{ key: 'detail', label: '查看详情', on: (r: any) => openDetail(r) },
],
{ mode: 'text', width: 100, fixed: 'right' },
),
];
const detailVisible = ref(false);
+10 -11
View File
@@ -1,12 +1,12 @@
<template>
<div class="main-wrapper">
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="search-wrap fix">
<span class="tip">路由注册表(open_api_route):治理旋钮行内编辑,保存后网关热重载,无须重启。qps&gt;0 时按 调用方×本行 限流(超限裸 429,0=不限);timeout/bodyLimit 立即生效。</span>
<Button type="primary" class="fr" @click="refresh">刷新</Button>
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight">
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns">
<template #visibility="{ row }">
<Select v-model="row.visibility" size="small" style="width: 110px">
<Option value="INTERNAL">INTERNAL</Option>
@@ -43,9 +43,6 @@
<template #remark="{ row }">
<Input v-model="row.remark" size="small" placeholder="-" />
</template>
<template #action="{ row }">
<Button type="primary" size="small" :loading="savingId === row.id" @click="save(row)">保存</Button>
</template>
</TablePageAlovaNew>
</div>
</div>
@@ -53,21 +50,17 @@
<script setup lang="ts">
import { ref } from 'vue';
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import { actionColumn } from '@/utils/tableActions';
defineOptions({ name: 'RouteList' });
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.routes({ 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: '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: '启用', slot: 'enabled', width: 80, align: 'center' },
{ 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() {
+10 -11
View File
@@ -1,12 +1,12 @@
<template>
<div class="main-wrapper">
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="search-wrap fix">
<span class="tip">目标地址(open_api_target):base_url 与启停行内编辑,保存后网关热重载 target 快照。</span>
<Button type="primary" class="fr" @click="refresh">刷新</Button>
</div>
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns" :tableHeight="tableHeight">
<TablePageAlovaNew ref="tableRef" :dataApi="tableApi" :columns="columns">
<template #baseUrl="{ row }">
<Input v-model="row.baseUrl" size="small" placeholder="http://{target}-service:{port}" />
</template>
@@ -16,9 +16,6 @@
<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>
</div>
</div>
@@ -26,29 +23,31 @@
<script setup lang="ts">
import { ref } from 'vue';
import useCurrentInstance from '@/utils/hook/useCurrentInstance';
import TablePageAlovaNew from '@/components/TablePageAlovaNew.vue';
import { actionColumn } from '@/utils/tableActions';
import Utils from '@/utils';
defineOptions({ name: 'TargetList' });
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.targets({ 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: 'target', key: 'targetCode', width: 140, ellipsis: true, tooltip: true },
{ title: 'base_url', slot: 'baseUrl', minWidth: 300 },
{ title: '启用', slot: 'enabled', width: 80, align: 'center' },
{ 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() {
+8
View File
@@ -138,6 +138,14 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
target: process.env.YH_THEME_PROXY_TARGET || "http://base.yunhaixingtong.com",
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: "./",