feat(theme): 设置页「主题」导航组 + iframe 预览页 + 学校默认档对接(主题设置 S2)
- Settings.vue:新增「主题」导航组(组级不设 adminOnly),mytheme「我的主题」所有人可见、schooltheme「本校默认」item 级 adminOnly + 入 ADMIN_PANES 双保险(NavItem 扩展 adminOnly 字段,navGroups 组级+item 级双层过滤);两个 pane 左色块右预览 iframe——候选档经 ?yh-theme=/yh-side=/yh-grad= URL 覆盖参数拼进 #/theme-preview src,切候选即换 src 重载;档位清单本轮硬编码 5 档(主色取通道 tokens);「跟随学校默认」=删用户键、「跟随系统默认」=clearSchTheme 清空学校配置;当前生效档与来源(我的选择/学校默认/系统默认)经 resolveEffectiveThemeState 标注 - views/ThemePreview.vue(新增):纯展示预览页——顶栏 .header-container + 左菜单 .left-menu + 面包屑 + iview Table 写死 4 行,DOM 结构对齐 Notice layout,观感由通道 CSS 按档位类驱动;根容器 pointer-events:none,无搜索/分页/ThemeSwitcher;挂载时动态 append 通道 init(baseHost + /static/js/yh-theme-init.js,判重防双挂)——仅该 document 吃通道 CSS,UserHome 本体 index.html 不加 loader(定案) - App.vue:/theme-preview 顶层独立分支(不进桌面框架、不依赖登录态与 isRouterAlive——未登录直开不白屏);菜单白名单加 /theme-preview;登录成功后异步拉 schTheme 写 localStorage[yh-theme-sch-default](失败/空不写不报错,推荐值语义) - router/routes.ts:登记 /theme-preview(实际渲染走 App.vue 分支;若后续引入守卫须列入免登录白名单——注释已写明) - BaseApi:getSchTheme / saveSchThemeState / clearSchTheme 三封装 - src/utils/theme.ts:YschTheme npm run sync 下发(版本 20260930-1248-4e12e6d,含 SCHOOL_DEFAULT_STORAGE_KEY 与 resolveEffectiveThemeState 新导出)
This commit is contained in:
+24
-2
@@ -12,6 +12,7 @@ import BschIframe from "@/components/BschIframe.vue";
|
||||
import SearchPanel from "@/components/SearchPanel.vue";
|
||||
import UserInfoModal from "@/components/UserInfoModal.vue";
|
||||
import LightPageModal from "@/components/LightPageModal.vue";
|
||||
import ThemePreview from "@/views/ThemePreview.vue";
|
||||
|
||||
import BaseApi from "@/api/BaseApi";
|
||||
import { BschIframeType, IframePageParmas, RoleInfo, StateEnum } from "./types";
|
||||
@@ -60,6 +61,7 @@ mittBus.on("openBschIframePage", async (params: IframePageParmas) => {
|
||||
try {
|
||||
const [r1, r2] = await Promise.all([p1, p2]);
|
||||
store.$state.userInfo = userinfo;
|
||||
loadSchThemeDefault();
|
||||
if (r1.length == 0) {
|
||||
store.$state.userRoleList = [
|
||||
{
|
||||
@@ -86,6 +88,21 @@ mittBus.on("openBschIframePage", async (params: IframePageParmas) => {
|
||||
console.timeEnd("init");
|
||||
})();
|
||||
|
||||
/** 拉学校默认主题档写键(异步尽力而为:失败/404/空 → 不写、不报错、不阻塞登录流程) */
|
||||
function loadSchThemeDefault() {
|
||||
BaseApi.getSchTheme()
|
||||
.then((res: any) => {
|
||||
if (res && res.themeState) {
|
||||
try {
|
||||
localStorage.setItem("yh-theme-sch-default", res.themeState);
|
||||
} catch (e) {
|
||||
/* 隐私模式写失败静默 */
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
/**切换角色 */
|
||||
async function switchRole() {
|
||||
await loadUserMenuBarList();
|
||||
@@ -113,8 +130,9 @@ async function loadUserMenuBarList() {
|
||||
} else {
|
||||
findIndex = menuList.findIndex(x => x.url === route.path);
|
||||
}
|
||||
// /msg、/settings 由后台「边栏设置」配置后即进入菜单表;未配置时也允许直达/刷新,不被弹回桌面
|
||||
if (findIndex === -1 && !["/msg", "/settings"].includes(route.path)) router.push({ name: "index" });
|
||||
// /msg、/settings 由后台「边栏设置」配置后即进入菜单表;未配置时也允许直达/刷新,不被弹回桌面。
|
||||
// /theme-preview 同理(预览页按 URL 参数挂档,不依赖菜单表与登录态)
|
||||
if (findIndex === -1 && !["/msg", "/settings", "/theme-preview"].includes(route.path)) router.push({ name: "index" });
|
||||
}
|
||||
|
||||
const getRouterKey: (route: any) => string = route => {
|
||||
@@ -128,7 +146,11 @@ const mainStyle = computed(() => `background-image: url(${store.getCurWallPaperU
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- 主题预览页(主题设置 2026-09-30):顶层独立分支——不进桌面框架(无左栏/Header/壁纸)、
|
||||
不依赖登录态与 isRouterAlive 门控(未登录直开也不白屏);仅在该 document 内动态引通道 init -->
|
||||
<ThemePreview v-if="route.path === '/theme-preview'" />
|
||||
<div
|
||||
v-else
|
||||
:class="['app-desktop app-desktop__animation', !menuVisible ? 'app-desktop__full' : '']"
|
||||
:style="mainStyle"
|
||||
v-show="StateType > StateEnum.Loading"
|
||||
|
||||
@@ -181,6 +181,29 @@ class Base extends Abstract {
|
||||
return this.postRequest({ url, params: { logoUrl } });
|
||||
}
|
||||
|
||||
// ===== 学校默认主题档(主题设置 2026-09-30;后端契约见 SchSettingsController)=====
|
||||
|
||||
/**
|
||||
* 读本校默认主题档(已登录可读):{themeState: "<ThemeState JSON>" | null}——
|
||||
* null 表示未配置(前端"不写键、不报错"),登录后调一次写 localStorage["yh-theme-sch-default"]
|
||||
*/
|
||||
getSchTheme() {
|
||||
const url = path + "/schTheme";
|
||||
return this.getRequest<{ themeState: string | null; schId: number }>({ url });
|
||||
}
|
||||
|
||||
/** 保存本校默认主题档(仅学校管理员):themeState 为整份 ThemeState JSON 字符串 */
|
||||
saveSchThemeState(themeState: string) {
|
||||
const url = path + "/saveSchThemeState";
|
||||
return this.postRequest({ url, params: { themeState } });
|
||||
}
|
||||
|
||||
/** 清空本校默认主题档(仅学校管理员;置空后读侧返回 null) */
|
||||
clearSchTheme() {
|
||||
const url = path + "/clearSchTheme";
|
||||
return this.postRequest({ url });
|
||||
}
|
||||
|
||||
savUserRole(info: any) {
|
||||
const url = path + "/user/savUserRole";
|
||||
return this.postRequest({ url, data: info as any });
|
||||
|
||||
@@ -83,5 +83,14 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import("@/views/Settings.vue"),
|
||||
meta: { title: "设置" },
|
||||
},
|
||||
{
|
||||
// 主题预览页(主题设置 2026-09-30):实际渲染走 App.vue 顶层条件分支(不进桌面框架,未登录直开不白屏)。
|
||||
// 此处登记是为 URL 匹配合法(免 no-match 告警);若后续引入全局路由守卫,必须把本路由列入
|
||||
// 免登录/免初始化白名单——预览页按 URL 覆盖参数挂档,不依赖登录态(YschTheme 方案 §3.4/§4 S2.6)。
|
||||
path: "/theme-preview",
|
||||
name: "ThemePreview",
|
||||
component: () => import("@/views/ThemePreview.vue"),
|
||||
meta: { title: "主题预览" },
|
||||
},
|
||||
];
|
||||
export default routes;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20260930-1248-4e12e6d),勿手改。
|
||||
* 分发方式: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());
|
||||
}
|
||||
+308
-4
@@ -172,6 +172,69 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 我的主题(所有人;主题设置 2026-09-30) -->
|
||||
<div v-else-if="curPane === 'mytheme'" class="pane">
|
||||
<div class="pane-head">
|
||||
<h3>我的主题</h3>
|
||||
<p>选择你的管理端主题档,各管理端(通知/用户中心等)同浏览器自动同步;当前来源:<b>{{ myOriginLabel }}</b>({{ myEffective.theme }})</p>
|
||||
</div>
|
||||
<div class="theme-pane">
|
||||
<div class="theme-left">
|
||||
<div class="theme-grid">
|
||||
<div
|
||||
v-for="t in THEME_OPTIONS"
|
||||
:key="t.key"
|
||||
:class="['theme-item', { sel: myEffective.theme === t.key, cand: myCandidate === t.key }]"
|
||||
@click="pickMyTheme(t.key)"
|
||||
>
|
||||
<span class="swatch" :style="{ background: t.color }"></span>
|
||||
<span class="tn">{{ t.name }}</span>
|
||||
<i v-if="myEffective.theme === t.key" class="tick">✓</i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane-foot theme-foot">
|
||||
<!-- 删用户键后回落学校默认/系统默认(§3.3) -->
|
||||
<button class="btn plain" @click="followSchoolTheme" :disabled="myOrigin !== 'mine'">跟随学校默认</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-preview-box">
|
||||
<iframe :src="previewSrc(myCandidate)" class="theme-iframe" title="主题预览"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 本校默认(仅管理员;主题设置 2026-09-30) -->
|
||||
<div v-else-if="curPane === 'schooltheme'" class="pane">
|
||||
<div class="pane-head">
|
||||
<h3>本校默认</h3>
|
||||
<p>配置本校师生的推荐主题档(推荐非强制,用户自选优先);
|
||||
当前已配置:<b>{{ schConfigured ? (THEME_OPTIONS.find(t => t.key === schConfigured)?.name || schConfigured) : "未配置(系统默认)" }}</b></p>
|
||||
</div>
|
||||
<div class="theme-pane">
|
||||
<div class="theme-left">
|
||||
<div class="theme-grid">
|
||||
<div
|
||||
v-for="t in THEME_OPTIONS"
|
||||
:key="t.key"
|
||||
:class="['theme-item', { sel: schCandidate === t.key }]"
|
||||
@click="schCandidate = t.key"
|
||||
>
|
||||
<span class="swatch" :style="{ background: t.color }"></span>
|
||||
<span class="tn">{{ t.name }}</span>
|
||||
<i v-if="schCandidate === t.key" class="tick">✓</i>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pane-foot theme-foot">
|
||||
<button class="btn primary" :disabled="schSaving" @click="onSaveSchTheme">保存为本校默认</button>
|
||||
<button class="btn plain" :disabled="schSaving || !schConfigured" @click="onClearSchTheme">跟随系统默认(清空)</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="theme-preview-box">
|
||||
<iframe :src="previewSrc(schCandidate)" class="theme-iframe" title="主题预览"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LOGO 设置(管理员) -->
|
||||
<div v-else-if="curPane === 'logo'" class="pane">
|
||||
<div class="pane-head">
|
||||
@@ -343,8 +406,8 @@ const { $conf } = useCurrentInstance();
|
||||
const isAdmin = computed(() => !!store.getIsAdmin);
|
||||
|
||||
/** 普通用户不可用的管理 pane(导航隐藏;URL 直达渲染 noperm 黄条,不白屏不报错) */
|
||||
const ADMIN_PANES = ["logo", "wallpaper", "sidebar", "sys", "statset", "backend"];
|
||||
const ALL_PANES = ["profile", "password", "avatar", "logo", "wallpaper", "sidebar", "sys", "statset", "backend", "noperm"];
|
||||
const ADMIN_PANES = ["logo", "wallpaper", "sidebar", "sys", "statset", "backend", "schooltheme"];
|
||||
const ALL_PANES = ["profile", "password", "avatar", "mytheme", "schooltheme", "logo", "wallpaper", "sidebar", "sys", "statset", "backend", "noperm"];
|
||||
|
||||
const curPane = ref<string>("profile");
|
||||
|
||||
@@ -374,6 +437,8 @@ interface NavItem {
|
||||
key: string;
|
||||
name: string;
|
||||
icon: string;
|
||||
/** item 级仅管理员可见(主题设置 §3.3:schooltheme 双保险之一,另一道在 ADMIN_PANES) */
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
const svgWrap = (inner: string) =>
|
||||
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
|
||||
@@ -382,6 +447,10 @@ const NAV_ACCOUNT: NavItem[] = [
|
||||
{ key: "password", name: "修改密码", icon: svgWrap('<rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>') },
|
||||
{ key: "avatar", name: "头像设置", icon: svgWrap('<circle cx="12" cy="12" r="9"/><circle cx="12" cy="10" r="3"/><path d="M6.2 19a6.5 6.5 0 0 1 11.6 0"/>') },
|
||||
];
|
||||
const NAV_THEME: NavItem[] = [
|
||||
{ key: "mytheme", name: "我的主题", icon: svgWrap('<circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18c1.5 0 2-1 2-2s-1-1.5-1-2.5 1-2 2.5-2H19a2 2 0 0 0 2-2 9 9 0 0 0-9-9z"/><circle cx="7.5" cy="11" r="1"/><circle cx="12" cy="7.5" r="1"/><circle cx="16.5" cy="11" r="1"/>') },
|
||||
{ key: "schooltheme", name: "本校默认", icon: svgWrap('<path d="M4 21V8l8-5 8 5v13"/><path d="M4 21h16"/><path d="M12 11v4"/><circle cx="12" cy="9" r="2"/>') },
|
||||
];
|
||||
const NAV_APPEARANCE: NavItem[] = [
|
||||
{ key: "logo", name: "LOGO 设置", icon: svgWrap('<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9.5" r="1.5"/><path d="M21 15l-5-5L5 20"/>') },
|
||||
{ key: "wallpaper", name: "壁纸设置", icon: svgWrap('<path d="M5 3.5A9 9 0 1 0 20.5 12H5z"/><path d="M5 3.5V12h15.5"/>') },
|
||||
@@ -393,14 +462,19 @@ const NAV_SYSTEM: NavItem[] = [
|
||||
{ key: "backend", name: "后台管理", icon: svgWrap('<circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/><circle cx="18" cy="17" r="2.5"/>') },
|
||||
];
|
||||
|
||||
/** 分组导航:管理员全可见;普通用户只见「账号」组 */
|
||||
/** 分组导航:管理员全可见;普通用户只见「账号」「主题(仅我的主题)」——组级 + item 级(adminOnly)双层过滤 */
|
||||
const navGroups = computed(() => {
|
||||
const groups = [
|
||||
{ title: "账号", adminOnly: false, items: NAV_ACCOUNT },
|
||||
{ title: "主题", adminOnly: false, items: NAV_THEME },
|
||||
{ title: "外观", adminOnly: true, items: NAV_APPEARANCE },
|
||||
{ title: "系统", adminOnly: true, items: NAV_SYSTEM },
|
||||
];
|
||||
return isAdmin.value ? groups : groups.filter(g => !g.adminOnly);
|
||||
const visible = isAdmin.value ? groups : groups.filter(g => !g.adminOnly);
|
||||
return visible.map(g => ({
|
||||
...g,
|
||||
items: isAdmin.value ? g.items : g.items.filter(it => !it.adminOnly),
|
||||
}));
|
||||
});
|
||||
|
||||
/* ===== profile:读 store.userInfo(真实展示;保存占位) ===== */
|
||||
@@ -588,6 +662,153 @@ watch(curPane, pane => {
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 主题(主题设置 2026-09-30:mytheme 所有人 / schooltheme 仅管理员)=====
|
||||
import {
|
||||
THEME_STORAGE_KEY,
|
||||
SCHOOL_DEFAULT_STORAGE_KEY,
|
||||
DEFAULT_THEME_STATE,
|
||||
resolveEffectiveThemeState,
|
||||
saveThemeState,
|
||||
} from "@/utils/theme";
|
||||
import type { ThemeState } from "@/utils/theme";
|
||||
|
||||
/** 档位清单本轮硬编码在 UserHome(YschTheme《阶段性方案》§4:痛感已从 ×N 降到 ×1);主色取自通道 tokens 当前值 */
|
||||
const THEME_OPTIONS = [
|
||||
{ key: "blue", name: "蔚蓝", color: "#577afb" },
|
||||
{ key: "ocean", name: "海洋", color: "#2f7ce0" },
|
||||
{ key: "ink", name: "墨青", color: "#3d4d63" },
|
||||
{ key: "violet", name: "紫韵", color: "#6d5ae0" },
|
||||
{ key: "teal", name: "青碧", color: "#0d9488" },
|
||||
];
|
||||
|
||||
/** 预览 iframe src:候选档经 URL 覆盖参数拼进 #/theme-preview(§3.2/§3.4,切候选即换 src 重载) */
|
||||
function previewSrc(candidate: string) {
|
||||
const cur = resolveEffectiveThemeState();
|
||||
const q = new URLSearchParams();
|
||||
q.set("yh-theme", candidate);
|
||||
q.set("yh-side", cur.sideDeep ? "deep" : "light");
|
||||
q.set("yh-grad", cur.gradOn ? "on" : "off");
|
||||
return `${location.origin}${location.pathname}?${q.toString()}#/theme-preview`;
|
||||
}
|
||||
|
||||
/** 键在且合法(按 parseThemeState 能还原出用户/学校语义)才计为存在 */
|
||||
function keyExists(key: string) {
|
||||
try {
|
||||
return !!localStorage.getItem(key);
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 我的主题(所有人)----
|
||||
const myCandidate = ref<string>(resolveEffectiveThemeState().theme);
|
||||
const myOrigin = computed<"mine" | "school" | "system">(() => {
|
||||
if (keyExists(THEME_STORAGE_KEY)) return "mine";
|
||||
if (keyExists(SCHOOL_DEFAULT_STORAGE_KEY)) return "school";
|
||||
return "system";
|
||||
});
|
||||
const myOriginLabel = computed(
|
||||
() => ({ mine: "我的选择", school: "学校默认", system: "系统默认" })[myOrigin.value]
|
||||
);
|
||||
/** 刷新来源/选中态(saveThemeState 是直写 localStorage,无响应式,操作后手动刷) */
|
||||
const myTick = ref(0);
|
||||
const myEffective = computed(() => {
|
||||
void myTick.value;
|
||||
return resolveEffectiveThemeState();
|
||||
});
|
||||
|
||||
function pickMyTheme(key: string) {
|
||||
const cur = resolveEffectiveThemeState();
|
||||
saveThemeState({ ...cur, theme: key } as ThemeState);
|
||||
myCandidate.value = key;
|
||||
myTick.value += 1;
|
||||
}
|
||||
/** 跟随学校默认 = 删用户自选键(之后回落学校档/系统默认,§3.3) */
|
||||
function followSchoolTheme() {
|
||||
try {
|
||||
localStorage.removeItem(THEME_STORAGE_KEY);
|
||||
} catch (e) {
|
||||
/* 隐私模式静默 */
|
||||
}
|
||||
myTick.value += 1;
|
||||
}
|
||||
|
||||
// ---- 本校默认(仅管理员)----
|
||||
const schCandidate = ref<string>(resolveEffectiveThemeState().theme);
|
||||
const schConfigured = ref<string | null>(null); // 已配置的学校档(theme 名)
|
||||
const schTick = ref(0);
|
||||
const schSaving = ref(false);
|
||||
|
||||
async function loadSchTheme() {
|
||||
try {
|
||||
const res = await BaseApi.getSchTheme();
|
||||
if (res && res.themeState) {
|
||||
try {
|
||||
const st = JSON.parse(res.themeState);
|
||||
schConfigured.value = st.theme || null;
|
||||
schCandidate.value = st.theme || schCandidate.value;
|
||||
} catch (e) {
|
||||
schConfigured.value = null;
|
||||
}
|
||||
} else {
|
||||
schConfigured.value = null;
|
||||
}
|
||||
} catch (e) {
|
||||
/* 读取失败不打扰 */
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存本校默认:整份 ThemeState JSON(档名=候选,其余继承当前生效值),成功后同步本地键 */
|
||||
async function onSaveSchTheme() {
|
||||
if (schSaving.value) return;
|
||||
schSaving.value = true;
|
||||
try {
|
||||
const cur = resolveEffectiveThemeState();
|
||||
const state = { ...cur, theme: schCandidate.value } as ThemeState;
|
||||
await BaseApi.saveSchThemeState(JSON.stringify(state));
|
||||
try {
|
||||
localStorage.setItem(SCHOOL_DEFAULT_STORAGE_KEY, JSON.stringify(state));
|
||||
} catch (e) {
|
||||
/* 隐私模式静默 */
|
||||
}
|
||||
schConfigured.value = schCandidate.value;
|
||||
schTick.value += 1;
|
||||
myTick.value += 1;
|
||||
Message.success("本校默认主题已保存");
|
||||
} catch (error) {
|
||||
Message.error("保存失败:" + error);
|
||||
} finally {
|
||||
schSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 跟随系统默认(清空学校配置,§3.3:清空=删配置语义) */
|
||||
async function onClearSchTheme() {
|
||||
if (schSaving.value) return;
|
||||
schSaving.value = true;
|
||||
try {
|
||||
await BaseApi.clearSchTheme();
|
||||
try {
|
||||
localStorage.removeItem(SCHOOL_DEFAULT_STORAGE_KEY);
|
||||
} catch (e) {
|
||||
/* 隐私模式静默 */
|
||||
}
|
||||
schConfigured.value = null;
|
||||
schTick.value += 1;
|
||||
myTick.value += 1;
|
||||
Message.success("已恢复系统默认");
|
||||
} catch (error) {
|
||||
Message.error("操作失败:" + error);
|
||||
} finally {
|
||||
schSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 打开主题 pane 时拉一次学校配置(管理员视角;失败静默)
|
||||
watch(curPane, pane => {
|
||||
if (pane === "mytheme" || pane === "schooltheme") loadSchTheme();
|
||||
});
|
||||
|
||||
const sidebarItems = ref([
|
||||
{ name: "通知公告", on: true },
|
||||
{ name: "流程中心", on: true },
|
||||
@@ -918,6 +1139,89 @@ function openBackend() {
|
||||
}
|
||||
}
|
||||
|
||||
/* 主题选择(主题设置 2026-09-30):左色块 + 右预览 iframe(独立 document 吃通道 CSS,本体不吃) */
|
||||
.theme-pane {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.theme-left {
|
||||
flex: 0 0 320px;
|
||||
}
|
||||
.theme-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
padding: 4px 0 12px;
|
||||
}
|
||||
.theme-item {
|
||||
position: relative;
|
||||
height: 84px;
|
||||
border: 2px solid #eef0f3;
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 14px;
|
||||
cursor: pointer;
|
||||
transition: all 0.18s;
|
||||
&:hover {
|
||||
border-color: #5979ea;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
&.sel {
|
||||
border-color: #5979ea;
|
||||
box-shadow: 0 0 0 3px rgba(89, 121, 234, 0.18);
|
||||
}
|
||||
.swatch {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
box-shadow: inset 0 -6px 12px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.tn {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
color: #2c3138;
|
||||
}
|
||||
.tick {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
bottom: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #5979ea;
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
.theme-foot {
|
||||
padding-top: 12px;
|
||||
}
|
||||
.theme-preview-box {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
height: 460px;
|
||||
border: 1px solid #eef0f3;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #f5f6f8;
|
||||
}
|
||||
.theme-iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: none;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 壁纸 / LOGO 网格 */
|
||||
.wall-grid {
|
||||
display: grid;
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
<template>
|
||||
<!-- 主题预览页(#/theme-preview,YschTheme《主题设置与通道改动-执行方案》§3.4):
|
||||
只在本路由的 document 内加载通道(组件挂载时动态 append init 脚本),
|
||||
档位类与通道 CSS 只落在本 document 的 <html> 上——UserHome 本体(父页)永远不吃通道 CSS。
|
||||
纯展示:根容器 pointer-events:none,不渲染搜索区/分页操作/ThemeSwitcher。
|
||||
DOM 结构对齐 Notice layout(header-container / left-menu / path-nav / main-container),
|
||||
观感(配色/侧栏形态)由通道 base.layout.css 按 URL 覆盖参数(?yh-theme= 等)驱动的档位类提供。 -->
|
||||
<div class="theme-preview-root">
|
||||
<header class="header-container fix">
|
||||
<span class="logo-badge fl"><span class="logo-mark">云海</span></span>
|
||||
<div class="delimiter fl align-middle"></div>
|
||||
<h4 class="fl align-middle">管理端预览</h4>
|
||||
<div class="school-bg"></div>
|
||||
<div class="header-aside fr">
|
||||
<span class="name fl align-middle text-ellipsis">演示用户</span>
|
||||
<div class="delimiter fl align-middle"></div>
|
||||
<a href="javascript:;" class="logout fl align-middle" title="退出登陆"><i>⎋</i></a>
|
||||
</div>
|
||||
</header>
|
||||
<div class="left-menu">
|
||||
<div class="menu-top-box"></div>
|
||||
<ul>
|
||||
<li v-for="(m, i) in menus" :key="m" :class="{ active: i === 1 }">
|
||||
<a href="javascript:void(0);"><span class="menu-dot"></span>{{ m }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="clear-box"></div>
|
||||
</div>
|
||||
<div class="path-nav">
|
||||
<span>首页</span><span class="crumb-sep">/</span><span>通知管理</span><span class="crumb-sep">/</span><span>通知列表</span>
|
||||
</div>
|
||||
<div class="preview-main">
|
||||
<div class="main-container">
|
||||
<h3 class="panel-title">通知列表</h3>
|
||||
<Table :columns="columns" :data="rows" size="small" :border="true" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted } from "vue";
|
||||
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
|
||||
|
||||
/**
|
||||
* 主题预览路由页:档位来源 = URL 覆盖参数(?yh-theme= / ?yh-side= / ?yh-grad=,由设置页 iframe src 拼入,
|
||||
* 切候选档即换 src 重载),init 启动段按合成优先级挂档——本组件不读写 localStorage、不提供任何交互。
|
||||
* 直开(不经设置页、未登录)也可渲染:本页由 App.vue 顶层条件分支挂载,不依赖桌面框架与登录态。
|
||||
*/
|
||||
const { $conf } = useCurrentInstance();
|
||||
|
||||
const menus = ["工作台", "通知管理", "统计分析", "应用中心", "系统设置"];
|
||||
|
||||
const columns = [
|
||||
{ type: "index", title: "序号", width: 70, align: "center" },
|
||||
{ title: "标题", key: "title" },
|
||||
{ title: "发布人", key: "author", width: 120, align: "center" },
|
||||
{ title: "日期", key: "date", width: 160, align: "center" },
|
||||
];
|
||||
const rows = [
|
||||
{ title: "关于国庆节放假安排的通知", author: "校务办", date: "2026-09-28" },
|
||||
{ title: "期中教学质量检查实施方案", author: "教务处", date: "2026-09-25" },
|
||||
{ title: "校园安全专项检查通报", author: "保卫处", date: "2026-09-21" },
|
||||
{ title: "教师信息化能力培训报名", author: "信息中心", date: "2026-09-18" },
|
||||
];
|
||||
|
||||
onMounted(() => {
|
||||
// 动态引通道 init(仅本 document;UserHome 本体 index.html 不加 loader 是定案)。
|
||||
// baseHost 即通道域(与 UserBase/Notice 的 loader 同源策略);判重防重复挂载。
|
||||
if (document.querySelector("script[data-yh-theme-init]")) return;
|
||||
const s = document.createElement("script");
|
||||
s.src = `${String($conf.baseHost || "").replace(/\/+$/, "")}/static/js/yh-theme-init.js`;
|
||||
s.setAttribute("data-yh-theme-init", "1");
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
/* 本页只补最小布局;配色/圆角/阴影等观感全部由通道 base.layout.css 的档位类驱动 */
|
||||
.theme-preview-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none; /* 纯展示:整页不可交互 */
|
||||
background: #f5f6f8;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
.logo-mark {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
h4 {
|
||||
color: #fff;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
margin: 0 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.menu-dot {
|
||||
display: inline-block;
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
opacity: 0.45;
|
||||
margin-right: 9px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.path-nav .crumb-sep {
|
||||
margin: 0 8px;
|
||||
color: #c5c8cd;
|
||||
}
|
||||
|
||||
.preview-main {
|
||||
position: fixed;
|
||||
top: calc(var(--topbar-h, 52px) + var(--crumb-h, 44px));
|
||||
left: var(--side-w, 200px);
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
padding: 14px 24px 24px;
|
||||
overflow: auto;
|
||||
|
||||
.panel-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
color: #2c3138;
|
||||
margin: 4px 0 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user