外观偏好异步补齐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
This commit is contained in:
chenxing
2026-10-08 18:58:30 +08:00
parent b2307f583f
commit 4f36ff9ceb
4 changed files with 65 additions and 8 deletions
+29
View File
@@ -249,6 +249,35 @@ class Base extends Abstract {
return this.postRequest({ url });
}
// ===== 用户外观偏好(外观偏好服务端化 2026-10-08;后端契约见 AppearanceController)=====
/**
* 读当前用户外观偏好聚合:{myTheme, schTheme, avatarMy, avatarSch, logoUrl}(紧凑编码/绝对 URL,查不到为 null)。
* 各子应用 yh-theme-init 异步补齐段的同源数据源;选择端预留(本轮设置页双写用不到)。
*/
getAppearance() {
const url = path + "/appearance";
return this.getRequest<{
myTheme: string | null;
schTheme: string | null;
avatarMy: string | null;
avatarSch: string | null;
logoUrl: string | null;
}>({ url });
}
/** 保存用户自选主题档(紧凑编码 theme|sideDeep01|gradOn01|sideFollow01);null=跟随学校默认。设置页 fire-and-forget 双写:成功不提示、失败不回滚本地 */
saveMyTheme(themeState: string | null) {
const url = path + "/saveMyTheme";
return this.postRequest({ url, data: { themeState } as any });
}
/** 保存用户自选头像样式("text"|"silhouette");null=跟随学校默认。双写语义同 saveMyTheme */
saveMyAvatarStyle(avatarStyle: string | null) {
const url = path + "/saveMyAvatarStyle";
return this.postRequest({ url, data: { avatarStyle } as any });
}
savUserRole(info: any) {
const url = path + "/user/savUserRole";
return this.postRequest({ url, data: info as any });
+11 -2
View File
@@ -4,7 +4,7 @@
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
import { computed } from "vue";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
const props = withDefaults(
@@ -22,7 +22,16 @@ const props = withDefaults(
);
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
const style = computed(() => resolveAvatarStyle());
/* 外观偏好异步补齐(yh-theme-init 在挂载后拉服务端真源收敛 localStorage 键)会在
document 上派发 yh-appearance-updated;tick 自增让 style 依赖失效重读键,三态即时切换(无需刷新)。 */
const appearanceTick = ref(0);
const onAppearanceUpdated = () => { appearanceTick.value += 1; };
onMounted(() => document.addEventListener("yh-appearance-updated", onAppearanceUpdated));
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", onAppearanceUpdated));
const style = computed(() => {
appearanceTick.value;
return resolveAvatarStyle();
});
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
const showText = computed(() => style.value === "text" && !!text.value);
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20261006-2013-75c1d10),勿手改。
* 本文件由 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> 内先行挂好,本模块只负责读写与切换。
*/
+24 -5
View File
@@ -1033,6 +1033,18 @@ function keyExists(key: string) {
}
}
/** ThemeState → 服务端紧凑编码 theme|sideDeep01|gradOn01|sideFollow01(与 SchSettings 存储形态同款) */
function themeToCompact(s: ThemeState): string {
return `${s.theme}|${s.sideDeep ? 1 : 0}|${s.gradOn ? 1 : 0}|${s.sideFollow ? 1 : 0}`;
}
/** 外观偏好服务端双写(2026-10-08):本地体验不变,fire-and-forget 补写真源——成功不提示、失败 console.warn 不回滚本地 */
function syncMyThemeRemote(s: ThemeState | null) {
BaseApi.saveMyTheme(s ? themeToCompact(s) : null).catch((e: any) => console.warn("[appearance] saveMyTheme 失败(本地已生效,不回滚):", e));
}
function syncMyAvatarRemote(style: string | null) {
BaseApi.saveMyAvatarStyle(style).catch((e: any) => console.warn("[appearance] saveMyAvatarStyle 失败(本地已生效,不回滚):", e));
}
// ---- 我的主题(所有人)----
const myCandidate = ref<string>(resolveEffectiveThemeState().theme);
const myOrigin = computed<"mine" | "school" | "system">(() => {
@@ -1052,17 +1064,20 @@ const myEffective = computed(() => {
function pickMyTheme(key: string) {
const cur = resolveEffectiveThemeState();
saveThemeState({ ...cur, theme: key } as ThemeState);
const state = { ...cur, theme: key } as ThemeState;
saveThemeState(state);
syncMyThemeRemote(state);
myCandidate.value = key;
myTick.value += 1;
}
/** 跟随学校默认 = 删用户自选键(之后回落学校档/系统默认,§3.3) */
/** 跟随学校默认 = 删用户自选键(之后回落学校档/系统默认,§3.3);服务端同步置 NULL */
function followSchoolTheme() {
try {
localStorage.removeItem(THEME_STORAGE_KEY);
} catch (e) {
/* 隐私模式静默 */
}
syncMyThemeRemote(null);
myTick.value += 1;
}
@@ -1170,16 +1185,18 @@ const myAvatarOriginLabel = computed(
() => ({ mine: "我的选择", school: "学校默认", system: "系统默认" })[myAvatarOrigin.value]
);
/** 选样式即写键即生效(saveAvatarStyle 直写 localStorage 无响应式,靠 tick 刷示例与来源标签) */
/** 选样式即写键即生效(saveAvatarStyle 直写 localStorage 无响应式,靠 tick 刷示例与来源标签);服务端双写真源 */
function pickMyAvatarStyle(key: AvatarStyle) {
saveAvatarStyle(key);
syncMyAvatarRemote(key);
myAvatarCandidate.value = key;
myAvatarTick.value += 1;
}
/** 跟随学校默认 = 删用户自选键(之后回落学校默认/系统默认,与 followSchoolTheme 同构) */
/** 跟随学校默认 = 删用户自选键(之后回落学校默认/系统默认,与 followSchoolTheme 同构);服务端同步置 NULL */
function followSchoolAvatar() {
clearAvatarStyle();
syncMyAvatarRemote(null);
myAvatarTick.value += 1;
}
@@ -1255,7 +1272,9 @@ const sideDeep = ref(resolveEffectiveThemeState().sideDeep);
const gradOn = ref(resolveEffectiveThemeState().gradOn);
function pickSide(patch: Partial<ThemeState>) {
saveThemeState({ ...resolveEffectiveThemeState(), ...patch } as ThemeState);
const state = { ...resolveEffectiveThemeState(), ...patch } as ThemeState;
saveThemeState(state);
syncMyThemeRemote(state);
myTick.value += 1;
}