Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d5034fbaf | ||
|
|
0b62e7c304 | ||
|
|
7c969955ae | ||
|
|
f085d6d74d | ||
|
|
62d9024f57 | ||
|
|
c068321279 | ||
|
|
310743222c | ||
|
|
46710cd18e |
@@ -19,7 +19,10 @@ jobs:
|
|||||||
options: --network infra-infra-net
|
options: --network infra-infra-net
|
||||||
steps:
|
steps:
|
||||||
- name: 安装 git / curl / zip(alpine)
|
- 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 本仓库
|
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
|
||||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||||
@@ -51,6 +54,8 @@ jobs:
|
|||||||
set -e
|
set -e
|
||||||
cd /build
|
cd /build
|
||||||
if [ -f .npmrc ]; then sed -i "/SASS_BINARY_PATH/d" .npmrc; fi
|
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 ci
|
||||||
npm run build
|
npm run build
|
||||||
'
|
'
|
||||||
|
|||||||
@@ -27,3 +27,6 @@ yarn.lock
|
|||||||
|
|
||||||
# scratch
|
# scratch
|
||||||
.temp
|
.temp
|
||||||
|
|
||||||
|
# vite 配置 bundle 临时产物
|
||||||
|
vite.config.*.timestamp-*
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB |
@@ -61,6 +61,45 @@ export interface ApprovalPage<T> {
|
|||||||
hasNext: boolean;
|
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 = {
|
export const approvalApi = {
|
||||||
/** 我的待审核(bizType=notice 固定在后端) */
|
/** 我的待审核(bizType=notice 固定在后端) */
|
||||||
todo(page = 1, size = 10): Method<ApprovalPage<ApprovalTodoTask>> {
|
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>> {
|
instances(status?: number, page = 1, size = 10): Method<ApprovalPage<ApprovalInstance>> {
|
||||||
return get(`${APPROVAL}/Instances`, status ? { status, page, size } : { page, size });
|
return get(`${APPROVAL}/Instances`, status ? { status, page, size } : { page, size });
|
||||||
},
|
},
|
||||||
/** 通过(opinion 可选) */
|
/** 当前人在该通知上的待办(卡片可写/只读、多待办与多出口判定的唯一依据) */
|
||||||
approve(taskId: number, opinion?: string): Method<void> {
|
byBiz(serid: number): Method<ByBizResult> {
|
||||||
return postJSON(`${APPROVAL}/Approve?taskId=${taskId}`, opinion ? { opinion } : {});
|
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 必填) */
|
/** 拒绝(opinion 必填) */
|
||||||
reject(taskId: number, opinion: string): Method<void> {
|
reject(taskId: number, opinion: string): Method<void> {
|
||||||
|
|||||||
@@ -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;
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 186 KiB |
+19
-14
@@ -98,17 +98,25 @@ h4.subtitle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 表格加载(规范 §4 表格加载行):图形=通道 .ysch-spin(随 --brand),本地零图形资产 */
|
||||||
.table-spin-load {
|
.table-spin-load {
|
||||||
|
position: relative;
|
||||||
width: 60px;
|
width: 60px;
|
||||||
height: 90px;
|
height: 90px;
|
||||||
padding-top: 60px;
|
padding-top: 60px;
|
||||||
background-image: url(../image/loading.gif);
|
|
||||||
background-size: 60px 60px;
|
|
||||||
background-position: top center;
|
|
||||||
background-repeat: no-repeat;
|
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--brand, #577afb);
|
color: var(--brand);
|
||||||
margin: 0 auto;
|
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 {
|
.ivu-table-cell-tree {
|
||||||
@@ -116,15 +124,12 @@ h4.subtitle {
|
|||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 修复表格幻影省略号(2026-10-10):.ivu-table-cell 基础样式自带 text-overflow:ellipsis,
|
/* 修复表格幻影省略号(2026-10-10):.ivu-table-cell 自带 overflow:hidden + text-overflow:ellipsis,
|
||||||
tooltip 列(.ivu-tooltip 行内块包装 + width:100%)与操作列(图标外包 Tooltip)未溢出也常显「...」。
|
tooltip 列的包装层(.ivu-tooltip 行内块 + width:100%)未溢出也会被画出「...」;
|
||||||
恢复组件预期的块级/flex 渲染层级即可;内层 .ivu-table-cell-tooltip-content 的真省略号与
|
恢复组件预期的 flex 层级即可,内层 .ivu-table-cell-tooltip-content 的真省略号与悬浮 tooltip 不受影响。
|
||||||
悬浮 tooltip 不受影响(已在 dev:mock 实测:短内容无省略号、长标题正常截断+悬浮提示)。 */
|
注意:不要再给操作列的裸 .ivu-tooltip 加 display:block——那会以更高特异性顶掉 App.vue 的横排规则,
|
||||||
/* 两条同为 3 类特异性(0,3,0):后者居后胜出 → tooltip 列(.ivu-table-cell-tooltip)最终 flex
|
使 operatingHandle 渲染的图标按钮竖排(2026-10-10 踩过,已回退)。 */
|
||||||
(组件预期布局),操作列裸 .ivu-tooltip 包装取 block。 */
|
/* tooltip 列的包装层同时挂 .ivu-tooltip{display:inline-block},靠声明顺序顶掉了组件预期的 flex,这里恢复 */
|
||||||
.ivu-table .ivu-table-cell > .ivu-tooltip {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
.ivu-table .ivu-table-cell > .ivu-table-cell-tooltip {
|
.ivu-table .ivu-table-cell > .ivu-table-cell-tooltip {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ defineExpose({
|
|||||||
no-data-text="暂无数据"
|
no-data-text="暂无数据"
|
||||||
>
|
>
|
||||||
<template #loading>
|
<template #loading>
|
||||||
<div class="table-spin-load">加载中</div>
|
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||||
</template>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
<div class="page-panel">
|
<div class="page-panel">
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||||
<template #loading>
|
<template #loading>
|
||||||
<div class="table-spin-load">加载中</div>
|
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||||
</template>
|
</template>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
<Table :columns="columns" :data="list" :height="tableH" :loading="loading" no-data-text="暂无数据"
|
||||||
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
class="link-table table-bottom-box" @on-selection-change="selectChange">
|
||||||
<template #loading>
|
<template #loading>
|
||||||
<div class="table-spin-load">加载中</div>
|
<div class="table-spin-load"><i class="ysch-spin"></i>加载中</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
<template v-for="slotName in tableSlotNames" :key="slotName" v-slot:[slotName]="slotProps">
|
||||||
<slot :name="slotName" v-bind="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>
|
||||||
@@ -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/图标依赖,不写死品牌色。
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+3
-3
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* 本文件由 YschTheme scripts/gen-theme.mjs 从 assets-src/pc/theme-core.js 生成(版本 20261009-1655-488a3c3),勿手改。
|
* 本文件由 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 对齐报告)。
|
* 分发方式:YschTheme `npm run sync` 拷进各端 src/utils/theme.ts(与 ThemeSwitcher.vue 配套,MD5 对齐报告)。
|
||||||
* 运行时档位类名由 /static/js/yh-theme-init.js 在 <head> 内先行挂好,本模块只负责读写与切换。
|
* 运行时档位类名由 /static/js/yh-theme-init.js 在 <head> 内先行挂好,本模块只负责读写与切换。
|
||||||
*/
|
*/
|
||||||
export type ThemeName = "blue" | "ocean" | "ink" | "violet" | "teal";
|
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 {
|
export interface ThemeState {
|
||||||
theme: ThemeName;
|
theme: ThemeName;
|
||||||
/** 侧栏深色(false=浅色档) */
|
/** 侧栏深色(false=浅色档) */
|
||||||
@@ -30,7 +30,7 @@ export const SCHOOL_DEFAULT_STORAGE_KEY = "yh-theme-sch-default";
|
|||||||
|
|
||||||
export const DEFAULT_THEME_STATE: ThemeState = {
|
export const DEFAULT_THEME_STATE: ThemeState = {
|
||||||
theme: "blue",
|
theme: "blue",
|
||||||
sideDeep: true,
|
sideDeep: false,
|
||||||
gradOn: true,
|
gradOn: true,
|
||||||
sideFollow: true
|
sideFollow: true
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -421,7 +421,7 @@ async function saveEditAndLeave(data: NoticeRequest) {
|
|||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<!-- 流程图 Drawer(后端 DiagramDef 同源 PNG;竖版长图容器内纵向滚动) -->
|
<!-- 流程图 Drawer(后端 DiagramDef 同源 PNG;竖版长图容器内纵向滚动) -->
|
||||||
<Drawer v-model="flowDiagramVisible" title="流程示意图" :width="860" :transfer="false"
|
<Drawer v-model="flowDiagramVisible" title="流程示意图" :width="740" :transfer="false"
|
||||||
:styles="{ body: { padding: '0' } }">
|
:styles="{ body: { padding: '0' } }">
|
||||||
<div class="flow-diagram-frame">
|
<div class="flow-diagram-frame">
|
||||||
<img ref="flowDiagramEl" v-if="flowDiagramSrc" :src="flowDiagramSrc" alt="流程示意图" @load="fitDiagram(flowDiagramEl)" @error="onFlowDiagramError" />
|
<img ref="flowDiagramEl" v-if="flowDiagramSrc" :src="flowDiagramSrc" alt="流程示意图" @load="fitDiagram(flowDiagramEl)" @error="onFlowDiagramError" />
|
||||||
|
|||||||
@@ -8,41 +8,76 @@ import FilePreviewModal from "@/components/FilePreviewModal.vue";
|
|||||||
import SvgIcon from "@/components/SvgIcon.vue";
|
import SvgIcon from "@/components/SvgIcon.vue";
|
||||||
import Utils from "@/utils";
|
import Utils from "@/utils";
|
||||||
|
|
||||||
/* ============ 左侧列表 ============ */
|
/* ============ 左侧列表(每页 50,滚动到底部加载下一页并追加) ============ */
|
||||||
const keyword = ref("");
|
const keyword = ref("");
|
||||||
const pageNum = ref(1);
|
const PAGE_SIZE = 50;
|
||||||
const pageSize = ref(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 selected = ref(0);
|
||||||
|
|
||||||
const { data: page, loading, send: reload } = useRequest(
|
const { send: reload } = useRequest((params: NoticeQuery) => noticeApi.list(params), { immediate: false });
|
||||||
(params: NoticeQuery) => noticeApi.list(params),
|
|
||||||
{
|
|
||||||
immediate: false,
|
|
||||||
initialData: { content: [], total: 0, pageIndex: 1, pageSize: 50, totalPages: 0, hasNext: false, unreadCount: 0 },
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
async function run() {
|
async function fetchList(append: boolean) {
|
||||||
const data: NoticePageResult<NoticeVo> = await reload({
|
const data: NoticePageResult<NoticeVo> = await reload({
|
||||||
keyword: keyword.value || "",
|
keyword: keyword.value || "",
|
||||||
pageIndex: pageNum.value,
|
pageIndex: append ? pageIndex.value + 1 : 1,
|
||||||
pageSize: pageSize.value,
|
pageSize: PAGE_SIZE,
|
||||||
orderByTime: "1",
|
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 || [];
|
const first = list.value[0];
|
||||||
if (!detail.value && list.length) {
|
if (!detail.value && first) {
|
||||||
selected.value = 0;
|
selected.value = 0;
|
||||||
await loadDetail(list[0].id, true);
|
await loadDetail(first.id, true);
|
||||||
|
markRead(first);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function onSearch() {
|
|
||||||
pageNum.value = 1;
|
/* 触底预取:距底部 60px 即发下一页请求,避免滑到尽头才出现内容 */
|
||||||
run();
|
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() {
|
function onClear() {
|
||||||
keyword.value = "";
|
keyword.value = "";
|
||||||
onSearch();
|
run();
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 一键已读(旧版 icon-saoba 清除按钮) */
|
/* 一键已读(旧版 icon-saoba 清除按钮) */
|
||||||
@@ -50,7 +85,8 @@ async function allRead() {
|
|||||||
try {
|
try {
|
||||||
await noticeApi.initializeRed().send();
|
await noticeApi.initializeRed().send();
|
||||||
Message.success("操作成功!");
|
Message.success("操作成功!");
|
||||||
run();
|
list.value.forEach(markRead);
|
||||||
|
unreadCount.value = 0;
|
||||||
if (detail.value) loadDetail(detail.value.serid, false);
|
if (detail.value) loadDetail(detail.value.serid, false);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Message.error(e.message || "操作失败");
|
Message.error(e.message || "操作失败");
|
||||||
@@ -68,16 +104,13 @@ onMounted(async () => {
|
|||||||
async function checkNotice(item: NoticeVo, index: number) {
|
async function checkNotice(item: NoticeVo, index: number) {
|
||||||
selected.value = index;
|
selected.value = index;
|
||||||
await loadDetail(item.id, true);
|
await loadDetail(item.id, true);
|
||||||
|
markRead(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDetail(serid: number, markRead: boolean) {
|
async function loadDetail(serid: number, markAsRead: boolean) {
|
||||||
detailLoading.value = true;
|
detailLoading.value = true;
|
||||||
try {
|
try {
|
||||||
detail.value = await noticeApi.modify(serid, markRead ? 1 : 0).send();
|
detail.value = await noticeApi.modify(serid, markAsRead ? 1 : 0).send();
|
||||||
if (markRead) {
|
|
||||||
// 标读后刷新左侧未读状态
|
|
||||||
run();
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Message.error(e.message || "加载通知失败");
|
Message.error(e.message || "加载通知失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -171,7 +204,7 @@ function shortDate(date: string) {
|
|||||||
<!-- 左侧通知列表 -->
|
<!-- 左侧通知列表 -->
|
||||||
<div class="title-list">
|
<div class="title-list">
|
||||||
<div class="total">
|
<div class="total">
|
||||||
<span>未读<span class="num">{{ page.unreadCount || 0 }}</span>条</span>
|
<span>未读<span class="num">{{ unreadCount }}</span>条</span>
|
||||||
<Tooltip content="一键已读" placement="top" transfer>
|
<Tooltip content="一键已读" placement="top" transfer>
|
||||||
<SvgIcon class="clear-icon" name="read-all" :size="18" color="var(--text-3)" hover-color="var(--brand)" @click="allRead" />
|
<SvgIcon class="clear-icon" name="read-all" :size="18" color="var(--text-3)" hover-color="var(--brand)" @click="allRead" />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -182,13 +215,13 @@ function shortDate(date: string) {
|
|||||||
clearable
|
clearable
|
||||||
search
|
search
|
||||||
placeholder="搜索发布人,标题,部门......"
|
placeholder="搜索发布人,标题,部门......"
|
||||||
@on-search="onSearch"
|
@on-search="run"
|
||||||
@on-clear="onClear"
|
@on-clear="onClear"
|
||||||
></Input>
|
></Input>
|
||||||
</div>
|
</div>
|
||||||
<div class="notice-items">
|
<div class="notice-items" @scroll="onListScroll">
|
||||||
<div
|
<div
|
||||||
v-for="(item, index) in page.content"
|
v-for="(item, index) in list"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
:class="{ list_item: true, readed: item.read, active: index == selected }"
|
:class="{ list_item: true, readed: item.read, active: index == selected }"
|
||||||
@click="checkNotice(item, index)"
|
@click="checkNotice(item, index)"
|
||||||
@@ -199,7 +232,9 @@ function shortDate(date: string) {
|
|||||||
<span>{{ shortDate(item.createTime) }}</span>
|
<span>{{ shortDate(item.createTime) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -381,6 +416,12 @@ function shortDate(date: string) {
|
|||||||
color: var(--text-3);
|
color: var(--text-3);
|
||||||
padding: 40px 0;
|
padding: 40px 0;
|
||||||
}
|
}
|
||||||
|
.load-more {
|
||||||
|
padding: 10px 0 4px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--text-3);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,273 +1,89 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="page-wrapper yh-fill">
|
<div class="page-wrapper yh-fill">
|
||||||
<div class="main-container yh-tabs-card">
|
<FlowApprovalList ref="listRef" :api="approvalApiAdapter" biz-type="notice"
|
||||||
<Tabs class="tabs-panel" v-model="tab" @on-click="load">
|
:tabs="['todo', 'instances']"
|
||||||
<TabPane label="我的待审核" name="todo" />
|
@open-record="openRecord" @open-task="openTask" @handled="onHandled" />
|
||||||
<TabPane label="办件视角" name="instances" />
|
|
||||||
</Tabs>
|
|
||||||
|
|
||||||
<!-- 待审核列表 -->
|
<!-- 审批记录:token 只读页,地址由抽屉自己现取(不缓存、无审批按钮) -->
|
||||||
<div class="tab-body" v-if="tab === 'todo'">
|
<FlowRecordDrawer v-model="recordVisible" :api="approvalApiAdapter" :biz-id="recordBizId" />
|
||||||
<div class="table-region" ref="todoRegionRef">
|
|
||||||
<Table :columns="todoCols" :data="todoRows" :height="todoH || undefined" :loading="loading" no-data-text="暂无待审核通知">
|
|
||||||
<template #opinion="{ row }">
|
|
||||||
<span class="opinion">{{ row._opinion }}</span>
|
|
||||||
</template>
|
|
||||||
<template #action="{ row }">
|
|
||||||
<span class="operating-link" @click="openEmbed(row)">查看详情</span>
|
|
||||||
<span class="operating-link" @click="approve(row)">通过</span>
|
|
||||||
<span class="operating-link text-error" @click="openReject(row)">驳回</span>
|
|
||||||
</template>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
<div class="page-panel" v-if="todoPage.total > todoPage.pageSize">
|
|
||||||
<Page class="page-box fr" :current="todoPage.pageIndex" :page-size="todoPage.pageSize"
|
|
||||||
:total="todoPage.total" show-total @on-change="loadTodo" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 办件视角 -->
|
<!-- 完整办理:CAS 去壳任务页,地址由后端确认"taskId 属于当前人"后才签发 -->
|
||||||
<div class="tab-body" v-else>
|
<FlowTaskEmbed v-model="taskVisible" :page-url="taskPageUrl" :task-id="taskTaskId"
|
||||||
<div class="filter-bar">
|
@handled="onTaskHandled" @open-biz="onOpenBiz" />
|
||||||
<RadioGroup v-model="instStatus" type="button" @on-change="loadInstances(1)">
|
|
||||||
<Radio :label="0">全部</Radio>
|
|
||||||
<Radio :label="10">审批中</Radio>
|
|
||||||
<Radio :label="20">已完成</Radio>
|
|
||||||
<Radio :label="30">已拒绝</Radio>
|
|
||||||
<Radio :label="40">已撤回</Radio>
|
|
||||||
</RadioGroup>
|
|
||||||
</div>
|
|
||||||
<div class="table-region" ref="instRegionRef">
|
|
||||||
<Table :columns="instCols" :data="instRows" :height="instH || undefined" :loading="loading" no-data-text="暂无办件">
|
|
||||||
<template #status="{ row }">
|
|
||||||
<Tag :color="instStatusColor(row.status)">{{ instStatusText(row.status) }}</Tag>
|
|
||||||
</template>
|
|
||||||
<template #instAction="{ row }">
|
|
||||||
<Button size="small" @click="openEmbed(row)">审批记录</Button>
|
|
||||||
</template>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
<div class="page-panel" v-if="instPage.total > instPage.pageSize">
|
|
||||||
<Page class="page-box fr" :current="instPage.pageIndex" :page-size="instPage.pageSize"
|
|
||||||
:total="instPage.total" show-total @on-change="loadInstances" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- 驳回弹窗:意见必填 -->
|
|
||||||
<Modal
|
|
||||||
v-model="rejectVisible"
|
|
||||||
title="驳回审核"
|
|
||||||
:loading="acting"
|
|
||||||
@on-ok="doReject"
|
|
||||||
@on-cancel="rejectTaskId = null"
|
|
||||||
>
|
|
||||||
<p style="margin-bottom: 8px">
|
|
||||||
驳回「<b>{{ rejectTitle }}</b
|
|
||||||
>」,必须填写意见:
|
|
||||||
</p>
|
|
||||||
<Input v-model="rejectOpinion" type="textarea" :rows="3" placeholder="驳回意见(必填)" />
|
|
||||||
</Modal>
|
|
||||||
|
|
||||||
<!-- 审批记录 Drawer(flow-center 嵌入页 iframe 撑满:状态/基本信息/流程示意图/轨迹/审批流水/表单六块) -->
|
|
||||||
<Drawer v-model="embedVisible" title="审批记录" :width="740" :transfer="false" :styles="{ body: { padding: '0' } }">
|
|
||||||
<div class="embed-frame-wrap">
|
|
||||||
<iframe v-if="embedPageUrl" :src="embedPageUrl" title="办件进度"></iframe>
|
|
||||||
</div>
|
|
||||||
</Drawer>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from "vue";
|
|
||||||
import { Message } from "view-ui-plus";
|
|
||||||
import { useElementHeight } from "@/utils/hook/useElementHeight";
|
|
||||||
import approvalApi, {
|
|
||||||
type ApprovalInstance,
|
|
||||||
type ApprovalTodoTask,
|
|
||||||
} from "@/api/module/approval";
|
|
||||||
import { noticeApi } from "@/api/module/notice";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 我的待审核(应用内审批,第三步):待办列表 + 办件视角,通过/驳回走本应用后端代理
|
* 我的待审核(v2.1 重构):列表/动作/记录抽屉/任务页容器全部下沉到 flow-approval 共享组件,
|
||||||
* (operator=登录态,对端按任务办理人复核;非办理人会收到业务错提示)。
|
* 本页只剩两件事——注入 Notice 自己的 API 适配器,以及决定"记录/任务页"这两个浮层放在哪。
|
||||||
* bizSnapshot 是发起时透传的 JSON 摘要,解析失败只展示标题。
|
*
|
||||||
|
* 页签沿用「我的待审核 + 办件视角」(方案 D5:前置阶段不扩四页签,避免行为变化)。
|
||||||
|
* 身份仍然一个都不从前端传:适配器只送 serid/taskId/opinion/edgeName,
|
||||||
|
* operatorId/schId/name 由 NoticeService 从 CAS 会话取。
|
||||||
*/
|
*/
|
||||||
const tab = ref<"todo" | "instances">("todo");
|
import { ref } from "vue";
|
||||||
const loading = ref(false);
|
import { useRouter } from "vue-router";
|
||||||
const acting = ref(false);
|
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 router = useRouter();
|
||||||
const todoRegionRef = ref<HTMLElement | null>(null);
|
const listRef = ref<any>(null);
|
||||||
const instRegionRef = ref<HTMLElement | null>(null);
|
|
||||||
const todoH = useElementHeight(todoRegionRef);
|
|
||||||
const instH = useElementHeight(instRegionRef);
|
|
||||||
|
|
||||||
const todoRows = ref<(ApprovalTodoTask & { _summary: string })[]>([]);
|
const approvalApiAdapter = noticeFlowApprovalApi;
|
||||||
const todoPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
|
|
||||||
|
|
||||||
const instRows = ref<ApprovalInstance[]>([]);
|
/* ===== 审批记录抽屉 ===== */
|
||||||
const instPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
|
const recordVisible = ref(false);
|
||||||
const instStatus = ref(0);
|
const recordBizId = ref("");
|
||||||
|
|
||||||
const rejectVisible = ref(false);
|
function openRecord(bizId: string) {
|
||||||
const rejectTaskId = ref<number | null>(null);
|
recordBizId.value = String(bizId);
|
||||||
const rejectTitle = ref("");
|
recordVisible.value = true;
|
||||||
const rejectOpinion = ref("");
|
}
|
||||||
|
|
||||||
const todoCols = [
|
/* ===== 完整办理(去壳任务页) ===== */
|
||||||
{ title: "通知", key: "bizTitle", minWidth: 220, ellipsis: true, tooltip: true },
|
const taskVisible = ref(false);
|
||||||
{ title: "摘要", key: "_summary", minWidth: 200, ellipsis: true, tooltip: true },
|
const taskPageUrl = ref("");
|
||||||
{ title: "当前节点", key: "nodeName", width: 110 },
|
const taskTaskId = ref<number | string>("");
|
||||||
{ title: "到达时间", key: "arriveTime", width: 170, render: fmtTime },
|
|
||||||
{ title: "操作", slot: "action", width: 190, align: "center" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const instCols = [
|
async function openTask(payload: { bizId: string; taskId: number }) {
|
||||||
{ title: "通知", key: "bizTitle", minWidth: 240, ellipsis: true, tooltip: true },
|
taskPageUrl.value = "";
|
||||||
{ title: "状态", slot: "status", width: 100 },
|
|
||||||
{ title: "提交人", key: "submitterName", width: 100 },
|
|
||||||
{ title: "提交时间", key: "submitTime", width: 170, render: fmtTime },
|
|
||||||
{ title: "办结时间", key: "finishTime", width: 170, render: fmtTime },
|
|
||||||
{ title: "操作", slot: "instAction", width: 100 },
|
|
||||||
];
|
|
||||||
|
|
||||||
/* 审批记录 Drawer(嵌入页 iframe):pageUrl 含 10 分钟短时 token,每次打开现取,
|
|
||||||
* 不缓存、不落 storage(既有安全纪律);业务错(尚未进入流程/无权限 403)原文提示,不打断列表 */
|
|
||||||
const embedVisible = ref(false);
|
|
||||||
const embedPageUrl = ref("");
|
|
||||||
|
|
||||||
async function openEmbed(row: ApprovalInstance | ApprovalTodoTask) {
|
|
||||||
try {
|
try {
|
||||||
const res = await noticeApi.flowEmbed(Number(row.bizId)).send();
|
const res = await approvalApiAdapter.taskPageUrl(String(payload.bizId), payload.taskId);
|
||||||
embedPageUrl.value = res?.pageUrl || "";
|
const url = (res as any)?.pageUrl || "";
|
||||||
if (!embedPageUrl.value) {
|
if (!url) {
|
||||||
Message.error("审批记录地址获取失败");
|
Message.error((res as any)?.msg || "办理页地址获取失败");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
embedVisible.value = true;
|
taskTaskId.value = payload.taskId;
|
||||||
|
taskPageUrl.value = url;
|
||||||
|
taskVisible.value = true;
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
Message.error(e?.message || "审批记录打开失败");
|
Message.error(e?.msg || e?.message || "办理页打开失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtTime(h: any, p: any) {
|
/** 任务页里办完了:关弹窗 + 刷新列表(不靠轮询) */
|
||||||
return h(
|
function onTaskHandled() {
|
||||||
"span",
|
taskVisible.value = false;
|
||||||
{},
|
listRef.value?.load?.();
|
||||||
String(p.row[p.column.key] || "").replace("T", " ").slice(0, 19) || "—"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSummary(task: ApprovalTodoTask): string {
|
/** 任务页"查看业务详情"交回本页:列表页里即跳通知详情(同应用内路由,不用 window.open 白跳中心站) */
|
||||||
if (!task.bizSnapshot) return "—";
|
function onOpenBiz(payload: { bizId?: string | null; bizUrl?: string }) {
|
||||||
try {
|
taskVisible.value = false;
|
||||||
const snap = JSON.parse(task.bizSnapshot);
|
const serid = payload?.bizId;
|
||||||
return snap.summary || snap.title || "—";
|
if (serid) {
|
||||||
} catch {
|
router.push(`/list/detail?serid=${serid}`);
|
||||||
return "—";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadTodo(page = 1) {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await approvalApi.todo(page, todoPage.value.pageSize).send();
|
|
||||||
todoPage.value = res;
|
|
||||||
todoRows.value = (res.content || []).map((t) => ({ ...t, _summary: parseSummary(t) }));
|
|
||||||
} catch (e: any) {
|
|
||||||
Message.error(e?.message || "待审核列表加载失败");
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadInstances(page = 1) {
|
|
||||||
loading.value = true;
|
|
||||||
try {
|
|
||||||
const res = await approvalApi
|
|
||||||
.instances(instStatus.value || undefined, page, instPage.value.pageSize)
|
|
||||||
.send();
|
|
||||||
instPage.value = res;
|
|
||||||
instRows.value = res.content || [];
|
|
||||||
} catch (e: any) {
|
|
||||||
Message.error(e?.message || "办件列表加载失败");
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function load() {
|
|
||||||
if (tab.value === "todo") {
|
|
||||||
loadTodo(1);
|
|
||||||
} else {
|
|
||||||
loadInstances(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function approve(row: ApprovalTodoTask) {
|
|
||||||
if (acting.value) return; // 链接式操作无 :loading 反馈,用 acting 拦截连点重复提交
|
|
||||||
acting.value = true;
|
|
||||||
try {
|
|
||||||
await approvalApi.approve(row.taskId).send();
|
|
||||||
Message.success("已通过");
|
|
||||||
loadTodo(todoPage.value.pageIndex);
|
|
||||||
} catch (e: any) {
|
|
||||||
// 非办理人/已被办理等业务错,后端信封 msg 直接展示
|
|
||||||
Message.error(e?.message || "操作失败");
|
|
||||||
} finally {
|
|
||||||
acting.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function openReject(row: ApprovalTodoTask) {
|
|
||||||
rejectTaskId.value = row.taskId;
|
|
||||||
rejectTitle.value = row.bizTitle;
|
|
||||||
rejectOpinion.value = "";
|
|
||||||
rejectVisible.value = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function doReject() {
|
|
||||||
if (!rejectOpinion.value.trim()) {
|
|
||||||
Message.warning("驳回必须填写意见");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
acting.value = true;
|
if (payload?.bizUrl) window.open(payload.bizUrl, "_blank");
|
||||||
try {
|
|
||||||
await approvalApi.reject(rejectTaskId.value!, rejectOpinion.value.trim()).send();
|
|
||||||
Message.success("已驳回");
|
|
||||||
rejectVisible.value = false;
|
|
||||||
loadTodo(todoPage.value.pageIndex);
|
|
||||||
} catch (e: any) {
|
|
||||||
Message.error(e?.message || "操作失败");
|
|
||||||
} finally {
|
|
||||||
acting.value = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function instStatusText(s: number) {
|
function onHandled() {
|
||||||
return ({ 10: "审批中", 20: "已完成", 30: "已拒绝", 40: "已撤回" } as Record<number, string>)[s] || String(s);
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.filter-bar {
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
/* 审批记录 Drawer:iframe 撑满,嵌入页内部自己滚动 */
|
|
||||||
.embed-frame-wrap {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
.embed-frame-wrap iframe {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
border: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
+719
-703
File diff suppressed because it is too large
Load Diff
@@ -189,7 +189,9 @@ const columns = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "附件",
|
title: "附件",
|
||||||
width: 70,
|
/* 图标外包 Tooltip 后占 32+2*2=36px,cell 左右内边距各 18px:70px 列只剩 34px 内容宽,
|
||||||
|
溢出 2px 会让 cell 的 text-overflow:ellipsis 在图标后画出「...」,故留到 80 */
|
||||||
|
width: 80,
|
||||||
align: "center",
|
align: "center",
|
||||||
render: (h: any, p: any) =>
|
render: (h: any, p: any) =>
|
||||||
(p.row.upFile ?? p.row.UpFile)
|
(p.row.upFile ?? p.row.UpFile)
|
||||||
|
|||||||
@@ -552,7 +552,7 @@ const groupH = useElementHeight(groupRegionRef);
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 流程图 Drawer(后端 DiagramDef 同源 PNG;竖版长图容器内纵向滚动) -->
|
<!-- 流程图 Drawer(后端 DiagramDef 同源 PNG;竖版长图容器内纵向滚动) -->
|
||||||
<Drawer v-model="flowDiagramVisible" title="流程示意图" :width="860" :transfer="false"
|
<Drawer v-model="flowDiagramVisible" title="流程示意图" :width="740" :transfer="false"
|
||||||
:styles="{ body: { padding: '0' } }">
|
:styles="{ body: { padding: '0' } }">
|
||||||
<div class="flow-diagram-frame">
|
<div class="flow-diagram-frame">
|
||||||
<img ref="flowDiagramEl" v-if="flowDiagramSrc" :src="flowDiagramSrc" alt="流程示意图" @load="fitDiagram(flowDiagramEl)" @error="onFlowDiagramError" />
|
<img ref="flowDiagramEl" v-if="flowDiagramSrc" :src="flowDiagramSrc" alt="流程示意图" @load="fitDiagram(flowDiagramEl)" @error="onFlowDiagramError" />
|
||||||
|
|||||||
Reference in New Issue
Block a user