Compare commits

...
Author SHA1 Message Date
chenxing 690d329371 fix(notice): 通知功能修订12项全量落地——已发送幻影省略号/翻页日期格式化/附件upFile字段/审核状态Tag徽标对齐流程色系/审批中禁用删除/待审核操作列operating-link+查看详情/流程绑定预览选中流程图/一键已读改SvgIcon(read-all.svg)/图片附件icon-tupian/详情标签两端对齐/上传附件换行布局/抄送页接入TablePageAlovaNew(usePagination须返回Method)
build / build (push) Successful in 2m37s
2026-10-10 15:16:32 +08:00
chenxing 6a63a7cb8f feat(notice): 审批记录/审批进度 Drawer 收窄至 740,适配 flow-center 嵌入页 v2.4
build / build (push) Successful in 2m53s
- 两处内嵌 Drawer :width 860→740;「流程示意图」Modal 保持 860 不动
- 同批带上:审批页操作列改链接式(查看详情/通过/驳回)+ acting 防连点;
  详情页附件图片图标改 icon-tupian、正文高度模型(page-wrapper 转 flex 纵向)
2026-10-10 14:55:22 +08:00
chenxing f90aefc8e3 fix(diagram): 流程示意图 PNG 按逻辑尺寸显示(SCALE=2 图按自然尺寸渲染整体放大一倍)——详情 Modal 与两处流程图 Drawer 统一走 fitDiagram
build / build (push) Successful in 4m10s
2026-10-09 22:10:21 +08:00
chenxing c1fe0783fb Merge remote-tracking branch 'origin/main' into feat/flow-embed
build / build (push) Successful in 4m13s
2026-10-09 20:56:41 +08:00
chenxing 3de71aa99b feat(notice): 办件视角加操作列「审批记录」——Drawer iframe 嵌 flow-center 进度页(token 每次现取不缓存),业务错原文提示不打断列表 2026-10-09 20:15:34 +08:00
chenxing 8fe3e2ef8c feat(notice): 查看流程图改 Drawer(后端 DiagramDef 同源 PNG 竖版直嵌+纵向滚动)——替换 window.open 深链 404 坏链(生产 /flow-center/front* 无根路由);解绑隐藏入口、未发布走错误文案 2026-10-09 20:04:38 +08:00
chenxing 13afb22060 feat(layout): 菜单图标兜底 icon-list(平台未配置时恒有图标保缩进一致)+ 面包屑首页图标回归行内基线流(-0.125em 光学居中,修 inline-flex 首项无字图标基线把整盒顶高 4px 的错位)+ 通道版本戳跟版
build / build (push) Successful in 4m8s
2026-10-09 17:09:58 +08:00
chenxing dbbabc56eb feat(layout): 菜单图标兜底 icon-list(平台未配置时恒有图标保缩进一致)+ 面包屑首页图标回归行内基线流(-0.125em 光学居中,修 inline-flex 首项无字图标基线把整盒顶高 4px 的错位)+ 通道版本戳跟版 2026-10-09 17:08:28 +08:00
chenxing 5e3f151ce7 revert(顶栏): 移除 LOGO 文字显示(用户反馈子应用不展示,仅门户展示)(LOGO文字与主题JSON化增补)
build / build (push) Successful in 2m29s
2026-10-09 12:30:01 +08:00
chenxing ab647c67ba revert(顶栏): 移除 LOGO 文字显示(用户反馈子应用不展示,仅门户展示)(LOGO文字与主题JSON化增补) 2026-10-09 12:29:57 +08:00
chenxing e8ccc7d3ba fix(顶栏): LOGO 文字字号对齐应用名层级(19px/700)(LOGO文字与主题JSON化增补)
build / build (push) Successful in 3m1s
2026-10-09 12:04:01 +08:00
chenxing b685ff5dc0 fix(顶栏): LOGO 文字字号对齐应用名层级(19px/700)(LOGO文字与主题JSON化增补) 2026-10-09 11:56:42 +08:00
chenxing 9c8b0f900a chore(theme): theme.ts 同步通道 1038 版本戳(LOGO文字与主题JSON化) 2026-10-09 10:49:42 +08:00
chenxing 1af6005a55 feat(顶栏): LOGO 徽标旁显示平台文字(yh-logo-text 键接入);theme.ts 同步通道新版本戳(LOGO文字与主题JSON化)
build / build (push) Successful in 3m5s
2026-10-09 10:44:02 +08:00
chenxing fea12895c1 feat(顶栏): LOGO 徽标旁显示平台文字(yh-logo-text 键接入);theme.ts 同步通道新版本戳(LOGO文字与主题JSON化) 2026-10-09 10:35:45 +08:00
chenxing 599fed01b5 chore(theme): YschTheme sync 件更新(版本 20261009-0054-5732ee2,信封兼容) 2026-10-09 00:54:49 +08:00
chenxing e0d1b2b769 chore(theme): YschTheme sync 件更新(版本 20261009-0036-19f025a,serviceHost 前缀修复) 2026-10-09 00:36:34 +08:00
chenxing 71452fcfd2 chore(theme): YschTheme sync 件更新(版本 20261008-2326-f7fb534,oapi 代理轮)
build / build (push) Successful in 2m36s
2026-10-09 00:23:46 +08:00
chenxing eee4c10155 refactor(theme): 移除顶栏配色切换器入口(oapi 代理轮拍板:设置收敛 UserHome)
本地切换会被服务端生效值收敛打回,不留半残入口;ThemeSwitcher 资产保留(通道 sync 件)
2026-10-09 00:23:45 +08:00
chenxing f611c15a6e chore(theme): YschTheme sync 件更新(版本 20261008-2326-f7fb534,oapi 代理轮) 2026-10-09 00:23:28 +08:00
chenxing 941031e1b4 refactor(theme): 移除顶栏配色切换器入口(oapi 代理轮拍板:设置收敛 UserHome)
本地切换会被服务端生效值收敛打回,不留半残入口;ThemeSwitcher 资产保留(通道 sync 件)
2026-10-08 23:27:35 +08:00
chenxing 59f8fad4ca 外观偏好异步补齐C:YschTheme sync 件更新(UserAvatar 监听 yh-appearance-updated 重读键 + theme.ts 版本戳 20261008-1831-d9db725)
build / build (push) Successful in 2m38s
2026-10-08 18:52:14 +08:00
chenxing f4d0c7d97a 外观偏好异步补齐C:顶栏 Logo 接平台键 yh-logo-url(服务端学校 Logo 异步生效)
- .logo-badge img 改动态 src:localStorage["yh-logo-url"](yh-theme-init 异步补齐段写入)优先,本仓 yunhai-logo.svg 兜底,@error 回落兜底——绝无空 src/裂图
- mounted 读一次 + 监听 yh-appearance-updated 即时切换(无需刷新)
2026-10-08 18:51:51 +08:00
chenxing e37ff9f8d1 feat(avatar): 默认头像轮接入(YschTheme sync 件 + 顶栏三态头像)
- src/utils/avatar.ts + src/components/UserAvatar.vue:YschTheme 默认头像轮 sync 下发公共源(取值链 URL > yh-avatar > yh-avatar-sch-default > text)
- layout.vue:顶栏 img 头像换 UserAvatar 三态组件(placement=brand,size=28);头像/姓名点击统一跳 UserHome 设置页「个人信息」pane(原 /v/userinfo 已下线语义)
- theme.ts/ThemeSwitcher.vue:sync 刷新至 20261006-2013-75c1d10(theme.ts 仅版本戳;ThemeSwitcher 与源仓逐字节一致)
- auto-imports.d.ts:UserAvatar 组件自动登记
当前分支 feat/flow-embed(avatar 轮独立于流程嵌入工作,随分支推送时机一并处理)
2026-10-06 20:25:37 +08:00
chenxing c81f097188 feat(notice): 接入流程进度嵌入页(守卫端点 + Drawer iframe) 2026-10-03 16:59:48 +08:00
chenxing 05804f41a9 theme: 切 A 类免插件接入(YschTheme 方案 §11),删 vendored vite 插件
build / build (push) Successful in 4m19s
- 接入点收敛为 index.html <head> 内一段同步 loader(env-config 引导之后、title/入口之前),
  document.write 保 parser-blocking;通道 init 已吸收 FOUC 门 + manifest 预取 + DCL 挂 <link>
  + href 去重交棒,主题逻辑升级全走 /static,不再需要重新构建本应用
- 域名四档:staticHost > baseHost > VITE_THEME_HOST(dev 跨源直连专用,需通道 CORS)> 同源相对。
  不复用 VITE_BASE_HOST:它与 SSO/API 共用、.env.dev 里本就非空,复用会让 dev 立刻跨源被 CORS 拦;
  build 产物不读 .env,占位符留字面量 → 置空 → 同源(产物零环境值烧入,与 2f423c2 口径一致)
- 删 plugins/vite-plugin-theme(-420 行 vendor)+ package.json/lock 的 file: 依赖,
  根治 CI 容器解析不到 file:../../YschTheme 那类 npm ci 秒失败
- vite.config:保留 dev /static 代理(联调便利,changeOrigin 顺带改写 Host,等价旧插件的
  headers.Host 补偿),注释端口纠错 5174 → 9527;theme.ts 同步到通道版本 20260929-1112-1b1724d
- 已验:npm run build 通过,dist/index.html 注入段存活且无 B 类痕迹,
  YschTheme npm run smoke 断言 3 张主题表按序挂载、FOUC 门先于首帧、manifest 只拉一次
2026-09-29 11:38:31 +08:00
chenxing 29c051350b fix(layout): 菜单点击/高亮统一走 resolveMenuPage,修 /approval 尾空格导致要点多次+不高亮
build / build (push) Successful in 2m35s
- 之前模板与 linkMenu 用 raw item.page,后端 GetUserMod 待审核 page="/approval "(带尾空格)
  与 routeChange 解析后的 "/approval" 不一致 → router.push 带空格需多次才命中、active 比对不等不高亮。
- linkMenu 与 active-class 改用 resolveMenuPage 解析后的 path(顺带修复外链 item.external 之前从 raw 取不到)。
- 不改数据;根治建议:平台模块管理把该 page 的尾空格去掉。
2026-09-24 15:38:36 +08:00
chenxing 8c2d539f34 fix(menu): modId 按真实 /base/GetUserMod 校正(1/2/3/4/21/89/91),撤销上版误值
build / build (push) Successful in 2m59s
- 上版(693aba9)照 31ca694 提交说明的假设值 1000/…/2100,与后端实际返回不符。
  真实 modId:已收到1 已发送2 全部通知3 通知统计4 应用设置21 待审核89 抄送91。
- 深层页回到 parentId 2/2/3(发通知22 / 详情23 / 详情24),修正上版 parentId:2000/3000
  造成的父菜单高亮失效。
- config/menu.ts、mock GetUserMod 同步真实值并补「抄送给我的」91。
- 说明:/approval 导航本就靠 page 精确匹配(后端 page "/approval " 尾空格已被 resolveMenuPage trim),
  本次主要修 modId 契约与详情撞车/高亮,不改变可点击性。
2026-09-24 15:15:46 +08:00
chenxing 693aba9968 fix(menu): modId 契约全量对齐后端 /base/GetUserMod,解 /approval 与 /list/detail 撞车
build / build (push) Successful in 2m41s
- routes.ts:已收到1000/已发送2000/全部通知3000/通知统计4000/应用设置5000;
  深层页按 UserBase 范式编码 20001/20002/30001;/approval 独立 2100
  (原与通知详情同为 23,ROUTE_BY_MODID 取先注册者 → 待审核会被误解析到通知详情)。
- config/menu.ts、mock GetUserMod:id/modId 同步对齐。
- 不动 NoticeApproval.vue 主题改动;/cc 维持 25(与平台一致,未纳入本次对齐)。
2026-09-24 14:22:11 +08:00
chenxing 21a0bd36b6 fix(ci): vendor @yunhai/vite-plugin-theme 进仓,修 CI npm ci 解析不到 file:../../YschTheme
build / build (push) Successful in 2m34s
- CI 只 clone NoticeFront,file:../../YschTheme/packages/vite-plugin 在容器里不存在 → npm ci 秒失败(run#13)。
- 改为仓内 plugins/vite-plugin-theme(拷构建产物,dist→lib 规避 .gitignore 的 dist 规则;剥离 devDeps 防嵌套安装)。
- package.json 消费改 file:./plugins/vite-plugin-theme,重生成 lock(已清 extraneous 孤儿)。
- 本地 npm run build 通过。待 Nexus npm 仓就绪后再迁回版本号消费(方案 B)。
2026-09-24 12:22:09 +08:00
chenxing 796a1b9a7b chore(theme): 同步 theme.ts 到已发布通道版本 20260924-1142-e34cbc4
build / build (push) Failing after 10s
YschTheme 通道已 --publish 上线(base.yunhaixingtong.com 实测 manifest=1142);
经 npm run sync 刷新 src/utils/theme.ts 版本戳。
2026-09-24 11:51:46 +08:00
chenxing 74b9e80b0d theme: 以 feature/theme-remote-p0 内容整体替换 main(主题通道化试点,非 merge)
- 本地 icons.css/layout.css 下线改走中央静态通道;新增 ThemeSwitcher.vue + useElementHeight.ts;
  layout/main.ts/vite/menu 及各页适配对齐 feature 试点。
- 已知回退:本轮按 feature 覆盖,冲掉了 main 上 31ca694(/approval 可达性+modId 对齐) 与
  1516eff(cc useTableHeight) 两处功能修复,待后续单独补回。
- 回滚锚点:archive/main-before-theme-20260924 = 替换前 main(1516eff)。
2026-09-24 11:33:13 +08:00
chenxing 1516effb69 fix(cc): 抄送页高度改走 Utils.useTableHeight——main 分支无 theme 试点分支的 useElementHeight 钩子
build / build (push) Successful in 2m41s
2026-09-24 09:33:36 +08:00
chenxing edd91b53b7 feat(flow): 应用内「抄送给我的」——notice_cc 列表页(路由 /cc)、API 封装、跳通知详情(type=0 不标读)
数据源 /api/notice/cc/My(flw_cc 落表经 flow.cc.sent 回调);未发布通知的详情可见由后端 NoticeFlowGuard 第 4 条(被抄送人)放行
2026-09-24 09:29:21 +08:00
chenxing 7c885c0b4b chore(theme): 同步 YschTheme 再生产物 theme.ts(版本戳 20260923-1454-9619fbf)
build / build (push) Successful in 2m33s
仅版本戳注释行变化,正文与 20260921-1219-bb1637c 版一致;main 此前无该文件,本次新增。
2026-09-23 15:59:51 +08:00
chenxing 31ca694d48 fix(approval): /approval 点击不可达——modId 全量对齐 GetUserMod + 页面按 bsch 应用页规范重构
根因:/approval 与 /list/detail 的 meta.modId 同为 23,menuRoute.ts 的 ROUTE_BY_MODID 按注册
顺序取首条,23 恒解析为「通知详情」;菜单下发的 path 一旦未精确命中,兜底即串页 → 表现为点不到。

- routes.ts:modId 全量对齐 /base/GetUserMod(已收到 1000 / 已发送 2000 / 全部通知 3000 /
  通知统计 4000 / 应用设置 5000 / 待审核 2100);深层页按「父×10+序」重排
  (/list/new=20001、/list/detail=20002、/noticelist/detail=30001,parentId 同步);
  /approval 去掉 parentId,按一级模块处理;文件头契约注释重写
- NoticeApproval.vue:tabs-wrapper/tabs-panel[type=card] + main-container(v-show 保活);
  link-table table-bottom-box + useTableHeight;page-panel + customPageCount 记忆;
  操作列 operatingTextHandle(通过 / 驳回 text-error,fixed:right);
  状态列改用行级 cellClassName(status-color-N,本仓 view-ui-plus 为行级对象语义);
  驳回弹窗改 Form + 必填红星,校验失败统一提示「请填写红色星号内容!」
- mock/noticeapi.ts、config/menu.ts:GetUserMod 六条全量对齐并补「待审核」(顺序按平台配置)
2026-09-23 15:59:44 +08:00
chenxing f41719f3c4 feat(approval): 我的待审核页——待办列表+办件视角+通过/驳回
build / build (push) Successful in 2m41s
- /approval 路由(modId 23 深层页,菜单挂载待 GetUserMod 配置)
- 待办:标题/摘要(bizSnapshot 解析)/节点/到达时间,通过一键,驳回弹窗强制意见
- 办件:状态 RadioGroup 筛选(10/20/30/40),状态 Tag 着色
- 全部走本应用后端代理 /api/notice/approval/**(前端不直调 oapi,不传身份)
- vite build 通过(2m24s)
2026-09-23 00:36:21 +08:00
chenxing 468cb8670f feat(details): 审批卡片升级——撤回走后端中转、流程图弹窗同源嵌入、结构化步骤时间线
build / build (push) Successful in 3m17s
2026-09-17 01:10:41 +08:00
chenxing 830f9088f4 feat: 通知前端存量(审批状态/消息中心/详情流程入口等 2026-09-13/14 改动)
build / build (push) Successful in 3m19s
2026-09-16 01:21:46 +08:00
chenxing c3ff58fd94 先审后发
build / build (push) Successful in 5m1s
2026-09-15 22:09:27 +08:00
chenxing 1b39e0086d ci修订:构建容器名唯一化(修复与同宿主其他仓并发构建时 exit 137 互杀)-runner 挂宿主 docker.sock,容器名在整个 daemon 上共享命名空间,固定名 frontbuild 在多仓/多 workflow 并发时会被对方的 docker rm -f 直接 SIGKILL(2026-09-10 三仓同推:UserBase 与 Cms 的 build 被打死、Notice 侥幸绿);改法 BUILD_CONTAINER=frontbuild-${GITHUB_RUN_ID}-${时间戳},run/exec/cp/rm 全步统一引用;YAML 解析与 step sh -n 通过
build / build (push) Successful in 2m30s
2026-09-10 12:17:00 +08:00
chenxing c50cafe44e 面包屑首页图标垂直居中+首页入口改指 userhome:crumb-home 用 inline-flex 真居中并关掉压缩 layout.css 遗留的 .path-nav .icon-category{top:1px} 字体基线补偿(该补偿是给老 iconfont 字形用的,对 mask 的 1em 方块反而顶偏;不改压缩产物,覆盖收在组件 scoped);linkHomePage 与面板「全部应用」由 /BschHome/front/index.html 改 /userhome/front/index.html(线上 BschHome 实测 404,userhome 200 且 hash 含 /apps);头部「常用应用」改「返回首页」(icon-shouye,面板 appList 全仓无赋值属死入口,面板 DOM 与 isPanel 保留仅失去入口);build 通过
build / build (push) Successful in 2m33s
2026-09-10 11:57:47 +08:00
chenxing 99e4ea8369 登录修订
build / build (push) Successful in 3m46s
2026-09-09 14:57:50 +08:00
chenxing 2f423c2d33 env-config 运行时配置迁移:vite 产物直出 dist/(根治 zip 根只有 base/ 目录、projman 前端发布校验缺 html),index.html 内联引导同步拉 /api/env-config?target=front,host.config 改三级合并(__APP_CONFIG__ > VITE_* 仅本地 > 同域推导);删 .env.base/.env.prod/.env.ld,build 产物零环境值烧入
build / build (push) Successful in 4m21s
2026-09-08 22:10:37 +08:00
chenxing d2d0eb6cae 构建修复:同步 package-lock(补齐 @open-file-viewer 依赖树,修复 CI npm ci 失败),移除未使用的 node-sass/sass-loader(Node20 装包需下载二进制,内网不可达)
build / build (push) Successful in 2m31s
2026-09-08 20:50:19 +08:00
chenxing 17be4e6445 菜单图标切换本地 bsch-icons:layout.vue 改用 iconfont 掩码类,刷新 icons.css 图标库;登出地址改为 /cas/logout
build / build (push) Failing after 57s
2026-09-08 20:22:53 +08:00
chenxing 23750b4fde 功能修订 2026-09-08 20:22:52 +08:00
46 changed files with 5223 additions and 3290 deletions
-7
View File
@@ -1,7 +0,0 @@
# 正式环境 - base
VITE_BASE_HOST=http://base.yunhaixingtong.com
VITE_SSO_HOST=http://sso.yunhaixingtong.com
VITE_SERVICE_HOST=http://base.yunhaixingtong.com/notice
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
VITE_IS_MOCK=false
+6 -1
View File
@@ -1,8 +1,13 @@
# 开发环境 (dev):API 走 /api 同源相对路径(vite proxy 转发到本地后端 8070),
# 开发环境 (dev):仅 vite dev --mode dev 加载;build 产物不读任何 .env.*
# (.env.base/.env.ld/.env.prod 已删,线上由 /api/env-config?target=front 运行时下发)。
# ⚠️ VITE_* 仅本地开发生效。
# API 走 /api 同源相对路径(vite proxy 转发到本地后端 8101),
# 规避后端 CAS 过滤器不回 CORS 头导致的跨域拦截;SSO/基础平台用真实环境
VITE_BASE_HOST=http://base.yunhaixingtong.com
VITE_SSO_HOST=http://sso.yunhaixingtong.com
VITE_SERVICE_HOST=
# 流程中心本地直连(详情页撤回/催办直调;生产同主域自动推导 /flow-center)
VITE_FLOW_HOST=http://localhost:8085
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
VITE_IS_MOCK=false
-7
View File
@@ -1,7 +0,0 @@
# 正式环境 - ld
VITE_BASE_HOST=https://ld.bestsch.com
VITE_SSO_HOST=http://sso.yunhaixingtong.com
VITE_SERVICE_HOST=https://ld.yunhaixingtong.com/notice
VITE_ZIYUANKU_HOST=https://ld.bestsch.com/BschZyk
VITE_ZHUANXIANG_HOST=https://ld.bestsch.com/BschZncpd
VITE_IS_MOCK=false
+3 -2
View File
@@ -1,8 +1,9 @@
# 本地纯前端调试模式:后端 SSO/base 不可达时启用内置 mock
# 本地纯前端调试模式:仅 vite dev --mode mock 加载(build 产物不读任何 .env.*,
# 线上由 /api/env-config?target=front 运行时下发)。后端 SSO/base 不可达时启用内置 mock
# 启动:npm run dev:mock
VITE_BASE_HOST=
VITE_SSO_HOST=
VITE_SERVICE_HOST=http://localhost:8070
VITE_SERVICE_HOST=http://localhost:8101
VITE_ZIYUANKU_HOST=
VITE_ZHUANXIANG_HOST=
VITE_IS_MOCK=true
-7
View File
@@ -1,7 +0,0 @@
# 生产环境 - prod(vite build --mode prod 加载此文件)
VITE_BASE_HOST=http://base.yunhaixingtong.com
VITE_SSO_HOST=http://sso.yunhaixingtong.com
VITE_SERVICE_HOST=http://base.yunhaixingtong.com/notice
VITE_ZIYUANKU_HOST=https://base.bestsch.com/BschZyk
VITE_ZHUANXIANG_HOST=https://base.bestsch.com/BschZncpd
VITE_IS_MOCK=false
+16 -8
View File
@@ -38,10 +38,16 @@ jobs:
- name: 构建前端(node 容器)
run: |
set -e
docker rm -f frontbuild 2>/dev/null || true
docker run -d --name frontbuild -w /build node:20 sleep 3600
docker cp "${PWD}/." frontbuild:/build/
docker exec frontbuild sh -c '
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上是共享
# 命名空间。旧写法固定名 frontbuild 会在多仓/多条 workflow 并发时互相 docker rm -f 掉
# 对方正在构建的容器,表现为无编译报错的 exit 137(SIGKILL,2026-09-10 三仓同推即撞)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底(手动重跑/未注入时也不撞名)。
BUILD_CONTAINER="frontbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" -w /build node:20 sleep 3600
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
docker exec "$BUILD_CONTAINER" sh -c '
set -e
cd /build
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
@@ -49,13 +55,15 @@ jobs:
npm run build
'
echo "=== dist 产物 ==="
docker exec frontbuild sh -c 'ls -la /build/dist/ | head -20'
docker exec "$BUILD_CONTAINER" sh -c 'ls -la /build/dist/ | head -20'
# 从容器 cp dist 到宿主工作目录
docker cp frontbuild:/build/dist ./dist
docker rm -f frontbuild
docker cp "$BUILD_CONTAINER":/build/dist ./dist
docker rm -f "$BUILD_CONTAINER"
echo "BUILD CHECK OK"
# 打包 dist → zip(进入 dist 目录打包,zip 根目录直接是 html/css/js 文件)
# 打包 dist → zip(vite outDir 已简化为直出 dist/,无中间层;进入 dist 打包,
# zip 根目录直接是 index.html/css/js。此前 outDir=dist/base 时曾误打 dist/ 整层,
# zip 根只有 base/ 目录,projman 前端发布校验报「zip 根目录缺少 html 文件」)
- name: 打包产物
run: |
TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
+46
View File
@@ -5,6 +5,52 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="../../favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- 运行时配置引导:在任何应用模块执行前同步拉取 /<contextPath>/api/env-config
(后端免登录端点,projman 配置管理在线维护),替代生产构建环境变量。
必须同步:host.config.ts 在模块求值期就读 window.__APP_CONFIG__;失败/本地开发
(根路径,段数不足 2)则不设置:本地走 .env.dev/.env.mock(VITE_*),线上走同域推导兜底。
target = URL 第 2 段目录名:本应用产物部署于 /{contextPath}/front/,故为 front,
对应后端配置键 yh.env-config.source.front(环境变量 YH_ENV_CONFIG_SOURCE_FRONT)。
注意:同步 XHR 不支持 timeout 属性(赋值会抛 InvalidAccessError),依赖内网快速失败 -->
<script>
(function () {
try {
var seg = location.pathname.split("/").filter(Boolean);
if (seg.length < 2) return; // 本地开发(根路径):走 .env.dev / .env.mock
var xhr = new XMLHttpRequest();
xhr.open("GET", "/" + seg[0] + "/api/env-config?target=" + encodeURIComponent(seg[1]), false);
xhr.send(null);
if (xhr.status === 200) {
var conf = JSON.parse(xhr.responseText);
if (conf && typeof conf === "object") window.__APP_CONFIG__ = conf;
}
} catch (e) {
/* 拉取失败保持未设置,走同域推导兜底 */
}
})();
</script>
<!-- 主题通道引导(A 类免插件接入,方案 §11):唯一接入点,不装依赖、不改 vite.config 也能吃主题。
origin 零写死,优先级四档:
__APP_CONFIG__.staticHost(专用通道覆盖,projman env-config 在线配)
> __APP_CONFIG__.baseHost(换域名改配置即生效)
> %VITE_THEME_HOST%(仅 dev 注入:主题通道专用域,需通道已开 CORS)
> 同源相对 "/static/…"(生产常态:通道与应用同域;本地 dev 由 vite /static 代理兜到通道)
为何不直接复用 .env.dev 的 VITE_BASE_HOST:那会让 dev 跨源直连线上域,而通道 Caddy 至今没有
Access-Control-Allow-Origin(方案 §10.4 现网无该头)→ manifest 是 fetch 跨源,会被拦成降级。
运维加上那行头之后,在 .env.dev 补 VITE_THEME_HOST=http://base.yunhaixingtong.com 即切直连,零改码。
document.write 保证 parser-blocking:init 的档位挂载与 FOUC 门先于首帧(同旧插件 initBootstrap 机制,
且 vite build 不解析内联脚本里的字符串,规避 "/static/…" 被当本仓资源解析报错;build 产物不读
.env.*,占位符原样留字面量 → 判首字符 % 置空,产物零环境值烧入)。
挂载/FOUC/CSS 排序全部由通道 init 脚本承担,逻辑升级全走 /static 通道,无需重新构建本应用 -->
<script>
(function () {
var c = window.__APP_CONFIG__ || {};
var themeHost = "%VITE_THEME_HOST%";
if (themeHost.charAt(0) === "%") themeHost = "";
var host = String(c.staticHost || c.baseHost || themeHost).replace(/\/+$/, "");
document.write('<script src="' + host + '/static/js/yh-theme-init.js"><\/script>');
})();
</script>
<title><%- title %></title>
<link rel="stylesheet" href="./libs/ztree/zTreeStyle.css" />
<script src="./libs/jquery.min.js"></script>
+2590 -2800
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -41,8 +41,6 @@
"@unocss/preset-uno": "^66.6.0",
"@vitejs/plugin-vue": "^5.0.4",
"fast-glob": "^3.3.2",
"node-sass": "^6.0.1",
"sass-loader": "^10.0.1",
"typescript": "^5.2.2",
"unocss": "^66.6.0",
"unplugin-auto-import": "0.18.3",
+9 -2
View File
@@ -62,7 +62,7 @@ onMounted(async () => {
}
} catch (error: any) {
console.error("加载用户信息失败", error);
if (error !== "redirect") {
if (error?.message !== "redirect") {
Message.error("载入用户信息失败,请检查后端服务是否正常!");
}
// 降级:使用默认值("/" 即已收到页,无需跳转)
@@ -78,7 +78,8 @@ onMounted(async () => {
<layout v-if="isLogin" :layoutData="layoutData" />
<div v-if="isLogin" class="base-wrap-main" :class="{ 'alone-page': layoutData.isAlone }">
<div class="main-wrapper">
<router-view />
<!-- key=fullPath:详情页仅 query 变化(id/serid)时强制重渲染,避免显示上一条数据 -->
<router-view :key="$route.fullPath" />
</div>
</div>
</template>
@@ -101,4 +102,10 @@ body {
height: 100%;
overflow-y: auto;
}
/* 表格操作列(operatingHandle 渲染的 Tooltip 包裹层)横排且随单元格居中 */
.ivu-table-cell > .ivu-tooltip {
display: inline-block;
vertical-align: middle;
margin: 0 2px;
}
</style>
+83
View File
@@ -0,0 +1,83 @@
import { Method } from "alova";
import { alovaInstance } from "@/utils/alova/alova-iview";
/**
* 应用内审批接口封装(第三步 T-C4,"我的待审核")。
* 走本应用后端代理 /api/notice/approval/**(operator=CAS 登录态,前端不传身份);
* 后端经网关调 flow-center,契约=BasePageResult{total,content,...}。
*/
const APPROVAL = "/api/notice/approval";
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
return new Method<T>("GET", alovaInstance, url, { params });
}
function postJSON<T = any>(url: string, data?: Record<string, any>): Method<T> {
return new Method<T>(
"POST",
alovaInstance,
url,
{ headers: { "Content-Type": "application/json;charset=UTF-8" } },
data ? JSON.stringify(data) : undefined
);
}
export interface ApprovalTodoTask {
taskId: number;
instanceId: number;
instanceVersion: number | null;
bizType: string;
bizId: string;
bizTitle: string;
bizSnapshot: string | null;
nodeId: string;
nodeName: string;
assigneeId: number;
assigneeName: string;
arriveTime: string;
}
export interface ApprovalInstance {
instanceId: number;
status: number;
version: number;
bizType: string;
bizId: string;
bizTitle: string;
bizUrl: string | null;
submitterName: string;
submitTime: string;
finishTime: string | null;
currentNodes: string;
}
export interface ApprovalPage<T> {
total: number;
content: T[];
pageIndex: number;
pageSize: number;
totalPages: number;
hasNext: boolean;
}
export const approvalApi = {
/** 我的待审核(bizType=notice 固定在后端) */
todo(page = 1, size = 10): Method<ApprovalPage<ApprovalTodoTask>> {
return get(`${APPROVAL}/Todo`, { page, size });
},
/** 办件视角(status 可空;10 审批中/20 完成/30 拒绝/40 撤回) */
instances(status?: number, page = 1, size = 10): Method<ApprovalPage<ApprovalInstance>> {
return get(`${APPROVAL}/Instances`, status ? { status, page, size } : { page, size });
},
/** 通过(opinion 可选) */
approve(taskId: number, opinion?: string): Method<void> {
return postJSON(`${APPROVAL}/Approve?taskId=${taskId}`, opinion ? { opinion } : {});
},
/** 拒绝(opinion 必填) */
reject(taskId: number, opinion: string): Method<void> {
return postJSON(`${APPROVAL}/Reject?taskId=${taskId}`, { opinion });
},
};
export default approvalApi;
+45
View File
@@ -0,0 +1,45 @@
import { Method } from "alova";
import { alovaInstance } from "@/utils/alova/alova-iview";
/**
* 应用内「抄送给我的」接口封装(2026-09-24)。
* 走本应用后端 /api/notice/cc/My(身份=CAS 登录态,前端不传身份);
* 数据源是 flow.cc.sent 回调落的 notice_cc 表,点击行跳通知详情。
*/
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
return new Method<T>("GET", alovaInstance, url, { params });
}
export interface CcItem {
id: number;
noticeId: number;
flowInstanceId: number | null;
nodeId: string | null;
nodeName: string | null;
sentTime: string;
title: string | null;
deptName: string | null;
publisherName: string | null;
noticeStatus: number | null;
auditStatus: number | null;
auditStatusDesc: string | null;
}
export interface CcPage {
total: number;
content: CcItem[];
pageIndex: number;
pageSize: number;
totalPages: number;
hasNext: boolean;
}
export const ccApi = {
/** 抄送给我的(分页,sent_time 倒序) */
my(page = 1, size = 10): Method<CcPage> {
return get(`/api/notice/cc/My`, { page, size });
},
};
export default ccApi;
+33 -5
View File
@@ -15,6 +15,9 @@ import type {
NoticeRequest,
NoticeQuery,
DictOption,
FlowOption,
FlowTraceVo,
FlowBindingVo,
DeptManageItem,
ContactGroupPage,
ContactUserPage,
@@ -107,6 +110,36 @@ class BaseApi {
}
class NoticeApi {
// ========== 先审后发(流程中心托管) ==========
/** 审核流程下拉选项(流程中心未接入时返回空数组,页面隐藏选择器) */
getFlowOptions(schId?: number) {
return get<FlowOption[]>(`${NOTICE}/flow/Options`, schId ? { schId } : undefined);
}
/** 提交/重新提交审核(幂等:bizId=notice.id;审批中会被拒绝) */
trySubmitFlow(serid: number) {
return postForm<void>(`${NOTICE}/flow/TrySubmit`, { serid });
}
/** 审批进度(详情页进度卡片;未绑流程返回 bound=false) */
flowTrace(serid: number) {
return get<FlowTraceVo>(`${NOTICE}/flow/Trace`, { serid });
}
/** 撤回审核(2026-09-17:后端经 SDK 托管件中转,仅创建人本人+审批中;替代原先前端直调 flow-center) */
flowWithdraw(serid: number) {
return postForm<void>(`${NOTICE}/flow/Withdraw`, { serid });
}
/** 办件进度嵌入页地址(Drawer iframe 直嵌;pageUrl 含短时 token 现取现发,勿缓存复用) */
flowEmbed(serid: number) {
return get<{ pageUrl: string }>(`${NOTICE}/flow/Embed`, { serid });
}
/** 应用级流程绑定(应用设置 > 流程绑定;发通知页据此判断「提交审核」还是「发送」) */
getFlowBinding() {
return get<FlowBindingVo>(`${NOTICE}/flow/Binding`);
}
/** 保存/解除绑定(flowGuid 空=解绑;仅本校管理员) */
saveFlowBinding(flowGuid: string) {
return postForm<FlowBindingVo>(`${NOTICE}/flow/Binding/Save`, { flowGuid });
}
// ========== 收件箱 ==========
/** 已收通知列表 */
list(params: NoticeQuery) {
@@ -214,11 +247,6 @@ class SettingApi {
sortDeptDic(ids: string) {
return postForm<void>(`${DICT}/Sort`, { ids });
}
/** 同步基础平台部门 */
getDeptSync() {
return get<void>(`${DICT}/Sync`);
}
// ========== 自定义组管理 ==========
/** 获取联系组列表 */
getGroup(page: number, count: number) {
+51 -4
View File
@@ -1,22 +1,69 @@
import { BaseConf } from "@/types/Basics";
/**
* 通过 Vite 环境变量管理配置,不再依赖 window.conf / config.js
* 环境变量定义在项目根目录的 .env.dev / .env.base / .env.ld 中
* 配置优先级(高 → 低):
* ① window.__APP_CONFIG__:线上运行时配置,index.html 引导脚本在应用模块加载前同步拉取
* /<contextPath>/api/env-config?target=<部署目录名>(本应用部署于 /{contextPath}/front/,
* 故为 front,对应后端键 yh.env-config.source.front,projman 配置管理维护)写入
* ② import.meta.env.VITE_*:仅本地开发/演示(vite dev --mode dev 读 .env.dev、--mode mock 读 .env.mock);
* build 产物不读任何 .env.*(.env.base/.env.ld/.env.prod 已删),VITE_* 恒为 undefined,
* 本层自动跳过,产物不含任何环境值
* ③ 同域推导兜底:env-config 未配置/拉取失败时框架至少能渲染并调通同源 API;
* ssoHost 为空 → 登出跳转不可用——与后端未就绪状态一致,可接受。
*/
const env = import.meta.env;
// ③ 同域推导兜底:context 段取当前路径首段(/{contextPath}/front/ → contextPath,即后端上下文),
// 与服务端 context-path 对齐(本地无、base/prod /notice);本地 dev/mock 在根路径运行(无段),
// serviceHost 留空走 vite 同源代理(规避 CAS 过滤器不回 CORS 头)
const seg = window.location.pathname.split("/").filter(Boolean);
const origin = window.location.origin;
let conf: BaseConf = {
baseHost: origin,
ssoHost: "",
serviceHost: seg.length >= 2 ? `${origin}/${seg[0]}` : "",
// 流程中心基址(Notice 详情页撤回/催办直调 /api/flow/*):生产同主域 Caddy 路由 /flow-center/;
// 本地 dev 由 VITE_FLOW_HOST 指向 http://localhost:8085(flow-center CORS 已收 8077)
flowHost: seg.length >= 2 ? `${origin}/flow-center` : "",
relayConf: {
ziyuankuHost: "",
zhuanxiangHost: "",
},
isMock: false,
isDebug: env.DEV,
};
// 逐键覆盖:只覆盖来源里实际出现且非空的键,未出现的键沿用底层值
const applyConf = (target: any, source: any) => {
if (!source || typeof source !== "object") return;
Object.keys(source).forEach(key => {
if (source[key] !== undefined && source[key] !== null && source[key] !== "") {
(target as any)[key] = source[key];
}
});
};
// ② 本地开发/演示(build 产物中恒 undefined → 自动跳过)
applyConf(conf, {
baseHost: env.VITE_BASE_HOST,
ssoHost: env.VITE_SSO_HOST,
serviceHost: env.VITE_IS_MOCK === "true" ? "" : env.VITE_SERVICE_HOST,
flowHost: env.VITE_FLOW_HOST,
relayConf: {
ziyuankuHost: env.VITE_ZIYUANKU_HOST,
zhuanxiangHost: env.VITE_ZHUANXIANG_HOST,
},
isMock: env.VITE_IS_MOCK === "true",
isDebug: env.DEV,
};
});
// ① 线上运行时配置(最高优先,覆盖前面所有);relayConf 为嵌套对象,按子键合并,
// 运行时 JSON 只配部分资源库地址时未配的键沿用兜底值
const runtime = (window as any).__APP_CONFIG__;
applyConf(conf, runtime);
if (runtime && typeof runtime === "object" && runtime.relayConf) {
applyConf(conf.relayConf, runtime.relayConf);
}
// HTTPS 自动升级:当前页面是 https 时,将所有 http:// 配置项升级为 https://
var ishttps = "https:" == document.location.protocol ? true : false;
+2
View File
@@ -9,8 +9,10 @@ export function getMenuList(): Promise<IModule[]> {
{ id: 1, modId: 1, name: "已收到", page: "/" },
{ id: 2, modId: 2, name: "已发送", page: "/list" },
{ id: 3, modId: 3, name: "全部通知", page: "/noticelist" },
{ id: 89, modId: 89, name: "待审核", page: "/approval" },
{ id: 4, modId: 4, name: "通知统计", page: "/stats" },
{ id: 21, modId: 21, name: "应用设置", page: "/setting" },
{ id: 91, modId: 91, name: "抄送给我的", page: "/cc" },
];
return Promise.resolve(menu);
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path d="M859.977 336.128H608.251V171.489c0-41.159-33.318-74.836-74.039-74.836h-44.421c-40.729 0-74.044 33.677-74.044 74.836v164.639H164.023c-16.289 0-29.616 13.473-29.616 29.936v149.669c0 16.469 13.326 29.942 29.616 29.942h14.803v231.241c0 65.859-14.803 120.493-14.803 120.493 0 16.459 13.324 29.938 29.611 29.938h636.729c16.291 0 29.615-13.481 29.615-29.938 0 0-14.81-56.882-14.81-123.486V545.675h14.81c16.287 0 29.616-13.477 29.616-29.938V366.064c-0.001-16.469-13.323-29.936-29.617-29.936zM682.282 897.403V699.089c0-5.989-5.18-11.225-11.103-11.225h-7.407c-5.917 0-11.103 5.236-11.103 11.225v198.314H534.212V699.089c0-5.989-5.181-11.225-11.116-11.225H515.7c-5.918 0-11.106 5.236-11.106 11.225v198.314H378.731V699.089c0-5.989-5.186-11.225-11.111-11.225h-7.399c-5.918 0-11.106 5.236-11.106 11.225v198.314H193.634s14.812-53.884 14.812-120.487V545.675h607.113v231.241c0 66.606 14.804 120.487 14.804 120.487H682.282z m177.695-396.634c0 8.233-6.66 14.968-14.81 14.968H178.826c-8.145 0-14.807-6.735-14.807-14.968V381.028c0-8.235 6.662-14.964 14.807-14.964H445.36V178.971c0-29.184 22.956-52.382 51.835-52.382h29.61c28.876 0 51.828 23.199 51.828 52.382v187.093h266.535c8.15 0 14.81 6.729 14.81 14.964v119.741z"></path></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
<path fill="#4aaff0" d="M512,585 C415,455 330,398 215,395 C185,395 160,415 160,450 L160,770 C160,815 190,838 240,845 C340,856 430,872 512,888 C594,872 684,856 784,845 C834,838 864,815 864,770 L864,450 C864,415 839,395 809,395 C694,398 609,455 512,585 Z"/>
<path fill="#4aaff0" d="M218,312 C335,292 462,352 514,462 C452,404 334,354 218,378 Z"/>
<circle fill="#4aaff0" cx="218" cy="345" r="33"/>
<path fill="#80d976" d="M508,462 C455,310 525,145 700,105 C758,250 668,425 508,462 Z"/>
<path fill="none" stroke="#4aaff0" stroke-width="44" stroke-linecap="round" d="M210,935 Q285.5,881 361,935 T512,935 T663,935 T814,935"/>
</svg>

After

Width:  |  Height:  |  Size: 701 B

+71 -6
View File
@@ -1,7 +1,7 @@
/**
* 通知公告本地 Mock 数据(vite-plugin-mock,dev 下 VITE_IS_MOCK=true 生效)。
*
* 背景:本地无法访问 sso.yunhaixingtong.com / base.yunhaixingtong.com,
* 背景:本地无法访问真实环境的 SSO / 基础平台域名,
* 后端 CAS 过滤器会拦截 /api/* 请求,导致前端无法联调。
* 该文件按后端 NoticeApiController / DictController / ContactsController /
* FileController 的真实响应契约({code, msg, data})提供内存数据,
@@ -252,6 +252,33 @@ function fileUrl(id: number) {
/* ============================ Mock 定义 ============================ */
/* ---------- 我的待审核(flow-center 代理)内存数据(刷新/改本文件即重置为初始 6 条) ---------- */
const approvalTodos = [
{ taskId: 5001, instanceId: 8001, instanceVersion: 1, bizType: "notice", bizId: "1", bizTitle: "关于开展2026年秋季学期教学检查的通知", bizSnapshot: JSON.stringify({ summary: "定于下周开展秋季学期教学检查,请各教研组提前准备教案、听课记录。" }), nodeId: "n_dept", nodeName: "部门审核", assigneeId: 186532, assigneeName: "朱皓东", arriveTime: "2026-09-23T09:12:00" },
{ taskId: 5002, instanceId: 8002, instanceVersion: 1, bizType: "notice", bizId: "2", bizTitle: "校园网络安全培训通知", bizSnapshot: JSON.stringify({ summary: "本周五下午 15:00 在报告厅开展网络安全培训,请全体教职工参加。" }), nodeId: "n_school", nodeName: "校级审核", assigneeId: 186532, assigneeName: "朱皓东", arriveTime: "2026-09-23T10:40:00" },
{ taskId: 5003, instanceId: 8003, instanceVersion: 2, bizType: "notice", bizId: "5", bizTitle: "期中考试考务工作安排", bizSnapshot: JSON.stringify({ summary: "期中考试定于第 10 周,请各年级上报监考安排。" }), nodeId: "n_dept", nodeName: "部门审核", assigneeId: 186532, assigneeName: "朱皓东", arriveTime: "2026-09-22T15:05:00" },
{ taskId: 5004, instanceId: 8004, instanceVersion: 1, bizType: "notice", bizId: "7", bizTitle: "教师职称评审材料公示", bizSnapshot: JSON.stringify({ summary: "本年度职称评审材料已汇总,公示期 5 个工作日。" }), nodeId: "n_office", nodeName: "办公室复核", assigneeId: 186532, assigneeName: "朱皓东", arriveTime: "2026-09-22T08:30:00" },
{ taskId: 5005, instanceId: 8005, instanceVersion: 1, bizType: "notice", bizId: "9", bizTitle: "2027 年寒假放假通知", bizSnapshot: JSON.stringify({ summary: "寒假自 1 月 20 日起,下学期 2 月 15 日报到。" }), nodeId: "n_dept", nodeName: "部门审核", assigneeId: 186532, assigneeName: "朱皓东", arriveTime: "2026-09-21T16:20:00" },
{ taskId: 5006, instanceId: 8006, instanceVersion: 1, bizType: "notice", bizId: "12", bizTitle: "校园运动会报名通知", bizSnapshot: JSON.stringify({ summary: "秋季运动会报名截止本周五,请各班级汇总上报。" }), nodeId: "n_school", nodeName: "校级审核", assigneeId: 186532, assigneeName: "朱皓东", arriveTime: "2026-09-21T11:00:00" },
];
const approvalInstances = [
{ instanceId: 8001, status: 10, version: 1, bizType: "notice", bizId: "1", bizTitle: "关于开展2026年秋季学期教学检查的通知", bizUrl: null, submitterName: "刘文娟", submitTime: "2026-09-20T09:00:00", finishTime: null, currentNodes: "部门审核" },
{ instanceId: 8002, status: 20, version: 1, bizType: "notice", bizId: "2", bizTitle: "校园网络安全培训通知", bizUrl: null, submitterName: "黄瑶", submitTime: "2026-09-19T10:00:00", finishTime: "2026-09-21T14:00:00", currentNodes: "" },
{ instanceId: 8003, status: 30, version: 2, bizType: "notice", bizId: "5", bizTitle: "期中考试考务工作安排", bizUrl: null, submitterName: "张明", submitTime: "2026-09-18T08:30:00", finishTime: "2026-09-19T09:10:00", currentNodes: "" },
{ instanceId: 8004, status: 40, version: 1, bizType: "notice", bizId: "7", bizTitle: "教师职称评审材料公示", bizUrl: null, submitterName: "李强", submitTime: "2026-09-17T16:00:00", finishTime: "2026-09-17T16:30:00", currentNodes: "" },
{ instanceId: 8005, status: 10, version: 1, bizType: "notice", bizId: "9", bizTitle: "2027 年寒假放假通知", bizUrl: null, submitterName: "王芳", submitTime: "2026-09-16T09:00:00", finishTime: null, currentNodes: "校级审核" },
{ instanceId: 8006, status: 20, version: 1, bizType: "notice", bizId: "12", bizTitle: "校园运动会报名通知", bizUrl: null, submitterName: "陈垠", submitTime: "2026-09-15T10:00:00", finishTime: "2026-09-16T11:00:00", currentNodes: "" },
];
/** 组装 flow-center BasePageResult{total,content,pageIndex,pageSize,totalPages,hasNext} */
function approvalPage<T>(all: T[], query: any) {
const pageIndex = Number(query?.page || 1);
const pageSize = Number(query?.size || 10);
const start = (pageIndex - 1) * pageSize;
const total = all.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
return { total, content: all.slice(start, start + pageSize), pageIndex, pageSize, totalPages, hasNext: pageIndex < totalPages };
}
export default [
/* ---------- 布局 / 用户 ---------- */
{
@@ -271,11 +298,15 @@ export default [
method: "get",
response: () =>
ok([
{ id: 1, modId: 1, name: "已收到", parentId: 0, page: "/", orderNumber: 1, usable: true, children: [] },
{ id: 2, modId: 2, name: "已发送", parentId: 0, page: "/list", orderNumber: 2, usable: true, children: [] },
{ id: 3, modId: 3, name: "全部通知", parentId: 0, page: "/noticelist", orderNumber: 3, usable: true, children: [] },
{ id: 4, modId: 4, name: "通知统计", parentId: 0, page: "/stats", orderNumber: 4, usable: true, children: [] },
{ id: 21, modId: 21, name: "应用设置", parentId: 0, page: "/setting", orderNumber: 5, usable: true, children: [] },
// id/modId 对齐平台模块管理的「配置 ID」(对照 /base/GetUserMod 现行数据);
// icon:YschIcons 图标名,layout 渲染为 iconfont icon-<名称>
{ id: 1, modId: 1, name: "已收到", parentId: 0, page: "/", icon: "sjx", orderNumber: 1, usable: true, children: [] },
{ id: 2, modId: 2, name: "已发送", parentId: 0, page: "/list", icon: "fasong", orderNumber: 2, usable: true, children: [] },
{ id: 3, modId: 3, name: "全部通知", parentId: 0, page: "/noticelist", icon: "liebiao", orderNumber: 3, usable: true, children: [] },
{ id: 89, modId: 89, name: "待审核", parentId: 0, page: "/approval", icon: "shenpi", orderNumber: 4, usable: true, children: [] },
{ id: 4, modId: 4, name: "通知统计", parentId: 0, page: "/stats", icon: "tongji", orderNumber: 5, usable: true, children: [] },
{ id: 21, modId: 21, name: "应用设置", parentId: 0, page: "/setting", icon: "shezhi", orderNumber: 6, usable: true, children: [] },
{ id: 91, modId: 91, name: "抄送给我的", parentId: 0, page: "/cc", icon: "", orderNumber: 7, usable: true, children: [] },
]),
},
{
@@ -721,4 +752,38 @@ export default [
}))
),
},
/* ---------- 我的待审核(NoticeApproval → /api/notice/approval/**) ---------- */
{
url: "/api/notice/approval/Todo",
method: "get",
response: ({ query }: any) => ok(approvalPage(approvalTodos, query)),
},
{
url: "/api/notice/approval/Instances",
method: "get",
response: ({ query }: any) => {
const status = Number(query?.status || 0);
const list = status ? approvalInstances.filter((i) => i.status === status) : approvalInstances;
return ok(approvalPage(list, query));
},
},
{
url: "/api/notice/approval/Approve",
method: "post",
response: ({ query }: any) => {
const idx = approvalTodos.findIndex((t) => t.taskId === Number(query?.taskId));
if (idx >= 0) approvalTodos.splice(idx, 1);
return ok(null);
},
},
{
url: "/api/notice/approval/Reject",
method: "post",
response: ({ query }: any) => {
const idx = approvalTodos.findIndex((t) => t.taskId === Number(query?.taskId));
if (idx >= 0) approvalTodos.splice(idx, 1);
return ok(null);
},
},
];
-110
View File
@@ -1,110 +0,0 @@
/*
* 规范 iconfont 图标本地实现(bsch 应用页规范 §2 操作列 / 布局头尾图标)。
* 平台 base.layout.beta 主题不含 iconfont 字体文件,且新旧 BschHome 均已不再提供
* icon-browse 一类的字形,这里以 SVG mask + currentColor 实现同名类:
* 颜色跟随文字色,天然支持 .operating-btn:hover 圆形反色效果。
* 后续若接入平台真实 iconfont,可整文件移除。
*/
.iconfont {
font-family: inherit;
font-style: normal;
font-size: 16px;
line-height: 1;
display: inline-block;
vertical-align: middle;
}
.iconfont::before {
content: "" !important;
display: block;
width: 1em;
height: 1em;
background-color: currentColor;
-webkit-mask-position: center;
mask-position: center;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: contain;
mask-size: contain;
}
.icon-browse::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M512 256C299 256 121 361 32 512c89 151 267 256 480 256s391-105 480-256c-89-151-267-256-480-256zm0 416c-88 0-160-72-160-160s72-160 160-160 160 72 160 160-72 160-160 160z'/%3E%3Ccircle cx='512' cy='512' r='80'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M512 256C299 256 121 361 32 512c89 151 267 256 480 256s391-105 480-256c-89-151-267-256-480-256zm0 416c-88 0-160-72-160-160s72-160 160-160 160 72 160 160-72 160-160 160z'/%3E%3Ccircle cx='512' cy='512' r='80'/%3E%3C/svg%3E");
}
.icon-edit::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M768 128c18 0 35 7 49 21l58 58c27 27 27 71 0 98L448 833l-200 54c-16 4-31-11-27-27l54-200L803 149c14-14 31-21 49-21zM128 896h768v48H128z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M768 128c18 0 35 7 49 21l58 58c27 27 27 71 0 98L448 833l-200 54c-16 4-31-11-27-27l54-200L803 149c14-14 31-21 49-21zM128 896h768v48H128z'/%3E%3C/svg%3E");
}
.icon-ashbin::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M416 96h192l32 64h160c18 0 32 14 32 32s-14 32-32 32H224c-18 0-32-14-32-32s14-32 32-32h160l32-64zM224 288h576l-36 520c-3 46-41 82-88 82H348c-47 0-85-36-88-82l-36-520zm176 64c-18 0-32 14-32 32v352c0 18 14 32 32 32s32-14 32-32V384c0-18-14-32-32-32zm224 0c-18 0-32 14-32 32v352c0 18 14 32 32 32s32-14 32-32V384c0-18-14-32-32-32z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M416 96h192l32 64h160c18 0 32 14 32 32s-14 32-32 32H224c-18 0-32-14-32-32s14-32 32-32h160l32-64zM224 288h576l-36 520c-3 46-41 82-88 82H348c-47 0-85-36-88-82l-36-520zm176 64c-18 0-32 14-32 32v352c0 18 14 32 32 32s32-14 32-32V384c0-18-14-32-32-32zm224 0c-18 0-32 14-32 32v352c0 18 14 32 32 32s32-14 32-32V384c0-18-14-32-32-32z'/%3E%3C/svg%3E");
}
.icon-user-filling::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 128a176 176 0 1 1 0 352 176 176 0 0 1 0-352zm0 416c190 0 320 104 320 224v32c0 26-21 48-48 48H240c-27 0-48-22-48-48v-32c0-120 130-224 320-224z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 128a176 176 0 1 1 0 352 176 176 0 0 1 0-352zm0 416c190 0 320 104 320 224v32c0 26-21 48-48 48H240c-27 0-48-22-48-48v-32c0-120 130-224 320-224z'/%3E%3C/svg%3E");
}
.icon-attachment::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M640 64H256c-35 0-64 29-64 64v768c0 35 29 64 64 64h512c35 0 64-29 64-64V256L640 64zm-16 112l144 144h-144V176zM320 448h384v48H320v-48zm0 128h384v48H320v-48zm0 128h384v48H320v-48z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath fill-rule='evenodd' d='M640 64H256c-35 0-64 29-64 64v768c0 35 29 64 64 64h512c35 0 64-29 64-64V256L640 64zm-16 112l144 144h-144V176zM320 448h384v48H320v-48zm0 128h384v48H320v-48zm0 128h384v48H320v-48z'/%3E%3C/svg%3E");
}
.icon-apps::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M224 128h224c18 0 32 14 32 32v224c0 18-14 32-32 32H224c-18 0-32-14-32-32V160c0-18 14-32 32-32zm352 0h224c18 0 32 14 32 32v224c0 18-14 32-32 32H576c-18 0-32-14-32-32V160c0-18 14-32 32-32zM224 480h224c18 0 32 14 32 32v224c0 18-14 32-32 32H224c-18 0-32-14-32-32V512c0-18 14-32 32-32zm352 0h224c18 0 32 14 32 32v224c0 18-14 32-32 32H576c-18 0-32-14-32-32V512c0-18 14-32 32-32z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M224 128h224c18 0 32 14 32 32v224c0 18-14 32-32 32H224c-18 0-32-14-32-32V160c0-18 14-32 32-32zm352 0h224c18 0 32 14 32 32v224c0 18-14 32-32 32H576c-18 0-32-14-32-32V160c0-18 14-32 32-32zM224 480h224c18 0 32 14 32 32v224c0 18-14 32-32 32H224c-18 0-32-14-32-32V512c0-18 14-32 32-32zm352 0h224c18 0 32 14 32 32v224c0 18-14 32-32 32H576c-18 0-32-14-32-32V512c0-18 14-32 32-32z'/%3E%3C/svg%3E");
}
.icon-logout::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M544 96c18 0 32 14 32 32v96h-64v-64H256v704h256v-64h64v96c0 18-14 32-32 32H224c-18 0-32-14-32-32V128c0-18 14-32 32-32h320zm208 416L600 664l-45-45 75-75H448v-64h182l-75-75 45-45 152 152z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M544 96c18 0 32 14 32 32v96h-64v-64H256v704h256v-64h64v96c0 18-14 32-32 32H224c-18 0-32-14-32-32V128c0-18 14-32 32-32h320zm208 416L600 664l-45-45 75-75H448v-64h182l-75-75 45-45 152 152z'/%3E%3C/svg%3E");
}
.icon-category::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 96l448 384h-112v352c0 18-14 32-32 32H608V608H416v256H208c-18 0-32-14-32-32V480H64l448-384z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 96l448 384h-112v352c0 18-14 32-32 32H608V608H416v256H208c-18 0-32-14-32-32V480H64l448-384z'/%3E%3C/svg%3E");
}
/* 停用(圆圈斜杠),字形取自 bsch iconfont icon-stop(\e6e0) */
.icon-stop::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 74.666667C270.933333 74.666667 74.666667 270.933333 74.666667 512S270.933333 949.333333 512 949.333333 949.333333 753.066667 949.333333 512 753.066667 74.666667 512 74.666667zM885.333333 512c0 85.333333-29.866667 164.266667-78.933333 228.266667l-533.333333-514.133334c64-55.466667 149.333333-87.466667 238.933333-87.466666 204.8 0 373.333333 168.533333 373.333333 373.333333z m-746.666666 0c0-91.733333 34.133333-174.933333 87.466666-241.066667l535.466667 516.266667c-66.133333 59.733333-153.6 98.133333-251.733333 98.133333-202.666667 0-371.2-168.533333-371.2-373.333333z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M512 74.666667C270.933333 74.666667 74.666667 270.933333 74.666667 512S270.933333 949.333333 512 949.333333 949.333333 753.066667 949.333333 512 753.066667 74.666667 512 74.666667zM885.333333 512c0 85.333333-29.866667 164.266667-78.933333 228.266667l-533.333333-514.133334c64-55.466667 149.333333-87.466667 238.933333-87.466666 204.8 0 373.333333 168.533333 373.333333 373.333333z m-746.666666 0c0-91.733333 34.133333-174.933333 87.466666-241.066667l535.466667 516.266667c-66.133333 59.733333-153.6 98.133333-251.733333 98.133333-202.666667 0-371.2-168.533333-371.2-373.333333z'/%3E%3C/svg%3E");
}
/* ============ 通知模块图标(2026-08-31 与 oldfront 对齐,仅保留用到的) ============
* 附件类型字形(word/excel/ppt/pdf1/txt/tupian1/yasuo/weizhi)取自 oldfront/src/assets/js/iconfont/iconfont.js 同名 symbol,
* icon-saoba(一键已读)/icon-print(打印)平台原字体已失传,按同规格重绘。
*/
.icon-saoba::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M832 64L960 192 640 512 512 384 832 64zM512 384L640 512C560 704 448 848 256 960 192 992 128 976 96 928 64 880 80 816 128 768 256 608 384 480 512 384z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M832 64L960 192 640 512 512 384 832 64zM512 384L640 512C560 704 448 848 256 960 192 992 128 976 96 928 64 880 80 816 128 768 256 608 384 480 512 384z'/%3E%3C/svg%3E");
}
.icon-print::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M352 64h320c18 0 32 14 32 32v160H320V96c0-18 14-32 32-32zM224 320h576c35 0 64 29 64 64v224c0 35-29 64-64 64h-96v-96c0-18-14-32-32-32H384c-18 0-32 14-32 32v96h-96c-35 0-64-29-64-64V384c0-35 29-64 64-64zM352 512h96v64c0 18 14 32 32 32h64c18 0 32-14 32-32v-64h96v352c0 18-14 32-32 32H352c-18 0-32-14-32-32V544c0-18 14-32 32-32z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M352 64h320c18 0 32 14 32 32v160H320V96c0-18 14-32 32-32zM224 320h576c35 0 64 29 64 64v224c0 35-29 64-64 64h-96v-96c0-18-14-32-32-32H384c-18 0-32 14-32 32v96h-96c-35 0-64-29-64-64V384c0-35 29-64 64-64zM352 512h96v64c0 18 14 32 32 32h64c18 0 32-14 32-32v-64h96v352c0 18-14 32-32 32H352c-18 0-32-14-32-32V544c0-18 14-32 32-32z'/%3E%3C/svg%3E");
}
.icon-word::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M234.496 106.496h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H234.496c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M644.096 106.496l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V106.496z M329.728 428.032l81.158144 282.95168h48.254976l54.833152-212.762624h2.195456l54.833152 212.762624h48.254976L700.416 428.032h-52.641792l-52.641792 214.953984h-2.195456L538.103808 428.032h-48.254976l-52.641792 214.953984h-2.195456L382.369792 428.032z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M234.496 106.496h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H234.496c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M644.096 106.496l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V106.496z M329.728 428.032l81.158144 282.95168h48.254976l54.833152-212.762624h2.195456l54.833152 212.762624h48.254976L700.416 428.032h-52.641792l-52.641792 214.953984h-2.195456L538.103808 428.032h-48.254976l-52.641792 214.953984h-2.195456L382.369792 428.032z'/%3E%3C/svg%3E");
}
.icon-excel::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M246.784 102.4h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H246.784c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M438.9888 716.8l91.725824-131.657728L622.915584 716.8h43.708416l-114.704384-159.62112L658.366464 409.6h-43.708416l-83.943424 119.607296L447.1808 409.6H403.488768l105.590784 147.57888L395.264 716.8h43.7248z M656.384 102.4l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V102.4z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M246.784 102.4h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H246.784c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M438.9888 716.8l91.725824-131.657728L622.915584 716.8h43.708416l-114.704384-159.62112L658.366464 409.6h-43.708416l-83.943424 119.607296L447.1808 409.6H403.488768l105.590784 147.57888L395.264 716.8h43.7248z M656.384 102.4l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V102.4z'/%3E%3C/svg%3E");
}
.icon-ppt::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M234.496 106.496h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H234.496c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M428.335104 745.472v-125.636608h89.501696c70.234112 0 105.7792-30.547968 105.7792-91.213824 0-60.235776-35.119104-90.353664-105.34912-90.353664H393.216v307.2l35.119104 0.004096z m87.793664-156.18048h-87.793664v-120.471552h87.793664c24.408064 0 42.3936 4.734976 54.382592 15.065088 11.993088 9.03168 17.985536 24.088576 17.985536 44.7488 0 20.647936-5.988352 35.704832-17.563648 45.604864-11.984896 9.89184-29.970432 15.0528-54.808576 15.0528h0.004096z M644.096 106.496l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V106.496z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M234.496 106.496h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H234.496c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M428.335104 745.472v-125.636608h89.501696c70.234112 0 105.7792-30.547968 105.7792-91.213824 0-60.235776-35.119104-90.353664-105.34912-90.353664H393.216v307.2l35.119104 0.004096z m87.793664-156.18048h-87.793664v-120.471552h87.793664c24.408064 0 42.3936 4.734976 54.382592 15.065088 11.993088 9.03168 17.985536 24.088576 17.985536 44.7488 0 20.647936-5.988352 35.704832-17.563648 45.604864-11.984896 9.89184-29.970432 15.0528-54.808576 15.0528h0.004096z M644.096 106.496l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V106.496z'/%3E%3C/svg%3E");
}
.icon-pdf1::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M230.4 122.88h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H230.4c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M645.042176 674.586624c-27.643904-2.052096-54.255616-12.316672-75.75552-30.797824-41.971712 9.23648-81.903616 22.581248-121.831424 39.010304-31.735808 56.459264-61.423616 85.200896-87.01952 85.200896-5.12 0-11.259904-1.024-15.355904-4.108288-11.264-5.128192-17.403904-16.42496-17.403904-27.713536 0-9.240576 2.048-34.902016 99.303424-76.996608 22.528-41.0624 39.923712-83.1488 54.259712-127.287296-12.288-24.63744-38.899712-85.204992-20.48-115.99872 6.144-11.292672 18.432-17.453056 31.744-16.42496 10.235904 0 20.471808 5.132288 26.615808 13.340672 13.312 18.477056 12.288 57.48736-5.12 114.97472a309.497856 309.497856 0 0 0 63.471616 82.1248c21.504-4.112384 42.995712-7.18848 64.495616-7.18848 48.115712 1.024 55.283712 23.609344 54.259712 36.958208 0.008192 34.906112-33.779712 34.906112-51.183616 34.906112z m-286.646272 63.639552l3.072-1.024c14.331904-5.128192 25.587712-15.396864 33.779712-28.745728-15.36 6.160384-27.643904 16.42496-36.851712 29.773824v-0.004096z m136.155136-307.95776h-3.072c-1.024 0-3.072 0-4.096 1.024-4.096 17.44896-1.024 35.926016 6.144 52.350976 6.144-17.444864 6.144-35.926016 1.024-53.374976z m7.168 148.84864l-1.024 2.048-1.024-1.024c-9.216 23.61344-19.456 47.222784-30.715904 69.808128l2.048-1.024v2.048a594.771968 594.771968 0 0 1 69.615616-20.529152l-1.024-1.024h3.072c-15.355904-15.405056-29.687808-32.854016-40.947712-50.302976z m139.227136 54.407168c-9.216 0-17.403904 0-26.619904 2.048 10.24 5.136384 20.48 7.18848 30.715904 8.216576 7.168 1.024 14.336 0 20.475904-2.052096 0-3.084288-4.096-8.21248-24.571904-8.21248z M640 122.88l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V122.88z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M230.4 122.88h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H230.4c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M645.042176 674.586624c-27.643904-2.052096-54.255616-12.316672-75.75552-30.797824-41.971712 9.23648-81.903616 22.581248-121.831424 39.010304-31.735808 56.459264-61.423616 85.200896-87.01952 85.200896-5.12 0-11.259904-1.024-15.355904-4.108288-11.264-5.128192-17.403904-16.42496-17.403904-27.713536 0-9.240576 2.048-34.902016 99.303424-76.996608 22.528-41.0624 39.923712-83.1488 54.259712-127.287296-12.288-24.63744-38.899712-85.204992-20.48-115.99872 6.144-11.292672 18.432-17.453056 31.744-16.42496 10.235904 0 20.471808 5.132288 26.615808 13.340672 13.312 18.477056 12.288 57.48736-5.12 114.97472a309.497856 309.497856 0 0 0 63.471616 82.1248c21.504-4.112384 42.995712-7.18848 64.495616-7.18848 48.115712 1.024 55.283712 23.609344 54.259712 36.958208 0.008192 34.906112-33.779712 34.906112-51.183616 34.906112z m-286.646272 63.639552l3.072-1.024c14.331904-5.128192 25.587712-15.396864 33.779712-28.745728-15.36 6.160384-27.643904 16.42496-36.851712 29.773824v-0.004096z m136.155136-307.95776h-3.072c-1.024 0-3.072 0-4.096 1.024-4.096 17.44896-1.024 35.926016 6.144 52.350976 6.144-17.444864 6.144-35.926016 1.024-53.374976z m7.168 148.84864l-1.024 2.048-1.024-1.024c-9.216 23.61344-19.456 47.222784-30.715904 69.808128l2.048-1.024v2.048a594.771968 594.771968 0 0 1 69.615616-20.529152l-1.024-1.024h3.072c-15.355904-15.405056-29.687808-32.854016-40.947712-50.302976z m139.227136 54.407168c-9.216 0-17.403904 0-26.619904 2.048 10.24 5.136384 20.48 7.18848 30.715904 8.216576 7.168 1.024 14.336 0 20.475904-2.052096 0-3.084288-4.096-8.21248-24.571904-8.21248z M640 122.88l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V122.88z'/%3E%3C/svg%3E");
}
.icon-txt::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M238.592 126.976h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H238.592c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M540.11904 741.376v-276.652032h108.07296V434.176H397.312v30.547968h108.07296V741.376h34.73408z M648.192 126.976l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V126.976z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M238.592 126.976h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H238.592c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M540.11904 741.376v-276.652032h108.07296V434.176H397.312v30.547968h108.07296V741.376h34.73408z M648.192 126.976l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V126.976z'/%3E%3C/svg%3E");
}
.icon-tupian1::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M250.88 122.88h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H250.88c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M329.703424 716.53376l74.645504-109.71136a11.603968 11.603968 0 0 1 17.73568-1.748992l49.332224 48.3328 108.2368-169.652224a11.599872 11.599872 0 0 1 19.84512 0.446464l134.483968 233.0624a11.599872 11.599872 0 0 1-10.05568 17.412096H339.304448a11.599872 11.599872 0 0 1-9.60512-18.14528l0.004096 0.004096z M352.055296 468.115456c-0.004096 32.31744 26.189824 58.519552 58.507264 58.523648 32.31744 0.004096 58.519552-26.189824 58.523648-58.507264v-0.016384c0.004096-32.31744-26.189824-58.519552-58.507264-58.523648s-58.519552 26.189824-58.523648 58.507264v0.016384z M660.48 122.88l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V122.88z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M250.88 122.88h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H250.88c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M329.703424 716.53376l74.645504-109.71136a11.603968 11.603968 0 0 1 17.73568-1.748992l49.332224 48.3328 108.2368-169.652224a11.599872 11.599872 0 0 1 19.84512 0.446464l134.483968 233.0624a11.599872 11.599872 0 0 1-10.05568 17.412096H339.304448a11.599872 11.599872 0 0 1-9.60512-18.14528l0.004096 0.004096z M352.055296 468.115456c-0.004096 32.31744 26.189824 58.519552 58.507264 58.523648 32.31744 0.004096 58.519552-26.189824 58.523648-58.507264v-0.016384c0.004096-32.31744-26.189824-58.519552-58.507264-58.523648s-58.519552 26.189824-58.523648 58.507264v0.016384z M660.48 122.88l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V122.88z'/%3E%3C/svg%3E");
}
.icon-yasuo::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M250.88 126.976h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H250.88c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M588.8 630.378496v111.24736a27.783168 27.783168 0 0 1-8.167424 19.664896 27.89376 27.89376 0 0 1-19.70176 8.146944h-56.905728a27.89376 27.89376 0 0 1-19.70176-8.142848 27.795456 27.795456 0 0 1-8.167424-19.677184V630.3744h112.644096z m-27.869184 63.73376h-56.905728v55.857152h56.905728v-55.857152z m26.939392-174.98112v55.857152h-55.967744v-55.857152h55.967744zM532.598784 126.976v56.32h56.201216V239.616h-56.209408v52.146176h56.209408v56.32h-56.201216v55.627776h56.201216v55.627776h-56.201216v55.62368H476.16v-55.627776h56.201216V403.705856H476.16v-55.62368h56.201216v-56.32H476.16v-56.32h56.201216V183.296H476.16V126.976h56.438784z M660.48 126.976l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V126.976z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M250.88 126.976h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H250.88c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M588.8 630.378496v111.24736a27.783168 27.783168 0 0 1-8.167424 19.664896 27.89376 27.89376 0 0 1-19.70176 8.146944h-56.905728a27.89376 27.89376 0 0 1-19.70176-8.142848 27.795456 27.795456 0 0 1-8.167424-19.677184V630.3744h112.644096z m-27.869184 63.73376h-56.905728v55.857152h56.905728v-55.857152z m26.939392-174.98112v55.857152h-55.967744v-55.857152h55.967744zM532.598784 126.976v56.32h56.201216V239.616h-56.209408v52.146176h56.209408v56.32h-56.201216v55.627776h56.201216v55.627776h-56.201216v55.62368H476.16v-55.627776h56.201216V403.705856H476.16v-55.62368h56.201216v-56.32H476.16v-56.32h56.201216V183.296H476.16V126.976h56.438784z M660.48 126.976l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V126.976z'/%3E%3C/svg%3E");
}
.icon-weizhi::before {
-webkit-mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M242.688 102.4h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H242.688c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M652.288 102.4l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V102.4z M421.888 501.76c0-20.48 4.096-36.864 8.192-47.104s12.288-20.48 22.528-28.672c10.24-10.24 20.48-16.384 34.816-20.48 12.288-4.096 24.576-6.144 38.912-6.144 28.672 0 51.2 8.192 71.68 24.576 18.432 16.384 28.672 36.864 28.672 63.488 0 12.288-4.096 22.528-8.192 32.768s-14.336 22.528-30.72 36.864-22.528 24.576-28.672 30.72-8.192 14.336-12.288 22.528-2.048 14.336-2.048 34.816h-40.96c0-20.48 2.048-30.72 4.096-40.96s6.144-18.432 12.288-26.624 12.288-18.432 26.624-32.768 24.576-24.576 28.672-30.72 8.192-16.384 8.192-30.72-6.144-24.576-16.384-34.816-24.576-16.384-43.008-16.384c-40.96 0-61.44 28.672-61.44 69.632h-40.96z m122.88 225.28h-40.96v-40.96h40.96v40.96z'/%3E%3C/svg%3E");
mask-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 1024 1024'%3E%3Cpath d='M242.688 102.4h409.6l204.8 204.8v563.2c0 28.278784-22.921216 51.2-51.2 51.2H242.688c-28.278784 0-51.2-22.921216-51.2-51.2v-716.8c0-28.278784 22.921216-51.2 51.2-51.2z M652.288 102.4l204.8 204.8h-153.6c-28.278784 0-51.2-22.921216-51.2-51.2V102.4z M421.888 501.76c0-20.48 4.096-36.864 8.192-47.104s12.288-20.48 22.528-28.672c10.24-10.24 20.48-16.384 34.816-20.48 12.288-4.096 24.576-6.144 38.912-6.144 28.672 0 51.2 8.192 71.68 24.576 18.432 16.384 28.672 36.864 28.672 63.488 0 12.288-4.096 22.528-8.192 32.768s-14.336 22.528-30.72 36.864-22.528 24.576-28.672 30.72-8.192 14.336-12.288 22.528-2.048 14.336-2.048 34.816h-40.96c0-20.48 2.048-30.72 4.096-40.96s6.144-18.432 12.288-26.624 12.288-18.432 26.624-32.768 24.576-24.576 28.672-30.72 8.192-16.384 8.192-30.72-6.144-24.576-16.384-34.816-24.576-16.384-43.008-16.384c-40.96 0-61.44 28.672-61.44 69.632h-40.96z m122.88 225.28h-40.96v-40.96h40.96v40.96z'/%3E%3C/svg%3E");
}
File diff suppressed because one or more lines are too long
+19 -6
View File
@@ -27,7 +27,7 @@
content: "";
display: block;
width: 3px;
background: #5979ea;
background: var(--brand, #577afb);
height: 15px;
border-radius: 4px;
position: absolute;
@@ -39,7 +39,7 @@
h4.second-title {
@extend .base-subtitle;
&::before {
background: #5979ea;
background: var(--brand, #577afb);
}
}
@@ -62,7 +62,7 @@ h4.subtitle {
overflow-y: auto;
li {
height: 42px;
border: 1px solid #ebeef5;
border: 1px solid var(--line, #e9edf5);
border-top: none;
position: relative;
cursor: pointer;
@@ -87,8 +87,8 @@ h4.subtitle {
}
li.active {
// background: #f3f3f3;
color: #7892ed;
border-right: 3px solid #5979ea;
color: var(--brand-2, #7b8ffd);
border-right: 3px solid var(--brand, #577afb);
}
}
}
@@ -107,7 +107,7 @@ h4.subtitle {
background-position: top center;
background-repeat: no-repeat;
border: none;
color: #577afb;
color: var(--brand, #577afb);
margin: 0 auto;
}
@@ -115,3 +115,16 @@ h4.subtitle {
margin-right: 4px;
margin-bottom: 2px;
}
/* 修复表格幻影省略号(2026-10-10):.ivu-table-cell 基础样式自带 text-overflow:ellipsis,
tooltip 列(.ivu-tooltip 行内块包装 + width:100%)与操作列(图标外包 Tooltip)未溢出也常显「...」。
恢复组件预期的块级/flex 渲染层级即可;内层 .ivu-table-cell-tooltip-content 的真省略号与
悬浮 tooltip 不受影响(已在 dev:mock 实测:短内容无省略号、长标题正常截断+悬浮提示)。 */
/* 两条同为 3 类特异性(0,3,0):后者居后胜出 → tooltip 列(.ivu-table-cell-tooltip)最终 flex
(组件预期布局),操作列裸 .ivu-tooltip 包装取 block。 */
.ivu-table .ivu-table-cell > .ivu-tooltip {
display: block;
}
.ivu-table .ivu-table-cell > .ivu-table-cell-tooltip {
display: flex;
}
+1 -1
View File
@@ -14,7 +14,7 @@
color: $color-warning !important;
}
.text-primary {
color: $color-primary !important;
color: var(--brand, #577afb) !important;
}
.text-normal {
color: #404042 !important;
+2 -2
View File
@@ -1,10 +1,10 @@
/*scss变量*/
// 主题色
// 主题色(品牌主色不在此定义:由平台主题令牌 var(--brand) 经中央静态通道下发,
// 本文件只留业务语义色——成功/警告/错误不随主题档位变化)
$color-error: #e8684a;
$color-success: #45b972;
$color-warning: #e9b954;
$color-primary: #5979ea;
// 文字色
$color-black: #404042;
+34 -19
View File
@@ -1,22 +1,24 @@
<template>
<div class="notice-upload">
<!-- 自定义上传附件(自旧前端 oldfront/src/components/uploadFile.vue 适配) -->
<Upload
:on-success="encloSuccess"
:on-remove="encloRemove"
:default-file-list="defaultList"
:action="uploadFileUrl"
:accept="fileType"
:multiple="isMultiple"
:before-upload="handleUpload"
:format="formatArr"
:max-size="maxSize"
:on-format-error="handleFormatError"
:on-exceeded-size="handleExceeded"
>
<Button><Icon type="ios-cloud-upload-outline"></Icon>选择文件</Button>
</Upload>
<span class="upload-tip">提示:附件大小不超过{{ maxFileSize }}</span>
<div class="upload-row">
<Upload
:on-success="encloSuccess"
:on-remove="encloRemove"
:default-file-list="defaultList"
:action="uploadFileUrl"
:accept="fileType"
:multiple="isMultiple"
:before-upload="handleUpload"
:format="formatArr"
:max-size="maxSize"
:on-format-error="handleFormatError"
:on-exceeded-size="handleExceeded"
>
<Button><Icon type="ios-cloud-upload-outline"></Icon>选择文件</Button>
</Upload>
<span class="upload-tip">提示:附件大小不超过{{ maxFileSize }}</span>
</div>
</div>
</template>
@@ -87,17 +89,30 @@ export default {
<style lang="scss" scoped>
.notice-upload {
display: flex;
align-items: center;
display: block;
.upload-row {
display: flex;
align-items: flex-start; /* 提示语与按钮首行对齐(列表换行后不再垂直居中整块) */
}
:deep(.ivu-upload) {
display: inline-flex;
flex-wrap: wrap; /* 允许内部列表换行 */
align-items: center;
}
/* 文件列表整体另起一行,每项独占一行,不再与按钮挤在一起 */
:deep(.ivu-upload-list) {
display: block;
width: 100%; /* 文件列表整体独占下一行 */
margin-top: 4px;
}
:deep(.ivu-upload-list-file) {
padding: 4px 0;
}
.upload-tip {
margin-left: 10px;
color: #f90;
font-size: 12px;
line-height: 1;
line-height: 32px; /* 与默认 Button 等高,紧贴按钮右侧 */
}
}
</style>
+16 -11
View File
@@ -1,13 +1,15 @@
<template>
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
class="link-table table-bottom-box" @on-selection-change="selectChange">
<template #loading>
<div class="table-spin-load">加载中</div>
</template>
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</Table>
<div class="table-region" ref="regionRef">
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
class="link-table table-bottom-box" @on-selection-change="selectChange">
<template #loading>
<div class="table-spin-load">加载中</div>
</template>
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
</template>
</Table>
</div>
<div class="page-panel">
<div class="fl ml-15 align-middle">
@@ -22,7 +24,7 @@
<script setup lang="ts">
import { computed, ref, useSlots } from 'vue'
import Utils from '@/utils'
import { useElementHeight } from '@/utils/hook/useElementHeight'
import { OperatorEnum } from '@/types'
interface Props {
@@ -44,7 +46,10 @@ const emit = defineEmits<{
}>()
const slots = useSlots()
const tableH = ref(props.tableHeight ?? Utils.useTableHeight())
const regionRef = ref<HTMLElement | null>(null)
const measuredH = useElementHeight(regionRef)
// 显式传 tableHeight 则尊重之;否则实测 .table-region(flex:1)高度喂固定表头,首帧未测出前用 undefined(auto)
const tableH = computed(() => props.tableHeight ?? (measuredH.value || undefined))
const tableSlotNames = computed(() => Object.keys(slots).filter(name => !['default', 'loading'].includes(name)))
const selectList = ref<number[]>([])
+274
View File
@@ -0,0 +1,274 @@
<script setup lang="ts">
/* 管理端配色切换器(yh-theme 定稿 07-seam-pure 顶栏「配色」胶囊,方案 §6.5)。
* 状态唯一来源:utils/theme(localStorage 持久化 + <html> 档位类名),与门户皮肤包正交。 */
import { onBeforeUnmount, onMounted, ref } from "vue";
import {
loadThemeState,
saveThemeState,
applyThemeState,
type ThemeName,
type ThemeState,
} from "@/utils/theme";
const THEME_META: { name: ThemeName; label: string; color: string }[] = [
{ name: "blue", label: "原版靛蓝", color: "#577afb" },
{ name: "ocean", label: "云海蓝", color: "#2f7ce0" },
{ name: "ink", label: "石墨墨", color: "#3d4d63" },
{ name: "violet", label: "靛紫", color: "#6d5ae0" },
{ name: "teal", label: "松石绿", color: "#0d9488" },
];
const wrapEl = ref<HTMLElement | null>(null);
const open = ref(false);
const state = ref<ThemeState>(loadThemeState());
/* 挂载即把存储态落到 <html>(与 main.ts initTheme 同源幂等;组件晚于引导挂载时也自洽) */
applyThemeState(state.value);
function commit(patch: Partial<ThemeState>) {
state.value = { ...state.value, ...patch };
applyThemeState(state.value);
saveThemeState(state.value);
}
function toggle(ev: MouseEvent) {
ev.stopPropagation();
open.value = !open.value;
}
/** 切换器外任意点击即收起(弹层内部点击由 wrap 判定放过) */
function onDocClick(ev: MouseEvent) {
if (open.value && wrapEl.value && !wrapEl.value.contains(ev.target as Node)) {
open.value = false;
}
}
onMounted(() => document.addEventListener("click", onDocClick, true));
onBeforeUnmount(() => document.removeEventListener("click", onDocClick, true));
</script>
<template>
<div ref="wrapEl" class="theme-wrap">
<button type="button" class="theme-btn" :class="{ on: open }" @click="toggle">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 3a9 9 0 1 0 0 18c1.2 0 1.9-.9 1.9-1.8 0-.6-.3-1-.5-1.4-.2-.4-.4-.8-.4-1.3 0-.9.8-1.7 1.8-1.7H17a4 4 0 0 0 4-4c0-4.3-4-7.8-9-7.8z"/>
<circle cx="7.6" cy="11.2" r="1.1"/><circle cx="11" cy="7.6" r="1.1"/><circle cx="15.4" cy="8.6" r="1.1"/>
</svg>
<span>配色</span>
<svg class="caret" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M5.5 9.5l6.5 6.5 6.5-6.5"/>
</svg>
</button>
<div class="theme-pop" :class="{ open }">
<p class="tp-head">主题色</p>
<div class="tp-grid">
<button
v-for="t in THEME_META"
:key="t.name"
type="button"
class="theme-opt"
:class="{ active: state.theme === t.name }"
@click="commit({ theme: t.name })"
>
<i :style="{ background: t.color }"></i>{{ t.label }}
</button>
</div>
<div class="tp-sep"></div>
<p class="tp-head">侧栏样式</p>
<label class="tp-row">
<span>侧栏跟随顶栏色</span>
<span class="sw">
<input type="checkbox" :checked="state.sideFollow" @change="commit({ sideFollow: ($event.target as HTMLInputElement).checked })" />
<i></i>
</span>
</label>
<label class="tp-row" style="margin-top: 9px">
<span>侧栏深色(关=浅色)</span>
<span class="sw">
<input type="checkbox" :checked="state.sideDeep" @change="commit({ sideDeep: ($event.target as HTMLInputElement).checked })" />
<i></i>
</span>
</label>
<div class="tp-sep"></div>
<label class="tp-row">
<span>顶部过渡色(渐变)</span>
<span class="sw">
<input type="checkbox" :checked="state.gradOn" @change="commit({ gradOn: ($event.target as HTMLInputElement).checked })" />
<i></i>
</span>
</label>
</div>
</div>
</template>
<style scoped>
/* 皮肤规格照搬定稿 07-seam-pure.html .theme-wrap/.theme-btn/.theme-pop 段 */
.theme-wrap {
position: relative;
height: 100%;
display: flex;
align-items: center;
}
.theme-btn {
display: flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 11px;
cursor: pointer;
border: 1px solid rgba(255, 255, 255, 0.34);
border-radius: 999px;
background: rgba(255, 255, 255, 0.14);
color: #fff;
font-size: 12.5px;
transition: background 0.16s, border-color 0.16s;
}
.theme-btn:hover,
.theme-btn.on {
background: rgba(255, 255, 255, 0.26);
border-color: rgba(255, 255, 255, 0.6);
}
.theme-btn svg {
width: 14px;
height: 14px;
display: block;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
}
.theme-btn svg circle {
fill: currentColor;
stroke: none;
}
.theme-btn .caret {
width: 11px;
height: 11px;
opacity: 0.85;
transition: transform 0.18s;
}
.theme-btn.on .caret {
transform: rotate(180deg);
}
.theme-pop {
position: absolute;
top: calc(100% + 6px);
right: 0;
width: 224px;
background: #fff;
color: var(--text, #1e2635);
border-radius: 12px;
border: 1px solid var(--line, #e9edf5);
padding: 12px;
box-shadow: 0 12px 34px -10px rgba(16, 24, 40, 0.28), 0 2px 6px rgba(16, 24, 40, 0.08);
opacity: 0;
visibility: hidden;
transform: translateY(-6px);
transition: 0.18s;
z-index: 40;
}
.theme-pop.open {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.tp-head {
font-size: 11.5px;
color: var(--text-3, #98a1b2);
letter-spacing: 0.5px;
margin: 0 2px 8px;
}
.tp-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
}
.theme-opt {
display: flex;
align-items: center;
gap: 7px;
height: 32px;
padding: 0 8px;
cursor: pointer;
text-align: left;
border: 1px solid var(--line, #e9edf5);
border-radius: 8px;
background: #fff;
color: var(--text-2, #5a6478);
font-size: 12px;
transition: 0.15s;
font-family: inherit;
}
.theme-opt:hover {
border-color: #cfd7e6;
background: #fafbfe;
}
.theme-opt i {
width: 13px;
height: 13px;
border-radius: 50%;
flex: 0 0 auto;
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.theme-opt.active {
border-color: var(--brand, #577afb);
color: var(--brand, #577afb);
background: var(--brand-soft, #eef2ff);
font-weight: 600;
}
.tp-sep {
height: 1px;
background: var(--line, #e9edf5);
margin: 12px 0 10px;
}
.tp-row {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 12.5px;
color: var(--text-2, #5a6478);
cursor: pointer;
padding: 0 2px;
}
.sw {
position: relative;
width: 38px;
height: 21px;
flex: 0 0 auto;
}
.sw input {
position: absolute;
inset: 0;
opacity: 0;
margin: 0;
cursor: pointer;
z-index: 1;
}
.sw i {
position: absolute;
inset: 0;
border-radius: 999px;
background: #d7dce6;
transition: background 0.2s;
pointer-events: none;
}
.sw i::after {
content: "";
position: absolute;
top: 2.5px;
left: 2.5px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #fff;
box-shadow: 0 1px 3px rgba(16, 24, 40, 0.28);
transition: transform 0.2s;
}
.sw input:checked + i {
background: var(--brand, #577afb);
}
.sw input:checked + i::after {
transform: translateX(17px);
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<script lang="ts" setup>
/* 默认头像三态组件(YschTheme 公共源,scripts/sync-apps.mjs 下发三端 src/components/UserAvatar.vue):
头像 URL 非空 → <img>;否则 style==="text" 且能取到字 → 首字;否则 → 剪影(纯 CSS/SVG,零资源文件)。
取值链/取字契约见《设置页修复与默认头像落地方案》§10.1/§10.2;样式全在本文件 <style scoped>(唯一 CSS 源,
不进通道 icons.src.css——通道 CSS 对 UserHome 不可达,内联分发才能消双份漂移,见同方案 §10.4)。
根元素即 .yh-avatar 圆:尺寸内联必须挂圆本身(挂外层 span 会被 flex 拉成椭圆,§12 踩坑)。 */
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
import { pickAvatarText, resolveAvatarStyle } from "@/utils/avatar";
const props = withDefaults(
defineProps<{
/** 头像 URL:非空直接 <img>(三态第一优先) */
src?: string | null;
/** 取字来源(首字态用):经 pickAvatarText 判定,取不到字回落剪影 */
name?: string | null;
/** 直径 px;font-size = round(size * 0.44) */
size?: number;
/** 配色场景:light=页面浅底(品牌实底+白内容);brand=header 品牌色底(剪影 S1 玻璃底 / 首字 S2 白底反色,§10.3 分档) */
placement?: "light" | "brand";
}>(),
{ src: null, name: null, size: 28, placement: "light" },
);
const text = computed(() => (props.src ? "" : pickAvatarText(props.name)));
/* 外观偏好异步补齐(yh-theme-init 在挂载后拉服务端真源收敛 localStorage 键)会在
document 上派发 yh-appearance-updated;tick 自增让 style 依赖失效重读键,三态即时切换(无需刷新)。 */
const appearanceTick = ref(0);
const onAppearanceUpdated = () => { appearanceTick.value += 1; };
onMounted(() => document.addEventListener("yh-appearance-updated", onAppearanceUpdated));
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", onAppearanceUpdated));
const style = computed(() => {
appearanceTick.value;
return resolveAvatarStyle();
});
/** 首字态 = style==="text" 且能取到字(契约 §10.2:二者缺一即剪影,取到字但档位是剪影时绝不能出字) */
const showText = computed(() => style.value === "text" && !!text.value);
const rootClass = computed(() => [
"yh-avatar",
props.placement === "brand" ? "yh-avatar--brand" : "yh-avatar--light",
{ "is-text": showText.value },
]);
const rootStyle = computed(() => ({
width: `${props.size}px`,
height: `${props.size}px`,
fontSize: `${Math.round(props.size * 0.44)}px`,
}));
</script>
<template>
<span :class="rootClass" :style="rootStyle">
<img v-if="src" class="yh-avatar__img" :src="src" alt="" />
<template v-else-if="showText">{{ text }}</template>
<svg v-else class="yh-avatar__sil" viewBox="0 0 1024 1024" aria-hidden="true">
<circle cx="512" cy="399" r="174" />
<ellipse cx="512" cy="946" rx="420" ry="240" />
</svg>
</span>
</template>
<style scoped>
.yh-avatar {
position: relative;
border-radius: 50%;
overflow: hidden;
display: inline-grid;
place-items: center;
flex: 0 0 auto;
font-weight: 600;
line-height: 1;
user-select: none;
}
.yh-avatar__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* 剪影 SVG:容器圆 overflow:hidden 裁切肩部溢出(禁用 clipPath);overflow:visible 放行 viewBox 外形状 */
.yh-avatar__sil {
display: block;
width: 100%;
height: 100%;
overflow: visible;
fill: currentColor;
}
/* 页面内(浅底)——品牌实底 + 白内容 */
.yh-avatar--light {
background: var(--brand, #577afb);
color: #fff;
}
/* header(品牌色底)——与页面内反向:剪影档 = S1 半透明白,首字档 = S2 纯白底 + 品牌色字 */
.yh-avatar--brand {
background: rgba(255, 255, 255, 0.22);
color: #fff;
}
.yh-avatar--brand.is-text {
background: #fff;
color: var(--brand, #577afb);
}
</style>
+79 -40
View File
@@ -1,8 +1,11 @@
<script setup lang="ts">
import { ref, onBeforeMount, watch, computed } from "vue";
import { ref, onBeforeMount, onBeforeUnmount, onMounted, watch, computed } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { resolveMenuPage } from "@/utils/menuRoute";
import ThemeSwitcher from "@/components/ThemeSwitcher.vue";
import UserAvatar from "@/components/UserAvatar.vue";
import defaultLogo from "@/assets/image/yunhai-logo.svg";
const props = defineProps<{ layoutData: Record<string, any> }>();
const route = useRoute();
@@ -22,6 +25,27 @@ const modList = computed(() => props.layoutData?.modList || []);
const pathNavStyle = computed(() => (isAlone.value ? { left: "0", width: "100%" } : null));
// 顶栏 Logo:localStorage["yh-logo-url"](平台键,yh-theme-init 异步补齐段写入)优先,本仓资产兜底——绝无空 src/裂图。
// 刷新:mounted 读一次 + 监听 yh-appearance-updated(服务端偏好到达后即时切换,无需刷新页面)。
const logoUrl = ref<string>(defaultLogo);
const readLogoUrl = () => {
let v: string | null = null;
try {
v = localStorage.getItem("yh-logo-url");
} catch {
v = null;
}
logoUrl.value = v || defaultLogo;
};
const onLogoError = () => {
if (logoUrl.value !== defaultLogo) logoUrl.value = defaultLogo;
};
onMounted(() => {
readLogoUrl();
document.addEventListener("yh-appearance-updated", readLogoUrl);
});
onBeforeUnmount(() => document.removeEventListener("yh-appearance-updated", readLogoUrl));
onBeforeMount(() => {
routeChange(route);
});
@@ -94,15 +118,21 @@ const routeChange = (to: any) => {
navPath.value = [];
};
/** 统一解析菜单项目标:容忍平台配置的尾空格/旧 /notice 前缀/外链,返回内部路由 path 或外链 URL。
* 模板高亮与点击都走这里,避免「raw page(带尾空格)vs 解析后 path」不一致导致点不动/不高亮。 */
const resolveItemPage = (item: any) =>
resolveMenuPage(item?.page, item?.modId ?? item?.id, item?.name);
const pageOf = (item: any): string => resolveItemPage(item).path;
const linkMenu = (item: any) => {
const page: string = item?.page || "";
const { path: page, external } = resolveItemPage(item);
if (!page) {
// 解析不出有效页面:显式提示,而不是静默不动或被重定向吞到默认页
Message.warning(`菜单「${item?.name || "未命名"}」未配置有效页面路径,请检查基础平台模块设置`);
return;
}
// 平台配置的外部链接模块:整页跳转
if (item?.external) {
if (external) {
window.location.href = page;
return;
}
@@ -114,7 +144,8 @@ const linkMenu = (item: any) => {
};
const linkUserInfo = () => {
window.location.href = baseHost.value + "/v/userinfo";
// 头像/姓名点击统一跳 UserHome 设置页「个人信息」pane(默认头像轮 2026-09-30,原 /v/userinfo 已下线语义)
window.location.href = baseHost.value + "/userhome/front/index.html#/settings?pane=profile";
};
const logout = () => {
let url = window.location.href;
@@ -128,31 +159,16 @@ const childChange = (index: number) => {
}
};
/** 根据菜单项映射图标:优先按解析后的 page 判断(平台模块 id 不可依赖),
* 兜底保留旧版 modId 对照 */
const getMenuIcon = (item?: any): string => {
const page: string = item?.page || "";
if (page === "/list/new") return "ios-create";
if (page === "/list") return "ios-paper-plane";
if (page === "/" || page === "/accept") return "ios-mail";
if (page === "/noticelist") return "ios-list";
if (page === "/stats") return "ios-stats";
if (page === "/setting") return "ios-settings";
switch (item?.modId ?? item?.id) {
case 1: return "ios-mail";
case 2: return "ios-paper-plane";
case 3: return "ios-list";
case 4: return "ios-stats";
case 5: return "ios-document";
case 21: return "ios-settings";
case 22: return "ios-create";
default: return "";
}
};
/** 菜单图标:读取用户中心模块管理配置的 YschIcons 图标名(GetUserMod 的 icon 字段,如 list → icon-list)。
* icons.css(ysch-icons 掩码版,基类 iconfont,类名 icon-<名称>)现由中央静态通道下发;
* 平台未配置图标时统一兜底 icon-list(2026-10-09 定稿:菜单项恒有图标,保整列缩进/行高一致) */
const getMenuIcon = (item?: any): string =>
`icon-${String(item?.icon || "").trim() || "list"}`;
function linkHomePage() {
if (baseHost.value) {
window.location.href = baseHost.value + "/BschHome/front/index.html";
// 旧 BschHome 已下线(线上 404),首页统一指向用户中心 userhome
window.location.href = baseHost.value + "/userhome/front/index.html";
} else {
window.location.href = "/";
}
@@ -162,16 +178,13 @@ function linkHomePage() {
<template>
<div class="layout-wrap">
<header class="header-container fix">
<div class="fl align-middle icon-box">
<div title="常用应用" :class="['icon-btn', isPanel ? 'active' : '']" @click="isPanel = !isPanel">
<i class="iconfont icon-apps"></i>
</div>
</div>
<!-- 云海 logo 徽标(结构与 Cms SchoolLayout 对齐,皮肤见通道 base.layout.css .logo-badge) -->
<span class="logo-badge fl"><img :src="logoUrl" alt="云海" @error="onLogoError" /></span>
<div class="delimiter fl align-middle"></div>
<h4 class="fl align-middle">{{ appName }}</h4>
<div class="school-bg"></div>
<div class="header-aside fr">
<img :src="userInfo.avatar" class="fl align-middle" @click="linkUserInfo" />
<UserAvatar class="fl align-middle" :src="userInfo.avatar" :name="userInfo.name" :size="28" placement="brand" @click="linkUserInfo" />
<a class="name fl align-middle text-ellipsis" href="javascript:void(0);" @click="linkUserInfo">
{{ userInfo.name }}
</a>
@@ -180,6 +193,8 @@ function linkHomePage() {
<i class="iconfont icon-logout"></i>
</a>
</div>
<!-- 配色切换器已移除(oapi 代理轮 2026-10-08 拍板):设置入口收敛在 UserHome 设置中心,
本地切换会被服务端生效值收敛打回,不留半残入口 -->
</header>
<div class="left-menu" v-if="!isAlone">
<div class="menu-top-box"></div>
@@ -189,14 +204,14 @@ function linkHomePage() {
<li>
<a href="javascript:;" @click="childChange(index)">
<i :class="item.childState ? 'arrow-down' : 'arrow-up'"></i>
<Icon v-if="getMenuIcon(item)" :type="getMenuIcon(item)" />
<i :class="['iconfont', getMenuIcon(item)]"></i>
{{ item.name }}
</a>
</li>
<transition name="fade_child_ul">
<div class="child-item-wrap" v-show="item.childState">
<li
:class="citem.page == currPath ? 'active child-item' : 'child-item'"
:class="pageOf(citem) == currPath ? 'active child-item' : 'child-item'"
v-for="citem in item.children"
:key="citem.id"
>
@@ -207,9 +222,9 @@ function linkHomePage() {
</div>
</transition>
</template>
<li v-else :class="item.page == currPath ? 'active' : ''">
<li v-else :class="pageOf(item) == currPath ? 'active' : ''">
<a href="javascript:;" @click="linkMenu(item)">
<Icon v-if="getMenuIcon(item)" :type="getMenuIcon(item)" />
<i :class="['iconfont', getMenuIcon(item)]"></i>
{{ item.name }}
</a>
</li>
@@ -222,8 +237,10 @@ function linkHomePage() {
<Breadcrumb separator=">">
<a href="javascript:;" @click="linkHomePage">
<BreadcrumbItem>
<i class="iconfont icon-category"></i>
首页
<span class="crumb-home">
<i class="iconfont icon-shouye"></i>
首页
</span>
</BreadcrumbItem>
</a>
<BreadcrumbItem v-if="!isAlone" to="/">{{ appName }}</BreadcrumbItem>
@@ -250,7 +267,7 @@ function linkHomePage() {
</li>
</ul>
<div class="link-panel">
<a :href="baseHost + '/BschHome/front/index.html#/apps'" target="_blank" rel="noopener noreferrer">
<a :href="baseHost + '/userhome/front/index.html#/apps'" target="_blank" rel="noopener noreferrer">
全部应用
<i>→</i>
</a>
@@ -261,5 +278,27 @@ function linkHomePage() {
</template>
<style scoped>
/* 仅覆盖/补充必要的动态样式,其余样式由全局 layout.css 提供 */
/* 仅覆盖/补充必要的动态样式,其余样式由中央静态通道的 base.layout.css(骨架 + 皮肤)提供 */
/* 菜单图标(通道下发的 ysch-icons 掩码类)与文字间距 */
.left-menu a .iconfont {
margin-right: 8px;
}
/* 顶栏已由通道皮肤改为 flex 垂直居中(skin.src.css .header-container),
这里不再用 float 时代的 13px 顶偏移补偿,仅留右侧间距;徽标大小/圆底/阴影属皮肤 */
.header-container .logo-badge {
margin: 0 10px 0 0;
}
/* 面包屑「首页」垂直居中:骨架遗留的 .path-nav .icon-category{position:relative;top:1px}
是给老 iconfont 字体字形做的基线补偿,换成 ysch mask 的 1em 方块后反而把图标顶偏;
这里用 inline-flex 真居中并关掉该补偿(不动通道产物,覆盖收在本组件内) */
/* 面包屑「首页」与后续级目的文字对齐(2026-10-09 定稿):此前 .crumb-home 用 inline-flex,
行内级盒的基线取首项——16px 无字图标(其基线=方块底缘),整盒被顶高约 4px,首页文字
比相邻级目高。改回普通行内流:图标(.iconfont 基类 inline-block,基线=底缘)以 -0.125em
基线偏移做光学居中,文字与相邻级目共用同一行盒基线,天然对齐 */
.crumb-home .iconfont {
position: static;
top: 0;
margin-right: 4px;
vertical-align: -0.125em;
}
</style>
+2 -4
View File
@@ -13,10 +13,8 @@ import mittBus from "@/utils/mitt";
import "view-ui-plus/dist/styles/viewuiplus.css";
import "@/assets/styles/main.scss";
// 本地化的布局/主题样式(原远程 base.layout.beta / _iview_override / default-theme 合并)
import "@/assets/styles/layout.css";
// 规范 iconfont 图标本地实现(icon-browse / icon-edit / icon-ashbin 等,平台主题不含字体)
import "@/assets/styles/icons.css";
// 布局/主题/图标(base.layout.css + iview.override.css + icons.css)由中央静态通道下发:
// index.html 的 yh-theme-init.js 在 DOMContentLoaded 后按 manifest 版本追加 <link>,天然排在本文件组件库样式之后
//导入自定义指令
import directive from "@/utils/directive/index";
+36 -10
View File
@@ -6,12 +6,15 @@ import type { RouteRecordRaw } from "vue-router";
* - /list/new?serid= (编辑)
* - /list/new?fid= (转发)
* - /list/new?tempid=(模板引用)
* - /details?serid=&type= type: 0=编辑视角(不标读) 1=普通查看(标读)
* - /list/detail?serid=&type=、/noticelist/detail?serid=&type=
* type: 0=编辑视角(不标读) 1=普通查看(标读)
*
* modId 是菜单 ↔ 路由的稳定契约(对照后端 GetUserMod 现行数据):
* 1=已收到 2=已发送 3=全部通知 4=通知统计 21=应用设置
* 发通知为「已发送」的深层子页(modId 22 不在菜单树,meta.parentId=2 关联高亮),
* 与 UserBase「应用管理>应用类型管理」范式一致。
* modId 是菜单 ↔ 路由的稳定契约(严格对照后端 /base/GetUserMod 现行返回的 modId):
* 1=已收到 2=已发送 3=全部通知 4=通知统计 21=应用设置 89=待审核 91=抄送给我的
* (后两条平台已挂一级菜单,page 分别为 /approval、/cc)。
* 深层子页不在菜单树,靠 meta.parentId 关联高亮父菜单:/list/new=22(父2)、
* /list/detail=23(父2)、/noticelist/detail=24(父3)。深层页 modId 不得与任何
* 平台一级模块 ID(含 89/91)重复。
*/
const routes: RouteRecordRaw[] = [
// 已收通知(收件箱):后端菜单 page 配置为 "/",即应用入口页
@@ -43,12 +46,19 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/notice/NoticeList.vue"),
meta: { title: "全部通知", modId: 3 },
},
// 通知详情(已发/已收共用,type 区分是否标读)
// 通知详情(同一组件挂两个路由,面包屑/菜单高亮由 meta.parentId 确定父节点:
// 已发送下 /list/detail,全部通知下 /noticelist/detail;type 区分是否标读)
{
path: "/details",
name: "notice_details",
path: "/list/detail",
name: "notice_list_detail",
component: () => import("@/views/notice/NoticeDetails.vue"),
meta: { title: "通知详情", modId: 5 },
meta: { title: "通知详情", modId: 23, parentId: 2 },
},
{
path: "/noticelist/detail",
name: "notice_manage_detail",
component: () => import("@/views/notice/NoticeDetails.vue"),
meta: { title: "通知详情", modId: 24, parentId: 3 },
},
// 通知统计(旧版行为:整页跳转外部统计系统)
{
@@ -64,9 +74,25 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/notice/NoticeSetting.vue"),
meta: { title: "应用设置", modId: 21 },
},
// 待审核(应用内审批;平台模块管理已挂一级菜单,配置 ID = 89,path = /approval)
{
path: "/approval",
name: "notice_approval",
component: () => import("@/views/notice/NoticeApproval.vue"),
meta: { title: "我的待审核", modId: 89 },
},
// 抄送给我的(流程 NOTIFY 知会;平台模块管理已挂一级菜单,配置 ID = 91,path = /cc)
{
path: "/cc",
name: "notice_cc",
component: () => import("@/views/notice/NoticeCc.vue"),
meta: { title: "抄送给我的", modId: 91 },
},
/* ---- 旧版路径兼容重定向(书签、残留的平台模块配置) ---- */
{ path: "/accept", redirect: "/" },
{ path: "/details", redirect: "/list/detail" },
{ path: "/newnotice", redirect: "/list/new" },
{ path: "/edit", redirect: "/list/new" },
{ path: "/manage", redirect: "/noticelist" },
@@ -76,7 +102,7 @@ const routes: RouteRecordRaw[] = [
{ path: "/notice/notice_accept", redirect: "/accept" },
{ path: "/notice/notice_list", redirect: "/list" },
{ path: "/notice/manage", redirect: "/noticelist" },
{ path: "/notice/notice_details", redirect: "/details" },
{ path: "/notice/notice_details", redirect: "/list/detail" },
{ path: "/notice/statistics", redirect: "/stats" },
{ path: "/notice/setting", redirect: "/setting" },
];
+82
View File
@@ -64,10 +64,81 @@ export interface NoticeVo {
smsFlag: number;
/** 接收人列表(JSON) */
receiveList: string | null;
/** 附件ID串("1,2,3";后端 Lombok 序列化名为 upFile,对齐旧 .NET UpFile 语义) */
upFile?: string | null;
/** @deprecated 旧 .NET 接口的 PascalCase 附件字段,仅作读取兼容 */
UpFile?: string | null;
createBy: string | null;
createTime: string | null;
updateBy: string | null;
updateTime: string | null;
/** 绑定的审核流程 GUID(null=直发未接流程中心) */
flowGuid?: string | null;
/** 审核状态:0 待提交 / 10 审批中 / 20 已通过 / 30 已驳回 / 40 已撤回(null=未绑流程) */
auditStatus?: number | null;
/** 审核状态文案 */
auditStatusDesc?: string | null;
/** 流程中心办件 id */
flowInstanceId?: number | null;
}
// ============================ 先审后发(流程中心) ============================
/** 审核流程下拉选项(对齐 FlowModels.FlowSummaryVo) */
export interface FlowOption {
flowGuid: string;
name: string;
version: number;
nodeSummary: string | null;
}
/** 应用级流程绑定视图(GET /api/notice/flow/Binding;应用设置 > 流程绑定 + 发通知页按钮显隐) */
export interface FlowBindingVo {
/** 本校是否已绑定审核流程(false=全部直发,现有功能不变) */
bound: boolean;
flowGuid: string | null;
flowName: string | null;
/** 流程中心是否接入(false 时设置页隐藏绑定入口) */
flowEnabled: boolean;
/** 当前用户是否可维护绑定(本校管理员;false 时设置页只读展示) */
canEdit?: boolean;
}
/** 审批进度(GET /api/notice/flow/Trace) */
export interface FlowTraceVo {
bound: boolean;
flowGuid: string | null;
flowInstanceId: number | null;
auditStatus: number | null;
auditStatusDesc: string | null;
status: number | null;
flowResult?: { result?: string; opinion?: string; rejectNode?: string } | null;
instanceStatus?: number | null;
version?: number | null;
nodeSummary?: string | null;
currentNodes?: string | null;
assigneeIds?: number[] | null;
submitTime?: string | null;
finishTime?: string | null;
/** 办理轨迹(traceJson 解析后的数组) */
trace?: any[] | null;
/** 结构化步骤(2026-09-17 接入:steps[0] 固定"发起申请",同节点多任务每人一行) */
steps?: FlowProgressStepVo[] | null;
}
/** 流程步骤(SDK FlowProgressStep 同构:后端 /flow/Trace 的 steps 元素) */
export interface FlowProgressStepVo {
nodeId: string;
nodeName: string | null;
label: string | null;
active: boolean;
assignees?: {
userName?: string | null;
status?: number | null;
statusLabel?: string | null;
opinion?: string | null;
handleTime?: string | null;
}[] | null;
}
// ============================ 通知详情 ============================
@@ -76,6 +147,11 @@ export interface NoticeVo {
export interface NoticeDetailVo {
serid: number;
title: string;
flowGuid?: string | null;
auditStatus?: number | null;
auditStatusDesc?: string | null;
flowInstanceId?: number | null;
flowResult?: { result?: string; opinion?: string; rejectNode?: string } | null;
/** 通知内容(旧字段名 Contents) */
Contents: string;
/** 发布部门ID(旧字段名 DeptID) */
@@ -194,6 +270,7 @@ export interface NoticeRequest {
smsFlag?: number;
/** 转发源通知ID */
sourceId?: number;
// 审核流程不由前端指定:绑定在「应用设置 > 流程绑定」,后端按「已绑定 + 通知对象=全部用户」判定
}
/** 通知列表查询参数(对齐 NoticeQuery.java + BasePageQuery.java) */
@@ -202,6 +279,11 @@ export interface NoticeQuery {
pageSize?: number;
keyword?: string;
status?: number;
/**
* 审核状态筛选(列表「状态」下拉):0 待提交 / 10 审批中 / 20 已通过 / 30 已驳回 / 40 已撤回;
* -1 = 无需审核(直发通知);不传不过滤
*/
auditStatus?: number;
type?: number;
/** 列表范围(管理端):"user" 只看自己发布的,不传=全部 */
scope?: string;
+4 -2
View File
@@ -58,9 +58,11 @@ function errorHandle<T>(res: BschResponse<T>, status: number): T {
case 302:
console.log(res);
// 后端 CasSsoApiRedirectFilter 已在 service 中拼好 ;redirectToReferer=<hex>,
// 直接跳转即可;登录后由 CasSsoRefererRedirectFilter 解码 302 回前端页
// 直接跳转即可;登录后由 CasSsoRefererRedirectFilter 解码 302 回前端页。
// 跳转后返回永不 resolve 的挂起 Promise:调用方 await 悬停,
// 全局 onBizError 与各页面的 catch 均不再触发(不弹 redirect 报错)
window.location.href = res.urlToRedirectTo!;
throw new Error("redirect");
return new Promise(() => {}) as T;
case 4:
throw new Error(res.msg || "参数错误!");
default:
+94
View File
@@ -0,0 +1,94 @@
/**
* 默认头像样式(首字 / 剪影)公共源(YschTheme,scripts/sync-apps.mjs 下发三端 src/utils/avatar.ts)。
* 取值链与主题同构(theme-core.js resolveEffectiveThemeState 的合成链写法):
* URL ?yh-avatar=text|silhouette(仅预览/排障覆盖:只影响读、绝不写回)
* > localStorage["yh-avatar"](用户自选,纯字符串)
* > localStorage["yh-avatar-sch-default"](学校默认,UserHome 登录后写入)
* > "text"(系统默认)
* 值域恒为 "text" | "silhouette",键缺失/值非法均静默回落下一级,不报错不阻塞渲染。
* 渲染三态与取字规则的唯一契约见 UserHome《设置页修复与默认头像落地方案》§10.1/§10.2。
*/
export type AvatarStyle = "text" | "silhouette";
interface StorageLike {
getItem(k: string): string | null;
setItem(k: string, v: string): void;
removeItem(k: string): void;
}
export const AVATAR_STORAGE_KEY = "yh-avatar";
/** 学校默认头像存储键(UserHome 登录时写入;值格式与用户键相同,仅无"用户自选"语义) */
export const SCHOOL_DEFAULT_AVATAR_STORAGE_KEY = "yh-avatar-sch-default";
export const AVATAR_STYLES: readonly AvatarStyle[] = ["text", "silhouette"];
export const DEFAULT_AVATAR_STYLE: AvatarStyle = "text";
/** 值合法才采用(用户键/学校键同一判据;脏数据不生效) */
function readAvatarStyleOrNull(raw: string | null): AvatarStyle | null {
return raw === "text" || raw === "silhouette" ? raw : null;
}
/** URL 覆盖参数(?yh-avatar=text|silhouette):非法值忽略,且只影响"读哪一档"、绝不写回 localStorage */
function readAvatarOverride(): AvatarStyle | null {
try {
const search = (globalThis.location && globalThis.location.search) || "";
const m = new RegExp("[?&]yh-avatar=([^&]*)").exec(search);
return m ? readAvatarStyleOrNull(decodeURIComponent(m[1])) : null;
} catch {
/* 无 location 环境(测试/SSR):无覆盖 */
return null;
}
}
/**
* 生效样式合成(与 resolveEffectiveThemeState 同构):
* URL 覆盖 > localStorage["yh-avatar"](用户自选)> localStorage["yh-avatar-sch-default"](学校默认)
* > DEFAULT_AVATAR_STYLE。
*/
export function resolveAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): AvatarStyle {
let style: AvatarStyle | null = null;
if (storage) {
style = readAvatarStyleOrNull(storage.getItem(AVATAR_STORAGE_KEY));
if (!style) style = readAvatarStyleOrNull(storage.getItem(SCHOOL_DEFAULT_AVATAR_STORAGE_KEY));
}
if (!style) style = DEFAULT_AVATAR_STYLE;
return readAvatarOverride() || style;
}
/** 保存用户自选(直写 localStorage,无响应式;调用方操作后自行刷新 UI) */
export function saveAvatarStyle(style: AvatarStyle, storage: StorageLike | undefined = globalThis.localStorage): void {
try {
storage?.setItem(AVATAR_STORAGE_KEY, style);
} catch {
/* 隐私模式/配额失败静默:仅丢持久化,不丢本次会话生效 */
}
}
/** 跟随学校默认 = 删用户自选键(之后回落学校默认/系统默认) */
export function clearAvatarStyle(storage: StorageLike | undefined = globalThis.localStorage): void {
try {
storage?.removeItem(AVATAR_STORAGE_KEY);
} catch {
/* 隐私模式静默 */
}
}
/**
* 取字(顺序判定,先命中先返回):含 "@" 先截 "@" 前段(邮箱取首字母);
* 首字符必须用展开 [...s][0](禁用 charAt(0),避免代理对截半出乱码)。
* ASCII 字母转大写;纯数字回落 ""(剪影);符号/emoji/空白回落 ""(剪影);其余(CJK 等)原样。
*/
export function pickAvatarText(name: string | null | undefined): string {
let s = String(name ?? "");
const at = s.indexOf("@");
if (at > -1) s = s.slice(0, at);
const ch = [...s.trim()][0];
if (ch === undefined) return "";
if (/^[A-Za-z]$/.test(ch)) return ch.toUpperCase();
if (/^[0-9]$/.test(ch)) return "";
if (/^[\p{P}\p{S}\s]$/u.test(ch)) return "";
return ch;
}
+12
View File
@@ -0,0 +1,12 @@
/**
* 流程示意图 PNG 显示口径(2026-10-10 用户反馈:嵌入页图过大,同 flow-center/front 同名工具):
* 服务端按 SCALE=2 出图(视网膜清晰度),按自然尺寸渲染等于整体放大一倍。
* 须按逻辑尺寸(自然宽一半)显示;超宽长图收缩到容器宽。
* 用法:<img ref="diagramEl" :src="..." @load="onDiagramLoad">(图均为打开时挂载,@load 必触发)。
*/
export function fitDiagram(el: HTMLImageElement | null | undefined): void {
if (!el || !el.naturalWidth) return;
const want = el.naturalWidth / 2;
const cap = (el.parentElement?.clientWidth || 0) - 8;
el.style.width = Math.round(Math.min(want, cap > 120 ? cap : want)) + "px";
}
+36
View File
@@ -0,0 +1,36 @@
import { ref, onMounted, onBeforeUnmount, watch, type Ref } from "vue";
/**
* 实测某容器元素的可用高度(px),随尺寸变化实时更新。
*
* 用途:表格页把 <Table> 放进 flex:1 的 .table-region 里,用本组合式量出该区域
* 的真实高度喂给 view-ui 的 `:height`(固定表头需要一个数值高度),从而彻底替代
* 旧的 useTableHeight()「视口高 - 一堆写死常量」的算法——加/删工具条、改顶栏高
* 都不再需要同步改任何魔法数字。
*
* @param el 目标容器 ref(模板里 ref 绑定的那个元素)
* @returns 响应式高度(px);挂载测量前为 0,调用方可 `:height="h || undefined"` 兜底
*/
export function useElementHeight(el: Ref<HTMLElement | null>): Ref<number> {
const height = ref(0);
let ro: ResizeObserver | null = null;
const measure = () => {
if (el.value) height.value = Math.floor(el.value.clientHeight);
};
const observe = () => {
ro?.disconnect();
if (!el.value || typeof ResizeObserver === "undefined") return;
measure();
ro = new ResizeObserver(measure);
ro.observe(el.value);
};
onMounted(observe);
// v-if / v-show 切换导致容器重建时,重新挂观察
watch(el, observe);
onBeforeUnmount(() => ro?.disconnect());
return height;
}
+1 -1
View File
@@ -100,7 +100,7 @@ export default class Utils {
"div",
{
class: `operating-btn iconfont ${iconClass}`,
onClick: callback,
onClick: disabled ? null : callback,
},
null,
),
+151
View File
@@ -0,0 +1,151 @@
/**
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20261009-1655-488a3c3),勿手改。
* 分发方式: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());
}
+159 -12
View File
@@ -2,14 +2,15 @@
import { ref, reactive, shallowRef, computed, onMounted, onBeforeUnmount } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRequest } from "alova/client";
import { Message } from "view-ui-plus";
import { Message, Modal } from "view-ui-plus";
import { Editor, Toolbar } from "@wangeditor/editor-for-vue";
import "@wangeditor/editor/dist/css/style.css";
import { noticeApi } from "@/api/module/notice";
import type { NoticeDetailVo, NoticeRequest, UpFileVo } from "@/types/notice";
import type { NoticeDetailVo, NoticeRequest, UpFileVo, FlowBindingVo } from "@/types/notice";
import UserCheckInput from "@/components/UserCheckInput.vue";
import NoticeUpload from "@/components/NoticeUpload.vue";
import config from "@/assets/config/host.config";
import { fitDiagram } from "@/utils/diagramImg";
const route = useRoute();
const router = useRouter();
@@ -26,6 +27,29 @@ const form = reactive({
content: "",
isShortMsg: false,
});
/* ============ 先审后发(应用设置 > 流程绑定,按学校生效) ============
规则(后端为单一事实源):已绑定 + 通知对象=全部用户 → 必须走审核,只显示「提交审核」,
隐藏「发送」与「定时发送」;指定人员与未绑定 → 原直发链路不变。 */
const flowBinding = ref<FlowBindingVo>({ bound: false, flowGuid: null, flowName: null, flowEnabled: true });
const noticeFlowGuid = ref(""); // 编辑回显:该通知自身的流程快照(决定沿用哪个流程重审)
const auditStatus = ref<number | null>(null); // 编辑回显的审核态
const auditResult = ref<{ result?: string; opinion?: string; rejectNode?: string } | null>(null);
/** 通知对象是否「全部用户」(UserCheckInput 全部态 v-model 值为 "all") */
const isAllTarget = computed(() => {
const v = (form.rdoname || "").trim().toLowerCase();
return v === "" || v === "all";
});
/** 是否走审核(与后端 publish/edit 判定同式:已绑定 && 全部用户) */
const needAudit = computed(() => flowBinding.value.bound && isAllTarget.value);
/** 提示条展示的流程:优先该通知自身快照,其次应用绑定 */
const auditFlowGuid = computed(() => noticeFlowGuid.value || flowBinding.value.flowGuid || "");
const auditFlowName = computed(() => {
if (!noticeFlowGuid.value) return flowBinding.value.flowName || "";
return noticeFlowGuid.value === flowBinding.value.flowGuid ? flowBinding.value.flowName || "" : "";
});
const fileids = ref<number[]>([]);
const editFileList = ref<{ name: string; fileid: number }[]>([]);
const isSms = ref(false);
@@ -52,6 +76,33 @@ onMounted(async () => {
}
});
/* 流程绑定(决定按钮显隐;流程中心未接入时 bound 恒为 false → 原直发界面) */
onMounted(async () => {
try {
const view = await noticeApi.getFlowBinding().send();
if (view) flowBinding.value = view;
} catch {
flowBinding.value = { bound: false, flowGuid: null, flowName: null, flowEnabled: false };
}
});
/** 流程图 Drawer(后端 DiagramDef 中转 PNG:同源免 token;与设置页同款,修复旧深链 404) */
const flowDiagramVisible = ref(false);
const flowDiagramEl = ref<HTMLImageElement | null>(null);
const flowDiagramSrc = computed(() =>
flowDiagramVisible.value && auditFlowGuid.value
? `${config.serviceHost}/api/notice/flow/DiagramDef?flowGuid=${encodeURIComponent(auditFlowGuid.value)}`
: "",
);
function openFlowDiagram() {
if (!auditFlowGuid.value) return;
flowDiagramVisible.value = true;
}
function onFlowDiagramError() {
flowDiagramVisible.value = false;
Message.error("流程图加载失败(流程可能未发布或绑定已解除)");
}
/* ============ 富文本编辑器(wangeditor v5 for vue3) ============ */
const editorRef = shallowRef();
const editorLoading = ref(true);
@@ -134,6 +185,10 @@ onMounted(async () => {
form.dept = detail.DeptID || "";
form.rdoname = detail.tlist === "all" || !Array.isArray(detail.tlist) ? "all" : detail.tlist.join(",");
if (detail.DsfsTime) timeForm.date1 = detail.DsfsTime;
// 审核绑定回显(驳回/撤回态显示意见与重提按钮)
noticeFlowGuid.value = detail.flowGuid || "";
auditStatus.value = detail.auditStatus ?? null;
auditResult.value = detail.flowResult || null;
} else {
form.title = detail.title || "";
}
@@ -177,6 +232,7 @@ async function doAdd(_type?: string) {
deptId: form.dept,
fileIds: fileids.value.join(","), //附件
smsFlag: form.isShortMsg ? 1 : 0, //是否发送短信
// 审核流程不再由前端指定:后端按「应用设置绑定 + 通知对象=全部用户」判定
};
if (isTime) {
//定时发送
@@ -204,21 +260,41 @@ async function doAdd(_type?: string) {
Message.info("请选择大于当前的时间!");
return;
}
data.timingSendTime = dateFormat(new Date(timeForm.date1), "yyyy/MM/dd HH:mm:ss");
data.timingSendTime = dateFormat(new Date(timeForm.date1), "yyyy-MM-dd HH:mm:ss");
}
isLoading.value = true;
try {
if (!isEdit.value) {
await sendCreate(data);
Message.success("发送成功");
Message.success(needAudit.value ? "已提交审核,审核通过后自动发布" : "发送成功");
router.push("/list");
} else {
data.sourceId = serid.value;
if (data.title.indexOf("[修订稿]") == -1) data.title = "[修订稿]" + data.title;
await sendEdit(data);
Message.success("保存成功");
router.push("/list");
// §3.6:审核轨道内已发布(audit=20)编辑告知——修改后重新走审批,期间对用户不可见
if (needAudit.value && auditStatus.value === 20) {
Modal.confirm({
title: "重新送审确认",
content: "该通知已发布,修改后将重新走审批流程,期间对用户不可见,确认继续?",
onOk: async () => {
await saveEditAndLeave(data);
},
});
return;
}
// 绑定生效后把存量通知(此前直发、无审核轨道)改为全部用户:保存即将转入待审核
if (needAudit.value && !noticeFlowGuid.value) {
Modal.confirm({
title: "转为待审核确认",
content: "本应用已绑定审核流程,发送全部用户需审核通过后发布。保存后该通知将进入待审核状态,审核期间对用户不可见,确认继续?",
onOk: async () => {
await saveEditAndLeave(data);
},
});
return;
}
await saveEditAndLeave(data);
}
} catch (e: any) {
Message.error(e.message || "保存失败");
@@ -226,6 +302,27 @@ async function doAdd(_type?: string) {
isLoading.value = false;
}
}
/* 编辑保存 + 离开(§3.6 抽出:确认分支复用) */
async function saveEditAndLeave(data: NoticeRequest) {
await sendEdit(data);
// 编辑驳回/撤回态:后端 edit 内已自动重提(复用同办件 R4);待提交态由重提按钮处理
Message.success(needAudit.value ? "已保存并提交审核" : "保存成功");
router.push("/list");
}
/* 单独重提审核(待提交/驳回/撤回态;审批中按钮隐藏) */async function doResubmit() {
isLoading.value = true;
try {
await noticeApi.trySubmitFlow(serid.value).send();
Message.success("已提交审核");
router.push("/list");
} catch (e: any) {
Message.error(e.message || "提交审核失败");
} finally {
isLoading.value = false;
}
}
</script>
<template>
@@ -249,6 +346,20 @@ async function doAdd(_type?: string) {
</FormItem>
</Form>
<!-- 绑定了审核流程且通知对象=全部用户:告知需要审核(流程在「应用设置 > 流程绑定」维护) -->
<Alert v-if="needAudit" type="info" show-icon class="audit-alert">
本应用已绑定审核流程{{ auditFlowName ? `《${auditFlowName}》` : "" }}:发送全部用户需审核通过后发布
<template v-if="auditFlowGuid"> <Button type="text" size="small" @click="openFlowDiagram">查看流程图</Button></template>
<template v-if="isAllTarget"> 如需直接发送,可将通知对象改为「指定用户」</template>
</Alert>
<!-- 驳回/撤回意见提示(编辑驳回/撤回态) -->
<Alert v-if="isEdit && (auditStatus === 30 || auditStatus === 40)" type="warning" show-icon class="audit-alert">
审核{{ auditStatus === 30 ? "已驳回" : "已撤回" }}
<template v-if="auditResult?.opinion"> 办理意见:{{ auditResult.opinion }}<template v-if="auditResult?.rejectNode">({{ auditResult.rejectNode }})</template></template>
 修改保存后将自动重新提交审核
</Alert>
<!-- 富文本编辑器 -->
<div class="editor-wrap">
<Toolbar class="editor-toolbar" :editor="editorRef" :defaultConfig="toolbarConfig" mode="default" />
@@ -262,8 +373,19 @@ async function doAdd(_type?: string) {
</div>
<div class="bottom-btn">
<Button type="primary" :loading="isLoading" @click="doAdd()">{{ isEdit ? "保存" : "发送" }}</Button>
<Button v-if="!isEdit" style="margin-left: 10px" @click="isModal.timiSend = true">定时发送</Button>
<Button type="primary" :loading="isLoading" @click="doAdd()">
{{ isEdit ? (needAudit ? "保存并重新提交审核" : "保存") : needAudit ? "提交审核" : "发送" }}
</Button>
<!-- 待提交态单独重提(提交流程中心失败后的幂等重推入口) -->
<Button
v-if="isEdit && noticeFlowGuid && auditStatus === 0"
style="margin-left: 10px"
:loading="isLoading"
@click="doResubmit"
>重新提交审核</Button
>
<!-- 定时发送与审核互斥:走审核时隐藏(后端同步清空定时时间兜底) -->
<Button v-if="!isEdit && !needAudit" style="margin-left: 10px" @click="isModal.timiSend = true">定时发送</Button>
</div>
<!-- 定时发送 -->
@@ -297,11 +419,22 @@ async function doAdd(_type?: string) {
<Button @click="isModal.timiSend = false" style="margin-left: 8px">取消</Button>
</template>
</Modal>
<!-- 流程图 Drawer(后端 DiagramDef 同源 PNG;竖版长图容器内纵向滚动) -->
<Drawer v-model="flowDiagramVisible" title="流程示意图" :width="860" :transfer="false"
:styles="{ body: { padding: '0' } }">
<div class="flow-diagram-frame">
<img ref="flowDiagramEl" v-if="flowDiagramSrc" :src="flowDiagramSrc" alt="流程示意图" @load="fitDiagram(flowDiagramEl)" @error="onFlowDiagramError" />
</div>
</Drawer>
</div>
</div>
</template>
<style lang="scss" scoped>
.audit-alert {
margin-bottom: 12px;
}
/* 页面超高时的滚动由 App.vue 的 .main-wrapper 统一承接 */
.myform {
:deep(.ivu-form-item) {
@@ -309,11 +442,11 @@ async function doAdd(_type?: string) {
}
}
.editor-wrap {
border: 1px solid #dcdee2;
border-radius: 4px;
border: 1px solid var(--line);
border-radius: var(--r-s);
z-index: 100;
.editor-toolbar {
border-bottom: 1px solid #e8e8e8;
border-bottom: 1px solid var(--line);
}
.editor-content {
:deep(.w-e-text-container) {
@@ -325,4 +458,18 @@ async function doAdd(_type?: string) {
.bottom-btn {
margin-top: 6px;
}
/* 流程图 Drawer:浅底色 + 纵向滚动(竖版长图),图片撑满可读宽度 */
.flow-diagram-frame {
height: 100%;
overflow: auto;
background: #f5f7fa;
padding: 12px;
box-sizing: border-box;
}
.flow-diagram-frame img {
display: block;
margin: 0 auto;
max-width: 100%;
height: auto;
}
</style>
+176 -82
View File
@@ -5,6 +5,7 @@ import { Message, Tooltip } from "view-ui-plus";
import { noticeApi } from "@/api/module/notice";
import type { NoticeDetailVo, NoticeQuery, NoticeVo, NoticePageResult } from "@/types/notice";
import FilePreviewModal from "@/components/FilePreviewModal.vue";
import SvgIcon from "@/components/SvgIcon.vue";
import Utils from "@/utils";
/* ============ 左侧列表 ============ */
@@ -111,7 +112,7 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
};
}
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian1/yasuo/weizhi) */
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian/yasuo/weizhi) */
function getFileIcon(name: string) {
const suffix = String(name || "").split(".").pop()?.toLowerCase() || "";
if (["doc", "docx", "wps"].includes(suffix)) return "icon-word";
@@ -119,7 +120,8 @@ function getFileIcon(name: string) {
if (["ppt", "pptx"].includes(suffix)) return "icon-ppt";
if (["pdf"].includes(suffix)) return "icon-pdf1";
if (["txt"].includes(suffix)) return "icon-txt";
if (["jpg", "jpeg", "png", "gif", "bmp"].includes(suffix)) return "icon-tupian1";
// 平台 icons.css 的 icon-tupian 是线性「图片」图标;icon-tupian1 是旧彩色图转单色,看不出是图片
if (["jpg", "jpeg", "png", "gif", "bmp"].includes(suffix)) return "icon-tupian";
if (["zip", "rar"].includes(suffix)) return "icon-yasuo";
return "icon-weizhi";
}
@@ -163,7 +165,7 @@ function shortDate(date: string) {
/* 时间显示(列表 MM/dd HH:mm,同旧版) */</script>
<template>
<div class="page-wrapper">
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="accept-wrap">
<!-- 左侧通知列表 -->
@@ -171,7 +173,7 @@ function shortDate(date: string) {
<div class="total">
<span>未读<span class="num">{{ page.unreadCount || 0 }}</span>条</span>
<Tooltip content="一键已读" placement="top" transfer>
<i class="iconfont icon-saoba clear-icon" @click="allRead"></i>
<SvgIcon class="clear-icon" name="read-all" :size="18" color="var(--text-3)" hover-color="var(--brand)" @click="allRead" />
</Tooltip>
</div>
<div class="search-box">
@@ -211,11 +213,11 @@ function shortDate(date: string) {
</div>
</div>
<div class="title-content">
<p>来源:{{ detail.DeptName }}</p>
<p>发布人:{{ detail.TeaName }}</p>
<p>发布时间:{{ detail.EditDate }}</p>
<p><span class="meta-label">来源</span>:{{ detail.DeptName }}</p>
<p><span class="meta-label">发布人</span>:{{ detail.TeaName }}</p>
<p><span class="meta-label">发布时间</span>:{{ detail.EditDate }}</p>
<p>
<span>附件:{{ (detail.ChaoSend || []).length }}个</span>
<span class="meta-label">附件</span>:{{ (detail.ChaoSend || []).length }}个
<span class="to-file" v-if="detail.ChaoSend && detail.ChaoSend.length > 0" @click="downAll">全部下载</span>
</p>
</div>
@@ -262,188 +264,280 @@ function shortDate(date: string) {
</template>
<style lang="scss" scoped>
/* 根容器 page-wrapper > main-container:内容区可视高度 = 100vh - 112(头部+面包屑) - 24(容器底padding) */
.accept-wrap {
display: flex;
gap: 15px;
height: calc(100vh - 136px);
/* 已收到页 · 对齐原型 07-seam-pure.html 的 .nt-split(灰底上并排两张白卡)。
通道把 .main-container 画成单张白卡,这里压平它、让两张卡直接坐灰底;
外层 .page-wrapper.yh-fill 由通道转 flex 纵向铺满,卡片 flex:1 撑满、四周留白 14/18/24 全站一致。
所有颜色/圆角/阴影一律走主题令牌,禁止硬编码。 */
.page-wrapper .main-container {
background: transparent;
border: none;
box-shadow: none;
padding: 0;
overflow: visible;
}
.accept-wrap {
display: grid;
grid-template-columns: 296px minmax(0, 1fr);
gap: 14px;
align-items: stretch;
height: 100%;
}
/* ---------- 左:列表卡(原型 .nt-list) ---------- */
.title-list {
width: 360px;
flex: none;
background: #fff;
border: 1px solid #ebeef4;
border-radius: 3px;
padding: 14px 0;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--r);
box-shadow: var(--sh);
.total {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 15px 10px;
padding: 13px 16px 9px;
font-size: 13px;
color: var(--text-2);
.num {
padding: 0 5px;
color: #5c9edc;
padding: 0 2px;
color: var(--brand);
font-size: 15px;
font-weight: 600;
}
.clear-icon {
cursor: pointer;
font-size: 18px;
color: #999;
&:hover {
color: #2d8cf0;
}
}
}
.search-box {
padding: 0 15px;
padding: 0 12px;
margin-bottom: 10px;
/* 让 iview 搜索框吃原型灰底胶囊观感(浅改,不动结构) */
:deep(.ivu-input-wrapper) {
width: 100%;
}
:deep(.ivu-input) {
background: #f7f9fc;
border-color: transparent;
border-radius: var(--r-s);
color: var(--text);
}
:deep(.ivu-input:hover) {
background: #f1f4f9;
border-color: transparent;
}
:deep(.ivu-input-wrapper-focused .ivu-input) {
background: #fff;
border-color: var(--brand);
box-shadow: 0 0 0 3px var(--brand-a);
}
}
.notice-items {
flex: 1;
min-height: 0;
overflow-y: auto;
margin-top: 12px;
padding: 0 8px 12px;
.list_item {
padding: 10px 15px;
cursor: pointer;
padding: 10px 12px;
margin-bottom: 2px;
border-radius: var(--r-s);
border-left: 3px solid transparent;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
.tt {
font-size: 13px;
font-weight: 600;
color: #272829;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.other {
margin-top: 6px;
margin-top: 5px;
display: flex;
justify-content: space-between;
font-size: 12px;
color: #999;
font-size: 11.5px;
color: var(--text-3);
}
&:hover {
background: #f6f9ff;
background: #f7f9fc;
}
&.active {
background: #ecf3ff;
border-left-color: #2d8cf0;
background: var(--brand-soft);
border-left-color: var(--brand);
.tt {
color: var(--brand);
}
}
/* 已读弱化 */
&.readed .tt {
color: #8a94a6;
color: var(--text-2);
font-weight: 400;
}
}
.empty {
text-align: center;
color: #999;
color: var(--text-3);
padding: 40px 0;
}
}
}
/* ---------- 右:详情卡(原型 .nt-detail) ---------- */
.detail-content {
flex: 1;
background: #fff;
border: 1px solid #ebeef4;
border-radius: 3px;
padding: 16px 20px;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--r);
box-shadow: var(--sh);
padding: 18px 22px 24px;
&.empty-detail {
align-items: center;
justify-content: center;
color: #999;
color: var(--text-3);
}
.title-header {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
.tt {
font-size: 18px;
font-size: 17px;
font-weight: 600;
color: #272829;
letter-spacing: 0.2px;
color: var(--text);
}
.ops {
display: flex;
gap: 16px;
gap: 14px;
.op {
cursor: pointer;
color: #515a6e;
color: var(--text-2);
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 13px;
gap: 5px;
font-size: 12.5px;
transition: color 0.15s;
&:hover {
color: #2d8cf0;
color: var(--brand);
}
}
}
}
.title-content {
margin-top: 10px;
padding: 8px 0;
border-bottom: 1px dashed #e8e8e8;
font-size: 13px;
color: #808695;
margin-top: 14px;
padding-bottom: 16px;
border-bottom: 1px dashed var(--line);
display: grid;
gap: 9px;
font-size: 12.5px;
color: var(--text-2);
p {
margin: 4px 0;
margin: 0;
}
/* 标签两端撑开:冒号纵向对齐、值从同一列起(宽度取「发布时间」4 字) */
.meta-label {
display: inline-block;
width: 4em;
text-align: justify;
text-align-last: justify;
}
.to-file {
margin-left: 10px;
color: #2d8cf0;
margin-left: 8px;
color: var(--brand);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
.notice-content {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-top: 10px;
padding-top: 14px;
.ql-editor {
font-size: 14px;
color: var(--text);
}
.file {
border-top: 1px solid #f0f0f0;
margin-top: 16px;
border: 1px solid var(--line);
border-radius: var(--r);
overflow: hidden;
.file-title {
display: flex;
justify-content: space-between;
background: #f7f7f7;
padding: 8px 12px;
font-size: 13px;
align-items: center;
padding: 9px 14px;
background: #f7f9fc;
border-bottom: 1px solid var(--line);
font-size: 12.5px;
color: var(--text-2);
.all-down {
color: #2d8cf0;
color: var(--brand);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
}
.file-list {
padding: 10px 12px;
padding: 2px 0;
.file-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 6px;
border-bottom: 1px dashed #f0f0f0;
padding: 11px 14px;
transition: background 0.15s;
&:hover {
background: #fafbfe;
}
.left {
flex: 0 0 auto;
}
.file-type-icon {
font-size: 26px;
color: #515a6e;
width: 30px;
height: 30px;
display: grid;
place-items: center;
border-radius: 8px;
background: var(--brand-soft);
color: var(--brand);
font-size: 16px;
}
.center {
flex: 1;
overflow: hidden;
min-width: 0;
.name {
color: #272829;
font-size: 13px;
font-size: 12.5px;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.size {
font-size: 12px;
color: #999;
font-size: 11.5px;
color: var(--text-3);
margin-top: 2px;
}
}
.right a {
margin-right: 8px;
color: #2d8cf0;
font-size: 13px;
.right {
display: flex;
gap: 14px;
flex: 0 0 auto;
a {
color: var(--brand);
cursor: pointer;
font-size: 12.5px;
&:hover {
text-decoration: underline;
}
}
}
}
}
+273
View File
@@ -0,0 +1,273 @@
<template>
<div class="page-wrapper yh-fill">
<div class="main-container yh-tabs-card">
<Tabs class="tabs-panel" v-model="tab" @on-click="load">
<TabPane label="我的待审核" name="todo" />
<TabPane label="办件视角" name="instances" />
</Tabs>
<!-- 待审核列表 -->
<div class="tab-body" v-if="tab === 'todo'">
<div class="table-region" ref="todoRegionRef">
<Table :columns="todoCols" :data="todoRows" :height="todoH || undefined" :loading="loading" no-data-text="暂无待审核通知">
<template #opinion="{ row }">
<span class="opinion">{{ row._opinion }}</span>
</template>
<template #action="{ row }">
<span class="operating-link" @click="openEmbed(row)">查看详情</span>
<span class="operating-link" @click="approve(row)">通过</span>
<span class="operating-link text-error" @click="openReject(row)">驳回</span>
</template>
</Table>
</div>
<div class="page-panel" v-if="todoPage.total > todoPage.pageSize">
<Page class="page-box fr" :current="todoPage.pageIndex" :page-size="todoPage.pageSize"
:total="todoPage.total" show-total @on-change="loadTodo" />
</div>
</div>
<!-- 办件视角 -->
<div class="tab-body" v-else>
<div class="filter-bar">
<RadioGroup v-model="instStatus" type="button" @on-change="loadInstances(1)">
<Radio :label="0">全部</Radio>
<Radio :label="10">审批中</Radio>
<Radio :label="20">已完成</Radio>
<Radio :label="30">已拒绝</Radio>
<Radio :label="40">已撤回</Radio>
</RadioGroup>
</div>
<div class="table-region" ref="instRegionRef">
<Table :columns="instCols" :data="instRows" :height="instH || undefined" :loading="loading" no-data-text="暂无办件">
<template #status="{ row }">
<Tag :color="instStatusColor(row.status)">{{ instStatusText(row.status) }}</Tag>
</template>
<template #instAction="{ row }">
<Button size="small" @click="openEmbed(row)">审批记录</Button>
</template>
</Table>
</div>
<div class="page-panel" v-if="instPage.total > instPage.pageSize">
<Page class="page-box fr" :current="instPage.pageIndex" :page-size="instPage.pageSize"
:total="instPage.total" show-total @on-change="loadInstances" />
</div>
</div>
</div>
<!-- 驳回弹窗:意见必填 -->
<Modal
v-model="rejectVisible"
title="驳回审核"
:loading="acting"
@on-ok="doReject"
@on-cancel="rejectTaskId = null"
>
<p style="margin-bottom: 8px">
驳回「<b>{{ rejectTitle }}</b
>」,必须填写意见:
</p>
<Input v-model="rejectOpinion" type="textarea" :rows="3" placeholder="驳回意见(必填)" />
</Modal>
<!-- 审批记录 Drawer(flow-center 嵌入页 iframe 撑满:状态/基本信息/流程示意图/轨迹/审批流水/表单六块) -->
<Drawer v-model="embedVisible" title="审批记录" :width="740" :transfer="false" :styles="{ body: { padding: '0' } }">
<div class="embed-frame-wrap">
<iframe v-if="embedPageUrl" :src="embedPageUrl" title="办件进度"></iframe>
</div>
</Drawer>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { Message } from "view-ui-plus";
import { useElementHeight } from "@/utils/hook/useElementHeight";
import approvalApi, {
type ApprovalInstance,
type ApprovalTodoTask,
} from "@/api/module/approval";
import { noticeApi } from "@/api/module/notice";
/**
* 我的待审核(应用内审批,第三步):待办列表 + 办件视角,通过/驳回走本应用后端代理
* (operator=登录态,对端按任务办理人复核;非办理人会收到业务错提示)。
* bizSnapshot 是发起时透传的 JSON 摘要,解析失败只展示标题。
*/
const tab = ref<"todo" | "instances">("todo");
const loading = ref(false);
const acting = ref(false);
/* 两个表格高度:实测各自 .table-region(flex:1),替代写死常量 */
const todoRegionRef = ref<HTMLElement | null>(null);
const instRegionRef = ref<HTMLElement | null>(null);
const todoH = useElementHeight(todoRegionRef);
const instH = useElementHeight(instRegionRef);
const todoRows = ref<(ApprovalTodoTask & { _summary: string })[]>([]);
const todoPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
const instRows = ref<ApprovalInstance[]>([]);
const instPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
const instStatus = ref(0);
const rejectVisible = ref(false);
const rejectTaskId = ref<number | null>(null);
const rejectTitle = ref("");
const rejectOpinion = ref("");
const todoCols = [
{ title: "通知", key: "bizTitle", minWidth: 220, ellipsis: true, tooltip: true },
{ title: "摘要", key: "_summary", minWidth: 200, ellipsis: true, tooltip: true },
{ title: "当前节点", key: "nodeName", width: 110 },
{ title: "到达时间", key: "arriveTime", width: 170, render: fmtTime },
{ title: "操作", slot: "action", width: 190, align: "center" },
];
const instCols = [
{ title: "通知", key: "bizTitle", minWidth: 240, ellipsis: true, tooltip: true },
{ title: "状态", slot: "status", width: 100 },
{ title: "提交人", key: "submitterName", width: 100 },
{ title: "提交时间", key: "submitTime", width: 170, render: fmtTime },
{ title: "办结时间", key: "finishTime", width: 170, render: fmtTime },
{ title: "操作", slot: "instAction", width: 100 },
];
/* 审批记录 Drawer(嵌入页 iframe):pageUrl 含 10 分钟短时 token,每次打开现取,
* 不缓存、不落 storage(既有安全纪律);业务错(尚未进入流程/无权限 403)原文提示,不打断列表 */
const embedVisible = ref(false);
const embedPageUrl = ref("");
async function openEmbed(row: ApprovalInstance | ApprovalTodoTask) {
try {
const res = await noticeApi.flowEmbed(Number(row.bizId)).send();
embedPageUrl.value = res?.pageUrl || "";
if (!embedPageUrl.value) {
Message.error("审批记录地址获取失败");
return;
}
embedVisible.value = true;
} catch (e: any) {
Message.error(e?.message || "审批记录打开失败");
}
}
function fmtTime(h: any, p: any) {
return h(
"span",
{},
String(p.row[p.column.key] || "").replace("T", " ").slice(0, 19) || "—"
);
}
function parseSummary(task: ApprovalTodoTask): string {
if (!task.bizSnapshot) return "—";
try {
const snap = JSON.parse(task.bizSnapshot);
return snap.summary || snap.title || "—";
} catch {
return "—";
}
}
async function loadTodo(page = 1) {
loading.value = true;
try {
const res = await approvalApi.todo(page, todoPage.value.pageSize).send();
todoPage.value = res;
todoRows.value = (res.content || []).map((t) => ({ ...t, _summary: parseSummary(t) }));
} catch (e: any) {
Message.error(e?.message || "待审核列表加载失败");
} finally {
loading.value = false;
}
}
async function loadInstances(page = 1) {
loading.value = true;
try {
const res = await approvalApi
.instances(instStatus.value || undefined, page, instPage.value.pageSize)
.send();
instPage.value = res;
instRows.value = res.content || [];
} catch (e: any) {
Message.error(e?.message || "办件列表加载失败");
} finally {
loading.value = false;
}
}
function load() {
if (tab.value === "todo") {
loadTodo(1);
} else {
loadInstances(1);
}
}
async function approve(row: ApprovalTodoTask) {
if (acting.value) return; // 链接式操作无 :loading 反馈,用 acting 拦截连点重复提交
acting.value = true;
try {
await approvalApi.approve(row.taskId).send();
Message.success("已通过");
loadTodo(todoPage.value.pageIndex);
} catch (e: any) {
// 非办理人/已被办理等业务错,后端信封 msg 直接展示
Message.error(e?.message || "操作失败");
} finally {
acting.value = false;
}
}
function openReject(row: ApprovalTodoTask) {
rejectTaskId.value = row.taskId;
rejectTitle.value = row.bizTitle;
rejectOpinion.value = "";
rejectVisible.value = true;
}
async function doReject() {
if (!rejectOpinion.value.trim()) {
Message.warning("驳回必须填写意见");
return;
}
acting.value = true;
try {
await approvalApi.reject(rejectTaskId.value!, rejectOpinion.value.trim()).send();
Message.success("已驳回");
rejectVisible.value = false;
loadTodo(todoPage.value.pageIndex);
} catch (e: any) {
Message.error(e?.message || "操作失败");
} finally {
acting.value = false;
}
}
function instStatusText(s: number) {
return ({ 10: "审批中", 20: "已完成", 30: "已拒绝", 40: "已撤回" } as Record<number, string>)[s] || String(s);
}
function instStatusColor(s: number) {
return ({ 10: "blue", 20: "green", 30: "red", 40: "default" } as Record<number, string>)[s] || "default";
}
onMounted(load);
</script>
<style scoped>
.filter-bar {
margin-bottom: 12px;
}
/* 审批记录 Drawer:iframe 撑满,嵌入页内部自己滚动 */
.embed-frame-wrap {
height: 100%;
}
.embed-frame-wrap iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
}
</style>
+71
View File
@@ -0,0 +1,71 @@
<template>
<div class="page-wrapper yh-fill">
<div class="main-container">
<TablePageAlovaNew ref="listRef" :data-api="fetchPage" :columns="cols">
<template #title="{ row }">
<a v-if="row.title" class="notice-link" @click="openDetail(row)">{{ row.title }}</a>
<span v-else class="notice-gone">通知已删除</span>
</template>
<template #status="{ row }">
<Tag v-if="row.auditStatusDesc && row.auditStatusDesc !== '通知已删除'"
:color="statusColor(row.auditStatus)">{{ row.auditStatusDesc }}</Tag>
<Tag v-else-if="row.noticeStatus === 1" color="green">已发布</Tag>
<Tag v-else>—</Tag>
</template>
</TablePageAlovaNew>
</div>
</div>
</template>
<script setup lang="ts">
import { ref } from "vue";
import { useRouter } from "vue-router";
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
import ccApi, { type CcItem } from "@/api/module/cc";
/**
* 抄送给我的(2026-09-24):流程 NOTIFY 节点激活即抄送(审批过程中就出现在列表里,
* 此时通知未发布——被抄送人可见由 NoticeFlowGuard 第 4 条放行)。点击标题跳通知详情;
* 编辑视角(type=0)不标读:未发布通知的阅读统计无意义。
*/
const router = useRouter();
const listRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
// usePagination 的 handler 必须返回 Method(组件内部统一 send 并吃全局响应拦截),返回 Promise 会卡在 loading
const fetchPage = (p: { pageIndex: number; pageSize: number }) => ccApi.my(p.pageIndex, p.pageSize);
const cols = [
{ title: "通知标题", slot: "title", minWidth: 260, ellipsis: true, tooltip: true },
{ title: "来源部门", key: "deptName", width: 140 },
{ title: "抄送环节", key: "nodeName", width: 140 },
{ title: "抄送时间", key: "sentTime", width: 170, render: fmtTime },
{ title: "状态", slot: "status", width: 100 },
];
function fmtTime(h: any, p: any) {
return h(
"span",
{},
String(p.row[p.column.key] || "").replace("T", " ").slice(0, 19) || "—"
);
}
function statusColor(auditStatus: number | null) {
return ({ 0: "default", 10: "blue", 20: "green", 30: "red", 40: "orange" } as Record<number, string>)[
auditStatus ?? -1
] || "default";
}
function openDetail(row: CcItem) {
router.push({ path: "/list/detail", query: { serid: row.noticeId, type: 0 } });
}
</script>
<style scoped>
.notice-link {
cursor: pointer;
}
.notice-gone {
color: #999;
}
</style>
+328 -39
View File
@@ -3,9 +3,11 @@ import { ref, computed, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { noticeApi } from "@/api/module/notice";
import type { NoticeDetailVo, NextPrevVo, ReadGroup } from "@/types/notice";
import type { NoticeDetailVo, NextPrevVo, ReadGroup, FlowTraceVo } from "@/types/notice";
import FilePreviewModal from "@/components/FilePreviewModal.vue";
import Utils from "@/utils";
import { fitDiagram } from "@/utils/diagramImg";
import config from "@/assets/config/host.config";
const route = useRoute();
const router = useRouter();
@@ -16,12 +18,98 @@ const viewType = computed(() => {
const t = Number(route.query.type);
return t === 0 ? 0 : 1;
});
const fromPath = computed(() => (route.query.from as string) || "/accept");
/* 上一/下一篇的序列类型:管理视角走已发(type=0),查看走已收(type=1) */
const navType = computed(() => (viewType.value === 0 ? "0" : "1"));
const detail = ref<NoticeDetailVo | null>(null);
const loading = ref(false);
/* ============ 先审后发:审批进度卡片 + 撤回/催办 ============ */
const flowTrace = ref<FlowTraceVo | null>(null);
const flowActing = ref(false);
async function loadTrace() {
if (!detail.value?.flowGuid) return;
try {
flowTrace.value = await noticeApi.flowTrace(serid.value).send();
} catch {
flowTrace.value = null; // 进度不可用时只展示本地镜像
}
}
/**
* 撤回:走本应用后端(2026-09-17 接入:后端经 SDK 托管件中转并复核创建人,
* 不再前端直调 flow-center——跨应用 CAS 票据 + CORS 双重外溢且"谁能撤"落在前端)。
* 催办:仍是"人"的动作,走 flow-center 用户面(同主域 CAS 免二次登录)。
*/
async function flowAction(action: "withdraw" | "urge") {
const instanceId = detail.value?.flowInstanceId;
if (!instanceId) return;
flowActing.value = true;
try {
if (action === "withdraw") {
await noticeApi.flowWithdraw(serid.value).send();
Message.success("已撤回审核,可编辑后重新提交");
await load();
await loadTrace();
} else {
if (!config.flowHost) return;
const resp = await fetch(`${config.flowHost}/api/flow/instance/${instanceId}/urge`, {
method: "POST",
credentials: "include",
});
const res = await resp.json().catch(() => null);
if (resp.ok && res && res.code === 0) {
Message.success("已发送催办提醒");
} else {
Message.error(res?.msg || "催办失败");
}
}
} catch (e: any) {
Message.error(
action === "withdraw"
? "撤回失败(已有人办理时不可撤回):" + (e?.msg || e?.message || e)
: "流程中心不可达:" + (e?.message || e),
);
} finally {
flowActing.value = false;
}
}
/* 流程图弹窗(2026-09-17:后端同源出图 /flow/Diagram,img 免 token 免跨域,不再深链 flow-center) */
const diagramVisible = ref(false);
const diagramEl = ref<HTMLImageElement | null>(null);
function openFlowDiagram() {
if (!detail.value?.flowGuid || !detail.value?.flowInstanceId) return;
diagramVisible.value = true;
}
const diagramSrc = computed(() =>
diagramVisible.value && detail.value?.flowInstanceId
? `${config.serviceHost}/api/notice/flow/Diagram?serid=${serid.value}`
: "",
);
function onDiagramError() {
diagramVisible.value = false;
Message.error("流程图加载失败(可能已过期,请刷新页面重试)");
}
/* 办件进度嵌入页(iframe 免签嵌入 flow-center 统一进度页):每次打开现取 pageUrl——
* token 10 分钟短时效,不缓存、不落 localStorage 复用;嵌入页内部自己滚动 */
const embedVisible = ref(false);
const embedPageUrl = ref("");
async function openFlowEmbed() {
if (!detail.value?.flowInstanceId) return;
embedVisible.value = true;
embedPageUrl.value = "";
try {
const res = await noticeApi.flowEmbed(serid.value).send();
embedPageUrl.value = res?.pageUrl || "";
} catch (e: any) {
embedVisible.value = false;
Message.error(e?.msg || e?.message || "嵌入页地址获取失败");
}
}
const readGroups = ref<ReadGroup[]>([]);
const showRead = ref(false);
const prevId = ref(0);
@@ -29,6 +117,7 @@ const nextId = ref(0);
onMounted(async () => {
await load();
loadTrace(); // 详情加载后并行取审批进度(未绑流程接口自身返回 bound=false)
});
async function load() {
@@ -39,6 +128,7 @@ async function load() {
const nav: NextPrevVo = await noticeApi.getNextPrevNotice(serid.value, navType.value).send();
prevId.value = nav.prevId || 0;
nextId.value = nav.nextId || 0;
loadTrace();
} catch (e: any) {
Message.error(e.message || "加载通知失败");
} finally {
@@ -98,6 +188,15 @@ function onPrint() {
win.print();
}
/** 审核状态 Tag 颜色 */
function auditColor(auditStatus?: number | null) {
if (auditStatus === 20) return "green";
if (auditStatus === 30) return "red";
if (auditStatus === 40) return "orange";
if (auditStatus === 0) return "default";
return "blue"; // 审批中
}
function downFile(id: number) {
if (!id) return;
window.location.href = noticeApi.fileDownUrl(id);
@@ -110,7 +209,7 @@ function downAll() {
function displayName(item: { name?: string; filename?: string }) {
return item.name || item.filename || "";
}
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian1/yasuo/weizhi) */
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian/yasuo/weizhi) */
function getFileIcon(name: string) {
const suffix = String(name || "").split(".").pop()?.toLowerCase() || "";
if (["doc", "docx", "wps"].includes(suffix)) return "icon-word";
@@ -118,7 +217,8 @@ function getFileIcon(name: string) {
if (["ppt", "pptx"].includes(suffix)) return "icon-ppt";
if (["pdf"].includes(suffix)) return "icon-pdf1";
if (["txt"].includes(suffix)) return "icon-txt";
if (["jpg", "jpeg", "png", "gif", "bmp"].includes(suffix)) return "icon-tupian1";
// 平台 icons.css 的 icon-tupian 是线性「图片」图标;icon-tupian1 是旧彩色图转单色,看不出是图片
if (["jpg", "jpeg", "png", "gif", "bmp"].includes(suffix)) return "icon-tupian";
if (["zip", "rar"].includes(suffix)) return "icon-yasuo";
return "icon-weizhi";
}
@@ -140,16 +240,12 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
fileName: displayName(item),
};
}
function back() {
router.push(fromPath.value);
}
</script>
<template>
<div class="page-wrapper">
<div class="main-container" v-if="detail">
<div class="ops-top">
<Button icon="ios-arrow-back" @click="back">返回</Button>
<div class="nav">
<Button size="small" :disabled="!prevId" @click="goNav(prevId)">上一篇</Button>
<Button size="small" :disabled="!nextId" @click="goNav(nextId)">下一篇</Button>
@@ -181,6 +277,88 @@ function back() {
<!-- 查看视角:普通标题 -->
<h2 v-else class="notice-title2">{{ detail.title }}</h2>
<!-- 审批进度卡片(绑流程通知;提交人/管理员/办理人可见) -->
<div v-if="flowTrace && flowTrace.bound" class="flow-card">
<div class="flow-card-head">
<span class="flow-title">审批进度</span>
<Tag :color="auditColor(flowTrace.auditStatus)">{{ flowTrace.auditStatusDesc || "—" }}</Tag>
<span v-if="flowTrace.nodeSummary" class="flow-nodes">流程:{{ flowTrace.nodeSummary }}</span>
<span v-if="flowTrace.currentNodes" class="flow-nodes">当前节点:{{ flowTrace.currentNodes }}</span>
<div class="flow-ops">
<Button v-if="detail.flowGuid" size="small" @click="openFlowDiagram">查看流程图</Button>
<Button
v-if="detail.flowInstanceId"
size="small"
style="margin-left: 8px"
@click="openFlowEmbed"
>审批进度</Button
>
<Button
v-if="detail.flowInstanceId && flowTrace.auditStatus === 10"
size="small"
:loading="flowActing"
style="margin-left: 8px"
@click="flowAction('withdraw')"
>撤回审核</Button
>
<Button
v-if="detail.flowInstanceId && flowTrace.auditStatus === 10"
size="small"
:loading="flowActing"
style="margin-left: 8px"
@click="flowAction('urge')"
>催办</Button
>
</div>
</div>
<!-- 驳回/撤回意见 -->
<div v-if="flowTrace.flowResult && flowTrace.flowResult.opinion" class="flow-opinion">
办理意见:{{ flowTrace.flowResult.opinion }}
<template v-if="flowTrace.flowResult.rejectNode">({{ flowTrace.flowResult.rejectNode }})</template>
</div>
<!-- 结构化步骤时间线(2026-09-17 接入 SDK progress;steps[0] 固定"发起申请",同节点多任务每人一行) -->
<div v-if="flowTrace.steps && flowTrace.steps.length" class="flow-timeline">
<div
v-for="(s, i) in flowTrace.steps"
:key="'fs' + i"
class="flow-step"
:class="{ 'flow-step-active': s.active }"
>
<span class="flow-step-node">{{ s.label || s.nodeName || `节点${i + 1}` }}</span>
<template v-if="s.assignees && s.assignees.length">
<span v-for="(a, j) in s.assignees" :key="j" class="flow-step-user">
{{ a.userName || "" }}
<em v-if="a.statusLabel" class="flow-step-status">{{ a.statusLabel }}</em>
<em v-if="a.opinion" class="flow-step-op">({{ a.opinion }})</em>
</span>
</template>
</div>
</div>
<!-- 节点时间线(traceJson 解析,steps 不可用时的兜底) -->
<div v-else-if="flowTrace.trace && flowTrace.trace.length" class="flow-timeline">
<div v-for="(t, i) in flowTrace.trace" :key="i" class="flow-step">
<span class="flow-step-node">{{ t.nodeName || t.node || t.taskName || `节点${i + 1}` }}</span>
<span class="flow-step-user">{{ t.userName || t.assigneeName || t.handlerName || "" }}</span>
<span class="flow-step-time">{{ t.handleTime || t.time || t.createTime || "" }}</span>
<span class="flow-step-op" v-if="t.opinion">{{ t.opinion }}</span>
</div>
</div>
</div>
<!-- 流程图弹窗(后端同源出图,免 token 免跨域) -->
<Modal v-model="diagramVisible" title="流程示意图" :width="860" footer-hide>
<div class="diagram-wrap">
<img v-if="diagramSrc" ref="diagramEl" :src="diagramSrc" alt="流程示意图" @load="fitDiagram(diagramEl)" @error="onDiagramError" />
</div>
</Modal>
<!-- 办件进度嵌入页(Drawer 内 iframe 撑满,嵌入页内部滚动;token 每次打开现取) -->
<Drawer v-model="embedVisible" title="审批进度" :width="740" :transfer="false" :styles="{ body: { padding: '0' } }">
<div class="embed-frame-wrap">
<iframe v-if="embedPageUrl" :src="embedPageUrl" title="办件进度"></iframe>
</div>
</Drawer>
<div v-if="viewType !== 0" class="meta">
<span>发文单位:{{ detail.DeptName || "—" }}</span>
<span>发布人:{{ detail.TeaName || "—" }}</span>
@@ -201,9 +379,12 @@ function back() {
</div>
</div>
<!-- 管理视角底部操作(同旧版:编辑 / 打印) -->
<!-- 管理视角底部操作(同旧版:编辑 / 打印;审批中隐藏编辑——后端 403 死按钮,BP-R5-1) -->
<div v-if="viewType === 0" class="bottom-btns">
<Button @click="onEdit">编辑</Button>
<Button v-if="detail.auditStatus !== 10" @click="onEdit">编辑</Button>
<Tooltip v-else content="审批中,请先撤回再编辑" placement="top" transfer>
<Button disabled>编辑</Button>
</Tooltip>
<Button style="margin-left: 10px" @click="onPrint">打印</Button>
</div>
@@ -238,6 +419,19 @@ function back() {
</template>
<style scoped>
/* 高度模型(2026-10-10 定稿):卡片撑满视口(与全站其他页一致),但必须能"随长文生长"。
不能直接用通道 .yh-fill:其卡片是 flex:1(basis:0)+ min-height:420px,卡片被钉在容器高,
而 .main-container 带 overflow:hidden(base.layout.css:222)→ 长文被裁掉且滚不到(最小复现已验证)。
故此处 page-wrapper 转 flex 纵向(全局已给 min-height:100%),卡片 flex:1 0 auto(basis:auto)
→ 短文撑满、长文随内容长高、滚动留在 main-wrapper 整页滚。 */
.page-wrapper {
display: flex;
flex-direction: column;
}
.page-wrapper > .main-container {
flex: 1 0 auto;
min-height: 420px;
}
.ops-top {
display: flex;
justify-content: space-between;
@@ -249,16 +443,110 @@ function back() {
}
.notice-title2 {
font-size: 20px;
font-weight: 600;
color: var(--text);
margin: 0 0 12px;
}
.flow-card {
border: 1px solid var(--line);
border-radius: var(--r-s);
padding: 10px 14px;
margin-bottom: 14px;
background: #f7f9fc;
}
.flow-card-head {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
font-size: 13px;
}
.flow-title {
font-weight: 600;
color: var(--text);
}
.flow-nodes {
color: var(--text-3);
}
.flow-ops {
margin-left: auto;
}
.flow-opinion {
margin-top: 8px;
padding: 6px 10px;
background: var(--panel);
border-radius: var(--r-s);
color: var(--red);
font-size: 13px;
}
.flow-timeline {
margin-top: 8px;
background: var(--panel);
border-radius: var(--r-s);
padding: 6px 10px;
}
.flow-step {
display: flex;
gap: 12px;
font-size: 13px;
color: var(--text-2);
padding: 3px 0;
border-bottom: 1px dashed var(--line);
}
.flow-step:last-child {
border-bottom: none;
}
.flow-step-node {
min-width: 120px;
}
.flow-step-user {
min-width: 90px;
color: var(--brand);
}
.flow-step-time {
color: var(--text-3);
}
.flow-step-op {
color: var(--red);
}
.flow-step-active .flow-step-node {
color: var(--brand);
font-weight: 600;
}
.flow-step-status {
font-style: normal;
color: var(--text-3);
margin-left: 4px;
}
.flow-step .flow-step-op {
font-style: normal;
}
.diagram-wrap {
text-align: center;
min-height: 200px;
}
.diagram-wrap img {
max-width: 100%;
height: auto;
}
/* 嵌入页 iframe 撑满 Drawer body(body padding 已由 :styles 置 0),嵌入页内部滚动 */
.embed-frame-wrap {
height: 100%;
}
.embed-frame-wrap iframe {
display: block;
width: 100%;
height: 100%;
border: 0;
}
.meta {
display: flex;
flex-wrap: wrap;
gap: 18px;
padding: 8px 0;
border-bottom: 1px dashed #e8e8e8;
border-bottom: 1px dashed var(--line);
font-size: 13px;
color: #808695;
color: var(--text-3);
}
/* 管理视角统计条 */
.stat-bar {
@@ -267,7 +555,7 @@ function back() {
gap: 24px;
padding: 4px 0;
font-size: 13px;
color: #17233d;
color: var(--text);
flex-wrap: wrap;
}
.progress-box {
@@ -277,7 +565,7 @@ function back() {
width: 300px;
}
.percent-label {
color: #f60;
color: var(--brand);
white-space: nowrap;
font-size: 13px;
}
@@ -288,18 +576,15 @@ function back() {
white-space: nowrap;
cursor: pointer;
}
.stat-nums:hover .item {
color: #2d8cf0;
}
.stat-nums .item {
color: #8a94a6;
color: var(--text-3);
}
.stat-nums b {
font-size: 20px;
margin: 0 2px;
}
.stat-nums .blue {
color: #5c9edc;
color: var(--brand);
}
.stat-nums .orange {
color: #f60;
@@ -310,66 +595,70 @@ function back() {
flex-wrap: wrap;
}
.divider {
border-bottom: 1px dashed #e8e8e8;
border-bottom: 1px dashed var(--line);
margin: 8px 0;
}
.content {
padding: 16px 0;
/* 正文区最小高度:短通知别让附件/按钮紧贴一行字(旧版用 clientHeight-356 铺满视口,
会把附件顶出首屏;这里只留"呼吸区",剩余空白落在按钮下方的卡片底部) */
min-height: 200px;
line-height: 1.8;
word-break: break-all;
color: var(--text);
}
.files {
border-top: 1px dashed #e8e8e8;
border-top: 1px dashed var(--line);
padding-top: 12px;
}
.files-title {
font-size: 13px;
color: #808695;
color: var(--text-3);
margin-bottom: 8px;
}
.down-all {
margin-left: 12px;
color: #2d8cf0;
color: var(--brand);
cursor: pointer;
}
.file-item {
display: inline-flex;
align-items: center;
gap: 6px;
color: #515a6e;
color: var(--text-2);
margin: 0 16px 8px 0;
font-size: 13px;
.file-type-icon {
font-size: 16px;
color: #515a6e;
color: var(--text-2);
}
.file-name {
max-width: 320px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-size {
color: #999;
color: var(--text-3);
font-size: 12px;
}
.file-op {
color: #2d8cf0;
color: var(--brand);
cursor: pointer;
}
}
.bottom-btns {
border-top: 1px dashed #e8e8e8;
border-top: 1px dashed var(--line);
padding-top: 14px;
margin-top: 8px;
}
/* 阅读情况弹窗 */
.read-tip {
background: #e8f4ff;
border: 1px solid #d3e9ff;
color: #515a6e;
background: var(--brand-soft);
border: 1px solid var(--line);
color: var(--text-2);
padding: 8px 12px;
border-radius: 4px;
border-radius: var(--r-s);
font-size: 13px;
margin-bottom: 12px;
}
@@ -386,7 +675,7 @@ function back() {
flex: none;
width: 130px;
text-align: right;
color: #515a6e;
color: var(--text-2);
font-size: 13px;
line-height: 22px;
}
@@ -402,13 +691,13 @@ function back() {
cursor: default;
}
.green {
color: #19be6b;
color: var(--green);
}
.red {
color: #ed4014;
color: var(--red);
}
.empty {
color: #999;
color: var(--text-3);
font-size: 13px;
}
</style>
+76 -28
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch, h } from "vue";
import { ref, computed, onMounted, watch, h, resolveComponent } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useRequest } from "alova/client";
import { Message } from "view-ui-plus";
@@ -7,6 +7,7 @@ import { noticeApi } from "@/api/module/notice";
import type { NoticeVo, NoticeQuery } from "@/types/notice";
import MyConfirm from "@/components/MyConfirm";
import Utils from "@/utils";
import { useElementHeight } from "@/utils/hook/useElementHeight";
const route = useRoute();
const router = useRouter();
@@ -17,13 +18,25 @@ const isManage = computed(() => route.path === "/noticelist");
/* ============ 搜索条件(规范 §1:日期 fl + 下拉 fl + 关键字 fr 宽200 即搜) ============ */
const keyword = ref("");
const deptType = ref("");
const auditType = ref<number | "">(""); // 状态筛选(audit_status;-1=无需审核)
const startDt = ref("");
const endDt = ref("");
const { data: deptOptions } = useRequest(noticeApi.getSysDic());
function fmtDate(d: Date) {
/** 状态筛选选项(与列表「审核状态」列取值一一对应;-1 = 未走审核的直发通知) */
const auditStatusOptions = [
{ value: 0, label: "待提交" },
{ value: 10, label: "审批中" },
{ value: 20, label: "已通过" },
{ value: 30, label: "已驳回" },
{ value: 40, label: "已撤回" },
{ value: -1, label: "无需审核" },
];
function fmtDate(d: Date | string) {
const dt = new Date(d);
const pad = (n: number) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}`;
}
/* ============ 分页(规范 §3:customPageCount 页长记忆) ============ */
@@ -43,8 +56,10 @@ async function run() {
pageSize: pageSize.value,
orderByTime: "1",
};
if (startDt.value) params.startTime = startDt.value;
if (endDt.value) params.endTime = endDt.value;
// DatePicker 选过日期后 v-model 值是 Date 对象,后端 LocalDate 只认 yyyy-MM-dd → 统一格式化
if (startDt.value) params.startTime = fmtDate(new Date(startDt.value));
if (endDt.value) params.endTime = fmtDate(new Date(endDt.value));
if (auditType.value !== "" && auditType.value !== null) params.auditStatus = Number(auditType.value);
if (!isManage.value) {
params.scope = "user";
}
@@ -90,6 +105,7 @@ watch(
pageNum.value = 1;
keyword.value = "";
deptType.value = "";
auditType.value = "";
if (!isManage.value) {
const start = new Date();
start.setMonth(start.getMonth() - 1);
@@ -105,7 +121,9 @@ watch(
/* ============ 操作(规范 §2:operating-btn iconfont;§6:删除 myConfirm) ============ */
function openDetail(row: NoticeVo) {
router.push({ path: "/details", query: { serid: row.id, type: 0, from: route.path } });
// 详情路由随入口:已发送 → /list/detail(高亮已发送),全部通知 → /noticelist/detail(高亮全部通知)
const detailPath = route.path.startsWith("/noticelist") ? "/noticelist/detail" : "/list/detail";
router.push({ path: detailPath, query: { serid: row.id, type: 0 } });
}
function del(row: NoticeVo) {
@@ -128,9 +146,10 @@ function del(row: NoticeVo) {
}
/* 附件下载:逐个触发(间隔 500ms,同旧版) */
function downloadFiles(row: NoticeVo & { UpFile?: string }) {
if (!row.UpFile) return;
row.UpFile.split(",").forEach((id: string, i: number) => {
function downloadFiles(row: NoticeVo) {
const upFile = row.upFile ?? row.UpFile;
if (!upFile) return;
upFile.split(",").forEach((id: string, i: number) => {
setTimeout(() => {
window.location.href = noticeApi.fileDownUrl(Number(id));
}, i * 500);
@@ -139,7 +158,7 @@ function downloadFiles(row: NoticeVo & { UpFile?: string }) {
/* 表格列(对照旧版 notice_list.vue;操作列图标式 width 180 fixed right) */
const columns = [
{ title: "序号", type: "index", width: 60, align: "center" },
{ title: "序号", type: "index", width: 90, align: "center" },
{ title: "标题", key: "title", align: "left", tooltip: true },
{ title: "来源部门", key: "deptName", width: 130, align: "left" },
{ title: "发布人", key: "publisherName", width: 110, align: "left", tooltip: true },
@@ -149,10 +168,22 @@ const columns = [
align: "left",
render: (h: any, p: any) => h("span", {}, String(p.row.createTime || "").slice(0, 16)),
},
{ title: "已读人数", key: "readCount", width: 90, align: "left" },
{
title: "审核状态",
width: 100,
align: "center",
render: (h: any, p: any) => {
const st = p.row.auditStatus;
if (st === null || st === undefined) return h("span", { style: "color:var(--text-3);" }, "—");
// 色系对齐流程侧:待提交灰 / 审批中橙 / 已通过绿 / 已驳回红 / 已撤回灰
const colors: Record<number, string> = { 0: "default", 10: "warning", 20: "success", 30: "error", 40: "default" };
return h(resolveComponent("Tag"), { color: colors[st] || "default" }, () => p.row.auditStatusDesc || "—");
},
},
{ title: "已读人数", key: "readCount", width: 110, align: "left" },
{
title: "未读人数",
width: 90,
width: 110,
align: "left",
render: (h: any, p: any) => h("span", {}, (p.row.allowCount || 0) - (p.row.readCount || 0)),
},
@@ -161,27 +192,33 @@ const columns = [
width: 70,
align: "center",
render: (h: any, p: any) =>
p.row.UpFile
(p.row.upFile ?? p.row.UpFile)
? Utils.operatingHandle(h, "附件下载", "icon-attachment", () => downloadFiles(p.row))
: h("span", { style: "color:#999;" }, "无"),
: h("span", { style: "color:var(--text-3);" }, "无"),
},
{
title: "操作",
width: 180,
align: "center",
fixed: "right",
render: (h: any, p: any) => [
Utils.operatingHandle(h, "查看通知", "icon-browse", () => openDetail(p.row)),
Utils.operatingHandle(h, "删除通知", "icon-ashbin", () => del(p.row)),
],
render: (h: any, p: any) => {
// BP-R5-1:审批中(audit=10)禁止删除(后端 403)——渲染禁用态而非隐藏,保持操作列对齐
const delDisabled = p.row.auditStatus === 10;
return [
Utils.operatingHandle(h, "查看通知", "icon-browse", () => openDetail(p.row)),
Utils.operatingHandle(h, "删除通知", "icon-ashbin", () => del(p.row), delDisabled),
];
},
},
];
const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOperat: true, isPage: true }));
/* 表格高度:不再用 useTableHeight 猜常量,改为实测 .table-region(flex:1)真实高度喂给固定表头 */
const tableRegionRef = ref<HTMLElement | null>(null);
const tableH = useElementHeight(tableRegionRef);
</script>
<template>
<div class="page-wrapper">
<div class="page-wrapper yh-fill">
<div class="main-container">
<!-- 1. 操作按钮区 -->
<div class="fix mb-12">
@@ -198,6 +235,15 @@ const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOper
<Select v-model="deptType" class="search-wrap_sel ml-12" clearable placeholder="来源部门" @on-change="doSearch">
<Option v-for="d in deptOptions" :key="d.k" :value="d.k">{{ d.v }}</Option>
</Select>
<Select
v-model="auditType"
class="search-wrap_sel ml-12"
clearable
placeholder="状态"
@on-change="doSearch"
>
<Option v-for="s in auditStatusOptions" :key="s.value" :value="s.value">{{ s.label }}</Option>
</Select>
<Input
class="fr"
style="width: 200px"
@@ -211,14 +257,16 @@ const tableHeight = computed(() => Utils.useTableHeight({ isSearch: true, isOper
</div>
<!-- 3. 表格 + 分页 -->
<Table
:columns="columns"
:data="page.content"
class="link-table table-bottom-box"
:height="tableHeight"
:loading="loading"
no-data-text="暂无数据"
></Table>
<div class="table-region" ref="tableRegionRef">
<Table
:columns="columns"
:data="page.content"
class="link-table table-bottom-box"
:height="tableH || undefined"
:loading="loading"
no-data-text="暂无数据"
></Table>
</div>
<div class="page-panel">
<div class="fl ml-15 align-middle"></div>
<Page
+210 -27
View File
@@ -5,11 +5,14 @@ import draggable from "vuedraggable";
import MyConfirm from "@/components/MyConfirm";
import UserCheckModal from "@/components/UserCheckModal.vue";
import TypeSetBox from "@/components/TypeSetBox.vue";
import { settingApi, baseApi } from "@/api/module/notice";
import type { DeptManageItem, ContactGroupPage, ContactUserPage, SchoolTreeNode } from "@/types/notice";
import { settingApi, baseApi, noticeApi } from "@/api/module/notice";
import type { DeptManageItem, ContactGroupPage, ContactUserPage, SchoolTreeNode, FlowOption } from "@/types/notice";
import config from "@/assets/config/host.config";
import Utils from "@/utils";
import { fitDiagram } from "@/utils/diagramImg";
import { useElementHeight } from "@/utils/hook/useElementHeight";
/* ============ Tab 切换(规范 §1:tabs-wrapper + Tabs type=card,切换只 v-show 内容区) ============ */
/* ============ Tab 切换(单卡片 yh-tabs-card 内含线性 Tabs,切换只 v-show 对应 tab-body) ============ */
const currentType = ref(1);
function tabChange(name: string) {
@@ -18,9 +21,98 @@ function tabChange(name: string) {
getDeptList();
} else if (currentType.value === 3) {
getGroupList();
} else if (currentType.value === 2) {
loadFlowBinding();
}
}
/* ============ 流程绑定(应用设置 > 流程绑定) ============ */
const flowOptions = ref<FlowOption[]>([]);
const flowEnabled = ref(true);
const flowCanEdit = ref(false); // 仅本校管理员可维护绑定
const boundFlowGuid = ref(""); // 已保存的绑定(提交前的编辑值另用 selectedGuid)
const boundFlowName = ref("");
const selectedGuid = ref("");
const bindingSaving = ref(false);
async function loadFlowBinding() {
try {
const view = await noticeApi.getFlowBinding().send();
flowEnabled.value = view?.flowEnabled !== false;
flowCanEdit.value = view?.canEdit === true;
boundFlowGuid.value = view?.flowGuid || "";
boundFlowName.value = view?.flowName || "";
selectedGuid.value = boundFlowGuid.value;
} catch (e: any) {
Message.error(e.message || "获取流程绑定失败");
}
try {
flowOptions.value = (await noticeApi.getFlowOptions().send()) || [];
} catch {
flowOptions.value = [];
}
}
async function saveFlowBinding() {
bindingSaving.value = true;
try {
const view = await noticeApi.saveFlowBinding(selectedGuid.value).send();
boundFlowGuid.value = view?.flowGuid || "";
boundFlowName.value = view?.flowName || "";
selectedGuid.value = boundFlowGuid.value;
Message.success(boundFlowGuid.value ? "绑定成功" : "已解除绑定");
} catch (e: any) {
Message.error(e.message || "保存失败");
} finally {
bindingSaving.value = false;
}
}
function unbindFlow() {
MyConfirm({
title: "解除绑定",
msg: "确定解除流程绑定吗?",
info: "解除后发送全部用户将不再需要审核,直接发布",
async onOk() {
selectedGuid.value = "";
await saveFlowBinding();
},
});
}
/** 流程图 Drawer(后端 DiagramDef 中转 PNG:同源免 token 免跨域。修复旧深链 404——
* 生产前端静态挂 /flow-center/front*,Caddy 无 /flow-center/ 根路由,window.open 深链必 404) */
const flowDiagramVisible = ref(false);
const flowDiagramEl = ref<HTMLImageElement | null>(null);
const flowDiagramGuid = ref(""); // 打开抽屉时定格的流程 GUID(绑定态=boundFlowGuid,预览态=selectedGuid)
const flowDiagramSrc = computed(() =>
flowDiagramVisible.value && flowDiagramGuid.value
? `${config.serviceHost}/api/notice/flow/DiagramDef?flowGuid=${encodeURIComponent(flowDiagramGuid.value)}`
: "",
);
function openFlowDiagram() {
if (!boundFlowGuid.value) return;
flowDiagramGuid.value = boundFlowGuid.value;
flowDiagramVisible.value = true;
}
/** 预览下拉当前选中(未必已保存)的流程 */
function previewSelectedFlow() {
if (!selectedGuid.value) return;
flowDiagramGuid.value = selectedGuid.value;
flowDiagramVisible.value = true;
}
function onFlowDiagramError() {
flowDiagramVisible.value = false;
Message.error("流程图加载失败(流程可能未发布或绑定已解除)");
}
/** 当前绑定流程的节点摘要(下拉里回显) */
const boundFlowSummary = computed(() => {
const hit = flowOptions.value.find((f) => f.flowGuid === boundFlowGuid.value);
return hit?.nodeSummary || "—";
});
/* ============ 发文部门(直接复用 UserBase 同源 TypeSetBox 卡片组件) ============ */
interface DeptItem {
SerID: number;
@@ -144,16 +236,6 @@ async function deptSortSave() {
}
}
async function syncDept() {
try {
await settingApi.getDeptSync().send();
Message.success("同步成功");
await getDeptList();
} catch (e: any) {
Message.error(e.message || "同步失败");
}
}
/* ============ 自定义组(规范 §5 联系组管理页形态) ============ */
interface GroupItem {
serid: number;
@@ -306,6 +388,8 @@ async function settingUserSubmit(users: SchoolTreeNode[]) {
try {
await settingApi.setContactsTea(setUserGid.value, ids).send();
Message.success("设置成功");
// 刷新组列表,组员数即时更新
await getGroupList();
} catch (e: any) {
Message.error(e.message || "设置失败");
}
@@ -324,7 +408,6 @@ const groupColumns = [
title: "操作",
width: 250,
align: "center",
fixed: "right",
render: (h: any, params: any) => {
const row: GroupItem = params.row;
return [
@@ -337,22 +420,25 @@ const groupColumns = [
},
];
const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isSearch: true, isPage: true }));
/* 自定义组表格高度:实测 .table-region(flex:1),替代 useTableHeight 常量减法 */
const groupRegionRef = ref<HTMLElement | null>(null);
const groupH = useElementHeight(groupRegionRef);
</script>
<template>
<div class="tabs-wrapper">
<Tabs class="tabs-panel" type="card" :value="String(currentType)" @on-click="tabChange">
<div class="page-wrapper yh-fill">
<div class="main-container yh-tabs-card">
<Tabs class="tabs-panel" :value="String(currentType)" @on-click="tabChange">
<TabPane label="发文部门" name="1"></TabPane>
<TabPane label="自定义组" name="3"></TabPane>
<TabPane label="流程绑定" name="2"></TabPane>
</Tabs>
<!-- 发文部门 -->
<div class="main-container" v-show="currentType === 1">
<div class="tab-body" v-show="currentType === 1">
<Alert show-icon>设置说明:拖动卡片调整部门排序;停用的部门不出现在「通知发送」的部门下拉中</Alert>
<div class="search-wrap fix">
<Button class="fl" type="primary" @click="addDeptShow">新增部门</Button>
<Button class="fr" @click="syncDept">同步基础平台部门</Button>
</div>
<draggable
class="type_set-wrap fix"
@@ -377,17 +463,19 @@ const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isS
</div>
<!-- 自定义组 -->
<div class="main-container" v-show="currentType === 3">
<div class="tab-body" v-show="currentType === 3">
<div class="search-wrap fix">
<Button class="fl" type="primary" @click="addGroupShow">新建联系组</Button>
</div>
<Table
:columns="groupColumns"
:data="groupData"
class="link-table table-bottom-box"
:height="groupTableHeight"
no-data-text="暂无数据"
></Table>
<div class="table-region" ref="groupRegionRef">
<Table
:columns="groupColumns"
:data="groupData"
class="link-table table-bottom-box"
:height="groupH || undefined"
no-data-text="暂无数据"
></Table>
</div>
<div class="page-panel">
<div class="fl ml-15 align-middle"></div>
<Page
@@ -417,6 +505,60 @@ const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isS
</div>
</div>
<!-- 流程绑定(应用设置 > 流程绑定) -->
<div class="tab-body" v-show="currentType === 2">
<Alert show-icon type="warning">
绑定说明:绑定后「发送全部用户」的通知必须审核通过才发布(发通知页只显示「提交审核」按钮,
隐藏发送与定时发送);「发送指定人员」不受影响,仍直接发布。未绑定则保持现有发送方式。
</Alert>
<div v-if="!flowEnabled" class="flow-binding-empty">流程中心未接入,暂不支持流程绑定。</div>
<template v-else>
<div class="flow-binding-form">
<span class="label">审核流程:</span>
<Select
v-model="selectedGuid"
clearable
:disabled="!flowCanEdit"
placeholder="不绑定,全部直发"
style="width: 360px"
>
<Option value="">不绑定,全部直发</Option>
<Option v-for="f in flowOptions" :key="f.flowGuid" :value="f.flowGuid">
{{ f.name }}({{ f.nodeSummary || "—" }})
</Option>
</Select>
<template v-if="flowCanEdit">
<Button type="primary" :loading="bindingSaving" @click="saveFlowBinding">保存绑定</Button>
<Button v-if="boundFlowGuid" @click="unbindFlow">解除绑定</Button>
<Button v-if="selectedGuid" @click="previewSelectedFlow">查看流程图</Button>
</template>
<span v-else class="readonly-hint">仅本校管理员可修改流程绑定</span>
</div>
<div class="flow-binding-state">
当前状态:
<template v-if="boundFlowGuid">
已绑定
<Tag color="blue">{{ boundFlowName || boundFlowGuid }}</Tag>
<span class="node-summary">{{ boundFlowSummary }}</span>
<Button type="text" size="small" @click="openFlowDiagram">查看流程图</Button>
</template>
<template v-else>
<Tag>未绑定</Tag>
<span class="node-summary">发送全部用户与指定人员均直接发布</span>
</template>
</div>
</template>
</div>
</div>
<!-- 流程图 Drawer(后端 DiagramDef 同源 PNG;竖版长图容器内纵向滚动) -->
<Drawer v-model="flowDiagramVisible" title="流程示意图" :width="860" :transfer="false"
:styles="{ body: { padding: '0' } }">
<div class="flow-diagram-frame">
<img ref="flowDiagramEl" v-if="flowDiagramSrc" :src="flowDiagramSrc" alt="流程示意图" @load="fitDiagram(flowDiagramEl)" @error="onFlowDiagramError" />
</div>
</Drawer>
<!-- 新增发文部门 Modal(编辑走 TypeSetBox 内联编辑) -->
<Modal v-model="deptModal" :mask-closable="false" width="450px" title="新增部门">
<Form ref="deptFormRef" :model="deptForm" :rules="deptRule" :label-width="110">
@@ -489,4 +631,45 @@ const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isS
display: flex;
justify-content: flex-end;
}
/* 流程绑定面板 */
.flow-binding-form {
margin: 16px 0;
display: flex;
align-items: center;
gap: 10px;
}
.flow-binding-form .label {
color: var(--text-2);
}
.flow-binding-state {
color: var(--text-2);
display: flex;
align-items: center;
gap: 6px;
}
.flow-binding-state .node-summary {
color: var(--text-3);
}
.readonly-hint {
color: var(--text-3);
margin-left: 8px;
}
.flow-binding-empty {
margin-top: 16px;
color: var(--text-3);
}
/* 流程图 Drawer:浅底色 + 纵向滚动(竖版长图),图片撑满可读宽度 */
.flow-diagram-frame {
height: 100%;
overflow: auto;
background: #f5f7fa;
padding: 12px;
box-sizing: border-box;
}
.flow-diagram-frame img {
display: block;
margin: 0 auto;
max-width: 100%;
height: auto;
}
</style>
+3 -4
View File
@@ -7,9 +7,9 @@ import { Icon } from "view-ui-plus";
</script>
<template>
<div class="page-wrapper">
<div class="page-wrapper yh-fill">
<div class="main-container stats-dev">
<Icon type="ios-stats" size="56" color="#c5c8ce" />
<Icon type="ios-stats" size="56" color="var(--text-3)" />
<p class="tip">通知统计功能开发中,敬请期待……</p>
</div>
</div>
@@ -21,8 +21,7 @@ import { Icon } from "view-ui-plus";
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - 136px);
color: #999;
color: var(--text-3);
}
.tip {
margin-top: 16px;
+19 -6
View File
@@ -12,9 +12,11 @@ import presetUno from "@unocss/preset-uno";
import presetRemToPx from "@unocss/preset-rem-to-px";
export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
// 构建输出目录:base -> dist/base, ld -> dist/ld, 默认 dist/base
const subDir = mode === "ld" ? "ld" : "base";
const outDir = command === "build" ? `dist/${subDir}` : "dist";
// 构建产物直出 dist/(env-config 模式,与 Cms/UserBase 同风格):CI zip 打包取 dist 根,
// 不再有 dist/base 中间层(此前 outDir=dist/base 时 zip 打 dist 整层,zip 根只有 base/
// 目录,projman 前端发布校验报「zip 根目录缺少 html 文件」);dev(serve) 不写产物,
// 两者共用该值无冲突
const outDir = "dist";
return {
plugins: [
@@ -71,7 +73,6 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
"flex-center": "flex items-center justify-center",
// 常用组件
btn: "px-16px py-8px rounded-4px cursor-pointer transition-all duration-200",
"btn-primary": "bg-blue-600 text-white hover:bg-blue-700",
card: "bg-white rounded-8px shadow-md p-16px",
// 文本截断
"text-ellipsis": "truncate",
@@ -88,6 +89,9 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
},
},
}),
// 主题接入已免插件化(A 类,YschTheme《管理端主题远程化与公共静态通道方案》§11):
// 唯一接入点 = index.html 一段同步 loader,挂载/FOUC/CSS 排序全由通道 init 承担;
// 下面的 /static 代理只是 dev 联调便利(prod 同源用不到),删除不影响线上主题
],
resolve: {
alias: {
@@ -101,7 +105,16 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
// dev 正式模式:serviceHost 为空(同源相对路径 /api/...),经此代理转发到本地后端
// (后端 CAS 过滤器对 /api/* 不回 CORS 头,浏览器直连 8070 会被拦,必须同源代理)
"/api": {
target: "http://localhost:8070",
target: "http://localhost:8101",
changeOrigin: true,
},
// 主题通道本地联调:dev 下 loader 走同源相对(见 index.html 注释的优先级四档),
// manifest/CSS 经此代理到中央通道;changeOrigin 会把 Host 改写成目标域,Caddy 按 Host 匹配站点,
// 无需再手工补 Host 头(这是旧 vite 插件里那段 headers.Host 的等价替代)。
// 联调本地通道源:YschTheme 侧 npm run serve:static(默认 9527),再用
// YH_THEME_PROXY_TARGET=http://localhost:9527 覆盖本 target
"/static": {
target: process.env.YH_THEME_PROXY_TARGET || "http://base.yunhaixingtong.com",
changeOrigin: true,
},
},
@@ -117,7 +130,7 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
base: "./",
build: {
target: "modules", //设置最终构建的浏览器兼容目标 //es2015(编译成es5) | modules
outDir, // 构建输出目录,根据环境变量区分 base/ld
outDir, // 构建输出目录(恒 dist 根,见文件头说明)
assetsDir: "assets", // 静态资源得存放路径文件名 assets
sourcemap: false, //构建后是否生成 source map 文件
minify: "esbuild", // 项目压缩 :boolean | 'terser' | 'esbuild'