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)
337 lines
12 KiB
Vue
337 lines
12 KiB
Vue
<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>
|