Compare commits

..
44 Commits
Author SHA1 Message Date
ops 6d5034fbaf fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:27 +00:00
ops 0b62e7c304 ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:36:59 +00:00
chenxing 7c969955ae [fix] 通知详情「审批进度」卡片收口:全量流水交给「全部审批记录」抽屉
build / build (push) Successful in 4m8s
- 卡片只留 状态/流程摘要/当前节点/办理意见 + 入口,删掉 steps 时间线与 trace 兜底两段(与下方动作卡
  的『最近轨迹』和抽屉三视图三处重复)
- 当前节点由 node id 串经 steps.nodeId→nodeName 翻译(此前直接显示 u1 这类 id)
- 卡头『审批进度』按钮与抽屉标题统一为『全部审批记录』
- types.ts TraceStep 补 nodeId?(可选字段,向后兼容)
2026-10-19 22:30:05 +08:00
chenxing f085d6d74d [fix] NoticeDetails 绑定 FlowTaskEmbed @open-biz:任务页"查看业务详情"此前无响应
build / build (push) Successful in 3m39s
- bizId=serid 时关弹窗并刷新详情/进度;跨单据才 router.push(bizUrl 是裸相对路径,不能直接 window.open)
2026-10-19 20:10:16 +08:00
chenxing 62d9024f57 [v2.1] C 应用内审批前端:flow-approval 共享组件四件套(List/Card/RecordDrawer/TaskEmbed)+approvalAdapter,NoticeApproval/NoticeDetails 换组件化实现,完整办理走去壳任务页(embed=1 由组件补)
build / build (push) Successful in 2m55s
2026-10-19 16:59:39 +08:00
chenxing c068321279 feat(loading): 表格加载接通道 .ysch-spin(v1.11)——TablePage/Alova/AlovaNew 三组件插槽挂 ysch-spin,main.scss 改布局块+文案 var(--brand),删 loading.gif 两份副本;同步通道件 theme.ts 版本戳 20261014-1811-d7e6a3c
build / build (push) Successful in 2m47s
2026-10-14 18:13:10 +08:00
chenxing 310743222c 外观SDK化A:sync 件跟版 20261011-1332-246e1e3(侧栏默认翻转 sideDeep→false)
build / build (push) Successful in 4m4s
2026-10-11 13:34:30 +08:00
chenxing 46710cd18e fix(notice): 待办中心左列表改滚动分页追加/表格列宽与幻影省略号修正/流程图弹窗收窄至740
build / build (push) Successful in 2m35s
- NoticeAccept 左侧列表:改为每页50滚动触底预取并追加(hasNext 驱动,加载中/没有更多提示),
  已读状态改本地置位(markRead),不再整页重拉列表(重拉会丢已追加分页与滚动位置)
- NoticeList 附件列 70→80:图标外包 Tooltip 后内容宽不足,cell ellipsis 会在图标后画出「...」
- NoticeApproval 办件表格:状态/提交人/时间列 width→minWidth 参与余量分配,避免通知列独占宽屏余量;操作列固定150居中
- 流程图弹窗/抽屉 860→740(NewNotice / NoticeSetting / NoticeDetails)
- 回退 main.scss 中操作列裸 .ivu-tooltip 的 display:block(会以更高特异性顶掉横排规则致图标竖排),改由 .ivu-table-cell-tooltip 恢复 flex
- .gitignore 忽略 vite 配置 bundle 临时产物 vite.config.*.timestamp-*
2026-10-10 16:57:07 +08:00
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
50 changed files with 4237 additions and 2122 deletions
+6 -1
View File
@@ -19,7 +19,10 @@ jobs:
options: --network infra-infra-net
steps:
- name: 安装 git / curl / zip(alpine)
run: apk add --no-cache git curl zip
run: |
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
apk add --no-cache git curl zip
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -51,6 +54,8 @@ jobs:
set -e
cd /build
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
# 国内镜像源:内网直连 registry.npmjs.org 跨境极慢(实测 npm install 12 分 46 秒 / 754 包)
npm config set registry https://registry.npmmirror.com
npm ci
npm run build
'
+3
View File
@@ -27,3 +27,6 @@ yarn.lock
# scratch
.temp
# vite 配置 bundle 临时产物
vite.config.*.timestamp-*
+22
View File
@@ -29,6 +29,28 @@
}
})();
</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>
+777
View File
@@ -75,6 +75,431 @@
"alova": "dist/bin/cli.js"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
"integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/android-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
"integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/android-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
"integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/android-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
"integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/darwin-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
"integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/darwin-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
"integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/freebsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
"integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/freebsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
"integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-arm": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
"integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
"integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
"integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-loong64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
"integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-mips64el": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
"integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
"integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-riscv64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
"integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-s390x": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
"integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/linux-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
"integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/netbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
"integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/netbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
"integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/openbsd-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
"integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/openbsd-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
"integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/openharmony-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
"integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/sunos-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
"integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/win32-arm64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
"integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/win32-ia32": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
"integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@alova/wormhole/node_modules/@esbuild/win32-x64": {
"version": "0.25.12",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
@@ -10766,6 +11191,358 @@
"dev": true,
"license": "MIT"
},
"node_modules/vite/node_modules/@esbuild/aix-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/android-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/android-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/android-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/darwin-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/darwin-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/freebsd-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/freebsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-arm": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-loong64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
"cpu": [
"loong64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-mips64el": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
"cpu": [
"mips64el"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-ppc64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
"cpu": [
"ppc64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-riscv64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-s390x": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
"cpu": [
"s390x"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/linux-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/netbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/openbsd-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/sunos-x64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/win32-arm64": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/@esbuild/win32-ia32": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
"cpu": [
"ia32"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=12"
}
},
"node_modules/vite/node_modules/esbuild": {
"version": "0.21.5",
"resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 KiB

+57 -3
View File
@@ -61,6 +61,45 @@ export interface ApprovalPage<T> {
hasNext: boolean;
}
/* ===== v2.1 应用内审批:业务详情卡片数据源(/oapi/flow/tasks/by-biz 经本应用后端代理) ===== */
/** 出口(多出口节点办理时须选其一;单出口节点该数组为空) */
export interface ByBizOutlet {
name: string;
toNodeId: string;
toNodeName: string;
}
/** 当前人在本通知上的一条待办任务 */
export interface ByBizTask {
taskId: number;
nodeId: string;
nodeName: string;
arriveTime: string;
outlets: ByBizOutlet[];
}
/**
* by-biz 出参。语义要点:
* - tasks 长度 0/1/N 都是成功响应,N>1 时卡片不得猜选(引导列表/任务页);
* - "通知不存在/不可见/不是我的待办" 三者同形(tasks 空 + instanceId 可能为 null),
* 前端不得据此反推是否存在别人的待办;
* - currentNodes 是**节点 id** 逗号串(办件原文口径),人读名称用 tasks[].nodeName。
*/
export interface ByBizResult {
instanceId: number | null;
bizType: string;
bizId: string;
status: number | null;
currentNodes: string | null;
tasks: ByBizTask[];
}
/** 换址类端点统一返回 { pageUrl } */
export interface PageUrlResult {
pageUrl: string;
}
export const approvalApi = {
/** 我的待审核(bizType=notice 固定在后端) */
todo(page = 1, size = 10): Method<ApprovalPage<ApprovalTodoTask>> {
@@ -70,9 +109,24 @@ export const approvalApi = {
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 } : {});
/** 当前人在该通知上的待办(卡片可写/只读、多待办与多出口判定的唯一依据) */
byBiz(serid: number): Method<ByBizResult> {
return get(`${APPROVAL}/ByBiz`, { serid });
},
/** 审批记录抽屉地址(token 只读页,现取现用) */
recordUrl(serid: number): Method<PageUrlResult> {
return get(`${APPROVAL}/RecordUrl`, { serid });
},
/** 完整办理任务页地址(后端已确认 taskId 属于当前人才签发;CAS 页无 token) */
taskPageUrl(serid: number, taskId: number): Method<PageUrlResult> {
return get(`${APPROVAL}/TaskPageUrl`, { serid, taskId });
},
/** 通过(opinion 可选;多出口节点必须带 edgeName,否则后端业务错) */
approve(taskId: number, opinion?: string, edgeName?: string): Method<void> {
const body: Record<string, string> = {};
if (opinion) body.opinion = opinion;
if (edgeName) body.edgeName = edgeName;
return postJSON(`${APPROVAL}/Approve?taskId=${taskId}`, body);
},
/** 拒绝(opinion 必填) */
reject(taskId: number, opinion: string): Method<void> {
+25
View File
@@ -0,0 +1,25 @@
import approvalApi from "@/api/module/approval";
import { noticeApi } from "@/api/module/notice";
import type { FlowApprovalApi } from "@/components/flow-approval/types";
/**
* Notice ↔ flow-approval 共享组件的适配器(方案 §5.4 的 Notice 侧落点)。
*
* 组件契约用 `bizId: string`(跨应用通用),Notice 后端用 `serid: number`;
* trace 走通知自己的进度接口而不新建 approval 端点,避免同一份轨迹两个来源。
*
* 这一层是**唯一的**映射位置:列表页与详情页卡片共用,改端点只改这里。
* 身份字段(operatorId/schId/name)在此一律不出现——由 NoticeService 从 CAS 会话取。
*/
export const noticeFlowApprovalApi: FlowApprovalApi = {
todo: (p) => approvalApi.todo(p?.page ?? 1, p?.size ?? 10).send(),
instances: (p) => approvalApi.instances(p?.status, p?.page ?? 1, p?.size ?? 10).send(),
byBiz: (bizId) => approvalApi.byBiz(Number(bizId)).send(),
trace: (bizId) => noticeApi.flowTrace(Number(bizId)).send(),
approve: (taskId, opinion, edgeName) => approvalApi.approve(taskId, opinion, edgeName).send(),
reject: (taskId, opinion) => approvalApi.reject(taskId, opinion).send(),
recordUrl: (bizId) => approvalApi.recordUrl(Number(bizId)).send(),
taskPageUrl: (bizId, taskId) => approvalApi.taskPageUrl(Number(bizId), taskId).send(),
};
export default noticeFlowApprovalApi;
+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;
+4
View File
@@ -127,6 +127,10 @@ class NoticeApi {
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`);
+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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 186 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

+67 -3
View File
@@ -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,12 +298,15 @@ export default [
method: "get",
response: () =>
ok([
// icon:用户中心模块管理配置的 YschIcons 图标名,layout 渲染为 iconfont icon-<名称>
// 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: 4, modId: 4, name: "通知统计", parentId: 0, page: "/stats", icon: "tongji", orderNumber: 4, usable: true, children: [] },
{ id: 21, modId: 21, name: "应用设置", parentId: 0, page: "/setting", icon: "shezhi", orderNumber: 5, 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: [] },
]),
},
{
@@ -722,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);
},
},
];
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+28 -10
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);
}
}
}
@@ -98,20 +98,38 @@ h4.subtitle {
}
}
/* 表格加载(规范 §4 表格加载行):图形=通道 .ysch-spin(随 --brand),本地零图形资产 */
.table-spin-load {
position: relative;
width: 60px;
height: 90px;
padding-top: 60px;
background-image: url(../image/loading.gif);
background-size: 60px 60px;
background-position: top center;
background-repeat: no-repeat;
border: none;
color: #577afb;
color: var(--brand);
margin: 0 auto;
text-align: center;
}
.table-spin-load .ysch-spin {
position: absolute;
left: 50%;
top: 0;
width: 60px;
height: 60px;
margin-left: -30px;
font-size: 60px; /* .ysch-spin 尺寸=1em,60px 图形用字号给 */
}
.ivu-table-cell-tree {
margin-right: 4px;
margin-bottom: 2px;
}
/* 修复表格幻影省略号(2026-10-10):.ivu-table-cell 自带 overflow:hidden + text-overflow:ellipsis,
tooltip 列的包装层(.ivu-tooltip 行内块 + width:100%)未溢出也会被画出「...」;
恢复组件预期的 flex 层级即可,内层 .ivu-table-cell-tooltip-content 的真省略号与悬浮 tooltip 不受影响。
注意:不要再给操作列的裸 .ivu-tooltip 加 display:block——那会以更高特异性顶掉 App.vue 的横排规则,
使 operatingHandle 渲染的图标按钮竖排(2026-10-10 踩过,已回退)。 */
/* tooltip 列的包装层同时挂 .ivu-tooltip{display:inline-block},靠声明顺序顶掉了组件预期的 flex,这里恢复 */
.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;
+5 -3
View File
@@ -92,16 +92,18 @@ export default {
display: block;
.upload-row {
display: flex;
align-items: center;
align-items: flex-start; /* 提示语与按钮首行对齐(列表换行后不再垂直居中整块) */
}
:deep(.ivu-upload) {
display: inline-flex;
flex-wrap: wrap; /* 允许内部列表换行 */
align-items: center;
}
/* 文件列表整体另起一行,每项独占一行,不再与按钮挤在一起 */
:deep(.ivu-upload-list) {
display: block;
margin-top: 8px;
width: 100%; /* 文件列表整体独占下一行 */
margin-top: 4px;
}
:deep(.ivu-upload-list-file) {
padding: 4px 0;
@@ -110,7 +112,7 @@ export default {
margin-left: 10px;
color: #f90;
font-size: 12px;
line-height: 1;
line-height: 32px; /* 与默认 Button 等高,紧贴按钮右侧 */
}
}
</style>
+1 -1
View File
@@ -116,7 +116,7 @@ defineExpose({
no-data-text="暂无数据"
>
<template #loading>
<div class="table-spin-load">加载中</div>
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
</template>
</Table>
<div class="page-panel">
+1 -1
View File
@@ -3,7 +3,7 @@
<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>
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
</template>
</Table>
+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"><i class="ysch-spin"></i>加载中</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>
@@ -0,0 +1,303 @@
<template>
<div class="flow-card-region">
<!-- 不可达:本地状态照常展示,动作明确提示稍后重试(绝不伪装成功,也不静默变只读) -->
<Alert v-if="unavailable" type="warning" show-icon class="flow-alert">
流程中心暂时不可达,审批动作稍后重试。
<a class="operating-link" @click="load">重新加载</a>
</Alert>
<!-- 首次加载:data 还没回来时不得渲染成"尚未进入流程"(空态与未加载是两回事,误报会误导审批人) -->
<div v-else-if="loading" class="flow-muted">审批状态加载中…</div>
<!-- NO_INSTANCE:未进入流程 -->
<div v-else-if="state === 'NO_INSTANCE'" class="flow-muted">该业务尚未进入审批流程。</div>
<div v-else class="flow-body">
<div class="flow-head">
<Tag :color="statusTag">{{ statusText }}</Tag>
<span v-if="currentNodeText" class="flow-nodes">当前节点:{{ currentNodeText }}</span>
<span v-if="lastTraceText" class="flow-trace">最近轨迹:{{ lastTraceText }}</span>
</div>
<!-- ONE_TASK:当前人可办理(readonly 视角即使有待办也不给动作) -->
<div v-if="state === 'ONE_TASK' && !readonly" class="flow-actions">
<Button size="small" type="primary" :loading="acting" @click="openApprove">通过</Button>
<Button size="small" :loading="acting" @click="openReject">驳回</Button>
<Button size="small" @click="emitTask">完整办理</Button>
</div>
<!-- MULTI_TASK:不猜选,引导逐条办理 -->
<div v-else-if="state === 'MULTI_TASK' && !readonly" class="flow-multi">
<span>你在本单据上有 <b>{{ tasks.length }}</b> 个待办,请逐条处理。</span>
<Button size="small" @click="emitTask">逐条办理</Button>
</div>
<div v-else-if="state === 'MULTI_TASK'" class="flow-multi">
<span>本单据有多个待办。</span>
</div>
<!-- NO_TASK / TERMINAL:无动作区,只保留下方的"查看审批记录" -->
<div class="flow-links">
<a class="operating-link" @click="$emit('open-record', bizId)">查看审批记录</a>
</div>
</div>
<!-- 通过:意见可选;多出口必须先选出口,不选不给提交 -->
<Modal v-model="approveVisible" title="通过审核" :loading="acting" @on-ok="doApprove" @on-cancel="reset">
<div class="flow-modal-row" v-if="chosenOutlets.length > 1">
<span class="flow-modal-label">办理去向:</span>
<Select v-model="chosenEdge" size="small" style="width: 260px" placeholder="请选择办理去向">
<Option v-for="o in chosenOutlets" :key="o.name" :value="o.name" :label="o.name">
{{ o.name }}({{ o.toNodeName }})
</Option>
</Select>
</div>
<p class="flow-modal-tip">
通过「<b>{{ title || bizId }}</b>」
<template v-if="chosenOutlets.length > 1">,该节点有多个去向,必须选择其一</template>
<template v-else>(单去向,无需选择)</template>
</p>
<Input v-model="opinion" type="textarea" :rows="3" placeholder="办理意见(可选)" />
</Modal>
<!-- 驳回:意见必填 -->
<Modal v-model="rejectVisible" title="驳回审核" :loading="acting" @on-ok="doReject" @on-cancel="reset">
<p class="flow-modal-tip">驳回「<b>{{ title || bizId }}</b>」,必须填写意见:</p>
<Input v-model="opinion" type="textarea" :rows="3" placeholder="驳回意见(必填)" />
</Modal>
</div>
</template>
<script setup lang="ts">
/**
* 业务详情审批卡片(方案 §4.3)。数据来源只有一个:`api.byBiz(bizId)`——
* 与列表共用同一个 taskId 口径,杜绝"卡片和列表算出两个待办"。
*
* 卡片只管**当前人**的动作:
* - 非待办人 / readonly / 终态 → 只读状态 + 查看记录,不出通过/驳回;
* - tasks>1(会签/并行分支)→ 不猜选,引导逐条办理;
* - outlets>1 → 通过弹窗必须选出口,未选不提交;
* - 动作成功后只刷新自己与通知父页,业务状态由 flow-center 回调推进,卡片不本地写状态。
*/
import { computed, onMounted, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import type { ByBizResult, ByBizTask, FlowApprovalApi, TraceVo } from "./types";
import { INSTANCE_STATUS_TAG, INSTANCE_STATUS_TEXT } from "./types";
const props = withDefaults(
defineProps<{
api: FlowApprovalApi;
bizType: string;
bizId: string;
title?: string;
/** 外部已有进度时可传入,卡片不重复拉轨迹 */
trace?: TraceVo | null;
/** 提交人/管理员视角:即便有待办也不给动作 */
readonly?: boolean;
}>(),
{ title: "", trace: null, readonly: false }
);
const emit = defineEmits<{
(e: "handled", payload: { taskId: number; action: string }): void;
(e: "open-record", bizId: string): void;
(e: "open-task", taskId: number): void;
}>();
const data = ref<ByBizResult | null>(null);
const unavailable = ref(false);
const acting = ref(false);
const loading = ref(false);
const approveVisible = ref(false);
const rejectVisible = ref(false);
const opinion = ref("");
const chosenEdge = ref("");
const targetTask = ref<ByBizTask | null>(null);
const tasks = computed<ByBizTask[]>(() => data.value?.tasks || []);
type CardState = "NO_INSTANCE" | "NO_TASK" | "ONE_TASK" | "MULTI_TASK" | "TERMINAL";
/**
* 终态判定优先于"我有待办":办件已完成/拒绝/撤回时即便残留 pending 行也不给动作,
* 由 flow-center 复核并拒绝重复办理(前端先少给一个必然失败的按钮)。
*/
const state = computed<CardState>(() => {
const d = data.value;
if (!d) return "NO_INSTANCE";
if (d.instanceId == null && tasks.value.length === 0) return "NO_INSTANCE";
if (d.status != null && d.status !== 10) return "TERMINAL";
if (tasks.value.length === 0) return "NO_TASK";
if (tasks.value.length > 1) return "MULTI_TASK";
return "ONE_TASK";
});
const chosenOutlets = computed(() => targetTask.value?.outlets || []);
const statusText = computed(() => {
const s = data.value?.status;
if (s != null && INSTANCE_STATUS_TEXT[s]) return INSTANCE_STATUS_TEXT[s];
return props.trace?.auditStatusDesc || "—";
});
const statusTag = computed(() => {
const s = data.value?.status;
return (s != null && INSTANCE_STATUS_TAG[s]) || "default";
});
/** 人读当前节点:优先本人待办的节点名(by-biz 给的是快照名),退回 trace 摘要 */
const currentNodeText = computed(() => tasks.value[0]?.nodeName || props.trace?.nodeSummary || "");
const lastTraceText = computed(() => {
const steps = props.trace?.steps;
if (!steps || !steps.length) return "";
const last = steps[steps.length - 1];
const who = last?.assignees?.map((a) => a?.userName).filter(Boolean).join("、");
const label = last?.label || "";
if (!last?.nodeName && !label) return "";
return [last?.nodeName, who, label].filter(Boolean).join(" ");
});
async function load() {
loading.value = true;
unavailable.value = false;
try {
data.value = (await props.api.byBiz(props.bizId)) as ByBizResult;
} catch (e: any) {
unavailable.value = true;
data.value = null;
} finally {
loading.value = false;
}
}
function openApprove() {
if (state.value !== "ONE_TASK") return;
targetTask.value = tasks.value[0];
opinion.value = "";
chosenEdge.value = "";
approveVisible.value = true;
}
function openReject() {
if (state.value !== "ONE_TASK") return;
targetTask.value = tasks.value[0];
opinion.value = "";
rejectVisible.value = true;
}
async function doApprove() {
const task = targetTask.value;
if (!task) return;
// 多出口未选 → 拦下并留在弹窗,不发一个必然被后端拒绝的请求
if (task.outlets.length > 1 && !chosenEdge.value) {
Message.warning("该节点有多个办理去向,请先选择");
acting.value = false;
return;
}
acting.value = true;
try {
await props.api.approve(task.taskId, opinion.value.trim() || undefined,
task.outlets.length > 1 ? chosenEdge.value : undefined);
Message.success("已通过");
approveVisible.value = false;
emit("handled", { taskId: task.taskId, action: "approve" });
await load();
} catch (e: any) {
Message.error(e?.msg || e?.message || "操作失败");
approveVisible.value = false;
} finally {
acting.value = false;
}
}
async function doReject() {
const task = targetTask.value;
if (!task) return;
if (!opinion.value.trim()) {
Message.warning("驳回必须填写意见");
acting.value = false;
return;
}
acting.value = true;
try {
await props.api.reject(task.taskId, opinion.value.trim());
Message.success("已驳回");
rejectVisible.value = false;
emit("handled", { taskId: task.taskId, action: "reject" });
await load();
} catch (e: any) {
Message.error(e?.msg || e?.message || "操作失败");
rejectVisible.value = false;
} finally {
acting.value = false;
}
}
function emitTask() {
const task = tasks.value[0];
if (!task) return;
emit("open-task", task.taskId);
}
function reset() {
opinion.value = "";
chosenEdge.value = "";
targetTask.value = null;
}
watch(() => props.bizId, load);
onMounted(load);
defineExpose({ load, state });
</script>
<style scoped>
.flow-card-region {
margin-top: 12px;
}
.flow-alert {
margin-bottom: 8px;
}
.flow-muted {
padding: 8px 0;
}
.flow-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 10px;
}
.flow-nodes,
.flow-trace {
font-size: 13px;
}
.flow-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.flow-multi {
display: flex;
align-items: center;
gap: 10px;
}
.flow-links {
margin-top: 10px;
}
.flow-modal-row {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.flow-modal-label {
width: 90px;
font-size: 13px;
flex-shrink: 0;
}
.flow-modal-tip {
margin-bottom: 8px;
}
</style>
@@ -0,0 +1,348 @@
<template>
<div class="yh-fill">
<div class="main-container yh-tabs-card">
<Tabs class="tabs-panel" :model-value="innerTab" @on-click="switchTab">
<TabPane v-for="t in tabs" :key="t" :label="tabLabel(t)" :name="t" />
</Tabs>
<!-- 待办 -->
<div class="tab-body" v-if="innerTab === 'todo'">
<div class="table-region" ref="todoRegionRef">
<Table :columns="todoCols" :data="todoRows" :height="todoH || undefined" :loading="loading"
no-data-text="暂无待审核通知">
<template #action="{ row }">
<span class="operating-link" @click="$emit('open-record', row.bizId)">审批记录</span>
<span class="operating-link" @click="openApprove(row)">通过</span>
<span class="operating-link text-error" @click="openReject(row)">驳回</span>
<span class="operating-link" @click="openTask(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 :model-value="instStatus" type="button" @on-change="onStatusChange">
<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="statusTag(row.status)">{{ statusText(row.status) }}</Tag>
</template>
<template #instAction="{ row }">
<Button size="small" @click="$emit('open-record', row.bizId)">审批记录</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="approveVisible" title="通过审核" :loading="acting" @on-ok="doApprove" @on-cancel="reset">
<div class="modal-row" v-if="chosenOutlets.length > 1">
<span class="modal-label">办理去向:</span>
<Select v-model="chosenEdge" size="small" style="width: 260px" placeholder="请选择办理去向">
<Option v-for="o in chosenOutlets" :key="o.name" :value="o.name" :label="o.name">
{{ o.name }}({{ o.toNodeName }})
</Option>
</Select>
</div>
<p class="modal-tip">
通过「<b>{{ targetTitle }}</b>」
<template v-if="chosenOutlets.length > 1">,该节点有多个去向,必须选择其一</template>
</p>
<Input v-model="opinion" type="textarea" :rows="3" placeholder="办理意见(可选)" />
</Modal>
<!-- 驳回:意见必填 -->
<Modal v-model="rejectVisible" title="驳回审核" :loading="acting" @on-ok="doReject" @on-cancel="reset">
<p class="modal-tip">驳回「<b>{{ targetTitle }}</b>」,必须填写意见:</p>
<Input v-model="opinion" type="textarea" :rows="3" placeholder="驳回意见(必填)" />
</Modal>
</div>
</template>
<script setup lang="ts">
/**
* 独立审批列表(方案 §4.2)。两个页签共用本组件与同一套动作,数据面全部走注入的 `api`,
* 组件不认得 Notice 也不认得 flow-center。
*
* 三条硬纪律:
* 1. 动作只认 `taskId`(行数据里带),不按行号/标题猜;
* 2. 列表的 todo 出参**没有 outlets**(那是 by-biz 才给的),所以点"通过"时现拉一次
* `byBiz(bizId)` 定位本行 taskId:既拿到出口清单,也顺带确认"这条仍然是我的待办"——
* 已被他人办理/已失效的行会在这里被拦下并刷新列表,而不是发一个必然失败的请求;
* 3. 多出口未选不得提交;驳回意见必填。
*/
import { computed, onMounted, ref } from "vue";
import { Message } from "view-ui-plus";
import { useElementHeight } from "@/utils/hook/useElementHeight";
import type { ByBizTask, FlowApprovalApi, FlowPage, InstanceItem, TodoTask } from "./types";
import { INSTANCE_STATUS_TAG, INSTANCE_STATUS_TEXT } from "./types";
const props = withDefaults(
defineProps<{
api: FlowApprovalApi;
bizType: string;
tabs?: Array<"todo" | "instances">;
/** 保留位:多业务共页时由调用方渲染筛选(本期 Notice 单一 bizType,不用) */
showAppFilter?: boolean;
}>(),
{ tabs: () => ["todo", "instances"], showAppFilter: false }
);
const emit = defineEmits<{
(e: "handled", payload: { taskId: number; action: string }): void;
(e: "open-record", bizId: string): void;
(e: "open-task", payload: { bizId: string; taskId: number }): void;
}>();
const innerTab = ref<"todo" | "instances">(props.tabs[0] || "todo");
const loading = ref(false);
const acting = ref(false);
const todoRegionRef = ref<HTMLElement | null>(null);
const instRegionRef = ref<HTMLElement | null>(null);
const todoH = useElementHeight(todoRegionRef);
const instH = useElementHeight(instRegionRef);
const todoRows = ref<Array<TodoTask & { _summary: string }>>([]);
const todoPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
const instRows = ref<InstanceItem[]>([]);
const instPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
const instStatus = ref(0);
const approveVisible = ref(false);
const rejectVisible = ref(false);
const opinion = ref("");
const chosenEdge = ref("");
const targetTask = ref<ByBizTask | null>(null);
const targetTitle = ref("");
const chosenOutlets = computed(() => targetTask.value?.outlets || []);
const TAB_LABELS: Record<string, string> = { todo: "我的待审核", instances: "办件视角" };
function tabLabel(t: string) {
return TAB_LABELS[t] || t;
}
const todoCols = [
{ title: "通知", key: "bizTitle", minWidth: 220, ellipsis: true, tooltip: true },
{ title: "摘要", key: "_summary", minWidth: 180, ellipsis: true, tooltip: true },
{ title: "当前节点", key: "nodeName", width: 120 },
{ title: "到达时间", key: "arriveTime", width: 170, render: fmtTime },
{ title: "操作", slot: "action", width: 250, align: "center" },
];
const instCols = [
{ title: "通知", key: "bizTitle", minWidth: 240, ellipsis: true, tooltip: true },
{ title: "状态", slot: "status", minWidth: 100 },
{ title: "提交人", key: "submitterName", minWidth: 100 },
{ title: "提交时间", key: "submitTime", minWidth: 170, render: fmtTime },
{ title: "办结时间", key: "finishTime", minWidth: 170, render: fmtTime },
{ title: "操作", slot: "instAction", width: 150, align: "center" },
];
function fmtTime(h: any, p: any) {
return h("span", {}, String(p.row[p.column.key] || "").replace("T", " ").slice(0, 19) || "—");
}
function statusText(s: number) {
return INSTANCE_STATUS_TEXT[s] || String(s);
}
function statusTag(s: number) {
return INSTANCE_STATUS_TAG[s] || "default";
}
function parseSummary(task: TodoTask): string {
if (!task.bizSnapshot) return "—";
try {
const snap = JSON.parse(task.bizSnapshot);
return snap.summary || snap.title || "—";
} catch {
return "—";
}
}
function switchTab(name: any) {
innerTab.value = String(name) as "todo" | "instances";
load();
}
function onStatusChange(val: any) {
instStatus.value = Number(val);
loadInstances(1);
}
async function loadTodo(page = 1) {
loading.value = true;
try {
const res: FlowPage<TodoTask> = await props.api.todo({ page, size: todoPage.value.pageSize });
todoPage.value = { total: res.total, pageIndex: res.pageIndex, pageSize: res.pageSize };
todoRows.value = (res.content || []).map((t) => ({ ...t, _summary: parseSummary(t) }));
} catch (e: any) {
Message.error(e?.msg || e?.message || "待审核列表加载失败");
} finally {
loading.value = false;
}
}
async function loadInstances(page = 1) {
loading.value = true;
try {
const res: FlowPage<InstanceItem> = await props.api.instances({
status: instStatus.value || undefined,
page,
size: instPage.value.pageSize,
});
instPage.value = { total: res.total, pageIndex: res.pageIndex, pageSize: res.pageSize };
instRows.value = res.content || [];
} catch (e: any) {
Message.error(e?.msg || e?.message || "办件列表加载失败");
} finally {
loading.value = false;
}
}
function load() {
if (innerTab.value === "todo") loadTodo(1);
else loadInstances(1);
}
/**
* 现取本行的出口与归属:by-biz 只返回"当前人 PENDING",所以这一步同时充当
* "这条还是不是我办的"复核——查不到即说明已被办理/失效,刷新列表而不是白点一次。
*/
async function resolveTask(row: TodoTask): Promise<ByBizTask | null> {
try {
const data: any = await props.api.byBiz(row.bizId);
const tasks: ByBizTask[] = data?.tasks || [];
return tasks.find((t) => Number(t.taskId) === Number(row.taskId)) || null;
} catch (e: any) {
Message.error(e?.msg || e?.message || "办理信息获取失败");
return null;
}
}
async function openApprove(row: TodoTask) {
if (acting.value) return;
const task = await resolveTask(row);
if (!task) {
Message.warning("该任务已不在你的待办中(可能已被办理),列表已刷新");
loadTodo(todoPage.value.pageIndex);
return;
}
targetTask.value = task;
targetTitle.value = row.bizTitle;
opinion.value = "";
chosenEdge.value = "";
approveVisible.value = true;
}
function openReject(row: TodoTask) {
if (acting.value) return;
targetTask.value = { taskId: row.taskId, nodeId: row.nodeId, nodeName: row.nodeName,
arriveTime: row.arriveTime, outlets: [] };
targetTitle.value = row.bizTitle;
opinion.value = "";
rejectVisible.value = true;
}
async function openTask(row: TodoTask) {
emit("open-task", { bizId: row.bizId, taskId: row.taskId });
}
async function doApprove() {
const task = targetTask.value;
if (!task) return;
if (task.outlets.length > 1 && !chosenEdge.value) {
Message.warning("该节点有多个办理去向,请先选择");
acting.value = false;
return;
}
acting.value = true;
try {
await props.api.approve(task.taskId, opinion.value.trim() || undefined,
task.outlets.length > 1 ? chosenEdge.value : undefined);
Message.success("已通过");
approveVisible.value = false;
emit("handled", { taskId: task.taskId, action: "approve" });
loadTodo(todoPage.value.pageIndex);
} catch (e: any) {
Message.error(e?.msg || e?.message || "操作失败");
approveVisible.value = false;
loadTodo(todoPage.value.pageIndex);
} finally {
acting.value = false;
}
}
async function doReject() {
const task = targetTask.value;
if (!task) return;
if (!opinion.value.trim()) {
Message.warning("驳回必须填写意见");
acting.value = false;
return;
}
acting.value = true;
try {
await props.api.reject(task.taskId, opinion.value.trim());
Message.success("已驳回");
rejectVisible.value = false;
emit("handled", { taskId: task.taskId, action: "reject" });
loadTodo(todoPage.value.pageIndex);
} catch (e: any) {
Message.error(e?.msg || e?.message || "操作失败");
rejectVisible.value = false;
} finally {
acting.value = false;
}
}
function reset() {
opinion.value = "";
chosenEdge.value = "";
targetTask.value = null;
targetTitle.value = "";
}
onMounted(load);
defineExpose({ load, loadTodo, loadInstances });
</script>
<style scoped>
.filter-bar {
margin-bottom: 12px;
}
.modal-row {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.modal-label {
width: 90px;
font-size: 13px;
flex-shrink: 0;
}
.modal-tip {
margin-bottom: 8px;
}
</style>
@@ -0,0 +1,109 @@
<template>
<Drawer :model-value="modelValue" :title="title" :width="width" :transfer="false"
:styles="{ body: { padding: '0' } }" @update:model-value="close">
<div class="flow-embed-region">
<div v-if="error" class="flow-embed-error">
<Alert type="error" show-icon>{{ error }}</Alert>
<Button size="small" @click="load">重试</Button>
</div>
<!-- iframe 一旦给出就是只读嵌入页:本组件不放任何通过/驳回入口 -->
<iframe v-else-if="pageUrl" :src="pageUrl" :title="title" class="flow-embed-frame" />
<div v-else class="flow-embed-loading">{{ loading ? "加载中…" : "" }}</div>
</div>
</Drawer>
</template>
<script setup lang="ts">
/**
* 审批记录抽屉(方案 §4.4):iframe 嵌 flow-center 的 **token 只读页**。
*
* 三条纪律写死在组件里,不给调用方犯错的机会:
* 1. pageUrl **每次打开现取**(token 10 分钟短时效),关闭即清空——不缓存、不进 storage;
* 2. 只读:组件内不出现通过/驳回,也不接收这类回调;
* 3. 换址失败(未进入流程 / 无权限 / 过期)原文提示 + 重试,不白屏。
*/
import { ref, watch } from "vue";
import type { FlowApprovalApi } from "./types";
const props = withDefaults(
defineProps<{
modelValue: boolean;
api: FlowApprovalApi;
bizId: string;
title?: string;
width?: number;
}>(),
{ title: "审批记录", width: 740 }
);
const emit = defineEmits<{
(e: "update:modelValue", v: boolean): void;
}>();
const pageUrl = ref("");
const loading = ref(false);
const error = ref("");
async function load() {
loading.value = true;
error.value = "";
pageUrl.value = "";
try {
const res: any = await props.api.recordUrl(props.bizId);
const url = res?.pageUrl || "";
if (!url) {
error.value = "审批记录地址获取失败,请稍后重试";
return;
}
pageUrl.value = url;
} catch (e: any) {
// 后端信封 msg 原文透传("该通知尚未进入流程"/"通知不存在或未发布"等)
error.value = e?.msg || e?.message || String(e) || "审批记录打开失败";
} finally {
loading.value = false;
}
}
watch(
() => props.modelValue,
(open) => {
if (open) load();
else {
// 关闭即丢地址:token 不进缓存,下次打开重新签发
pageUrl.value = "";
error.value = "";
}
}
);
function close(v: boolean) {
emit("update:modelValue", v);
}
</script>
<style scoped>
.flow-embed-region {
height: 100%;
display: flex;
flex-direction: column;
}
.flow-embed-frame {
display: block;
width: 100%;
height: 100%;
border: 0;
}
.flow-embed-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.flow-embed-error {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
</style>
@@ -0,0 +1,144 @@
<template>
<Modal :model-value="modelValue" :title="title" :width="width" footer-hide transfer
@update:model-value="close">
<div class="flow-task-wrap">
<!-- CAS 会话页被 X-Frame-Options/CSP 挡住时 iframe 只会留白:给出显式逃生口 -->
<!-- 新窗口用的是后端原样地址(完整窗口形态,不该带 embed 壳标记) -->
<div class="flow-task-fallback">
<span class="flow-task-tip">打不开办理面板?</span>
<a class="operating-link" :href="pageUrl" target="_blank" rel="noopener">在新窗口打开任务页</a>
</div>
<iframe v-if="embedSrc" ref="frameEl" :src="embedSrc" :title="title" class="flow-task-frame"
:style="frameStyle" />
</div>
</Modal>
</template>
<script setup lang="ts">
/**
* 去壳任务办理页容器(方案 §4.5):iframe 嵌 flow-center `/task/{taskId}?embed=1`。
*
* 该页是 **CAS 写面**(不是 token 页),办理权由任务页自己的用户面接口按 assigneeId 复核;
* 本组件只负责三件事——收 `handled` 通知父页刷新、转 `open-biz` 让父页开业务详情、按 `height` 自适应。
*
* origin 双向校验:子页 targetOrigin 取 document.referrer 的 origin;父页这里把**期望 origin
* 直接算自 pageUrl**(不是通配),并且再核对消息确实来自本 iframe。跨端口 dev(8077↔8088)
* 靠的是两端 origin 都在 pageUrl 里显式出现,而不是放宽成 `*`。
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { isFlowEmbedMessage } from "./types";
const props = withDefaults(
defineProps<{
modelValue: boolean;
pageUrl: string;
taskId?: number | string;
title?: string;
width?: number;
}>(),
{ title: "完整办理", width: 860, taskId: "" }
);
const emit = defineEmits<{
(e: "update:modelValue", v: boolean): void;
(e: "handled", payload: { taskId: string; action?: string }): void;
(e: "open-biz", payload: { bizId?: string | null; bizUrl?: string }): void;
(e: "close"): void;
}>();
const frameEl = ref<HTMLIFrameElement | null>(null);
const frameHeight = ref(0);
/**
* 后端 TaskPageUrl 给的是**完整窗口形态**的 `#/task/{id}`(svc-hall 等"新窗口打开"场景要用它)。
* 内嵌时"去壳"是嵌入方的诉求,所以由本组件补 `embed=1`——hash 路由下 query 必须落在 `#` 之后,
* 写成 `?embed=1#/task/1` 是进不去的(vue-router 读的是 hash 段)。
*/
function asEmbedUrl(raw: string): string {
if (!raw) return "";
const hashAt = raw.indexOf("#");
if (hashAt < 0) return raw;
const head = raw.slice(0, hashAt);
const hash = raw.slice(hashAt);
if (/[?&]embed=1(?:&|$)/.test(hash)) return raw;
return head + hash + (hash.includes("?") ? "&" : "?") + "embed=1";
}
const embedSrc = computed(() => asEmbedUrl(props.pageUrl));
/** iframe 的期望 origin——pageUrl 解析失败即拒绝接收一切消息 */
const expectedOrigin = computed(() => {
try {
const origin = new URL(props.pageUrl).origin;
return origin && origin !== "null" ? origin : "";
} catch {
return "";
}
});
const frameStyle = computed(() =>
frameHeight.value ? { height: `${Math.min(Math.max(frameHeight.value, 320), 1600)}px` } : {}
);
function onMessage(ev: MessageEvent) {
if (!expectedOrigin.value || ev.origin !== expectedOrigin.value) return;
// 同一页面里可能并存多个嵌入实例,来源窗口必须是本 iframe
if (frameEl.value && ev.source !== frameEl.value.contentWindow) return;
if (!isFlowEmbedMessage(ev.data)) return;
if (ev.data.type === "handled") {
emit("handled", { taskId: String(ev.data.taskId ?? props.taskId), action: ev.data.action });
} else if (ev.data.type === "open-biz") {
emit("open-biz", { bizId: ev.data.bizId, bizUrl: ev.data.bizUrl });
} else if (ev.data.type === "height" && typeof ev.data.height === "number") {
frameHeight.value = ev.data.height;
}
}
onMounted(() => window.addEventListener("message", onMessage));
onBeforeUnmount(() => window.removeEventListener("message", onMessage));
watch(
() => props.modelValue,
(open) => {
if (open) return;
frameHeight.value = 0;
sendClose();
}
);
/** 父→子可选指令:任务页目前不消费 close 之外的入向消息,保留协议位 */
function sendClose() {
const win = frameEl.value?.contentWindow;
if (!win || !expectedOrigin.value) return;
win.postMessage({ proto: "flow", v: 1, type: "close" }, expectedOrigin.value);
}
function close(v: boolean) {
emit("update:modelValue", v);
emit("close");
}
</script>
<style scoped>
.flow-task-wrap {
position: relative;
}
.flow-task-fallback {
display: flex;
gap: 6px;
align-items: center;
justify-content: flex-end;
margin-bottom: 6px;
}
.flow-task-tip {
font-size: 12px;
}
.flow-task-frame {
display: block;
width: 100%;
min-height: 320px;
height: 70vh;
border: 0;
}
</style>
+80
View File
@@ -0,0 +1,80 @@
# flow-approval 共享组件(v2.1 应用内审批)
子应用侧的审批 UI 组件族。**组件不直连 flow-center**,一律通过注入的 `api`(`FlowApprovalApi`)
打本应用自己的后端代理,由本应用后端持 app 签名调 `/oapi/flow/**`。
对应方案:`flow-center/docs/ysch-flow应用内审批-列表与卡片-实施方案v2.1-20261019.md` §4/§5.4/§7。
组件版本:**v2.1-Notice1** | 最后同步:2026-10-19。
## 文件
| 文件 | 职责 | 可写 |
|---|---|---|
| `types.ts` | `FlowApprovalApi` 契约 + `TodoTask/InstanceItem/ByBizResult/TraceVo` + postMessage 信封 | - |
| `FlowApprovalList.vue` | 独立待办/办件列表(页签、分页、行内动作、出口选择) | 是 |
| `FlowApprovalCard.vue` | 业务详情卡片(0/1/N 待办、终态、只读) | 是(当前人待办时) |
| `FlowRecordDrawer.vue` | 审批记录抽屉(token 只读页 iframe) | 否 |
| `FlowTaskEmbed.vue` | 完整办理任务页 iframe/modal(CAS 去壳页) | 是 |
## 用法
```vue
<script setup lang="ts">
import FlowApprovalList from "@/components/flow-approval/FlowApprovalList.vue";
import FlowApprovalCard from "@/components/flow-approval/FlowApprovalCard.vue";
import FlowRecordDrawer from "@/components/flow-approval/FlowRecordDrawer.vue";
import FlowTaskEmbed from "@/components/flow-approval/FlowTaskEmbed.vue";
import myFlowApi from "@/api/module/approvalAdapter"; // 你的 FlowApprovalApi 实现
</script>
<template>
<FlowApprovalList :api="myFlowApi" biz-type="notice" :tabs="['todo', 'instances']"
@open-record="openRecord" @open-task="openTask" @handled="reload" />
<FlowRecordDrawer v-model="recordVisible" :api="myFlowApi" :biz-id="recordBizId" />
<FlowTaskEmbed v-model="taskVisible" :page-url="taskUrl" @handled="reload" @open-biz="goBiz" />
</template>
```
`FlowApprovalApi` 的返回值可以是 alova `Method`,也可以是 Promise——组件只 `await`。
## 必须遵守的契约(改代码前先读)
1. **身份不从前端来**。`api` 的入参只允许 `bizId / taskId / opinion / edgeName / 分页`。
`operatorId / schId / name` 一律由子应用后端从 CAS 会话取;组件与适配器都不带这些字段。
2. **token 记录页只读**。`FlowRecordDrawer` 内不出现通过/驳回,也不接收动作回调;
`pageUrl` 每次打开现取、关闭即丢,不缓存、不进 storage。
3. **多待办不猜**。`byBiz().tasks.length > 1` → 卡片只显示"你有 N 个待办"+引导,不代替用户挑一条办理。
4. **多出口必选**。`outlets.length > 1` 时通过弹窗必须选到 `edgeName` 才允许提交;未选不发请求。
5. **驳回意见必填,通过意见可选**。
6. **列表行"通过"前现拉一次 `byBiz`**。`tasks/todo` 出参没有 `outlets`,且行数据可能是陈旧快照;
点通过时以 `byBiz(bizId)` 里 `taskId` 命中为准——命中不到就是"已不属于你的待办",刷新列表。
7. **`currentNodes` 是节点 id 逗号串**(办件原文口径,与 `/oapi/flow/instances`、`progress` 一致)。
要给人看节点名,用 `tasks[].nodeName`。
8. **错误原文透传**。flow-center 业务码拒绝的 `msg` 直接展示;网络故障提示稍后重试,不伪装成功。
9. **postMessage 双向校验 origin**。父侧期望 origin 算自 `pageUrl`,并核对 `event.source` 是本 iframe;
子侧 targetOrigin 取 `document.referrer` 的 origin。任一侧都不许用 `*`。
## 去壳任务页协议(子页 → 父页)
flow-center 前端 `/task/{taskId}?embed=1` 发出,字面量与
`flow-center/front/src/utils/taskEmbed.ts` 一致:
```json
{"proto":"flow","v":1,"type":"handled","taskId":"442","action":"approve"}
{"proto":"flow","v":1,"type":"open-biz","bizId":"71","bizUrl":"/list/detail?serid=71"}
{"proto":"flow","v":1,"type":"height","height":1200}
```
父 → 子:`{"proto":"flow","v":1,"type":"close"}`(任务页当前不消费,协议占位)。
## 复制到 svc-hall 的同步清单
平台现行约定不抽 npm 包(《云海子应用前端接入规范》),因此**改本目录任一文件必须双端同步**。
1. 整目录复制到 `svc-hall/hall/src/components/flow-approval/`;
2. 只改两处:`api` 适配器(`/api/apply/approval/**`)与 `bizType`(决策见方案 §8.5,二选一不许混用);
3. 两侧 README 头部「组件版本 / 最后同步日期」一起更新;
4. 依赖前提:宿主应用需有平台中央静态通道下发的布局类
(`page-wrapper` / `yh-fill` / `yh-tabs-card` / `tab-body` / `table-region` / `page-panel` /
`operating-link` / `text-error`)与 `@/utils/hook/useElementHeight`;
5. UI 库沿用 `view-ui-plus`,不新增 UI/图标依赖,不写死品牌色。
+157
View File
@@ -0,0 +1,157 @@
/**
* v2.1 应用内审批共享组件的契约类型(方案 §4)。
*
* 本文件刻意**不 import 本应用任何业务类型**:组件靠 `FlowApprovalApi` 注入数据面,
* 迁移到下一个子应用(svc-hall)时只换适配器,类型与组件本体一字不改。
* 改这里必须双端同步(与平台"四端同构文件"纪律一致)。
*/
/** 分页信封(flow-center BasePageResult 经子应用后端原样透出) */
export interface FlowPage<T> {
total: number;
content: T[];
pageIndex: number;
pageSize: number;
totalPages: number;
hasNext: boolean;
}
/** 待办行(列表"我的待办"数据源) */
export interface TodoTask {
taskId: number;
instanceId: number;
instanceVersion?: number | null;
bizType: string;
bizId: string;
bizTitle: string;
bizSnapshot?: string | null;
nodeId: string;
nodeName: string;
arriveTime: string;
}
/** 办件行(列表"办件视角"数据源) */
export interface InstanceItem {
instanceId: number;
status: number;
bizType: string;
bizId: string;
bizTitle: string;
bizUrl?: string | null;
submitterName?: string | null;
submitTime?: string | null;
finishTime?: string | null;
currentNodes?: string | null;
}
/** 出口:多出口节点办理时须选其一(选中值作为 approve 的 edgeName) */
export interface ByBizOutlet {
name: string;
toNodeId: string;
toNodeName: string;
}
/** 当前人在该业务单据上的一条待办 */
export interface ByBizTask {
taskId: number;
nodeId: string;
nodeName: string;
arriveTime: string;
/** 单出口节点为空数组;>1 即"必须先选出口" */
outlets: ByBizOutlet[];
}
/**
* by-biz 出参。注意两点契约:
* - `tasks` 的 0/1/N 都是成功;N>1 时卡片不得猜选(引导列表/任务页);
* - `currentNodes` 是**节点 id** 逗号串(办件原文口径),给人看的节点名用 `tasks[].nodeName`。
*/
export interface ByBizResult {
instanceId: number | null;
bizType: string;
bizId: string;
status: number | null;
currentNodes: string | null;
tasks: ByBizTask[];
}
/** 审批轨迹(各子应用自有进度接口形态;组件只用其中"最近一步"做摘要,缺位则不展示) */
export interface TraceStep {
/** 节点 id(与 currentNodes 同一口径,用于把 id 串翻成节点名) */
nodeId?: string | null;
nodeName?: string | null;
label?: string | null;
assignees?: Array<{ userName?: string | null; statusLabel?: string | null; opinion?: string | null }> | null;
}
export interface TraceVo {
bound?: boolean;
auditStatus?: number | null;
auditStatusDesc?: string | null;
instanceStatus?: number | null;
currentNodes?: string | null;
nodeSummary?: string | null;
steps?: TraceStep[] | null;
flowResult?: { result?: string; opinion?: string; rejectNode?: string } | null;
}
/** 换址类返回 */
export interface PageUrlResult {
pageUrl: string;
}
/**
* 组件与子应用后端之间的唯一契约。方法返回 alova `Method` 或 Promise 都可——组件只 `await`,
* 两种都成立(alova 的 Method 实现了 thenable)。
*/
export interface FlowApprovalApi {
/** 我的待办分页 */
todo(params?: { page?: number; size?: number }): any;
/** 办件视角分页 */
instances(params?: { status?: number; page?: number; size?: number }): any;
/** 当前人在该单据上的待办 */
byBiz(bizId: string): any;
/** 审批轨迹(可缺省:传了就不再由卡片自取) */
trace?(bizId: string): any;
approve(taskId: number, opinion?: string, edgeName?: string): any;
reject(taskId: number, opinion: string): any;
recordUrl(bizId: string): any;
taskPageUrl(bizId: string, taskId: number): any;
}
/** 办件状态 → 文案(flow-center 实例状态;组件不依赖子应用的枚举字典) */
export const INSTANCE_STATUS_TEXT: Record<number, string> = {
10: "审批中",
20: "已完成",
30: "已拒绝",
40: "已撤回",
};
/** 办件状态 → view-ui-plus Tag 预设色名(用组件库语义色,不写死色值) */
export const INSTANCE_STATUS_TAG: Record<number, string> = {
10: "blue",
20: "green",
30: "red",
40: "default",
};
/** 去壳任务页 postMessage 协议信封(与 flow-center front `utils/taskEmbed.ts` 同字面量,双端同步) */
export const FLOW_EMBED_PROTO = "flow";
export const FLOW_EMBED_VERSION = 1;
export interface FlowEmbedMessage {
proto: string;
v: number;
type: "handled" | "open-biz" | "height";
taskId?: string;
action?: string;
bizId?: string | null;
bizUrl?: string;
height?: number;
}
/** 父侧只认本协议的消息体;origin 白名单由 FlowTaskEmbed 按 iframe 地址另判 */
export function isFlowEmbedMessage(data: unknown): data is FlowEmbedMessage {
const d = data as FlowEmbedMessage | null;
return !!d && d.proto === FLOW_EMBED_PROTO && d.v === FLOW_EMBED_VERSION && !!d.type;
}
+64 -30
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;
@@ -129,12 +160,10 @@ const childChange = (index: number) => {
};
/** 菜单图标:读取用户中心模块管理配置的 YschIcons 图标名(GetUserMod 的 icon 字段,如 list → icon-list)。
* 本地 icons.css 即 ysch-icons 掩码版(基类 iconfont,类名 icon-<名称>);
* 平台约定留空不下发图标,此时返回空串由模板 v-if 不渲染 */
const getMenuIcon = (item?: any): string => {
const icon = String(item?.icon || "").trim();
return icon ? `icon-${icon}` : "";
};
* 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) {
@@ -149,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" @click="linkHomePage">
<i class="iconfont icon-shouye"></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>
@@ -167,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>
@@ -176,14 +204,14 @@ function linkHomePage() {
<li>
<a href="javascript:;" @click="childChange(index)">
<i :class="item.childState ? 'arrow-down' : 'arrow-up'"></i>
<i v-if="getMenuIcon(item)" :class="['iconfont', getMenuIcon(item)]"></i>
<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"
>
@@ -194,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)">
<i v-if="getMenuIcon(item)" :class="['iconfont', getMenuIcon(item)]"></i>
<i :class="['iconfont', getMenuIcon(item)]"></i>
{{ item.name }}
</a>
</li>
@@ -210,7 +238,7 @@ function linkHomePage() {
<a href="javascript:;" @click="linkHomePage">
<BreadcrumbItem>
<span class="crumb-home">
<i class="iconfont icon-category"></i>
<i class="iconfont icon-shouye"></i>
首页
</span>
</BreadcrumbItem>
@@ -250,21 +278,27 @@ function linkHomePage() {
</template>
<style scoped>
/* 仅覆盖/补充必要的动态样式,其余样式由全局 layout.css 提供 */
/* 菜单图标(本地 bsch-icons 掩码类)与文字间距 */
/* 仅覆盖/补充必要的动态样式,其余样式由中央静态通道的 base.layout.css(骨架 + 皮肤)提供 */
/* 菜单图标(通道下发的 ysch-icons 掩码类)与文字间距 */
.left-menu a .iconfont {
margin-right: 8px;
}
/* 面包屑「首页」垂直居中:layout.css 遗留的 .path-nav .icon-category{position:relative;top:1px}
是给老 iconfont 字体字形做的基线补偿,换成 ysch mask 的 1em 方块后反而把图标顶偏;
这里用 inline-flex 真居中并关掉该补偿(不动压缩产物 layout.css,覆盖收在本组件内) */
.crumb-home {
display: inline-flex;
align-items: center;
gap: 4px;
/* 顶栏已由通道皮肤改为 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";
+16 -6
View File
@@ -9,10 +9,12 @@ import type { RouteRecordRaw } from "vue-router";
* - /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 配置为 "/",即应用入口页
@@ -72,12 +74,20 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/notice/NoticeSetting.vue"),
meta: { title: "应用设置", modId: 21 },
},
// 我的待审核(应用内审批;modId 23 深层页不在菜单树,菜单挂载待后端 GetUserMod 配置)
// 待审核(应用内审批;平台模块管理已挂一级菜单,配置 ID = 89,path = /approval)
{
path: "/approval",
name: "notice_approval",
component: () => import("@/views/notice/NoticeApproval.vue"),
meta: { title: "我的待审核", modId: 23, parentId: 2 },
meta: { title: "我的待审核", modId: 89 },
},
// 抄送给我的(流程 NOTIFY 知会;平台模块管理已挂一级菜单,配置 ID = 91,path = /cc)
{
path: "/cc",
name: "notice_cc",
component: () => import("@/views/notice/NoticeCc.vue"),
meta: { title: "抄送给我的", modId: 91 },
},
/* ---- 旧版路径兼容重定向(书签、残留的平台模块配置) ---- */
+4
View File
@@ -64,6 +64,10 @@ 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;
+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 生成(版本 20261014-1811-d7e6a3c),勿手改。
* 分发方式: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-light + grad-on + 侧栏跟随(2026-10-11 翻转:什么都不配=浅色,深色需主动开启) */
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: false,
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());
}
+40 -7
View File
@@ -10,6 +10,7 @@ import type { NoticeDetailVo, NoticeRequest, UpFileVo, FlowBindingVo } from "@/t
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();
@@ -85,11 +86,21 @@ onMounted(async () => {
}
});
/** 流程图:flow-center 示意图深链(Notice 指定到人场景用人员视图) */
/** 流程图 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) {
window.open(`${config.flowHost}/#/diagram?flowGuid=${auditFlowGuid.value}&mode=user`, "_blank");
}
if (!auditFlowGuid.value) return;
flowDiagramVisible.value = true;
}
function onFlowDiagramError() {
flowDiagramVisible.value = false;
Message.error("流程图加载失败(流程可能未发布或绑定已解除)");
}
/* ============ 富文本编辑器(wangeditor v5 for vue3) ============ */
@@ -408,6 +419,14 @@ async function saveEditAndLeave(data: NoticeRequest) {
<Button @click="isModal.timiSend = false" style="margin-left: 8px">取消</Button>
</template>
</Modal>
<!-- 流程图 Drawer(后端 DiagramDef 同源 PNG;竖版长图容器内纵向滚动) -->
<Drawer v-model="flowDiagramVisible" title="流程示意图" :width="740" :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>
@@ -423,11 +442,11 @@ async function saveEditAndLeave(data: NoticeRequest) {
}
}
.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) {
@@ -439,4 +458,18 @@ async function saveEditAndLeave(data: NoticeRequest) {
.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>
+249 -114
View File
@@ -5,43 +5,79 @@ 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";
/* ============ 左侧列表 ============ */
/* ============ 左侧列表(每页 50,滚动到底部加载下一页并追加) ============ */
const keyword = ref("");
const pageNum = ref(1);
const pageSize = ref(50);
const PAGE_SIZE = 50;
const pageIndex = ref(1);
const list = ref<NoticeVo[]>([]);
const unreadCount = ref(0);
const hasNext = ref(false);
const loadingMore = ref(false);
const selected = ref(0);
const { data: page, loading, send: reload } = useRequest(
(params: NoticeQuery) => noticeApi.list(params),
{
immediate: false,
initialData: { content: [], total: 0, pageIndex: 1, pageSize: 50, totalPages: 0, hasNext: false, unreadCount: 0 },
}
);
const { send: reload } = useRequest((params: NoticeQuery) => noticeApi.list(params), { immediate: false });
async function run() {
async function fetchList(append: boolean) {
const data: NoticePageResult<NoticeVo> = await reload({
keyword: keyword.value || "",
pageIndex: pageNum.value,
pageSize: pageSize.value,
pageIndex: append ? pageIndex.value + 1 : 1,
pageSize: PAGE_SIZE,
orderByTime: "1",
});
const content = data?.content || [];
if (append) {
list.value = list.value.concat(content);
pageIndex.value += 1;
} else {
list.value = content;
pageIndex.value = 1;
}
unreadCount.value = data?.unreadCount || 0;
hasNext.value = !!data?.hasNext;
}
async function run() {
await fetchList(false);
// 默认选中第一条(同旧版进入页面即展示最新通知)
const list = data?.content || [];
if (!detail.value && list.length) {
const first = list.value[0];
if (!detail.value && first) {
selected.value = 0;
await loadDetail(list[0].id, true);
await loadDetail(first.id, true);
markRead(first);
}
}
function onSearch() {
pageNum.value = 1;
run();
/* 触底预取:距底部 60px 即发下一页请求,避免滑到尽头才出现内容 */
function onListScroll(e: Event) {
const el = e.target as HTMLElement;
if (!hasNext.value || loadingMore.value) return;
if (el.scrollTop + el.clientHeight < el.scrollHeight - 60) return;
loadMore();
}
async function loadMore() {
loadingMore.value = true;
try {
await fetchList(true);
} catch (e: any) {
Message.error(e?.message || "加载更多失败");
} finally {
loadingMore.value = false;
}
}
/** 已读状态本地置位:标读接口已改库,无需重拉列表(重拉会丢掉已追加的分页与滚动位置) */
function markRead(item: NoticeVo) {
if (item.read) return;
item.read = true;
unreadCount.value = Math.max(0, unreadCount.value - 1);
}
function onClear() {
keyword.value = "";
onSearch();
run();
}
/* 一键已读(旧版 icon-saoba 清除按钮) */
@@ -49,7 +85,8 @@ async function allRead() {
try {
await noticeApi.initializeRed().send();
Message.success("操作成功!");
run();
list.value.forEach(markRead);
unreadCount.value = 0;
if (detail.value) loadDetail(detail.value.serid, false);
} catch (e: any) {
Message.error(e.message || "操作失败");
@@ -67,16 +104,13 @@ onMounted(async () => {
async function checkNotice(item: NoticeVo, index: number) {
selected.value = index;
await loadDetail(item.id, true);
markRead(item);
}
async function loadDetail(serid: number, markRead: boolean) {
async function loadDetail(serid: number, markAsRead: boolean) {
detailLoading.value = true;
try {
detail.value = await noticeApi.modify(serid, markRead ? 1 : 0).send();
if (markRead) {
// 标读后刷新左侧未读状态
run();
}
detail.value = await noticeApi.modify(serid, markAsRead ? 1 : 0).send();
} catch (e: any) {
Message.error(e.message || "加载通知失败");
} finally {
@@ -111,7 +145,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 +153,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,15 +198,15 @@ 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">
<!-- 左侧通知列表 -->
<div class="title-list">
<div class="total">
<span>未读<span class="num">{{ page.unreadCount || 0 }}</span>条</span>
<span>未读<span class="num">{{ unreadCount }}</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">
@@ -180,13 +215,13 @@ function shortDate(date: string) {
clearable
search
placeholder="搜索发布人,标题,部门......"
@on-search="onSearch"
@on-search="run"
@on-clear="onClear"
></Input>
</div>
<div class="notice-items">
<div class="notice-items" @scroll="onListScroll">
<div
v-for="(item, index) in page.content"
v-for="(item, index) in list"
:key="item.id"
:class="{ list_item: true, readed: item.read, active: index == selected }"
@click="checkNotice(item, index)"
@@ -197,7 +232,9 @@ function shortDate(date: string) {
<span>{{ shortDate(item.createTime) }}</span>
</div>
</div>
<div v-if="!page.content.length" class="empty">暂无通知</div>
<div v-if="loadingMore" class="load-more">加载中...</div>
<div v-else-if="list.length && !hasNext" class="load-more">没有更多了</div>
<div v-if="!list.length" class="empty">暂无通知</div>
</div>
</div>
@@ -211,11 +248,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 +299,286 @@ 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;
}
.load-more {
padding: 10px 0 4px;
text-align: center;
font-size: 11.5px;
color: var(--text-3);
}
}
}
/* ---------- 右:详情卡(原型 .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;
}
}
}
}
}
+60 -203
View File
@@ -1,232 +1,89 @@
<template>
<div class="approval-page">
<Tabs v-model="tab" @on-click="load">
<TabPane label="我的待审核" name="todo" />
<TabPane label="办件视角" name="instances" />
</Tabs>
<div class="page-wrapper yh-fill">
<FlowApprovalList ref="listRef" :api="approvalApiAdapter" biz-type="notice"
:tabs="['todo', 'instances']"
@open-record="openRecord" @open-task="openTask" @handled="onHandled" />
<!-- 待审核列表 -->
<div v-if="tab === 'todo'">
<Table :columns="todoCols" :data="todoRows" :loading="loading" no-data-text="暂无待审核通知">
<template #opinion="{ row }">
<span class="opinion">{{ row._opinion }}</span>
</template>
<template #action="{ row }">
<Button size="small" type="primary" :loading="acting" @click="approve(row)">通过</Button>
<Button size="small" type="error" style="margin-left: 6px" @click="openReject(row)"
>驳回</Button
>
</template>
</Table>
<Page
v-if="todoPage.total > todoPage.pageSize"
:current="todoPage.pageIndex"
:page-size="todoPage.pageSize"
:total="todoPage.total"
show-total
style="margin-top: 14px; text-align: right"
@on-change="loadTodo"
/>
</div>
<!-- 审批记录:token 只读页,地址由抽屉自己现取(不缓存、无审批按钮) -->
<FlowRecordDrawer v-model="recordVisible" :api="approvalApiAdapter" :biz-id="recordBizId" />
<!-- 办件视角 -->
<div 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>
<Table :columns="instCols" :data="instRows" :loading="loading" no-data-text="暂无办件">
<template #status="{ row }">
<Tag :color="instStatusColor(row.status)">{{ instStatusText(row.status) }}</Tag>
</template>
</Table>
<Page
v-if="instPage.total > instPage.pageSize"
:current="instPage.pageIndex"
:page-size="instPage.pageSize"
:total="instPage.total"
show-total
style="margin-top: 14px; text-align: right"
@on-change="loadInstances"
/>
</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>
<!-- 完整办理:CAS 去壳任务页,地址由后端确认"taskId 属于当前人"后才签发 -->
<FlowTaskEmbed v-model="taskVisible" :page-url="taskPageUrl" :task-id="taskTaskId"
@handled="onTaskHandled" @open-biz="onOpenBiz" />
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { Message } from "view-ui-plus";
import approvalApi, {
type ApprovalInstance,
type ApprovalTodoTask,
} from "@/api/module/approval";
/**
* 我的待审核(应用内审批,第三步):待办列表 + 办件视角,通过/驳回走本应用后端代理
* (operator=登录态,对端按任务办理人复核;非办理人会收到业务错提示)。
* bizSnapshot 是发起时透传的 JSON 摘要,解析失败只展示标题。
* 我的待审核(v2.1 重构):列表/动作/记录抽屉/任务页容器全部下沉到 flow-approval 共享组件,
* 本页只剩两件事——注入 Notice 自己的 API 适配器,以及决定"记录/任务页"这两个浮层放在哪。
*
* 页签沿用「我的待审核 + 办件视角」(方案 D5:前置阶段不扩四页签,避免行为变化)。
* 身份仍然一个都不从前端传:适配器只送 serid/taskId/opinion/edgeName,
* operatorId/schId/name 由 NoticeService 从 CAS 会话取。
*/
const tab = ref<"todo" | "instances">("todo");
const loading = ref(false);
const acting = ref(false);
import { ref } from "vue";
import { useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import FlowApprovalList from "@/components/flow-approval/FlowApprovalList.vue";
import FlowRecordDrawer from "@/components/flow-approval/FlowRecordDrawer.vue";
import FlowTaskEmbed from "@/components/flow-approval/FlowTaskEmbed.vue";
import noticeFlowApprovalApi from "@/api/module/approvalAdapter";
const todoRows = ref<(ApprovalTodoTask & { _summary: string })[]>([]);
const todoPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
const router = useRouter();
const listRef = ref<any>(null);
const instRows = ref<ApprovalInstance[]>([]);
const instPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
const instStatus = ref(0);
const approvalApiAdapter = noticeFlowApprovalApi;
const rejectVisible = ref(false);
const rejectTaskId = ref<number | null>(null);
const rejectTitle = ref("");
const rejectOpinion = ref("");
/* ===== 审批记录抽屉 ===== */
const recordVisible = ref(false);
const recordBizId = 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: 150 },
];
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 },
];
function fmtTime(h: any, p: any) {
return h(
"span",
{},
String(p.row[p.column.key] || "").replace("T", " ").slice(0, 19) || "—"
);
function openRecord(bizId: string) {
recordBizId.value = String(bizId);
recordVisible.value = true;
}
function parseSummary(task: ApprovalTodoTask): string {
if (!task.bizSnapshot) return "—";
try {
const snap = JSON.parse(task.bizSnapshot);
return snap.summary || snap.title || "—";
} catch {
return "—";
}
}
/* ===== 完整办理(去壳任务页) ===== */
const taskVisible = ref(false);
const taskPageUrl = ref("");
const taskTaskId = ref<number | string>("");
async function loadTodo(page = 1) {
loading.value = true;
async function openTask(payload: { bizId: string; taskId: number }) {
taskPageUrl.value = "";
try {
const res = await approvalApi.todo(page, todoPage.value.pageSize).send();
todoPage.value = res;
todoRows.value = (res.content || []).map((t) => ({ ...t, _summary: parseSummary(t) }));
const res = await approvalApiAdapter.taskPageUrl(String(payload.bizId), payload.taskId);
const url = (res as any)?.pageUrl || "";
if (!url) {
Message.error((res as any)?.msg || "办理页地址获取失败");
return;
}
taskTaskId.value = payload.taskId;
taskPageUrl.value = url;
taskVisible.value = true;
} catch (e: any) {
Message.error(e?.message || "待审核列表加载失败");
} finally {
loading.value = false;
Message.error(e?.msg || e?.message || "办理页打开失败");
}
}
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 onTaskHandled() {
taskVisible.value = false;
listRef.value?.load?.();
}
function load() {
if (tab.value === "todo") {
loadTodo(1);
} else {
loadInstances(1);
}
}
async function approve(row: ApprovalTodoTask) {
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("驳回必须填写意见");
/** 任务页"查看业务详情"交回本页:列表页里即跳通知详情(同应用内路由,不用 window.open 白跳中心站) */
function onOpenBiz(payload: { bizId?: string | null; bizUrl?: string }) {
taskVisible.value = false;
const serid = payload?.bizId;
if (serid) {
router.push(`/list/detail?serid=${serid}`);
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;
}
if (payload?.bizUrl) window.open(payload.bizUrl, "_blank");
}
function instStatusText(s: number) {
return ({ 10: "审批中", 20: "已完成", 30: "已拒绝", 40: "已撤回" } as Record<number, string>)[s] || String(s);
function onHandled() {
listRef.value?.load?.();
}
function instStatusColor(s: number) {
return ({ 10: "blue", 20: "green", 30: "red", 40: "default" } as Record<number, string>)[s] || "default";
}
onMounted(load);
</script>
<style scoped>
.approval-page {
padding: 12px 0;
}
.filter-bar {
margin-bottom: 12px;
}
</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>
File diff suppressed because it is too large Load Diff
+41 -30
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();
@@ -32,9 +33,10 @@ const auditStatusOptions = [
{ value: -1, label: "无需审核" },
];
function fmtDate(d: Date) {
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 页长记忆) ============ */
@@ -54,8 +56,9 @@ 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";
@@ -143,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);
@@ -170,9 +174,10 @@ const columns = [
align: "center",
render: (h: any, p: any) => {
const st = p.row.auditStatus;
if (st === null || st === undefined) return h("span", { style: "color:#999;" }, "—");
const colors: Record<number, string> = { 0: "default", 10: "blue", 20: "green", 30: "red", 40: "orange" };
return h("Tag", { color: colors[st] || "default" }, () => p.row.auditStatusDesc || "—");
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" },
@@ -184,12 +189,14 @@ const columns = [
},
{
title: "附件",
width: 70,
/* 图标外包 Tooltip 后占 32+2*2=36px,cell 左右内边距各 18px:70px 列只剩 34px 内容宽,
溢出 2px 会让 cell 的 text-overflow:ellipsis 在图标后画出「...」,故留到 80 */
width: 80,
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: "操作",
@@ -197,21 +204,23 @@ const columns = [
align: "center",
fixed: "right",
render: (h: any, p: any) => {
// BP-R5-1:审批中(audit=10)禁止删除(后端 403)——不再渲染死按钮,审批通过/驳回/撤回后恢复
const ops = [Utils.operatingHandle(h, "查看通知", "icon-browse", () => openDetail(p.row))];
if (p.row.auditStatus !== 10) {
ops.push(Utils.operatingHandle(h, "删除通知", "icon-ashbin", () => del(p.row)));
}
return ops;
// 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">
@@ -250,14 +259,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
+75 -23
View File
@@ -9,8 +9,10 @@ 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) {
@@ -79,9 +81,30 @@ function unbindFlow() {
});
}
/** 流程图(flow-center 示意图深链,与发通知页同一入口) */
function openFlowDiagram(guid: string) {
if (guid) window.open(`${config.flowHost}/#/diagram?flowGuid=${guid}`, "_blank");
/** 流程图 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("流程图加载失败(流程可能未发布或绑定已解除)");
}
/** 当前绑定流程的节点摘要(下拉里回显) */
@@ -397,19 +420,22 @@ 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>
@@ -437,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
@@ -478,7 +506,7 @@ const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isS
</div>
<!-- 流程绑定(应用设置 > 流程绑定) -->
<div class="main-container" v-show="currentType === 2">
<div class="tab-body" v-show="currentType === 2">
<Alert show-icon type="warning">
绑定说明:绑定后「发送全部用户」的通知必须审核通过才发布(发通知页只显示「提交审核」按钮,
隐藏发送与定时发送);「发送指定人员」不受影响,仍直接发布。未绑定则保持现有发送方式。
@@ -502,6 +530,7 @@ const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isS
<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>
@@ -511,7 +540,7 @@ const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isS
已绑定
<Tag color="blue">{{ boundFlowName || boundFlowGuid }}</Tag>
<span class="node-summary">{{ boundFlowSummary }}</span>
<Button type="text" size="small" @click="openFlowDiagram(boundFlowGuid)">查看流程图</Button>
<Button type="text" size="small" @click="openFlowDiagram">查看流程图</Button>
</template>
<template v-else>
<Tag>未绑定</Tag>
@@ -520,6 +549,15 @@ const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isS
</div>
</template>
</div>
</div>
<!-- 流程图 Drawer(后端 DiagramDef 同源 PNG;竖版长图容器内纵向滚动) -->
<Drawer v-model="flowDiagramVisible" title="流程示意图" :width="740" :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="新增部门">
@@ -601,23 +639,37 @@ const groupTableHeight = computed(() => Utils.useTableHeight({ isTabs: true, isS
gap: 10px;
}
.flow-binding-form .label {
color: #515a6e;
color: var(--text-2);
}
.flow-binding-state {
color: #515a6e;
color: var(--text-2);
display: flex;
align-items: center;
gap: 6px;
}
.flow-binding-state .node-summary {
color: #808695;
color: var(--text-3);
}
.readonly-hint {
color: #808695;
color: var(--text-3);
margin-left: 8px;
}
.flow-binding-empty {
margin-top: 16px;
color: #808695;
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;
+12 -1
View File
@@ -73,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",
@@ -90,6 +89,9 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
},
},
}),
// 主题接入已免插件化(A 类,YschTheme《管理端主题远程化与公共静态通道方案》§11):
// 唯一接入点 = index.html 一段同步 loader,挂载/FOUC/CSS 排序全由通道 init 承担;
// 下面的 /static 代理只是 dev 联调便利(prod 同源用不到),删除不影响线上主题
],
resolve: {
alias: {
@@ -106,6 +108,15 @@ export default defineConfig(({ command, mode }: ConfigEnv): UserConfig => {
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,
},
},
},
// css预处理器