NOTIFY(2026-09-23):编辑器抄送卡片+抽屉+画布 bpmn:ServiceTask 映射、工作台「抄送我的」页签、轨迹 kind=CC 行、示意图青框卡片 CONDITION(2026-09-24):ConditionBox 互斥分支容器(分支列出口名就地编辑、无 JOIN 语义分支尾隐式汇合)、ConditionDrawer 判断服务下拉(flw_router)、convert 双向序列化(CONDITION+命名出边、uid+c 尾缀往返收敛)、validate 镜像后端口径、画布 bpmn:ExclusiveGateway 映射、轨迹「系统判断→出口」行(失败挂起提示)、示意图紫菱形(SVG 与服务端 PNG 同配色)、管理面 flw_router 维护页(/admin/routers)与办件记录「重跑路由」入口 convert 往返 54 项全过、npm run build 通过
This commit is contained in:
@@ -67,6 +67,10 @@ class FlowApi {
|
|||||||
done(params: { page?: number; size?: number }) {
|
done(params: { page?: number; size?: number }) {
|
||||||
return get(`${ROOT}/done`, params);
|
return get(`${ROOT}/done`, params);
|
||||||
}
|
}
|
||||||
|
/** 我的抄送(NOTIFY 节点落库;知会不占待办,独立页签) */
|
||||||
|
ccMy(params: { page?: number; size?: number }) {
|
||||||
|
return get(`${ROOT}/cc/my`, params);
|
||||||
|
}
|
||||||
/** 我提交的办件 */
|
/** 我提交的办件 */
|
||||||
my(params: { status?: number; page?: number; size?: number }) {
|
my(params: { status?: number; page?: number; size?: number }) {
|
||||||
return get(`${ROOT}/my`, params);
|
return get(`${ROOT}/my`, params);
|
||||||
@@ -204,6 +208,31 @@ class FlowApi {
|
|||||||
return post(`${ROOT}/admin/callback/repush`, ids);
|
return post(`${ROOT}/admin/callback/repush`, ids);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== 管理面:条件路由注册 / 重跑 ====================
|
||||||
|
|
||||||
|
/** 判断服务注册列表(flw_router 全局表;编辑器下拉与管理页共用) */
|
||||||
|
routerList() {
|
||||||
|
return get(`${ROOT}/admin/router/list`);
|
||||||
|
}
|
||||||
|
routerSave(body: {
|
||||||
|
id?: number;
|
||||||
|
routerKey: string;
|
||||||
|
name: string;
|
||||||
|
serviceKey: string;
|
||||||
|
path: string;
|
||||||
|
basePath?: string;
|
||||||
|
}) {
|
||||||
|
return post(`${ROOT}/admin/router/save`, body);
|
||||||
|
}
|
||||||
|
/** 启用/停用注册(停用后发布校验拦截新引用) */
|
||||||
|
routerStatus(id: number | string, enabled: boolean) {
|
||||||
|
return post(`${ROOT}/admin/router/status/${id}?enabled=${enabled}`);
|
||||||
|
}
|
||||||
|
/** 重跑路由(判断失败挂起的恢复入口:重新调判断,成功即按出口续走) */
|
||||||
|
instanceReroute(instanceId: number | string) {
|
||||||
|
return post(`${ROOT}/admin/instance/${instanceId}/reroute`);
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 管理面:办件查询 ====================
|
// ==================== 管理面:办件查询 ====================
|
||||||
adminInstances(params: { status?: number; keyword?: string; page?: number; size?: number }) {
|
adminInstances(params: { status?: number; keyword?: string; page?: number; size?: number }) {
|
||||||
return get(`${ROOT}/admin/instance/page`, params);
|
return get(`${ROOT}/admin/instance/page`, params);
|
||||||
|
|||||||
@@ -77,6 +77,13 @@ const routes: RouteRecordRaw[] = [
|
|||||||
component: () => import("@/views/flow/admin/Callbacks.vue"),
|
component: () => import("@/views/flow/admin/Callbacks.vue"),
|
||||||
meta: { title: "回调管理", modId: 105 },
|
meta: { title: "回调管理", modId: 105 },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// 条件路由维护(flw_router 注册表,2026-09-24):菜单需在 UserBase 侧配置同 path 的 app_module
|
||||||
|
path: "/admin/routers",
|
||||||
|
name: "flow_admin_routers",
|
||||||
|
component: () => import("@/views/flow/admin/Routers.vue"),
|
||||||
|
meta: { title: "条件路由", modId: 106 },
|
||||||
|
},
|
||||||
{ path: "/", redirect: "/workbench" },
|
{ path: "/", redirect: "/workbench" },
|
||||||
// 兜底:UserMsg jumpPath / bizUrl 等外部深链若落到未登记路由,避免 no-match 静默空白页
|
// 兜底:UserMsg jumpPath / bizUrl 等外部深链若落到未登记路由,避免 no-match 静默空白页
|
||||||
{ path: "/:pathMatch(.*)*", redirect: "/workbench" },
|
{ path: "/:pathMatch(.*)*", redirect: "/workbench" },
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ export const NODE_BOX: Record<string, { w: number; h: number }> = {
|
|||||||
START: { w: 104, h: 40 },
|
START: { w: 104, h: 40 },
|
||||||
END: { w: 104, h: 40 },
|
END: { w: 104, h: 40 },
|
||||||
APPROVAL: { w: 184, h: 64 },
|
APPROVAL: { w: 184, h: 64 },
|
||||||
|
NOTIFY: { w: 184, h: 64 },
|
||||||
PARALLEL_SPLIT: { w: 128, h: 72 },
|
PARALLEL_SPLIT: { w: 128, h: 72 },
|
||||||
PARALLEL_JOIN: { w: 128, h: 72 },
|
PARALLEL_JOIN: { w: 128, h: 72 },
|
||||||
|
CONDITION: { w: 128, h: 72 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Pos {
|
export interface Pos {
|
||||||
|
|||||||
@@ -403,6 +403,33 @@ watch(() => route.query, () => load());
|
|||||||
<text :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + pos(n).h / 2 + 5"
|
<text :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + pos(n).h / 2 + 5"
|
||||||
text-anchor="middle" fill="#e46a0a" font-size="12">{{ n.label }}</text>
|
text-anchor="middle" fill="#e46a0a" font-size="12">{{ n.label }}</text>
|
||||||
</g>
|
</g>
|
||||||
|
<!-- CONDITION 条件分支:紫菱形(与服务端 PNG 出图同配色;挂起=active 红框) -->
|
||||||
|
<g v-else-if="n.type === 'CONDITION'">
|
||||||
|
<title>{{ n.label }}{{ n.subLabel ? '\n' + n.subLabel : '' }}</title>
|
||||||
|
<polygon :points="diamondPoints(n)"
|
||||||
|
fill="#f9f0ff"
|
||||||
|
:stroke="nodeState(n) === 'active' ? '#ed4014' : '#9254de'"
|
||||||
|
:stroke-width="nodeState(n) === 'active' ? 2.5 : 1.5" />
|
||||||
|
<text :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + pos(n).h / 2 + 5"
|
||||||
|
text-anchor="middle" fill="#722ed1" font-size="12">{{ trunc(n.label, 8) }}</text>
|
||||||
|
</g>
|
||||||
|
<!-- NOTIFY 抄送:青框卡片两行文本(与服务端 PNG 出图同配色) -->
|
||||||
|
<g v-else-if="n.type === 'NOTIFY'">
|
||||||
|
<title>{{ n.label }}{{ n.subLabel ? '\n' + n.subLabel : '' }}</title>
|
||||||
|
<rect :x="pos(n).x" :y="pos(n).y" :width="pos(n).w" :height="pos(n).h"
|
||||||
|
rx="8"
|
||||||
|
fill="#f0f9ff"
|
||||||
|
:stroke="STATE_STROKE[nodeState(n) || ''] || '#13a8a8'"
|
||||||
|
:stroke-width="nodeState(n) === 'active' ? 2.5 : 1.5" />
|
||||||
|
<text :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + 27"
|
||||||
|
text-anchor="middle" fill="#13a8a8" font-size="13" font-weight="600">
|
||||||
|
{{ trunc(n.label, 10) }}
|
||||||
|
</text>
|
||||||
|
<text v-if="n.subLabel" :x="pos(n).x + pos(n).w / 2" :y="pos(n).y + 47"
|
||||||
|
text-anchor="middle" fill="#808695" font-size="11">
|
||||||
|
{{ trunc(n.subLabel, 15) }}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
<!-- APPROVAL:圆角卡片两行文本 -->
|
<!-- APPROVAL:圆角卡片两行文本 -->
|
||||||
<g v-else>
|
<g v-else>
|
||||||
<title>{{ n.label }}{{ n.subLabel ? '\n' + n.subLabel : '' }}</title>
|
<title>{{ n.label }}{{ n.subLabel ? '\n' + n.subLabel : '' }}</title>
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ import { flowApi, TASK_STATUS, TASK_STATUS_COLOR, INSTANCE_STATUS } from "@/api/
|
|||||||
import Utils from "@/utils";
|
import Utils from "@/utils";
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const tab = ref<"todo" | "done">("todo");
|
const tab = ref<"todo" | "done" | "cc">("todo");
|
||||||
const todoTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
const todoTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
const doneTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
const doneTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
|
const ccTableRef = ref<InstanceType<typeof TablePageAlovaNew> | null>(null);
|
||||||
// tabs 页:表格高度在常规骨架上再扣 Tab 条
|
// tabs 页:表格高度在常规骨架上再扣 Tab 条
|
||||||
const tableHeight = ref(Utils.useTableHeight({ isTabs: true, isOperat: true, isPage: true }));
|
const tableHeight = ref(Utils.useTableHeight({ isTabs: true, isOperat: true, isPage: true }));
|
||||||
|
|
||||||
@@ -48,22 +49,39 @@ const doneColumns = [
|
|||||||
Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.instanceId}?pid=100`)) },
|
Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.instanceId}?pid=100`)) },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 抄送行是 NOTIFY 知会(非任务):无 taskId/status,直接跳办件详情
|
||||||
|
const ccColumns = [
|
||||||
|
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||||
|
{ title: "办件标题", key: "bizTitle", minWidth: 220 },
|
||||||
|
{ title: "抄送节点", key: "nodeName", width: 160 },
|
||||||
|
{ title: "抄送时间", width: 170, render: (h: any, p: any) => h("span", (p.row.sentTime || "").replace("T", " ")) },
|
||||||
|
{ title: "办件状态", width: 100, render: (h: any, p: any) => h("span", INSTANCE_STATUS[p.row.instanceStatus] || p.row.instanceStatus || "-") },
|
||||||
|
{ title: "操作", width: 100, align: "center", fixed: "right", render: (h: any, p: any) =>
|
||||||
|
Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.instanceId}?pid=100`)) },
|
||||||
|
];
|
||||||
|
|
||||||
// dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
|
// dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
|
||||||
const todoApi = (p: { pageIndex: number; pageSize: number }) => flowApi.todo({ page: p.pageIndex, size: p.pageSize });
|
const todoApi = (p: { pageIndex: number; pageSize: number }) => flowApi.todo({ page: p.pageIndex, size: p.pageSize });
|
||||||
const doneApi = (p: { pageIndex: number; pageSize: number }) => flowApi.done({ page: p.pageIndex, size: p.pageSize });
|
const doneApi = (p: { pageIndex: number; pageSize: number }) => flowApi.done({ page: p.pageIndex, size: p.pageSize });
|
||||||
|
const ccApi = (p: { pageIndex: number; pageSize: number }) => flowApi.ccMy({ page: p.pageIndex, size: p.pageSize });
|
||||||
|
|
||||||
function goHandle(row: any) {
|
function goHandle(row: any) {
|
||||||
router.push(`/task/${row.taskId}?pid=100`);
|
router.push(`/task/${row.taskId}?pid=100`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 两个内容区 v-show 共存,已办首次切换才拉取(保持原惰性加载行为)
|
// 两个内容区 v-show 共存,已办/抄送首次切换才拉取(保持原惰性加载行为)
|
||||||
const doneLoaded = ref(false);
|
const doneLoaded = ref(false);
|
||||||
|
const ccLoaded = ref(false);
|
||||||
function switchTab(name: string) {
|
function switchTab(name: string) {
|
||||||
tab.value = name as any;
|
tab.value = name as any;
|
||||||
if (name === "done" && !doneLoaded.value) {
|
if (name === "done" && !doneLoaded.value) {
|
||||||
doneLoaded.value = true;
|
doneLoaded.value = true;
|
||||||
doneTableRef.value?.reloadData(OperatorEnum.sel);
|
doneTableRef.value?.reloadData(OperatorEnum.sel);
|
||||||
}
|
}
|
||||||
|
if (name === "cc" && !ccLoaded.value) {
|
||||||
|
ccLoaded.value = true;
|
||||||
|
ccTableRef.value?.reloadData(OperatorEnum.sel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -72,6 +90,7 @@ function switchTab(name: string) {
|
|||||||
<Tabs type="card" :value="tab" @on-click="switchTab">
|
<Tabs type="card" :value="tab" @on-click="switchTab">
|
||||||
<TabPane label="我的待办" name="todo" />
|
<TabPane label="我的待办" name="todo" />
|
||||||
<TabPane label="我的已办" name="done" />
|
<TabPane label="我的已办" name="done" />
|
||||||
|
<TabPane label="抄送我的" name="cc" />
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<div class="main-container" v-show="tab === 'todo'">
|
<div class="main-container" v-show="tab === 'todo'">
|
||||||
@@ -80,5 +99,8 @@ function switchTab(name: string) {
|
|||||||
<div class="main-container" v-show="tab === 'done'">
|
<div class="main-container" v-show="tab === 'done'">
|
||||||
<TablePageAlovaNew ref="doneTableRef" :dataApi="doneApi" :columns="doneColumns" :tableHeight="tableHeight" :autoLoad="false" />
|
<TablePageAlovaNew ref="doneTableRef" :dataApi="doneApi" :columns="doneColumns" :tableHeight="tableHeight" :autoLoad="false" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="main-container" v-show="tab === 'cc'">
|
||||||
|
<TablePageAlovaNew ref="ccTableRef" :dataApi="ccApi" :columns="ccColumns" :tableHeight="tableHeight" :autoLoad="false" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 办件记录(管理端):全校办件查询
|
// 办件记录(管理端):全校办件查询;CONDITION 判断失败挂起的办件在此"重跑路由"恢复
|
||||||
import { ref } from "vue";
|
import { ref } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
|
import { OperatorEnum } from "@/types";
|
||||||
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
|
import TablePageAlovaNew from "@/components/TablePageAlovaNew.vue";
|
||||||
import { flowApi, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
|
import { flowApi, INSTANCE_STATUS, INSTANCE_STATUS_COLOR } from "@/api/module/flow";
|
||||||
import Utils from "@/utils";
|
import Utils from "@/utils";
|
||||||
@@ -19,10 +22,30 @@ const columns = [
|
|||||||
h("Tag", { color: INSTANCE_STATUS_COLOR[p.row.status] || "gray" }, () => INSTANCE_STATUS[p.row.status] || p.row.status) },
|
h("Tag", { color: INSTANCE_STATUS_COLOR[p.row.status] || "gray" }, () => INSTANCE_STATUS[p.row.status] || p.row.status) },
|
||||||
{ title: "提交人", key: "submitterName", width: 100 },
|
{ title: "提交人", key: "submitterName", width: 100 },
|
||||||
{ title: "提交时间", width: 160, render: (h: any, p: any) => h("span", (p.row.submitTime || "").replace("T", " ")) },
|
{ title: "提交时间", width: 160, render: (h: any, p: any) => h("span", (p.row.submitTime || "").replace("T", " ")) },
|
||||||
{ title: "操作", width: 100, align: "center", fixed: "right", render: (h: any, p: any) =>
|
{ title: "操作", width: 170, align: "center", fixed: "right", render: (h: any, p: any) => [
|
||||||
Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.id}?pid=102`)) },
|
Utils.operatingHandle(h, "详情", "icon-browse", () => router.push(`/instance/${p.row.id}?pid=102`)),
|
||||||
|
h("span", { style: "display:inline-block;width:10px" }),
|
||||||
|
// 仅审批中的办件可能挂在条件分支节点(重跑入口对非挂起办件会得到业务错提示)
|
||||||
|
p.row.status === 10
|
||||||
|
? Utils.operatingHandle(h, "重跑路由", "icon-play", () => onReroute(p.row))
|
||||||
|
: h("span"),
|
||||||
|
] },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 重跑路由(2026-09-24 §4.4):重新调判断服务,成功即按出口续走;仍失败回业务错(办件继续挂起) */
|
||||||
|
function onReroute(row: any) {
|
||||||
|
MyConfirm({
|
||||||
|
title: "重跑路由",
|
||||||
|
msg: `确定对办件「${row.bizTitle || row.id}」重跑条件路由判断吗?`,
|
||||||
|
info: "仅对挂在条件分支节点的挂起办件生效(未挂起的会提示无需重跑)",
|
||||||
|
async onOk() {
|
||||||
|
await flowApi.instanceReroute(row.id).send();
|
||||||
|
Message.success("路由已重跑,流程按新判断结果继续");
|
||||||
|
tableRef.value?.reloadData(OperatorEnum.edit);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
|
// dataApi 返回 Method(alova usePagination 负责发送与解包),不能调 .send()
|
||||||
const tableApi = (p: { pageIndex: number; pageSize: number }) => {
|
const tableApi = (p: { pageIndex: number; pageSize: number }) => {
|
||||||
const params: any = { page: p.pageIndex, size: p.pageSize };
|
const params: any = { page: p.pageIndex, size: p.pageSize };
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 条件路由维护(2026-09-24 二期第二批 §4.2):flw_router 判断服务注册表(全局表)。
|
||||||
|
// CONDITION 节点的 routerKey 必须命中启用中的注册(发布校验口径);basePath 是对端 context-path,
|
||||||
|
// 漏填必 401(规范 §4.2 同款坑)——生产 notice 为 /notice,本地为空。
|
||||||
|
import { ref, onMounted } from "vue";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
|
import MyConfirm from "@/components/MyConfirm";
|
||||||
|
import { flowApi } from "@/api/module/flow";
|
||||||
|
import Utils from "@/utils";
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const rows = ref<any[]>([]);
|
||||||
|
const tableHeight = ref(Utils.useTableHeight({ isSearch: true, isOperat: true }));
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: "序号", type: "index", width: 70, align: "center" },
|
||||||
|
{ title: "注册键", key: "routerKey", width: 220 },
|
||||||
|
{ title: "名称", key: "name", width: 160 },
|
||||||
|
{ title: "服务目录键", key: "serviceKey", width: 110 },
|
||||||
|
{ title: "判断端点", key: "path", minWidth: 300 },
|
||||||
|
{
|
||||||
|
title: "basePath",
|
||||||
|
width: 110,
|
||||||
|
render: (h: any, p: any) => h("span", p.row.basePath || "(空=沿用目录)"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "状态",
|
||||||
|
width: 90,
|
||||||
|
align: "center",
|
||||||
|
render: (h: any, p: any) =>
|
||||||
|
h("Tag", { color: p.row.status === 1 ? "green" : "gray" }, () => (p.row.status === 1 ? "启用" : "停用")),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "操作",
|
||||||
|
width: 150,
|
||||||
|
align: "center",
|
||||||
|
fixed: "right",
|
||||||
|
render: (h: any, p: any) => [
|
||||||
|
Utils.operatingHandle(h, "编辑", "icon-edit", () => onEdit(p.row)),
|
||||||
|
h("span", { style: "display:inline-block;width:10px" }),
|
||||||
|
p.row.status === 1
|
||||||
|
? Utils.operatingHandle(h, "停用", "icon-stop", () => onDisable(p.row))
|
||||||
|
: Utils.operatingHandle(h, "启用", "icon-play", () => onEnable(p.row)),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
rows.value = (await flowApi.routerList().send()) || [];
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 新增/编辑弹窗 ----
|
||||||
|
const saveVisible = ref(false);
|
||||||
|
const saving = ref(false);
|
||||||
|
const formRef = ref<any>(null);
|
||||||
|
const form = ref<any>({ id: null, routerKey: "", name: "", serviceKey: "", path: "", basePath: "" });
|
||||||
|
const saveRules = {
|
||||||
|
routerKey: [{ required: true, message: "请填写红色星号内容!", trigger: "blur" }],
|
||||||
|
name: [{ required: true, message: "请填写红色星号内容!", trigger: "blur" }],
|
||||||
|
serviceKey: [{ required: true, message: "请填写红色星号内容!", trigger: "blur" }],
|
||||||
|
path: [{ required: true, message: "请填写红色星号内容!", trigger: "blur" }],
|
||||||
|
};
|
||||||
|
|
||||||
|
function onAdd() {
|
||||||
|
form.value = { id: null, routerKey: "", name: "", serviceKey: "notice", path: "", basePath: "" };
|
||||||
|
saveVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEdit(row: any) {
|
||||||
|
form.value = {
|
||||||
|
id: row.id,
|
||||||
|
routerKey: row.routerKey,
|
||||||
|
name: row.name,
|
||||||
|
serviceKey: row.serviceKey,
|
||||||
|
path: row.path,
|
||||||
|
basePath: row.basePath || "",
|
||||||
|
};
|
||||||
|
saveVisible.value = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSave() {
|
||||||
|
formRef.value?.validate(async (valid: boolean) => {
|
||||||
|
if (!valid) return;
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await flowApi.routerSave({
|
||||||
|
id: form.value.id || undefined,
|
||||||
|
routerKey: form.value.routerKey.trim(),
|
||||||
|
name: form.value.name.trim(),
|
||||||
|
serviceKey: form.value.serviceKey.trim(),
|
||||||
|
path: form.value.path.trim(),
|
||||||
|
basePath: form.value.basePath.trim(),
|
||||||
|
}).send();
|
||||||
|
Message.success("已保存");
|
||||||
|
saveVisible.value = false;
|
||||||
|
load();
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDisable(row: any) {
|
||||||
|
MyConfirm({
|
||||||
|
title: "停用",
|
||||||
|
msg: `确定停用判断服务「${row.name || row.routerKey}」吗?`,
|
||||||
|
info: "停用后引用它的流程定义不能发布;在途挂起办件的重跑会失败",
|
||||||
|
async onOk() {
|
||||||
|
await flowApi.routerStatus(row.id, false).send();
|
||||||
|
Message.success("已停用");
|
||||||
|
load();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function onEnable(row: any) {
|
||||||
|
MyConfirm({
|
||||||
|
title: "启用",
|
||||||
|
msg: `确定启用判断服务「${row.name || row.routerKey}」吗?`,
|
||||||
|
info: "启用后流程编辑器的判断服务下拉可选它,发布校验放行引用",
|
||||||
|
async onOk() {
|
||||||
|
await flowApi.routerStatus(row.id, true).send();
|
||||||
|
Message.success("已启用");
|
||||||
|
load();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="main-container">
|
||||||
|
<div class="fix mb-12">
|
||||||
|
<Button class="fl" type="primary" size="large" @click="onAdd">新增注册</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="search-wrap fix">
|
||||||
|
<span class="fr" style="color: #808695; font-size: 12px; line-height: 32px">
|
||||||
|
判断逻辑由业务方实现:引擎运行到 CONDITION 节点时按注册键同步调用(3s×3 重试),
|
||||||
|
失败挂起、管理端办件记录里重跑路由
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Table :columns="columns" :data="rows" :loading="loading" class="link-table table-bottom-box"
|
||||||
|
:height="tableHeight" no-data-text="暂无数据" />
|
||||||
|
|
||||||
|
<Modal v-model="saveVisible" title="新增/编辑判断服务" :mask-closable="false">
|
||||||
|
<Form ref="formRef" :model="form" :rules="saveRules" :label-width="100">
|
||||||
|
<FormItem label="注册键" prop="routerKey">
|
||||||
|
<Input v-model="form.routerKey" placeholder="CONDITION 节点 routerKey 引用它,如 notice.dept-judge" :maxlength="64" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="名称" prop="name">
|
||||||
|
<Input v-model="form.name" placeholder="展示名(编辑器下拉),如 发文部门判断" :maxlength="128" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="服务目录键" prop="serviceKey">
|
||||||
|
<Input v-model="form.serviceKey" placeholder="服务目录三级解析的 key,如 notice" :maxlength="64" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="判断端点" prop="path">
|
||||||
|
<Input v-model="form.path" placeholder="以 / 开头,如 /oapi/notice/flow/route/dept-judge" :maxlength="255" />
|
||||||
|
</FormItem>
|
||||||
|
<FormItem label="basePath">
|
||||||
|
<Input v-model="form.basePath" placeholder="对端 context-path(生产 /notice;本地无则留空)" :maxlength="64" />
|
||||||
|
<p style="color: #808695; font-size: 12px; line-height: 1.6; margin-top: 4px">
|
||||||
|
签名拼串要剥它,漏填必 401;留空=沿用服务目录条目自带的前缀
|
||||||
|
</p>
|
||||||
|
</FormItem>
|
||||||
|
</Form>
|
||||||
|
<template #footer>
|
||||||
|
<Button @click="saveVisible = false">取消</Button>
|
||||||
|
<Button type="primary" :loading="saving" @click="onSave">提交</Button>
|
||||||
|
</template>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -18,10 +18,13 @@ const TRANSLATIONS: Record<string, string> = {
|
|||||||
"Create EndEvent": "创建结束",
|
"Create EndEvent": "创建结束",
|
||||||
"Create Task": "创建审批节点",
|
"Create Task": "创建审批节点",
|
||||||
"Create User Task": "创建审批节点",
|
"Create User Task": "创建审批节点",
|
||||||
|
"Create Service Task": "创建抄送节点",
|
||||||
"Create Gateway": "创建并行分支",
|
"Create Gateway": "创建并行分支",
|
||||||
"Create Gateways": "创建并行分支",
|
"Create Gateways": "创建并行分支",
|
||||||
"Create ParallelGateway": "创建并行分支",
|
"Create ParallelGateway": "创建并行分支",
|
||||||
"Create expanded ParallelGateway": "创建并行分支",
|
"Create expanded ParallelGateway": "创建并行分支",
|
||||||
|
"Create ExclusiveGateway": "创建条件分支",
|
||||||
|
"Create expanded ExclusiveGateway": "创建条件分支",
|
||||||
"Append anything": "追加",
|
"Append anything": "追加",
|
||||||
"Change type": "变更类型",
|
"Change type": "变更类型",
|
||||||
"Remove element": "删除",
|
"Remove element": "删除",
|
||||||
@@ -89,7 +92,9 @@ function createPaletteModule() {
|
|||||||
"lc-start": action("bpmn:StartEvent", "创建开始", "bpmn-icon-start-event-none"),
|
"lc-start": action("bpmn:StartEvent", "创建开始", "bpmn-icon-start-event-none"),
|
||||||
"lc-end": action("bpmn:EndEvent", "创建结束", "bpmn-icon-end-event-none"),
|
"lc-end": action("bpmn:EndEvent", "创建结束", "bpmn-icon-end-event-none"),
|
||||||
"lc-task": action("bpmn:UserTask", "创建审批节点", "bpmn-icon-user-task"),
|
"lc-task": action("bpmn:UserTask", "创建审批节点", "bpmn-icon-user-task"),
|
||||||
|
"lc-notify": action("bpmn:ServiceTask", "创建抄送节点", "bpmn-icon-send-task"),
|
||||||
"lc-gateway": action("bpmn:ParallelGateway", "创建并行分支", "bpmn-icon-gateway-parallel"),
|
"lc-gateway": action("bpmn:ParallelGateway", "创建并行分支", "bpmn-icon-gateway-parallel"),
|
||||||
|
"lc-condition": action("bpmn:ExclusiveGateway", "创建条件分支", "bpmn-icon-gateway-xor"),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
return { __init__: ["paletteProvider"], paletteProvider: ["type", CustomPaletteProvider] };
|
return { __init__: ["paletteProvider"], paletteProvider: ["type", CustomPaletteProvider] };
|
||||||
|
|||||||
@@ -29,8 +29,10 @@ const BPMN_TYPE: Record<string, string> = {
|
|||||||
START: "bpmn:StartEvent",
|
START: "bpmn:StartEvent",
|
||||||
END: "bpmn:EndEvent",
|
END: "bpmn:EndEvent",
|
||||||
APPROVAL: "bpmn:UserTask",
|
APPROVAL: "bpmn:UserTask",
|
||||||
|
NOTIFY: "bpmn:ServiceTask",
|
||||||
PARALLEL_SPLIT: "bpmn:ParallelGateway",
|
PARALLEL_SPLIT: "bpmn:ParallelGateway",
|
||||||
PARALLEL_JOIN: "bpmn:ParallelGateway",
|
PARALLEL_JOIN: "bpmn:ParallelGateway",
|
||||||
|
CONDITION: "bpmn:ExclusiveGateway",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 把 contentJson 画到画布上(缺 layout 时自动布局) */
|
/** 把 contentJson 画到画布上(缺 layout 时自动布局) */
|
||||||
@@ -95,10 +97,17 @@ export function fromBpmn(modeler: any, base: CanvasJson, code: string, name: str
|
|||||||
case "bpmn:UserTask": {
|
case "bpmn:UserTask": {
|
||||||
return "APPROVAL";
|
return "APPROVAL";
|
||||||
}
|
}
|
||||||
|
case "bpmn:ServiceTask": {
|
||||||
|
return "NOTIFY";
|
||||||
|
}
|
||||||
case "bpmn:ParallelGateway": {
|
case "bpmn:ParallelGateway": {
|
||||||
// 读出推断:>1 入 = 汇聚;否则分发(存储在 contentJson,故多数载入已在 JSON 有显式类型)
|
// 读出推断:>1 入 = 汇聚;否则分发(存储在 contentJson,故多数载入已在 JSON 有显式类型)
|
||||||
return (n.incoming || []).length > 1 ? "PARALLEL_JOIN" : "PARALLEL_SPLIT";
|
return (n.incoming || []).length > 1 ? "PARALLEL_JOIN" : "PARALLEL_SPLIT";
|
||||||
}
|
}
|
||||||
|
case "bpmn:ExclusiveGateway": {
|
||||||
|
// CONDITION 无 join 对应物(排他网关不用于汇聚):多入口也按条件分支读出
|
||||||
|
return "CONDITION";
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -113,11 +122,14 @@ export function fromBpmn(modeler: any, base: CanvasJson, code: string, name: str
|
|||||||
type,
|
type,
|
||||||
};
|
};
|
||||||
if (biz.name) node.name = biz.name;
|
if (biz.name) node.name = biz.name;
|
||||||
if (type === "APPROVAL" && base) {
|
if (type === "APPROVAL" || type === "NOTIFY" || type === "CONDITION") {
|
||||||
// 审批人等业务属性:从 base 中同 id 继承(编辑连线不影响已有配置)
|
// 审批人/抄送对象/判断服务等业务属性:从 base 中同 id 继承(编辑连线不影响已有配置)
|
||||||
const prev = base.nodes.find((x) => String(x.id) === String(node.id));
|
const prev = base.nodes.find((x) => String(x.id) === String(node.id));
|
||||||
if (prev) {
|
if (prev) {
|
||||||
["assignee", "countersign", "deadlineHours"].forEach((k) => {
|
const keys = type === "APPROVAL"
|
||||||
|
? ["assignee", "countersign", "deadlineHours"]
|
||||||
|
: type === "NOTIFY" ? ["receivers"] : ["routerKey"];
|
||||||
|
keys.forEach((k) => {
|
||||||
if (prev[k] !== undefined) node[k] = prev[k];
|
if (prev[k] !== undefined) node[k] = prev[k];
|
||||||
});
|
});
|
||||||
if (!node.name && prev.name) node.name = prev.name;
|
if (!node.name && prev.name) node.name = prev.name;
|
||||||
@@ -168,8 +180,10 @@ const DI_SIZE: Record<string, { w: number; h: number }> = {
|
|||||||
START: { w: 36, h: 36 },
|
START: { w: 36, h: 36 },
|
||||||
END: { w: 36, h: 36 },
|
END: { w: 36, h: 36 },
|
||||||
APPROVAL: { w: 150, h: 76 },
|
APPROVAL: { w: 150, h: 76 },
|
||||||
|
NOTIFY: { w: 150, h: 76 },
|
||||||
PARALLEL_SPLIT: { w: 50, h: 50 },
|
PARALLEL_SPLIT: { w: 50, h: 50 },
|
||||||
PARALLEL_JOIN: { w: 50, h: 50 },
|
PARALLEL_JOIN: { w: 50, h: 50 },
|
||||||
|
CONDITION: { w: 50, h: 50 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 条件分支容器(2026-09-24):紫系区分并行橙;与 ParallelBox 的本质差异——无 JOIN 语义,
|
||||||
|
// 分支按出口名互斥选路(运行时调判断服务),分支尾隐式汇合到容器后继。
|
||||||
|
// 分支头部的出口名 Input 即 contentJson 里 CONDITION 命名出边的 name(判断接口 outlets 契约)。
|
||||||
|
import { computed, inject } from "vue";
|
||||||
|
import { BRANCH_LABELS, EDITOR_CTX, MAX_BRANCHES, MIN_BRANCHES, ConditionNode } from "../model";
|
||||||
|
import NodeList from "./NodeList.vue";
|
||||||
|
|
||||||
|
const props = defineProps<{ node: ConditionNode }>();
|
||||||
|
|
||||||
|
const ctx = inject(EDITOR_CTX)!;
|
||||||
|
|
||||||
|
const issues = computed(() => ctx.issueOf(props.node.uid));
|
||||||
|
const hasError = computed(() => issues.value.some((i) => i.level === "error"));
|
||||||
|
const canRemoveBranch = computed(() => props.node.branches.length > MIN_BRANCHES);
|
||||||
|
const canAddBranch = computed(() => props.node.branches.length < MAX_BRANCHES);
|
||||||
|
|
||||||
|
const routerLabel = computed(() => {
|
||||||
|
if (!props.node.routerKey) return "判断服务:未选择";
|
||||||
|
const r = ctx.routers.find((x) => x.routerKey === props.node.routerKey);
|
||||||
|
return r ? `判断服务:${r.name}(${props.node.routerKey})` : `判断服务:${props.node.routerKey}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 出口名就地编辑(重复/空由校验提示;与 branches 下标一一对应) */
|
||||||
|
function renameOutlet(i: number, v: string): void {
|
||||||
|
props.node.outlets[i] = v;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="condition-box" :class="{ error: hasError }">
|
||||||
|
<div class="cb-title" @click="ctx.openNode(node.uid)">
|
||||||
|
条件分支(按出口互斥选路)
|
||||||
|
<span class="cb-hint">(点击标题配置判断服务)</span>
|
||||||
|
</div>
|
||||||
|
<div class="cb-router">{{ routerLabel }}</div>
|
||||||
|
<div class="cb-cols">
|
||||||
|
<div v-for="(branch, i) in node.branches" :key="i" class="cb-col">
|
||||||
|
<div class="cb-col-head">
|
||||||
|
<span class="cb-col-no">{{ BRANCH_LABELS[i] || i + 1 }}</span>
|
||||||
|
<input
|
||||||
|
class="cb-outlet"
|
||||||
|
:value="node.outlets[i]"
|
||||||
|
placeholder="出口名(必填唯一)"
|
||||||
|
maxlength="32"
|
||||||
|
@input="renameOutlet(i, ($event.target as HTMLInputElement).value)"
|
||||||
|
/>
|
||||||
|
<Poptip
|
||||||
|
v-if="canRemoveBranch"
|
||||||
|
confirm
|
||||||
|
title="删除该分支及其节点?"
|
||||||
|
ok-text="删除"
|
||||||
|
cancel-text="取消"
|
||||||
|
transfer
|
||||||
|
@on-ok="ctx.removeBranch(node.uid, i)"
|
||||||
|
>
|
||||||
|
<span class="cb-col-del" title="删除分支">×</span>
|
||||||
|
</Poptip>
|
||||||
|
</div>
|
||||||
|
<NodeList :items="branch" :container="`${node.uid}:${i}`" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button v-if="canAddBranch" class="cb-add" size="small" long @click="ctx.addBranch(node.uid)">
|
||||||
|
+ 添加分支
|
||||||
|
</Button>
|
||||||
|
<div v-for="(issue, k) in issues" :key="k" class="cb-issue" :class="issue.level">⚠ {{ issue.text }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.condition-box {
|
||||||
|
border: 1.5px dashed #b37feb;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 14px 14px 12px;
|
||||||
|
background: rgba(249, 240, 255, 0.45);
|
||||||
|
}
|
||||||
|
.condition-box.error {
|
||||||
|
border-color: #ed4014;
|
||||||
|
}
|
||||||
|
.cb-title {
|
||||||
|
text-align: center;
|
||||||
|
color: #722ed1;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.cb-title:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.cb-hint {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 11px;
|
||||||
|
color: #b37feb;
|
||||||
|
}
|
||||||
|
.cb-router {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #9254de;
|
||||||
|
margin: 6px 0 10px;
|
||||||
|
}
|
||||||
|
.cb-cols {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.cb-col {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 10px 14px;
|
||||||
|
min-width: 230px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.cb-col-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
.cb-col-no {
|
||||||
|
flex: none;
|
||||||
|
color: #9254de;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.cb-outlet {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid #e3e5ee;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #722ed1;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.cb-outlet:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #9254de;
|
||||||
|
}
|
||||||
|
.cb-col-del {
|
||||||
|
flex: none;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #c5c9d1;
|
||||||
|
font-size: 16px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.cb-col-del:hover {
|
||||||
|
color: #ed4014;
|
||||||
|
}
|
||||||
|
.cb-add {
|
||||||
|
margin-top: 12px;
|
||||||
|
border-style: dashed;
|
||||||
|
color: #9254de;
|
||||||
|
border-color: #9254de;
|
||||||
|
background: transparent;
|
||||||
|
width: auto;
|
||||||
|
padding: 0 16px;
|
||||||
|
display: block;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
.cb-add:hover {
|
||||||
|
background: #f9f0ff;
|
||||||
|
color: #722ed1;
|
||||||
|
border-color: #9254de;
|
||||||
|
}
|
||||||
|
.cb-issue {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
.cb-issue.error {
|
||||||
|
color: #ed4014;
|
||||||
|
}
|
||||||
|
.cb-issue.warn {
|
||||||
|
color: #ff9900;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 条件分支配置抽屉(2026-09-24):结构与 NotifyDrawer 同源(本地表单 + 确定回写)。
|
||||||
|
// 只管节点名与判断服务(flw_router 下拉);出口命名/分支编排 ConditionBox 就地做。
|
||||||
|
import { computed, inject, reactive, watch } from "vue";
|
||||||
|
import { Message } from "view-ui-plus";
|
||||||
|
import { ConditionNode, EDITOR_CTX } from "../model";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
node: ConditionNode | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [boolean];
|
||||||
|
/** 节点已删除(由抽屉内删除按钮触发),由外层关闭抽屉并重算校验 */
|
||||||
|
removed: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const ctx = inject(EDITOR_CTX)!;
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: "",
|
||||||
|
routerKey: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
function syncFromNode(): void {
|
||||||
|
const n = props.node;
|
||||||
|
if (!n) return;
|
||||||
|
form.name = n.name;
|
||||||
|
form.routerKey = n.routerKey || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.modelValue, props.node?.uid],
|
||||||
|
() => {
|
||||||
|
if (props.modelValue) syncFromNode();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 判断服务下拉:仅启用中的注册(ctx.routers 由编辑器载入);当前值兜底防选项丢失 */
|
||||||
|
const routerOptions = computed(() => {
|
||||||
|
const list = ctx.routers
|
||||||
|
.filter((r) => r.status === 1)
|
||||||
|
.map((r) => ({ value: r.routerKey, label: `${r.name}(${r.routerKey})` }));
|
||||||
|
if (form.routerKey && !list.some((o) => o.value === form.routerKey)) {
|
||||||
|
list.unshift({ value: form.routerKey, label: `${form.routerKey}(未注册或已停用)` });
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
const routerHint = computed(() => {
|
||||||
|
if (!form.routerKey) return "";
|
||||||
|
const r = ctx.routers.find((x) => x.routerKey === form.routerKey);
|
||||||
|
if (!r) return `判断服务「${form.routerKey}」未注册或已停用,发布会被拦截`;
|
||||||
|
return `调用 ${r.serviceKey}${r.path};返回出口名即分支走向`;
|
||||||
|
});
|
||||||
|
|
||||||
|
function onOk(): void {
|
||||||
|
const n = props.node;
|
||||||
|
if (!n) {
|
||||||
|
emit("update:modelValue", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
Message.warning("节点名称不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.routerKey) {
|
||||||
|
Message.warning("请选择判断服务(未配置的判断服务运行到该节点会挂起)");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
n.name = form.name.trim();
|
||||||
|
n.routerKey = form.routerKey;
|
||||||
|
emit("update:modelValue", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRemove(): void {
|
||||||
|
if (props.node) ctx.removeNode(props.node.uid);
|
||||||
|
emit("removed");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Drawer
|
||||||
|
:model-value="modelValue"
|
||||||
|
title="配置条件分支"
|
||||||
|
:width="430"
|
||||||
|
:mask-closable="false"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<Form :label-width="0" class="dw-form">
|
||||||
|
<div class="fld">
|
||||||
|
<label>节点名称</label>
|
||||||
|
<Input v-model="form.name" placeholder="如:发文部门判断" :maxlength="30" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fld">
|
||||||
|
<label>判断服务</label>
|
||||||
|
<Select v-model="form.routerKey" filterable placeholder="请选择判断服务(flw_router 注册)">
|
||||||
|
<Option v-for="o in routerOptions" :key="o.value" :value="o.value" :label="o.label" />
|
||||||
|
</Select>
|
||||||
|
<div v-if="routerHint" class="hint" :class="{ warn: routerHint.includes('拦截') }">
|
||||||
|
{{ routerHint.includes("拦截") ? "⚠ " : "" }}{{ routerHint }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="hint">
|
||||||
|
判断逻辑由业务方实现:引擎运行到该节点时同步调用判断接口,按返回出口名走对应分支
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fld">
|
||||||
|
<label>出口与分支</label>
|
||||||
|
<div class="hint">
|
||||||
|
出口名在各分支列头部就地编辑(互斥分支,出口名即判断依据,必填且唯一);判断失败流程挂起,
|
||||||
|
由管理端"重跑路由"恢复,不配默认出口
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div class="dw-foot">
|
||||||
|
<Poptip confirm title="删除该条件分支及其全部分支?" ok-text="删除" cancel-text="取消" transfer @on-ok="onRemove">
|
||||||
|
<Button type="error" ghost>删除节点</Button>
|
||||||
|
</Poptip>
|
||||||
|
<span style="flex: 1" />
|
||||||
|
<Button @click="emit('update:modelValue', false)">取消</Button>
|
||||||
|
<Button type="primary" @click="onOk">确定</Button>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dw-form {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.fld {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.fld > label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: #808695;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.hint.warn {
|
||||||
|
color: #ff9900;
|
||||||
|
}
|
||||||
|
.dw-foot {
|
||||||
|
position: sticky;
|
||||||
|
bottom: -16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
// 节点序列渲染单元(方案 §3.2):容器内的"+卡片+"重复模式;并行分支与主流程共用,天然支持嵌套
|
// 节点序列渲染单元(方案 §3.2):容器内的"+卡片+"重复模式;并行/条件分支与主流程共用,天然支持嵌套
|
||||||
import { EditorItem } from "../model";
|
import { EditorItem } from "../model";
|
||||||
import NodeCard from "./NodeCard.vue";
|
import NodeCard from "./NodeCard.vue";
|
||||||
|
import NotifyCard from "./NotifyCard.vue";
|
||||||
import ParallelBox from "./ParallelBox.vue";
|
import ParallelBox from "./ParallelBox.vue";
|
||||||
|
import ConditionBox from "./ConditionBox.vue";
|
||||||
import PlusButton from "./PlusButton.vue";
|
import PlusButton from "./PlusButton.vue";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
items: EditorItem[];
|
items: EditorItem[];
|
||||||
/** 容器标识:root 或 `${parallelUid}:${branchIndex}` */
|
/** 容器标识:root 或 `${容器uid}:${branchIndex}`(并行/条件分支同格式) */
|
||||||
container: string;
|
container: string;
|
||||||
}>();
|
}>();
|
||||||
</script>
|
</script>
|
||||||
@@ -17,7 +19,9 @@ defineProps<{
|
|||||||
<PlusButton :container="container" :index="0" />
|
<PlusButton :container="container" :index="0" />
|
||||||
<template v-for="(item, i) in items" :key="item.uid">
|
<template v-for="(item, i) in items" :key="item.uid">
|
||||||
<NodeCard v-if="item.kind === 'approval'" :node="item" />
|
<NodeCard v-if="item.kind === 'approval'" :node="item" />
|
||||||
<ParallelBox v-else :node="item" />
|
<NotifyCard v-else-if="item.kind === 'notify'" :node="item" />
|
||||||
|
<ParallelBox v-else-if="item.kind === 'parallel'" :node="item" />
|
||||||
|
<ConditionBox v-else :node="item" />
|
||||||
<PlusButton :container="container" :index="i + 1" />
|
<PlusButton :container="container" :index="i + 1" />
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 抄送节点卡片(2026-09-23):青色系区分审批蓝;标题 + 抄送对象摘要 + 就地校验标注;hover 显示删除
|
||||||
|
import { computed, inject } from "vue";
|
||||||
|
import { NotifyNode, EDITOR_CTX, receiversText } from "../model";
|
||||||
|
|
||||||
|
const props = defineProps<{ node: NotifyNode }>();
|
||||||
|
|
||||||
|
const ctx = inject(EDITOR_CTX)!;
|
||||||
|
|
||||||
|
const issues = computed(() => ctx.issueOf(props.node.uid));
|
||||||
|
const hasError = computed(() => issues.value.some((i) => i.level === "error"));
|
||||||
|
const summary = computed(() => receiversText(props.node.receivers, ctx.depts, ctx.userNames));
|
||||||
|
|
||||||
|
// 删除控件在卡片内,点击不应顺带打开配置抽屉(确认气泡已 transfer 到 body,不受影响)
|
||||||
|
function onOpen(e: MouseEvent): void {
|
||||||
|
if ((e.target as HTMLElement).closest(".nc-del")) return;
|
||||||
|
ctx.openNode(props.node.uid);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="notify-card" :class="{ error: hasError }" @click="onOpen">
|
||||||
|
<div class="nc-head">
|
||||||
|
<span class="nc-icon">抄</span>
|
||||||
|
<span class="nc-name">{{ node.name || "未命名节点" }}</span>
|
||||||
|
<!-- view-ui-plus 无 Popconfirm 组件,二次确认用 Poptip 的 confirm 模式 -->
|
||||||
|
<Poptip
|
||||||
|
confirm
|
||||||
|
title="删除该抄送节点?"
|
||||||
|
ok-text="删除"
|
||||||
|
cancel-text="取消"
|
||||||
|
transfer
|
||||||
|
@on-ok="ctx.removeNode(node.uid)"
|
||||||
|
>
|
||||||
|
<span class="nc-del" title="删除节点">×</span>
|
||||||
|
</Poptip>
|
||||||
|
</div>
|
||||||
|
<div class="nc-sub">{{ summary }}</div>
|
||||||
|
<div class="nc-sub last"><span class="tag">知会 · 不产生待办</span></div>
|
||||||
|
<div v-for="(i, k) in issues" :key="k" class="nc-issue" :class="i.level">⚠ {{ i.msg }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notify-card {
|
||||||
|
width: 264px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.notify-card:hover {
|
||||||
|
border-color: #13a8a8;
|
||||||
|
}
|
||||||
|
.notify-card.error {
|
||||||
|
border-color: #ed4014;
|
||||||
|
}
|
||||||
|
.nc-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 14px 2px;
|
||||||
|
}
|
||||||
|
.nc-icon {
|
||||||
|
flex: none;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #e6f6f6;
|
||||||
|
color: #13a8a8;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.nc-name {
|
||||||
|
flex: 1;
|
||||||
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.nc-del {
|
||||||
|
flex: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #fff;
|
||||||
|
background: #c5c9d1;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
.notify-card:hover .nc-del {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
.nc-del:hover {
|
||||||
|
background: #ed4014;
|
||||||
|
}
|
||||||
|
.nc-sub {
|
||||||
|
padding: 4px 16px 0;
|
||||||
|
color: #808695;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
.nc-sub.last {
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
.tag {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 1px 6px;
|
||||||
|
margin-right: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
background: #e6f6f6;
|
||||||
|
color: #13a8a8;
|
||||||
|
}
|
||||||
|
.nc-issue {
|
||||||
|
padding: 4px 16px 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.nc-issue.error {
|
||||||
|
color: #ed4014;
|
||||||
|
}
|
||||||
|
.nc-issue.warn {
|
||||||
|
color: #ff9900;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 抄送节点配置抽屉(2026-09-23):结构与 ApprovalDrawer 同源(本地表单 + 确定回写),
|
||||||
|
// 去掉审批专属的会签/时限;选人同用 UserCheckModal(teacherTree),科室口径同审批人。
|
||||||
|
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 { NotifyNode, EDITOR_CTX } from "../model";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean;
|
||||||
|
node: NotifyNode | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
"update:modelValue": [boolean];
|
||||||
|
/** 节点已删除(由抽屉内删除按钮触发),由外层关闭抽屉并重算校验 */
|
||||||
|
removed: [];
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const ctx = inject(EDITOR_CTX)!;
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: "",
|
||||||
|
type: "DEPT_KEY" as "DEPT_KEY" | "USERS",
|
||||||
|
deptKey: "",
|
||||||
|
userIds: [] as number[],
|
||||||
|
});
|
||||||
|
|
||||||
|
const userCheckModal = ref<any>();
|
||||||
|
const userModalSources = [
|
||||||
|
{
|
||||||
|
name: "基础平台",
|
||||||
|
loader: () => flowApi.adminTeacherTree().send() as Promise<any[]>,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function syncFromNode(): void {
|
||||||
|
const n = props.node;
|
||||||
|
if (!n) return;
|
||||||
|
form.name = n.name;
|
||||||
|
form.type = n.receivers?.type === "USERS" ? "USERS" : "DEPT_KEY";
|
||||||
|
form.deptKey = n.receivers?.type === "DEPT_KEY" ? n.receivers.deptKey : "";
|
||||||
|
form.userIds = n.receivers?.type === "USERS" ? [...n.receivers.userIds] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.modelValue, props.node?.uid],
|
||||||
|
() => {
|
||||||
|
if (props.modelValue) syncFromNode();
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
/** 科室下拉项:mapList 聚合 + 当前值(可能已不在映射表中)兜底,避免选项丢失 */
|
||||||
|
const deptOptions = computed(() => {
|
||||||
|
const list = ctx.depts.map((d) => ({
|
||||||
|
value: d.deptKey,
|
||||||
|
label: `${d.deptName || d.deptKey}(${d.deptKey})· ${d.count} 人`,
|
||||||
|
}));
|
||||||
|
if (form.deptKey && !list.some((o) => o.value === form.deptKey)) {
|
||||||
|
list.unshift({ value: form.deptKey, label: `${form.deptKey}(未配置映射)` });
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 当前科室的映射状态提示(抄送运行时宽容:无人只跳过不阻塞,故仅提示不谈"拦截") */
|
||||||
|
const deptHint = computed(() => {
|
||||||
|
if (form.type !== "DEPT_KEY" || !form.deptKey) return "";
|
||||||
|
const d = ctx.depts.find((x) => x.deptKey === form.deptKey);
|
||||||
|
if (!d) return `科室映射「${form.deptKey}」不存在,发布会被拦截`;
|
||||||
|
if (!d.count) return `科室「${d.deptName || form.deptKey}」无启用人员,抄送会落空(不阻塞流程)`;
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
function userNameOf(id: number): string {
|
||||||
|
return ctx.userNames[id] || `#${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 选人弹窗提交:选中后的 id+name 合入本地 userIds 与姓名快照(不再逐个 byId 检索) */
|
||||||
|
function onUsersPicked(arr: any[]): void {
|
||||||
|
let added = 0;
|
||||||
|
for (const u of arr || []) {
|
||||||
|
if (u?.id == null) continue;
|
||||||
|
const id = Number(u.id);
|
||||||
|
if (!form.userIds.includes(id)) {
|
||||||
|
form.userIds.push(id);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
if (u.name) ctx.userNames[id] = u.name;
|
||||||
|
}
|
||||||
|
Message.success(`已选择 ${added} 名用户`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeUser(id: number): void {
|
||||||
|
const i = form.userIds.indexOf(id);
|
||||||
|
if (i >= 0) form.userIds.splice(i, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onOk(): void {
|
||||||
|
const n = props.node;
|
||||||
|
if (!n) {
|
||||||
|
emit("update:modelValue", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
Message.warning("节点名称不能为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
n.name = form.name.trim();
|
||||||
|
n.receivers =
|
||||||
|
form.type === "USERS"
|
||||||
|
? { type: "USERS", userIds: [...form.userIds] }
|
||||||
|
: { type: "DEPT_KEY", deptKey: form.deptKey };
|
||||||
|
emit("update:modelValue", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRemove(): void {
|
||||||
|
if (props.node) ctx.removeNode(props.node.uid);
|
||||||
|
emit("removed");
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Drawer
|
||||||
|
:model-value="modelValue"
|
||||||
|
title="配置抄送节点"
|
||||||
|
:width="430"
|
||||||
|
:mask-closable="false"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
>
|
||||||
|
<Form :label-width="0" class="dw-form">
|
||||||
|
<div class="fld">
|
||||||
|
<label>节点名称</label>
|
||||||
|
<Input v-model="form.name" placeholder="如:抄送校领导" :maxlength="30" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="fld">
|
||||||
|
<label>抄送对象类型</label>
|
||||||
|
<RadioGroup v-model="form.type" type="button" button-style="solid">
|
||||||
|
<Radio label="DEPT_KEY">按科室映射</Radio>
|
||||||
|
<Radio label="USERS">指定用户</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="form.type === 'DEPT_KEY'" class="fld">
|
||||||
|
<label>科室映射</label>
|
||||||
|
<Select v-model="form.deptKey" filterable placeholder="请选择科室">
|
||||||
|
<Option v-for="o in deptOptions" :key="o.value" :value="o.value" :label="o.label" />
|
||||||
|
</Select>
|
||||||
|
<div v-if="deptHint" class="hint warn">⚠ {{ deptHint }}</div>
|
||||||
|
<div v-else class="hint">科室启用中的全部人员都会收到抄送;运行时无人可抄送时只跳过,不阻塞流程</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="fld">
|
||||||
|
<label>指定用户</label>
|
||||||
|
<div class="user-add">
|
||||||
|
<Button type="primary" @click="userCheckModal?.show()">选择用户</Button>
|
||||||
|
</div>
|
||||||
|
<div class="user-tags">
|
||||||
|
<Tag
|
||||||
|
v-for="id in form.userIds"
|
||||||
|
:key="id"
|
||||||
|
closable
|
||||||
|
color="cyan"
|
||||||
|
@on-close="removeUser(id)"
|
||||||
|
>
|
||||||
|
{{ userNameOf(id) }}
|
||||||
|
</Tag>
|
||||||
|
<span v-if="!form.userIds.length" class="hint">尚未添加用户</span>
|
||||||
|
</div>
|
||||||
|
<div class="hint">抄送是知会:每人一条站内通知,不产生待办、不需要办理</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Form>
|
||||||
|
|
||||||
|
<div class="dw-foot">
|
||||||
|
<Poptip confirm title="删除该抄送节点?" ok-text="删除" cancel-text="取消" transfer @on-ok="onRemove">
|
||||||
|
<Button type="error" ghost>删除节点</Button>
|
||||||
|
</Poptip>
|
||||||
|
<span style="flex: 1" />
|
||||||
|
<Button @click="emit('update:modelValue', false)">取消</Button>
|
||||||
|
<Button type="primary" @click="onOk">确定</Button>
|
||||||
|
</div>
|
||||||
|
</Drawer>
|
||||||
|
|
||||||
|
<UserCheckModal
|
||||||
|
ref="userCheckModal"
|
||||||
|
:sources="userModalSources"
|
||||||
|
:userids="form.userIds"
|
||||||
|
@on-submit="onUsersPicked"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dw-form {
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.fld {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.fld > label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.hint {
|
||||||
|
color: #808695;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin-top: 5px;
|
||||||
|
}
|
||||||
|
.hint.warn {
|
||||||
|
color: #ff9900;
|
||||||
|
}
|
||||||
|
.user-add {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.user-tags {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 0;
|
||||||
|
}
|
||||||
|
.dw-foot {
|
||||||
|
position: sticky;
|
||||||
|
bottom: -16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 0;
|
||||||
|
margin-top: 8px;
|
||||||
|
background: #fff;
|
||||||
|
border-top: 1px solid #f0f0f0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,7 +12,7 @@ const props = defineProps<{
|
|||||||
const ctx = inject(EDITOR_CTX)!;
|
const ctx = inject(EDITOR_CTX)!;
|
||||||
const visible = ref(false);
|
const visible = ref(false);
|
||||||
|
|
||||||
function pick(kind: "approval" | "parallel"): void {
|
function pick(kind: "approval" | "parallel" | "notify" | "condition"): void {
|
||||||
visible.value = false;
|
visible.value = false;
|
||||||
ctx.insertItem(props.container, props.index, kind);
|
ctx.insertItem(props.container, props.index, kind);
|
||||||
}
|
}
|
||||||
@@ -30,6 +30,13 @@ function pick(kind: "approval" | "parallel"): void {
|
|||||||
<i>配置审批人 / 会签 / 时限</i>
|
<i>配置审批人 / 会签 / 时限</i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="menu-item" @click="pick('notify')">
|
||||||
|
<span class="mi-icon notify">抄</span>
|
||||||
|
<span class="mi-text">
|
||||||
|
<b>抄送节点</b>
|
||||||
|
<i>知会指定人,不产生待办、不阻塞流程</i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="menu-item" @click="pick('parallel')">
|
<div class="menu-item" @click="pick('parallel')">
|
||||||
<span class="mi-icon parallel">⇉</span>
|
<span class="mi-icon parallel">⇉</span>
|
||||||
<span class="mi-text">
|
<span class="mi-text">
|
||||||
@@ -37,6 +44,13 @@ function pick(kind: "approval" | "parallel"): void {
|
|||||||
<i>多分支同时进行,全部完成后继续</i>
|
<i>多分支同时进行,全部完成后继续</i>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="menu-item" @click="pick('condition')">
|
||||||
|
<span class="mi-icon condition">◇</span>
|
||||||
|
<span class="mi-text">
|
||||||
|
<b>条件分支</b>
|
||||||
|
<i>系统判断按出口互斥选路(判断服务)</i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Poptip>
|
</Poptip>
|
||||||
</div>
|
</div>
|
||||||
@@ -105,9 +119,15 @@ function pick(kind: "approval" | "parallel"): void {
|
|||||||
.mi-icon.approval {
|
.mi-icon.approval {
|
||||||
background: #2d8cf0;
|
background: #2d8cf0;
|
||||||
}
|
}
|
||||||
|
.mi-icon.notify {
|
||||||
|
background: #13a8a8;
|
||||||
|
}
|
||||||
.mi-icon.parallel {
|
.mi-icon.parallel {
|
||||||
background: #ff9900;
|
background: #ff9900;
|
||||||
}
|
}
|
||||||
|
.mi-icon.condition {
|
||||||
|
background: #9254de;
|
||||||
|
}
|
||||||
.mi-text {
|
.mi-text {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
* 编辑树 ⇄ contentJson 双向转换(方案 §3.3)。
|
* 编辑树 ⇄ contentJson 双向转换(方案 §3.3)。
|
||||||
*
|
*
|
||||||
* toContentJson:隐式补 start/end,并行展开为 PARALLEL_SPLIT + PARALLEL_JOIN(waitFor=ALL),
|
* toContentJson:隐式补 start/end,并行展开为 PARALLEL_SPLIT + PARALLEL_JOIN(waitFor=ALL),
|
||||||
* edges 按容器顺序自动串联——结构类校验(DefValidator 的孤儿/环/度约束)恒不触发。
|
* 条件分支(2026-09-24)展开为 CONDITION 节点 + 命名出边(无 JOIN,分支尾隐式汇合
|
||||||
* fromContentJson:反解 SPLIT/JOIN 配对(各出边可达集求交,取 BFS 序最早的 JOIN),还原为编辑树。
|
* 到容器后继),edges 按容器顺序自动串联——结构类校验(DefValidator 的孤儿/环/度约束)恒不触发。
|
||||||
|
* fromContentJson:反解 SPLIT/JOIN 配对(各出边可达集求交,取 BFS 序最早的 JOIN)与 CONDITION
|
||||||
|
* 汇合点(同法不限节点类型),还原为编辑树。
|
||||||
* 无法还原为卡片视图的非常规 DAG 抛中文错误,引导用户用"源码"高级模式。
|
* 无法还原为卡片视图的非常规 DAG 抛中文错误,引导用户用"源码"高级模式。
|
||||||
*
|
*
|
||||||
* 算法与《流程编辑器原型.html》同源,已在原型阶段全链路验证(含嵌套并行/环/跨分支连线)。
|
* 算法与《流程编辑器原型.html》同源,已在原型阶段全链路验证(含嵌套并行/环/跨分支连线)。
|
||||||
@@ -11,6 +13,7 @@
|
|||||||
import {
|
import {
|
||||||
ApprovalNode,
|
ApprovalNode,
|
||||||
Assignee,
|
Assignee,
|
||||||
|
ConditionNode,
|
||||||
Countersign,
|
Countersign,
|
||||||
DEFAULT_URGE,
|
DEFAULT_URGE,
|
||||||
DefEdgeJson,
|
DefEdgeJson,
|
||||||
@@ -19,6 +22,7 @@ import {
|
|||||||
EditorItem,
|
EditorItem,
|
||||||
EditorModel,
|
EditorModel,
|
||||||
EditorSettings,
|
EditorSettings,
|
||||||
|
NotifyNode,
|
||||||
ParallelNode,
|
ParallelNode,
|
||||||
createApproval,
|
createApproval,
|
||||||
newUid,
|
newUid,
|
||||||
@@ -35,8 +39,27 @@ export function toContentJson(model: EditorModel): DefJson {
|
|||||||
if (model.startName) startNode.name = model.startName;
|
if (model.startName) startNode.name = model.startName;
|
||||||
nodes.push(startNode);
|
nodes.push(startNode);
|
||||||
|
|
||||||
const walk = (items: EditorItem[], prev: string): string => {
|
/**
|
||||||
let cursor = prev;
|
* 条件分支(2026-09-24)无 JOIN 语义:分支尾不互连,隐式汇合到容器后继——cursor 置 null 后
|
||||||
|
* 下一个节点(或 END)只接收各分支尾的连线;firstEdgeName 只作用于分支首边(出口名)。
|
||||||
|
* walk 返回"尾集合"(普通序列=末节点,末尾含条件分支=各分支尾)。
|
||||||
|
*/
|
||||||
|
const walk = (items: EditorItem[], prev: string, firstEdgeName?: string): string[] => {
|
||||||
|
let cursor: string | null = prev;
|
||||||
|
let pending: string[] = [];
|
||||||
|
let first = true;
|
||||||
|
const linkTo = (toId: string): void => {
|
||||||
|
if (cursor !== null) {
|
||||||
|
if (first && firstEdgeName) {
|
||||||
|
edges.push({ from: cursor, to: toId, name: firstEdgeName });
|
||||||
|
} else {
|
||||||
|
edges.push({ from: cursor, to: toId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pending.forEach((t) => edges.push({ from: t, to: toId }));
|
||||||
|
pending = [];
|
||||||
|
first = false;
|
||||||
|
};
|
||||||
items.forEach((item) => {
|
items.forEach((item) => {
|
||||||
if (item.kind === "approval") {
|
if (item.kind === "approval") {
|
||||||
const id = item.uid;
|
const id = item.uid;
|
||||||
@@ -49,26 +72,57 @@ export function toContentJson(model: EditorModel): DefJson {
|
|||||||
if (item.countersign === "ANY") node.countersign = "ANY";
|
if (item.countersign === "ANY") node.countersign = "ANY";
|
||||||
if (item.deadlineHours) node.deadlineHours = item.deadlineHours;
|
if (item.deadlineHours) node.deadlineHours = item.deadlineHours;
|
||||||
nodes.push(node);
|
nodes.push(node);
|
||||||
edges.push({ from: cursor, to: id });
|
linkTo(id);
|
||||||
cursor = id;
|
cursor = id;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (item.kind === "notify") {
|
||||||
|
const id = item.uid;
|
||||||
|
const node: DefNodeJson = {
|
||||||
|
id,
|
||||||
|
type: "NOTIFY",
|
||||||
|
name: item.name || "抄送",
|
||||||
|
receivers: cloneAssignee(item.receivers),
|
||||||
|
};
|
||||||
|
nodes.push(node);
|
||||||
|
linkTo(id);
|
||||||
|
cursor = id;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (item.kind === "condition") {
|
||||||
|
const condId = `${item.uid}c`;
|
||||||
|
nodes.push({
|
||||||
|
id: condId,
|
||||||
|
type: "CONDITION",
|
||||||
|
name: item.name || "条件判断",
|
||||||
|
routerKey: item.routerKey || "",
|
||||||
|
});
|
||||||
|
linkTo(condId);
|
||||||
|
const tails: string[] = [];
|
||||||
|
item.branches.forEach((branch, bi) => {
|
||||||
|
tails.push(...walk(branch, condId, item.outlets[bi]));
|
||||||
|
});
|
||||||
|
cursor = null;
|
||||||
|
pending = tails;
|
||||||
|
return;
|
||||||
|
}
|
||||||
const split = `${item.uid}s`;
|
const split = `${item.uid}s`;
|
||||||
const join = `${item.uid}j`;
|
const join = `${item.uid}j`;
|
||||||
nodes.push({ id: split, type: "PARALLEL_SPLIT" });
|
nodes.push({ id: split, type: "PARALLEL_SPLIT" });
|
||||||
nodes.push({ id: join, type: "PARALLEL_JOIN", waitFor: "ALL" });
|
nodes.push({ id: join, type: "PARALLEL_JOIN", waitFor: "ALL" });
|
||||||
edges.push({ from: cursor, to: split });
|
linkTo(split);
|
||||||
item.branches.forEach((branch) => {
|
item.branches.forEach((branch) => {
|
||||||
const tail = walk(branch, split);
|
walk(branch, split).forEach((tail) => edges.push({ from: tail, to: join }));
|
||||||
edges.push({ from: tail, to: join });
|
|
||||||
});
|
});
|
||||||
cursor = join;
|
cursor = join;
|
||||||
});
|
});
|
||||||
return cursor;
|
const tails: string[] = [];
|
||||||
|
if (cursor !== null) tails.push(cursor);
|
||||||
|
tails.push(...pending);
|
||||||
|
return tails;
|
||||||
};
|
};
|
||||||
|
|
||||||
const tail = walk(model.list, "start");
|
walk(model.list, "start").forEach((tail) => edges.push({ from: tail, to: "end" }));
|
||||||
edges.push({ from: tail, to: "end" });
|
|
||||||
const endNode: DefNodeJson = { id: "end", type: "END" };
|
const endNode: DefNodeJson = { id: "end", type: "END" };
|
||||||
if (model.endName) endNode.name = model.endName;
|
if (model.endName) endNode.name = model.endName;
|
||||||
nodes.push(endNode);
|
nodes.push(endNode);
|
||||||
@@ -176,6 +230,89 @@ export function fromContentJson(def: any): ParsedDef {
|
|||||||
return candidates[0].id;
|
return candidates[0].id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CONDITION 汇合点(2026-09-24):无 JOIN 节点,分支尾隐式连到同一后继——
|
||||||
|
* 各出边可达集求交(不限节点类型)取 BFS 序最早者即汇合点(分支内部节点不可能被全分支公共可达)。
|
||||||
|
*/
|
||||||
|
function findMerge(condId: string): string {
|
||||||
|
const order = new Map<string, number>([[condId, 0]]);
|
||||||
|
const queue: string[] = [condId];
|
||||||
|
while (queue.length) {
|
||||||
|
const cur = queue.shift()!;
|
||||||
|
(out[cur] || []).forEach((to) => {
|
||||||
|
if (!order.has(to)) {
|
||||||
|
order.set(to, order.size);
|
||||||
|
queue.push(to);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const reachSets = (out[condId] || []).map((head) => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
const q: string[] = [head];
|
||||||
|
while (q.length) {
|
||||||
|
const cur = q.shift()!;
|
||||||
|
if (set.has(cur)) continue;
|
||||||
|
set.add(cur);
|
||||||
|
(out[cur] || []).forEach((to) => q.push(to));
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
});
|
||||||
|
let common = reachSets[0] || new Set<string>();
|
||||||
|
reachSets.forEach((set) => {
|
||||||
|
common = new Set([...common].filter((x) => set.has(x)));
|
||||||
|
});
|
||||||
|
const candidates = nodes
|
||||||
|
.filter((n) => n.id !== condId && common.has(n.id))
|
||||||
|
.sort((a, b) => (order.get(a.id) ?? 0) - (order.get(b.id) ?? 0));
|
||||||
|
if (!candidates.length) {
|
||||||
|
throw new Error("条件分支未汇聚到同一个节点,无法还原为卡片视图");
|
||||||
|
}
|
||||||
|
return candidates[0].id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CONDITION 反解:出口名取 cond→分支首节点的边 name(判断接口 outlets 契约) */
|
||||||
|
function makeCondition(condId: string): { item: ConditionNode; mergeId: string } {
|
||||||
|
const n = byId[condId];
|
||||||
|
const heads = out[condId] || [];
|
||||||
|
if (heads.length < 2) {
|
||||||
|
throw new Error(`条件分支节点「${n.name || condId}」至少 2 条出边`);
|
||||||
|
}
|
||||||
|
const mergeId = findMerge(condId);
|
||||||
|
// 不预占 seen:汇合点是普通节点,交回外层循环正常消费(区别于并行 join 的"跳过式"处理)
|
||||||
|
const branches = heads.map((head) => {
|
||||||
|
const r = walkList(head, mergeId);
|
||||||
|
if (r.endedAt !== mergeId) {
|
||||||
|
throw new Error("条件分支未汇聚到同一个节点,无法还原为卡片视图");
|
||||||
|
}
|
||||||
|
return r.list;
|
||||||
|
});
|
||||||
|
const outlets = heads.map((head) => {
|
||||||
|
const e = edges.find((x) => x.from === condId && x.to === head);
|
||||||
|
return e && e.name ? String(e.name) : "";
|
||||||
|
});
|
||||||
|
if (outlets.some((o) => !o || !o.trim())) {
|
||||||
|
throw new Error(`条件分支节点「${n.name || condId}」的每个出口必须命名(出口名即判断依据)`);
|
||||||
|
}
|
||||||
|
const dup = outlets.find((o, i) => outlets.indexOf(o) !== i);
|
||||||
|
if (dup) {
|
||||||
|
throw new Error(`条件分支出口名重复:「${dup}」(同一节点的出口名必须唯一)`);
|
||||||
|
}
|
||||||
|
// uid 复用 JSON id 并剥掉生成态的 "c" 尾缀:uid+"c" 是本编辑器的 JSON id 生成约定,
|
||||||
|
// 剥尾保证"图→树→图→树→图"的 id 收敛(否则每轮追加一个 c,往返不幂等)
|
||||||
|
const uidRaw = condId.endsWith("c") ? condId.slice(0, -1) : condId;
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
uid: UsedIds.take(uidRaw),
|
||||||
|
kind: "condition",
|
||||||
|
name: n.name || "条件判断",
|
||||||
|
routerKey: n.routerKey || "",
|
||||||
|
outlets,
|
||||||
|
branches: branches.map((b) => (b.length ? b : [createApproval()])),
|
||||||
|
},
|
||||||
|
mergeId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function walkList(cur: string, stopAt?: string): { list: EditorItem[]; endedAt: string } {
|
function walkList(cur: string, stopAt?: string): { list: EditorItem[]; endedAt: string } {
|
||||||
const list: EditorItem[] = [];
|
const list: EditorItem[] = [];
|
||||||
while (true) {
|
while (true) {
|
||||||
@@ -191,6 +328,12 @@ export function fromContentJson(def: any): ParsedDef {
|
|||||||
}
|
}
|
||||||
list.push(toApprovalNode(n));
|
list.push(toApprovalNode(n));
|
||||||
cur = out[cur][0];
|
cur = out[cur][0];
|
||||||
|
} else if (n.type === "NOTIFY") {
|
||||||
|
if ((out[cur] || []).length !== 1) {
|
||||||
|
throw new Error(`抄送节点「${n.name || cur}」的出边必须恰好 1 条`);
|
||||||
|
}
|
||||||
|
list.push(toNotifyNode(n));
|
||||||
|
cur = out[cur][0];
|
||||||
} else if (n.type === "PARALLEL_SPLIT") {
|
} else if (n.type === "PARALLEL_SPLIT") {
|
||||||
const heads = out[cur] || [];
|
const heads = out[cur] || [];
|
||||||
if (!heads.length) throw new Error("并行分发节点没有分支");
|
if (!heads.length) throw new Error("并行分发节点没有分支");
|
||||||
@@ -206,6 +349,11 @@ export function fromContentJson(def: any): ParsedDef {
|
|||||||
list.push(makeParallel(branches));
|
list.push(makeParallel(branches));
|
||||||
if ((out[joinId] || []).length !== 1) throw new Error("汇聚节点出边必须恰好 1 条");
|
if ((out[joinId] || []).length !== 1) throw new Error("汇聚节点出边必须恰好 1 条");
|
||||||
cur = out[joinId][0];
|
cur = out[joinId][0];
|
||||||
|
} else if (n.type === "CONDITION") {
|
||||||
|
// 条件分支容器:反解后 cur=汇合点(下一节点/END/外层容器的 join),循环顶部统一续走
|
||||||
|
const made = makeCondition(cur);
|
||||||
|
list.push(made.item);
|
||||||
|
cur = made.mergeId;
|
||||||
} else if (n.type === "END") {
|
} else if (n.type === "END") {
|
||||||
return { list, endedAt: cur };
|
return { list, endedAt: cur };
|
||||||
} else if (n.type === "PARALLEL_JOIN") {
|
} else if (n.type === "PARALLEL_JOIN") {
|
||||||
@@ -258,6 +406,15 @@ function toApprovalNode(n: any): ApprovalNode {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toNotifyNode(n: any): NotifyNode {
|
||||||
|
return {
|
||||||
|
uid: UsedIds.take(n.id),
|
||||||
|
kind: "notify",
|
||||||
|
name: n.name || "抄送",
|
||||||
|
receivers: n.receivers ? cloneAssignee(n.receivers as Assignee) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 载入会话内已占用的 uid 集(防重复/缺 id 撞车) */
|
/** 载入会话内已占用的 uid 集(防重复/缺 id 撞车) */
|
||||||
const UsedIds = {
|
const UsedIds = {
|
||||||
set: new Set<string>(),
|
set: new Set<string>(),
|
||||||
|
|||||||
@@ -10,15 +10,20 @@ import { Message, Modal } from "view-ui-plus";
|
|||||||
import { flowApi } from "@/api/module/flow";
|
import { flowApi } from "@/api/module/flow";
|
||||||
import {
|
import {
|
||||||
ApprovalNode,
|
ApprovalNode,
|
||||||
|
ConditionNode,
|
||||||
DeptOption,
|
DeptOption,
|
||||||
EDITOR_CTX,
|
EDITOR_CTX,
|
||||||
EditorCtx,
|
EditorCtx,
|
||||||
EditorItem,
|
EditorItem,
|
||||||
EditorModel,
|
EditorModel,
|
||||||
|
NotifyNode,
|
||||||
|
RouterOption,
|
||||||
UrgeSetting,
|
UrgeSetting,
|
||||||
collectUserIds,
|
collectUserIds,
|
||||||
createApproval,
|
createApproval,
|
||||||
|
createCondition,
|
||||||
createEmptyModel,
|
createEmptyModel,
|
||||||
|
createNotify,
|
||||||
createParallel,
|
createParallel,
|
||||||
findItem,
|
findItem,
|
||||||
removeItem,
|
removeItem,
|
||||||
@@ -29,6 +34,8 @@ import { ParsedDef, fromContentJson, toContentJson } from "./convert";
|
|||||||
import { Issue, countErrors, countWarns, validateModel } from "./validate";
|
import { Issue, countErrors, countWarns, validateModel } from "./validate";
|
||||||
import NodeList from "./components/NodeList.vue";
|
import NodeList from "./components/NodeList.vue";
|
||||||
import ApprovalDrawer from "./components/ApprovalDrawer.vue";
|
import ApprovalDrawer from "./components/ApprovalDrawer.vue";
|
||||||
|
import NotifyDrawer from "./components/NotifyDrawer.vue";
|
||||||
|
import ConditionDrawer from "./components/ConditionDrawer.vue";
|
||||||
import SettingsDrawer from "./components/SettingsDrawer.vue";
|
import SettingsDrawer from "./components/SettingsDrawer.vue";
|
||||||
import JsonDrawer from "./components/JsonDrawer.vue";
|
import JsonDrawer from "./components/JsonDrawer.vue";
|
||||||
import BpmnCanvas from "./components/BpmnCanvas.vue";
|
import BpmnCanvas from "./components/BpmnCanvas.vue";
|
||||||
@@ -53,6 +60,8 @@ const model = reactive<EditorModel>(createEmptyModel());
|
|||||||
const depts = reactive<DeptOption[]>([]);
|
const depts = reactive<DeptOption[]>([]);
|
||||||
/** userId → 姓名快照(mapLookupUser 检索补全,失败降级 #id) */
|
/** userId → 姓名快照(mapLookupUser 检索补全,失败降级 #id) */
|
||||||
const userNames = reactive<Record<number, string>>({});
|
const userNames = reactive<Record<number, string>>({});
|
||||||
|
/** 条件分支的判断服务注册(flw_router;拉取失败为空数组,校验降级提示) */
|
||||||
|
const routers = reactive<RouterOption[]>([]);
|
||||||
|
|
||||||
// ==================== 双模式统一(2026-09-17):卡片树 = 唯一数据源,画布 = 其可视化投影 ====================
|
// ==================== 双模式统一(2026-09-17):卡片树 = 唯一数据源,画布 = 其可视化投影 ====================
|
||||||
/**
|
/**
|
||||||
@@ -78,20 +87,42 @@ const canvasConfigs = reactive<Record<string, any>>({});
|
|||||||
const sourceJson = ref("");
|
const sourceJson = ref("");
|
||||||
|
|
||||||
function onCanvasNode(_el: any): void {
|
function onCanvasNode(_el: any): void {
|
||||||
// selection.changed 自画布 emit:选中审批节点 → 打开配置抽屉
|
// selection.changed 自画布 emit:选中审批/抄送/条件分支节点 → 打开对应配置抽屉
|
||||||
if (!_el || _el.type !== "bpmn:UserTask") return;
|
const isApproval = _el && _el.type === "bpmn:UserTask";
|
||||||
|
const isNotify = _el && _el.type === "bpmn:ServiceTask";
|
||||||
|
const isCondition = _el && _el.type === "bpmn:ExclusiveGateway";
|
||||||
|
if (!isApproval && !isNotify && !isCondition) return;
|
||||||
const id = String(_el.businessObject.id || _el.id);
|
const id = String(_el.businessObject.id || _el.id);
|
||||||
const item = findItem(model, id);
|
// 条件分支的画布元素 id 是 JSON id(uid+"c" 后缀),卡片树按 uid 查(uid 由本编辑器生成,不带 c 尾)
|
||||||
if (item && item.kind === "approval") {
|
const lookupId = isCondition && id.endsWith("c") ? id.slice(0, -1) : id;
|
||||||
|
const item = findItem(model, lookupId);
|
||||||
|
if (item && (item.kind === "approval" || item.kind === "notify" || item.kind === "condition")) {
|
||||||
// 卡片树已有节点:抽屉直接编辑卡片本体(id=uid 天然对应)
|
// 卡片树已有节点:抽屉直接编辑卡片本体(id=uid 天然对应)
|
||||||
editingUid.value = id;
|
openNode(item.uid);
|
||||||
nodeDrawer.value = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// 画布上新画的节点(卡片树里没有):配置进 canvasConfigs,保存时并入
|
// 画布上新画的节点(卡片树里没有):配置进 canvasConfigs,保存时并入
|
||||||
|
const kind = isNotify ? "notify" : isCondition ? "condition" : "approval";
|
||||||
const baseNode = (canvasBase.value?.nodes || []).find((n: any) => n.id === id) || {};
|
const baseNode = (canvasBase.value?.nodes || []).find((n: any) => n.id === id) || {};
|
||||||
if (!canvasConfigs[id]) {
|
if (!canvasConfigs[id]) {
|
||||||
canvasConfigs[id] = {
|
canvasConfigs[id] =
|
||||||
|
kind === "notify"
|
||||||
|
? {
|
||||||
|
uid: id,
|
||||||
|
kind: "notify",
|
||||||
|
name: _el.businessObject.name || baseNode.name || "抄送",
|
||||||
|
receivers: baseNode.receivers || { type: "USERS", userIds: [] },
|
||||||
|
}
|
||||||
|
: kind === "condition"
|
||||||
|
? {
|
||||||
|
uid: id,
|
||||||
|
kind: "condition",
|
||||||
|
name: _el.businessObject.name || baseNode.name || "条件判断",
|
||||||
|
routerKey: baseNode.routerKey || "",
|
||||||
|
outlets: [],
|
||||||
|
branches: [],
|
||||||
|
}
|
||||||
|
: {
|
||||||
uid: id,
|
uid: id,
|
||||||
kind: "approval",
|
kind: "approval",
|
||||||
name: _el.businessObject.name || baseNode.name || "审批",
|
name: _el.businessObject.name || baseNode.name || "审批",
|
||||||
@@ -101,7 +132,13 @@ function onCanvasNode(_el: any): void {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
editingUid.value = id;
|
editingUid.value = id;
|
||||||
|
if (canvasConfigs[id].kind === "notify") {
|
||||||
|
notifyDrawer.value = true;
|
||||||
|
} else if (canvasConfigs[id].kind === "condition") {
|
||||||
|
conditionDrawer.value = true;
|
||||||
|
} else {
|
||||||
nodeDrawer.value = true;
|
nodeDrawer.value = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function canvasRemoveNode(uid: string): void {
|
function canvasRemoveNode(uid: string): void {
|
||||||
@@ -110,27 +147,34 @@ function canvasRemoveNode(uid: string): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 画布序列化 → contentJson 字符串:结构性内容(节点/连线/布局)出自画布,
|
/** 画布序列化 → contentJson 字符串:结构性内容(节点/连线/布局)出自画布,
|
||||||
* 业务配置(审批人/会签/时限)以卡片树为 base 按 id 继承,settings(含催办)随 base 带出 */
|
* 业务配置(审批人/会签/时限/抄送对象)以卡片树为 base 按 id 继承,settings(含催办)随 base 带出 */
|
||||||
function serializeUnified(): string {
|
function serializeUnified(): string {
|
||||||
const canvasJson = snapshotCanvasObject();
|
const canvasJson = snapshotCanvasObject();
|
||||||
// canvasConfigs(画布新画节点的抽屉配置)按 id 并入
|
// canvasConfigs(画布新画节点的抽屉配置)按 id 并入
|
||||||
(canvasJson.nodes || []).forEach((n: any) => {
|
(canvasJson.nodes || []).forEach((n: any) => {
|
||||||
const cfg = canvasConfigs[String(n.id)];
|
const cfg = canvasConfigs[String(n.id)];
|
||||||
if (cfg && n.type === "APPROVAL") {
|
if (!cfg) return;
|
||||||
|
if (cfg.kind === "notify" && n.type === "NOTIFY") {
|
||||||
|
n.name = cfg.name || n.name;
|
||||||
|
if (cfg.receivers) n.receivers = cfg.receivers;
|
||||||
|
} else if (cfg.kind === "approval" && n.type === "APPROVAL") {
|
||||||
n.name = cfg.name || n.name;
|
n.name = cfg.name || n.name;
|
||||||
if (cfg.assignee) n.assignee = cfg.assignee;
|
if (cfg.assignee) n.assignee = cfg.assignee;
|
||||||
if (cfg.countersign) n.countersign = cfg.countersign;
|
if (cfg.countersign) n.countersign = cfg.countersign;
|
||||||
if (cfg.deadlineHours) n.deadlineHours = cfg.deadlineHours;
|
if (cfg.deadlineHours) n.deadlineHours = cfg.deadlineHours;
|
||||||
|
} else if (cfg.kind === "condition" && n.type === "CONDITION") {
|
||||||
|
n.name = cfg.name || n.name;
|
||||||
|
if (cfg.routerKey) n.routerKey = cfg.routerKey;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return JSON.stringify(canvasJson);
|
return JSON.stringify(canvasJson);
|
||||||
}
|
}
|
||||||
|
|
||||||
const issues = computed<Issue[]>(() => validateModel(model, depts));
|
const issues = computed<Issue[]>(() => validateModel(model, depts, routers));
|
||||||
const defJson = computed(() => toContentJson(model));
|
const defJson = computed(() => toContentJson(model));
|
||||||
|
|
||||||
/** 序列化 base:原始 contentJson 为底(保住卡片树不支持结构/新画节点的既有业务配置),
|
/** 序列化 base:原始 contentJson 为底(保住卡片树不支持结构/新画节点的既有业务配置),
|
||||||
* code/name/settings 与卡片树审批节点配置(审批人/会签/时限)以卡片树为准覆盖 */
|
* code/name/settings 与卡片树审批/抄送节点配置(审批人/会签/时限/抄送对象)以卡片树为准覆盖 */
|
||||||
function buildSerializeBase(): any {
|
function buildSerializeBase(): any {
|
||||||
const modelDef = toContentJson(model);
|
const modelDef = toContentJson(model);
|
||||||
const base = JSON.parse(JSON.stringify(canvasBase.value || {}));
|
const base = JSON.parse(JSON.stringify(canvasBase.value || {}));
|
||||||
@@ -141,8 +185,9 @@ function buildSerializeBase(): any {
|
|||||||
(base.nodes || []).forEach((n: any) => {
|
(base.nodes || []).forEach((n: any) => {
|
||||||
byId[String(n.id)] = n;
|
byId[String(n.id)] = n;
|
||||||
});
|
});
|
||||||
model.list.forEach((item) => {
|
// 全层级遍历(含并行分支内节点):只覆盖 id 能对上的,画布独有节点不受影响
|
||||||
if (item.kind !== "approval") return;
|
walkItems(model.list, (item) => {
|
||||||
|
if (item.kind === "approval") {
|
||||||
const merged: any = { ...(byId[item.uid] || {}) };
|
const merged: any = { ...(byId[item.uid] || {}) };
|
||||||
merged.id = item.uid;
|
merged.id = item.uid;
|
||||||
merged.type = "APPROVAL";
|
merged.type = "APPROVAL";
|
||||||
@@ -152,6 +197,23 @@ function buildSerializeBase(): any {
|
|||||||
else delete merged.countersign; // 后端缺省即会签,不回写
|
else delete merged.countersign; // 后端缺省即会签,不回写
|
||||||
if (item.deadlineHours) merged.deadlineHours = item.deadlineHours;
|
if (item.deadlineHours) merged.deadlineHours = item.deadlineHours;
|
||||||
byId[item.uid] = merged;
|
byId[item.uid] = merged;
|
||||||
|
} else if (item.kind === "notify") {
|
||||||
|
const merged: any = { ...(byId[item.uid] || {}) };
|
||||||
|
merged.id = item.uid;
|
||||||
|
merged.type = "NOTIFY";
|
||||||
|
merged.name = item.name || "抄送";
|
||||||
|
if (item.receivers) merged.receivers = JSON.parse(JSON.stringify(item.receivers));
|
||||||
|
byId[item.uid] = merged;
|
||||||
|
} else if (item.kind === "condition") {
|
||||||
|
// 条件分支的 JSON id 带 c 后缀(convert.ts 生成约定),routerKey/名称随卡片树覆盖
|
||||||
|
const jsonId = `${item.uid}c`;
|
||||||
|
const merged: any = { ...(byId[jsonId] || {}) };
|
||||||
|
merged.id = jsonId;
|
||||||
|
merged.type = "CONDITION";
|
||||||
|
merged.name = item.name || "条件判断";
|
||||||
|
merged.routerKey = item.routerKey || "";
|
||||||
|
byId[jsonId] = merged;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
base.nodes = Object.values(byId);
|
base.nodes = Object.values(byId);
|
||||||
return base;
|
return base;
|
||||||
@@ -197,6 +259,8 @@ const currentSnapshot = computed(() =>
|
|||||||
const isDirty = computed(() => !loading.value && currentSnapshot.value !== savedSnapshot.value);
|
const isDirty = computed(() => !loading.value && currentSnapshot.value !== savedSnapshot.value);
|
||||||
|
|
||||||
const nodeDrawer = ref(false);
|
const nodeDrawer = ref(false);
|
||||||
|
const notifyDrawer = ref(false);
|
||||||
|
const conditionDrawer = ref(false);
|
||||||
const settingsDrawer = ref(false);
|
const settingsDrawer = ref(false);
|
||||||
const jsonDrawer = ref(false);
|
const jsonDrawer = ref(false);
|
||||||
const editingUid = ref("");
|
const editingUid = ref("");
|
||||||
@@ -205,12 +269,35 @@ const editingNode = computed<ApprovalNode | null>(() => {
|
|||||||
if (editMode.value === "canvas") {
|
if (editMode.value === "canvas") {
|
||||||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||||
if (item && item.kind === "approval") return item;
|
if (item && item.kind === "approval") return item;
|
||||||
return canvasConfigs[editingUid.value] || null;
|
const cfg = canvasConfigs[editingUid.value];
|
||||||
|
return cfg && cfg.kind === "approval" ? cfg : null;
|
||||||
}
|
}
|
||||||
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||||
return item && item.kind === "approval" ? item : null;
|
return item && item.kind === "approval" ? item : null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const editingNotify = computed<NotifyNode | null>(() => {
|
||||||
|
if (editMode.value === "canvas") {
|
||||||
|
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||||
|
if (item && item.kind === "notify") return item;
|
||||||
|
const cfg = canvasConfigs[editingUid.value];
|
||||||
|
return cfg && cfg.kind === "notify" ? cfg : null;
|
||||||
|
}
|
||||||
|
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||||
|
return item && item.kind === "notify" ? item : null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const editingCondition = computed<ConditionNode | null>(() => {
|
||||||
|
if (editMode.value === "canvas") {
|
||||||
|
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||||
|
if (item && item.kind === "condition") return item;
|
||||||
|
const cfg = canvasConfigs[editingUid.value];
|
||||||
|
return cfg && cfg.kind === "condition" ? cfg : null;
|
||||||
|
}
|
||||||
|
const item = editingUid.value ? findItem(model, editingUid.value) : null;
|
||||||
|
return item && item.kind === "condition" ? item : null;
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== 载入 ====================
|
// ==================== 载入 ====================
|
||||||
|
|
||||||
async function loadDepts(): Promise<void> {
|
async function loadDepts(): Promise<void> {
|
||||||
@@ -245,6 +332,16 @@ async function resolveUserNames(): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 条件分支的判断服务注册下拉(flw_router 启用项;失败不阻塞编辑,校验降级提示) */
|
||||||
|
async function loadRouters(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const rows: any[] = (await flowApi.routerList().send()) || [];
|
||||||
|
routers.splice(0, routers.length, ...rows);
|
||||||
|
} catch {
|
||||||
|
Message.warning("判断服务注册拉取失败,条件分支的判断服务下拉可能不完整");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applyParsed(parsed: ParsedDef): void {
|
function applyParsed(parsed: ParsedDef): void {
|
||||||
model.code = parsed.code || model.code;
|
model.code = parsed.code || model.code;
|
||||||
model.name = parsed.name || model.name;
|
model.name = parsed.name || model.name;
|
||||||
@@ -260,6 +357,7 @@ async function load(): Promise<void> {
|
|||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
await loadDepts();
|
await loadDepts();
|
||||||
|
await loadRouters();
|
||||||
|
|
||||||
const queryId = route.query.id ? Number(route.query.id) : null;
|
const queryId = route.query.id ? Number(route.query.id) : null;
|
||||||
const queryGuid = (route.query.baseGuid as string) || "";
|
const queryGuid = (route.query.baseGuid as string) || "";
|
||||||
@@ -376,7 +474,14 @@ function issueOf(uid: string): Issue[] {
|
|||||||
|
|
||||||
function openNode(uid: string): void {
|
function openNode(uid: string): void {
|
||||||
editingUid.value = uid;
|
editingUid.value = uid;
|
||||||
|
const item = findItem(model, uid);
|
||||||
|
if (item && item.kind === "notify") {
|
||||||
|
notifyDrawer.value = true;
|
||||||
|
} else if (item && item.kind === "condition") {
|
||||||
|
conditionDrawer.value = true;
|
||||||
|
} else {
|
||||||
nodeDrawer.value = true;
|
nodeDrawer.value = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeNode(uid: string): void {
|
function removeNode(uid: string): void {
|
||||||
@@ -394,7 +499,7 @@ function removeNode(uid: string): void {
|
|||||||
removeItem(model, uid);
|
removeItem(model, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
function insertItem(container: string, index: number, kind: "approval" | "parallel"): void {
|
function insertItem(container: string, index: number, kind: "approval" | "parallel" | "notify" | "condition"): void {
|
||||||
const list = resolveContainer(model, container);
|
const list = resolveContainer(model, container);
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
if (kind === "approval") {
|
if (kind === "approval") {
|
||||||
@@ -402,31 +507,49 @@ function insertItem(container: string, index: number, kind: "approval" | "parall
|
|||||||
list.splice(index, 0, node);
|
list.splice(index, 0, node);
|
||||||
// 新建即弹出配置,引导填审批人(避免留下"任务无主"节点)
|
// 新建即弹出配置,引导填审批人(避免留下"任务无主"节点)
|
||||||
openNode(node.uid);
|
openNode(node.uid);
|
||||||
|
} else if (kind === "notify") {
|
||||||
|
const node = createNotify();
|
||||||
|
list.splice(index, 0, node);
|
||||||
|
openNode(node.uid);
|
||||||
|
} else if (kind === "condition") {
|
||||||
|
const node = createCondition();
|
||||||
|
list.splice(index, 0, node);
|
||||||
|
// 新建即弹配置选判断服务(未配置的判断服务运行到该节点会挂起)
|
||||||
|
openNode(node.uid);
|
||||||
} else {
|
} else {
|
||||||
list.splice(index, 0, createParallel());
|
list.splice(index, 0, createParallel());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addBranch(parallelUid: string): void {
|
/** 添加分支(并行/条件分支容器通用;条件分支同步补一个占位出口名) */
|
||||||
const item = findItem(model, parallelUid);
|
function addBranch(containerUid: string): void {
|
||||||
if (!item || item.kind !== "parallel") return;
|
const item = findItem(model, containerUid);
|
||||||
|
if (!item) return;
|
||||||
|
if (item.kind === "parallel") {
|
||||||
item.branches.push([createApproval()]);
|
item.branches.push([createApproval()]);
|
||||||
|
} else if (item.kind === "condition") {
|
||||||
|
const no = item.branches.length + 1;
|
||||||
|
item.branches.push([createApproval()]);
|
||||||
|
item.outlets.push(`出口${["一", "二", "三", "四", "五", "六"][no - 1] || no}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeBranch(parallelUid: string, index: number): void {
|
function removeBranch(containerUid: string, index: number): void {
|
||||||
const item = findItem(model, parallelUid);
|
const item = findItem(model, containerUid);
|
||||||
if (!item || item.kind !== "parallel") return;
|
if (!item || (item.kind !== "parallel" && item.kind !== "condition")) return;
|
||||||
if (item.branches.length <= 2) {
|
if (item.branches.length <= 2) {
|
||||||
Message.warning("并行分支至少保留 2 个");
|
Message.warning("分支至少保留 2 个");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
item.branches.splice(index, 1);
|
item.branches.splice(index, 1);
|
||||||
|
if (item.kind === "condition") item.outlets.splice(index, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
provide<EditorCtx>(EDITOR_CTX, {
|
provide<EditorCtx>(EDITOR_CTX, {
|
||||||
model,
|
model,
|
||||||
depts,
|
depts,
|
||||||
userNames,
|
userNames,
|
||||||
|
routers,
|
||||||
issueOf,
|
issueOf,
|
||||||
openNode,
|
openNode,
|
||||||
removeNode,
|
removeNode,
|
||||||
@@ -442,13 +565,20 @@ function onUrgeSave(urge: UrgeSetting): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 抽屉关闭(确定/删除)后,画布上的节点名回写(配置改的是卡片树本体) */
|
/** 抽屉关闭(确定/删除)后,画布上的节点名回写(配置改的是卡片树本体) */
|
||||||
watch(nodeDrawer, (open, was) => {
|
watch([nodeDrawer, notifyDrawer, conditionDrawer], ([aOpen, nOpen, cOpen], [aWas, nWas, cWas]) => {
|
||||||
if (open || !was) return;
|
const closed = (open: boolean, was: boolean) => !open && was;
|
||||||
|
if (!closed(aOpen, aWas) && !closed(nOpen, nWas) && !closed(cOpen, cWas)) return;
|
||||||
if (editMode.value !== "canvas" || !bpmnWrap.value) return;
|
if (editMode.value !== "canvas" || !bpmnWrap.value) return;
|
||||||
const item = findItem(model, editingUid.value);
|
const item = findItem(model, editingUid.value);
|
||||||
if (item && item.kind === "approval") {
|
if (item && item.kind === "approval") {
|
||||||
bpmnWrap.value.renameNode(item.uid, item.name);
|
bpmnWrap.value.renameNode(item.uid, item.name);
|
||||||
}
|
}
|
||||||
|
if (item && item.kind === "notify") {
|
||||||
|
bpmnWrap.value.renameNode(item.uid, item.name);
|
||||||
|
}
|
||||||
|
if (item && item.kind === "condition") {
|
||||||
|
bpmnWrap.value.renameNode(`${item.uid}c`, item.name);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 源码抽屉应用:写回卡片树;画布模式下画布源同步换投影,避免看到旧结构 */
|
/** 源码抽屉应用:写回卡片树;画布模式下画布源同步换投影,避免看到旧结构 */
|
||||||
@@ -541,6 +671,33 @@ function checkCanvas(content: string): string[] {
|
|||||||
if (!n.name) problems.push("审批节点未命名");
|
if (!n.name) problems.push("审批节点未命名");
|
||||||
if (!n.assignee) problems.push("审批节点「" + (n.name || n.id) + "」缺少审批人配置");
|
if (!n.assignee) problems.push("审批节点「" + (n.name || n.id) + "」缺少审批人配置");
|
||||||
}
|
}
|
||||||
|
if (n.type === "NOTIFY") {
|
||||||
|
if (!n.name) problems.push("抄送节点未命名");
|
||||||
|
if (!n.receivers) problems.push("抄送节点「" + (n.name || n.id) + "」缺少抄送对象配置");
|
||||||
|
else if (n.receivers.type === "USERS" && !(n.receivers.userIds || []).length) {
|
||||||
|
problems.push("抄送节点「" + (n.name || n.id) + "」未选择用户");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (n.type === "CONDITION") {
|
||||||
|
// 条件分支(镜像后端 DefValidator 的 CONDITION 口径):routerKey 必填 + 出口命名唯一 + 出边 ≥2
|
||||||
|
if (!n.routerKey) problems.push("条件分支「" + (n.name || n.id) + "」未配置判断服务(routerKey)");
|
||||||
|
const outs = edges.filter((e: any) => e.from === n.id);
|
||||||
|
if (outs.length < 2) {
|
||||||
|
problems.push("条件分支「" + (n.name || n.id) + "」至少 2 条出边(当前 " + outs.length + ")");
|
||||||
|
} else {
|
||||||
|
const names = new Set<string>();
|
||||||
|
outs.forEach((e: any) => {
|
||||||
|
const label = e.name?.trim() || "";
|
||||||
|
if (!label) {
|
||||||
|
problems.push(`条件分支「${n.name || n.id}」每个出口必须命名(出口名即判断依据)`);
|
||||||
|
} else if (names.has(label)) {
|
||||||
|
problems.push(`条件分支「${n.name || n.id}」出口名重复:「${label}」`);
|
||||||
|
} else {
|
||||||
|
names.add(label);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
edges.forEach((e: any) => {
|
edges.forEach((e: any) => {
|
||||||
if (!nodeIds.has(e.to) || !nodeIds.has(e.from)) {
|
if (!nodeIds.has(e.to) || !nodeIds.has(e.from)) {
|
||||||
@@ -674,7 +831,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
:source="sourceJson" @node-config="onCanvasNode" />
|
:source="sourceJson" @node-config="onCanvasNode" />
|
||||||
|
|
||||||
<main v-if="!loading && editMode === 'cards'" class="ed-canvas">
|
<main v-if="!loading && editMode === 'cards'" class="ed-canvas">
|
||||||
<div class="ed-hint">点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 并行分支)</div>
|
<div class="ed-hint">点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 抄送 / 并行分支)</div>
|
||||||
<div class="ed-flow">
|
<div class="ed-flow">
|
||||||
<div class="capsule start">发起开始</div>
|
<div class="capsule start">发起开始</div>
|
||||||
<NodeList :items="model.list" container="root" />
|
<NodeList :items="model.list" container="root" />
|
||||||
@@ -696,6 +853,8 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<ApprovalDrawer v-model="nodeDrawer" :node="editingNode" @removed="nodeDrawer = false" />
|
<ApprovalDrawer v-model="nodeDrawer" :node="editingNode" @removed="nodeDrawer = false" />
|
||||||
|
<NotifyDrawer v-model="notifyDrawer" :node="editingNotify" @removed="notifyDrawer = false" />
|
||||||
|
<ConditionDrawer v-model="conditionDrawer" :node="editingCondition" @removed="conditionDrawer = false" />
|
||||||
<SettingsDrawer v-model="settingsDrawer" :settings="model.settings" @save="onUrgeSave" />
|
<SettingsDrawer v-model="settingsDrawer" :settings="model.settings" @save="onUrgeSave" />
|
||||||
<JsonDrawer v-model="jsonDrawer" :def="defJson" @applied="onJsonApplied" />
|
<JsonDrawer v-model="jsonDrawer" :def="defJson" @applied="onJsonApplied" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ export interface ApprovalNode {
|
|||||||
deadlineHours: number | null;
|
deadlineHours: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 抄送节点(2026-09-23):瞬时知会,不产生待办;receivers 复用审批人结构(DEPT_KEY/USERS) */
|
||||||
|
export interface NotifyNode {
|
||||||
|
uid: string;
|
||||||
|
kind: "notify";
|
||||||
|
name: string;
|
||||||
|
receivers: Assignee | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ParallelNode {
|
export interface ParallelNode {
|
||||||
uid: string;
|
uid: string;
|
||||||
kind: "parallel";
|
kind: "parallel";
|
||||||
@@ -37,7 +45,23 @@ export interface ParallelNode {
|
|||||||
branches: EditorItem[][];
|
branches: EditorItem[][];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EditorItem = ApprovalNode | ParallelNode;
|
/**
|
||||||
|
* 条件分支容器(2026-09-24 CONDITION):运行时同步调外部判断服务(flw_router,routerKey 寻址)
|
||||||
|
* 按返回出口名互斥选路——与并行的本质差异:无 JOIN 语义,分支尾隐式汇合到容器后继。
|
||||||
|
* outlets[i] 即第 i 条分支首边的 name(判断接口的 outlets 契约),必须非空且唯一。
|
||||||
|
*/
|
||||||
|
export interface ConditionNode {
|
||||||
|
uid: string;
|
||||||
|
kind: "condition";
|
||||||
|
name: string;
|
||||||
|
/** 判断服务注册键(flw_router.router_key,发布校验须已注册且启用) */
|
||||||
|
routerKey: string;
|
||||||
|
/** 各分支出口名(与 branches 一一对应) */
|
||||||
|
outlets: string[];
|
||||||
|
branches: EditorItem[][];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EditorItem = ApprovalNode | NotifyNode | ParallelNode | ConditionNode;
|
||||||
|
|
||||||
export interface UrgeSetting {
|
export interface UrgeSetting {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -72,6 +96,10 @@ export interface DefNodeJson {
|
|||||||
type: string;
|
type: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
assignee?: Assignee;
|
assignee?: Assignee;
|
||||||
|
/** NOTIFY 节点的抄送接收人(与审批人同结构) */
|
||||||
|
receivers?: Assignee;
|
||||||
|
/** CONDITION 节点的判断服务注册键(flw_router.router_key) */
|
||||||
|
routerKey?: string;
|
||||||
countersign?: Countersign;
|
countersign?: Countersign;
|
||||||
waitFor?: string;
|
waitFor?: string;
|
||||||
deadlineHours?: number;
|
deadlineHours?: number;
|
||||||
@@ -125,10 +153,26 @@ export function createApproval(name = "审批节点", assignee: Assignee | null
|
|||||||
return { uid: newUid(), kind: "approval", name, assignee, countersign: "ANY", deadlineHours: null };
|
return { uid: newUid(), kind: "approval", name, assignee, countersign: "ANY", deadlineHours: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function createNotify(name = "抄送节点"): NotifyNode {
|
||||||
|
return { uid: newUid(), kind: "notify", name, receivers: { type: "USERS", userIds: [] } };
|
||||||
|
}
|
||||||
|
|
||||||
export function createParallel(): ParallelNode {
|
export function createParallel(): ParallelNode {
|
||||||
return { uid: newUid(), kind: "parallel", branches: [[createApproval()], [createApproval()]] };
|
return { uid: newUid(), kind: "parallel", branches: [[createApproval()], [createApproval()]] };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 条件分支:默认 2 分支占位出口名,插入后弹配置抽屉选判断服务 */
|
||||||
|
export function createCondition(): ConditionNode {
|
||||||
|
return {
|
||||||
|
uid: newUid(),
|
||||||
|
kind: "condition",
|
||||||
|
name: "条件判断",
|
||||||
|
routerKey: "",
|
||||||
|
outlets: ["出口一", "出口二"],
|
||||||
|
branches: [[createApproval()], [createApproval()]],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 新建流程的默认模板:一个待配置的审批节点(引导用户点卡片配置) */
|
/** 新建流程的默认模板:一个待配置的审批节点(引导用户点卡片配置) */
|
||||||
export function createEmptyModel(): EditorModel {
|
export function createEmptyModel(): EditorModel {
|
||||||
return {
|
return {
|
||||||
@@ -142,12 +186,18 @@ export function createEmptyModel(): EditorModel {
|
|||||||
// ==================== 遍历/查询 ====================
|
// ==================== 遍历/查询 ====================
|
||||||
|
|
||||||
export interface ContainerRef {
|
export interface ContainerRef {
|
||||||
/** 容器标识:root 或 `${parallelUid}:${branchIndex}` */
|
/** 容器标识:root 或 `${容器uid}:${branchIndex}`(并行/条件分支容器同格式,uid 天然不撞) */
|
||||||
container: string;
|
container: string;
|
||||||
index: number;
|
index: number;
|
||||||
items: EditorItem[];
|
items: EditorItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 容器型节点(并行/条件分支)的分支数组取用(resolveContainer/removeItem 等共用口径) */
|
||||||
|
export function branchesOf(item: EditorItem | null): EditorItem[][] | null {
|
||||||
|
if (item && (item.kind === "parallel" || item.kind === "condition")) return item.branches;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function walkItems(
|
export function walkItems(
|
||||||
items: EditorItem[],
|
items: EditorItem[],
|
||||||
fn: (item: EditorItem, container: string, index: number) => void,
|
fn: (item: EditorItem, container: string, index: number) => void,
|
||||||
@@ -155,8 +205,9 @@ export function walkItems(
|
|||||||
): void {
|
): void {
|
||||||
items.forEach((item, index) => {
|
items.forEach((item, index) => {
|
||||||
fn(item, container, index);
|
fn(item, container, index);
|
||||||
if (item.kind === "parallel") {
|
const branches = branchesOf(item);
|
||||||
item.branches.forEach((branch, bi) => walkItems(branch, fn, `${item.uid}:${bi}`));
|
if (branches) {
|
||||||
|
branches.forEach((branch, bi) => walkItems(branch, fn, `${item.uid}:${bi}`));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -174,13 +225,13 @@ export function findParallel(model: EditorModel, uid: string): ParallelNode | nu
|
|||||||
return item && item.kind === "parallel" ? item : null;
|
return item && item.kind === "parallel" ? item : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解析容器标识为可变数组引用(插入/删除用) */
|
/** 解析容器标识为可变数组引用(插入/删除用;并行/条件分支容器同格式) */
|
||||||
export function resolveContainer(model: EditorModel, container: string): EditorItem[] | null {
|
export function resolveContainer(model: EditorModel, container: string): EditorItem[] | null {
|
||||||
if (container === "root") return model.list;
|
if (container === "root") return model.list;
|
||||||
const [parallelUid, bi] = container.split(":");
|
const [parentUid, bi] = container.split(":");
|
||||||
const parent = findParallel(model, parallelUid);
|
const branches = branchesOf(findItem(model, parentUid));
|
||||||
if (!parent) return null;
|
if (!branches) return null;
|
||||||
return parent.branches[Number(bi)] || null;
|
return branches[Number(bi)] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 按 uid 删除节点(跨层级);返回是否命中 */
|
/** 按 uid 删除节点(跨层级);返回是否命中 */
|
||||||
@@ -194,26 +245,40 @@ export function removeItem(model: EditorModel, uid: string): boolean {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
items.forEach((it) => {
|
items.forEach((it) => {
|
||||||
if (!removed && it.kind === "parallel") it.branches.forEach(rec);
|
if (!removed) branchesOf(it)?.forEach(rec);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
rec(model.list);
|
rec(model.list);
|
||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 收集全部 USERS 型审批人 userId(用于补全姓名快照) */
|
/** 收集全部 USERS 型审批人/抄送对象 userId(用于补全姓名快照) */
|
||||||
export function collectUserIds(model: EditorModel): number[] {
|
export function collectUserIds(model: EditorModel): number[] {
|
||||||
const ids = new Set<number>();
|
const ids = new Set<number>();
|
||||||
walkItems(model.list, (item) => {
|
walkItems(model.list, (item) => {
|
||||||
if (item.kind === "approval" && item.assignee?.type === "USERS") {
|
if (item.kind === "approval" && item.assignee?.type === "USERS") {
|
||||||
item.assignee.userIds.forEach((id) => ids.add(Number(id)));
|
item.assignee.userIds.forEach((id) => ids.add(Number(id)));
|
||||||
}
|
}
|
||||||
|
if (item.kind === "notify" && item.receivers?.type === "USERS") {
|
||||||
|
item.receivers.userIds.forEach((id) => ids.add(Number(id)));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return [...ids];
|
return [...ids];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== 展示辅助 ====================
|
// ==================== 展示辅助 ====================
|
||||||
|
|
||||||
|
/** flw_router 注册下拉项(管理端 /admin/router/list;status=1 启用) */
|
||||||
|
export interface RouterOption {
|
||||||
|
id: number;
|
||||||
|
routerKey: string;
|
||||||
|
name: string;
|
||||||
|
serviceKey: string;
|
||||||
|
path: string;
|
||||||
|
basePath?: string | null;
|
||||||
|
status: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeptOption {
|
export interface DeptOption {
|
||||||
deptKey: string;
|
deptKey: string;
|
||||||
deptName: string;
|
deptName: string;
|
||||||
@@ -242,18 +307,35 @@ export function nodeTags(n: ApprovalNode): string[] {
|
|||||||
return tags;
|
return tags;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 抄送对象摘要文案(抄送卡片第二行;口径同 assigneeText,前缀换"抄送") */
|
||||||
|
export function receiversText(r: Assignee | null, depts: DeptOption[], userNames: Record<number, string>): string {
|
||||||
|
if (!r) return "未配置抄送对象";
|
||||||
|
if (r.type === "DEPT_KEY") {
|
||||||
|
if (!r.deptKey) return "抄送:未选择科室";
|
||||||
|
const d = depts.find((x) => x.deptKey === r.deptKey);
|
||||||
|
return d
|
||||||
|
? `抄送:${d.deptName || d.deptKey}(${d.deptKey})· ${d.count} 人`
|
||||||
|
: `抄送:${r.deptKey}(未配置映射)`;
|
||||||
|
}
|
||||||
|
if (!r.userIds.length) return "抄送:未选择用户";
|
||||||
|
return `抄送:${r.userIds.map((id) => userNames[id] || `#${id}`).join("、")}`;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 编辑器上下文(画布子树共享,避免逐层透传回调) ====================
|
// ==================== 编辑器上下文(画布子树共享,避免逐层透传回调) ====================
|
||||||
|
|
||||||
export interface EditorCtx {
|
export interface EditorCtx {
|
||||||
model: EditorModel;
|
model: EditorModel;
|
||||||
depts: DeptOption[];
|
depts: DeptOption[];
|
||||||
userNames: Record<number, string>;
|
userNames: Record<number, string>;
|
||||||
|
/** 条件分支的判断服务注册下拉(flw_router 启用项;拉取失败为空数组,校验降级提示) */
|
||||||
|
routers: RouterOption[];
|
||||||
issueOf: (uid: string) => { level: "error" | "warn"; text: string }[];
|
issueOf: (uid: string) => { level: "error" | "warn"; text: string }[];
|
||||||
openNode: (uid: string) => void;
|
openNode: (uid: string) => void;
|
||||||
removeNode: (uid: string) => void;
|
removeNode: (uid: string) => void;
|
||||||
insertItem: (container: string, index: number, kind: "approval" | "parallel") => void;
|
insertItem: (container: string, index: number, kind: "approval" | "parallel" | "notify" | "condition") => void;
|
||||||
addBranch: (parallelUid: string) => void;
|
/** 添加分支(并行/条件分支容器通用;条件分支同步补一个占位出口名) */
|
||||||
removeBranch: (parallelUid: string, index: number) => void;
|
addBranch: (containerUid: string) => void;
|
||||||
|
removeBranch: (containerUid: string, index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const EDITOR_CTX: InjectionKey<EditorCtx> = Symbol("flowDefEditorCtx");
|
export const EDITOR_CTX: InjectionKey<EditorCtx> = Symbol("flowDefEditorCtx");
|
||||||
|
|||||||
@@ -5,7 +5,15 @@
|
|||||||
* 结构类规则(id 唯一/度约束/孤儿/环/START·END 唯一)由结构化生成保证,前端无需校验。
|
* 结构类规则(id 唯一/度约束/孤儿/环/START·END 唯一)由结构化生成保证,前端无需校验。
|
||||||
* 后端 DefValidator 仍是发布期权威,此处仅作体验前置。
|
* 后端 DefValidator 仍是发布期权威,此处仅作体验前置。
|
||||||
*/
|
*/
|
||||||
import { BRANCH_LABELS, DeptOption, EditorItem, EditorModel, MAX_BRANCHES, MIN_BRANCHES } from "./model";
|
import {
|
||||||
|
BRANCH_LABELS,
|
||||||
|
DeptOption,
|
||||||
|
EditorItem,
|
||||||
|
EditorModel,
|
||||||
|
MAX_BRANCHES,
|
||||||
|
MIN_BRANCHES,
|
||||||
|
RouterOption,
|
||||||
|
} from "./model";
|
||||||
|
|
||||||
export type IssueLevel = "error" | "warn";
|
export type IssueLevel = "error" | "warn";
|
||||||
|
|
||||||
@@ -20,7 +28,7 @@ export interface Issue {
|
|||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function validateModel(model: EditorModel, depts: DeptOption[]): Issue[] {
|
export function validateModel(model: EditorModel, depts: DeptOption[], routers: RouterOption[] = []): Issue[] {
|
||||||
const issues: Issue[] = [];
|
const issues: Issue[] = [];
|
||||||
const push = (uid: string, level: IssueLevel, path: string, msg: string): void => {
|
const push = (uid: string, level: IssueLevel, path: string, msg: string): void => {
|
||||||
issues.push({ uid, level, path, msg, text: `${path}:${msg}` });
|
issues.push({ uid, level, path, msg, text: `${path}:${msg}` });
|
||||||
@@ -50,6 +58,62 @@ export function validateModel(model: EditorModel, depts: DeptOption[]): Issue[]
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.kind === "notify") {
|
||||||
|
// 抄送节点(运行时宽容:解析不出人只跳过不阻塞,但发布期后端仍拦配置缺失)
|
||||||
|
if (!item.name.trim()) push(item.uid, "error", path, "节点名称不能为空");
|
||||||
|
const r = item.receivers;
|
||||||
|
if (!r) {
|
||||||
|
push(item.uid, "error", path, "未配置抄送对象");
|
||||||
|
} else if (r.type === "DEPT_KEY") {
|
||||||
|
if (!r.deptKey) {
|
||||||
|
push(item.uid, "error", path, "未选择抄送科室");
|
||||||
|
} else {
|
||||||
|
const d = depts.find((x) => x.deptKey === r.deptKey);
|
||||||
|
if (!d) {
|
||||||
|
push(item.uid, "warn", path, `科室映射「${r.deptKey}」不存在,发布会被拦截`);
|
||||||
|
} else if (!d.count) {
|
||||||
|
push(item.uid, "warn", path, `科室「${d.deptName || r.deptKey}」无启用人员,抄送会落空`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (!r.userIds.length) {
|
||||||
|
push(item.uid, "error", path, "抄送用户不能为空");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.kind === "condition") {
|
||||||
|
// 条件分支(2026-09-24):判断服务必选(未注册仅 warn,发布期后端权威拦截);
|
||||||
|
// 出口名即判断接口 outlets 契约,非空唯一(镜像后端 CONDITION 校验口径)
|
||||||
|
if (!item.name.trim()) push(item.uid, "error", path, "节点名称不能为空");
|
||||||
|
if (!item.routerKey) {
|
||||||
|
push(item.uid, "error", path, "未选择判断服务(发布后运行到该节点会挂起)");
|
||||||
|
} else if (routers.length && !routers.some((r) => r.routerKey === item.routerKey && r.status === 1)) {
|
||||||
|
push(item.uid, "warn", path, `判断服务「${item.routerKey}」未注册或已停用,发布会被拦截`);
|
||||||
|
}
|
||||||
|
if (item.branches.length < MIN_BRANCHES) {
|
||||||
|
push(item.uid, "error", path, `条件分支至少保留 ${MIN_BRANCHES} 个`);
|
||||||
|
}
|
||||||
|
if (item.branches.length > MAX_BRANCHES) {
|
||||||
|
push(item.uid, "error", path, `条件分支最多 ${MAX_BRANCHES} 个`);
|
||||||
|
}
|
||||||
|
item.outlets.forEach((o, i) => {
|
||||||
|
if (!o.trim()) {
|
||||||
|
push(item.uid, "error", path, `第 ${i + 1} 条分支的出口名不能为空`);
|
||||||
|
} else if (item.outlets.indexOf(o) !== i) {
|
||||||
|
push(item.uid, "error", path, `出口名重复:「${o}」(出口名即判断依据,必须唯一)`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
item.branches.forEach((branch, i) => {
|
||||||
|
const branchPath = `${path}·出口「${item.outlets[i] || BRANCH_LABELS[i] || i + 1}」`;
|
||||||
|
if (!branch.length) {
|
||||||
|
push(item.uid, "error", branchPath, "分支为空,请添加节点");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rec(branch, branchPath);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (item.branches.length < MIN_BRANCHES) {
|
if (item.branches.length < MIN_BRANCHES) {
|
||||||
push(item.uid, "error", path, `并行分支至少保留 ${MIN_BRANCHES} 个`);
|
push(item.uid, "error", path, `并行分支至少保留 ${MIN_BRANCHES} 个`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ const KIND_DOT: Record<string, string> = {
|
|||||||
TRANSFER: "cyan",
|
TRANSFER: "cyan",
|
||||||
INVALID: "gray",
|
INVALID: "gray",
|
||||||
PENDING: "blue",
|
PENDING: "blue",
|
||||||
|
CC: "cyan",
|
||||||
|
ROUTE: "purple",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 事件类型 → 动作标签主题色(标签文案取 TASK_STATUS,避免两处口径漂移) */
|
/** 事件类型 → 动作标签主题色(标签文案取 TASK_STATUS,避免两处口径漂移) */
|
||||||
@@ -38,6 +40,8 @@ const KIND_TAG: Record<string, string> = {
|
|||||||
TRANSFER: "cyan",
|
TRANSFER: "cyan",
|
||||||
INVALID: "default",
|
INVALID: "default",
|
||||||
PENDING: "blue",
|
PENDING: "blue",
|
||||||
|
CC: "cyan",
|
||||||
|
ROUTE: "purple",
|
||||||
};
|
};
|
||||||
|
|
||||||
const rows = computed(() => props.trace || []);
|
const rows = computed(() => props.trace || []);
|
||||||
@@ -59,6 +63,7 @@ function roundTitle(round: number): string {
|
|||||||
|
|
||||||
/** 操作者:按人员=姓名;按科室=科室名/指定办理人/提交人(不暴露具体人名) */
|
/** 操作者:按人员=姓名;按科室=科室名/指定办理人/提交人(不暴露具体人名) */
|
||||||
function actor(t: any): string {
|
function actor(t: any): string {
|
||||||
|
if (t.kind === "ROUTE") return "流程引擎";
|
||||||
if (props.mode !== "dept") {
|
if (props.mode !== "dept") {
|
||||||
return t.assigneeName || t.assigneeId || "-";
|
return t.assigneeName || t.assigneeId || "-";
|
||||||
}
|
}
|
||||||
@@ -74,8 +79,11 @@ function actor(t: any): string {
|
|||||||
: (t.assigneeName || "-");
|
: (t.assigneeName || "-");
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 动作标签:失效行标"失效"而不是笼统的"提交" */
|
/** 动作标签:失效行标"失效"而不是笼统的"提交";抄送行(flw_cc,非任务无状态码)标"已抄送";
|
||||||
|
* 路由判断行(flw_route_log)标"系统判断"——outlet 挂在行上,空=判断失败挂起 */
|
||||||
function actionText(t: any): string {
|
function actionText(t: any): string {
|
||||||
|
if (t.kind === "CC") return "已抄送";
|
||||||
|
if (t.kind === "ROUTE") return "系统判断";
|
||||||
return TASK_STATUS[t.status] || String(t.status ?? "");
|
return TASK_STATUS[t.status] || String(t.status ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,6 +107,10 @@ function fmtTime(v: string | null | undefined): string {
|
|||||||
<Tag size="small" :color="KIND_TAG[t.kind] || 'default'">{{ actionText(t) }}</Tag>
|
<Tag size="small" :color="KIND_TAG[t.kind] || 'default'">{{ actionText(t) }}</Tag>
|
||||||
<span class="opinion" v-if="t.opinion">「{{ t.opinion }}」</span>
|
<span class="opinion" v-if="t.opinion">「{{ t.opinion }}」</span>
|
||||||
<span class="opinion" v-if="t.edgeName">去向:{{ t.edgeName }}</span>
|
<span class="opinion" v-if="t.edgeName">去向:{{ t.edgeName }}</span>
|
||||||
|
<span class="opinion route" v-if="t.kind === 'ROUTE' && t.outlet">→ {{ t.outlet }}</span>
|
||||||
|
<span class="opinion route-hung" v-if="t.kind === 'ROUTE' && !t.outlet">
|
||||||
|
判断失败已挂起(第 {{ t.attempt || 1 }} 次),等待管理端重跑路由
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="time">{{ fmtTime(t.time || t.handleTime || t.arriveTime) }}</div>
|
<div class="time">{{ fmtTime(t.time || t.handleTime || t.arriveTime) }}</div>
|
||||||
</TimelineItem>
|
</TimelineItem>
|
||||||
@@ -122,6 +134,8 @@ function fmtTime(v: string | null | undefined): string {
|
|||||||
.line { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
|
.line { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; }
|
||||||
.node { font-weight: 600; }
|
.node { font-weight: 600; }
|
||||||
.opinion { color: #666; }
|
.opinion { color: #666; }
|
||||||
|
.opinion.route { color: #722ed1; font-weight: 600; }
|
||||||
|
.opinion.route-hung { color: #ed4014; }
|
||||||
.time { color: #aaa; font-size: 12px; }
|
.time { color: #aaa; font-size: 12px; }
|
||||||
.muted { color: #999; }
|
.muted { color: #999; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user