feat(flow): 流程图入口/失败提示/指派预览/线级停发闭环/配置分层/选人宿主
build / build (push) Successful in 1m28s

- 修复办理页「查看流程图」双 hash URL:改 router.resolve + toAppHashUrl 单 hash
- 修复管理端办件抽屉「查看大图」域名尾点(origin + BASE_URL("./") 拼接)
- 新增 flowError 口径层与办理页内联失败条,审批失败提示可读、不泄漏堆栈
- 审批节点抽屉新增「预览候选人」真入口 + AssigneePreviewModal(只读,不写回定义)
- 线级停发/恢复失败改为单条错误面,避免同错双 toast
- 审批抽屉核心区/高级设置分层;插入菜单常用/高级分组
- 新增 FlowAssignEmbed 选人 iframe 父侧宿主(origin/source/信封/context 四校验)
- 修复 /diagram、/assign-preview 面包屑误显示「流程编辑器」
This commit is contained in:
chenxing
2026-10-28 03:18:54 +08:00
parent f2841e2740
commit c93313cdb7
26 changed files with 1602 additions and 36 deletions
+2 -2
View File
@@ -412,8 +412,8 @@ async function applyLineAction(row: any, action: "offline" | "enable") {
lineOffline: Number(row.lineOffline) || 0,
action,
request: async (guid) => Number(await (action === "offline"
? flowApi.defOfflineLine(guid).send()
: flowApi.defEnableLine(guid).send())) || 0,
? flowApi.defOfflineLine(guid, { silentBizToast: true }).send()
: flowApi.defEnableLine(guid, { silentBizToast: true }).send())) || 0,
reload: () => load(),
});
if (out.kind === "skipped") {
@@ -0,0 +1,226 @@
/**
* 审批节点「预览候选人」(FL上线独立验收报告 P2-1:/assign-preview 页存在但编辑器内无入口)。
*
* 口径:
* - 只读。解析当前抽屉里的**草稿配置**,不写回节点、不改流程定义、不建实例。
* - 数据全部来自既有接口(科室成员 / UserBase 用户检索 / 发起预览),前端不重实现人员解析:
* DEPT_KEY 读的是后端 AssigneeResolver 同一张 flw_dept_member,USERS 读的是 UserBase byId。
* - 最终办理资格仍由 FL 服务端在正式发起/办理时复核(跨校、禁用、非本人等),这里只是设计期核对。
*/
import { describeFlowError } from "@/utils/flowError";
export type PreviewAssigneeType = "DEPT_KEY" | "USERS";
export interface PreviewAssignPolicy {
mode?: string;
min?: number | null;
max?: number | null;
required?: boolean;
}
/** 抽屉里的当前(可能未保存)节点配置 */
export interface PreviewNodeConfig {
uid: string;
name: string;
type: PreviewAssigneeType;
deptKey: string;
userIds: number[];
countersign: "ALL" | "ANY";
assignPolicy: PreviewAssignPolicy | null | undefined;
}
export interface PreviewDept {
deptKey: string;
deptName: string;
count: number;
}
export interface CandidateRow {
userId: number;
userName: string;
/** 该候选人不计入/存在风险的说明;空=正常 */
warn?: string;
}
/** 已发布版本的发起预览对账结果 */
export interface PublishedView {
state: "ok" | "missing" | "unavailable";
defVersion?: number;
rows: CandidateRow[];
text: string;
}
export interface NodePreviewResult {
/** 是否解析出了可用候选人(false 时一定有 blocked 或 published.unavailable 解释) */
ok: boolean;
headline: string;
rows: CandidateRow[];
blocked?: string;
published?: PublishedView;
notes: string[];
}
export interface PreviewApi {
deptMembers: (deptId: string) => Promise<any[]>;
lookupUser: (userId: number) => Promise<any>;
applyPreview: (flowGuid: string) => Promise<any>;
}
/** flw_dept_member.status / flw_dept.status:0=启用(与后端 resolveEnabled 同口径) */
const DEPT_MEMBER_ENABLED = 0;
function countersignText(c: "ALL" | "ANY"): string {
return c === "ALL" ? "会签(全部同意才通过)" : "或签(任一人同意即通过)";
}
function assignPolicyText(p: PreviewAssignPolicy | null | undefined): string {
if (!p) return "";
const mode = p.mode === "SINGLE" ? "单选 1 人" : `多选 ${p.min ?? 1}~${p.max ?? "不限"} 人`;
return `;发起时指派:${mode}${p.required ? " · 必选" : ""}`;
}
/** UserBase BoaUser.status:1=启用(后端 D11 终审按此拒绝禁用/未知状态账号) */
function userRow(u: any, id: number): CandidateRow {
if (!u) return { userId: id, userName: `#${id}`, warn: "UserBase 查不到该用户,正式发起时会被拒绝" };
const status = typeof u.status === "number" ? u.status : null;
return {
userId: id,
userName: u.name || `#${id}`,
warn: status === 1 ? undefined : `账号状态${status == null ? "未知" : status}(非启用),正式办理时会被服务端拒绝`,
};
}
async function deptRows(deptKey: string, api: PreviewApi): Promise<CandidateRow[]> {
const members = (await api.deptMembers(deptKey)) || [];
return members.map((m: any) => ({
userId: Number(m.userId),
userName: m.userName || `#${m.userId}`,
warn: Number(m.status) === DEPT_MEMBER_ENABLED ? undefined : "该成员已停用,不计入候选",
}));
}
/** 已发布版本对账:只有本线已保存(有 flowGuid)时才有可比对象 */
export async function loadPublishedView(
cfg: PreviewNodeConfig,
flowGuid: string,
api: PreviewApi,
): Promise<PublishedView> {
if (!flowGuid) {
return { state: "unavailable", rows: [], text: "本流程线尚未保存,没有可比对的已发布版本(预览按当前草稿配置解析)。" };
}
let res: any;
try {
res = await api.applyPreview(flowGuid);
} catch (e: any) {
return { state: "unavailable", rows: [], text: `已发布版本预览不可用:${describeFlowError(e)}` };
}
const version = Number(res?.defVersion) || undefined;
const hit = (res?.nodes || []).find((n: any) => String(n.nodeId) === String(cfg.uid));
if (!hit) {
return {
state: "missing",
defVersion: version,
rows: [],
text: `已发布版本 v${version ?? "?"} 的发起预览里没有本节点:要么它未开启发起时指派,要么是尚未发布的草稿节点。`,
};
}
const rows: CandidateRow[] = (hit.candidates || []).map((c: any) => ({
userId: Number(c.userId),
userName: c.userName || `#${c.userId}`,
}));
return {
state: "ok",
defVersion: version,
rows,
text: `已发布版本 v${version ?? "?"} 解析出的可指派候选人(发起人实际可选范围以此为准,本节点${rows.length ? "" : "当前无候选人"})。`,
};
}
/**
* 解析当前节点配置的候选人。
* depts 传编辑器的科室上下文(含启用人数),用于把「科室不存在/0 人」讲清楚而不靠猜。
*/
export async function resolveNodePreview(
cfg: PreviewNodeConfig,
depts: PreviewDept[],
api: PreviewApi,
flowGuid = "",
): Promise<NodePreviewResult> {
const notes = [
`多人审批方式:${countersignText(cfg.countersign)}${assignPolicyText(cfg.assignPolicy)}`,
"预览只读:不写回节点、不改动流程定义;最终办理资格由 FL 服务端在正式发起时复核。",
];
const published = await loadPublishedView(cfg, flowGuid, api);
if (cfg.type === "DEPT_KEY") {
if (!cfg.deptKey) {
return { ok: false, headline: "按科室映射解析", rows: [], blocked: "尚未选择科室映射,请先在「科室映射」里选一个科室。", published, notes };
}
const dept = depts.find((d) => d.deptKey === cfg.deptKey);
if (!dept) {
return {
ok: false,
headline: "按科室映射解析",
rows: [],
blocked: `科室映射「${cfg.deptKey}」不在本校科室列表中(可能已删除),发布会被拦截。`,
published,
notes,
};
}
let rows: CandidateRow[];
try {
rows = await deptRows(cfg.deptKey, api);
} catch (e: any) {
return {
ok: false,
headline: `科室「${dept.deptName || dept.deptKey}」`,
rows: [],
blocked: `读取科室成员失败:${describeFlowError(e)}`,
published,
notes,
};
}
const enabled = rows.filter((r) => !r.warn);
if (!enabled.length) {
return {
ok: false,
headline: `科室「${dept.deptName || dept.deptKey}」(${dept.deptKey})`,
rows,
blocked: `科室「${dept.deptName || dept.deptKey}」当前 0 名启用办理人,发布会被拦截;请到「办理人映射」补人。`,
published,
notes,
};
}
if (rows.length !== enabled.length) {
notes.unshift(`共 ${rows.length} 名成员,其中 ${enabled.length} 名启用(停用成员已在下表标注)。`);
}
return {
ok: true,
headline: `科室「${dept.deptName || dept.deptKey}」(${dept.deptKey})· ${enabled.length} 名启用办理人`,
rows,
published,
notes,
};
}
if (!cfg.userIds.length) {
return { ok: false, headline: "按指定用户解析", rows: [], blocked: "尚未选择用户,请先点「选择用户」添加审批人。", published, notes };
}
const rows: CandidateRow[] = [];
for (const id of cfg.userIds) {
try {
rows.push(userRow(await api.lookupUser(Number(id)), Number(id)));
} catch (e: any) {
rows.push({ userId: Number(id), userName: `#${id}`, warn: `用户检索失败:${describeFlowError(e)}` });
}
}
const usable = rows.filter((r) => !r.warn);
return {
ok: usable.length > 0,
headline: `指定用户 · ${rows.length} 人(${usable.length} 人可检索到且状态启用)`,
rows,
blocked: usable.length ? undefined : "指定的用户全部查不到或状态非启用,发布会被拦截。",
published,
notes,
};
}
@@ -6,6 +6,8 @@ import { computed, inject, reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import UserCheckModal from "@/components/UserCheckModal.vue";
import { flowApi } from "@/api/module/flow";
import AssigneePreviewModal from "./AssigneePreviewModal.vue";
import type { PreviewNodeConfig } from "../assignPreview";
import { ApprovalNode, EDITOR_CTX, createApproval } from "../model";
const props = defineProps<{
@@ -114,6 +116,49 @@ function removeUser(id: number): void {
if (i >= 0) form.userIds.splice(i, 1);
}
// ===== 高级设置分层(P2 核心/高级):只挪位置,不改字段、不改数据语义 =====
/** 高级区是否展开;打开抽屉时若已有高级配置则默认展开,避免"配了但看不见" */
const advOpen = ref(false);
const activeAdvanced = computed(() => {
const hits: string[] = [];
if (form.countersign === "ALL") hits.push("会签");
if (form.assignEnabled) hits.push("发起时指派");
if (form.deadlineHours) hits.push("办理时限");
if (form.multiOutlet) hits.push("多出口");
return hits;
});
watch(() => [props.modelValue, props.node?.uid], () => {
if (props.modelValue) advOpen.value = activeAdvanced.value.length > 0;
}, { immediate: true });
// ===== 指派预览(验收 P2-1:编辑器内要有可发现入口) =====
const previewModal = ref<any>();
/** 用抽屉里的实时表单(含未保存改动)作为预览输入;预览全程只读,不回写节点 */
function previewConfig(): PreviewNodeConfig {
return {
uid: props.node?.uid || "",
name: form.name,
type: form.type,
deptKey: form.deptKey,
userIds: [...form.userIds],
countersign: form.countersign,
assignPolicy: form.assignEnabled
? {
mode: form.assignMode,
min: form.assignMin,
max: form.assignMax,
required: form.assignRequired,
}
: null,
};
}
function onPreviewCandidates(): void {
previewModal.value?.show(previewConfig(), ctx.depts, ctx.flowGuid);
}
function onOk(): void {
const n = props.node;
if (!n) {
@@ -206,6 +251,20 @@ function onRemove(): void {
</div>
</template>
<!-- 候选人预览:核心区入口(读当前配置,不保存、不改动定义) -->
<div class="fld">
<Button size="small" type="primary" ghost @click="onPreviewCandidates">预览候选人</Button>
<div class="hint">按当前配置(含未保存改动)解析实际会拿到待办的人,并与已发布版本对账;最终资格由服务端在正式发起时复核</div>
</div>
<!-- 高级能力:默认收起,已配置时自动展开并在收起态标出条目(不删除、不改语义) -->
<div class="adv-head" @click="advOpen = !advOpen">
<span class="adv-caret">{{ advOpen ? "▾" : "▸" }}</span>
<span class="adv-title">高级设置</span>
<span v-if="!advOpen && activeAdvanced.length" class="adv-badge">已配置 {{ activeAdvanced.join("、") }}</span>
<span v-else-if="!advOpen" class="adv-sub">会签/或签 · 发起时指派 · 办理时限 · 多出口路由</span>
</div>
<div v-show="advOpen" class="adv-body">
<div class="fld">
<label>多人审批方式</label>
<RadioGroup v-model="form.countersign" type="button" button-style="solid">
@@ -260,6 +319,7 @@ function onRemove(): void {
关闭请用泳道头部的"恢复单出口"(需处理泳道内容去向)
</div>
</div>
</div>
</Form>
<div class="dw-foot">
@@ -278,6 +338,8 @@ function onRemove(): void {
:userids="form.userIds"
@on-submit="onUsersPicked"
/>
<AssigneePreviewModal ref="previewModal" />
</template>
<style scoped>
@@ -302,6 +364,32 @@ function onRemove(): void {
.hint.warn {
color: #ff9900;
}
.adv-head {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 0;
margin: 4px 0 12px;
border-top: 1px dashed #e8eaec;
cursor: pointer;
user-select: none;
}
.adv-title {
font-weight: 600;
font-size: 13px;
}
.adv-sub,
.adv-badge {
font-size: 12px;
color: #808695;
}
.adv-badge {
color: #ff9900;
}
.adv-body {
padding-left: 10px;
border-left: 2px solid #f0f0f0;
}
.user-add {
display: flex;
gap: 8px;
@@ -0,0 +1,91 @@
<script setup lang="ts">
/**
* 审批节点候选人预览弹窗(编辑器内入口,验收报告 P2-1)。
* 只读展示 resolveNodePreview 的结果:草稿解析 + 已发布版本对账 + 明确的不可预览原因。
* 关闭不做任何写回——它只接收抽屉里的本地表单快照。
*/
import { ref } from "vue";
import { Modal, Alert, Tag, Button } from "view-ui-plus";
import { resolveNodePreview, type PreviewApi, type PreviewDept, type PreviewNodeConfig } from "../assignPreview";
import { flowApi } from "@/api/module/flow";
const api: PreviewApi = {
deptMembers: (deptId) => flowApi.deptMembers(deptId).send() as Promise<any[]>,
lookupUser: (userId) => flowApi.mapLookupUser(userId).send() as Promise<any>,
applyPreview: (flowGuid) => flowApi.applyPreview(flowGuid).send() as Promise<any>,
};
const visible = ref(false);
const loading = ref(false);
const title = ref("预览候选人");
const result = ref<Awaited<ReturnType<typeof resolveNodePreview>> | null>(null);
async function show(cfg: PreviewNodeConfig, depts: PreviewDept[], flowGuid = ""): Promise<void> {
visible.value = true;
loading.value = true;
result.value = null;
title.value = `预览候选人 · ${cfg.name || "未命名节点"}`;
try {
result.value = await resolveNodePreview(cfg, depts, api, flowGuid);
} finally {
loading.value = false;
}
}
defineExpose({ show });
</script>
<template>
<Modal v-model="visible" :title="title" :width="620" :footer-hide="true">
<div v-if="loading" class="muted" style="padding: 24px 0; text-align: center">正在解析候选人…</div>
<template v-else-if="result">
<Alert v-if="result.blocked" type="warning" show-icon class="pv-block">{{ result.blocked }}</Alert>
<div class="pv-headline">
<b>{{ result.headline }}</b>
<span class="pv-source">数据源:{{ result.rows.length ? "FL 科室映射 / UserBase 既有接口" : "未取到候选" }}</span>
</div>
<div v-if="result.rows.length" class="pv-rows">
<div v-for="r in result.rows" :key="r.userId" class="pv-row">
<Tag :color="r.warn ? 'warning' : 'blue'">{{ r.userName }}</Tag>
<span class="pv-id">#{{ r.userId }}</span>
<span v-if="r.warn" class="pv-warn">{{ r.warn }}</span>
</div>
</div>
<div v-else class="muted">(本节点当前没有可展示的候选人)</div>
<div class="pv-pub" v-if="result.published">
<div class="pv-pub-title">已发布版本对账</div>
<div class="pv-pub-body">
<Tag :color="result.published.state === 'ok' ? 'success' : result.published.state === 'missing' ? 'default' : 'warning'">
{{ result.published.state === "ok" ? "可比对" : result.published.state === "missing" ? "草稿节点未发布" : "不可比对" }}
</Tag>
<span>{{ result.published.text }}</span>
</div>
<div v-if="result.published.state === 'ok' && result.published.rows.length" class="pv-rows">
<Tag v-for="p in result.published.rows" :key="p.userId" color="green">{{ p.userName }}</Tag>
</div>
</div>
<ul class="pv-notes">
<li v-for="(n, i) in result.notes" :key="i">{{ n }}</li>
</ul>
</template>
<div style="margin-top: 12px; text-align: right">
<Button @click="visible = false">关闭</Button>
</div>
</Modal>
</template>
<style scoped>
.pv-block { margin-bottom: 10px; }
.pv-headline { display: flex; align-items: baseline; gap: 10px; margin-bottom: 10px; }
.pv-source { color: #808695; font-size: 12px; }
.pv-rows { display: flex; flex-wrap: wrap; gap: 6px 0; margin-bottom: 12px; }
.pv-row { display: flex; align-items: center; gap: 6px; width: 100%; }
.pv-id { color: #c5c8ce; font-size: 12px; }
.pv-warn { color: #ff9900; font-size: 12px; }
.pv-pub { border-top: 1px dashed #e8eaec; padding-top: 10px; margin-bottom: 10px; }
.pv-pub-title { font-weight: 600; font-size: 13px; margin-bottom: 6px; }
.pv-pub-body { display: flex; align-items: flex-start; gap: 8px; font-size: 13px; }
.pv-notes { margin: 0; padding-left: 18px; color: #808695; font-size: 12px; line-height: 1.7; }
</style>
@@ -23,6 +23,7 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
<Poptip v-model="visible" trigger="click" placement="right-start" transfer :width="200" popper-class="def-plus-pop">
<div class="plus-btn" title="在此插入节点">+</div>
<template #content>
<div class="menu-group">常用节点</div>
<div class="menu-item" @click="pick('approval')">
<span class="mi-icon approval">审</span>
<span class="mi-text">
@@ -37,6 +38,8 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
<i>知会指定人,不产生待办、不阻塞流程</i>
</span>
</div>
<!-- 高级结构:只是分组展示,四类插入能力一个都没少,插入后的 JSON 语义完全不变 -->
<div class="menu-group adv">高级结构</div>
<div class="menu-item" @click="pick('parallel')">
<span class="mi-icon parallel">⇉</span>
<span class="mi-text">
@@ -94,6 +97,17 @@ function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
color: #fff;
box-shadow: 0 0 0 4px rgba(45, 140, 240, 0.15);
}
.menu-group {
padding: 4px 10px 2px;
font-size: 11px;
color: #808695;
letter-spacing: 0.5px;
}
.menu-group.adv {
margin-top: 6px;
border-top: 1px dashed #e8eaec;
padding-top: 8px;
}
.menu-item {
display: flex;
gap: 10px;
+7 -3
View File
@@ -428,6 +428,10 @@ provide<EditorCtx>(EDITOR_CTX, {
model,
depts,
userNames,
// 升版本/编辑草稿时由 defGet 带回;新建态为空串(预览据此说明"没有可比对的已发布版本")
get flowGuid() {
return sourceFlowGuid.value || "";
},
routers,
availableVars,
issueOf,
@@ -647,13 +651,13 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
:disabled="!!editId || !!baseGuid" :maxlength="40" />
</div>
<div class="ip-fld">
<label>归属应用 <span style="color: #ed4014">*</span></label>
<Select v-model="appKey" placeholder="归属应用(必选)" filterable>
<label>归属应用<span class="ip-mini">可选 · 仅作创建方筛选/审计,不是发起资格</span></label>
<Select v-model="appKey" placeholder="归属应用(可留空)" filterable clearable>
<Option v-for="a in appOptions" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
</Select>
</div>
<div class="ip-fld">
<label>关联表单<span class="ip-mini">表单中心 · 成对存版本</span></label>
<label>关联表单<span class="ip-mini">可选 · 表单中心 · 成对存版本</span></label>
<Select :value="formUuid" clearable filterable placeholder="不关联(原生办理)"
:disabled="!!formCenterError" @on-change="onFormChange">
<Option v-for="f in formOptions" :key="f.formUuid" :value="f.formUuid">
+2
View File
@@ -415,6 +415,8 @@ export interface EditorCtx {
model: EditorModel;
depts: DeptOption[];
userNames: Record<number, string>;
/** 当前流程线 flowGuid(新建/尚未保存时为空):指派预览与已发布版本对账要用 */
flowGuid: string;
/** 条件分支的判断服务注册下拉(flw_router 启用项;拉取失败为空数组,校验降级提示) */
routers: RouterOption[];
/** 可用变量 chips:近期办件 formData 顶层键聚合 + 内置变量(供条件表达式内联提示/插入) */