diff --git a/src/components/UserAvatar.vue b/src/components/UserAvatar.vue new file mode 100644 index 0000000..aefb0b1 --- /dev/null +++ b/src/components/UserAvatar.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/src/components/layout.vue b/src/components/layout.vue index a202132..d297acb 100644 --- a/src/components/layout.vue +++ b/src/components/layout.vue @@ -4,6 +4,7 @@ import { useRoute, useRouter } from "vue-router"; import { Message } from "view-ui-plus"; import { resolveMenuPage } from "@/utils/menuRoute"; import ThemeSwitcher from "@/components/ThemeSwitcher.vue"; +import UserAvatar from "@/components/UserAvatar.vue"; const props = defineProps<{ layoutData: Record }>(); const route = useRoute(); @@ -121,7 +122,8 @@ const linkMenu = (item: any) => { }; const linkUserInfo = () => { - window.location.href = baseHost.value + "/v/userinfo"; + // 头像/姓名点击统一跳 UserHome 设置页「个人信息」pane(默认头像轮 2026-09-30,原 /v/userinfo 已下线语义) + window.location.href = baseHost.value + "/userhome/front/index.html#/settings?pane=profile"; }; const logout = () => { let url = window.location.href; @@ -162,7 +164,7 @@ function linkHomePage() {

{{ appName }}

- + {{ userInfo.name }} diff --git a/src/utils/avatar.ts b/src/utils/avatar.ts new file mode 100644 index 0000000..40d5bda --- /dev/null +++ b/src/utils/avatar.ts @@ -0,0 +1,94 @@ +/** + * 默认头像样式(首字 / 剪影)公共源(YschTheme,scripts/sync-apps.mjs 下发三端 src/utils/avatar.ts)。 + * 取值链与主题同构(theme-core.js resolveEffectiveThemeState 的合成链写法): + * URL ?yh-avatar=text|silhouette(仅预览/排障覆盖:只影响读、绝不写回) + * > localStorage["yh-avatar"](用户自选,纯字符串) + * > localStorage["yh-avatar-sch-default"](学校默认,UserHome 登录后写入) + * > "text"(系统默认) + * 值域恒为 "text" | "silhouette",键缺失/值非法均静默回落下一级,不报错不阻塞渲染。 + * 渲染三态与取字规则的唯一契约见 UserHome《设置页修复与默认头像落地方案》§10.1/§10.2。 + */ + +export type AvatarStyle = "text" | "silhouette"; + +interface StorageLike { + getItem(k: string): string | null; + setItem(k: string, v: string): void; + removeItem(k: string): void; +} + +export const AVATAR_STORAGE_KEY = "yh-avatar"; + +/** 学校默认头像存储键(UserHome 登录时写入;值格式与用户键相同,仅无"用户自选"语义) */ +export const SCHOOL_DEFAULT_AVATAR_STORAGE_KEY = "yh-avatar-sch-default"; + +export const AVATAR_STYLES: readonly AvatarStyle[] = ["text", "silhouette"]; + +export const DEFAULT_AVATAR_STYLE: AvatarStyle = "text"; + +/** 值合法才采用(用户键/学校键同一判据;脏数据不生效) */ +function readAvatarStyleOrNull(raw: string | null): AvatarStyle | null { + return raw === "text" || raw === "silhouette" ? raw : null; +} + +/** URL 覆盖参数(?yh-avatar=text|silhouette):非法值忽略,且只影响"读哪一档"、绝不写回 localStorage */ +function readAvatarOverride(): AvatarStyle | null { + try { + const search = (globalThis.location && globalThis.location.search) || ""; + const m = new RegExp("[?&]yh-avatar=([^&]*)").exec(search); + return m ? readAvatarStyleOrNull(decodeURIComponent(m[1])) : null; + } catch { + /* 无 location 环境(测试/SSR):无覆盖 */ + return null; + } +} + +/** + * 生效样式合成(与 resolveEffectiveThemeState 同构): + * URL 覆盖 > localStorage["yh-avatar"](用户自选)> localStorage["yh-avatar-sch-default"](学校默认) + * > DEFAULT_AVATAR_STYLE。 + */ +export function resolveAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): AvatarStyle { + let style: AvatarStyle | null = null; + if (storage) { + style = readAvatarStyleOrNull(storage.getItem(AVATAR_STORAGE_KEY)); + if (!style) style = readAvatarStyleOrNull(storage.getItem(SCHOOL_DEFAULT_AVATAR_STORAGE_KEY)); + } + if (!style) style = DEFAULT_AVATAR_STYLE; + return readAvatarOverride() || style; +} + +/** 保存用户自选(直写 localStorage,无响应式;调用方操作后自行刷新 UI) */ +export function saveAvatarStyle(style: AvatarStyle, storage: StorageLike | undefined = globalThis.localStorage): void { + try { + storage?.setItem(AVATAR_STORAGE_KEY, style); + } catch { + /* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */ + } +} + +/** 跟随学校默认 = 删用户自选键(之后回落学校默认/系统默认) */ +export function clearAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): void { + try { + storage?.removeItem(AVATAR_STORAGE_KEY); + } catch { + /* 隐私模式静默 */ + } +} + +/** + * 取字(顺序判定,先命中先返回):含 "@" 先截 "@" 前段(邮箱取首字母); + * 首字符必须用展开 [...s][0](禁用 charAt(0),避免代理对截半出乱码)。 + * ASCII 字母转大写;纯数字回落 ""(剪影);符号/emoji/空白回落 ""(剪影);其余(CJK 等)原样。 + */ +export function pickAvatarText(name: string | null | undefined): string { + let s = String(name ?? ""); + const at = s.indexOf("@"); + if (at > -1) s = s.slice(0, at); + const ch = [...s.trim()][0]; + if (ch === undefined) return ""; + if (/^[A-Za-z]$/.test(ch)) return ch.toUpperCase(); + if (/^[0-9]$/.test(ch)) return ""; + if (/^[\p{P}\p{S}\s]$/u.test(ch)) return ""; + return ch; +} diff --git a/src/utils/theme.ts b/src/utils/theme.ts index 967f71b..8e83c37 100644 --- a/src/utils/theme.ts +++ b/src/utils/theme.ts @@ -1,5 +1,5 @@ /** - * 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20260929-1112-1b1724d),勿手改。 + * 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20261006-2013-75c1d10),勿手改。 * 分发方式:YschTheme `npm run sync` 拷进各端 src/utils/theme.ts(与 ThemeSwitcher.vue 配套,MD5 对齐报告)。 * 运行时档位类名由 /static/js/yh-theme-init.js 在 内先行挂好,本模块只负责读写与切换。 */ @@ -25,6 +25,9 @@ export const THEME_NAMES: readonly ThemeName[] = ["blue", "ocean", "ink", "viole 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, @@ -49,6 +52,75 @@ export function parseThemeState(raw: string | null): ThemeState { } } +/** 严格读取:四字段全部合法才返回状态,否则 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));