Compare commits

...
27 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 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
30 changed files with 2289 additions and 1030 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-*
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;
+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

+23 -5
View File
@@ -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: var(--brand, #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;
}
+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>
+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>
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
<slot :name="slotName" v-bind="slotProps" />
@@ -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;
}
+11 -12
View File
@@ -161,11 +161,9 @@ 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}` : "";
};
* 平台未配置图标时统一兜底 icon-list(2026-10-09 定稿:菜单项恒有图标,保整列缩进/行高一致) */
const getMenuIcon = (item?: any): string =>
`icon-${String(item?.icon || "").trim() || "list"}`;
function linkHomePage() {
if (baseHost.value) {
@@ -206,7 +204,7 @@ 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>
@@ -226,7 +224,7 @@ function linkHomePage() {
</template>
<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>
@@ -293,13 +291,14 @@ function linkHomePage() {
/* 面包屑「首页」垂直居中:骨架遗留的 .path-nav .icon-category{position:relative;top:1px}
是给老 iconfont 字体字形做的基线补偿,换成 ysch mask 的 1em 方块后反而把图标顶偏;
这里用 inline-flex 真居中并关掉该补偿(不动通道产物,覆盖收在本组件内) */
.crumb-home {
display: inline-flex;
align-items: center;
gap: 4px;
}
/* 面包屑「首页」与后续级目的文字对齐(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>
+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;
+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";
}
+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,
),
+3 -3
View File
@@ -1,11 +1,11 @@
/**
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20261008-2326-f7fb534),勿手改。
* 本文件由 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-deep + grad-on + 侧栏跟随(D1 拍板 deep) */
/** 定稿默认档:blue + side-light + grad-on + 侧栏跟随(2026-10-11 翻转:什么都不配=浅色,深色需主动开启) */
export interface ThemeState {
theme: ThemeName;
/** 侧栏深色(false=浅色档) */
@@ -30,7 +30,7 @@ export const SCHOOL_DEFAULT_STORAGE_KEY = "yh-theme-sch-default";
export const DEFAULT_THEME_STATE: ThemeState = {
theme: "blue",
sideDeep: true,
sideDeep: false,
gradOn: true,
sideFollow: true
};
+37 -4
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>
@@ -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>
+89 -45
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";
}
@@ -169,9 +204,9 @@ function shortDate(date: string) {
<!-- 左侧通知列表 -->
<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>
@@ -306,12 +343,6 @@ function shortDate(date: string) {
}
.clear-icon {
cursor: pointer;
font-size: 18px;
color: var(--text-3);
transition: color 0.15s;
&:hover {
color: var(--brand);
}
}
}
.search-box {
@@ -385,6 +416,12 @@ function shortDate(date: string) {
color: var(--text-3);
padding: 40px 0;
}
.load-more {
padding: 10px 0 4px;
text-align: center;
font-size: 11.5px;
color: var(--text-3);
}
}
}
@@ -443,6 +480,13 @@ function shortDate(date: string) {
p {
margin: 0;
}
/* 标签两端撑开:冒号纵向对齐、值从同一列起(宽度取「发布时间」4 字) */
.meta-label {
display: inline-block;
width: 4em;
text-align: justify;
text-align-last: justify;
}
.to-file {
margin-left: 8px;
color: var(--brand);
+59 -202
View File
@@ -1,232 +1,89 @@
<template>
<div class="page-wrapper yh-fill">
<div class="main-container yh-tabs-card">
<Tabs class="tabs-panel" v-model="tab" @on-click="load">
<TabPane label="我的待审核" name="todo" />
<TabPane label="办件视角" name="instances" />
</Tabs>
<FlowApprovalList ref="listRef" :api="approvalApiAdapter" biz-type="notice"
:tabs="['todo', 'instances']"
@open-record="openRecord" @open-task="openTask" @handled="onHandled" />
<!-- 待审核列表 -->
<div class="tab-body" v-if="tab === 'todo'">
<div class="table-region" ref="todoRegionRef">
<Table :columns="todoCols" :data="todoRows" :height="todoH || undefined" :loading="loading" no-data-text="暂无待审核通知">
<template #opinion="{ row }">
<span class="opinion">{{ row._opinion }}</span>
</template>
<template #action="{ row }">
<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>
</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>
<!-- 审批记录:token 只读页,地址由抽屉自己现取(不缓存、无审批按钮) -->
<FlowRecordDrawer v-model="recordVisible" :api="approvalApiAdapter" :biz-id="recordBizId" />
<!-- 办件视角 -->
<div class="tab-body" v-else>
<div class="filter-bar">
<RadioGroup v-model="instStatus" type="button" @on-change="loadInstances(1)">
<Radio :label="0">全部</Radio>
<Radio :label="10">审批中</Radio>
<Radio :label="20">已完成</Radio>
<Radio :label="30">已拒绝</Radio>
<Radio :label="40">已撤回</Radio>
</RadioGroup>
</div>
<div class="table-region" ref="instRegionRef">
<Table :columns="instCols" :data="instRows" :height="instH || undefined" :loading="loading" no-data-text="暂无办件">
<template #status="{ row }">
<Tag :color="instStatusColor(row.status)">{{ instStatusText(row.status) }}</Tag>
</template>
</Table>
</div>
<div class="page-panel" v-if="instPage.total > instPage.pageSize">
<Page class="page-box fr" :current="instPage.pageIndex" :page-size="instPage.pageSize"
:total="instPage.total" show-total @on-change="loadInstances" />
</div>
</div>
</div>
<!-- 驳回弹窗:意见必填 -->
<Modal
v-model="rejectVisible"
title="驳回审核"
:loading="acting"
@on-ok="doReject"
@on-cancel="rejectTaskId = null"
>
<p style="margin-bottom: 8px">
驳回「<b>{{ rejectTitle }}</b
>」,必须填写意见:
</p>
<Input v-model="rejectOpinion" type="textarea" :rows="3" placeholder="驳回意见(必填)" />
</Modal>
<!-- 完整办理: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 { useElementHeight } from "@/utils/hook/useElementHeight";
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";
/* 两个表格高度:实测各自 .table-region(flex:1),替代写死常量 */
const todoRegionRef = ref<HTMLElement | null>(null);
const instRegionRef = ref<HTMLElement | null>(null);
const todoH = useElementHeight(todoRegionRef);
const instH = useElementHeight(instRegionRef);
const router = useRouter();
const listRef = ref<any>(null);
const todoRows = ref<(ApprovalTodoTask & { _summary: string })[]>([]);
const todoPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
const approvalApiAdapter = noticeFlowApprovalApi;
const instRows = ref<ApprovalInstance[]>([]);
const instPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
const instStatus = ref(0);
/* ===== 审批记录抽屉 ===== */
const recordVisible = ref(false);
const recordBizId = ref("");
const rejectVisible = ref(false);
const rejectTaskId = ref<number | null>(null);
const rejectTitle = ref("");
const rejectOpinion = ref("");
const todoCols = [
{ title: "通知", key: "bizTitle", minWidth: 220, ellipsis: true, tooltip: true },
{ title: "摘要", key: "_summary", minWidth: 200, ellipsis: true, tooltip: true },
{ title: "当前节点", key: "nodeName", width: 110 },
{ title: "到达时间", key: "arriveTime", width: 170, render: fmtTime },
{ title: "操作", slot: "action", width: 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>
.filter-bar {
margin-bottom: 12px;
}
</style>
+17 -43
View File
@@ -1,34 +1,26 @@
<template>
<div class="page-wrapper yh-fill">
<div class="main-container">
<div class="table-region" ref="regionRef">
<Table :columns="cols" :data="rows" :height="tableH || undefined" :loading="loading"
no-data-text="暂无抄送通知">
<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>
</Table>
</div>
<div class="page-panel" v-if="page.total > page.pageSize">
<Page class="page-box fr" :current="page.pageIndex" :page-size="page.pageSize" :total="page.total"
show-total @on-change="load" />
</div>
<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 { onMounted, ref } from "vue";
import { ref } from "vue";
import { useRouter } from "vue-router";
import { Message } from "view-ui-plus";
import { useElementHeight } from "@/utils/hook/useElementHeight";
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
import ccApi, { type CcItem } from "@/api/module/cc";
/**
@@ -37,12 +29,9 @@ import ccApi, { type CcItem } from "@/api/module/cc";
* 编辑视角(type=0)不标读:未发布通知的阅读统计无意义。
*/
const router = useRouter();
const loading = ref(false);
const rows = ref<CcItem[]>([]);
const page = ref({ total: 0, pageIndex: 1, pageSize: 10 });
const regionRef = ref<HTMLElement | null>(null);
const tableH = useElementHeight(regionRef);
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 },
@@ -69,21 +58,6 @@ function statusColor(auditStatus: number | null) {
function openDetail(row: CcItem) {
router.push({ path: "/list/detail", query: { serid: row.noticeId, type: 0 } });
}
async function load(p = 1) {
loading.value = true;
try {
const res = await ccApi.my(p, page.value.pageSize).send();
page.value = res;
rows.value = res.content || [];
} catch (e: any) {
Message.error(e?.message || "抄送列表加载失败");
} finally {
loading.value = false;
}
}
onMounted(() => load(1));
</script>
<style scoped>
+113 -77
View File
@@ -5,7 +5,11 @@ import { Message } from "view-ui-plus";
import { noticeApi } from "@/api/module/notice";
import type { NoticeDetailVo, NextPrevVo, ReadGroup, FlowTraceVo } from "@/types/notice";
import FilePreviewModal from "@/components/FilePreviewModal.vue";
import FlowApprovalCard from "@/components/flow-approval/FlowApprovalCard.vue";
import FlowTaskEmbed from "@/components/flow-approval/FlowTaskEmbed.vue";
import noticeFlowApprovalApi from "@/api/module/approvalAdapter";
import Utils from "@/utils";
import { fitDiagram } from "@/utils/diagramImg";
import config from "@/assets/config/host.config";
const route = useRoute();
@@ -36,6 +40,23 @@ async function loadTrace() {
}
}
/**
* 当前节点:flow-center 的 currentNodes 是**节点 id 逗号串**(办件原文口径),
* 用人看的名字得拿 steps 的 nodeId→nodeName 翻;翻不到(老实例/步骤缺失)才回落原 id。
*/
const currentNodeText = computed(() => {
const ids = String(flowTrace.value?.currentNodes || "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
if (!ids.length) return "";
const names = new Map<string, string>();
for (const s of flowTrace.value?.steps || []) {
if (s.nodeId && s.nodeName) names.set(String(s.nodeId), s.nodeName);
}
return ids.map((id) => names.get(id) || id).join("、");
});
/**
* 撤回:走本应用后端(2026-09-17 接入:后端经 SDK 托管件中转并复核创建人,
* 不再前端直调 flow-center——跨应用 CAS 票据 + CORS 双重外溢且"谁能撤"落在前端)。
@@ -77,6 +98,7 @@ async function flowAction(action: "withdraw" | "urge") {
/* 流程图弹窗(2026-09-17:后端同源出图 /flow/Diagram,img 免 token 免跨域,不再深链 flow-center) */
const diagramVisible = ref(false);
const diagramEl = ref<HTMLImageElement | null>(null);
function openFlowDiagram() {
if (!detail.value?.flowGuid || !detail.value?.flowInstanceId) return;
diagramVisible.value = true;
@@ -108,6 +130,56 @@ async function openFlowEmbed() {
}
}
/* ============ v2.1 审批动作:卡片办理后刷新;完整办理走 CAS 去壳任务页 ============ */
/** 卡片里通过/驳回成功:刷新进度与详情(业务状态由 flow-center 回调推进,前端不本地写) */
async function onApprovalHandled() {
await Promise.all([load(), loadTrace()]);
}
const taskVisible = ref(false);
const taskPageUrl = ref("");
const taskCurrentId = ref<number | string>("");
/** 任务页地址由后端签发(它会先确认 taskId 属于当前人待办),前端不拼 flow-center 基址 */
async function openTaskFromCard(taskId: number) {
taskPageUrl.value = "";
try {
const res: any = await noticeFlowApprovalApi.taskPageUrl(String(serid.value), taskId);
if (!res?.pageUrl) {
Message.error(res?.msg || "办理页地址获取失败");
return;
}
taskCurrentId.value = taskId;
taskPageUrl.value = res.pageUrl;
taskVisible.value = true;
} catch (e: any) {
Message.error(e?.msg || e?.message || "办理页打开失败");
}
}
/** 任务页里办完:关弹窗并刷新进度/详情,不等轮询 */
async function onTaskHandled() {
taskVisible.value = false;
await Promise.all([load(), loadTrace()]);
}
/**
* 任务页"查看业务详情":bizId 就是 notice.id。本页通常就是该单据详情 → 关弹窗刷新即可;
* 指向别的单据才跳转。bizUrl 是裸相对路径(不含 ctx/#),由本应用路由接管,不能 window.open
* 原串(会白跳一个 404)。
*/
function onTaskOpenBiz(payload: { bizId?: string | null; bizUrl?: string }) {
taskVisible.value = false;
const target = String(payload?.bizId ?? "");
if (target && target !== String(serid.value)) {
router.push(`/list/detail?serid=${target}`);
return;
}
load();
loadTrace();
}
const readGroups = ref<ReadGroup[]>([]);
const showRead = ref(false);
const prevId = ref(0);
@@ -207,7 +279,7 @@ function downAll() {
function displayName(item: { name?: string; filename?: string }) {
return item.name || item.filename || "";
}
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian1/yasuo/weizhi) */
/* 附件图标(按扩展名,图标名对齐旧版 matchSuffix → icon-word/excel/ppt/pdf1/txt/tupian/yasuo/weizhi) */
function getFileIcon(name: string) {
const suffix = String(name || "").split(".").pop()?.toLowerCase() || "";
if (["doc", "docx", "wps"].includes(suffix)) return "icon-word";
@@ -215,7 +287,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";
}
@@ -274,13 +347,15 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
<!-- 查看视角:普通标题 -->
<h2 v-else class="notice-title2">{{ detail.title }}</h2>
<!-- 审批进度卡片(绑流程通知;提交人/管理员/办理人可见) -->
<!-- 审批进度卡片(绑流程通知;提交人/管理员/办理人可见)
只回答"到哪一步了":状态 / 流程摘要 / 当前节点 / 办理意见 + 入口;
完整流水(按节点/时间线/列表三视图)在「全部审批记录」抽屉里,同一份 trace 不两处铺开。 -->
<div v-if="flowTrace && flowTrace.bound" class="flow-card">
<div class="flow-card-head">
<span class="flow-title">审批进度</span>
<Tag :color="auditColor(flowTrace.auditStatus)">{{ flowTrace.auditStatusDesc || "—" }}</Tag>
<span v-if="flowTrace.nodeSummary" class="flow-nodes">流程:{{ flowTrace.nodeSummary }}</span>
<span v-if="flowTrace.currentNodes" class="flow-nodes">当前节点:{{ flowTrace.currentNodes }}</span>
<span v-if="currentNodeText" class="flow-nodes">当前节点:{{ currentNodeText }}</span>
<div class="flow-ops">
<Button v-if="detail.flowGuid" size="small" @click="openFlowDiagram">查看流程图</Button>
<Button
@@ -288,7 +363,7 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
size="small"
style="margin-left: 8px"
@click="openFlowEmbed"
>审批进度</Button
>全部审批记录</Button
>
<Button
v-if="detail.flowInstanceId && flowTrace.auditStatus === 10"
@@ -313,49 +388,35 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
办理意见:{{ flowTrace.flowResult.opinion }}
<template v-if="flowTrace.flowResult.rejectNode">({{ flowTrace.flowResult.rejectNode }})</template>
</div>
<!-- 结构化步骤时间线(2026-09-17 接入 SDK progress;steps[0] 固定"发起申请",同节点多任务每人一行) -->
<div v-if="flowTrace.steps && flowTrace.steps.length" class="flow-timeline">
<div
v-for="(s, i) in flowTrace.steps"
:key="'fs' + i"
class="flow-step"
:class="{ 'flow-step-active': s.active }"
>
<span class="flow-step-node">{{ s.label || s.nodeName || `节点${i + 1}` }}</span>
<template v-if="s.assignees && s.assignees.length">
<span v-for="(a, j) in s.assignees" :key="j" class="flow-step-user">
{{ a.userName || "" }}
<em v-if="a.statusLabel" class="flow-step-status">{{ a.statusLabel }}</em>
<em v-if="a.opinion" class="flow-step-op">({{ a.opinion }})</em>
</span>
</template>
</div>
</div>
<!-- 节点时间线(traceJson 解析,steps 不可用时的兜底) -->
<div v-else-if="flowTrace.trace && flowTrace.trace.length" class="flow-timeline">
<div v-for="(t, i) in flowTrace.trace" :key="i" class="flow-step">
<span class="flow-step-node">{{ t.nodeName || t.node || t.taskName || `节点${i + 1}` }}</span>
<span class="flow-step-user">{{ t.userName || t.assigneeName || t.handlerName || "" }}</span>
<span class="flow-step-time">{{ t.handleTime || t.time || t.createTime || "" }}</span>
<span class="flow-step-op" v-if="t.opinion">{{ t.opinion }}</span>
</div>
</div>
</div>
<!-- v2.1 审批动作卡片:当前人有待办→通过/驳回/完整办理;非待办人与终态→只读 + 查看记录。
与上方"审批进度"合用一份 flowTrace,动作区归卡片,状态/时间线仍归原卡片(方案 §7.4) -->
<FlowApprovalCard v-if="flowTrace && flowTrace.bound" :api="noticeFlowApprovalApi"
biz-type="notice" :biz-id="String(serid)" :title="detail.title"
:trace="flowTrace"
@handled="onApprovalHandled" @open-record="openFlowEmbed"
@open-task="openTaskFromCard" />
<!-- 流程图弹窗(后端同源出图,免 token 免跨域) -->
<Modal v-model="diagramVisible" title="流程示意图" :width="860" footer-hide>
<Modal v-model="diagramVisible" title="流程示意图" :width="740" footer-hide>
<div class="diagram-wrap">
<img v-if="diagramSrc" :src="diagramSrc" alt="流程示意图" @error="onDiagramError" />
<img v-if="diagramSrc" ref="diagramEl" :src="diagramSrc" alt="流程示意图" @load="fitDiagram(diagramEl)" @error="onDiagramError" />
</div>
</Modal>
<!-- 办件进度嵌入页(Drawer 内 iframe 撑满,嵌入页内部滚动;token 每次打开现取) -->
<Drawer v-model="embedVisible" title="审批进度" :width="860" :transfer="false" :styles="{ body: { padding: '0' } }">
<!-- 全部审批记录:flow-center 只读嵌入页(按节点/时间线/列表三视图 + 流程图 + 业务信息) -->
<Drawer v-model="embedVisible" title="全部审批记录" :width="740" :transfer="false" :styles="{ body: { padding: '0' } }">
<div class="embed-frame-wrap">
<iframe v-if="embedPageUrl" :src="embedPageUrl" title="办件进度"></iframe>
</div>
</Drawer>
<!-- 完整办理:flow-center 去壳任务页(CAS 写面;退回/移交/多出口在那一侧) -->
<FlowTaskEmbed v-model="taskVisible" :page-url="taskPageUrl" :task-id="taskCurrentId"
@handled="onTaskHandled" @open-biz="onTaskOpenBiz" />
<div v-if="viewType !== 0" class="meta">
<span>发文单位:{{ detail.DeptName || "—" }}</span>
<span>发布人:{{ detail.TeaName || "—" }}</span>
@@ -416,6 +477,19 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
</template>
<style scoped>
/* 高度模型(2026-10-10 定稿):卡片撑满视口(与全站其他页一致),但必须能"随长文生长"。
不能直接用通道 .yh-fill:其卡片是 flex:1(basis:0)+ min-height:420px,卡片被钉在容器高,
而 .main-container 带 overflow:hidden(base.layout.css:222)→ 长文被裁掉且滚不到(最小复现已验证)。
故此处 page-wrapper 转 flex 纵向(全局已给 min-height:100%),卡片 flex:1 0 auto(basis:auto)
→ 短文撑满、长文随内容长高、滚动留在 main-wrapper 整页滚。 */
.page-wrapper {
display: flex;
flex-direction: column;
}
.page-wrapper > .main-container {
flex: 1 0 auto;
min-height: 420px;
}
.ops-top {
display: flex;
justify-content: space-between;
@@ -463,54 +537,13 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
color: var(--red);
font-size: 13px;
}
.flow-timeline {
margin-top: 8px;
background: var(--panel);
border-radius: var(--r-s);
padding: 6px 10px;
}
.flow-step {
display: flex;
gap: 12px;
font-size: 13px;
color: var(--text-2);
padding: 3px 0;
border-bottom: 1px dashed var(--line);
}
.flow-step:last-child {
border-bottom: none;
}
.flow-step-node {
min-width: 120px;
}
.flow-step-user {
min-width: 90px;
color: var(--brand);
}
.flow-step-time {
color: var(--text-3);
}
.flow-step-op {
color: var(--red);
}
.flow-step-active .flow-step-node {
color: var(--brand);
font-weight: 600;
}
.flow-step-status {
font-style: normal;
color: var(--text-3);
margin-left: 4px;
}
.flow-step .flow-step-op {
font-style: normal;
}
.diagram-wrap {
text-align: center;
min-height: 200px;
}
.diagram-wrap img {
max-width: 100%;
height: auto;
}
/* 嵌入页 iframe 撑满 Drawer body(body padding 已由 :styles 置 0),嵌入页内部滚动 */
.embed-frame-wrap {
@@ -583,6 +616,9 @@ function openPreview(item: { name?: string; filename?: string; fileid?: number;
}
.content {
padding: 16px 0;
/* 正文区最小高度:短通知别让附件/按钮紧贴一行字(旧版用 clientHeight-356 铺满视口,
会把附件顶出首屏;这里只留"呼吸区",剩余空白落在按钮下方的卡片底部) */
min-height: 200px;
line-height: 1.8;
word-break: break-all;
color: var(--text);
+24 -18
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";
@@ -33,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 页长记忆) ============ */
@@ -55,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";
@@ -144,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);
@@ -172,8 +175,9 @@ const columns = [
render: (h: any, p: any) => {
const st = p.row.auditStatus;
if (st === null || st === undefined) return h("span", { style: "color:var(--text-3);" }, "—");
const colors: Record<number, string> = { 0: "default", 10: "blue", 20: "green", 30: "red", 40: "orange" };
return h("Tag", { color: colors[st] || "default" }, () => p.row.auditStatusDesc || "—");
// 色系对齐流程侧:待提交灰 / 审批中橙 / 已通过绿 / 已驳回红 / 已撤回灰
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" },
@@ -185,10 +189,12 @@ 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:var(--text-3);" }, "无"),
},
@@ -198,12 +204,12 @@ 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),
];
},
},
];
+49 -4
View File
@@ -9,6 +9,7 @@ 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 切换(单卡片 yh-tabs-card 内含线性 Tabs,切换只 v-show 对应 tab-body) ============ */
@@ -80,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("流程图加载失败(流程可能未发布或绑定已解除)");
}
/** 当前绑定流程的节点摘要(下拉里回显) */
@@ -508,6 +530,7 @@ const groupH = useElementHeight(groupRegionRef);
<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>
@@ -517,7 +540,7 @@ const groupH = useElementHeight(groupRegionRef);
已绑定
<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>
@@ -528,6 +551,14 @@ const groupH = useElementHeight(groupRegionRef);
</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="新增部门">
<Form ref="deptFormRef" :model="deptForm" :rules="deptRule" :label-width="110">
@@ -627,4 +658,18 @@ const groupH = useElementHeight(groupRegionRef);
margin-top: 16px;
color: var(--text-3);
}
/* 流程图 Drawer:浅底色 + 纵向滚动(竖版长图),图片撑满可读宽度 */
.flow-diagram-frame {
height: 100%;
overflow: auto;
background: #f5f7fa;
padding: 12px;
box-sizing: border-box;
}
.flow-diagram-frame img {
display: block;
margin: 0 auto;
max-width: 100%;
height: auto;
}
</style>