build / build (push) Successful in 2m22s
步骤 4 harness 取证(role=user dump)发现 NAV_THEME.schooltheme 漏标 adminOnly: true, item 级过滤未生效(ADMIN_PANES 兜底仍在,直达 URL 落 noperm 黄条验证通过)。 修复后重验:user 导航无「本校默认」、有「我的主题」、直达 ?pane=schooltheme 落 noperm;主构建绿
1528 lines
52 KiB
Vue
1528 lines
52 KiB
Vue
<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>
|