对接规范落地:版本锁1.3.24/操作列actionColumn text/表格撑满新模型/撤配色切换器/超管门槛+外观三键
build / build (push) Successful in 2m54s
build / build (push) Successful in 2m54s
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 端点收敛。
|
||||
|
||||
## 实现备注
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 @@
|
||||
/**
|
||||
* 超管门槛(治理面前置守卫):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>
|
||||
<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" 文本 */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>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() {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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: "./",
|
||||
|
||||
Reference in New Issue
Block a user