95 lines
4.1 KiB
TypeScript
95 lines
4.1 KiB
TypeScript
/**
|
||
* 默认头像样式(首字 / 剪影)公共源(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;
|
||
}
|