Files
svc-hall-hall/src/components/flow-approval/FlowApprovalCard.vue
T
chenxing 351a513547
build / build (push) Successful in 2m12s
feat(p3.3): 应用内审批前端——flow-approval 组件族本地化 + /todo 我的审批页 + 详情审批卡片
- 从 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)
2026-10-19 23:59:48 +08:00

337 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>