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:
chenxing
2026-09-30 12:59:35 +08:00
parent 0ded3cce9f
commit 357c0edb52
6 changed files with 657 additions and 6 deletions
+308 -4
View File
@@ -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;