feat(avatar): 顶栏三态头像接入(YschTheme 默认头像轮 sync 件)

- src/utils/avatar.ts + src/components/UserAvatar.vue:YschTheme sync 下发公共源(取值链 URL > yh-avatar > yh-avatar-sch-default > text,值域 text|silhouette)
- layout.vue:顶栏 img 头像换 UserAvatar 组件(placement=brand,size=28);头像/姓名点击统一跳 UserHome 设置页「个人信息」pane(原 /v/userinfo 已下线语义)
This commit is contained in:
chenxing
2026-10-06 20:16:20 +08:00
parent 5296b5999c
commit efd55a3a75
3 changed files with 191 additions and 2 deletions
+93
View File
@@ -0,0 +1,93 @@
<script lang="ts" setup>
/* 默认头像三态组件(YschTheme 公共源,scripts/sync-apps.mjs 下发三端 src/components/UserAvatar.vue):
头像 URL 非空 → <img>;否则 style==="text" 且能取到字 → 首字;否则 → 剪影(纯 CSS/SVG,零资源文件)。
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
import { computed } from "vue";
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
const props = withDefaults(
defineProps<{
/** 头像 URL:非空直接 <img>(三态第一优先) */
src?: string | null;
/** 取字来源(首字态用):经 pickAvatarText 判定,取不到字回落剪影 */
name?: string | null;
/** 直径 px;font-size = round(size * 0.44) */
size?: number;
/** 配色场景:light=页面浅底(品牌实底+白内容);brand=header 品牌色底(剪影 S1 玻璃底 / 首字 S2 白底反色,§10.3 分档) */
placement?: "light" | "brand";
}>(),
{ src: null, name: null, size: 28, placement: "light" },
);
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
const style = computed(() => resolveAvatarStyle());
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
const showText = computed(() => style.value === "text" && !!text.value);
const rootClass = computed(() => [
"yh-avatar",
props.placement === "brand" ? "yh-avatar--brand" : "yh-avatar--light",
{ "is-text": showText.value },
]);
const rootStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
fontSize: `${Math.round(props.size * 0.44)}px`,
}));
</script>
<template>
<span :class="rootClass" :style="rootStyle">
<img v-if="src" class="yh-avatar__img" :src="src" alt="" />
<template v-else-if="showText">{{ text }}</template>
<svg v-else class="yh-avatar__sil" viewBox="0 0 1024 1024" aria-hidden="true">
<circle cx="512" cy="399" r="174" />
<ellipse cx="512" cy="946" rx="420" ry="240" />
</svg>
</span>
</template>
<style scoped>
.yh-avatar {
position: relative;
border-radius: 50%;
overflow: hidden;
display: inline-grid;
place-items: center;
flex: 0 0 auto;
font-weight: 600;
line-height: 1;
user-select: none;
}
.yh-avatar__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 剪影 SVG:容器圆 overflow:hidden 裁切肩部溢出(禁用 clipPath);overflow:visible 放行 viewBox 外形状 */
.yh-avatar__sil {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: currentColor;
}
/* 页面内(浅底)——品牌实底 + 白内容 */
.yh-avatar--light {
background: var(--brand, #577afb);
color: #fff;
}
/* header(品牌色底)——与页面内反向:剪影档 = S1 半透明白,首字档 = S2 纯白底 + 品牌色字 */
.yh-avatar--brand {
background: rgba(255, 255, 255, 0.22);
color: #fff;
}
.yh-avatar--brand.is-text {
background: #fff;
color: var(--brand, #577afb);
}
</style>
+4 -2
View File
@@ -5,6 +5,7 @@ import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import SvgIcon from "@/components/SvgIcon.vue";
import UserAvatar from "@/components/UserAvatar.vue";
let props = defineProps<{ layoutData: LayoutData }>();
const reload: Function = inject("reload") || new Function("console.error('empty')");
@@ -138,7 +139,8 @@ const linkMenu = (page: string) => {
};
const linkUserInfo = () => {
window.location.href = baseHost + "/v/userinfo";
// 头像/姓名点击统一跳 UserHome 设置页「个人信息」pane(默认头像轮 2026-09-30,原 /v/userinfo 已下线语义)
window.location.href = baseHost + "/userhome/front/index.html#/settings?pane=profile";
};
const logout = () => {
let url = window.location.href;
@@ -174,7 +176,7 @@ function linkHomePage() {
<h4 class="fl align-middle">{{ appName }}</h4>
<div class="school-bg"></div>
<div class="header-aside fr">
<img :src="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
<UserAvatar class="fl align-middle" :src="userInfo.avatar" :name="userInfo.name" :size="28" placement="brand" @click="linkUserInfo" />
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
{{ userInfo.name }}
</a>
+94
View File
@@ -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;
}