Compare commits

...
5 Commits
Author SHA1 Message Date
chenxing 302aec52a6 fix(theme): schooltheme 补 item 级 adminOnly(审计发现普通用户导航出现「本校默认」)
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;主构建绿
2026-09-30 13:19:44 +08:00
chenxing 357c0edb52 feat(theme): 设置页「主题」导航组 + iframe 预览页 + 学校默认档对接(主题设置 S2)
- Settings.vue:新增「主题」导航组(组级不设 adminOnly),mytheme「我的主题」所有人可见、schooltheme「本校默认」item 级 adminOnly + 入 ADMIN_PANES 双保险(NavItem 扩展 adminOnly 字段,navGroups 组级+item 级双层过滤);两个 pane 左色块右预览 iframe——候选档经 ?yh-theme=/yh-side=/yh-grad= URL 覆盖参数拼进 #/theme-preview src,切候选即换 src 重载;档位清单本轮硬编码 5 档(主色取通道 tokens);「跟随学校默认」=删用户键、「跟随系统默认」=clearSchTheme 清空学校配置;当前生效档与来源(我的选择/学校默认/系统默认)经 resolveEffectiveThemeState 标注
- views/ThemePreview.vue(新增):纯展示预览页——顶栏 .header-container + 左菜单 .left-menu + 面包屑 + iview Table 写死 4 行,DOM 结构对齐 Notice layout,观感由通道 CSS 按档位类驱动;根容器 pointer-events:none,无搜索/分页/ThemeSwitcher;挂载时动态 append 通道 init(baseHost + /static/js/yh-theme-init.js,判重防双挂)——仅该 document 吃通道 CSS,UserHome 本体 index.html 不加 loader(定案)
- App.vue:/theme-preview 顶层独立分支(不进桌面框架、不依赖登录态与 isRouterAlive——未登录直开不白屏);菜单白名单加 /theme-preview;登录成功后异步拉 schTheme 写 localStorage[yh-theme-sch-default](失败/空不写不报错,推荐值语义)
- router/routes.ts:登记 /theme-preview(实际渲染走 App.vue 分支;若后续引入守卫须列入免登录白名单——注释已写明)
- BaseApi:getSchTheme / saveSchThemeState / clearSchTheme 三封装
- src/utils/theme.ts:YschTheme npm run sync 下发(版本 20260930-1248-4e12e6d,含 SCHOOL_DEFAULT_STORAGE_KEY 与 resolveEffectiveThemeState 新导出)
2026-09-30 12:59:35 +08:00
chenxing 0ded3cce9f feat(settings): 五个 pane 对接真实数据(零新接口)——profile 读 store、改密登出流程、头像直传、logo/wallpaper 管理列表
- profile:姓名/工号(暂以登录账号展示,接口无独立工号字段)/部门/手机/邮箱改读 store.userInfo(无值显示 -),保存按钮占位 disabled(接口待 UserBase 支持本人自助修改,拍板搁置);头像行 v-real-img 真实图
- password:非空/一致性/不同旧密码/长度校验 + BaseApi.updatePassword,成功提示后 1500ms 登出跳 CAS(确认登出流程照抄 UserInfoModal L162-183,不自创)
- avatar:Upload 直传移植 UserInfoModal(action=/api/avatarUpload、accept/format/max-size 20480/with-credentials/网关头/成功写 store.profilePicture 照抄);预设色块保留、「使用默认头像」disabled 占位
- logo:getSchLogoManageList 管理列表(不带 schId,按登录人归属校返回;超管按机构切换走 sys pane)+ 直传 /api/logoUpload + deleteLogoById + 「保存设置」走 saveSchLogo(schId 后端按登录人推断)
- wallpaper:getSchWallpaperManageList + 直传 + deleteWallById + 「应用壁纸」采用 checkWallpaper 个人壁纸语义(与 Header 壁纸弹窗现状一致,执行报告写明;校级默认壁纸保存接口不存在,不发明新语义)
- BaseApi 补 saveSchLogo 封装(复用已有后端接口);sidebar/statset/sys/backend/noperm 五个不动 pane 模板 diff 为空;navGroups/ADMIN_PANES 权限结构未动
2026-09-30 12:43:27 +08:00
chenxing 0f7101983e feat(msgcenter): /msg 换真实数据——服务端筛选+滚动分页、铃铛直跳、unreadCount 上提 store、WS 通知带应用名
- views/msg/index.vue:数据层整体替换(msgType 0/1/2 映射、isRead 1=已读、fmtTime 重写绝对时间);列表走 msgCenter/list 服务端筛选(msgType/appKey/isRead/keyword 防抖 300ms),滚动到底追加下一页,切视图/搜索重置第 1 页;左栏分类计数走 summary(旧版 UserMsg 缺字段降级显示 —),应用清单取 store 应用列表、计数同源 todos;点击卡片标已读、全部已读、去处理(showJump/jumpUrl)、审批直批(共享弹窗);失败态有重试;WS 新消息经 mittBus 自刷新
- components/Header.vue:铃铛直跳 /msg(移除 Poptip 浮层与 900 宽 Modal 及 columns/tableApi 死代码);unreadCount 上提 pinia store(角标与 /msg、LeftMenu 同源);WS Notice.info 标题带应用名(appKey 匹配 store 应用列表,缺失回退类型标签);壁纸/LOGO Modal 不动
- components/MsgApprovalModal.vue(新增):审批直批弹窗抽共享组件,逻辑照抄原 Header 内联实现(同意可选意见/拒绝必填校验/三种失败反馈)
- components/LeftMenu.vue:「消息」入口(url=/msg)未读角标(读 store,红底白字胶囊)
- store/index.ts:unreadCount + setUnreadCount/incUnread/decUnread
- api/BaseApi.ts:getMsgCenterList 参数类型放宽(服务端筛选项);getMsgCenterSummary 返回类型补分类计数字段(可缺省)
- assets/styles/header.scss:删除消息浮层死样式 .message-wrap 段
2026-09-30 12:37:30 +08:00
chenxing e91ce14408 feat(msgcenter,settings): 留白对齐与左侧图标规范化——/msg、/settings 壳层 padding 15px、msg-side 216px、sys pane :deep 抹平、LeftMenu 移除硬编码入口、菜单图标 fill 化 2026-09-30 12:18:57 +08:00
14 changed files with 1411 additions and 622 deletions
+24 -2
View File
@@ -12,6 +12,7 @@ import BschIframe from "@/components/BschIframe.vue";
import SearchPanel from "@/components/SearchPanel.vue"; import SearchPanel from "@/components/SearchPanel.vue";
import UserInfoModal from "@/components/UserInfoModal.vue"; import UserInfoModal from "@/components/UserInfoModal.vue";
import LightPageModal from "@/components/LightPageModal.vue"; import LightPageModal from "@/components/LightPageModal.vue";
import ThemePreview from "@/views/ThemePreview.vue";
import BaseApi from "@/api/BaseApi"; import BaseApi from "@/api/BaseApi";
import { BschIframeType, IframePageParmas, RoleInfo, StateEnum } from "./types"; import { BschIframeType, IframePageParmas, RoleInfo, StateEnum } from "./types";
@@ -60,6 +61,7 @@ mittBus.on("openBschIframePage", async (params: IframePageParmas) => {
try { try {
const [r1, r2] = await Promise.all([p1, p2]); const [r1, r2] = await Promise.all([p1, p2]);
store.$state.userInfo = userinfo; store.$state.userInfo = userinfo;
loadSchThemeDefault();
if (r1.length == 0) { if (r1.length == 0) {
store.$state.userRoleList = [ store.$state.userRoleList = [
{ {
@@ -86,6 +88,21 @@ mittBus.on("openBschIframePage", async (params: IframePageParmas) => {
console.timeEnd("init"); console.timeEnd("init");
})(); })();
/** 拉学校默认主题档写键(异步尽力而为:失败/404/空 → 不写、不报错、不阻塞登录流程) */
function loadSchThemeDefault() {
BaseApi.getSchTheme()
.then((res: any) => {
if (res && res.themeState) {
try {
localStorage.setItem("yh-theme-sch-default", res.themeState);
} catch (e) {
/* 隐私模式写失败静默 */
}
}
})
.catch(() => undefined);
}
/**切换角色 */ /**切换角色 */
async function switchRole() { async function switchRole() {
await loadUserMenuBarList(); await loadUserMenuBarList();
@@ -113,8 +130,9 @@ async function loadUserMenuBarList() {
} else { } else {
findIndex = menuList.findIndex(x => x.url === route.path); findIndex = menuList.findIndex(x => x.url === route.path);
} }
// /msg、/settings 是前台硬编码路由(不在后台菜单表里),直达/刷新不应被弹回桌面 // /msg、/settings 由后台「边栏设置」配置后即进入菜单表;未配置时也允许直达/刷新,不被弹回桌面。
if (findIndex === -1 && !["/msg", "/settings"].includes(route.path)) router.push({ name: "index" }); // /theme-preview 同理(预览页按 URL 参数挂档,不依赖菜单表与登录态)
if (findIndex === -1 && !["/msg", "/settings", "/theme-preview"].includes(route.path)) router.push({ name: "index" });
} }
const getRouterKey: (route: any) => string = route => { const getRouterKey: (route: any) => string = route => {
@@ -128,7 +146,11 @@ const mainStyle = computed(() => `background-image: url(${store.getCurWallPaperU
</script> </script>
<template> <template>
<!-- 主题预览页(主题设置 2026-09-30):顶层独立分支——不进桌面框架(无左栏/Header/壁纸)、
不依赖登录态与 isRouterAlive 门控(未登录直开也不白屏);仅在该 document 内动态引通道 init -->
<ThemePreview v-if="route.path === '/theme-preview'" />
<div <div
v-else
:class="['app-desktop app-desktop__animation', !menuVisible ? 'app-desktop__full' : '']" :class="['app-desktop app-desktop__animation', !menuVisible ? 'app-desktop__full' : '']"
:style="mainStyle" :style="mainStyle"
v-show="StateType > StateEnum.Loading" v-show="StateType > StateEnum.Loading"
+46 -4
View File
@@ -98,21 +98,31 @@ class Base extends Abstract {
// ===== 消息中心代理(§3.5:经后端签名代理 UserMsg,前端不直连)===== // ===== 消息中心代理(§3.5:经后端签名代理 UserMsg,前端不直连)=====
/** /**
* 消息中心分页列表({total, list[]};元素含改写后的绝对 jumpUrl) * 消息中心分页列表({total, list[]};元素含改写后的绝对 jumpUrl)。
* 可选服务端筛选:msgType 0/1/2、appKey、isRead 0/1、keyword 标题/内容模糊。
* @memberof Basic * @memberof Basic
*/ */
getMsgCenterList(params: PageParams) { getMsgCenterList(params: PageParams & { msgType?: number; appKey?: string; isRead?: number; keyword?: string }) {
const url = path + "/msgCenter/list"; const url = path + "/msgCenter/list";
return this.getRequest<PageData>({ url, params }); return this.getRequest<PageData>({ url, params });
} }
/** /**
* 铃铛摘要:未读数 + 各应用待办角标 * 铃铛摘要:未读数 + 各应用待办角标 + 分类计数(sysUnread/bizUnread/approvalUnread/total/readCount,
* 旧版 UserMsg 无 summary 端点时这些字段缺省,前端按降级处理)
* @memberof Basic * @memberof Basic
*/ */
getMsgCenterSummary() { getMsgCenterSummary() {
const url = path + "/msgCenter/summary"; const url = path + "/msgCenter/summary";
return this.getRequest<{ unreadCount: number; todos: { appKey: string; count: number }[] }>({ url }); return this.getRequest<{
unreadCount: number;
todos: { appKey: string; count: number }[];
sysUnread?: number;
bizUnread?: number;
approvalUnread?: number;
total?: number;
readCount?: number;
}>({ url });
} }
/** /**
@@ -162,6 +172,38 @@ class Base extends Abstract {
return this.postRequest({ url, data: { oldPassword, password } }); return this.postRequest({ url, data: { oldPassword, password } });
} }
/**
* 保存校级 LOGO(schId 后端按登录人推断:主学校→归属组→0 平台默认)
* @memberof Basic
*/
saveSchLogo(logoUrl: string) {
const url = path + "/saveSchLogo";
return this.postRequest({ url, params: { logoUrl } });
}
// ===== 学校默认主题档(主题设置 2026-09-30;后端契约见 SchSettingsController)=====
/**
* 读本校默认主题档(已登录可读):{themeState: "<ThemeState JSON>" | null}——
* null 表示未配置(前端"不写键、不报错"),登录后调一次写 localStorage["yh-theme-sch-default"]
*/
getSchTheme() {
const url = path + "/schTheme";
return this.getRequest<{ themeState: string | null; schId: number }>({ url });
}
/** 保存本校默认主题档(仅学校管理员):themeState 为整份 ThemeState JSON 字符串 */
saveSchThemeState(themeState: string) {
const url = path + "/saveSchThemeState";
return this.postRequest({ url, params: { themeState } });
}
/** 清空本校默认主题档(仅学校管理员;置空后读侧返回 null) */
clearSchTheme() {
const url = path + "/clearSchTheme";
return this.postRequest({ url });
}
savUserRole(info: any) { savUserRole(info: any) {
const url = path + "/user/savUserRole"; const url = path + "/user/savUserRole";
return this.postRequest({ url, data: info as any }); return this.postRequest({ url, data: info as any });
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" fill-rule="evenodd"><path d="M801.4 393.1 798.6 313.1 794.7 286.7 788.0 259.2 778.1 231.1 765.2 204.2 751.5 181.3 735.6 159.9 715.6 137.7 693.5 117.7 672.0 101.9 649.1 88.1 622.2 75.3 594.1 65.3 568.2 58.9 541.8 54.9 512.0 53.3 482.2 54.9 455.8 58.9 429.9 65.3 401.8 75.3 374.9 88.1 352.0 101.9 329.3 118.7 308.4 137.7 290.4 157.4 271.7 182.7 258.0 205.6 245.9 231.1 236.4 257.7 229.3 286.7 225.3 314.7 222.6 393.1 218.7 440.0 212.7 482.2 200.5 537.1 190.8 568.5 180.3 595.9 169.4 619.5 158.4 639.7 147.8 656.5 133.1 675.9 121.1 689.0 104.4 703.7 99.3 711.2 96.9 717.9 96.0 725.1 96.8 732.3 99.9 740.7 104.1 746.6 110.9 752.4 119.1 756.1 128.0 757.3 896.0 757.3 903.2 756.5 911.5 753.3 917.4 749.1 921.1 745.2 926.3 735.7 927.8 728.7 927.9 723.3 925.4 712.8 919.6 703.7 902.9 689.0 890.9 675.9 876.2 656.5 865.6 639.7 854.6 619.5 843.7 595.9 833.2 568.5 823.5 537.1 811.3 482.2 805.3 440.0 801.4 393.1Z M200.3 693.0 219.8 661.1 233.2 634.5 251.1 589.9 262.0 554.5 271.5 515.0 275.6 493.7 282.2 447.7 286.5 396.2 289.1 319.4 292.3 297.6 297.7 276.3 305.1 255.6 314.5 235.8 325.8 216.9 338.9 199.3 353.6 183.0 369.9 168.2 387.6 155.1 406.4 143.8 426.3 134.4 447.0 127.0 468.3 121.7 490.0 118.5 512.0 117.4 534.0 118.5 555.7 121.7 577.0 127.0 597.7 134.4 617.6 143.8 636.4 155.1 654.1 168.2 670.4 183.0 685.1 199.3 698.2 216.9 709.5 235.8 718.9 255.6 726.3 276.3 731.7 297.6 734.9 319.4 737.5 396.2 741.8 447.7 748.2 492.7 756.8 534.3 767.3 572.7 784.9 621.2 803.7 660.2 823.9 693.3 200.3 693.0Z M553.4 884.6 543.6 894.0 531.8 900.5 518.8 903.9 505.2 903.9 492.2 900.5 483.2 895.9 475.2 889.6 464.3 877.2 457.0 872.0 448.6 869.1 437.8 868.8 430.9 870.7 426.1 873.0 421.7 876.2 416.8 881.4 414.0 886.0 411.4 892.7 410.6 898.0 410.8 905.1 412.0 910.4 415.0 916.9 425.1 929.9 437.3 941.5 455.8 954.0 474.4 962.2 490.8 966.4 511.0 968.4 527.9 967.3 540.5 964.9 554.7 960.3 568.2 954.0 580.8 946.1 592.3 936.6 602.4 925.8 611.2 913.7 615.0 905.6 616.4 898.6 616.4 893.2 615.5 887.9 612.9 881.2 608.9 875.3 605.1 871.5 600.7 868.4 594.2 865.5 583.6 864.0 578.2 864.6 571.4 866.8 562.5 872.8 553.4 884.6Z" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 826 B

After

Width:  |  Height:  |  Size: 5.1 KiB

-93
View File
@@ -150,99 +150,6 @@
} }
} }
.message-wrap {
h3.title {
height: 50px;
padding: 12px 16px;
color: #272829;
font-weight: 600;
font-size: 16px;
border-bottom: 1px solid #e8eaec;
}
.msg-ul {
width: 360px;
background: #fff;
padding-bottom: 0;
border-radius: 10px;
max-height: 240px;
min-height: 100px;
li {
height: 46px;
padding: 5px 18px;
position: relative;
overflow: hidden;
line-height: 36px;
border-bottom: 1px dotted #e8eaec;
h4 {
width: calc(100% - 40px);
float: left;
font-size: 15px;
color: #333;
font-weight: 500;
}
.content {
border: none;
font-size: 13px;
cursor: pointer;
}
.read {
color: #999999;
}
a {
float: right;
height: 32px;
outline: none;
display: flex;
align-items: center;
}
svg {
width: 20px;
height: 20px;
}
}
.no-msg-box {
display: flex;
height: 100px;
justify-content: center;
align-items: center;
border: none;
}
.app-name-box {
border: none;
color: #fff !important;
background: #577afb;
border-radius: 3px;
padding: 6px 8px;
font-size: 13px;
margin-right: 12px;
}
.fix a img {
width: 22px;
height: 22px;
margin-top: 5px;
}
}
.footer {
background-color: #f9f9f9;
height: 54px;
line-height: 54px;
padding: 0 20px;
font-size: 15px;
border-top: 1px solid #e8eaec;
a.link-all {
color: #999999;
font-weight: 550;
}
a.read-all {
color: #5979ea;
}
}
}
.role-info { .role-info {
height: 22px; height: 22px;
color: #6b8cf4 !important; color: #6b8cf4 !important;
+30 -302
View File
@@ -23,58 +23,14 @@
</div> </div>
</Transition> </Transition>
</li> </li>
<!-- 铃铛直跳 /msg(消息对接 2026-09-30:移除 Poptip 浮层与 900 宽列表 Modal,
未读数上提 store 与 /msg 页左栏同源;审批直批弹窗抽至 MsgApprovalModal) -->
<li> <li>
<Poptip class="fl" popper-class="message-wrap reset-panel" :width="360" trigger="click" <div class="icon-box" v-wave title="消息中心" @click="router.push('/msg')">
placement="bottom-end" v-model="isMsgShow" :transfer="true">
<div class="icon-box" v-wave>
<Badge :count="unreadCount" :offset="[2, 0]" overflow-count="99"> <Badge :count="unreadCount" :offset="[2, 0]" overflow-count="99">
<SvgIcon name="header-2" color="#333333" /> <SvgIcon name="header-2" color="#333333" />
</Badge> </Badge>
</div> </div>
<template #content>
<h3 class="title">查看消息</h3>
<vuescroll>
<ul class="msg-ul">
<li class="no-msg-box" v-if="userMsgList.length == 0">暂无消息</li>
<template v-else v-for="(item, index) in userMsgList">
<li class="fix">
<h4 :class="{
'text-ellipsis': true,
read: item.isRead === 1,
}">
<span class="app-name-box">{{ item.appName || msgTypeLabel(item.msgType) }}</span>
<span class="content" :title="item.content"
@click="onClickMsg(item)">
{{ item.title }}:{{ item.content }}
</span>
</h4>
<span v-if="item.msgType === 2" class="approval-box">
<a href="javascript:;" class="approval-link approve"
@click="openApproval(item, 'approve')">同意</a>
<a href="javascript:;" class="approval-link reject"
@click="openApproval(item, 'reject')">拒绝</a>
</span>
<a v-if="item.showJump" href="javascript:;" @click="onClickMsg(item)"
title="查看详情">
<SvgIcon name="arrow-right" color="#bbbbbb" />
</a>
</li>
</template>
</ul>
</vuescroll>
<div class="footer fix">
<a href="javascript:;" class="fl link-all" @click="showMsgList">查看全部</a>
<a href="javascript:;" class="fr read-all" v-if="userMsgList.length > 0"
@click="readMsgAll(1)">
全部已读
</a>
<!-- <a class="tzql_r" @click="clearMsg" href="javascript:;" title="清空消息"
><img src="image/msg-clear.png"
/></a> -->
</div>
</template>
</Poptip>
</li> </li>
<li> <li>
<Poptip class="fl" popper-class="roleinfo-wrap reset-panel" :width="240" trigger="click" <Poptip class="fl" popper-class="roleinfo-wrap reset-panel" :width="240" trigger="click"
@@ -175,17 +131,6 @@
</ul> </ul>
</header> </header>
<Modal v-model="isMsgListShow" width="900" :mask="true" title="消息" class-name="msg-modal" :transfer="false">
<div class="fix mb-10">
<Button type="primary" @click="resetMsgPage" size="small" class="fl ml-10 width-10" ghost>刷新</Button>
<Button type="primary" @click="readMsgAll(2)" size="small" class="fl ml-10 width-10" ghost>全部已读</Button>
</div>
<TablePage :table-height="360" ref="refTablePage" page-type="small" :dataApi="tableApi" :pageParams="pageParams"
:columns="columns" :auto-load="false" />
<template #footer>
<Button type="primary" ghost @click="isMsgListShow = false">关闭</Button>
</template>
</Modal>
<Modal v-model="isWallpaperShow" class-name="wallpaper-modal" width="960" :mask="false" title="设置壁纸" <Modal v-model="isWallpaperShow" class-name="wallpaper-modal" width="960" :mask="false" title="设置壁纸"
:transfer="false"> :transfer="false">
<vuescroll> <vuescroll>
@@ -277,21 +222,8 @@
</template> </template>
</Modal> </Modal>
<!-- 卡片直批弹窗(B3 §2):同意可选意见 / 拒绝意见必填;身份服务端取会话 --> <!-- 审批直批弹窗(B3 §2)已抽共享组件(消息对接 2026-09-30),供 /msg 页复用;Header 内已无直批入口 -->
<Modal v-model="approval.visible" width="460" :mask-closable="false" <MsgApprovalModal ref="refMsgApproval" />
:title="approval.action === 'approve' ? '同意办理' : '拒绝办理'">
<div class="approval-body">
<p class="approval-title text-ellipsis">{{ approval.title || "确认办理该待办?" }}</p>
<Input v-model="approval.opinion" type="textarea" :rows="3" maxlength="200" show-word-limit
:placeholder="approval.action === 'approve' ? '审批意见(可选)' : '请输入拒绝意见(必填)'" />
</div>
<template #footer>
<Button type="primary" :loading="approval.submitting" @click="submitApproval">
{{ approval.action === "approve" ? "确认同意" : "确认拒绝" }}
</Button>
<Button @click="approval.visible = false">取消</Button>
</template>
</Modal>
<MenuBarModal ref="refMenuBarModal" /> <MenuBarModal ref="refMenuBarModal" />
<StatsBarModal ref="refStatsBarModal" /> <StatsBarModal ref="refStatsBarModal" />
@@ -304,6 +236,7 @@ import SvgIcon from "@/components/SvgIcon.vue";
import vuescroll from "vuescroll"; import vuescroll from "vuescroll";
import { ref, reactive, computed, onMounted, onUnmounted, inject, watch } from "vue"; import { ref, reactive, computed, onMounted, onUnmounted, inject, watch } from "vue";
import { useDefaultStore } from "@/store"; import { useDefaultStore } from "@/store";
import { storeToRefs } from "pinia";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { requireImg } from "@/utils/hook/useImg"; import { requireImg } from "@/utils/hook/useImg";
import ContextMenu from "@/components/ContextMenu.vue"; import ContextMenu from "@/components/ContextMenu.vue";
@@ -311,11 +244,10 @@ import MenuBarModal from "@/components/MenuBarModal.vue";
import StatsBarModal from "@/components/StatsBarModal.vue"; import StatsBarModal from "@/components/StatsBarModal.vue";
import TeacherCopilotModal from "@/components/TeacherCopilotModal.vue"; import TeacherCopilotModal from "@/components/TeacherCopilotModal.vue";
import BaseApi from "@/api/BaseApi"; import BaseApi from "@/api/BaseApi";
import TablePage from "@/components/TablePage.vue"; import MsgApprovalModal from "@/components/MsgApprovalModal.vue";
import { Notice } from "view-ui-plus"; import { Notice } from "view-ui-plus";
import { IframePageParmas, RoleInfo } from "@/types"; import { IframePageParmas, RoleInfo } from "@/types";
import { isEqual, orderBy } from "lodash"; import { isEqual, orderBy } from "lodash";
import { PageParams } from "@/types/Basics";
import UserSettingApi from "@/api/UserSettingApi"; import UserSettingApi from "@/api/UserSettingApi";
import mittBus from "@/utils/mitt"; import mittBus from "@/utils/mitt";
import MenuBarApi from "@/api/MenuBarApi"; import MenuBarApi from "@/api/MenuBarApi";
@@ -324,10 +256,13 @@ import Utils from "@/utils";
const route = useRoute(); const route = useRoute();
const router = useRouter(); const router = useRouter();
const store = useDefaultStore(); const store = useDefaultStore();
const refTablePage: any = ref(null); const refMsgApproval = ref<any>();
const { $conf } = useCurrentInstance(); const { $conf } = useCurrentInstance();
const baseUrl = computed(() => $conf.fileHost); const baseUrl = computed(() => $conf.fileHost);
/** 未读消息数上提 pinia store(消息对接 2026-09-30):Header 角标与 /msg 页左栏、LeftMenu 同源 */
const { unreadCount } = storeToRefs(store);
const refSearchInput = ref<any>(); const refSearchInput = ref<any>();
const refMenuBarModal = ref<any>(); const refMenuBarModal = ref<any>();
const refStatsBarModal = ref<any>(); const refStatsBarModal = ref<any>();
@@ -341,19 +276,14 @@ const logoUrl = computed(() => {
const sysLogo = logoList.value.find(x => x.type == 0); const sysLogo = logoList.value.find(x => x.type == 0);
return sysLogo ? sysLogo.url : ""; return sysLogo ? sysLogo.url : "";
}); });
const isMsgShow = ref(false), const isUserInfoShow = ref(false),
isMsgListShow = ref(false),
isUserInfoShow = ref(false),
isRoleInfoShow = ref(false), isRoleInfoShow = ref(false),
isWallpaperShow = ref(false), isWallpaperShow = ref(false),
isLogoShow = ref(false), isLogoShow = ref(false),
isSearching = ref(false), isSearching = ref(false),
searchAppText = ref<string | null>(null), searchAppText = ref<string | null>(null),
wallpaperList = ref<any[]>([]), wallpaperList = ref<any[]>([]),
logoList = ref<any[]>([]), logoList = ref<any[]>([]);
userMsgList = ref<any[]>([]),
/** 未读消息数(msgCenter/summary 拉取 + WS newMessage 实时累加) */
unreadCount = ref(0);
// ===== 消息中心 WS 实时推送(UserMsg /ws/msg,一次性票据握手;断线 5s 重连换新票) ===== // ===== 消息中心 WS 实时推送(UserMsg /ws/msg,一次性票据握手;断线 5s 重连换新票) =====
let msgWs: WebSocket | null = null; let msgWs: WebSocket | null = null;
@@ -364,7 +294,7 @@ let msgWsClosed = false;
async function loadSummary() { async function loadSummary() {
try { try {
const summary = await BaseApi.getMsgCenterSummary(); const summary = await BaseApi.getMsgCenterSummary();
unreadCount.value = (summary && summary.unreadCount) || 0; store.setUnreadCount(summary && summary.unreadCount);
} catch (e) { } catch (e) {
/* 摘要失败不打扰页面 */ /* 摘要失败不打扰页面 */
} }
@@ -415,12 +345,22 @@ async function connectMsgWs() {
}; };
} }
/** WS 帧按 appKey 匹配应用名(store 应用列表);查无回退类型标签(缺失降级,不报错) */
function frameAppName(frame: any) {
const appKey = frame && frame.appKey;
if (appKey) {
const hit = store.getFlattenAppList.find((x: any) => x.appKey == appKey || x.appCode == appKey);
if (hit && hit.name) return hit.name;
}
return msgTypeLabel(frame ? frame.msgType : null);
}
function onNewMessage(frame: any) { function onNewMessage(frame: any) {
unreadCount.value += 1; store.incUnread();
reloadMsg(); // /msg 页开着时自刷新(页内监听该事件重拉第一页与摘要)
if (isMsgListShow.value) resetMsgPage(); mittBus.emit("msgCenter:newMessage", frame);
Notice.info({ Notice.info({
title: frame.title || "新消息", title: `【${frameAppName(frame)}】新消息`,
desc: frame.content || "", desc: frame.content || "",
duration: 5, duration: 5,
}); });
@@ -461,107 +401,8 @@ const userRole = computed(() => store.userRoleList);
const toggleSearch: any = inject("toggleSearch"); const toggleSearch: any = inject("toggleSearch");
const pageParams: PageParams = reactive({ page: 1, count: 10 });
const tableApi = (params: PageParams) => BaseApi.getMsgCenterList(params);
const columns = ref<any[]>([
{
type: "index",
title: "序号",
width: 70,
align: "center",
},
{
title: "应用",
width: 110,
align: "center",
render(h: any, params: any) {
return h("span", params.row.appName || msgTypeLabel(params.row.msgType));
},
},
{
title: "消息内容",
render(h: any, params: any) {
const row = params.row;
return h("span", { attrs: { title: row.content } }, `${row.title || ""}:${row.content || ""}`);
},
},
{
title: "状态",
width: 100,
render(h: any, params: any) {
return h("span", params.row.isRead === 1 ? "已读" : "未读");
},
},
{
title: "日期",
align: "center",
width: 200,
key: "sendTime",
},
{
title: "操作",
width: 210,
align: "center",
render(h: any, params: any) {
const row = params.row;
const nodes: any[] = [];
// 审批待办(msgType=2)直批入口:taskId 由服务端按 bizId 匹配定位;
// 无权限/已处理由后端返回文案,直批按钮不受跳转权限影响
if (row.msgType === 2 && row.msgId) {
nodes.push(
h(
"a",
{
attrs: { href: "javascript:;" },
on: {
click: () => openApproval(row, "approve"),
},
},
"同意"
),
h("span", " "),
h(
"a",
{
attrs: { href: "javascript:;" },
on: {
click: () => openApproval(row, "reject"),
},
},
"拒绝"
)
);
}
if (row.showJump) {
if (nodes.length) nodes.push(h("span", " "));
nodes.push(
h(
"a",
{
attrs: { href: "javascript:;" },
on: {
click: () => {
if (row.isRead !== 1 && row.msgId) {
BaseApi.readMsgCenter(row.msgId);
row.isRead = 1;
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
window.open(row.jumpUrl, "_blank");
},
},
},
"去处理"
)
);
}
if (!nodes.length) return h("span", "—");
return h("span", nodes);
},
},
]);
onMounted(async () => { onMounted(async () => {
setTimeout(() => { setTimeout(() => {
reloadMsg();
loadSummary(); loadSummary();
connectMsgWs(); connectMsgWs();
// isWallpaperShow.value = true; // isWallpaperShow.value = true;
@@ -610,94 +451,11 @@ function onUserinfoClickRoles() {
}, 0); }, 0);
} }
async function readMsgAll(type: 1 | 2) { /** 消息类型名(UserMsg msgType:0系统通知/1业务提醒/2审批待办);WS 帧缺 appName 时的降级标签 */
await BaseApi.readMsgCenterAll(); function msgTypeLabel(msgType: number | null) {
await reloadMsg();
unreadCount.value = 0;
if (type == 2) resetMsgPage();
}
async function reloadMsg() {
const result: any = await BaseApi.getMsgCenterList({ page: 1, count: 10 });
userMsgList.value = result.list || [];
}
/** 消息类型名(UserMsg msgType:0系统通知/1业务提醒/2审批待办) */
function msgTypeLabel(msgType: number) {
return { 0: "系统通知", 1: "业务提醒", 2: "审批待办" }[msgType as 0 | 1 | 2] || "系统通知"; return { 0: "系统通知", 1: "业务提醒", 2: "审批待办" }[msgType as 0 | 1 | 2] || "系统通知";
} }
/** 点击消息:未读先标读;配了 jumpUrl(如 flow.task 待办)新窗口打开落地页 */
async function onClickMsg(item: any) {
if (item.isRead !== 1 && item.msgId) {
await BaseApi.readMsgCenter(item.msgId);
item.isRead = 1;
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
if (item.jumpUrl) window.open(item.jumpUrl, "_blank");
}
// ===== 卡片直批(B3 §2):同意/拒绝待办;operatorId/schId/operatorName 服务端取会话 =====
const approval = reactive({
visible: false,
msgId: 0,
action: "approve" as "approve" | "reject",
title: "",
opinion: "",
submitting: false,
});
function openApproval(item: any, action: "approve" | "reject") {
approval.msgId = item.msgId;
approval.action = action;
approval.title = item.title ? String(item.title) : "";
approval.opinion = "";
approval.visible = true;
}
async function submitApproval() {
if (approval.submitting) return;
if (approval.action === "reject" && !approval.opinion.trim()) {
Notice.warning({ title: "拒绝必须填写意见", duration: 3 });
return;
}
approval.submitting = true;
try {
const res: any = await BaseApi.handleMsg(approval.msgId, approval.action, approval.opinion.trim());
if (res && res.ok) {
Notice.success({ title: "办理成功", duration: 3 });
approval.visible = false;
markRowRead(approval.msgId);
} else {
Notice.warning({ title: (res && res.message) || "办理失败", duration: 5 });
}
// 成功/失效/被他人处理等场景都同步刷新,让卡片状态跟上服务端
reloadMsg();
} catch (e) {
// flow-center 不可达等:降级为提示,不阻塞消息卡片其余功能
Notice.error({ title: "流程中心暂不可用,请稍后重试", duration: 5 });
} finally {
approval.submitting = false;
}
}
function markRowRead(msgId: number) {
const row = userMsgList.value.find((m: any) => m.msgId === msgId);
if (row && row.isRead !== 1) {
row.isRead = 1;
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
}
function resetMsgPage() {
refTablePage.value.reloadData();
}
function showMsgList() {
isMsgShow.value = false;
router.push("/msg");
refTablePage.value.reloadData();
}
async function wallpaperSuccess(response: any, file: any, fileListt: any) { async function wallpaperSuccess(response: any, file: any, fileListt: any) {
await getUserWallpaperList(); await getUserWallpaperList();
} }
@@ -802,36 +560,6 @@ function logout() {
<style lang="scss" scoped> <style lang="scss" scoped>
@import url("@/assets/styles/header.scss"); @import url("@/assets/styles/header.scss");
::v-deep .ivu-table td {
height: 32px;
}
/* 卡片直批(B3):待办消息行内「同意/拒绝」入口 */
.approval-box {
margin-right: 6px;
white-space: nowrap;
}
.approval-link {
margin-left: 8px;
font-size: 12px;
color: #577afb;
}
.approval-link.reject {
color: #ed4014;
}
.approval-body {
padding: 4px 2px;
}
.approval-title {
margin-bottom: 10px;
color: #515a6e;
font-weight: 600;
}
</style> </style>
<style lang="scss"> <style lang="scss">
+26 -28
View File
@@ -5,19 +5,22 @@
<SvgIcon name="menu-operate" color="#ececec" /> <SvgIcon name="menu-operate" color="#ececec" />
</li> </li>
<template v-for="(item, index) in menuList" :key="index"> <template v-for="(item, index) in menuList" :key="index">
<!-- 硬编码补位的「设置」一级入口上方的分隔线(业务入口 vs 系统入口分层) -->
<li v-if="item.sep" class="menu-sep" aria-hidden="true"></li>
<li <li
v-wave="myWave" v-wave="myWave"
@click="linkPage(item, index)" @click="linkPage(item, index)"
:class="{ active: index == activeIndex, 'first-bottom': index == menuFirstBottomIndex }" :class="{ active: index == activeIndex, 'first-bottom': index == menuFirstBottomIndex }"
> >
<a :title="item.name"> <a :title="item.name">
<!-- 图标走后台上传的 svg(菜单 icon 字段),保留本地 sprite 兜底能力 -->
<SvgIcon v-if="item.localIcon" :name="item.localIcon" size="24" <SvgIcon v-if="item.localIcon" :name="item.localIcon" size="24"
:color="index == activeIndex ? '#fff' : '#ececec'" /> :color="index == activeIndex ? '#fff' : '#ececec'" />
<FetchSvg v-else size="24" :src="$conf.serviceHost + item.icon" <FetchSvg v-else size="24" :src="$conf.serviceHost + item.icon"
:color="index == activeIndex ? '#fff' : '#ececec'" /> :color="index == activeIndex ? '#fff' : '#ececec'" />
<span>{{ item.name }}</span> <span>{{ item.name }}</span>
<!-- 「消息」入口未读角标(读 store,与 Header 铃铛同源) -->
<span v-if="item.url === '/msg' && store.unreadCount > 0" class="menu-badge">
{{ store.unreadCount > 99 ? "99+" : store.unreadCount }}
</span>
</a> </a>
</li> </li>
</template> </template>
@@ -66,26 +69,11 @@ const route = useRoute(),
const store = useDefaultStore(); const store = useDefaultStore();
const { menuVisible } = storeToRefs(store); const { menuVisible } = storeToRefs(store);
/** 硬编码的「设置」一级入口:isNative 直走 router.push,图标用本地 svg sprite */
const SETTINGS_MENU_ITEM = {
id: -1,
name: "设置",
url: "/settings",
isNative: true,
isBottom: true,
orderNum: 9999,
localIcon: "menu-settings",
sep: true,
};
/** 一级入口(含「消息」「设置」)统一由后台「边栏设置」配置,图标走 icon 字段上传的 svg */
const menuList = computed<any[]>(() => { const menuList = computed<any[]>(() => {
let topArr = store.curUserMenuList.filter(x => x.isBottom !== true).sort((a, b) => a.orderNum - b.orderNum); const topArr = store.curUserMenuList.filter(x => x.isBottom !== true).sort((a, b) => a.orderNum - b.orderNum);
let bottomArr = store.curUserMenuList.filter(x => x.isBottom === true).sort((a, b) => a.orderNum - b.orderNum); const bottomArr = store.curUserMenuList.filter(x => x.isBottom === true).sort((a, b) => a.orderNum - b.orderNum);
// 「设置」一级入口走前台硬编码补位(路径 A,不动后端菜单表);
// 若后台菜单表已配置同 url 的项则不重复追加,避免双入口
if (!store.curUserMenuList.some(x => x.url === "/settings")) {
bottomArr = [...bottomArr, SETTINGS_MENU_ITEM];
}
return [...topArr, ...bottomArr]; return [...topArr, ...bottomArr];
}); });
@@ -163,6 +151,7 @@ async function linkPage(item: any, index: number) {
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
li { li {
position: relative;
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
-webkit-transform: translateZ(0); -webkit-transform: translateZ(0);
@@ -216,14 +205,6 @@ async function linkPage(item: any, index: number) {
bottom: 8px; bottom: 8px;
left: 5px; left: 5px;
} }
li.menu-sep {
width: 36px;
height: 1px;
margin: 6px auto;
padding: 0;
background: rgba(255, 255, 255, 0.22);
pointer-events: none;
}
li:hover { li:hover {
background-color: rgba(255, 255, 255, 0.15); background-color: rgba(255, 255, 255, 0.15);
} }
@@ -236,6 +217,23 @@ async function linkPage(item: any, index: number) {
margin-top: auto; margin-top: auto;
} }
} }
/* 「消息」入口未读角标:图标右上角红底白字胶囊(与 Header Badge 同源数据) */
.menu-badge {
position: absolute;
top: 6px;
right: 4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 8px;
background: #f24822;
color: #fff;
font-size: 11px;
line-height: 16px;
text-align: center;
pointer-events: none;
}
.menu-arrow_box { .menu-arrow_box {
position: relative; position: relative;
left: 62px; left: 62px;
+89
View File
@@ -0,0 +1,89 @@
<template>
<!-- 消息卡片直批弹窗(B3 §2,消息对接 2026-09-30 抽共享):同意可选意见 / 拒绝意见必填;
身份(operatorId/schId/operatorName)服务端取会话。Header 铃铛域已无直批入口,
本组件供 /msg 消息中心页使用,逻辑照抄原 Header 内联实现。 -->
<Modal :model-value="visible" width="460" :mask-closable="false"
:title="action === 'approve' ? '同意办理' : '拒绝办理'" @on-cancel="close">
<div class="approval-body">
<p class="approval-title text-ellipsis">{{ title || "确认办理该待办?" }}</p>
<Input v-model="opinion" type="textarea" :rows="3" maxlength="200" show-word-limit
:placeholder="action === 'approve' ? '审批意见(可选)' : '请输入拒绝意见(必填)'" />
</div>
<template #footer>
<Button type="primary" :loading="submitting" @click="submit">
{{ action === "approve" ? "确认同意" : "确认拒绝" }}
</Button>
<Button @click="close">取消</Button>
</template>
</Modal>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue";
import { Notice } from "view-ui-plus";
import BaseApi from "@/api/BaseApi";
/**
* 审批直批弹窗(msgCenter/handle 代理)。
* 父组件 open({msgId, title, action}) 打开;成功(后端 ok)后 emit("handled", msgId),
* 失败/已被处理等场景只弹后端文案,不关弹窗(成功/失败/已被处理三种场景都有反馈)。
*/
const emit = defineEmits<{ (e: "handled", msgId: number): void }>();
const visible = ref(false);
const msgId = ref(0);
const title = ref("");
const action = ref<"approve" | "reject">("approve");
const opinion = ref("");
const submitting = ref(false);
function open(row: { msgId: number; title?: string }, act: "approve" | "reject") {
msgId.value = row.msgId;
action.value = act;
title.value = row.title ? String(row.title) : "";
opinion.value = "";
submitting.value = false;
visible.value = true;
}
function close() {
visible.value = false;
}
async function submit() {
if (submitting.value) return;
if (action.value === "reject" && !opinion.value.trim()) {
Notice.warning({ title: "拒绝必须填写意见", duration: 3 });
return;
}
submitting.value = true;
try {
const res: any = await BaseApi.handleMsg(msgId.value, action.value, opinion.value.trim());
if (res && res.ok) {
Notice.success({ title: "办理成功", duration: 3 });
visible.value = false;
emit("handled", msgId.value);
} else {
Notice.warning({ title: (res && res.message) || "办理失败", duration: 5 });
}
} catch (e) {
// flow-center 不可达等:降级为提示,不阻塞消息卡片其余功能
Notice.error({ title: "流程中心暂不可用,请稍后重试", duration: 5 });
} finally {
submitting.value = false;
}
}
defineExpose({ open });
</script>
<style scoped lang="scss">
.approval-body {
.approval-title {
font-size: 14px;
font-weight: 500;
color: #2c3138;
margin-bottom: 12px;
}
}
</style>
+9
View File
@@ -83,5 +83,14 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/Settings.vue"), component: () => import("@/views/Settings.vue"),
meta: { title: "设置" }, meta: { title: "设置" },
}, },
{
// 主题预览页(主题设置 2026-09-30):实际渲染走 App.vue 顶层条件分支(不进桌面框架,未登录直开不白屏)。
// 此处登记是为 URL 匹配合法(免 no-match 告警);若后续引入全局路由守卫,必须把本路由列入
// 免登录/免初始化白名单——预览页按 URL 覆盖参数挂档,不依赖登录态(YschTheme 方案 §3.4/§4 S2.6)。
path: "/theme-preview",
name: "ThemePreview",
component: () => import("@/views/ThemePreview.vue"),
meta: { title: "主题预览" },
},
]; ];
export default routes; export default routes;
+12
View File
@@ -13,6 +13,8 @@ export interface IdefaultStore {
curUserMenuList: Array<any>; //当前角色菜单 curUserMenuList: Array<any>; //当前角色菜单
curUserAppList: Array<any>; //当前角色所有应用 curUserAppList: Array<any>; //当前角色所有应用
curRoleUid: string | null; //当前角色uid curRoleUid: string | null; //当前角色uid
/** 未读消息数(msgCenter/summary 拉取 + WS newMessage 实时累加;Header 角标与 /msg 页左栏同源) */
unreadCount: number;
// curUsedAppList: Array<any>; //当前角色常用应用 // curUsedAppList: Array<any>; //当前角色常用应用
} }
@@ -50,12 +52,22 @@ export const useDefaultStore = defineStore("defaultStore", {
curUserAppList: [], curUserAppList: [],
curUserMenuList: [], curUserMenuList: [],
curRoleUid: null, curRoleUid: null,
unreadCount: 0,
}; };
}, },
actions: { actions: {
setCurRoleInfo(info: RoleInfo) { setCurRoleInfo(info: RoleInfo) {
this.curRoleInfo = info; this.curRoleInfo = info;
}, },
setUnreadCount(count: number) {
this.unreadCount = Math.max(0, count || 0);
},
incUnread() {
this.unreadCount += 1;
},
decUnread() {
this.unreadCount = Math.max(0, this.unreadCount - 1);
},
setCurUserSettingsInfo(info: UserSettingInfo) { setCurUserSettingsInfo(info: UserSettingInfo) {
this.curUserSettingInfo = info; this.curUserSettingInfo = info;
}, },
+151
View File
@@ -0,0 +1,151 @@
/**
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20260930-1248-4e12e6d),勿手改。
* 分发方式:YschTheme `npm run sync` 拷进各端 src/utils/theme.ts(与 ThemeSwitcher.vue 配套,MD5 对齐报告)。
* 运行时档位类名由 /static/js/yh-theme-init.js 在 <head> 内先行挂好,本模块只负责读写与切换。
*/
export type ThemeName = "blue" | "ocean" | "ink" | "violet" | "teal";
/** 定稿默认档:blue + side-deep + grad-on + 侧栏跟随(D1 拍板 deep) */
export interface ThemeState {
theme: ThemeName;
/** 侧栏深色(false=浅色档) */
sideDeep: boolean;
/** 顶栏渐变(联动侧栏竖向渐变) */
gradOn: boolean;
/** 侧栏颜色跟随顶栏品牌色(false=中性灰 side-plain) */
sideFollow: boolean;
}
interface StorageLike {
getItem(k: string): string | null;
setItem(k: string, v: string): void;
}
export const THEME_NAMES: readonly ThemeName[] = ["blue", "ocean", "ink", "violet", "teal"];
export const THEME_STORAGE_KEY = "yh-theme";
/* 学校默认档存储键(UserHome 登录时写入;值格式与 yh-theme 完全相同,仅无"用户自选"语义) */
export const SCHOOL_DEFAULT_STORAGE_KEY = "yh-theme-sch-default";
export const DEFAULT_THEME_STATE: ThemeState = {
theme: "blue",
sideDeep: true,
gradOn: true,
sideFollow: true
};
/** 宽松解析:任一字段非法/缺失即回退默认档(脏数据不阻塞启动) */
export function parseThemeState(raw: string | null): ThemeState {
if (!raw) return { ...DEFAULT_THEME_STATE };
try {
const o = JSON.parse(raw);
if (!o || typeof o !== "object") return { ...DEFAULT_THEME_STATE };
return {
theme: THEME_NAMES.includes(o.theme) ? o.theme : DEFAULT_THEME_STATE.theme,
sideDeep: typeof o.sideDeep === "boolean" ? o.sideDeep : DEFAULT_THEME_STATE.sideDeep,
gradOn: typeof o.gradOn === "boolean" ? o.gradOn : DEFAULT_THEME_STATE.gradOn,
sideFollow: typeof o.sideFollow === "boolean" ? o.sideFollow : DEFAULT_THEME_STATE.sideFollow
};
} catch {
return { ...DEFAULT_THEME_STATE };
}
}
/** 严格读取:四字段全部合法才返回状态,否则 null(合成链"值非法→回落下一级"用;用户键仍走宽松解析保现状) */
function readThemeStateOrNull(raw: string | null): ThemeState | null {
if (!raw) return null;
try {
const o = JSON.parse(raw);
if (!o || typeof o !== "object") return null;
if (!THEME_NAMES.includes(o.theme)) return null;
if (typeof o.sideDeep !== "boolean" || typeof o.gradOn !== "boolean" || typeof o.sideFollow !== "boolean") return null;
return { theme: o.theme, sideDeep: o.sideDeep, gradOn: o.gradOn, sideFollow: o.sideFollow };
} catch {
return null;
}
}
/** URL 覆盖参数(?yh-theme= / ?yh-side=deep|light / ?yh-grad=on|off):仅预览/排障用,
非法值忽略(对应字段不覆盖),且只影响"读哪一档"、绝不写回 localStorage。 */
function readThemeOverrides(): { theme?: ThemeName; sideDeep?: boolean; gradOn?: boolean } | null {
let out = null;
try {
const search = (globalThis.location && globalThis.location.search) || "";
const get = (name) => {
const m = new RegExp("[?&]" + name + "=([^&]*)").exec(search);
return m ? decodeURIComponent(m[1]) : null;
};
const t = get("yh-theme");
if (t && THEME_NAMES.includes(t)) {
out = out || {};
out.theme = t;
}
const s = get("yh-side");
if (s === "deep" || s === "light") {
out = out || {};
out.sideDeep = s === "deep";
}
const g = get("yh-grad");
if (g === "on" || g === "off") {
out = out || {};
out.gradOn = g === "on";
}
} catch {
/* 无 location 环境(测试/SSR):无覆盖 */
}
return out;
}
/**
* 生效档合成(唯一实现,init 启动段与 UserHome theme.ts 共用):
* URL 覆盖 > localStorage["yh-theme"](用户自选,存在即用——宽松修补,与历史行为一致)
* > localStorage["yh-theme-sch-default"](学校默认,整体合法才生效)
* > DEFAULT_THEME_STATE。
* 键不存在/解析失败/值非法均静默回落,不报错不阻塞首帧。
*/
export function resolveEffectiveThemeState(storage: StorageLike | undefined = globalThis.localStorage): ThemeState {
let state = null;
if (storage) {
const userRaw = storage.getItem(THEME_STORAGE_KEY);
if (userRaw) state = parseThemeState(userRaw);
if (!state) state = readThemeStateOrNull(storage.getItem(SCHOOL_DEFAULT_STORAGE_KEY));
}
if (!state) state = { ...DEFAULT_THEME_STATE };
const ov = readThemeOverrides();
if (ov) {
if (ov.theme) state = { ...state, theme: ov.theme };
if (ov.sideDeep != null) state = { ...state, sideDeep: ov.sideDeep };
if (ov.gradOn != null) state = { ...state, gradOn: ov.gradOn };
}
return state;
}
export function loadThemeState(storage: StorageLike | undefined = globalThis.localStorage): ThemeState {
if (!storage) return { ...DEFAULT_THEME_STATE };
return parseThemeState(storage.getItem(THEME_STORAGE_KEY));
}
export function saveThemeState(state: ThemeState, storage: StorageLike | undefined = globalThis.localStorage): void {
try {
storage?.setItem(THEME_STORAGE_KEY, JSON.stringify(state));
} catch {
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
}
}
/** 把状态落到根元素档位类名(幂等:先清后挂),返回传入的 state 便于链式 */
export function applyThemeState(state: ThemeState, root: HTMLElement = document.documentElement): ThemeState {
root.dataset.theme = state.theme;
root.classList.toggle("side-deep", state.sideDeep);
root.classList.toggle("side-light", !state.sideDeep);
root.classList.toggle("grad-on", state.gradOn);
root.classList.toggle("grad-off", !state.gradOn);
root.classList.toggle("side-plain", !state.sideFollow);
return state;
}
/** 启动引导:各端 main.ts(或本仓 init 脚本的启动段)于挂载前调用,消除首帧闪色 */
export function initTheme(): ThemeState {
return applyThemeState(loadThemeState());
}
+586 -69
View File
@@ -49,28 +49,29 @@
<div class="form-row"> <div class="form-row">
<span class="lb">头像</span> <span class="lb">头像</span>
<div class="ctl"> <div class="ctl">
<div class="avatar-now avatar-sm">张</div> <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> <button class="btn ghost sm" @click="setPane('avatar')">更换头像</button>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<span class="lb">姓名</span> <span class="lb">姓名</span>
<div class="ctl"> <div class="ctl">
<input class="inp" value="张明" disabled /> <input class="inp" :value="profileView.name" disabled />
<span class="hint">由学校统一维护</span> <span class="hint">由学校统一维护</span>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<span class="lb">工号</span> <span class="lb">工号</span>
<div class="ctl"> <div class="ctl">
<input class="inp" value="T20240315" disabled /> <input class="inp" :value="profileView.account" disabled />
<span class="hint">由学校统一维护</span> <span class="hint">由学校统一维护</span>
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
<span class="lb">所属部门</span> <span class="lb">所属部门</span>
<div class="ctl"> <div class="ctl">
<input class="inp" value="教务处 / 教研组" disabled /> <input class="inp" :value="profileView.deptName" disabled />
</div> </div>
</div> </div>
<div class="form-row"> <div class="form-row">
@@ -87,7 +88,8 @@
</div> </div>
</div> </div>
<div class="pane-foot"> <div class="pane-foot">
<button class="btn primary">保存修改</button> <!-- 保存占位:接口待 UserBase 支持本人自助修改,本轮不做(拍板结论) -->
<button class="btn primary" disabled title="接口待 UserBase 支持本人自助修改,本轮暂不可用">保存修改</button>
<button class="btn plain" @click="resetProfileForm">重置</button> <button class="btn plain" @click="resetProfileForm">重置</button>
</div> </div>
</div> </div>
@@ -120,7 +122,7 @@
</div> </div>
</div> </div>
<div class="pane-foot"> <div class="pane-foot">
<button class="btn primary">确认修改</button> <button class="btn primary" :disabled="passwordSubmitting" @click="onUpdatePassword">确认修改</button>
</div> </div>
</div> </div>
@@ -134,13 +136,22 @@
<h4>当前头像</h4> <h4>当前头像</h4>
<div class="avatar-pad"> <div class="avatar-pad">
<div class="avatar-box"> <div class="avatar-box">
<div class="avatar-now">张</div> <img class="avatar-img" :src="requireImg('user-avatar-default.png')"
v-real-img="store.userInfo.profilePicture" alt="" />
<div class="avatar-ops"> <div class="avatar-ops">
<div class="avatar-btns"> <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> <button class="btn primary sm">上传新头像</button>
<button class="btn plain sm">使用默认头像</button> </Upload>
<!-- 存储方案本轮不做(拍板),保持占位 -->
<button class="btn plain sm" disabled title="存储方案本轮不做">使用默认头像</button>
</div> </div>
<span class="tip">上传后立即生效,全站同步展示。</span> <span class="tip">上传后立即生效,全站同步展示。支持 JPG / PNG / GIF / WebP,≤ 20MB。</span>
</div> </div>
</div> </div>
</div> </div>
@@ -161,6 +172,69 @@
</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 设置(管理员) --> <!-- LOGO 设置(管理员) -->
<div v-else-if="curPane === 'logo'" class="pane"> <div v-else-if="curPane === 'logo'" class="pane">
<div class="pane-head"> <div class="pane-head">
@@ -170,25 +244,31 @@
<div class="sec-card"> <div class="sec-card">
<h4>当前 LOGO</h4> <h4>当前 LOGO</h4>
<div class="logo-grid"> <div class="logo-grid">
<div v-if="logoManageList.length === 0" class="logo-empty">暂无 LOGO,请先上传</div>
<div <div
v-for="(l, i) in logoList" v-for="l in logoManageList"
:key="i" :key="l.id"
:class="['logo-item', { sel: logoSel === i }]" :class="['logo-item', { sel: logoSelUrl === l.url }]"
@click="logoSel = i" @click="logoSelUrl = l.url"
> >
<span class="mk">{{ l.marks }}</span>{{ l.name }} <img :src="l.url" alt="" />
<i v-if="l.isEdit" class="del" title="删除" @click.stop="onDeleteLogo(l)">×</i>
</div> </div>
</div> </div>
<div class="form-row form-row--last form-row--top"> <div class="form-row form-row--last form-row--top">
<span class="lb">上传 LOGO</span> <span class="lb">上传 LOGO</span>
<div class="ctl"> <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> <button class="btn ghost sm">选择文件</button>
</Upload>
<span class="hint">建议 PNG 透明底,高度 ≥ 64px,≤ 1MB</span> <span class="hint">建议 PNG 透明底,高度 ≥ 64px,≤ 1MB</span>
</div> </div>
</div> </div>
</div> </div>
<div class="pane-foot"> <div class="pane-foot">
<button class="btn primary">保存设置</button> <button class="btn primary" :disabled="logoSaving" @click="onSaveLogo">保存设置</button>
</div> </div>
</div> </div>
@@ -201,24 +281,33 @@
<div class="sec-card"> <div class="sec-card">
<h4>系统壁纸</h4> <h4>系统壁纸</h4>
<div class="wall-grid"> <div class="wall-grid">
<div v-if="wallpaperManageList.length === 0" class="logo-empty">暂无壁纸,请先上传</div>
<div <div
v-for="(w, i) in wallpaperList" v-for="w in wallpaperManageList"
:key="i" :key="w.id"
:class="['wall-item', { sel: wallSel === i }]" :class="['wall-item', { sel: wallSelUrl === w.url }]"
:style="{ background: w }" :style="{ background: 'center / cover no-repeat url(' + w.url + ')' }"
@click="wallSel = i" @click="wallSelUrl = w.url"
> >
<i v-if="w.isEdit" class="del" title="删除" @click.stop="onDeleteWallpaper(w)">×</i>
<i class="tick">✓</i> <i class="tick">✓</i>
</div> </div>
</div> </div>
<div class="form-row form-row--last form-row--top"> <div class="form-row form-row--last form-row--top">
<span class="lb">上传壁纸</span> <span class="lb">上传壁纸</span>
<div class="ctl"> <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> <button class="btn ghost sm">选择文件</button>
</Upload>
<span class="hint">建议 1920×1080 以上,≤ 5MB</span> <span class="hint">建议 1920×1080 以上,≤ 5MB</span>
</div> </div>
</div> </div>
</div> </div>
<div class="pane-foot">
<!-- 保存语义=设为我的壁纸(checkWallpaper,与 Header 壁纸弹窗一致) -->
<button class="btn primary" :disabled="wallApplying" @click="onApplyWallpaper">应用壁纸</button>
</div>
</div> </div>
<!-- 边栏设置(管理员) --> <!-- 边栏设置(管理员) -->
@@ -291,11 +380,22 @@ import { ref, reactive, computed, watch } from "vue";
import { useRoute, useRouter } from "vue-router"; import { useRoute, useRouter } from "vue-router";
import { useDefaultStore } from "@/store"; import { useDefaultStore } from "@/store";
import useCurrentInstance from "@/utils/hook/useCurrentInstance"; import useCurrentInstance from "@/utils/hook/useCurrentInstance";
import { requireImg } from "@/utils/hook/useImg";
import SysSetting from "@/views/SysSetting.vue"; 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 方案左右双卡)。 * 设置中心(/settings):左 216px 分组导航卡 + 右内容卡(B 方案左右双卡)。
* 本轮只做导航壳 + pane 切换 + 背景层,各 pane 内表单均为占位(接口对接属下一轮)。 * 五个 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 route = useRoute();
@@ -306,8 +406,8 @@ const { $conf } = useCurrentInstance();
const isAdmin = computed(() => !!store.getIsAdmin); const isAdmin = computed(() => !!store.getIsAdmin);
/** 普通用户不可用的管理 pane(导航隐藏;URL 直达渲染 noperm 黄条,不白屏不报错) */ /** 普通用户不可用的管理 pane(导航隐藏;URL 直达渲染 noperm 黄条,不白屏不报错) */
const ADMIN_PANES = ["logo", "wallpaper", "sidebar", "sys", "statset", "backend"]; const ADMIN_PANES = ["logo", "wallpaper", "sidebar", "sys", "statset", "backend", "schooltheme"];
const ALL_PANES = ["profile", "password", "avatar", "logo", "wallpaper", "sidebar", "sys", "statset", "backend", "noperm"]; const ALL_PANES = ["profile", "password", "avatar", "mytheme", "schooltheme", "logo", "wallpaper", "sidebar", "sys", "statset", "backend", "noperm"];
const curPane = ref<string>("profile"); const curPane = ref<string>("profile");
@@ -337,6 +437,8 @@ interface NavItem {
key: string; key: string;
name: string; name: string;
icon: string; icon: string;
/** item 级仅管理员可见(主题设置 §3.3:schooltheme 双保险之一,另一道在 ADMIN_PANES) */
adminOnly?: boolean;
} }
const svgWrap = (inner: string) => 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>`; `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
@@ -345,6 +447,10 @@ const NAV_ACCOUNT: NavItem[] = [
{ 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: "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"/>') }, { 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[] = [ 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: "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: "wallpaper", name: "壁纸设置", icon: svgWrap('<path d="M5 3.5A9 9 0 1 0 20.5 12H5z"/><path d="M5 3.5V12h15.5"/>') },
@@ -356,23 +462,106 @@ const NAV_SYSTEM: NavItem[] = [
{ 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"/>') }, { 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 navGroups = computed(() => {
const groups = [ const groups = [
{ title: "账号", adminOnly: false, items: NAV_ACCOUNT }, { title: "账号", adminOnly: false, items: NAV_ACCOUNT },
{ title: "主题", adminOnly: false, items: NAV_THEME },
{ title: "外观", adminOnly: true, items: NAV_APPEARANCE }, { title: "外观", adminOnly: true, items: NAV_APPEARANCE },
{ title: "系统", adminOnly: true, items: NAV_SYSTEM }, { title: "系统", adminOnly: true, items: NAV_SYSTEM },
]; ];
return isAdmin.value ? groups : groups.filter(g => !g.adminOnly); 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),
}));
}); });
/* ===== 以下均为 pane 占位数据(真实表单/接口对接属下一轮) ===== */ /* ===== profile:读 store.userInfo(真实展示;保存占位) ===== */
const profileForm = reactive({ mobile: "138****8899", email: "zhangming@school.edu.cn" }); 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() { function resetProfileForm() {
profileForm.mobile = "138****8899"; profileForm.mobile = profileView.value.phone;
profileForm.email = "zhangming@school.edu.cn"; profileForm.email = profileView.value.email;
} }
/** 保存占位:接口待 UserBase 支持本人自助修改,本轮不做(拍板结论) */
/* ===== password:updatePassword + 确认登出(流程照抄 UserInfoModal) ===== */
const passwordForm = reactive({ old: "", next: "", confirm: "" }); 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 = [ const avatarPresets = [
"linear-gradient(135deg,#5979ea,#7d9bf5)", "linear-gradient(135deg,#5979ea,#7d9bf5)",
@@ -381,24 +570,244 @@ const avatarPresets = [
"linear-gradient(135deg,#e8684a,#f1957e)", "linear-gradient(135deg,#e8684a,#f1957e)",
"linear-gradient(135deg,#8a7ce0,#b3a8f0)", "linear-gradient(135deg,#8a7ce0,#b3a8f0)",
]; ];
const avatarLetters = ["张", "A", "B", "C", "D"]; const avatarLetters = computed(() => {
const name = String(store.userInfo?.name || "A").trim();
return [name.charAt(0) || "A", "A", "B", "C", "D"];
});
const avatarSel = ref(0); const avatarSel = ref(0);
const logoList = [ /* ===== logo / wallpaper:管理列表 + 直传 + 删除(照抄 SysSetting.vue 管理语义) ===== */
{ name: "智慧校园平台", marks: "🎓" }, const logoManageList = ref<any[]>([]);
{ name: "云海教育", marks: "📘" }, const wallpaperManageList = ref<any[]>([]);
{ name: "第一中学", marks: "🏫" }, const logoSelUrl = ref<string>("");
]; const wallSelUrl = ref<string>("");
const logoSel = ref(0); /** 直传端点(本 pane 无机构树,超管按机构切换请用「系统设置」pane;不带 schId 即落到登录人归属校) */
const logoUploadAction = $conf.serviceHost + "/api/logoUpload";
const wallpaperUploadAction = $conf.serviceHost + "/api/wallpaperUpload";
const wallpaperList = [ async function loadManageLists() {
"linear-gradient(135deg,#cfe0ea,#d9e0d2)", // 本 pane 无机构树:不带 schId,列表按登录人归属校返回(超管按机构切换请用「系统设置」pane)
"linear-gradient(135deg,#e6e2d6,#c9d4c0)", const [walls, logos] = await Promise.all([
"linear-gradient(135deg,#bfd4e8,#a8bdd6)", SysSettingApi.getSchWallpaperManageList(),
"linear-gradient(135deg,#f0e2d0,#e0cdb8)", SysSettingApi.getSchLogoManageList(),
"linear-gradient(135deg,#d6e0f0,#c0cde6)", ]);
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" },
]; ];
const wallSel = ref(0);
/** 预览 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([ const sidebarItems = ref([
{ name: "通知公告", on: true }, { name: "通知公告", on: true },
@@ -424,12 +833,15 @@ function openBackend() {
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
/* 壳:透明,不平铺白(B 方案),gap 加在两卡的直接父容器上 */ /* 壳:透明,不平铺白(B 方案),gap 加在两卡的直接父容器上。
padding 与 .syssetting-wrap 对齐(15px 四周),overflow:hidden 与 .app-list-wrapper/.stats-wrap 对齐,
避免内容撑出页面级滚动条把右侧留白撑大。 */
.set-shell { .set-shell {
display: flex; display: flex;
gap: 16px; gap: 16px;
height: calc(100% - var(--headerH)); height: calc(100% - var(--headerH));
padding: 0 20px 20px; padding: 15px;
overflow: hidden;
background: transparent; background: transparent;
border: none; border: none;
box-shadow: none; box-shadow: none;
@@ -540,6 +952,12 @@ function openBackend() {
overflow: hidden; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
/* 内嵌的 SysSetting 自带 padding:15px,与 .set-shell 的 15px 叠加成 30px,此处抹平。
仅作用于本内嵌场景,独立页 /syssetting 不受影响(该页退役后可删本段) */
:deep(.syssetting-wrap) {
padding: 0;
}
} }
} }
@@ -663,23 +1081,16 @@ function openBackend() {
align-items: center; align-items: center;
gap: 16px; gap: 16px;
} }
.avatar-now { .avatar-img {
width: 72px; width: 72px;
height: 72px; height: 72px;
border-radius: 50%; border-radius: 50%;
background: linear-gradient(135deg, #5979ea, #7d9bf5); object-fit: cover;
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
font-weight: 600;
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 4px 14px rgba(89, 121, 234, 0.3); box-shadow: 0 4px 14px rgba(89, 121, 234, 0.3);
&.avatar-sm { &.avatar-img-sm {
width: 44px; width: 44px;
height: 44px; height: 44px;
font-size: 16px;
} }
} }
.avatar-ops { .avatar-ops {
@@ -689,6 +1100,7 @@ function openBackend() {
.avatar-btns { .avatar-btns {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: center;
} }
.tip { .tip {
font-size: 12px; font-size: 12px;
@@ -727,6 +1139,89 @@ function openBackend() {
} }
} }
/* 主题选择(主题设置 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 网格 */ /* 壁纸 / LOGO 网格 */
.wall-grid { .wall-grid {
display: grid; display: grid;
@@ -777,6 +1272,12 @@ function openBackend() {
gap: 12px; gap: 12px;
padding: 14px 0 4px; padding: 14px 0 4px;
} }
.logo-empty {
grid-column: 1 / -1;
padding: 18px 0 8px;
color: #b3b3b3;
font-size: 13px;
}
.logo-item { .logo-item {
height: 72px; height: 72px;
border-radius: 6px; border-radius: 6px;
@@ -787,10 +1288,13 @@ function openBackend() {
justify-content: center; justify-content: center;
cursor: pointer; cursor: pointer;
transition: all 0.18s; transition: all 0.18s;
font-size: 15px; position: relative;
font-weight: 600; overflow: hidden;
color: #3f5fd4; img {
gap: 8px; max-width: 86%;
max-height: 82%;
object-fit: contain;
}
&:hover { &:hover {
border-color: #5979ea; border-color: #5979ea;
background: #eef2ff; background: #eef2ff;
@@ -799,17 +1303,30 @@ function openBackend() {
border-color: #5979ea; border-color: #5979ea;
box-shadow: 0 0 0 3px rgba(89, 121, 234, 0.18); box-shadow: 0 0 0 3px rgba(89, 121, 234, 0.18);
} }
.mk {
width: 26px;
height: 26px;
border-radius: 7px;
background: linear-gradient(135deg, #5979ea, #7d9bf5);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
} }
.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;
} }
/* 边栏设置:拖拽示意列表 */ /* 边栏设置:拖拽示意列表 */
+142
View File
@@ -0,0 +1,142 @@
<template>
<!-- 主题预览页(#/theme-preview,YschTheme《主题设置与通道改动-执行方案》§3.4):
只在本路由的 document 内加载通道(组件挂载时动态 append init 脚本),
档位类与通道 CSS 只落在本 document 的 <html> 上——UserHome 本体(父页)永远不吃通道 CSS。
纯展示:根容器 pointer-events:none,不渲染搜索区/分页操作/ThemeSwitcher。
DOM 结构对齐 Notice layout(header-container / left-menu / path-nav / main-container),
观感(配色/侧栏形态)由通道 base.layout.css 按 URL 覆盖参数(?yh-theme= 等)驱动的档位类提供。 -->
<div class="theme-preview-root">
<header class="header-container fix">
<span class="logo-badge fl"><span class="logo-mark">云海</span></span>
<div class="delimiter fl align-middle"></div>
<h4 class="fl align-middle">管理端预览</h4>
<div class="school-bg"></div>
<div class="header-aside fr">
<span class="name fl align-middle text-ellipsis">演示用户</span>
<div class="delimiter fl align-middle"></div>
<a href="javascript:;" class="logout fl align-middle" title="退出登陆"><i>⎋</i></a>
</div>
</header>
<div class="left-menu">
<div class="menu-top-box"></div>
<ul>
<li v-for="(m, i) in menus" :key="m" :class="{ active: i === 1 }">
<a href="javascript:void(0);"><span class="menu-dot"></span>{{ m }}</a>
</li>
</ul>
<div class="clear-box"></div>
</div>
<div class="path-nav">
<span>首页</span><span class="crumb-sep">/</span><span>通知管理</span><span class="crumb-sep">/</span><span>通知列表</span>
</div>
<div class="preview-main">
<div class="main-container">
<h3 class="panel-title">通知列表</h3>
<Table :columns="columns" :data="rows" size="small" :border="true" />
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { onMounted } from "vue";
import useCurrentInstance from "@/utils/hook/useCurrentInstance";
/**
* 主题预览路由页:档位来源 = URL 覆盖参数(?yh-theme= / ?yh-side= / ?yh-grad=,由设置页 iframe src 拼入,
* 切候选档即换 src 重载),init 启动段按合成优先级挂档——本组件不读写 localStorage、不提供任何交互。
* 直开(不经设置页、未登录)也可渲染:本页由 App.vue 顶层条件分支挂载,不依赖桌面框架与登录态。
*/
const { $conf } = useCurrentInstance();
const menus = ["工作台", "通知管理", "统计分析", "应用中心", "系统设置"];
const columns = [
{ type: "index", title: "序号", width: 70, align: "center" },
{ title: "标题", key: "title" },
{ title: "发布人", key: "author", width: 120, align: "center" },
{ title: "日期", key: "date", width: 160, align: "center" },
];
const rows = [
{ title: "关于国庆节放假安排的通知", author: "校务办", date: "2026-09-28" },
{ title: "期中教学质量检查实施方案", author: "教务处", date: "2026-09-25" },
{ title: "校园安全专项检查通报", author: "保卫处", date: "2026-09-21" },
{ title: "教师信息化能力培训报名", author: "信息中心", date: "2026-09-18" },
];
onMounted(() => {
// 动态引通道 init(仅本 document;UserHome 本体 index.html 不加 loader 是定案)。
// baseHost 即通道域(与 UserBase/Notice 的 loader 同源策略);判重防重复挂载。
if (document.querySelector("script[data-yh-theme-init]")) return;
const s = document.createElement("script");
s.src = `${String($conf.baseHost || "").replace(/\/+$/, "")}/static/js/yh-theme-init.js`;
s.setAttribute("data-yh-theme-init", "1");
document.head.appendChild(s);
});
</script>
<style scoped lang="scss">
/* 本页只补最小布局;配色/圆角/阴影等观感全部由通道 base.layout.css 的档位类驱动 */
.theme-preview-root {
position: fixed;
inset: 0;
overflow: hidden;
pointer-events: none; /* 纯展示:整页不可交互 */
background: #f5f6f8;
font-size: 14px;
}
.header-container {
.logo-mark {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.22);
color: #fff;
font-size: 12px;
font-weight: 600;
}
h4 {
color: #fff;
font-size: 16px;
font-weight: 600;
margin: 0 12px;
}
}
.menu-dot {
display: inline-block;
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
opacity: 0.45;
margin-right: 9px;
vertical-align: middle;
}
.path-nav .crumb-sep {
margin: 0 8px;
color: #c5c8cd;
}
.preview-main {
position: fixed;
top: calc(var(--topbar-h, 52px) + var(--crumb-h, 44px));
left: var(--side-w, 200px);
right: 0;
bottom: 0;
padding: 14px 24px 24px;
overflow: auto;
.panel-title {
font-size: 15px;
font-weight: 600;
color: #2c3138;
margin: 4px 0 12px;
}
}
</style>
+286 -115
View File
@@ -23,18 +23,18 @@
</div> </div>
</div> </div>
<!-- 模式二:按应用(平铺全部应用,首项「全部」+ 分隔线) --> <!-- 模式二:按应用(应用清单取 store 应用列表;计数取 summary todos 待办角标) -->
<div v-show="mode === 'app'"> <div v-show="mode === 'app'">
<div class="side-title">全部应用</div> <div class="side-title">全部应用</div>
<div :class="['cat', { active: curApp === 'all' }]" @click="onClickApp('all')"> <div :class="['cat', { active: curApp === 'all' }]" @click="onClickApp('all')">
<span class="dot dot-primary"></span>全部<span class="cnt">{{ msgs.length }}</span> <span class="dot dot-primary"></span>全部<span class="cnt">{{ summaryTotalHint }}</span>
</div> </div>
<div class="side-sep"></div> <div class="side-sep"></div>
<div <div
v-for="a in appList" v-for="a in appList"
:key="a.name" :key="a.appKey"
:class="['cat', { active: curApp === a.name }]" :class="['cat', { active: curApp === a.appKey }]"
@click="onClickApp(a.name)" @click="onClickApp(a.appKey)"
> >
<span class="dot dot-primary"></span>{{ a.name }}<span class="cnt">{{ a.cnt }}</span> <span class="dot dot-primary"></span>{{ a.name }}<span class="cnt">{{ a.cnt }}</span>
</div> </div>
@@ -46,12 +46,12 @@
<div class="msg-toolbar"> <div class="msg-toolbar">
<div class="tb-left"> <div class="tb-left">
<h3>{{ curTitle }}</h3> <h3>{{ curTitle }}</h3>
<span class="sub"><i class="live"></i>未读 {{ unreadTotal }} 条</span> <span class="sub"><i class="live"></i>未读 {{ unreadCount }} 条</span>
</div> </div>
<div class="tb-right"> <div class="tb-right">
<input v-model="searchText" class="msg-search" placeholder="搜索消息内容…" /> <input v-model="searchText" class="msg-search" placeholder="搜索消息内容…" @input="onSearchInput" />
<button class="btn plain sm" @click="readAll">全部已读</button> <button class="btn plain sm" @click="readAll">全部已读</button>
<button class="btn ghost sm" @click="refresh">刷新</button> <button class="btn ghost sm" @click="reload">刷新</button>
<div class="skin-switch"> <div class="skin-switch">
<span class="lb">消息样式</span> <span class="lb">消息样式</span>
<div class="seg"> <div class="seg">
@@ -62,82 +62,83 @@
</div> </div>
</div> </div>
<div class="msg-body"> <div class="msg-body" ref="refMsgBody" @scroll="onScroll">
<div class="msg-list" :data-skin="skin"> <div class="msg-list" :data-skin="skin">
<div v-if="renderList.length === 0" class="empty"> <div v-if="loadError && rows.length === 0" class="empty">
<div class="ic">⚠️</div>
<div>消息加载失败</div>
<button class="btn ghost sm" @click="reload">重试</button>
</div>
<div v-else-if="rows.length === 0 && !loading" class="empty">
<div class="ic">✅</div> <div class="ic">✅</div>
<div>暂无消息</div> <div>暂无消息</div>
</div> </div>
<div <div
v-for="(m, i) in renderList" v-for="m in rows"
:key="i" :key="m.msgId"
:class="['msg-card', m.unread ? ['unread', m.t === 2 ? 'm2' : m.t === 3 ? 'm3' : ''] : '']" :class="['msg-card', isUnread(m) ? ['unread', m.msgType === 1 ? 'mbiz' : m.msgType === 2 ? 'mappr' : ''] : '']"
@click="onClickCard(m)"
> >
<div class="row1"> <div class="row1">
<div :class="['type-ic', m.t === 2 ? 'biz' : m.t === 3 ? 'appr' : 'sys']" :data-label="typeLabel(m.t)"> <div :class="['type-ic', m.msgType === 1 ? 'biz' : m.msgType === 2 ? 'appr' : 'sys']" :data-label="typeLabel(m.msgType)">
{{ typeIcon(m.t) }} {{ typeIcon(m.msgType) }}
</div> </div>
<span class="app">{{ m.app }}</span> <span class="app">{{ m.appName || typeAppName(m) }}</span>
<span class="type-tag">{{ typeName(m.t) }}</span> <span class="type-tag">{{ typeName(m.msgType) }}</span>
<span class="unread-scope"><span class="unread-dot"></span></span> <span class="unread-scope"><span class="unread-dot"></span></span>
</div> </div>
<div class="row2"><span class="tt">{{ m.tt }}</span>{{ m.body }}</div> <div class="row2"><span class="tt">{{ m.title }}</span>{{ m.content }}</div>
<div class="row3"> <div class="row3">
<span class="time">{{ fmtTime(m.min) }}</span> <span class="time">{{ fmtTime(m.sendTime) }}</span>
<div class="act"> <div class="act">
<template v-if="m.unread && m.approve"> <template v-if="isUnread(m) && m.msgType === 2 && m.msgId">
<button class="btn sm btn-ok" @click="onClickMsg(m)">同意</button> <button class="btn sm btn-ok" @click.stop="openApproval(m, 'approve')">同意</button>
<button class="btn plain sm btn-no" @click="onClickMsg(m)">拒绝</button> <button class="btn plain sm btn-no" @click.stop="openApproval(m, 'reject')">拒绝</button>
</template> </template>
<button class="btn ghost sm" @click="onClickMsg(m)">{{ m.t === 2 ? "去处理" : "查看详情" }}</button> <button v-if="m.showJump && m.jumpUrl" class="btn ghost sm" @click.stop="onJump(m)">去处理</button>
</div> </div>
</div> </div>
</div> </div>
<div v-if="loading" class="loading-more">加载中…</div>
<div v-else-if="rows.length > 0 && !hasMore" class="loading-more">没有更多了</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- 审批直批弹窗(与 Header 共享组件;成功后卡片置已读并刷新) -->
<MsgApprovalModal ref="refApproval" @handled="onApprovalHandled" />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, computed } from "vue"; import { ref, computed, onMounted, onUnmounted } from "vue";
import { storeToRefs } from "pinia";
import BaseApi from "@/api/BaseApi";
import MsgApprovalModal from "@/components/MsgApprovalModal.vue";
import mittBus from "@/utils/mitt";
import { useDefaultStore } from "@/store";
/** /**
* 消息中心路由页(/msg)。 * 消息中心路由页(/msg)。数据层对接 msgCenter 真实接口(消息对接 2026-09-30):
* 本轮数据全部为 mock(照 design/03 原型 APP_MSGS),接口对接(readMsgCenter / msgList / * 列表服务端筛选 + 滚动分页、左栏计数走 summary、已读/全部已读/直批/去处理接真实动作。
* handleMsg / WS)属独立一轮,见方案文档 §6。 * 字段映射:msgType 0系统/1业务/2审批;isRead 1=已读;appName/jumpUrl/showJump 由后端聚合改写。
*/ */
interface MockMsg { interface MsgRow {
app: string; msgId: number;
t: number; // 1 系统通知 / 2 业务提醒 / 3 审批待办 title: string;
tt: string; content: string;
body: string; msgType: number; // 0 系统通知 / 1 业务提醒 / 2 审批待办
min: number; // 距现在的分钟数(mock 时间) appName?: string | null;
unread: boolean; appKey?: string | null;
approve?: boolean; sendTime?: string; // yyyy-MM-dd HH:mm:ss
isRead: number; // 1=已读
jumpUrl?: string | null;
showJump?: boolean;
} }
/** mock 消息池(与原型 APP_MSGS 一致),刷新时从它重新克隆 */ const store = useDefaultStore();
const MOCK_MSGS: MockMsg[] = [ /** 未读数与 Header 铃铛 / LeftMenu 角标同源(store) */
{ app: "请假", t: 3, tt: "张老师的调课申请待你审批:", body: "因公出差,申请将周三 3-4 节《数据结构》调整至周五 5-6 节。", min: 3, unread: true, approve: true }, const { unreadCount } = storeToRefs(store);
{ app: "通知公告", t: 1, tt: "系统维护通知:", body: "平台将于今晚 23:00-24:00 进行版本升级,期间可能出现短暂无法访问。", min: 25, unread: true },
{ app: "请假", t: 3, tt: "李老师的病假申请待审批:", body: "申请 9 月 30 日病假一天,请予审批。", min: 40, unread: true, approve: true },
{ app: "公文申请", t: 2, tt: "公文待办:", body: "《关于秋季运动会的通知》待你签发。", min: 60, unread: true },
{ app: "通知公告", t: 1, tt: "放假安排:", body: "国庆假期 10 月 1 日至 7 日,请各部门提前做好工作交接。", min: 120, unread: true },
{ app: "教务管理", t: 2, tt: "成绩录入提醒:", body: "高二(3)班期中考试成绩尚未录入完毕,请于本周五前完成。", min: 180, unread: true },
{ app: "公文申请", t: 2, tt: "公文待阅:", body: "《关于加强校园安全管理的通知》请查阅。", min: 240, unread: true },
{ app: "用户首页", t: 1, tt: "门户栏目更新:", body: "「校园动态」栏目已上线,可在首页订阅。", min: 300, unread: true },
{ app: "报修", t: 2, tt: "报修派单:", body: "教学楼 301 多媒体设备故障,已派单至你处。", min: 360, unread: true },
{ app: "流程中心", t: 1, tt: "流程模板变更:", body: "「采购申请」流程新增一级审批,请留意。", min: 420, unread: true },
{ app: "学校管理", t: 1, tt: "组织架构调整:", body: "教务处新增「教研组」二级部门。", min: 480, unread: true },
{ app: "教师专业发展", t: 2, tt: "培训报名提醒:", body: "市级骨干教师培训报名明日截止。", min: 600, unread: true },
{ app: "场地预约", t: 2, tt: "场地冲突提醒:", body: "你预约的报告厅与校务会时间冲突,请调整。", min: 720, unread: true },
{ app: "请假", t: 3, tt: "已办结:", body: "王老师的年假申请已通过。", min: 1440, unread: false },
{ app: "通知公告", t: 1, tt: "新功能上线:", body: "应用中心已支持按角色分组管理,可在「设置 → 应用管理」中配置。", min: 1500, unread: false },
];
const msgs = ref<MockMsg[]>(MOCK_MSGS.map(m => ({ ...m })));
/** 左卡模式:cat 按分类 / app 按应用 */ /** 左卡模式:cat 按分类 / app 按应用 */
const mode = ref<"cat" | "app">("cat"); const mode = ref<"cat" | "app">("cat");
@@ -147,113 +148,274 @@ const searchText = ref("");
/** 消息样式:stripe 彩色条带 / badge 徽标 */ /** 消息样式:stripe 彩色条带 / badge 徽标 */
const skin = ref<"stripe" | "badge">("stripe"); const skin = ref<"stripe" | "badge">("stripe");
const unreadTotal = computed(() => msgs.value.filter(m => m.unread).length); // ===== 列表(服务端筛选 + 滚动分页)=====
const rows = ref<MsgRow[]>([]);
const total = ref(0);
const page = ref(1);
const PAGE_SIZE = 20;
const loading = ref(false);
const loadError = ref(false);
const hasMore = computed(() => rows.value.length < total.value);
const refMsgBody = ref<HTMLElement | null>(null);
/** 分类导航(计数实时随已读状态变化) */ /** summary 左栏计数(旧版 UserMsg 无 summary 端点时分类字段缺省 → 显示 —) */
const catCounts = ref<{ sysUnread?: number; bizUnread?: number; approvalUnread?: number; total?: number; readCount?: number }>({});
const summaryTotalHint = computed(() => (catCounts.value.total == null ? "—" : String(catCounts.value.total)));
function isUnread(m: MsgRow) {
return m.isRead !== 1;
}
/** appName 缺失降级:按 appKey 匹配 store 应用列表,再退类型标签 */
function typeAppName(m: MsgRow) {
if (m.appKey) {
const hit = store.getFlattenAppList.find((x: any) => x.appKey == m.appKey || x.appCode == m.appKey);
if (hit && hit.name) return hit.name;
}
return typeName(m.msgType);
}
/** 分类导航(计数走 summary:分类未读数;「已读」= 已读总数) */
const catList = computed(() => [ const catList = computed(() => [
{ key: "all", name: "全部消息", color: "#5979ea", cnt: msgs.value.length }, { key: "all", name: "全部消息", color: "#5979ea", cnt: catCounts.value.total == null ? "—" : catCounts.value.total },
{ key: "sys", name: "系统通知", color: "#5979ea", cnt: msgs.value.filter(m => m.t === 1).length }, { key: "sys", name: "系统通知", color: "#5979ea", cnt: catCounts.value.sysUnread == null ? "—" : catCounts.value.sysUnread },
{ key: "biz", name: "业务提醒", color: "#e9b954", cnt: msgs.value.filter(m => m.t === 2).length }, { key: "biz", name: "业务提醒", color: "#e9b954", cnt: catCounts.value.bizUnread == null ? "—" : catCounts.value.bizUnread },
{ key: "appr", name: "审批待办", color: "#e8684a", cnt: msgs.value.filter(m => m.t === 3).length }, { key: "appr", name: "审批待办", color: "#e8684a", cnt: catCounts.value.approvalUnread == null ? "—" : catCounts.value.approvalUnread },
{ key: "read", name: "已读", color: "#c0c6ce", cnt: msgs.value.filter(m => !m.unread).length }, { key: "read", name: "已读", color: "#c0c6ce", cnt: catCounts.value.readCount == null ? "—" : catCounts.value.readCount },
]); ]);
/** 应用导航:按时间倒序首次出现的顺序平铺 */ /** 应用导航:store 应用列表平铺;计数取 summary todos 待办角标(同源数据不新拉接口) */
const appList = computed(() => { const todoCounts = ref<Record<string, number>>({});
const sorted = [...msgs.value].sort((a, b) => a.min - b.min); const appList = computed(() =>
const names: string[] = []; store.getFlattenAppList
sorted.forEach(m => { .filter((x: any) => !!x.appKey || !!x.appCode)
if (!names.includes(m.app)) names.push(m.app); .map((x: any) => ({ appKey: String(x.appKey ?? x.appCode), name: x.name, cnt: todoCounts.value[String(x.appKey ?? x.appCode)] || 0 }))
}); );
return names.map(name => ({ name, cnt: msgs.value.filter(m => m.app === name).length }));
});
/** 右卡列表:模式内过滤 + 搜索,统一按时间倒序(已读消息保留,供回查) */
const renderList = computed(() => {
let rows = [...msgs.value];
if (mode.value === "cat") {
if (curCat.value === "read") rows = rows.filter(m => !m.unread);
else if (curCat.value !== "all") rows = rows.filter(m => m.t === Number(curCat.value === "sys" ? 1 : curCat.value === "biz" ? 2 : 3));
} else {
if (curApp.value !== "all") rows = rows.filter(m => m.app === curApp.value);
}
const kw = searchText.value.trim();
if (kw) rows = rows.filter(m => (m.app + m.tt + m.body).includes(kw));
return rows.sort((a, b) => a.min - b.min);
});
/** 当前视图的列表标题 */
const curTitle = computed(() => { const curTitle = computed(() => {
if (mode.value === "cat") { if (mode.value === "cat") {
const hit = catList.value.find(c => c.key === curCat.value); const hit = catList.value.find(c => c.key === curCat.value);
return hit ? hit.name : "全部消息"; return hit ? hit.name : "全部消息";
} }
return curApp.value === "all" ? "全部应用消息" : curApp.value; if (curApp.value === "all") return "全部应用消息";
const hit = appList.value.find(a => a.appKey === curApp.value);
return hit ? hit.name : "全部应用消息";
}); });
/** 当前筛选条件 → 服务端参数(筛选语义归服务端,本地不过滤) */
function currentParams(extra: { page: number }) {
const params: { page: number; count: number; msgType?: number; appKey?: string; isRead?: number; keyword?: string } = {
page: extra.page,
count: PAGE_SIZE,
};
if (mode.value === "cat") {
if (curCat.value === "sys") params.msgType = 0;
else if (curCat.value === "biz") params.msgType = 1;
else if (curCat.value === "appr") params.msgType = 2;
else if (curCat.value === "read") params.isRead = 1;
} else if (curApp.value !== "all") {
params.appKey = curApp.value;
}
const kw = searchText.value.trim();
if (kw) params.keyword = kw;
return params;
}
/** 拉列表:reset=true 重置第 1 页,false 追加下一页 */
async function loadList(reset: boolean) {
if (loading.value) return;
loading.value = true;
if (reset) {
page.value = 1;
loadError.value = false;
}
try {
const result: any = await BaseApi.getMsgCenterList(currentParams({ page: page.value }));
const list: MsgRow[] = (result && result.list) || [];
total.value = (result && result.total) || 0;
rows.value = reset ? list : rows.value.concat(list);
if (reset && list.length === 0 && total.value > 0) {
// 服务端筛选参数被旧版 UserMsg 忽略时 total 与 list 不匹配的兜底展示
total.value = list.length;
}
} catch (e) {
loadError.value = true;
} finally {
loading.value = false;
}
}
/** 滚动到底自动加载下一页 */
function onScroll() {
const el = refMsgBody.value;
if (!el || loading.value || !hasMore.value) return;
if (el.scrollHeight - el.scrollTop - el.clientHeight < 60) {
page.value += 1;
loadList(false);
}
}
/** 左栏计数一次拉齐(未读总数进 store 与铃铛同源) */
async function loadSummary() {
try {
const s = await BaseApi.getMsgCenterSummary();
if (!s) return;
store.setUnreadCount(s.unreadCount);
catCounts.value = {
sysUnread: (s as any).sysUnread,
bizUnread: (s as any).bizUnread,
approvalUnread: (s as any).approvalUnread,
total: (s as any).total,
readCount: (s as any).readCount,
};
const map: Record<string, number> = {};
(s.todos || []).forEach(t => (map[t.appKey] = t.count));
todoCounts.value = map;
} catch (e) {
/* 摘要失败不打扰列表 */
}
}
function switchMode(m: "cat" | "app") { function switchMode(m: "cat" | "app") {
if (mode.value === m) return;
mode.value = m; mode.value = m;
loadList(true);
} }
function onClickCat(key: string) { function onClickCat(key: string) {
if (curCat.value === key) return;
curCat.value = key; curCat.value = key;
loadList(true);
} }
function onClickApp(key: string) { function onClickApp(key: string) {
if (curApp.value === key) return;
curApp.value = key; curApp.value = key;
loadList(true);
} }
/** mock:点击「同意/拒绝/查看详情」视为已读(真实 readMsgCenter 对接属下一轮) */ /** 刷新:重置第 1 页 + 摘要 */
function onClickMsg(m: MockMsg) { async function reload() {
m.unread = false; await Promise.all([loadList(true), loadSummary()]);
} }
/** mock:全部已读 */
function readAll() { /** 搜索防抖(300ms) */
msgs.value.forEach(m => (m.unread = false)); let searchTimer: any = null;
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => loadList(true), 300);
} }
/** mock:刷新 = 恢复初始数据 */
function refresh() { /** 点击卡片:未读置已读(角标同步减);消息详情弹窗本轮不做 */
msgs.value = MOCK_MSGS.map(m => ({ ...m })); async function onClickCard(m: MsgRow) {
if (isUnread(m) && m.msgId) {
try {
await BaseApi.readMsgCenter(m.msgId);
m.isRead = 1;
store.decUnread();
loadSummary();
} catch (e) {
/* 已读失败静默,不打断浏览 */
} }
}
}
/** 去处理:未读先标读,新窗口打开落地页 */
async function onJump(m: MsgRow) {
if (isUnread(m) && m.msgId) {
BaseApi.readMsgCenter(m.msgId).catch(() => undefined);
m.isRead = 1;
store.decUnread();
loadSummary();
}
if (m.jumpUrl) window.open(m.jumpUrl, "_blank");
}
/** 全部已读:列表全部置灰 + 角标清零 + 计数刷新 */
async function readAll() {
try {
await BaseApi.readMsgCenterAll();
rows.value.forEach(m => (m.isRead = 1));
store.setUnreadCount(0);
loadSummary();
} catch (e) {
/* 失败保留现状 */
}
}
// ===== 审批直批(共享弹窗 MsgApprovalModal)=====
const refApproval = ref<any>();
function openApproval(m: MsgRow, action: "approve" | "reject") {
refApproval.value?.open({ msgId: m.msgId, title: m.title }, action);
}
/** 直批成功:该消息置已读(后端视角待办已消化)并刷新列表与计数 */
function onApprovalHandled(msgId: number) {
const row = rows.value.find(m => m.msgId === msgId);
if (row && isUnread(row)) {
row.isRead = 1;
store.decUnread();
}
loadList(true);
loadSummary();
}
// ===== WS 联动:新消息到达(Header 广播)→ 刷新第一页与计数 =====
function onWsNewMessage() {
loadList(true);
loadSummary();
}
onMounted(() => {
loadList(true);
loadSummary();
mittBus.on("msgCenter:newMessage", onWsNewMessage);
});
onUnmounted(() => {
mittBus.off("msgCenter:newMessage", onWsNewMessage);
clearTimeout(searchTimer);
});
function typeName(t: number) { function typeName(t: number) {
return t === 3 ? "审批待办" : t === 2 ? "业务提醒" : "系统通知"; return t === 2 ? "审批待办" : t === 1 ? "业务提醒" : "系统通知";
} }
function typeLabel(t: number) { function typeLabel(t: number) {
return t === 3 ? "审批" : t === 2 ? "提醒" : "系统"; return t === 2 ? "审批" : t === 1 ? "提醒" : "系统";
} }
function typeIcon(t: number) { function typeIcon(t: number) {
return t === 3 ? "⏳" : t === 2 ? "📋" : "🔔"; return t === 2 ? "⏳" : t === 1 ? "📋" : "🔔";
} }
/** 时间格式:刚刚 / N 分钟前 / N 小时前 / 昨天 HH:mm / MM-DD */ /** 时间格式:刚刚 / N 分钟前 / N 小时前 / 昨天 HH:mm / MM-DD(sendTime 绝对时间 yyyy-MM-dd HH:mm:ss) */
function fmtTime(min: number) { function fmtTime(sendTime?: string) {
if (min < 1) return "刚刚"; if (!sendTime) return "";
if (min < 60) return `${min} 分钟前`; const d = new Date(String(sendTime).replace(" ", "T"));
if (min < 1440) return `${Math.floor(min / 60)} 小时前`; if (isNaN(d.getTime())) return sendTime;
if (min < 2880) { const diffMin = Math.floor((Date.now() - d.getTime()) / 60000);
const hm = new Date(Date.now() - min * 60000);
const pad = (n: number) => String(n).padStart(2, "0");
return `昨天 ${pad(hm.getHours())}:${pad(hm.getMinutes())}`;
}
const d = new Date(Date.now() - min * 60000);
const pad = (n: number) => String(n).padStart(2, "0"); const pad = (n: number) => String(n).padStart(2, "0");
if (diffMin < 1) return "刚刚";
if (diffMin < 60) return `${diffMin} 分钟前`;
if (diffMin < 1440) return `${Math.floor(diffMin / 60)} 小时前`;
if (diffMin < 2880) return `昨天 ${pad(d.getHours())}:${pad(d.getMinutes())}`;
return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`; return `${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
} }
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">
/* 壳:透明,不平铺白,壁纸透出(B 方案) */ /* 壳:透明,不平铺白,壁纸透出(B 方案)。
padding 与 .set-shell / .syssetting-wrap 对齐(15px 四周),overflow:hidden 同 .set-shell。 */
.msg-shell { .msg-shell {
display: flex; display: flex;
gap: 16px; /* ★ 两卡间隔 —— 必须加在两卡的直接父容器(本容器)上 */ gap: 16px; /* ★ 两卡间隔 —— 必须加在两卡的直接父容器(本容器)上 */
height: calc(100% - var(--headerH)); height: calc(100% - var(--headerH));
padding: 0 20px 20px; padding: 15px;
overflow: hidden;
background: transparent; background: transparent;
border: none; border: none;
box-shadow: none; box-shadow: none;
} }
/* 左卡:分类 / 应用导航 */ /* 左卡:分类 / 应用导航(宽度与 .set-nav 统一为 216px) */
.msg-side { .msg-side {
width: 224px; width: 216px;
flex-shrink: 0; flex-shrink: 0;
background: #fff; background: #fff;
border-radius: 10px; border-radius: 10px;
@@ -422,6 +584,7 @@ function fmtTime(min: number) {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
transition: all 0.2s; transition: all 0.2s;
overflow: hidden; overflow: hidden;
cursor: pointer;
&:hover { &:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: #dfe3ea; border-color: #dfe3ea;
@@ -440,10 +603,10 @@ function fmtTime(min: number) {
&.unread::before { &.unread::before {
background: #5979ea; background: #5979ea;
} }
&.unread.m2::before { &.unread.mbiz::before {
background: #e9b954; background: #e9b954;
} }
&.unread.m3::before { &.unread.mappr::before {
background: #e8684a; background: #e8684a;
} }
.row1 { .row1 {
@@ -670,4 +833,12 @@ function fmtTime(min: number) {
opacity: 0.5; opacity: 0.5;
} }
} }
/* 滚动分页脚标(加载中 / 没有更多) */
.loading-more {
text-align: center;
padding: 10px 0 14px;
font-size: 12px;
color: #b3b3b3;
}
</style> </style>