feat(p3.3): 应用内审批前端——flow-approval 组件族本地化 + /todo 我的审批页 + 详情审批卡片
build / build (push) Successful in 2m12s

- 从 Notice/front 复制 flow-approval 六件并本地修复(漂移登记组件 README,Notice 不回改):
  · P0-04:四个写调用点(List 通过/驳回 + Card 通过/驳回)全部实际携带本行 bizId,
    选中 bizId 随弹窗生命周期锁定/清理
  · P0-05:删 useElementHeight/yh-* 平台布局依赖,本地 fa-* 样式(theme-base.css --sh-* 令牌)
  · P0-06:types.ts 本地契约——增 my(行=ApplyVo 子集/FlowPage 完整元数据/状态 0=待提交
    禁止当全部,全部用 -1 哨兵)、删 instances(不代理全校视角)、approve/reject 增 bizId 参
  · P1-05:卡片轨迹不自取(详情页把 /progress 转换 TraceVo 经 props 传入);节点摘要取
    active steps 节点名;MULTI_TASK 逐条列出本人待办各带动作,不默认 tasks[0];
    未进流程(flowInstanceId 空)详情页不渲染卡片(显式决策)
  · P1-09:FlowTaskEmbed 消息四条件(打开中+严格 origin+本 iframe+taskId/bizId 上下文一致),
    换址重置高度;宿主换址请求带代际号,关闭/换件废弃在途响应并清上下文;
    open-biz 只用与当前办件一致的 bizId 构造 /apply-detail?id=,不透传任意 URL
- ApprovalApi 类挂 svc.ts(todo/my/byBiz/approve/reject/recordUrl/taskPageUrl,写面
  taskId/applyId 查询参数+opinion/edgeName JSON body 两形态区分)+ approvalAdapter.ts 唯一映射
- /todo 路由 + 顶部导航第三链接「我的审批」+ MyApprovals.vue(双页签+记录抽屉+完整办理)
- ApplyDetail 挂审批卡片(审批操作区)+ TraceVo 转换器
- 小屏基础响应式(drawer/modal 宽度自适应、列表横向滚动;1200px 全量适配不在本批)
- 门禁:vue-tsc(ts4.9 外挂兼容组合)错误数 HEAD=工作区=18 零新增(仓内 vue-tsc1.8+ts5.9
  崩溃系历史遗留);vite build 绿(MyApprovals/approvalAdapter chunk 产出);
  preview 冒烟:/todo 渲染完整(导航/页签/表格空态/API 失败优雅降级)、详情页无崩溃(Stage S4/P3.3)
This commit is contained in:
chenxing
2026-10-19 23:59:48 +08:00
parent f5b39b96c6
commit 351a513547
12 changed files with 1567 additions and 1 deletions
+1
View File
@@ -82,6 +82,7 @@ async function applySiteThemeRecommend() {
<nav class="sh-nav">
<router-link to="/hall">办事大厅</router-link>
<router-link to="/my">我的办件</router-link>
<router-link to="/todo">我的审批</router-link>
<a v-if="portalFrom" :href="portalFrom" class="sh-back-portal">返回门户</a>
</nav>
<span class="sh-user" v-if="userName">{{ userName }}</span>
+28
View File
@@ -0,0 +1,28 @@
import type { FlowApprovalApi } from "@/components/flow-approval/types";
import { approvalApi, applyApi } from "@/api/module/svc";
/**
* svc-hall 应用内审批适配器(唯一映射位置;沿 Notice approvalAdapter 形态)。
*
* 纪律:
* - 身份三字段(operatorId/operatorName/schId)不出现——后端从 CAS 会话取;
* - 写面 bizId=applyId 是必需上下文(后端写面双闸),组件契约层已强制携带;
* - trace 复用既有 /api/apply/{id}/progress(卡片不自取轨迹,宿主转换后经 props 传入);
* - alova Method 是 thenable,组件 await 即发。
*/
export const svcFlowApprovalApi: FlowApprovalApi = {
todo: (p) => approvalApi.todo(p?.page ?? 1, p?.size ?? 10).send(),
my: (p) =>
approvalApi
.my(p?.page ?? 1, p?.size ?? 10, p?.flowStatus ?? null)
.send(),
byBiz: (bizId) => approvalApi.byBiz(Number(bizId)).send(),
trace: (bizId) => applyApi.progress(Number(bizId)).send(),
approve: (taskId, opinion, edgeName, bizId) =>
approvalApi.approve(taskId, Number(bizId), opinion, edgeName).send(),
reject: (taskId, opinion, bizId) => approvalApi.reject(taskId, Number(bizId), opinion).send(),
recordUrl: (bizId) => approvalApi.recordUrl(Number(bizId)).send(),
taskPageUrl: (bizId, taskId) => approvalApi.taskPageUrl(Number(bizId), taskId).send(),
};
export default svcFlowApprovalApi;
+36
View File
@@ -22,6 +22,7 @@ import type {
const HALL = "/api/hall";
const APPLY = "/api/apply";
const FILE = "/api/file";
const APPROVAL = "/api/apply/approval";
function get<T = any>(url: string, params?: Record<string, any>): Method<T> {
return new Method<T>("GET", alovaInstance, url, { params });
@@ -125,6 +126,41 @@ class FileApi {
}
}
class ApprovalApi {
/** 应用内审批代理(P3.1,/api/apply/approval/**;身份全部由后端从 CAS 会话取,前端不传) */
/** 待我审批(中心 todo;双值双查与合并都在后端) */
todo(page?: number, size?: number) {
return get(`${APPROVAL}/Todo`, { page, size });
}
/** 我提交的(本地办件;后端做 {total,list}→FlowPage 信封映射;flowStatus 空=全部) */
my(page?: number, size?: number, flowStatus?: number | null) {
return get(`${APPROVAL}/My`, { page, size, flowStatus: flowStatus ?? undefined });
}
/** 当前人在该办件上的待办(0/1/N 均 200;卡片/列表复核同源) */
byBiz(applyId: number) {
return get(`${APPROVAL}/ByBiz`, { applyId });
}
/** 通过:taskId/applyId 走查询参数(与中心端点同构),opinion/edgeName 走 JSON body */
approve(taskId: number, applyId: number, opinion?: string, edgeName?: string) {
return postJSON(`${APPROVAL}/Approve?taskId=${encodeURIComponent(taskId)}&applyId=${encodeURIComponent(applyId)}`,
{ opinion: opinion || undefined, edgeName: edgeName || undefined });
}
/** 拒绝:opinion 必填(后端前置校验) */
reject(taskId: number, applyId: number, opinion: string) {
return postJSON(`${APPROVAL}/Reject?taskId=${encodeURIComponent(taskId)}&applyId=${encodeURIComponent(applyId)}`,
{ opinion });
}
/** 审批记录抽屉地址(token 只读页,instanceId 形态换址) */
recordUrl(applyId: number) {
return get(`${APPROVAL}/RecordUrl`, { applyId });
}
/** 完整办理任务页地址(先 byBiz 归属预检再签发,在后端) */
taskPageUrl(applyId: number, taskId: number) {
return get(`${APPROVAL}/TaskPageUrl`, { applyId, taskId });
}
}
export const hallApi = new HallApi();
export const applyApi = new ApplyApi();
export const fileApi = new FileApi();
export const approvalApi = new ApprovalApi();
@@ -0,0 +1,336 @@
<template>
<div class="fa-card-region">
<!-- 不可达:本地状态照常展示,动作明确提示稍后重试(绝不伪装成功,也不静默变只读) -->
<Alert v-if="unavailable" type="warning" show-icon class="fa-alert">
流程中心暂时不可达,审批动作稍后重试。
<a class="fa-link" @click="load">重新加载</a>
</Alert>
<!-- 首次加载:空态与未加载是两回事,误报会误导审批人 -->
<div v-else-if="loading" class="fa-muted">审批状态加载中…</div>
<div v-else class="fa-body">
<div class="fa-head">
<Tag :color="statusTag">{{ statusText }}</Tag>
<span v-if="currentNodeText" class="fa-nodes">当前节点:{{ currentNodeText }}</span>
<span v-if="lastTraceText" class="fa-trace">最近轨迹:{{ lastTraceText }}</span>
</div>
<!-- ONE_TASK:当前人可办理(readonly 视角即使有待办也不给动作) -->
<div v-if="state === 'ONE_TASK' && !readonly" class="fa-actions">
<Button size="small" type="primary" :loading="acting" @click="openApprove(tasks[0])">通过</Button>
<Button size="small" :loading="acting" @click="openReject(tasks[0])">驳回</Button>
<Button size="small" @click="emitTask(tasks[0])">完整办理</Button>
</div>
<!-- MULTI_TASK:不默认首条——逐条列出本人的待办,每条自带动作(P1-05 漂移修正) -->
<div v-else-if="state === 'MULTI_TASK' && !readonly" class="fa-multi">
<p>你在本办件上有 <b>{{ tasks.length }}</b> 个待办(会签/并行分支),请逐条处理:</p>
<div class="fa-multi-row" v-for="t in tasks" :key="t.taskId">
<span class="fa-multi-node">{{ t.nodeName }}<template v-if="t.arriveTime">({{ t.arriveTime }})</template></span>
<Button size="small" type="primary" :loading="acting" @click="openApprove(t)">通过</Button>
<Button size="small" :loading="acting" @click="openReject(t)">驳回</Button>
<Button size="small" @click="emitTask(t)">完整办理</Button>
</div>
</div>
<div v-else-if="state === 'MULTI_TASK'" class="fa-multi">
<span>本办件有多个待办。</span>
</div>
<!-- NO_TASK / TERMINAL:无动作区,只保留下方的"查看审批记录" -->
<div class="fa-links">
<a class="fa-link" @click="$emit('open-record', bizId)">查看审批记录</a>
</div>
</div>
<!-- 通过:意见可选;多出口必须先选出口,不选不给提交 -->
<Modal v-model="approveVisible" title="通过审核" :loading="acting" @on-ok="doApprove" @on-cancel="reset">
<div class="fa-modal-row" v-if="chosenOutlets.length > 1">
<span class="fa-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="fa-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="fa-modal-tip">驳回「<b>{{ title || bizId }}</b>」,必须填写意见:</p>
<Input v-model="opinion" type="textarea" :rows="3" placeholder="驳回意见(必填)" />
</Modal>
</div>
</template>
<script setup lang="ts">
/**
* 办件详情审批卡片(hall 本地版,源自 Notice FlowApprovalCard;漂移登记见本目录 README)。
* 数据来源只有一个:`api.byBiz(bizId)`——与列表共用同一 taskId 口径。
* 轨迹不自取(P1-05):宿主把既有 /api/apply/{id}/progress 转换成 TraceVo 经 props 传入;
* 卡片只用它做"最近一步"摘要与节点名兜底。
*
* 卡片只管**当前人**的动作:
* - 非待办人 / readonly / 终态 → 只读状态 + 查看记录;
* - tasks>1(会签/并行)→ 逐条列出本人待办、各自带动作,**不默认 tasks[0]**(P1-05);
* - outlets>1 → 通过弹窗必须选出口,未选不提交;
* - 写调用一律携带 bizId(P0-04:后端写面双闸需要 applyId);
* - 动作成功后只刷新自己,本地 flow_status 由回调推进,卡片不本地写状态。
*/
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;
/** 外部已有进度时可传入(进度接口转换后的 TraceVo),卡片不重复拉轨迹 */
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", payload: { taskId: number; bizId: string }): 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";
});
/**
* 人读当前节点:本人待办的节点名去重拼接(多待办并行时都显示,不挑首条);
* 无待办时退回 trace 的活动节点名摘要——byBiz 的 currentNodes 是节点 id 不得当名称(P1-05)。
*/
const currentNodeText = computed(() => {
if (tasks.value.length) {
const names = Array.from(new Set(tasks.value.map((t) => t.nodeName).filter(Boolean)));
if (names.length) return names.join("、");
}
return props.trace?.nodeSummary || props.trace?.currentNodes || "";
});
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(task: ByBizTask) {
targetTask.value = task;
opinion.value = "";
chosenEdge.value = "";
approveVisible.value = true;
}
function openReject(task: ByBizTask) {
targetTask.value = task;
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 {
// bizId 必带(P0-04):svc-hall 写面双闸先校办件可见再校任务归属
await props.api.approve(task.taskId, opinion.value.trim() || undefined,
task.outlets.length > 1 ? chosenEdge.value : undefined, props.bizId);
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(), props.bizId);
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(task: ByBizTask) {
if (!task) return;
emit("open-task", { taskId: task.taskId, bizId: props.bizId });
}
function reset() {
opinion.value = "";
chosenEdge.value = "";
targetTask.value = null;
}
watch(() => props.bizId, load);
onMounted(load);
defineExpose({ load, state });
</script>
<style scoped>
.fa-card-region {
margin-top: 12px;
}
.fa-alert {
margin-bottom: 8px;
}
.fa-muted {
padding: 8px 0;
color: var(--sh-text-weak, #6b7280);
}
.fa-head {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 10px;
}
.fa-nodes,
.fa-trace {
font-size: 13px;
color: var(--sh-text-weak, #6b7280);
}
.fa-actions {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.fa-multi p {
margin: 0 0 8px;
font-size: 13px;
}
.fa-multi-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
padding: 6px 0;
border-bottom: 1px dashed var(--sh-border, #e6e9ef);
}
.fa-multi-node {
font-size: 13px;
min-width: 140px;
}
.fa-links {
margin-top: 10px;
}
.fa-link {
color: var(--sh-primary, #1a5cb0);
cursor: pointer;
font-size: 13px;
}
.fa-link:hover {
opacity: 0.8;
}
.fa-modal-row {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.fa-modal-label {
width: 90px;
font-size: 13px;
flex-shrink: 0;
}
.fa-modal-tip {
margin-bottom: 8px;
}
</style>
@@ -0,0 +1,412 @@
<template>
<div class="fa-fill">
<div class="fa-card">
<Tabs :model-value="innerTab" @on-click="switchTab">
<TabPane v-for="t in tabs" :key="t" :label="tabLabel(t)" :name="t" />
</Tabs>
<!-- 待我审批(中心 todo) -->
<div class="fa-tab-body" v-if="innerTab === 'todo'">
<div class="fa-table-region">
<Table :columns="todoCols" :data="todoRows" :loading="loading" no-data-text="暂无待审批办件">
<template #action="{ row }">
<a class="fa-link" @click="openRecord(row.bizId)">审批记录</a>
<a class="fa-link" @click="openApprove(row)">通过</a>
<a class="fa-link fa-danger" @click="openReject(row)">驳回</a>
<a class="fa-link" @click="openTask(row)">完整办理</a>
</template>
</Table>
</div>
<div class="fa-page" v-if="todoPage.total > todoPage.pageSize">
<Page :current="todoPage.pageIndex" :page-size="todoPage.pageSize"
:total="todoPage.total" show-total @on-change="loadTodo" />
</div>
</div>
<!-- 我提交的(本地办件) -->
<div class="fa-tab-body" v-else>
<div class="fa-filter">
<RadioGroup :model-value="myStatus" type="button" @on-change="onStatusChange">
<Radio :label="-1">全部</Radio>
<Radio :label="0">待提交</Radio>
<Radio :label="10">审批中</Radio>
<Radio :label="20">已完成</Radio>
<Radio :label="30">已拒绝</Radio>
<Radio :label="40">已撤回</Radio>
</RadioGroup>
</div>
<div class="fa-table-region">
<Table :columns="myCols" :data="myRows" :loading="loading" no-data-text="暂无办件">
<template #status="{ row }">
<Tag :color="statusTag(row.flowStatus)">{{ statusText(row.flowStatus) }}</Tag>
</template>
<template #myAction="{ row }">
<a class="fa-link" @click="emitOpenDetail(row.id)">查看详情</a>
</template>
</Table>
</div>
<div class="fa-page" v-if="myPage.total > myPage.pageSize">
<Page :current="myPage.pageIndex" :page-size="myPage.pageSize"
:total="myPage.total" show-total @on-change="loadMy" />
</div>
</div>
</div>
<!-- 通过:意见可选;多出口必须先选出口 -->
<Modal v-model="approveVisible" title="通过审核" :loading="acting" @on-ok="doApprove" @on-cancel="reset">
<div class="fa-modal-row" v-if="chosenOutlets.length > 1">
<span class="fa-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="fa-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="fa-modal-tip">驳回「<b>{{ targetTitle }}</b>」,必须填写意见:</p>
<Input v-model="opinion" type="textarea" :rows="3" placeholder="驳回意见(必填)" />
</Modal>
</div>
</template>
<script setup lang="ts">
/**
* 独立审批列表(hall 本地版,源自 Notice FlowApprovalList;漂移登记见本目录 README)。
* 页签:"待我审批"(中心 todo,经 /api/apply/approval/Todo)+ "我提交的"(本地 My)。
* hall 无 Notice 的平台布局类(yh-fill/yh-tabs-card/table-region/useElementHeight),
* 本文件用自有 fa-* 样式(theme-base.css --sh-* 令牌)与表格自然高度替代。
*
* 硬纪律(沿 Notice 原版 + P0-04 加严):
* 1. 动作只认 `taskId` + `bizId`(行数据里带)——svc-hall 后端写面双闸需要 applyId,
* 四个写调用点(通过/驳回×列表卡片)都必须实际携带本行 bizId,不传靠后端拒不算通过;
* 2. 列表 todo 出参没有 outlets,点"通过"先现拉 byBiz 复核归属与出口(顺带拦截陈旧行);
* 3. 多出口未选不得提交;驳回意见必填;
* 4. 选中 bizId/taskId 的生命周期:打开弹窗时锁定,取消/提交成功/失败后清理(P0-04)。
*/
import { computed, onMounted, ref } from "vue";
import { Message } from "view-ui-plus";
import type { ByBizTask, FlowApprovalApi, FlowPage, MyApplyRow, TodoTask } from "./types";
import { INSTANCE_STATUS_TAG, INSTANCE_STATUS_TEXT } from "./types";
const props = withDefaults(
defineProps<{
api: FlowApprovalApi;
tabs?: Array<"todo" | "my">;
}>(),
{ tabs: () => ["todo", "my"] }
);
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;
/** "我提交的"行查看详情 → 宿主跳 /apply-detail?id=(只用本行 id 构造,不透传任意 URL) */
(e: "open-detail", applyId: number): void;
}>();
const innerTab = ref<"todo" | "my">(props.tabs[0] || "todo");
const loading = ref(false);
const acting = ref(false);
const todoRows = ref<Array<TodoTask & { _summary: string }>>([]);
const todoPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
const myRows = ref<MyApplyRow[]>([]);
const myPage = ref({ total: 0, pageIndex: 1, pageSize: 10 });
/** -1=全部(本地哨兵;0 在 hall 语义里是"待提交",禁止当"全部"用) */
const myStatus = ref(-1);
const approveVisible = ref(false);
const rejectVisible = ref(false);
const opinion = ref("");
const chosenEdge = ref("");
const targetTask = ref<ByBizTask | null>(null);
const targetTitle = ref("");
/** 当前动作绑定的办件行 ID(P0-04:写调用必须携带,随弹窗生命周期锁定/清理) */
const targetBizId = ref("");
const chosenOutlets = computed(() => targetTask.value?.outlets || []);
const TAB_LABELS: Record<string, string> = { todo: "待我审批", my: "我提交的" };
function tabLabel(t: string) {
return TAB_LABELS[t] || t;
}
const todoCols = [
{ title: "办件", key: "bizTitle", minWidth: 220, ellipsis: true, tooltip: true },
{ title: "摘要", key: "_summary", minWidth: 160, ellipsis: true, tooltip: true },
{ title: "当前节点", key: "nodeName", width: 120 },
{ title: "到达时间", key: "arriveTime", width: 170, render: fmtTime },
{ title: "操作", slot: "action", width: 240, align: "center" },
];
const myCols = [
{ title: "事项", key: "itemTitle", minWidth: 220, ellipsis: true, tooltip: true },
{ title: "状态", slot: "status", minWidth: 100 },
{ title: "提交时间", key: "submitTime", minWidth: 170, render: fmtTime },
{ title: "办结时间", key: "finishTime", minWidth: 170, render: fmtTime },
{ title: "操作", slot: "myAction", width: 120, 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" | "my";
load();
}
function onStatusChange(val: any) {
myStatus.value = Number(val);
loadMy(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 ?? 0,
pageIndex: res.pageIndex ?? page,
pageSize: res.pageSize ?? todoPage.value.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 loadMy(page = 1) {
if (!props.api.my) {
myRows.value = [];
return;
}
loading.value = true;
try {
const res: FlowPage<MyApplyRow> = await props.api.my({
page,
size: myPage.value.pageSize,
flowStatus: myStatus.value < 0 ? null : myStatus.value,
});
myPage.value = {
total: res.total ?? 0,
pageIndex: res.pageIndex ?? page,
pageSize: res.pageSize ?? myPage.value.pageSize,
};
myRows.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 loadMy(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;
targetBizId.value = row.bizId;
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;
targetBizId.value = row.bizId;
opinion.value = "";
rejectVisible.value = true;
}
function openTask(row: TodoTask) {
emit("open-task", { bizId: row.bizId, taskId: row.taskId });
}
function openRecord(bizId: string) {
emit("open-record", bizId);
}
function emitOpenDetail(applyId: number) {
emit("open-detail", applyId);
}
async function doApprove() {
const task = targetTask.value;
if (!task || !targetBizId.value) 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, targetBizId.value);
Message.success("已通过");
approveVisible.value = false;
emit("handled", { taskId: task.taskId, action: "approve" });
reset();
loadTodo(todoPage.value.pageIndex);
} catch (e: any) {
Message.error(e?.msg || e?.message || "操作失败");
approveVisible.value = false;
reset();
loadTodo(todoPage.value.pageIndex);
} finally {
acting.value = false;
}
}
async function doReject() {
const task = targetTask.value;
if (!task || !targetBizId.value) return;
if (!opinion.value.trim()) {
Message.warning("驳回必须填写意见");
acting.value = false;
return;
}
acting.value = true;
try {
await props.api.reject(task.taskId, opinion.value.trim(), targetBizId.value);
Message.success("已驳回");
rejectVisible.value = false;
emit("handled", { taskId: task.taskId, action: "reject" });
reset();
loadTodo(todoPage.value.pageIndex);
} catch (e: any) {
Message.error(e?.msg || e?.message || "操作失败");
rejectVisible.value = false;
} finally {
acting.value = false;
}
}
/** 弹窗取消/关闭即清理锁定上下文(P0-04:bizId 不残留到下一次动作) */
function reset() {
opinion.value = "";
chosenEdge.value = "";
targetTask.value = null;
targetTitle.value = "";
targetBizId.value = "";
}
onMounted(load);
defineExpose({ load, loadTodo, loadMy });
</script>
<style scoped>
.fa-fill {
display: flex;
flex-direction: column;
gap: 12px;
}
.fa-card {
background: #fff;
border: 1px solid var(--sh-border, #e6e9ef);
border-radius: var(--sh-radius, 8px);
padding: 16px;
}
.fa-tab-body {
margin-top: 4px;
}
.fa-table-region {
overflow-x: auto;
}
.fa-filter {
margin-bottom: 12px;
}
.fa-page {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
.fa-link {
color: var(--sh-primary, #1a5cb0);
cursor: pointer;
margin-right: 10px;
font-size: 13px;
}
.fa-link:hover {
opacity: 0.8;
}
.fa-danger {
color: var(--sh-danger, #d03050);
}
.fa-modal-row {
display: flex;
align-items: center;
margin-bottom: 10px;
}
.fa-modal-label {
width: 90px;
font-size: 13px;
flex-shrink: 0;
}
.fa-modal-tip {
margin-bottom: 8px;
}
@media (max-width: 768px) {
.fa-card {
padding: 10px;
}
.fa-link {
margin-right: 8px;
}
}
</style>
@@ -0,0 +1,130 @@
<template>
<Drawer :model-value="modelValue" :title="title" :width="drawerWidth" :transfer="false"
:styles="{ body: { padding: '0' } }" @update:model-value="close">
<div class="fa-embed-region">
<div v-if="error" class="fa-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="fa-embed-frame" />
<div v-else class="fa-embed-loading">{{ loading ? "加载中…" : "" }}</div>
</div>
</Drawer>
</template>
<script setup lang="ts">
/**
* 审批记录抽屉(hall 本地版,源自 Notice FlowRecordDrawer;漂移登记见本目录 README)。
* iframe 嵌 flow-center 的 **token 只读页**(instanceId 形态换址,后端代理)。
*
* 三条纪律写死在组件里,不给调用方犯错的机会:
* 1. pageUrl **每次打开现取**(token 10 分钟短时效),关闭/换件即清空——不缓存、不进 storage;
* 2. 只读:组件内不出现通过/驳回,也不接收这类回调;
* 3. 换址失败(未进入流程 / 无权限 / 过期)原文提示 + 重试,不白屏。
* P1-09 加严:换办件(bizId 变化)时立即废弃旧地址与在途请求结果——晚到响应只写当前 bizId。
*/
import { computed, 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;
}>();
/** 小屏自适应(基础响应式:1200px 全量适配不在本批) */
const drawerWidth = computed(() => (typeof window !== "undefined" && window.innerWidth < 768
? Math.min(props.width, window.innerWidth - 24)
: props.width));
const pageUrl = ref("");
const loading = ref(false);
const error = ref("");
async function load() {
const bizId = String(props.bizId);
loading.value = true;
error.value = "";
pageUrl.value = "";
try {
const res: any = await props.api.recordUrl(bizId);
// 晚到防护:请求期间已换件/已关闭,丢弃本次结果(P1-09)
if (bizId !== String(props.bizId) || !props.modelValue) return;
const url = res?.pageUrl || "";
if (!url) {
error.value = "审批记录地址获取失败,请稍后重试";
return;
}
pageUrl.value = url;
} catch (e: any) {
if (bizId !== String(props.bizId)) return;
// 后端信封 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 = "";
}
}
);
// 换件即清(抽屉开着连续换办件的场景)
watch(
() => props.bizId,
() => {
pageUrl.value = "";
error.value = "";
if (props.modelValue) load();
}
);
function close(v: boolean) {
emit("update:modelValue", v);
}
</script>
<style scoped>
.fa-embed-region {
height: 100%;
display: flex;
flex-direction: column;
}
.fa-embed-frame {
display: block;
width: 100%;
height: 100%;
border: 0;
}
.fa-embed-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
}
.fa-embed-error {
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
align-items: flex-start;
}
</style>
@@ -0,0 +1,167 @@
<template>
<Modal :model-value="modelValue" :title="title" :width="modalWidth" footer-hide transfer
@update:model-value="close">
<div class="fa-task-wrap">
<!-- CAS 会话页被 X-Frame-Options/CSP 挡住时 iframe 只会留白:给出显式逃生口 -->
<div class="fa-task-fallback">
<span class="fa-task-tip">打不开办理面板?</span>
<a class="fa-link" :href="pageUrl" target="_blank" rel="noopener">在新窗口打开任务页</a>
</div>
<iframe v-if="embedSrc" ref="frameEl" :src="embedSrc" :title="title" class="fa-task-frame"
:style="frameStyle" />
</div>
</Modal>
</template>
<script setup lang="ts">
/**
* 去壳任务办理页容器(hall 本地版,源自 Notice FlowTaskEmbed;漂移登记见本目录 README)。
* iframe 嵌 flow-center `/task/{taskId}?embed=1`(CAS 写面,办理权由任务页按 assigneeId 复核)。
*
* P1-09 加严——iframe 消息四条件全过才处理:
* ① 组件处于打开态(modelValue=true);② 期望 origin 算自 pageUrl 且严格相等(非通配);
* ③ 消息来源窗口必须是本 iframe(ev.source === contentWindow);④ 消息携带的 taskId(若有)
* 与当前任务上下文一致(换任务/换件期间的旧消息作废)。
* 关闭/换址时:重置高度、清理上下文——宿主侧同时负责废弃在途换址请求与清空 taskUrl/选中 ID。
*/
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { isFlowEmbedMessage } from "./types";
const props = withDefaults(
defineProps<{
modelValue: boolean;
pageUrl: string;
taskId?: number | string;
/** 当前办件上下文(与消息 bizId 比对,换件期间旧消息作废) */
bizId?: string | number;
title?: string;
width?: number;
}>(),
{ title: "完整办理", width: 860, taskId: "", bizId: "" }
);
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);
/** 小屏自适应(基础响应式) */
const modalWidth = computed(() => (typeof window !== "undefined" && window.innerWidth < 768
? Math.min(props.width, window.innerWidth - 24)
: props.width));
/**
* 后端 TaskPageUrl 给的是**完整窗口形态**的 `#/task/{id}`("新窗口打开"场景要用它)。
* 内嵌时"去壳"是嵌入方的诉求,由本组件补 `embed=1`——hash 路由下 query 必须落在 `#` 之后。
*/
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 (!props.modelValue) return;
// 条件②:严格 origin
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") {
const msgTaskId = String(ev.data.taskId ?? "");
// 条件④:任务上下文一致(消息带 taskId 且与当前不同=旧任务迟到消息,作废)
if (msgTaskId && props.taskId !== "" && msgTaskId !== String(props.taskId)) return;
emit("handled", { taskId: msgTaskId || String(props.taskId), action: ev.data.action });
} else if (ev.data.type === "open-biz") {
// 条件④(办件维度):消息带 bizId 且与当前办件上下文不同=换件期间旧消息,作废
const msgBizId = ev.data.bizId != null ? String(ev.data.bizId) : "";
if (msgBizId && props.bizId !== "" && msgBizId !== String(props.bizId)) return;
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();
}
);
// 打开态下换址/换任务:重置自适应高度(iframe 会整体换 src)
watch([() => props.pageUrl, () => props.taskId], () => {
frameHeight.value = 0;
});
/** 父→子可选指令:任务页目前不消费 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>
.fa-task-wrap {
position: relative;
}
.fa-task-fallback {
display: flex;
gap: 6px;
align-items: center;
justify-content: flex-end;
margin-bottom: 6px;
}
.fa-task-tip {
font-size: 12px;
color: var(--sh-text-weak, #6b7280);
}
.fa-link {
color: var(--sh-primary, #1a5cb0);
}
.fa-task-frame {
display: block;
width: 100%;
min-height: 320px;
height: 70vh;
border: 0;
}
</style>
+39
View File
@@ -0,0 +1,39 @@
# flow-approval 组件(hall 本地版,v2.1 应用内审批)
源自 `Notice/front/src/components/flow-approval/`(组件版本 v2.1-Notice1,2026-10-19 复制)。
组件不直连 flow-center,一律通过注入的 `api`(`FlowApprovalApi`)打本应用后端代理
`/api/apply/approval/**`(P3.1,`SvcApprovalController`),由后端持 app 签名调 `/oapi/flow/**`。
## 与 Notice 原版的漂移登记(本地扩展,Notice 侧不回改)
| # | 漂移点 | 原因 |
|---|---|---|
| 1 | `types.ts`:`approve/reject` 增末参 `bizId`(四个写调用点全部实际携带) | P0-04:svc-hall 后端写面双闸(requireVisible + byBiz 归属预检)需要 applyId,bizId 是必需上下文非可选兜底 |
| 2 | `types.ts`:删 `instances` 契约、增 `my`(行=`MyApplyRow`,本地 ApplyVo 子集) | P0-06:不代理 /oapi/flow/instances(全校视角);"我提交的"走本地 `/api/apply/approval/My`(信封 FlowPage 映射在后端) |
| 3 | `types.ts`:状态 0=**待提交**(Notice 原版把 0 当"全部"——hall 禁用该口径,"全部"用 -1 哨兵) | P0-06:hall 本地 flow_status 0 是真实状态 |
| 4 | `FlowApprovalList.vue`:页签 `todo`/`my`;无 `useElementHeight`/`yh-*` 平台布局类,改为本地 `fa-*` 样式(theme-base.css `--sh-*` 令牌)+ 表格自然高度 | P0-05:hall 无平台中央静态通道与该 hook,按 hall 实情落地 |
| 5 | `FlowApprovalCard.vue`:MULTI_TASK 逐条列出本人待办、每条自带动作,**不默认 tasks[0]**;`emitTask` 带本卡片 bizId | P1-05:Notice 原版实际挑 tasks[0],与"多待办不猜"文档口径不符,本地修正 |
| 6 | `FlowApprovalCard.vue`:轨迹不自取——宿主把 `/api/apply/{id}/progress` 转换成 `TraceVo` 经 props 传入;当前节点名取 tasks[].nodeName 去重(currentNodes 是节点 id 不得当名称) | P1-05 |
| 7 | `FlowTaskEmbed.vue`:消息受理四条件(打开中+严格 origin+本 iframe+taskId/bizId 上下文一致);换址/换任务重置高度 | P1-09:关闭/换件期间旧消息与迟到换址响应不得误刷新/误开件 |
| 8 | `FlowRecordDrawer.vue`:换 bizId 立即清地址并重取;晚到响应丢弃 | P1-09 同源 |
| 9 | 小屏基础自适应(drawer/modal 宽度收窄、列表横向滚动) | 用户裁决:移动端只做新页面基础响应式,1200px 全量适配不在本批 |
## 必须遵守的契约(沿 Notice 原版)
1. **身份不从前端来**。`api` 的入参只允许 `bizId / taskId / opinion / edgeName / 分页 / flowStatus`。
2. **token 记录页只读**。pageUrl 现取现用、关闭即丢,不缓存不进 storage。
3. **多待办不猜**(见漂移 5)。
4. **多出口必选**;5. **驳回意见必填、通过意见可选**;6. **列表"通过"前现拉 byBiz 复核**;
7. **currentNodes 是节点 id**;8. **错误原文透传**;9. **postMessage 双向校验 origin,禁 `*`**。
## open-biz 的落地纪律(hall 宿主侧)
`open-biz` 消息的 `bizUrl` 是 flow-center 记录的业务地址,宿主**只用消息里的 `bizId`
(且必须与当前办件上下文一致)构造 `/apply-detail?id=<bizId>`**——不照抄任意 URL 直接路由
(P1-09:防伪造跳转;bizUrl 仅作展示/日志参考,不当路由依据)。
## 同步纪律
改本目录任一文件必须评估 Notice 原版同步(平台"四端同构"纪律);漂移表更新后两侧 README
「组件版本 / 最后同步日期」一起动。Notice 侧地址:`Notice/front/src/components/flow-approval/`。
组件版本:**v2.1-hall1** | 最后同步:2026-10-19。
+167
View File
@@ -0,0 +1,167 @@
/**
* v2.1 应用内审批共享组件的契约类型(hall 本地版,源自 Notice/front 同名文件)。
*
* 本文件刻意**不 import 本应用任何业务类型**:组件靠 `FlowApprovalApi` 注入数据面。
* 与 Notice 原版的漂移(hall 本地扩展,登记于本目录 README):
* - `approve/reject` 增末参 `bizId`(svc-hall 后端写面要 applyId 做归属预检,必需上下文非可选兜底);
* - 增 `my`("我提交的"页签走本地 /api/apply/approval/My,行=ApplyVo 子集,非中心 instances);
* - `instances` 契约删除(hall 不代理 /oapi/flow/instances——全校视角不暴露给用户面)。
*/
/** 分页信封(svc-hall 代理层统一映射后的 FlowPage 形态) */
export interface FlowPage<T> {
total: number;
content: T[];
pageIndex: number;
pageSize: number;
totalPages: number;
hasNext: boolean;
}
/** 待办行("待我审批"数据源,中心 /oapi/flow/tasks/todo 行) */
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;
}
/**
* "我提交的"行(本地 /api/apply/approval/My;ApplyVo 子集)。
* 状态域:0=待提交、10=审批中、20=已完成、30=已拒绝、40=已撤回——
* **0 是"待提交"不是"全部"**(Notice 原版 instances 页签把 0 当"全部",本页签禁止复用该口径)。
*/
export interface MyApplyRow {
id: number;
itemTitle: string;
flowStatus: number;
flowInstanceId?: number | null;
submitTime?: string | null;
finishTime?: 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[];
}
/** 审批轨迹(宿主把既有 /api/apply/{id}/progress 转换后经 props 传入;卡片不自取) */
export interface TraceStep {
nodeId?: string | null;
nodeName?: string | null;
label?: string | null;
active?: boolean;
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;
/** 活动节点名逗号串(由 active steps 的 nodeName 产生——byBiz 的 currentNodes 是节点 ID 不得当名称) */
currentNodes?: string | null;
nodeSummary?: string | null;
steps?: TraceStep[] | null;
flowResult?: { result?: string; opinion?: string; rejectNode?: string } | null;
}
/** 换址类返回 */
export interface PageUrlResult {
pageUrl: string;
expiresIn?: number;
}
/**
* 组件与子应用后端之间的唯一契约。返回 alova `Method` 或 Promise 都可——组件只 `await`。
* `flowStatus` 筛选一期**不透传**给 My(页签内提供本地状态筛选,走代理端点 query):
* 一期实现为前端整页拉取后按状态过滤的本地筛选不引入,直接透传 `flowStatus` query 给后端
* (/api/apply/approval/My 支持与否以适配器注释为准——当前透传)。
*/
export interface FlowApprovalApi {
/** 我的待办分页(中心 todo,双值双查在代理后端) */
todo(params?: { page?: number; size?: number }): any;
/** 我提交的分页(本地办件;flowStatus 可选,null=全部) */
my?(params?: { page?: number; size?: number; flowStatus?: number | null }): any;
/** 当前人在该办件上的待办 */
byBiz(bizId: string): any;
/** 审批轨迹(可缺省:传了卡片就不再自取) */
trace?(bizId: string): any;
/** 通过(bizId=applyId,svc-hall 写面归属预检必需) */
approve(taskId: number, opinion?: string, edgeName?: string, bizId?: string | number): any;
/** 拒绝(同上) */
reject(taskId: number, opinion: string, bizId?: string | number): any;
recordUrl(bizId: string): any;
taskPageUrl(bizId: string, taskId: number): any;
}
/** 办件状态 → 文案(0=待提交 是 hall 本地扩展;10~40 与中心实例状态同码) */
export const INSTANCE_STATUS_TEXT: Record<number, string> = {
0: "待提交",
10: "审批中",
20: "已完成",
30: "已拒绝",
40: "已撤回",
};
/** 办件状态 → view-ui-plus Tag 预设色名(用组件库语义色,不写死色值) */
export const INSTANCE_STATUS_TAG: Record<number, string> = {
0: "default",
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;
}
+7 -1
View File
@@ -2,7 +2,7 @@ import type { RouteRecordRaw } from "vue-router";
/**
* 公共办事大厅路由(用户面;后台管理路由在 svc-hall/front,两仓互不引用)。
* 四个页面:事项大厅(入口)/ 在线办理 / 我的办件 / 办件详情。
* 五个页面:事项大厅(入口)/ 在线办理 / 我的办件 / 我的审批 / 办件详情。
* 全部走 hash 路由(部署于 /{contextPath}/hall/,hash 免服务端 rewrite)。
* 参数页用 query 传参(与 CMS 指南页"我要办理"外链形态一致:
* {svc_hall_base}/#/apply?siteCode=&itemCode=&from=<指南页绝对URL>;
@@ -28,6 +28,12 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/ApplyMy.vue"),
meta: { title: "我的办件" },
},
{
path: "/todo",
name: "todo",
component: () => import("@/views/MyApprovals.vue"),
meta: { title: "我的审批" },
},
{
path: "/apply-detail",
name: "apply_detail",
+106
View File
@@ -10,6 +10,11 @@ import { Message, Modal } from "view-ui-plus";
import { applyApi } from "@/api/module/svc";
import type { ApplyVo, FlowProgressVo } from "@/types/svc";
import { buildFormDataGroups, FormDataGroup } from "@/utils/formDataGroups";
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 svcFlowApprovalApi from "@/api/module/approvalAdapter";
import type { TraceVo } from "@/components/flow-approval/types";
const route = useRoute();
const router = useRouter();
@@ -19,6 +24,93 @@ const acting = ref(false);
const diagramVisible = ref(false);
const diagramUrl = ref("");
// ===== 审批卡片(P3.3):轨迹转换 + 记录抽屉 + 完整办理 =====
/** /api/apply/{id}/progress → 卡片 TraceVo(P1-05:卡片不自取轨迹,宿主转换后传入) */
const cardTrace = computed<TraceVo | null>(() => {
const p = progress.value;
if (!p) return null;
const steps = (p.steps || []).map((st) => ({
nodeId: st.nodeId,
nodeName: st.nodeName,
label: st.label,
active: st.active,
assignees: (st.assignees || []).map((a) => ({
userName: a.userName,
statusLabel: a.statusLabel,
opinion: a.opinion,
})),
}));
// 活动节点名摘要:active steps 的节点名去重(byBiz currentNodes 是节点 id,不得当名称)
const activeNames = Array.from(
new Set((p.steps || []).filter((st) => st.active).map((st) => st.nodeName).filter(Boolean))
);
const res = parse(apply.value?.flowResultJson);
return {
instanceStatus: p.status,
auditStatus: p.status,
nodeSummary: activeNames.join("、") || undefined,
currentNodes: activeNames.join("、") || undefined,
steps,
flowResult: res.result || res.opinion ? { result: res.result, opinion: res.opinion } : null,
};
});
const recordVisible = ref(false);
function openRecord() {
if (!apply.value?.id) return;
recordVisible.value = true;
}
const taskVisible = ref(false);
const taskUrl = ref("");
const taskTaskId = ref<number | string>("");
/** 当前任务页上下文(P1-09:消息 bizId 与本办件一致才受理 open-biz) */
const taskBizId = ref<string | number>("");
let taskReqEpoch = 0;
async function openTask(payload: { taskId: number; bizId: string }) {
if (!apply.value?.id) return;
const epoch = ++taskReqEpoch;
taskBizId.value = payload.bizId;
taskTaskId.value = payload.taskId;
taskUrl.value = "";
taskVisible.value = true;
try {
const res: any = await svcFlowApprovalApi.taskPageUrl(payload.bizId, payload.taskId);
if (epoch !== taskReqEpoch) return;
const url = res?.pageUrl || "";
if (!url) {
Message.error("任务页地址获取失败");
taskVisible.value = false;
return;
}
taskUrl.value = url;
} catch (e: any) {
if (epoch !== taskReqEpoch) return;
Message.error(e?.msg || e?.message || "任务页地址获取失败");
taskVisible.value = false;
}
}
function onTaskClose() {
taskReqEpoch++;
taskUrl.value = "";
taskTaskId.value = "";
}
/** open-biz 在详情页=回本页(只用一致 bizId;bizUrl 不当路由依据) */
function onOpenBiz(payload: { bizId?: string | null }) {
const msgBizId = payload.bizId != null ? String(payload.bizId) : "";
if (msgBizId && msgBizId !== String(apply.value?.id)) return;
taskVisible.value = false;
load();
}
/** 卡片办理完成:详情与进度整体刷新(本地状态实际由回调推进) */
function onCardHandled() {
load();
}
const STATUS: Record<number, { text: string; cls: string }> = {
0: { text: "待提交", cls: "sh-tag" },
10: { text: "审批中", cls: "sh-tag sh-tag-primary" },
@@ -203,6 +295,14 @@ onMounted(load);
</template>
</div>
<!-- 审批卡片(P3.3):未进流程(flowInstanceId 空)不渲染——该场景走"待提交"主链,无审批面 -->
<div class="sh-card" v-if="apply.flowInstanceId">
<div class="section-title">审批操作</div>
<FlowApprovalCard :api="svcFlowApprovalApi" biz-type="svc-hall" :biz-id="String(apply.id)"
:title="snapshot.itemTitle || apply.itemTitle" :trace="cardTrace"
@open-record="openRecord" @open-task="openTask" @handled="onCardHandled" />
</div>
<div class="sh-card" v-if="result.result || result.opinion">
<div class="section-title">审批结果</div>
<p>结果:{{ result.result || "—" }}</p>
@@ -251,6 +351,12 @@ onMounted(load);
<img :src="diagramUrl" alt="流程图" />
</div>
</Modal>
<FlowRecordDrawer v-model="recordVisible" :api="svcFlowApprovalApi"
:biz-id="String(apply.id || '')" />
<FlowTaskEmbed v-model="taskVisible" :page-url="taskUrl" :task-id="taskTaskId"
:biz-id="String(apply.id || '')"
@handled="onCardHandled" @open-biz="onOpenBiz" @close="onTaskClose" />
</div>
<div class="sh-card" v-else>加载中…</div>
</template>
+138
View File
@@ -0,0 +1,138 @@
<script setup lang="ts">
/**
* 我的审批(/todo,P3.3):待我审批(中心 todo)+ 我提交的(本地办件)两页签。
* 完整办理=去壳任务页 iframe(FlowTaskEmbed);审批记录=token 只读抽屉(FlowRecordDrawer)。
*
* 宿主职责(P1-09):
* - open-task 换址请求带代际号——关闭/换件时废弃在途响应并清 taskUrl/选中 ID;
* - open-biz 只用与当前办件上下文一致的 bizId 构造 /apply-detail?id=,不照抄任意 URL。
*/
import { onBeforeUnmount, 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 svcFlowApprovalApi from "@/api/module/approvalAdapter";
const router = useRouter();
const listRef = ref<InstanceType<typeof FlowApprovalList> | null>(null);
// ===== 审批记录抽屉 =====
const recordVisible = ref(false);
const recordBizId = ref("");
function openRecord(bizId: string) {
recordBizId.value = bizId;
recordVisible.value = true;
}
// ===== 完整办理(去壳任务页) =====
const taskVisible = ref(false);
const taskUrl = ref("");
const taskTaskId = ref<number | string>("");
const taskBizId = ref<string | number>("");
/** 换址请求代际号:关闭/换件时 +1,晚到响应按代际丢弃(P1-09) */
let taskReqEpoch = 0;
async function openTask(payload: { bizId: string; taskId: number }) {
const epoch = ++taskReqEpoch;
taskBizId.value = payload.bizId;
taskTaskId.value = payload.taskId;
taskUrl.value = "";
taskVisible.value = true;
try {
const res: any = await svcFlowApprovalApi.taskPageUrl(payload.bizId, payload.taskId);
if (epoch !== taskReqEpoch) return; // 已关闭/已换件:废弃
const url = res?.pageUrl || "";
if (!url) {
Message.error("任务页地址获取失败");
taskVisible.value = false;
return;
}
taskUrl.value = url;
} catch (e: any) {
if (epoch !== taskReqEpoch) return;
Message.error(e?.msg || e?.message || "任务页地址获取失败");
taskVisible.value = false;
}
}
function onTaskClose() {
// 关闭即清上下文 + 废弃在途换址请求
taskReqEpoch++;
taskUrl.value = "";
taskTaskId.value = "";
taskBizId.value = "";
}
function onHandled() {
// 办理完成:刷新列表(本地 flow_status 由回调推进,前端只刷数据面)
listRef.value?.load();
}
/**
* open-biz:只用校验过的本办件 ID 构造 /apply-detail?id=(P1-09)。
* 消息 bizId 必须与打开任务页时锁定的办件上下文一致;缺失时用锁定上下文;绝不透传 bizUrl 路由。
*/
function onOpenBiz(payload: { bizId?: string | null; bizUrl?: string }) {
const msgBizId = payload.bizId != null ? String(payload.bizId) : "";
const ctxBizId = String(taskBizId.value || "");
if (!msgBizId && !ctxBizId) return;
if (msgBizId && ctxBizId && msgBizId !== ctxBizId) return; // 换件期间旧消息,作废
const applyId = Number(msgBizId || ctxBizId);
if (!Number.isInteger(applyId) || applyId <= 0) return;
taskVisible.value = false;
router.push(`/apply-detail?id=${applyId}`);
}
/** "我提交的"行查看详情(只用本行 id 构造路由) */
function openDetail(applyId: number) {
router.push(`/apply-detail?id=${applyId}`);
}
onBeforeUnmount(() => {
taskReqEpoch++;
});
</script>
<template>
<div class="page todo-page">
<div class="page-head">
<h2 class="page-title">我的审批</h2>
<span class="page-sub">「待我审批」为流程中心待办;「我提交的」为本人在大厅提交的办件</span>
</div>
<FlowApprovalList ref="listRef" :api="svcFlowApprovalApi" :tabs="['todo', 'my']"
@open-record="openRecord" @open-task="openTask" @handled="onHandled"
@open-detail="openDetail" />
<FlowRecordDrawer v-model="recordVisible" :api="svcFlowApprovalApi" :biz-id="recordBizId" />
<FlowTaskEmbed v-model="taskVisible" :page-url="taskUrl" :task-id="taskTaskId" :biz-id="taskBizId"
@handled="onHandled" @open-biz="onOpenBiz" @close="onTaskClose" />
</div>
</template>
<style scoped>
.todo-page {
max-width: 1080px;
margin: 0 auto;
width: 100%;
box-sizing: border-box;
}
.page-head {
display: flex;
align-items: baseline;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 12px;
}
.page-title {
font-size: 18px;
font-weight: 600;
margin: 0;
}
.page-sub {
font-size: 13px;
color: var(--sh-text-weak, #6b7280);
}
</style>