Files
UserhomeFront/src/views/Settings.vue
T
chenxing 302aec52a6
build / build (push) Successful in 2m22s
fix(theme): schooltheme 补 item 级 adminOnly(审计发现普通用户导航出现「本校默认」)
步骤 4 harness 取证(role=user dump)发现 NAV_THEME.schooltheme 漏标 adminOnly: true,
item 级过滤未生效(ADMIN_PANES 兜底仍在,直达 URL 落 noperm 黄条验证通过)。
修复后重验:user 导航无「本校默认」、有「我的主题」、直达 ?pane=schooltheme 落 noperm;主构建绿
2026-09-30 13:19:44 +08:00

1528 lines
52 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<!-- 左右双卡(B 方案):壳透明,白底分别铺在 .set-nav / .set-body 两张卡上,壁纸透出。
gap 加在两卡的直接父容器 .set-shell 上。 -->
<div class="set-shell">
<!-- 左卡:分组导航 -->
<aside class="set-nav">
<div class="nav-title">
<span>设置</span>
<span :class="['role-chip', { user: !isAdmin }]">{{ isAdmin ? "管理员" : "普通用户" }}</span>
</div>
<template v-for="g in navGroups" :key="g.title">
<div class="grp">{{ g.title }}</div>
<div
v-for="it in g.items"
:key="it.key"
:class="['item', { active: curPane === it.key }]"
@click="setPane(it.key)"
>
<span class="ic" v-html="it.icon"></span>
<span>{{ it.name }}</span>
<span v-if="it.key === 'backend'" class="ext">外部 ↗</span>
</div>
</template>
</aside>
<!-- 右区:sys pane 直接内嵌旧路由页 SysSetting(自带左右双卡视觉,故右区不再套白卡) -->
<div v-if="showSys" class="set-body set-body--sys">
<SysSetting />
</div>
<!-- 右卡:其余 pane -->
<div v-else class="set-body">
<!-- 无权限:普通用户直达管理项 / 显式 ?pane=noperm -->
<div v-if="isDenied || curPane === 'noperm'" class="pane">
<div class="no-perm">
<span>⚠️</span>
<span>该功能仅学校管理员可用。如需调整,请联系学校系统管理员。</span>
</div>
</div>
<!-- 个人信息 -->
<div v-else-if="curPane === 'profile'" class="pane">
<div class="pane-head">
<h3>个人信息</h3>
<p>查看与维护你的基础账号资料,姓名与工号由学校统一维护,如需变更请联系管理员。</p>
</div>
<div class="sec-card">
<h4>基础资料</h4>
<div class="form-row">
<span class="lb">头像</span>
<div class="ctl">
<img class="avatar-img avatar-img-sm" :src="requireImg('user-avatar-default.png')"
v-real-img="store.userInfo.profilePicture" alt="" />
<button class="btn ghost sm" @click="setPane('avatar')">更换头像</button>
</div>
</div>
<div class="form-row">
<span class="lb">姓名</span>
<div class="ctl">
<input class="inp" :value="profileView.name" disabled />
<span class="hint">由学校统一维护</span>
</div>
</div>
<div class="form-row">
<span class="lb">工号</span>
<div class="ctl">
<input class="inp" :value="profileView.account" disabled />
<span class="hint">由学校统一维护</span>
</div>
</div>
<div class="form-row">
<span class="lb">所属部门</span>
<div class="ctl">
<input class="inp" :value="profileView.deptName" disabled />
</div>
</div>
<div class="form-row">
<span class="lb req">手机号</span>
<div class="ctl">
<input class="inp" v-model="profileForm.mobile" placeholder="请输入手机号" />
</div>
</div>
<div class="form-row form-row--last">
<span class="lb">邮箱</span>
<div class="ctl">
<input class="inp" v-model="profileForm.email" placeholder="请输入邮箱" />
</div>
</div>
</div>
<div class="pane-foot">
<!-- 保存占位:接口待 UserBase 支持本人自助修改,本轮不做(拍板结论) -->
<button class="btn primary" disabled title="接口待 UserBase 支持本人自助修改,本轮暂不可用">保存修改</button>
<button class="btn plain" @click="resetProfileForm">重置</button>
</div>
</div>
<!-- 修改密码 -->
<div v-else-if="curPane === 'password'" class="pane">
<div class="pane-head">
<h3>修改密码</h3>
<p>修改后需重新登录。建议使用 8 位以上、包含字母与数字的组合。</p>
</div>
<div class="sec-card">
<h4>密码</h4>
<div class="form-row">
<span class="lb req">原密码</span>
<div class="ctl">
<input class="inp" type="password" v-model="passwordForm.old" placeholder="请输入当前密码" />
</div>
</div>
<div class="form-row">
<span class="lb req">新密码</span>
<div class="ctl">
<input class="inp" type="password" v-model="passwordForm.next" placeholder="8-20 位,字母 + 数字" />
</div>
</div>
<div class="form-row form-row--last">
<span class="lb req">确认新密码</span>
<div class="ctl">
<input class="inp" type="password" v-model="passwordForm.confirm" placeholder="请再次输入新密码" />
</div>
</div>
</div>
<div class="pane-foot">
<button class="btn primary" :disabled="passwordSubmitting" @click="onUpdatePassword">确认修改</button>
</div>
</div>
<!-- 头像设置 -->
<div v-else-if="curPane === 'avatar'" class="pane">
<div class="pane-head">
<h3>头像设置</h3>
<p>支持 JPG / PNG,建议 1:1 正方形,大小不超过 2MB。</p>
</div>
<div class="sec-card">
<h4>当前头像</h4>
<div class="avatar-pad">
<div class="avatar-box">
<img class="avatar-img" :src="requireImg('user-avatar-default.png')"
v-real-img="store.userInfo.profilePicture" alt="" />
<div class="avatar-ops">
<div class="avatar-btns">
<!-- 直传逻辑移植 UserInfoModal:action/headers/校验/成功回调照抄 -->
<Upload :on-success="uploadAvatarSuccess" :on-exceeded-size="onAvatarSizeExceeded"
:on-format-error="onAvatarFormatError" :show-upload-list="false"
:action="$conf.serviceHost + '/api/avatarUpload'"
accept=".jpg,.jpeg,.png,.gif,.webp" :format="['jpg', 'jpeg', 'png', 'gif', 'webp']"
:max-size="20480" :with-credentials="true" :headers="uploadHeaders">
<button class="btn primary sm">上传新头像</button>
</Upload>
<!-- 存储方案本轮不做(拍板),保持占位 -->
<button class="btn plain sm" disabled title="存储方案本轮不做">使用默认头像</button>
</div>
<span class="tip">上传后立即生效,全站同步展示。支持 JPG / PNG / GIF / WebP,≤ 20MB。</span>
</div>
</div>
</div>
</div>
<div class="sec-card">
<h4>可选头像 <span class="tag">系统预设</span></h4>
<div class="avatar-grid">
<div
v-for="(a, i) in avatarPresets"
:key="i"
:class="['avatar-item', { sel: avatarSel === i }]"
:style="{ background: a }"
@click="avatarSel = i"
>
{{ avatarLetters[i] }}<i class="tick">✓</i>
</div>
</div>
</div>
</div>
<!-- 我的主题(所有人;主题设置 2026-09-30) -->
<div v-else-if="curPane === 'mytheme'" class="pane">
<div class="pane-head">
<h3>我的主题</h3>
<p>选择你的管理端主题档,各管理端(通知/用户中心等)同浏览器自动同步;当前来源:<b>{{ myOriginLabel }}</b>({{ myEffective.theme }})</p>
</div>
<div class="theme-pane">
<div class="theme-left">
<div class="theme-grid">
<div
v-for="t in THEME_OPTIONS"
:key="t.key"
:class="['theme-item', { sel: myEffective.theme === t.key, cand: myCandidate === t.key }]"
@click="pickMyTheme(t.key)"
>
<span class="swatch" :style="{ background: t.color }"></span>
<span class="tn">{{ t.name }}</span>
<i v-if="myEffective.theme === t.key" class="tick">✓</i>
</div>
</div>
<div class="pane-foot theme-foot">
<!-- 删用户键后回落学校默认/系统默认(§3.3) -->
<button class="btn plain" @click="followSchoolTheme" :disabled="myOrigin !== 'mine'">跟随学校默认</button>
</div>
</div>
<div class="theme-preview-box">
<iframe :src="previewSrc(myCandidate)" class="theme-iframe" title="主题预览"></iframe>
</div>
</div>
</div>
<!-- 本校默认(仅管理员;主题设置 2026-09-30) -->
<div v-else-if="curPane === 'schooltheme'" class="pane">
<div class="pane-head">
<h3>本校默认</h3>
<p>配置本校师生的推荐主题档(推荐非强制,用户自选优先);
当前已配置:<b>{{ schConfigured ? (THEME_OPTIONS.find(t => t.key === schConfigured)?.name || schConfigured) : "未配置(系统默认)" }}</b></p>
</div>
<div class="theme-pane">
<div class="theme-left">
<div class="theme-grid">
<div
v-for="t in THEME_OPTIONS"
:key="t.key"
:class="['theme-item', { sel: schCandidate === t.key }]"
@click="schCandidate = t.key"
>
<span class="swatch" :style="{ background: t.color }"></span>
<span class="tn">{{ t.name }}</span>
<i v-if="schCandidate === t.key" class="tick">✓</i>
</div>
</div>
<div class="pane-foot theme-foot">
<button class="btn primary" :disabled="schSaving" @click="onSaveSchTheme">保存为本校默认</button>
<button class="btn plain" :disabled="schSaving || !schConfigured" @click="onClearSchTheme">跟随系统默认(清空)</button>
</div>
</div>
<div class="theme-preview-box">
<iframe :src="previewSrc(schCandidate)" class="theme-iframe" title="主题预览"></iframe>
</div>
</div>
</div>
<!-- LOGO 设置(管理员) -->
<div v-else-if="curPane === 'logo'" class="pane">
<div class="pane-head">
<h3>LOGO 设置</h3>
<p>设置顶部导航栏与登录页展示的站点 LOGO,全站生效。</p>
</div>
<div class="sec-card">
<h4>当前 LOGO</h4>
<div class="logo-grid">
<div v-if="logoManageList.length === 0" class="logo-empty">暂无 LOGO,请先上传</div>
<div
v-for="l in logoManageList"
:key="l.id"
:class="['logo-item', { sel: logoSelUrl === l.url }]"
@click="logoSelUrl = l.url"
>
<img :src="l.url" alt="" />
<i v-if="l.isEdit" class="del" title="删除" @click.stop="onDeleteLogo(l)">×</i>
</div>
</div>
<div class="form-row form-row--last form-row--top">
<span class="lb">上传 LOGO</span>
<div class="ctl">
<Upload :on-success="onLogoUploaded" :show-upload-list="false" :action="logoUploadAction"
accept="image/jpeg, image/jpg, image/png, image/gif, image/webp"
:with-credentials="true" :headers="uploadHeaders">
<button class="btn ghost sm">选择文件</button>
</Upload>
<span class="hint">建议 PNG 透明底,高度 ≥ 64px,≤ 1MB</span>
</div>
</div>
</div>
<div class="pane-foot">
<button class="btn primary" :disabled="logoSaving" @click="onSaveLogo">保存设置</button>
</div>
</div>
<!-- 壁纸设置(管理员) -->
<div v-else-if="curPane === 'wallpaper'" class="pane">
<div class="pane-head">
<h3>壁纸设置</h3>
<p>选择桌面背景壁纸,可上传自定义图片并删除历史壁纸。</p>
</div>
<div class="sec-card">
<h4>系统壁纸</h4>
<div class="wall-grid">
<div v-if="wallpaperManageList.length === 0" class="logo-empty">暂无壁纸,请先上传</div>
<div
v-for="w in wallpaperManageList"
:key="w.id"
:class="['wall-item', { sel: wallSelUrl === w.url }]"
:style="{ background: 'center / cover no-repeat url(' + w.url + ')' }"
@click="wallSelUrl = w.url"
>
<i v-if="w.isEdit" class="del" title="删除" @click.stop="onDeleteWallpaper(w)">×</i>
<i class="tick">✓</i>
</div>
</div>
<div class="form-row form-row--last form-row--top">
<span class="lb">上传壁纸</span>
<div class="ctl">
<Upload :on-success="onWallpaperUploaded" :show-upload-list="false" :action="wallpaperUploadAction"
accept="image/jpeg, image/jpg, image/png" :with-credentials="true" :headers="uploadHeaders">
<button class="btn ghost sm">选择文件</button>
</Upload>
<span class="hint">建议 1920×1080 以上,≤ 5MB</span>
</div>
</div>
</div>
<div class="pane-foot">
<!-- 保存语义=设为我的壁纸(checkWallpaper,与 Header 壁纸弹窗一致) -->
<button class="btn primary" :disabled="wallApplying" @click="onApplyWallpaper">应用壁纸</button>
</div>
</div>
<!-- 边栏设置(管理员) -->
<div v-else-if="curPane === 'sidebar'" class="pane">
<div class="pane-head">
<h3>边栏设置</h3>
<p>配置左侧应用边栏的显示项与排序,拖动左侧手柄调整顺序。</p>
</div>
<div class="sec-card">
<h4>边栏应用</h4>
<div class="drag-list">
<div v-for="(d, i) in sidebarItems" :key="i" :class="['drag-item', { off: !d.on }]">
<span class="hd">⠿</span>
<span class="nm">{{ d.name }}</span>
<span :class="['drag-switch', { on: d.on }]" @click="d.on = !d.on"></span>
</div>
</div>
</div>
<div class="pane-foot">
<button class="btn primary">保存排序</button>
<button class="btn plain">恢复默认</button>
</div>
</div>
<!-- 统计设置(管理员) -->
<div v-else-if="curPane === 'statset'" class="pane">
<div class="pane-head">
<h3>统计设置</h3>
<p>选择「统计」页面需要展示的图表与指标。</p>
</div>
<div class="sec-card">
<h4>展示指标</h4>
<div class="check-list">
<div
v-for="(c, i) in statChecks"
:key="i"
:class="['check-item', { on: c.on }]"
@click="c.on = !c.on"
>
<span class="bx">✓</span>{{ c.name }}
</div>
</div>
</div>
<div class="pane-foot">
<button class="btn primary">保存</button>
</div>
</div>
<!-- 后台管理(外跳说明页,管理员) -->
<div v-else-if="curPane === 'backend'" class="pane">
<div class="pane-head">
<h3>后台管理</h3>
<p>本项不属于设置中心内的页面,点击下方按钮在新标签页打开后台管理系统。</p>
</div>
<div class="info-bar">
<span>↗</span>
<span>跳转地址:<code>{{ $conf.baseHost }}/userbase/front/index.html</code>(<code>window.open</code> 新标签页)</span>
</div>
<div class="ph-box">后台管理系统由 userbase 独立承载,此处仅保留跳转入口</div>
<div class="pane-foot">
<button class="btn primary" @click="openBackend">打开后台管理</button>
</div>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { ref, reactive, computed, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useDefaultStore } from "@/store";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import { requireImg } from "@/utils/hook/useImg";
import SysSetting from "@/views/SysSetting.vue";
import BaseApi from "@/api/BaseApi";
import SysSettingApi from "@/api/SysSettingApi";
import UserSettingApi from "@/api/UserSettingApi";
import Utils from "@/utils";
import { Message } from "view-ui-plus";
/**
* 设置中心(/settings):左 216px 分组导航卡 + 右内容卡(B 方案左右双卡)。
* 五个 pane 已对接真实数据(设置对接 2026-09-30,零新接口):
* profile 读 store.userInfo(保存占位——接口待 UserBase 支持本人自助修改,本轮不做);
* password 走 updatePassword + 确认登出流程(照抄 UserInfoModal);
* avatar 移植 UserInfoModal 直传上传(预设色块/「使用默认头像」保持占位);
* logo/wallpaper 管理列表 + 直传 + 删除照抄 SysSetting.vue,LOGO 保存走 saveSchLogo(schId 后端按登录人推断),
* 「应用壁纸」采用 checkWallpaper 个人壁纸语义(与 Header 壁纸弹窗一致,执行报告已写明)。
*/
const route = useRoute();
const router = useRouter();
const store = useDefaultStore();
const { $conf } = useCurrentInstance();
const isAdmin = computed(() => !!store.getIsAdmin);
/** 普通用户不可用的管理 pane(导航隐藏;URL 直达渲染 noperm 黄条,不白屏不报错) */
const ADMIN_PANES = ["logo", "wallpaper", "sidebar", "sys", "statset", "backend", "schooltheme"];
const ALL_PANES = ["profile", "password", "avatar", "mytheme", "schooltheme", "logo", "wallpaper", "sidebar", "sys", "statset", "backend", "noperm"];
const curPane = ref<string>("profile");
// URL ?pane= 与当前 pane 双向同步:直达 / 刷新 / 前进后退均保持位置
watch(
() => route.query.pane,
pane => {
if (typeof pane === "string" && pane !== curPane.value) {
curPane.value = ALL_PANES.includes(pane) ? pane : "profile";
}
},
{ immediate: true },
);
watch(curPane, pane => {
if (route.query.pane !== pane) router.replace({ query: { ...route.query, pane } });
});
const isDenied = computed(() => !isAdmin.value && ADMIN_PANES.includes(curPane.value));
/** sys pane 内嵌旧路由页 SysSetting(管理员态自带机构树+内容双卡) */
const showSys = computed(() => isAdmin.value && curPane.value === "sys");
function setPane(key: string) {
curPane.value = key;
}
interface NavItem {
key: string;
name: string;
icon: string;
/** item 级仅管理员可见(主题设置 §3.3:schooltheme 双保险之一,另一道在 ADMIN_PANES) */
adminOnly?: boolean;
}
const svgWrap = (inner: string) =>
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
const NAV_ACCOUNT: NavItem[] = [
{ key: "profile", name: "个人信息", icon: svgWrap('<circle cx="12" cy="8" r="4"/><path d="M4 20a8 8 0 0 1 16 0"/>') },
{ key: "password", name: "修改密码", icon: svgWrap('<rect x="4" y="10" width="16" height="10" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>') },
{ key: "avatar", name: "头像设置", icon: svgWrap('<circle cx="12" cy="12" r="9"/><circle cx="12" cy="10" r="3"/><path d="M6.2 19a6.5 6.5 0 0 1 11.6 0"/>') },
];
const NAV_THEME: NavItem[] = [
{ key: "mytheme", name: "我的主题", icon: svgWrap('<circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18c1.5 0 2-1 2-2s-1-1.5-1-2.5 1-2 2.5-2H19a2 2 0 0 0 2-2 9 9 0 0 0-9-9z"/><circle cx="7.5" cy="11" r="1"/><circle cx="12" cy="7.5" r="1"/><circle cx="16.5" cy="11" r="1"/>') },
{ key: "schooltheme", name: "本校默认", icon: svgWrap('<path d="M4 21V8l8-5 8 5v13"/><path d="M4 21h16"/><path d="M12 11v4"/><circle cx="12" cy="9" r="2"/>'), adminOnly: true },
];
const NAV_APPEARANCE: NavItem[] = [
{ key: "logo", name: "LOGO 设置", icon: svgWrap('<rect x="3" y="4" width="18" height="16" rx="2"/><circle cx="8.5" cy="9.5" r="1.5"/><path d="M21 15l-5-5L5 20"/>') },
{ key: "wallpaper", name: "壁纸设置", icon: svgWrap('<path d="M5 3.5A9 9 0 1 0 20.5 12H5z"/><path d="M5 3.5V12h15.5"/>') },
{ key: "sidebar", name: "边栏设置", icon: svgWrap('<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/>') },
];
const NAV_SYSTEM: NavItem[] = [
{ key: "sys", name: "系统设置", icon: svgWrap('<rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/>') },
{ key: "statset", name: "统计设置", icon: svgWrap('<path d="M18 20V10M12 20V4M6 20v-6"/>') },
{ key: "backend", name: "后台管理", icon: svgWrap('<circle cx="12" cy="8" r="3.5"/><path d="M5 20a7 7 0 0 1 14 0"/><circle cx="18" cy="17" r="2.5"/>') },
];
/** 分组导航:管理员全可见;普通用户只见「账号」「主题(仅我的主题)」——组级 + item 级(adminOnly)双层过滤 */
const navGroups = computed(() => {
const groups = [
{ title: "账号", adminOnly: false, items: NAV_ACCOUNT },
{ title: "主题", adminOnly: false, items: NAV_THEME },
{ title: "外观", adminOnly: true, items: NAV_APPEARANCE },
{ title: "系统", adminOnly: true, items: NAV_SYSTEM },
];
const visible = isAdmin.value ? groups : groups.filter(g => !g.adminOnly);
return visible.map(g => ({
...g,
items: isAdmin.value ? g.items : g.items.filter(it => !it.adminOnly),
}));
});
/* ===== profile:读 store.userInfo(真实展示;保存占位) ===== */
const profileForm = reactive({ mobile: "", email: "" });
/** userInfo 派生展示(手机/邮箱编辑值初始同源;工号暂以登录账号展示——接口未提供独立工号字段) */
const profileView = computed(() => {
const u: any = store.userInfo || {};
return {
name: u.name || "-",
account: u.account || "-",
deptName: u.deptName || u.dept || "-",
phone: u.phone || "",
email: u.email || "",
};
});
watch(
() => store.userInfo,
() => {
profileForm.mobile = profileView.value.phone;
profileForm.email = profileView.value.email;
},
{ immediate: true },
);
function resetProfileForm() {
profileForm.mobile = profileView.value.phone;
profileForm.email = profileView.value.email;
}
/** 保存占位:接口待 UserBase 支持本人自助修改,本轮不做(拍板结论) */
/* ===== password:updatePassword + 确认登出(流程照抄 UserInfoModal) ===== */
const passwordForm = reactive({ old: "", next: "", confirm: "" });
const passwordSubmitting = ref(false);
async function onUpdatePassword() {
if (!passwordForm.old || !passwordForm.next) {
Message.warning("请填写原密码与新密码");
return;
}
if (passwordForm.next !== passwordForm.confirm) {
Message.warning("两次输入的新密码不一致");
return;
}
if (passwordForm.next === passwordForm.old) {
Message.warning("新密码不能与旧密码一致");
return;
}
if (passwordForm.next.length < 8) {
Message.warning("密码长度不能小于8位");
return;
}
if (passwordSubmitting.value) return;
passwordSubmitting.value = true;
try {
await BaseApi.updatePassword(passwordForm.old, passwordForm.next);
Message.success("操作成功!");
// 改密后旧会话失效,引导重新登录(照抄 UserInfoModal 确认登出流程)
setTimeout(logout, 1500);
} catch (error) {
Message.error("操作失败:" + error);
} finally {
passwordSubmitting.value = false;
}
}
function logout() {
let url = window.location.href;
url = "/logout?service=" + url;
url = $conf.ssoHost + url;
if (!!window.top) window.top.location.href = url;
else window.location.href = url;
}
/* ===== avatar:移植 UserInfoModal 直传上传(预设色块/「使用默认头像」保持占位) ===== */
// Upload 组件直传不经 axios 封装,需手动补网关校验头(照抄 UserInfoModal)
const uploadHeaders = { yschapirequest: 1, yschreferer: Utils.strToHexCharCode(window.location.href) };
function uploadAvatarSuccess(response: any) {
if (response.code == 0 && response.data && response.data.url) {
store.userInfo.profilePicture = response.data.url;
Message.success("头像更新成功!");
} else {
Message.error("头像更新失败:" + (response.msg || "未知错误"));
}
}
function onAvatarSizeExceeded() {
Message.error("图片大小不能超过20MB!");
}
function onAvatarFormatError() {
Message.error("仅支持 jpg/jpeg/png/gif/webp 格式图片!");
}
const avatarPresets = [
"linear-gradient(135deg,#5979ea,#7d9bf5)",
"linear-gradient(135deg,#45b972,#7bd39b)",
"linear-gradient(135deg,#e9b954,#f3d083)",
"linear-gradient(135deg,#e8684a,#f1957e)",
"linear-gradient(135deg,#8a7ce0,#b3a8f0)",
];
const avatarLetters = computed(() => {
const name = String(store.userInfo?.name || "A").trim();
return [name.charAt(0) || "A", "A", "B", "C", "D"];
});
const avatarSel = ref(0);
/* ===== logo / wallpaper:管理列表 + 直传 + 删除(照抄 SysSetting.vue 管理语义) ===== */
const logoManageList = ref<any[]>([]);
const wallpaperManageList = ref<any[]>([]);
const logoSelUrl = ref<string>("");
const wallSelUrl = ref<string>("");
/** 直传端点(本 pane 无机构树,超管按机构切换请用「系统设置」pane;不带 schId 即落到登录人归属校) */
const logoUploadAction = $conf.serviceHost + "/api/logoUpload";
const wallpaperUploadAction = $conf.serviceHost + "/api/wallpaperUpload";
async function loadManageLists() {
// 本 pane 无机构树:不带 schId,列表按登录人归属校返回(超管按机构切换请用「系统设置」pane)
const [walls, logos] = await Promise.all([
SysSettingApi.getSchWallpaperManageList(),
SysSettingApi.getSchLogoManageList(),
]);
wallpaperManageList.value = walls || [];
logoManageList.value = logos || [];
}
function onLogoUploaded() {
loadManageLists();
}
function onWallpaperUploaded() {
loadManageLists();
}
async function onDeleteLogo(item: any) {
await SysSettingApi.deleteLogoById(item.id);
Message.success("删除成功");
loadManageLists();
}
async function onDeleteWallpaper(item: any) {
await SysSettingApi.deleteWallById(item.id);
Message.success("删除成功");
loadManageLists();
}
/** LOGO 保存:选中项写入学校级配置(schId 后端按登录人推断:主学校→归属组→0 平台默认) */
const logoSaving = ref(false);
async function onSaveLogo() {
if (!logoSelUrl.value) {
Message.warning("请先选择一个 LOGO");
return;
}
if (logoSaving.value) return;
logoSaving.value = true;
try {
await BaseApi.saveSchLogo(logoSelUrl.value);
Message.success("保存成功,全站 LOGO 生效");
} catch (error) {
Message.error("操作失败:" + error);
} finally {
logoSaving.value = false;
}
}
/** 应用壁纸:采用 checkWallpaper 个人壁纸语义(与 Header 壁纸弹窗现状一致,见执行报告) */
const wallApplying = ref(false);
async function onApplyWallpaper() {
if (!wallSelUrl.value) {
Message.warning("请先选择一张壁纸");
return;
}
if (wallApplying.value) return;
wallApplying.value = true;
try {
await UserSettingApi.checkWallpaper(store.getCurSchId, wallSelUrl.value);
const userSetting = store.curUserSettingInfo;
if (!!userSetting) {
userSetting.wallpaperUrl = wallSelUrl.value;
store.setCurUserSettingsInfo(userSetting);
}
Message.success("壁纸已应用");
} catch (error) {
Message.error("操作失败:" + error);
} finally {
wallApplying.value = false;
}
}
// 首次切到 logo / wallpaper pane 时懒加载管理列表
watch(curPane, pane => {
if ((pane === "logo" || pane === "wallpaper") && isAdmin.value && logoManageList.value.length === 0 && wallpaperManageList.value.length === 0) {
loadManageLists();
}
});
// ===== 主题(主题设置 2026-09-30:mytheme 所有人 / schooltheme 仅管理员)=====
import {
THEME_STORAGE_KEY,
SCHOOL_DEFAULT_STORAGE_KEY,
DEFAULT_THEME_STATE,
resolveEffectiveThemeState,
saveThemeState,
} from "@/utils/theme";
import type { ThemeState } from "@/utils/theme";
/** 档位清单本轮硬编码在 UserHome(YschTheme《阶段性方案》§4:痛感已从 ×N 降到 ×1);主色取自通道 tokens 当前值 */
const THEME_OPTIONS = [
{ key: "blue", name: "蔚蓝", color: "#577afb" },
{ key: "ocean", name: "海洋", color: "#2f7ce0" },
{ key: "ink", name: "墨青", color: "#3d4d63" },
{ key: "violet", name: "紫韵", color: "#6d5ae0" },
{ key: "teal", name: "青碧", color: "#0d9488" },
];
/** 预览 iframe src:候选档经 URL 覆盖参数拼进 #/theme-preview(§3.2/§3.4,切候选即换 src 重载) */
function previewSrc(candidate: string) {
const cur = resolveEffectiveThemeState();
const q = new URLSearchParams();
q.set("yh-theme", candidate);
q.set("yh-side", cur.sideDeep ? "deep" : "light");
q.set("yh-grad", cur.gradOn ? "on" : "off");
return `${location.origin}${location.pathname}?${q.toString()}#/theme-preview`;
}
/** 键在且合法(按 parseThemeState 能还原出用户/学校语义)才计为存在 */
function keyExists(key: string) {
try {
return !!localStorage.getItem(key);
} catch (e) {
return false;
}
}
// ---- 我的主题(所有人)----
const myCandidate = ref<string>(resolveEffectiveThemeState().theme);
const myOrigin = computed<"mine" | "school" | "system">(() => {
if (keyExists(THEME_STORAGE_KEY)) return "mine";
if (keyExists(SCHOOL_DEFAULT_STORAGE_KEY)) return "school";
return "system";
});
const myOriginLabel = computed(
() => ({ mine: "我的选择", school: "学校默认", system: "系统默认" })[myOrigin.value]
);
/** 刷新来源/选中态(saveThemeState 是直写 localStorage,无响应式,操作后手动刷) */
const myTick = ref(0);
const myEffective = computed(() => {
void myTick.value;
return resolveEffectiveThemeState();
});
function pickMyTheme(key: string) {
const cur = resolveEffectiveThemeState();
saveThemeState({ ...cur, theme: key } as ThemeState);
myCandidate.value = key;
myTick.value += 1;
}
/** 跟随学校默认 = 删用户自选键(之后回落学校档/系统默认,§3.3) */
function followSchoolTheme() {
try {
localStorage.removeItem(THEME_STORAGE_KEY);
} catch (e) {
/* 隐私模式静默 */
}
myTick.value += 1;
}
// ---- 本校默认(仅管理员)----
const schCandidate = ref<string>(resolveEffectiveThemeState().theme);
const schConfigured = ref<string | null>(null); // 已配置的学校档(theme 名)
const schTick = ref(0);
const schSaving = ref(false);
async function loadSchTheme() {
try {
const res = await BaseApi.getSchTheme();
if (res && res.themeState) {
try {
const st = JSON.parse(res.themeState);
schConfigured.value = st.theme || null;
schCandidate.value = st.theme || schCandidate.value;
} catch (e) {
schConfigured.value = null;
}
} else {
schConfigured.value = null;
}
} catch (e) {
/* 读取失败不打扰 */
}
}
/** 保存本校默认:整份 ThemeState JSON(档名=候选,其余继承当前生效值),成功后同步本地键 */
async function onSaveSchTheme() {
if (schSaving.value) return;
schSaving.value = true;
try {
const cur = resolveEffectiveThemeState();
const state = { ...cur, theme: schCandidate.value } as ThemeState;
await BaseApi.saveSchThemeState(JSON.stringify(state));
try {
localStorage.setItem(SCHOOL_DEFAULT_STORAGE_KEY, JSON.stringify(state));
} catch (e) {
/* 隐私模式静默 */
}
schConfigured.value = schCandidate.value;
schTick.value += 1;
myTick.value += 1;
Message.success("本校默认主题已保存");
} catch (error) {
Message.error("保存失败:" + error);
} finally {
schSaving.value = false;
}
}
/** 跟随系统默认(清空学校配置,§3.3:清空=删配置语义) */
async function onClearSchTheme() {
if (schSaving.value) return;
schSaving.value = true;
try {
await BaseApi.clearSchTheme();
try {
localStorage.removeItem(SCHOOL_DEFAULT_STORAGE_KEY);
} catch (e) {
/* 隐私模式静默 */
}
schConfigured.value = null;
schTick.value += 1;
myTick.value += 1;
Message.success("已恢复系统默认");
} catch (error) {
Message.error("操作失败:" + error);
} finally {
schSaving.value = false;
}
}
// 打开主题 pane 时拉一次学校配置(管理员视角;失败静默)
watch(curPane, pane => {
if (pane === "mytheme" || pane === "schooltheme") loadSchTheme();
});
const sidebarItems = ref([
{ name: "通知公告", on: true },
{ name: "流程中心", on: true },
{ name: "教务管理", on: true },
{ name: "公文申请", on: true },
{ name: "场地预约", on: false },
]);
const statChecks = ref([
{ name: "应用访问量", on: true },
{ name: "消息总量", on: true },
{ name: "审批及时率", on: true },
{ name: "用户活跃度", on: false },
{ name: "设备在线率", on: false },
{ name: "存储使用量", on: false },
]);
/** 后台管理:保持外跳(与 Header.vue linkBackendManage 同一地址) */
function openBackend() {
window.open(`${$conf.baseHost}/userbase/front/index.html`, "_blank");
}
</script>
<style scoped lang="scss">
/* 壳:透明,不平铺白(B 方案),gap 加在两卡的直接父容器上。
padding 与 .syssetting-wrap 对齐(15px 四周),overflow:hidden 与 .app-list-wrapper/.stats-wrap 对齐,
避免内容撑出页面级滚动条把右侧留白撑大。 */
.set-shell {
display: flex;
gap: 16px;
height: calc(100% - var(--headerH));
padding: 15px;
overflow: hidden;
background: transparent;
border: none;
box-shadow: none;
}
/* 左卡:分组导航 */
.set-nav {
width: 216px;
flex-shrink: 0;
background: #fff;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
padding: 18px 12px;
overflow-y: auto;
.nav-title {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 10px 12px;
font-size: 13px;
font-weight: 600;
color: #2c3138;
}
.role-chip {
font-size: 10.5px;
font-weight: 500;
padding: 2px 8px;
border-radius: 9px;
background: #eef2ff;
color: #5979ea;
&.user {
background: #f0f1f3;
color: #7a8088;
}
}
.grp {
font-size: 11.5px;
color: #999;
padding: 12px 10px 6px;
letter-spacing: 0.4px;
}
.item {
display: flex;
align-items: center;
gap: 10px;
height: 40px;
padding: 0 12px;
border-radius: 6px;
font-size: 13.5px;
color: #4a4f55;
cursor: pointer;
margin-bottom: 2px;
transition: all 0.18s;
&:hover {
background: rgba(89, 121, 234, 0.08);
color: #5979ea;
}
&.active {
background: #5979ea;
color: #fff;
font-weight: 500;
}
.ic {
width: 18px;
height: 18px;
flex-shrink: 0;
color: #8a9098;
svg {
width: 18px;
height: 18px;
display: block;
}
}
&.active .ic {
color: #fff;
}
.ext {
margin-left: auto;
font-size: 10.5px;
color: #999;
}
&:hover .ext {
color: #5979ea;
}
&.active .ext {
color: rgba(255, 255, 255, 0.85);
}
}
}
/* 右卡:内容区 */
.set-body {
flex: 1;
min-width: 0;
background: #fff;
border-radius: 10px;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
overflow-y: auto;
padding: 22px 26px 30px;
/* sys pane:右区不套白卡,由内嵌的 SysSetting 自带左右双卡 */
&.set-body--sys {
padding: 0;
background: transparent;
box-shadow: none;
border-radius: 0;
overflow: hidden;
display: flex;
flex-direction: column;
/* 内嵌的 SysSetting 自带 padding:15px,与 .set-shell 的 15px 叠加成 30px,此处抹平。
仅作用于本内嵌场景,独立页 /syssetting 不受影响(该页退役后可删本段) */
:deep(.syssetting-wrap) {
padding: 0;
}
}
}
.pane {
animation: fadeIn 0.22s ease;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: none;
}
}
.pane-head {
margin-bottom: 20px;
h3 {
font-size: 17px;
font-weight: 600;
color: #2c3138;
margin-bottom: 5px;
}
p {
font-size: 12.5px;
color: #999;
line-height: 1.6;
}
}
.sec-card {
background: #fff;
border: 1px solid #eef0f3;
border-radius: 10px;
padding: 6px 18px 14px;
margin-bottom: 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
> h4 {
font-size: 13.5px;
font-weight: 600;
color: #2c3138;
padding: 14px 0 4px;
display: flex;
align-items: center;
gap: 8px;
.tag {
font-size: 10.5px;
font-weight: 500;
padding: 2px 7px;
border-radius: 8px;
background: #eef2ff;
color: #5979ea;
}
}
}
.form-row {
display: flex;
align-items: center;
gap: 14px;
padding: 13px 0;
border-bottom: 1px solid #eef0f3;
&--last {
border-bottom: none;
}
&--top {
margin-top: 8px;
}
.lb {
width: 110px;
flex-shrink: 0;
font-size: 13.5px;
color: #4a4f55;
&.req::before {
content: "*";
color: #e8684a;
margin-right: 3px;
}
}
.ctl {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
}
.inp {
height: 34px;
border: 1px solid #dcdfe6;
border-radius: 6px;
padding: 0 12px;
font-size: 13px;
width: 100%;
max-width: 340px;
outline: none;
transition: all 0.18s;
background: #fff;
&:focus {
border-color: #5979ea;
box-shadow: 0 0 0 3px rgba(89, 121, 234, 0.12);
}
&:disabled {
background: #f6f7f9;
color: #9aa0a8;
cursor: not-allowed;
}
}
.hint {
font-size: 12px;
color: #999;
}
/* 头像 */
.avatar-pad {
padding: 14px 0 6px;
}
.avatar-box {
display: flex;
align-items: center;
gap: 16px;
}
.avatar-img {
width: 72px;
height: 72px;
border-radius: 50%;
object-fit: cover;
flex-shrink: 0;
box-shadow: 0 4px 14px rgba(89, 121, 234, 0.3);
&.avatar-img-sm {
width: 44px;
height: 44px;
}
}
.avatar-ops {
display: flex;
flex-direction: column;
gap: 8px;
.avatar-btns {
display: flex;
gap: 8px;
align-items: center;
}
.tip {
font-size: 12px;
color: #999;
line-height: 1.6;
}
}
.avatar-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(76px, 1fr));
gap: 12px;
padding: 14px 0 4px;
}
.avatar-item {
height: 76px;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
border: 2px solid transparent;
transition: all 0.18s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 22px;
font-weight: 600;
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&.sel {
border-color: #5979ea;
box-shadow: 0 0 0 3px rgba(89, 121, 234, 0.18);
}
}
/* 主题选择(主题设置 2026-09-30):左色块 + 右预览 iframe(独立 document 吃通道 CSS,本体不吃) */
.theme-pane {
display: flex;
gap: 16px;
align-items: stretch;
}
.theme-left {
flex: 0 0 320px;
}
.theme-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 4px 0 12px;
}
.theme-item {
position: relative;
height: 84px;
border: 2px solid #eef0f3;
border-radius: 10px;
background: #fff;
display: flex;
align-items: center;
gap: 12px;
padding: 0 14px;
cursor: pointer;
transition: all 0.18s;
&:hover {
border-color: #5979ea;
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&.sel {
border-color: #5979ea;
box-shadow: 0 0 0 3px rgba(89, 121, 234, 0.18);
}
.swatch {
width: 40px;
height: 40px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: inset 0 -6px 12px rgba(0, 0, 0, 0.12);
}
.tn {
font-size: 14px;
font-weight: 500;
color: #2c3138;
}
.tick {
position: absolute;
right: 8px;
bottom: 8px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #5979ea;
color: #fff;
font-size: 12px;
font-style: normal;
display: flex;
align-items: center;
justify-content: center;
}
}
.theme-foot {
padding-top: 12px;
}
.theme-preview-box {
flex: 1;
min-width: 0;
height: 460px;
border: 1px solid #eef0f3;
border-radius: 10px;
overflow: hidden;
background: #f5f6f8;
}
.theme-iframe {
width: 100%;
height: 100%;
border: none;
display: block;
}
/* 壁纸 / LOGO 网格 */
.wall-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 12px;
padding: 14px 0 4px;
}
.wall-item {
height: 88px;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
border: 2px solid transparent;
transition: all 0.18s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
&:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
&.sel {
border-color: #5979ea;
box-shadow: 0 0 0 3px rgba(89, 121, 234, 0.18);
}
}
.tick {
position: absolute;
right: 6px;
bottom: 6px;
width: 20px;
height: 20px;
border-radius: 50%;
background: #5979ea;
color: #fff;
font-size: 12px;
display: none;
align-items: center;
justify-content: center;
font-style: normal;
}
.avatar-item.sel .tick,
.wall-item.sel .tick {
display: flex;
}
.logo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
padding: 14px 0 4px;
}
.logo-empty {
grid-column: 1 / -1;
padding: 18px 0 8px;
color: #b3b3b3;
font-size: 13px;
}
.logo-item {
height: 72px;
border-radius: 6px;
border: 2px solid #eef0f3;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.18s;
position: relative;
overflow: hidden;
img {
max-width: 86%;
max-height: 82%;
object-fit: contain;
}
&:hover {
border-color: #5979ea;
background: #eef2ff;
}
&.sel {
border-color: #5979ea;
box-shadow: 0 0 0 3px rgba(89, 121, 234, 0.18);
}
}
.wall-item {
position: relative;
}
.del {
position: absolute;
top: 4px;
right: 4px;
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.45);
color: #fff;
font-size: 12px;
font-style: normal;
line-height: 18px;
text-align: center;
cursor: pointer;
display: none;
z-index: 2;
}
.logo-item:hover .del,
.wall-item:hover .del {
display: block;
}
/* 边栏设置:拖拽示意列表 */
.drag-list {
padding: 14px 0 4px;
}
.drag-item {
display: flex;
align-items: center;
gap: 11px;
height: 44px;
padding: 0 14px;
border: 1px solid #eef0f3;
border-radius: 6px;
margin-bottom: 8px;
background: #fff;
font-size: 13.5px;
color: #4a4f55;
&.off {
opacity: 0.55;
}
.hd {
color: #c0c4cc;
cursor: grab;
font-size: 15px;
line-height: 1;
}
.nm {
flex: 1;
}
.drag-switch {
width: 38px;
height: 21px;
border-radius: 11px;
background: #dcdfe6;
position: relative;
cursor: pointer;
transition: background 0.2s;
&.on {
background: #5979ea;
}
&::after {
content: "";
position: absolute;
left: 2px;
top: 2px;
width: 17px;
height: 17px;
border-radius: 50%;
background: #fff;
transition: left 0.2s;
}
&.on::after {
left: 19px;
}
}
}
/* 统计设置:勾选项 */
.check-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 14px 0 4px;
}
.check-item {
display: flex;
align-items: center;
gap: 8px;
height: 34px;
padding: 0 13px;
border: 1px solid #dcdfe6;
border-radius: 6px;
font-size: 13px;
color: #4a4f55;
cursor: pointer;
transition: all 0.18s;
background: #fff;
&.on {
border-color: #5979ea;
background: #eef2ff;
color: #5979ea;
font-weight: 500;
}
.bx {
width: 15px;
height: 15px;
border-radius: 4px;
border: 1.5px solid #c8ccd4;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
color: #fff;
flex-shrink: 0;
}
&.on .bx {
background: #5979ea;
border-color: #5979ea;
}
}
/* 底部操作条 */
.pane-foot {
display: flex;
gap: 10px;
padding-top: 18px;
margin-top: 6px;
border-top: 1px solid #eef0f3;
}
/* 按钮 */
.btn {
height: 34px;
padding: 0 18px;
border-radius: 6px;
font-size: 13px;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
justify-content: center;
border: none;
cursor: pointer;
&.primary {
background: #5979ea;
color: #fff;
&:hover {
background: #6b8cf4;
box-shadow: 0 4px 12px rgba(89, 121, 234, 0.3);
}
}
&.ghost {
background: #fff;
color: #5979ea;
border: 1px solid #5979ea;
&:hover {
background: #eef2ff;
}
}
&.plain {
background: #fff;
color: #4a4f55;
border: 1px solid #dcdfe6;
&:hover {
border-color: #5979ea;
color: #5979ea;
}
}
&.sm {
height: 28px;
padding: 0 12px;
font-size: 12px;
}
}
/* 空占位 */
.ph-box {
height: auto;
padding: 22px;
border: 1px dashed #dcdfe6;
border-radius: 10px;
display: flex;
align-items: center;
justify-content: center;
color: #b3b3b3;
font-size: 13px;
gap: 8px;
margin-top: 12px;
}
/* 无权限黄条 / 后台管理信息条 */
.no-perm,
.info-bar {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 15px;
border-radius: 6px;
font-size: 12.5px;
line-height: 1.65;
margin-bottom: 16px;
}
.no-perm {
background: #fff8ec;
border-left: 3px solid #e9b954;
color: #8a6d2f;
}
.info-bar {
background: #eef2ff;
border-left: 3px solid #5979ea;
color: #3f5fd4;
code {
font-family: Consolas, Monaco, monospace;
font-size: 12px;
}
}
</style>