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
+132
View File
@@ -0,0 +1,132 @@
<script setup lang="ts">
/**
* FL 侧选人 iframe 宿主(验收 P2-2 协议收口)。
*
* 用法(业务页,不是 FL 自己的产品入口):
* <FlowAssignEmbed ref="picker" :flow-guid="guid" @confirmed="onPicked" @cancelled="onCancel" />
* picker.value.open()
*
* 语义(与既定架构一致):
* - 确认=把 iframe 内选择的最终结果交回宿主页面内存;宿主何时提交仍由业务调 FL 正式接口决定,
* 前端选择结果不是授权凭据,最终资格由 FL 服务端复核。
* - 取消/直接关框=只通知宿主,不改宿主已有的旧选择。
* - 重新打开后确认=以最近一次为准(B 替换 A)。
* - 收消息四道校验:event.origin、event.source(必须是本组件那个 iframe 的窗口)、
* 协议信封(proto/v/type)、本次 contextId;任一不过直接丢弃(旧会话迟到消息不会串台)。
* - 关闭即摘监听,重开换新 contextId。
*/
import { onBeforeUnmount, ref, watch } from "vue";
import { Modal } from "view-ui-plus";
import { isValidAssignMessage } from "@/utils/assignEmbed";
import type { AssignEmbedMessage } from "@/utils/assignEmbed";
import { toAppHashUrl } from "@/utils/appUrl";
const props = defineProps<{
/** 要预览/选择的流程线 */
flowGuid: string;
/** 弹框标题 */
title?: string;
}>();
const emit = defineEmits<{
/** 用户在 iframe 内点了确认:assigns 为最终选择,defVersion 为预览时的版本锚 */
confirmed: [payload: { flowGuid: string; defVersion?: number; assigns: { nodeId: string; userIds: number[] }[] }];
/** 取消或宿主关框:宿主应保持原有选择,不做任何覆盖 */
cancelled: [];
}>();
const visible = ref(false);
const frameUrl = ref("");
const contextId = ref("");
const frameEl = ref<HTMLIFrameElement | null>(null);
/** 本次打开锁定的父↔子通道条件;非响应式:只在 open 时刷新 */
let expectedOrigin = "";
let listening = false;
/** 本次会话是否已经由 iframe 明确答复过(确认/取消),用于区分"宿主自己关框" */
let settled = false;
function newContextId(): string {
return `c${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
}
function closeListener(): void {
if (listening) {
window.removeEventListener("message", onMessage);
listening = false;
}
}
function onMessage(ev: MessageEvent): void {
const msg: AssignEmbedMessage | null = isValidAssignMessage(
ev,
expectedOrigin,
frameEl.value?.contentWindow ?? null,
contextId.value,
);
if (!msg) return;
settled = true;
closeListener();
visible.value = false;
if (msg.type === "assign-cancelled") {
emit("cancelled");
return;
}
emit("confirmed", {
flowGuid: msg.flowGuid || props.flowGuid,
defVersion: msg.defVersion,
assigns: msg.assigns ?? [],
});
}
function open(): void {
if (!props.flowGuid) return;
// 重开:先摘掉上一轮监听,再换一个新的 contextId——旧 frame 的迟到消息按 context 失效
closeListener();
settled = false;
contextId.value = newContextId();
const query = `flowGuid=${encodeURIComponent(props.flowGuid)}&contextId=${encodeURIComponent(contextId.value)}`;
frameUrl.value = toAppHashUrl(`#/assign-preview?${query}`);
expectedOrigin = new URL(frameUrl.value, window.location.href).origin;
visible.value = true;
window.addEventListener("message", onMessage);
listening = true;
}
/** 宿主主动收起(等价于取消):同样摘监听,避免后续消息继续影响本页 */
function close(): void {
closeListener();
if (visible.value && !settled) emit("cancelled");
visible.value = false;
}
// 点遮罩/X 关闭走这里;已 settled 的关闭由 iframe 自己回过话,不再补发 cancelled
watch(visible, (val) => {
if (!val) closeListener();
});
onBeforeUnmount(closeListener);
defineExpose({ open, close, contextId });
</script>
<template>
<Modal v-model="visible" :title="title || '选择办理人'" :width="760" :footer-hide="true" class="assign-embed-modal">
<iframe
v-if="frameUrl"
ref="frameEl"
:src="frameUrl"
class="assign-embed-frame"
frameborder="0"
title="选择办理人"
/>
</Modal>
</template>
<style scoped>
.assign-embed-frame {
width: 100%;
height: 460px;
border: 0;
display: block;
}
</style>
+15 -4
View File
@@ -76,10 +76,15 @@ const routeChange = (to: any) => {
const navArr: any[] = [];
// 优先按 path 匹配菜单(平台模块 id 与路由 meta.modId 不保证一致),
// 匹配不到再退回 modId 对照
// 匹配不到再退回 modId 对照。
// modId 为空时必须短路:深链页(/diagram、/assign-preview、/task/*)不带 modId,
// 原先 `x.modId === undefined` 会命中第一条同样没有 modId 的菜单,面包屑于是显示成
// 不相干的「流程编辑器」(验收报告 P3)。
const find: any =
menuArr.value.find((x: any) => x.page && x.page === to.path) ||
menuArr.value.find((x: any) => x.modId === modId);
(modId !== undefined && modId !== null
? menuArr.value.find((x: any) => x.modId === modId)
: null);
if (find) {
currPath.value = find.page || "";
if (find.pid) {
@@ -109,7 +114,10 @@ const routeChange = (to: any) => {
}
}
const r = router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
// 同上:modId 缺失时不做这条兜底,否则又是"第一条无 modId 路由"中奖
const r = (modId === undefined || modId === null)
? undefined
: router.getRoutes().find((rt: any) => (rt.meta as any)?.modId === modId);
if (r) {
currPath.value = r.path;
navPath.value = [{ name: (r.meta as any).title, page: r.path }];
@@ -117,7 +125,10 @@ const routeChange = (to: any) => {
}
currPath.value = "";
navPath.value = [];
// 深链页(/diagram 流程示意图、/assign-preview 选择办理人、/task/*)不挂菜单:
// 面包屑退回平台应用名(FL 配置的是「流程编辑器」),与实际页面标题不符(验收报告 P3)。
// 菜单匹配不上时至少给出本页 meta.title,父级缺失不再伪造。
navPath.value = title ? [{ name: title }] : [];
};
const linkMenu = (item: any) => {