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
@@ -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>