- 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
152 lines
6.1 KiB
TypeScript
152 lines
6.1 KiB
TypeScript
/**
|
||
* 本文件由 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());
|
||
}
|