Files
UserhomeFront/src/utils/theme.ts
T
chenxing 4f36ff9ceb 外观偏好异步补齐D:设置页选择端双写服务端真源(fire-and-forget,本地体验不变)
- BaseApi 加三方法:getAppearance / saveMyTheme(紧凑编码,null=跟随)/ saveMyAvatarStyle
- Settings.vue 五个写键分支旁挂双写:pickMyTheme / followSchoolTheme(置 NULL) / pickMyAvatarStyle / followSchoolAvatar(置 NULL) / pickSide(写 yh-theme 的全部分支)
- 双写失败 console.warn 不弹错不回滚本地(本地体验与改造前逐字节一致)
- App.vue loadSchThemeDefault/loadSchAvatarDefault 保留不动(与 init 异步补齐幂等收敛)
- sync 件:UserAvatar.vue 监听 yh-appearance-updated + theme.ts 版本戳 20261008-1831-d9db725
- 注:本仓工作区另有并行会话 WIP(harness/命令面板),本提交只含本案文件与 hunks
2026-10-08 18:58:30 +08:00

152 lines
6.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20261008-1831-d9db725),勿手改。
* 分发方式: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());
}