feat: 办理人映射页一键拉取教师组(预览确认+批量导入)+ 流程编辑器存量
build / build (push) Successful in 2m6s

- Maps.vue:一键拉取教师组按钮 + 预览弹窗(科室/成员明细)+ 确认导入
- flow.ts:mapGroupPreview/mapImportGroups API
- 含流程编辑器(def-editor)、路由 catch-all、示意图入口存量
This commit is contained in:
chenxing
2026-09-16 01:21:10 +08:00
parent 670988816c
commit 842a4cfb14
15 changed files with 2105 additions and 72 deletions
+8
View File
@@ -114,6 +114,14 @@ class FlowApi {
mapLookupUser(userId: number) { mapLookupUser(userId: number) {
return get(`${ROOT}/admin/map/lookupUser`, { userId }); return get(`${ROOT}/admin/map/lookupUser`, { userId });
} }
/** 一键拉取教师组·预览(学校子树教师组容器 → 科室+组内成员) */
mapGroupPreview() {
return get(`${ROOT}/admin/map/groupPreview`);
}
/** 一键拉取教师组·导入(批量落映射,已存在跳过) */
mapImportGroups() {
return post(`${ROOT}/admin/map/importGroups`);
}
// ==================== 管理面:回调注册/日志/死信 ==================== // ==================== 管理面:回调注册/日志/死信 ====================
regList() { regList() {
+8
View File
@@ -52,6 +52,14 @@ const routes: RouteRecordRaw[] = [
component: () => import("@/views/flow/admin/Defs.vue"), component: () => import("@/views/flow/admin/Defs.vue"),
meta: { title: "流程定义", modId: 103 }, meta: { title: "流程定义", modId: 103 },
}, },
{
// 流程编辑器(钉钉式卡片流;深链页不挂菜单,同 /diagram 模式)。
// query:空=新建;id=编辑草稿;id+baseGuid=升版本(载入该版内容为底稿)
path: "/admin/defs/editor",
name: "flow_def_editor",
component: () => import("@/views/flow/admin/def-editor/index.vue"),
meta: { title: "流程编辑器" },
},
{ {
path: "/admin/maps", path: "/admin/maps",
name: "flow_admin_maps", name: "flow_admin_maps",
+13 -72
View File
@@ -1,5 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
// 流程定义管理(M1:JSON 文本编辑模式;发布跑静态校验,把"任务无主"拦在发布期) // 流程定义管理:列表 + 发布/停用/升版本/版本历史/图示。
// 新建与编辑走独立的全屏可视化编辑器(/admin/defs/editor,钉钉式卡片流,见《流程中心流程编辑器方案.md》);
// 本页不再直接编辑 JSON 文本(原 M1 textarea 已由编辑器取代,编辑器内置"源码"高级模式兜底)。
import { ref, onMounted } from "vue"; import { ref, onMounted } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import { Message } from "view-ui-plus"; import { Message } from "view-ui-plus";
@@ -10,29 +12,9 @@ const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "
const loading = ref(false); const loading = ref(false);
const rows = ref<any[]>([]); const rows = ref<any[]>([]);
const editVisible = ref(false);
const saving = ref(false);
const form = ref<any>({ id: null, code: "", name: "", contentJson: "" });
const versionVisible = ref(false); const versionVisible = ref(false);
const versions = ref<any[]>([]); const versions = ref<any[]>([]);
const SAMPLE_JSON = `{
"code": "sample_flow",
"name": "示例流程",
"start": "start",
"nodes": [
{"id": "start", "type": "START", "name": "开始"},
{"id": "n1", "type": "APPROVAL", "name": "科室审核",
"assignee": {"type": "DEPT_KEY", "deptKey": "ybb"}, "countersign": "ALL"},
{"id": "end", "type": "END"}
],
"edges": [
{"from": "start", "to": "n1"},
{"from": "n1", "to": "end"}
],
"settings": {"rejectPolicy": "END_REJECTED", "urge": {"enabled": true, "intervalHours": 24, "maxTimes": 3}}
}`;
const columns = [ const columns = [
{ title: "流程", key: "name", minWidth: 180 }, { title: "流程", key: "name", minWidth: 180 },
{ title: "编码", key: "code", width: 160 }, { title: "编码", key: "code", width: 160 },
@@ -67,42 +49,20 @@ async function load() {
} }
} }
function onEdit(row: any) { /** 新建:编辑器空模板(默认含一个待配置审批节点) */
flowApi.defGet(row.id).send().then((d: any) => {
form.value = { id: d.id, code: d.code, name: d.name, contentJson: d.contentJson };
editVisible.value = true;
});
}
function onNew() { function onNew() {
form.value = { id: null, code: "", name: "", contentJson: SAMPLE_JSON }; router.push({ path: "/admin/defs/editor" });
editVisible.value = true;
} }
async function onNewVersion(row: any) { /** 编辑草稿:编辑器载入该草稿内容,保存直接改本行 */
// 升版本:以已发布内容为底稿建草稿(flowGuid 不变,version 自动 +1) function onEdit(row: any) {
const d: any = await flowApi.defGet(row.id).send(); router.push({ path: "/admin/defs/editor", query: { id: String(row.id) } });
form.value = { id: null, code: row.code, name: row.name, contentJson: d.contentJson, baseGuid: row.flowGuid }; }
/** 升版本:以已发布内容为底稿,保存时走 baseGuid 建新草稿版本(version 自动 +1,flowGuid 不变) */
function onNewVersion(row: any) {
Message.info("已载入当前版本内容,保存即创建新草稿版本"); Message.info("已载入当前版本内容,保存即创建新草稿版本");
editVisible.value = true; router.push({ path: "/admin/defs/editor", query: { id: String(row.id), baseGuid: row.flowGuid } });
}
async function onSave() {
saving.value = true;
try {
await flowApi.defSave({
id: form.value.id || undefined,
code: form.value.code,
name: form.value.name,
contentJson: form.value.contentJson,
baseGuid: form.value.baseGuid,
}).send();
Message.success("已保存");
editVisible.value = false;
load();
} finally {
saving.value = false;
}
} }
async function onPublish(row: any) { async function onPublish(row: any) {
@@ -140,25 +100,6 @@ onMounted(load);
</div> </div>
<Table :columns="columns" :data="rows" :loading="loading" stripe /> <Table :columns="columns" :data="rows" :loading="loading" stripe />
<Modal v-model="editVisible" :title="form.id ? '编辑流程' : '新建流程'" width="760" :mask-closable="false">
<Form :label-width="80">
<FormItem label="编码">
<Input v-model="form.code" :disabled="!!form.id" placeholder="流程编码(内部管理键,发布后不可改)" />
</FormItem>
<FormItem label="名称">
<Input v-model="form.name" placeholder="流程名" />
</FormItem>
<FormItem label="定义 JSON">
<Input v-model="form.contentJson" type="textarea" :rows="16"
placeholder="§5.1 流程定义 JSON(START/END 唯一、无孤儿无环、DEPT_KEY 需先配好映射)" />
</FormItem>
</Form>
<template #footer>
<Button @click="editVisible = false">取消</Button>
<Button type="primary" :loading="saving" @click="onSave">保存(草稿)</Button>
</template>
</Modal>
<Modal v-model="versionVisible" title="版本历史" width="640"> <Modal v-model="versionVisible" title="版本历史" width="640">
<Table :columns="[ <Table :columns="[
{ title: '版本', key: 'version', width: 80 }, { title: '版本', key: 'version', width: 80 },
+51
View File
@@ -13,6 +13,45 @@ const saving = ref(false);
const form = ref<any>({ deptKey: "", deptName: "", userId: null, userName: "" }); const form = ref<any>({ deptKey: "", deptName: "", userId: null, userName: "" });
const lookingUp = ref(false); const lookingUp = ref(false);
// 一键拉取教师组(预览确认后批量导入)
const groupVisible = ref(false);
const groupLoading = ref(false);
const groupImporting = ref(false);
const groupRows = ref<any[]>([]);
const groupColumns = [
{ title: "科室编码", key: "deptKey", width: 100 },
{ title: "科室名称", key: "deptName", width: 160 },
{ title: "成员数", key: "memberCount", width: 90, render: (h: any, p: any) => h("span", String(p.row.users?.length || 0)) },
{ title: "成员", key: "members", render: (h: any, p: any) => h("span", (p.row.users || []).map((u: any) => `${u.userName}(${u.userId})`).join("、") || "(空,导入后可手工补)") },
];
async function onGroupPreview() {
groupVisible.value = true;
groupLoading.value = true;
try {
groupRows.value = (await flowApi.mapGroupPreview().send()) || [];
} catch (e: any) {
Message.error(e?.message || "教师组拉取失败(UserBase 不可达?)");
groupVisible.value = false;
} finally {
groupLoading.value = false;
}
}
async function onGroupImport() {
groupImporting.value = true;
try {
const r: any = await flowApi.mapImportGroups().send();
Message.success(`导入完成:${r?.depts ?? 0} 个科室 / 新增 ${r?.users ?? 0} 条映射 / 跳过已存在 ${r?.skippedExisting ?? 0} 条`);
groupVisible.value = false;
load();
} catch (e: any) {
Message.error(e?.message || "导入失败");
} finally {
groupImporting.value = false;
}
}
const columns = [ const columns = [
{ title: "科室编码", key: "deptKey", width: 140 }, { title: "科室编码", key: "deptKey", width: 140 },
{ title: "科室名称", key: "deptName", width: 160, render: (h: any, p: any) => h("span", p.row.deptName || "-") }, { title: "科室名称", key: "deptName", width: 160, render: (h: any, p: any) => h("span", p.row.deptName || "-") },
@@ -92,6 +131,7 @@ onMounted(load);
<Input v-model="deptKeyFilter" placeholder="按科室编码筛选" style="width: 200px" clearable @on-enter="load" /> <Input v-model="deptKeyFilter" placeholder="按科室编码筛选" style="width: 200px" clearable @on-enter="load" />
<Button @click="load">查询</Button> <Button @click="load">查询</Button>
<Button type="primary" @click="onAdd">新增映射</Button> <Button type="primary" @click="onAdd">新增映射</Button>
<Button type="info" :loading="groupLoading" @click="onGroupPreview">一键拉取教师组</Button>
</div> </div>
<Table :columns="columns" :data="rows" :loading="loading" stripe /> <Table :columns="columns" :data="rows" :loading="loading" stripe />
@@ -116,6 +156,17 @@ onMounted(load);
<Button type="primary" :loading="saving" @click="onSave">保存</Button> <Button type="primary" :loading="saving" @click="onSave">保存</Button>
</template> </template>
</Modal> </Modal>
<Modal v-model="groupVisible" title="一键拉取教师组(UserBase 学校组树)" width="720" :mask-closable="false">
<p style="margin-bottom: 8px; color: #808695">
按学校组树中类型=教师组(type 7)的容器组取其子组作为科室(无子组时容器自身即科室),科室编码=UserBase 组 id,成员=组内用户。已存在且启用中的映射自动跳过。
</p>
<Table :columns="groupColumns" :data="groupRows" :loading="groupLoading" max-height="360" stripe />
<template #footer>
<Button @click="groupVisible = false">取消</Button>
<Button type="primary" :loading="groupImporting" :disabled="!groupRows.length" @click="onGroupImport">确认导入</Button>
</template>
</Modal>
</div> </div>
</template> </template>
@@ -0,0 +1,259 @@
<script setup lang="ts">
// 审批节点配置抽屉(方案 §3.4):打开时把节点值拷进本地表单,确定时写回——
// 分段控件切换只改本地状态,不会因重渲染丢失未保存输入(原型阶段踩过该坑)。
import { computed, inject, reactive, ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { flowApi } from "@/api/module/flow";
import { ApprovalNode, EDITOR_CTX } from "../model";
const props = defineProps<{
modelValue: boolean;
node: ApprovalNode | 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[],
countersign: "ALL" as "ALL" | "ANY",
deadlineHours: null as number | null,
});
const lookupId = ref<number | null>(null);
const lookingUp = ref(false);
function syncFromNode(): void {
const n = props.node;
if (!n) return;
form.name = n.name;
form.type = n.assignee?.type === "USERS" ? "USERS" : "DEPT_KEY";
form.deptKey = n.assignee?.type === "DEPT_KEY" ? n.assignee.deptKey : "";
form.userIds = n.assignee?.type === "USERS" ? [...n.assignee.userIds] : [];
form.countersign = n.countersign;
form.deadlineHours = n.deadlineHours;
lookupId.value = null;
}
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;
});
/** 当前科室的映射状态提示(与 validate.ts 同口径:仅提示,不阻断存草稿) */
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 || d.deptKey}」无启用办理人,发布会被拦截`;
return "";
});
function userNameOf(id: number): string {
return ctx.userNames[id] || `#${id}`;
}
async function onLookup(): Promise<void> {
if (!lookupId.value) {
Message.warning("请先填用户ID");
return;
}
const id = Number(lookupId.value);
lookingUp.value = true;
try {
const u: any = await flowApi.mapLookupUser(id).send();
ctx.userNames[id] = u?.name || `#${id}`;
if (!form.userIds.includes(id)) form.userIds.push(id);
lookupId.value = null;
Message.success(`已添加:${u?.name || `#${id}`}`);
} catch {
Message.error("UserBase 检索失败");
} finally {
lookingUp.value = false;
}
}
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.assignee =
form.type === "USERS"
? { type: "USERS", userIds: [...form.userIds] }
: { type: "DEPT_KEY", deptKey: form.deptKey };
n.countersign = form.countersign;
n.deadlineHours = form.deadlineHours && form.deadlineHours > 0 ? form.deadlineHours : null;
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">科室 → 办理人的映射在「办理人映射」页维护;发布时校验至少 1 名启用办理人</div>
</div>
<template v-else>
<div class="fld">
<label>指定用户</label>
<div class="user-add">
<InputNumber v-model="lookupId" :min="1" placeholder="填 UserBase 用户ID" style="flex: 1" />
<Button type="primary" :loading="lookingUp" @click="onLookup">检索并添加</Button>
</div>
<div class="user-tags">
<Tag
v-for="id in form.userIds"
:key="id"
closable
color="blue"
@on-close="removeUser(id)"
>
{{ userNameOf(id) }}
</Tag>
<span v-if="!form.userIds.length" class="hint">尚未添加用户</span>
</div>
<div class="hint">会签时每名用户各自产生一条待办任务</div>
</div>
</template>
<div class="fld">
<label>多人审批方式</label>
<RadioGroup v-model="form.countersign" type="button" button-style="solid">
<Radio label="ALL">会签(全部同意)</Radio>
<Radio label="ANY">或签(一人同意)</Radio>
</RadioGroup>
<div class="hint">
会签:每名办理人都收到任务,全部同意才通过;或签:任一人办理即通过
</div>
<div v-if="form.countersign === 'ANY'" class="hint warn">
⚠ 当前引擎尚未实现「或签」放行:节点仍会等全部办理人同意后才推进(该语义待后续版本生效)
</div>
</div>
<div class="fld">
<label>办理时限(小时)</label>
<InputNumber v-model="form.deadlineHours" :min="1" placeholder="可空" style="width: 100%" />
<div class="hint">超过时限后进入催办提醒(取到达时间 + N 小时)</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;
}
.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>
@@ -0,0 +1,128 @@
<script setup lang="ts">
// 源码高级模式(方案 §3.6/§P7):展示卡片结构生成的 contentJson,也可手改后解析回卡片结构。
// 解析失败在抽屉内报错、模型不动;成功则由外层整体替换编辑树。
import { ref, watch } from "vue";
import { Message } from "view-ui-plus";
import { DefJson } from "../model";
import { ParsedDef, fromContentJson } from "../convert";
const props = defineProps<{
modelValue: boolean;
def: DefJson;
}>();
const emit = defineEmits<{
"update:modelValue": [boolean];
/** 解析成功:把还原后的编辑树交给外层替换 */
applied: [ParsedDef];
}>();
const text = ref("");
const error = ref("");
watch(
() => props.modelValue,
(v) => {
if (!v) return;
// 每次打开都以当前卡片结构为准重新生成,避免残留上次的手改内容
text.value = JSON.stringify(props.def, null, 2);
error.value = "";
},
{ immediate: true }
);
function onApply(): void {
let parsedJson: any;
try {
parsedJson = JSON.parse(text.value);
} catch (e: any) {
error.value = `JSON 语法错误:${e?.message || e}`;
return;
}
try {
const parsed = fromContentJson(parsedJson);
error.value = "";
emit("applied", parsed);
emit("update:modelValue", false);
Message.success("已按 JSON 还原为卡片结构");
} catch (e: any) {
// 不可还原为卡片视图(跨分支连线/环/未知类型等)——提示改用源码模式保存或修正结构
error.value = e?.message || "无法还原为卡片结构";
}
}
async function onCopy(): Promise<void> {
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text.value);
} else {
const ta = document.createElement("textarea");
ta.value = text.value;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
Message.success("已复制");
} catch {
Message.error("复制失败,请手动选择文本复制");
}
}
</script>
<template>
<Drawer
:model-value="modelValue"
title="源码(高级模式)"
:width="620"
:mask-closable="false"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="tip">
以下 JSON 由卡片结构自动生成,字段与后端流程定义模型一致(nodes/edges/settings)。也可手动编辑后点「应用到卡片」;
无法还原为卡片视图的复杂结构会提示错误,请改回卡片方式编辑。
</div>
<Input v-model="text" type="textarea" :rows="24" class="json-area" spellcheck="false" />
<div v-if="error" class="err">× {{ error }}</div>
<div class="dw-foot">
<span style="flex: 1" />
<Button @click="onCopy">复制</Button>
<Button type="primary" @click="onApply">应用到卡片</Button>
</div>
</Drawer>
</template>
<style scoped>
.tip {
color: #808695;
font-size: 12px;
line-height: 1.7;
margin-bottom: 10px;
}
.json-area :deep(textarea) {
font-family: Consolas, Menlo, monospace;
font-size: 12px;
line-height: 1.5;
}
.err {
margin-top: 8px;
color: #ed4014;
font-size: 12px;
line-height: 1.6;
word-break: break-all;
}
.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>
@@ -0,0 +1,136 @@
<script setup lang="ts">
// 审批节点卡片(方案 §3.2):标题 + 审批人摘要 + 会签/时限标签 + 就地校验标注;hover 显示删除
import { computed, inject } from "vue";
import { ApprovalNode, EDITOR_CTX, assigneeText, nodeTags } from "../model";
const props = defineProps<{ node: ApprovalNode }>();
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(() => assigneeText(props.node.assignee, ctx.depts, ctx.userNames));
const tags = computed(() => nodeTags(props.node));
// 删除控件在卡片内,点击不应顺带打开配置抽屉(确认气泡已 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="node-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 v-for="t in tags" :key="t" class="tag">{{ t }}</span>
</div>
<div v-for="(i, k) in issues" :key="k" class="nc-issue" :class="i.level">⚠ {{ i.msg }}</div>
</div>
</template>
<style scoped>
.node-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;
}
.node-card:hover {
border-color: #2d8cf0;
}
.node-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: #e8f3ff;
color: #2d8cf0;
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;
}
.node-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: #f5f7fa;
color: #808695;
}
.nc-issue {
padding: 4px 16px 8px;
font-size: 12px;
}
.nc-issue.error {
color: #ed4014;
}
.nc-issue.warn {
color: #ff9900;
}
</style>
@@ -0,0 +1,32 @@
<script setup lang="ts">
// 节点序列渲染单元(方案 §3.2):容器内的"+卡片+"重复模式;并行分支与主流程共用,天然支持嵌套
import { EditorItem } from "../model";
import NodeCard from "./NodeCard.vue";
import ParallelBox from "./ParallelBox.vue";
import PlusButton from "./PlusButton.vue";
defineProps<{
items: EditorItem[];
/** 容器标识:root 或 `${parallelUid}:${branchIndex}` */
container: string;
}>();
</script>
<template>
<div class="node-list">
<PlusButton :container="container" :index="0" />
<template v-for="(item, i) in items" :key="item.uid">
<NodeCard v-if="item.kind === 'approval'" :node="item" />
<ParallelBox v-else :node="item" />
<PlusButton :container="container" :index="i + 1" />
</template>
</div>
</template>
<style scoped>
.node-list {
display: flex;
flex-direction: column;
align-items: center;
}
</style>
@@ -0,0 +1,136 @@
<script setup lang="ts">
// 并行分发/汇聚容器(方案 §3.2):分支列并排,分支内递归渲染节点序列(可再嵌套并行)
import { computed, inject } from "vue";
import { BRANCH_LABELS, EDITOR_CTX, MAX_BRANCHES, MIN_BRANCHES, ParallelNode } from "../model";
import NodeList from "./NodeList.vue";
const props = defineProps<{ node: ParallelNode }>();
const ctx = inject(EDITOR_CTX)!;
const issues = computed(() => ctx.issueOf(props.node.uid));
const hasError = computed(() => issues.value.some((i) => i.level === "error"));
/** 分支数达下限才允许删分支(删到 1 个已失去并行语义) */
const canRemoveBranch = computed(() => props.node.branches.length > MIN_BRANCHES);
const canAddBranch = computed(() => props.node.branches.length < MAX_BRANCHES);
</script>
<template>
<div class="parallel-box" :class="{ error: hasError }">
<div class="pb-title">
并行分发 / 汇聚
<span class="pb-hint">(所有分支都完成后才继续)</span>
</div>
<div class="pb-cols">
<div v-for="(branch, i) in node.branches" :key="i" class="pb-col">
<div class="pb-col-head">
<span>分支 {{ BRANCH_LABELS[i] || i + 1 }}</span>
<Poptip
v-if="canRemoveBranch"
confirm
title="删除该分支及其节点?"
ok-text="删除"
cancel-text="取消"
transfer
@on-ok="ctx.removeBranch(node.uid, i)"
>
<span class="pb-col-del" title="删除分支">×</span>
</Poptip>
</div>
<NodeList :items="branch" :container="`${node.uid}:${i}`" />
</div>
</div>
<Button v-if="canAddBranch" class="pb-add" size="small" long @click="ctx.addBranch(node.uid)">
+ 添加分支
</Button>
<div v-for="(issue, k) in issues" :key="k" class="pb-issue" :class="issue.level">⚠ {{ issue.text }}</div>
</div>
</template>
<style scoped>
.parallel-box {
border: 1.5px dashed #f2b950;
border-radius: 10px;
padding: 14px 14px 12px;
background: rgba(255, 247, 230, 0.45);
}
.parallel-box.error {
border-color: #ed4014;
}
.pb-title {
text-align: center;
color: #d48806;
font-weight: 600;
font-size: 13px;
margin-bottom: 10px;
}
.pb-hint {
font-weight: 400;
font-size: 11px;
color: #c9a35c;
}
.pb-cols {
display: flex;
gap: 14px;
align-items: flex-start;
justify-content: center;
}
.pb-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;
}
.pb-col-head {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
margin-bottom: 8px;
padding: 0 4px;
color: #d48806;
font-weight: 600;
font-size: 13px;
}
.pb-col-del {
cursor: pointer;
color: #c5c9d1;
font-size: 16px;
line-height: 1;
}
.pb-col-del:hover {
color: #ed4014;
}
.pb-add {
margin-top: 12px;
border-style: dashed;
color: #ff9900;
border-color: #ff9900;
background: transparent;
width: auto;
padding: 0 16px;
display: block;
margin-left: auto;
margin-right: auto;
}
.pb-add:hover {
background: #fff7e6;
color: #ff9900;
border-color: #ff9900;
}
.pb-issue {
text-align: center;
font-size: 12px;
padding-top: 8px;
}
.pb-issue.error {
color: #ed4014;
}
.pb-issue.warn {
color: #ff9900;
}
</style>
@@ -0,0 +1,123 @@
<script setup lang="ts">
// 节点间隙的"+"插入按钮(方案 §3.2):点击弹出节点类型菜单,免连线插入
import { inject, ref } from "vue";
import { EDITOR_CTX } from "../model";
const props = defineProps<{
/** 容器标识:root 或 `${parallelUid}:${branchIndex}` */
container: string;
index: number;
}>();
const ctx = inject(EDITOR_CTX)!;
const visible = ref(false);
function pick(kind: "approval" | "parallel"): void {
visible.value = false;
ctx.insertItem(props.container, props.index, kind);
}
</script>
<template>
<div class="plus-wrap">
<Poptip v-model="visible" trigger="click" placement="right-start" transfer :width="200" popper-class="def-plus-pop">
<div class="plus-btn" title="在此插入节点">+</div>
<template #content>
<div class="menu-item" @click="pick('approval')">
<span class="mi-icon approval">审</span>
<span class="mi-text">
<b>审批节点</b>
<i>配置审批人 / 会签 / 时限</i>
</span>
</div>
<div class="menu-item" @click="pick('parallel')">
<span class="mi-icon parallel">⇉</span>
<span class="mi-text">
<b>并行分支</b>
<i>多分支同时进行,全部完成后继续</i>
</span>
</div>
</template>
</Poptip>
</div>
</template>
<style scoped>
/* 竖向连接线本体,+按钮浮在其上 */
.plus-wrap {
position: relative;
width: 2px;
height: 30px;
background: #ccd3e0;
display: flex;
align-items: center;
justify-content: center;
}
.plus-btn {
position: absolute;
left: 50%;
transform: translateX(-50%);
width: 22px;
height: 22px;
border-radius: 50%;
background: #fff;
border: 1px solid #2d8cf0;
color: #2d8cf0;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 14px;
line-height: 1;
opacity: 0.35;
transition: all 0.15s;
z-index: 2;
user-select: none;
}
.plus-wrap:hover .plus-btn {
opacity: 1;
background: #2d8cf0;
color: #fff;
box-shadow: 0 0 0 4px rgba(45, 140, 240, 0.15);
}
.menu-item {
display: flex;
gap: 10px;
align-items: center;
padding: 8px 10px;
border-radius: 6px;
cursor: pointer;
}
.menu-item:hover {
background: #e8f3ff;
}
.mi-icon {
flex: none;
width: 24px;
height: 24px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 12px;
}
.mi-icon.approval {
background: #2d8cf0;
}
.mi-icon.parallel {
background: #ff9900;
}
.mi-text {
display: flex;
flex-direction: column;
line-height: 1.4;
font-size: 13px;
color: #333;
}
.mi-text i {
font-style: normal;
font-size: 11px;
color: #808695;
}
</style>
@@ -0,0 +1,125 @@
<script setup lang="ts">
// 流程设置抽屉(方案 §3.4):催办开关/间隔/次数;驳回策略当前引擎仅支持 END_REJECTED,做只读说明
import { reactive, watch } from "vue";
import { Message } from "view-ui-plus";
import { EditorSettings, UrgeSetting } from "../model";
const props = defineProps<{
modelValue: boolean;
settings: EditorSettings;
}>();
const emit = defineEmits<{
"update:modelValue": [boolean];
/** 保存催办设置(由外层写回 model.settings,保持 props 只读) */
save: [UrgeSetting];
}>();
const form = reactive({
urgeEnabled: true,
intervalHours: 24,
maxTimes: 3,
});
watch(
() => props.modelValue,
(v) => {
if (!v) return;
form.urgeEnabled = props.settings.urge.enabled;
form.intervalHours = props.settings.urge.intervalHours;
form.maxTimes = props.settings.urge.maxTimes;
},
{ immediate: true }
);
function onOk(): void {
const interval = Number(form.intervalHours) || 0;
const max = Number(form.maxTimes) || 0;
if (form.urgeEnabled && (interval < 1 || max < 1)) {
Message.warning("催办间隔与次数需为正整数");
return;
}
emit("save", {
enabled: form.urgeEnabled,
intervalHours: interval || 24,
maxTimes: max || 3,
});
Message.success("流程设置已更新");
emit("update:modelValue", false);
}
</script>
<template>
<Drawer
:model-value="modelValue"
title="流程设置"
:width="430"
:mask-closable="false"
@update:model-value="emit('update:modelValue', $event)"
>
<div class="fld">
<label>驳回策略</label>
<Input model-value="驳回即终止流程(END_REJECTED)" disabled />
<div class="hint">当前引擎仅支持该策略:任一节点驳回后整个办件终止;提交人可重新提交(复用同一办件重跑)</div>
</div>
<div class="fld">
<label>催办</label>
<div class="urge-line">
<Switch v-model="form.urgeEnabled" />
<span>启用催办提醒</span>
</div>
<div class="hint">关闭后提交人/管理员将无法对该流程的办件发起催办</div>
</div>
<div class="fld">
<label>催办间隔(小时)</label>
<InputNumber v-model="form.intervalHours" :min="1" :disabled="!form.urgeEnabled" style="width: 100%" />
</div>
<div class="fld">
<label>最多催办次数</label>
<InputNumber v-model="form.maxTimes" :min="1" :disabled="!form.urgeEnabled" style="width: 100%" />
</div>
<div class="dw-foot">
<span style="flex: 1" />
<Button @click="emit('update:modelValue', false)">取消</Button>
<Button type="primary" @click="onOk">确定</Button>
</div>
</Drawer>
</template>
<style scoped>
.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;
}
.urge-line {
display: flex;
align-items: center;
gap: 8px;
}
.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>
+260
View File
@@ -0,0 +1,260 @@
/**
* 编辑树 ⇄ contentJson 双向转换(方案 §3.3)。
*
* toContentJson:隐式补 start/end,并行展开为 PARALLEL_SPLIT + PARALLEL_JOIN(waitFor=ALL),
* edges 按容器顺序自动串联——结构类校验(DefValidator 的孤儿/环/度约束)恒不触发。
* fromContentJson:反解 SPLIT/JOIN 配对(各出边可达集求交,取 BFS 序最早的 JOIN),还原为编辑树。
* 无法还原为卡片视图的非常规 DAG 抛中文错误,引导用户用"源码"高级模式。
*
* 算法与《流程编辑器原型.html》同源,已在原型阶段全链路验证(含嵌套并行/环/跨分支连线)。
*/
import {
ApprovalNode,
Assignee,
Countersign,
DEFAULT_URGE,
DefEdgeJson,
DefJson,
DefNodeJson,
EditorItem,
EditorModel,
EditorSettings,
ParallelNode,
createApproval,
newUid,
} from "./model";
/** 编辑树 → 后端 contentJson(code/name 恒取表单值,规避 contentJson 覆盖行字段的既有坑) */
export function toContentJson(model: EditorModel): DefJson {
const nodes: DefNodeJson[] = [];
const edges: DefEdgeJson[] = [];
let seq = 1;
const nid = (p: string) => `${p}${seq++}`;
// 端点 name 仅在既有定义带值时回写(新建流程不生成,保持最小 JSON)
const startNode: DefNodeJson = { id: "start", type: "START" };
if (model.startName) startNode.name = model.startName;
nodes.push(startNode);
const walk = (items: EditorItem[], prev: string): string => {
let cursor = prev;
items.forEach((item) => {
if (item.kind === "approval") {
const id = nid("n");
const node: DefNodeJson = {
id,
type: "APPROVAL",
name: item.name || "审批",
assignee: cloneAssignee(item.assignee),
};
if (item.countersign === "ANY") node.countersign = "ANY";
if (item.deadlineHours) node.deadlineHours = item.deadlineHours;
nodes.push(node);
edges.push({ from: cursor, to: id });
cursor = id;
return;
}
const split = nid("split");
const join = nid("join");
nodes.push({ id: split, type: "PARALLEL_SPLIT" });
nodes.push({ id: join, type: "PARALLEL_JOIN", waitFor: "ALL" });
edges.push({ from: cursor, to: split });
item.branches.forEach((branch) => {
const tail = walk(branch, split);
edges.push({ from: tail, to: join });
});
cursor = join;
});
return cursor;
};
const tail = walk(model.list, "start");
edges.push({ from: tail, to: "end" });
const endNode: DefNodeJson = { id: "end", type: "END" };
if (model.endName) endNode.name = model.endName;
nodes.push(endNode);
return {
code: model.code,
name: model.name,
start: "start",
nodes,
edges,
settings: {
rejectPolicy: "END_REJECTED",
urge: { ...model.settings.urge },
},
};
}
function cloneAssignee(a: Assignee | null): Assignee {
if (!a) return { type: "DEPT_KEY", deptKey: "" };
return a.type === "USERS" ? { type: "USERS", userIds: [...a.userIds] } : { type: "DEPT_KEY", deptKey: a.deptKey };
}
export interface ParsedDef {
code: string;
name: string;
list: EditorItem[];
settings: EditorSettings;
/** 端点 name 原样带出(见 EditorModel.startName 注释) */
startName?: string;
endName?: string;
}
/**
* contentJson → 编辑树。抛错即为"不可还原为卡片视图",调用方展示错误并建议源码模式。
*/
export function fromContentJson(def: any): ParsedDef {
if (!def || !Array.isArray(def.nodes) || !Array.isArray(def.edges)) {
throw new Error("JSON 需包含 nodes 与 edges 数组");
}
const nodes: any[] = def.nodes;
const edges: any[] = def.edges;
const byId: Record<string, any> = {};
nodes.forEach((n) => {
byId[n.id] = n;
});
const out: Record<string, string[]> = {};
edges.forEach((e) => {
if (!byId[e.from] || !byId[e.to]) {
throw new Error(`边引用了不存在的节点:${e.from} → ${e.to}`);
}
(out[e.from] = out[e.from] || []).push(e.to);
});
const starts = nodes.filter((n) => n.type === "START");
if (starts.length !== 1) throw new Error("必须有且仅有 1 个 START 节点");
const first = (out[starts[0].id] || [])[0];
if (!first) throw new Error("START 节点没有后继");
const seen = new Set<string>([starts[0].id]);
/** SPLIT 配对:各出边可达集求交 → 交集内取 BFS 序最早的 PARALLEL_JOIN(嵌套并行天然区分:内层 join 非全分支公共可达) */
function findJoin(splitId: string): string {
const order = new Map<string, number>([[splitId, 0]]);
const queue: string[] = [splitId];
while (queue.length) {
const cur = queue.shift()!;
(out[cur] || []).forEach((to) => {
if (!order.has(to)) {
order.set(to, order.size);
queue.push(to);
}
});
}
const branches = out[splitId] || [];
const reachSets = branches.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.type === "PARALLEL_JOIN" && 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;
}
function walkList(cur: string, stopAt?: string): { list: EditorItem[]; endedAt: string } {
const list: EditorItem[] = [];
while (true) {
if (stopAt !== undefined && cur === stopAt) return { list, endedAt: cur };
if (seen.has(cur)) throw new Error(`检测到环或重复连线:${cur}`);
seen.add(cur);
const n = byId[cur];
if (!n) throw new Error(`引用了不存在的节点:${cur}`);
if (n.type === "APPROVAL") {
if ((out[cur] || []).length !== 1) {
throw new Error(`审批节点「${n.name || cur}」的出边必须恰好 1 条`);
}
list.push(toApprovalNode(n));
cur = out[cur][0];
} else if (n.type === "PARALLEL_SPLIT") {
const heads = out[cur] || [];
if (!heads.length) throw new Error("并行分发节点没有分支");
const joinId = findJoin(cur);
seen.add(joinId);
const branches = heads.map((head) => {
const r = walkList(head, joinId);
if (r.endedAt !== joinId) {
throw new Error("并行分支未汇聚到同一个节点,无法还原为卡片视图");
}
return r.list;
});
list.push(makeParallel(branches));
if ((out[joinId] || []).length !== 1) throw new Error("汇聚节点出边必须恰好 1 条");
cur = out[joinId][0];
} else if (n.type === "END") {
return { list, endedAt: cur };
} else if (n.type === "PARALLEL_JOIN") {
throw new Error("汇聚节点出现在不受支持的位置(存在跨分支连线)");
} else {
throw new Error(`不支持的节点类型:${n.type}`);
}
}
}
const firstWalk = walkList(first);
if (byId[firstWalk.endedAt] && byId[firstWalk.endedAt].type !== "END") {
throw new Error("主流程未正确走到 END 节点");
}
const orphans = nodes.filter((n) => !seen.has(n.id));
if (orphans.length) {
throw new Error(`存在未能从开始到达的节点:${orphans.map((n) => n.name || n.id).join("、")}`);
}
const st = def.settings || {};
const urge = st.urge || {};
return {
code: def.code || "",
name: def.name || "",
list: firstWalk.list,
startName: starts[0].name || undefined,
endName: byId[firstWalk.endedAt]?.name || undefined,
settings: {
rejectPolicy: "END_REJECTED",
urge: {
enabled: urge.enabled !== false,
intervalHours: Number(urge.intervalHours) || DEFAULT_URGE.intervalHours,
maxTimes: Number(urge.maxTimes) || DEFAULT_URGE.maxTimes,
},
},
};
}
function toApprovalNode(n: any): ApprovalNode {
const countersign: Countersign = n.countersign === "ANY" ? "ANY" : "ALL";
return {
uid: newUid(),
kind: "approval",
name: n.name || "审批",
assignee: n.assignee ? cloneAssignee(n.assignee as Assignee) : null,
countersign,
deadlineHours: n.deadlineHours ? Number(n.deadlineHours) : null,
};
}
/** 还原时保留真实分支结构(含 1 分支的非常规定义,交给校验提示);仅空分支补占位,避免画布出现空容器 */
function makeParallel(branches: EditorItem[][]): ParallelNode {
return {
uid: newUid(),
kind: "parallel",
branches: branches.length ? branches : [[createApproval()], [createApproval()]],
};
}
+493
View File
@@ -0,0 +1,493 @@
<script setup lang="ts">
// 流程编辑器(方案 §3):钉钉式卡片流——纵向节点卡片 + 节点间"+"插入 + 点卡片右侧抽屉配置,免连线。
//
// 入口三态(query):无参=新建;?id={defId}=编辑草稿;?id={defId}&baseGuid={flowGuid}=升版本(载入该版本内容为底稿)。
// 保存只产草稿,走既有 POST /api/flow/admin/def/save;发布仍留在流程定义列表页(后端 DefValidator 终审)。
// 编辑态为结构化树(START/END 隐式固定端点),结构类校验错误从构造上不可能出现。
import { computed, onMounted, onUnmounted, provide, reactive, ref } from "vue";
import { onBeforeRouteLeave, useRoute, useRouter } from "vue-router";
import { Message, Modal } from "view-ui-plus";
import { flowApi } from "@/api/module/flow";
import {
ApprovalNode,
DeptOption,
EDITOR_CTX,
EditorCtx,
EditorItem,
EditorModel,
UrgeSetting,
collectUserIds,
createApproval,
createEmptyModel,
createParallel,
findItem,
removeItem,
resolveContainer,
walkItems,
} from "./model";
import { ParsedDef, fromContentJson, toContentJson } from "./convert";
import { Issue, countErrors, countWarns, validateModel } from "./validate";
import NodeList from "./components/NodeList.vue";
import ApprovalDrawer from "./components/ApprovalDrawer.vue";
import SettingsDrawer from "./components/SettingsDrawer.vue";
import JsonDrawer from "./components/JsonDrawer.vue";
const route = useRoute();
const router = useRouter();
const DEF_STATUS: Record<number, string> = { 0: "草稿", 1: "已发布", 2: "停用" };
const loading = ref(true);
const saving = ref(false);
/** 编辑目标草稿 id(升版本场景不带,交由 baseGuid 建新版本) */
const editId = ref<number | null>(null);
/** 升版本:源流程 flowGuid */
const baseGuid = ref("");
/** 载入源定义的状态(已发布/停用版本进入编辑器时提示保存会新建草稿版本) */
const sourceStatus = ref(0);
const sourceVersion = ref(0);
const model = reactive<EditorModel>(createEmptyModel());
/** 科室映射聚合(mapList):仅用于下拉选项与"无启用办理人"提示 */
const depts = reactive<DeptOption[]>([]);
/** userId → 姓名快照(mapLookupUser 检索补全,失败降级 #id) */
const userNames = reactive<Record<number, string>>({});
const issues = computed<Issue[]>(() => validateModel(model, depts));
const defJson = computed(() => toContentJson(model));
/** 脏检查基准(载入/保存后刷新);defJson 已含 code/name/settings,可完整代表待保存内容 */
const savedSnapshot = ref("");
const currentSnapshot = computed(() => JSON.stringify(defJson.value));
const isDirty = computed(() => !loading.value && currentSnapshot.value !== savedSnapshot.value);
const nodeDrawer = ref(false);
const settingsDrawer = ref(false);
const jsonDrawer = ref(false);
const editingUid = ref("");
const editingNode = computed<ApprovalNode | null>(() => {
const item = editingUid.value ? findItem(model, editingUid.value) : null;
return item && item.kind === "approval" ? item : null;
});
// ==================== 载入 ====================
async function loadDepts(): Promise<void> {
try {
const rows: any[] = (await flowApi.mapList().send()) || [];
const grouped: Record<string, DeptOption> = {};
rows.forEach((r) => {
const key = r.deptKey;
if (!key) return;
if (!grouped[key]) grouped[key] = { deptKey: key, deptName: r.deptName || "", count: 0 };
if (!grouped[key].deptName && r.deptName) grouped[key].deptName = r.deptName;
// 状态 0=启用(与办理人映射页口径一致),发布校验要求至少 1 名启用办理人
if (r.status === 0) grouped[key].count += 1;
});
depts.splice(0, depts.length, ...Object.values(grouped));
} catch {
// 映射表拉取失败不阻塞编辑:审批人下拉退化为"仅当前值",校验降级为提示
Message.warning("办理人映射拉取失败,科室下拉可能不完整");
}
}
/** 补全 USERS 型审批人的姓名快照(既有定义载入时逐个检索,失败降级 #id 不阻塞) */
async function resolveUserNames(): Promise<void> {
const ids = collectUserIds(model).filter((id) => !userNames[id]);
for (const id of ids) {
try {
const u: any = await flowApi.mapLookupUser(id).send();
userNames[id] = u?.name || `#${id}`;
} catch {
userNames[id] = `#${id}`;
}
}
}
function applyParsed(parsed: ParsedDef): void {
model.code = parsed.code || model.code;
model.name = parsed.name || model.name;
model.list = parsed.list;
model.settings = parsed.settings;
// 端点 name 随定义带出,保存时回写(新建流程为空,生成 JSON 不含该字段)
model.startName = parsed.startName;
model.endName = parsed.endName;
void resolveUserNames();
}
async function load(): Promise<void> {
loading.value = true;
try {
await loadDepts();
const queryId = route.query.id ? Number(route.query.id) : null;
const queryGuid = (route.query.baseGuid as string) || "";
if (queryGuid) baseGuid.value = queryGuid;
// 升版本:query 带 baseGuid + id(源为已发布版本),保存时不带 id 以创建新版本草稿
if (queryId && !queryGuid) editId.value = queryId;
if (!queryId) {
savedSnapshot.value = currentSnapshot.value;
return;
}
const d: any = await flowApi.defGet(queryId).send();
sourceStatus.value = d?.status ?? 0;
sourceVersion.value = d?.version ?? 0;
model.code = d?.code || "";
model.name = d?.name || "";
try {
applyParsed(fromContentJson(JSON.parse(d?.contentJson || "{}")));
} catch (e: any) {
Message.error(`既有定义无法还原为卡片视图:${e?.message || e}。请改用源码模式编辑。`);
}
await resolveUserNames();
savedSnapshot.value = currentSnapshot.value;
} finally {
loading.value = false;
}
}
// ==================== 编辑操作(画布子树经 ctx 共享) ====================
/** 定位某节点所在的兄弟列表(删除前的"至少保留一个"判断用) */
function containerOfItem(uid: string): EditorItem[] | null {
let container: string | null = null;
walkItems(model.list, (item, c) => {
if (item.uid === uid) container = c;
});
const c: string | null = container;
return c ? resolveContainer(model, c) : null;
}
function issueOf(uid: string): Issue[] {
return issues.value.filter((i) => i.uid === uid);
}
function openNode(uid: string): void {
editingUid.value = uid;
nodeDrawer.value = true;
}
function removeNode(uid: string): void {
const siblings = containerOfItem(uid);
if (siblings && siblings.length <= 1) {
Message.warning("每个流程/分支至少保留一个节点");
return;
}
removeItem(model, uid);
}
function insertItem(container: string, index: number, kind: "approval" | "parallel"): void {
const list = resolveContainer(model, container);
if (!list) return;
if (kind === "approval") {
const node = createApproval();
list.splice(index, 0, node);
// 新建即弹出配置,引导填审批人(避免留下"任务无主"节点)
openNode(node.uid);
} else {
list.splice(index, 0, createParallel());
}
}
function addBranch(parallelUid: string): void {
const item = findItem(model, parallelUid);
if (!item || item.kind !== "parallel") return;
item.branches.push([createApproval()]);
}
function removeBranch(parallelUid: string, index: number): void {
const item = findItem(model, parallelUid);
if (!item || item.kind !== "parallel") return;
if (item.branches.length <= 2) {
Message.warning("并行分支至少保留 2 个");
return;
}
item.branches.splice(index, 1);
}
provide<EditorCtx>(EDITOR_CTX, {
model,
depts,
userNames,
issueOf,
openNode,
removeNode,
insertItem,
addBranch,
removeBranch,
});
// ==================== 保存 / 离开 ====================
function onUrgeSave(urge: UrgeSetting): void {
model.settings.urge = urge;
}
async function onSave(): Promise<void> {
if (!model.name.trim()) {
Message.warning("请填写流程名称");
return;
}
if (!model.code.trim()) {
Message.warning("请填写流程编码");
return;
}
const errors = countErrors(issues.value);
if (errors.length) {
Message.error(`校验未通过:${errors[0].text}`);
return;
}
saving.value = true;
try {
// code/name 同时出现在 body 与 contentJson 且取值同源,规避 contentJson 静默覆盖行字段的既有坑
await flowApi.defSave({
id: editId.value || undefined,
code: model.code.trim(),
name: model.name.trim(),
contentJson: JSON.stringify(toContentJson(model)),
baseGuid: baseGuid.value || undefined,
}).send();
savedSnapshot.value = currentSnapshot.value;
Message.success("已保存草稿");
router.push("/admin/defs");
} finally {
saving.value = false;
}
}
function onOpenDiagram(): void {
if (!editId.value) return;
const href = router.resolve({ path: "/diagram", query: { defId: String(editId.value), mode: "auto" } }).href;
window.open(`${location.origin}${location.pathname}${href}`, "_blank");
}
function onBack(): void {
if (!isDirty.value) {
router.push("/admin/defs");
return;
}
Modal.confirm({
title: "离开编辑器",
content: "当前改动尚未保存,确定离开吗?",
okText: "离开",
cancelText: "继续编辑",
onOk: () => router.push("/admin/defs"),
});
}
function onBeforeUnload(e: BeforeUnloadEvent): void {
if (!isDirty.value) return;
e.preventDefault();
e.returnValue = "";
}
onBeforeRouteLeave((_to, _from, next) => {
if (!isDirty.value) {
next();
return;
}
Modal.confirm({
title: "离开编辑器",
content: "当前改动尚未保存,确定离开吗?",
okText: "离开",
cancelText: "继续编辑",
onOk: () => next(),
onCancel: () => next(false),
});
});
onMounted(() => {
window.addEventListener("beforeunload", onBeforeUnload);
load();
});
onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</script>
<template>
<div class="def-editor">
<header class="ed-header">
<div class="hd-left">
<span class="hd-back" title="返回流程定义列表" @click="onBack">←</span>
<span class="hd-title">流程编辑器</span>
<!-- 深链可带任意 defId:编辑已发布/停用版本时,后端 save 会复制出新草稿版本,此处显式告知 -->
<Tag v-if="editId && sourceStatus === 0" color="orange">草稿</Tag>
<Tag v-else-if="editId" color="red">{{ DEF_STATUS[sourceStatus] }} · 保存将新建草稿版本</Tag>
<Tag v-else-if="baseGuid" color="blue">升版本 · 基于 v{{ sourceVersion }}({{ DEF_STATUS[sourceStatus] }})</Tag>
<Tag v-else color="green">新建</Tag>
</div>
<div class="hd-mid">
<label>名称 <Input v-model="model.name" placeholder="流程名称" style="width: 200px" :maxlength="30" /></label>
<label>
编码
<Input
v-model="model.code"
placeholder="内部管理键,发布后不可改"
style="width: 220px"
:disabled="!!editId || !!baseGuid"
:maxlength="40"
/>
</label>
</div>
<div class="hd-right">
<Button @click="settingsDrawer = true">流程设置</Button>
<Button @click="jsonDrawer = true">源码</Button>
<Button :disabled="!editId" @click="onOpenDiagram">图示预览</Button>
<Button type="primary" :loading="saving" @click="onSave">保存草稿</Button>
</div>
</header>
<main v-if="!loading" class="ed-canvas">
<div class="ed-hint">点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 并行分支)</div>
<div class="ed-flow">
<div class="capsule start">发起开始</div>
<NodeList :items="model.list" container="root" />
<div class="capsule end">流程结束</div>
</div>
</main>
<main v-else class="ed-canvas loading">
<Spin size="large" fix />
</main>
<footer class="ed-status" :class="{ bad: countErrors(issues).length }">
<span v-if="countErrors(issues).length" class="st-bad">
✗ {{ countErrors(issues).length }} 个问题:{{ countErrors(issues).map((i) => i.text).join(";") }}
</span>
<span v-else class="st-ok">
✓ 校验通过<span v-if="countWarns(issues).length">({{ countWarns(issues).length }} 个警告)</span>
</span>
<span class="st-right">保存仅生成草稿;发布校验以后端为准</span>
</footer>
<ApprovalDrawer v-model="nodeDrawer" :node="editingNode" @removed="nodeDrawer = false" />
<SettingsDrawer v-model="settingsDrawer" :settings="model.settings" @save="onUrgeSave" />
<JsonDrawer v-model="jsonDrawer" :def="defJson" @applied="applyParsed" />
</div>
</template>
<style scoped>
.def-editor {
display: flex;
flex-direction: column;
/* 撑满布局壳可视区:100vh − 56(顶栏) − 56(面包屑 .path-nav) − 24(main-container 下内边距)
(实测值,见 layout.css .header-container/.path-nav/.base-wrap-main .main-container) */
height: calc(100vh - 136px);
background: #f5f7fa;
}
.ed-header {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 16px;
background: #fff;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
z-index: 10;
}
.hd-left {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
white-space: nowrap;
}
.hd-back {
cursor: pointer;
color: #808695;
font-size: 18px;
}
.hd-back:hover {
color: #2d8cf0;
}
.hd-title {
font-size: 15px;
}
.hd-mid {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
justify-content: center;
}
.hd-mid label {
display: flex;
align-items: center;
gap: 6px;
color: #808695;
font-size: 13px;
white-space: nowrap;
}
.hd-right {
display: flex;
gap: 8px;
white-space: nowrap;
}
.ed-canvas {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 16px 60px;
overflow: auto;
position: relative;
}
.ed-hint {
color: #a5abb8;
font-size: 12px;
background: #fff;
padding: 5px 14px;
border-radius: 14px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
margin-bottom: 16px;
white-space: nowrap;
}
.ed-flow {
display: flex;
flex-direction: column;
align-items: center;
}
.capsule {
width: 132px;
padding: 10px 0;
text-align: center;
border-radius: 24px;
font-weight: 600;
letter-spacing: 1px;
user-select: none;
}
.capsule.start {
background: #e8f9f0;
color: #19be6b;
border: 1.5px solid #19be6b;
}
.capsule.end {
background: #f0f1f3;
color: #808695;
border: 1.5px solid #808695;
}
.ed-status {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
background: #fff;
border-top: 1px solid #eee;
font-size: 12px;
z-index: 9;
}
.ed-status.bad {
background: #fff5f5;
}
.st-ok {
color: #19be6b;
}
.st-bad {
color: #ed4014;
}
.st-right {
margin-left: auto;
color: #b0b6c1;
white-space: nowrap;
}
</style>
+252
View File
@@ -0,0 +1,252 @@
/**
* 流程编辑器数据模型(方案 §3.3)。
*
* 编辑态是"结构化树":线性节点序列 + 并行分支嵌套;START/END 为隐式固定端点,
* 不进模型、不可删。结构合法性由构造保证——孤儿节点/悬空边/环/度错误不可能出现。
* 保存时经 convert.ts 生成与后端 FlowDefModel 对齐的 contentJson。
*/
import type { InjectionKey } from "vue";
export type Countersign = "ALL" | "ANY";
export interface AssigneeDept {
type: "DEPT_KEY";
deptKey: string;
}
export interface AssigneeUsers {
type: "USERS";
userIds: number[];
}
export type Assignee = AssigneeDept | AssigneeUsers;
export interface ApprovalNode {
uid: string;
kind: "approval";
name: string;
assignee: Assignee | null;
/** 多人审批方式:ALL=会签(默认,生成 JSON 时省略)|ANY=或签 */
countersign: Countersign;
/** 办理时限(小时):null=不限,生成 JSON 时省略 */
deadlineHours: number | null;
}
export interface ParallelNode {
uid: string;
kind: "parallel";
/** 分支列表;每个分支是一条节点序列(可再嵌套并行) */
branches: EditorItem[][];
}
export type EditorItem = ApprovalNode | ParallelNode;
export interface UrgeSetting {
enabled: boolean;
intervalHours: number;
maxTimes: number;
}
export interface EditorSettings {
/** 当前引擎唯一支持值(DefValidator 仅接受 END_REJECTED) */
rejectPolicy: "END_REJECTED";
urge: UrgeSetting;
}
export interface EditorModel {
code: string;
name: string;
list: EditorItem[];
settings: EditorSettings;
/**
* START/END 节点的 name:编辑器里端点是隐式固定的(不展示、不可编辑),但载入既有定义时
* 原样保留,生成 JSON 时回写——避免保存一次就静默丢掉平台里已存在的自定义端点名。
* 新建流程两者为空,此时不生成 name 字段(后端不依赖,图示用固定文案)。
*/
startName?: string;
endName?: string;
}
// ==================== contentJson 形状(与后端 engine/FlowDefModel 对齐) ====================
export interface DefNodeJson {
id: string;
type: string;
name?: string;
assignee?: Assignee;
countersign?: Countersign;
waitFor?: string;
deadlineHours?: number;
}
export interface DefEdgeJson {
from: string;
to: string;
name?: string;
}
export interface DefJson {
code: string;
name: string;
start: string;
nodes: DefNodeJson[];
edges: DefEdgeJson[];
settings: EditorSettings;
}
// ==================== 常量 ====================
/** 并行分支数下限(后端仅要求 ≥1,2 为业务下限) */
export const MIN_BRANCHES = 2;
/** 并行分支数上限(超出则画布不可读,封顶添加按钮) */
export const MAX_BRANCHES = 6;
/** 分支序号文案(下限用) */
export const BRANCH_LABELS = ["①", "②", "③", "④", "⑤", "⑥"];
export const DEFAULT_URGE: UrgeSetting = { enabled: true, intervalHours: 24, maxTimes: 3 };
// ==================== 工厂 ====================
let uidSeq = 0;
/** 编辑态 uid(仅前端用,每次载入模型重建,不写入 JSON) */
export function newUid(): string {
uidSeq += 1;
return `u${uidSeq}`;
}
export function createApproval(name = "审批节点", assignee: Assignee | null = { type: "DEPT_KEY", deptKey: "" }): ApprovalNode {
return { uid: newUid(), kind: "approval", name, assignee, countersign: "ALL", deadlineHours: null };
}
export function createParallel(): ParallelNode {
return { uid: newUid(), kind: "parallel", branches: [[createApproval()], [createApproval()]] };
}
/** 新建流程的默认模板:一个待配置的审批节点(引导用户点卡片配置) */
export function createEmptyModel(): EditorModel {
return {
code: "",
name: "",
list: [createApproval()],
settings: { rejectPolicy: "END_REJECTED", urge: { ...DEFAULT_URGE } },
};
}
// ==================== 遍历/查询 ====================
export interface ContainerRef {
/** 容器标识:root 或 `${parallelUid}:${branchIndex}` */
container: string;
index: number;
items: EditorItem[];
}
export function walkItems(
items: EditorItem[],
fn: (item: EditorItem, container: string, index: number) => void,
container = "root"
): void {
items.forEach((item, index) => {
fn(item, container, index);
if (item.kind === "parallel") {
item.branches.forEach((branch, bi) => walkItems(branch, fn, `${item.uid}:${bi}`));
}
});
}
export function findItem(model: EditorModel, uid: string): EditorItem | null {
let hit: EditorItem | null = null;
walkItems(model.list, (item) => {
if (item.uid === uid) hit = item;
});
return hit;
}
export function findParallel(model: EditorModel, uid: string): ParallelNode | null {
const item = findItem(model, uid);
return item && item.kind === "parallel" ? item : null;
}
/** 解析容器标识为可变数组引用(插入/删除用) */
export function resolveContainer(model: EditorModel, container: string): EditorItem[] | null {
if (container === "root") return model.list;
const [parallelUid, bi] = container.split(":");
const parent = findParallel(model, parallelUid);
if (!parent) return null;
return parent.branches[Number(bi)] || null;
}
/** 按 uid 删除节点(跨层级);返回是否命中 */
export function removeItem(model: EditorModel, uid: string): boolean {
let removed = false;
const rec = (items: EditorItem[]): void => {
const i = items.findIndex((it) => it.uid === uid);
if (i >= 0) {
items.splice(i, 1);
removed = true;
return;
}
items.forEach((it) => {
if (!removed && it.kind === "parallel") it.branches.forEach(rec);
});
};
rec(model.list);
return removed;
}
/** 收集全部 USERS 型审批人 userId(用于补全姓名快照) */
export function collectUserIds(model: EditorModel): number[] {
const ids = new Set<number>();
walkItems(model.list, (item) => {
if (item.kind === "approval" && item.assignee?.type === "USERS") {
item.assignee.userIds.forEach((id) => ids.add(Number(id)));
}
});
return [...ids];
}
// ==================== 展示辅助 ====================
export interface DeptOption {
deptKey: string;
deptName: string;
/** 启用状态的办理人数(发布校验要求 ≥1) */
count: number;
}
/** 审批人摘要文案(卡片第二行;deptOptions 来自 mapList,userNames 为 userId→姓名 快照) */
export function assigneeText(a: Assignee | null, depts: DeptOption[], userNames: Record<number, string>): string {
if (!a) return "未配置审批人";
if (a.type === "DEPT_KEY") {
if (!a.deptKey) return "审批人:未选择科室";
const d = depts.find((x) => x.deptKey === a.deptKey);
return d
? `审批人:${d.deptName || d.deptKey}(${d.deptKey})· ${d.count} 人`
: `审批人:${a.deptKey}(未配置映射)`;
}
if (!a.userIds.length) return "审批人:未选择用户";
return `审批人:${a.userIds.map((id) => userNames[id] || `#${id}`).join("、")}`;
}
/** 卡片标签行(会签/或签 + 时限) */
export function nodeTags(n: ApprovalNode): string[] {
const tags = [n.countersign === "ANY" ? "或签" : "会签"];
if (n.deadlineHours) tags.push(`${n.deadlineHours} 小时时限`);
return tags;
}
// ==================== 编辑器上下文(画布子树共享,避免逐层透传回调) ====================
export interface EditorCtx {
model: EditorModel;
depts: DeptOption[];
userNames: Record<number, string>;
issueOf: (uid: string) => { level: "error" | "warn"; text: string }[];
openNode: (uid: string) => void;
removeNode: (uid: string) => void;
insertItem: (container: string, index: number, kind: "approval" | "parallel") => void;
addBranch: (parallelUid: string) => void;
removeBranch: (parallelUid: string, index: number) => void;
}
export const EDITOR_CTX: InjectionKey<EditorCtx> = Symbol("flowDefEditorCtx");
@@ -0,0 +1,81 @@
/**
* 编辑器实时校验(方案 §3.5):镜像后端 DefValidator 中"可前端化"的业务规则。
*
* 分层:error 阻断保存(配置缺失类);warn 仅提示(映射缺失类,允许存草稿、发布时后端拦截)。
* 结构类规则(id 唯一/度约束/孤儿/环/START·END 唯一)由结构化生成保证,前端无需校验。
* 后端 DefValidator 仍是发布期权威,此处仅作体验前置。
*/
import { BRANCH_LABELS, DeptOption, EditorItem, EditorModel, MAX_BRANCHES, MIN_BRANCHES } from "./model";
export type IssueLevel = "error" | "warn";
export interface Issue {
/** 命中节点 uid(画布就地标注);空容器等无归属问题为空 */
uid: string;
level: IssueLevel;
/** 定位路径(主流程 / 主流程·分支①) */
path: string;
msg: string;
/** 状态栏/卡片展示用完整文案 */
text: string;
}
export function validateModel(model: EditorModel, depts: DeptOption[]): Issue[] {
const issues: Issue[] = [];
const push = (uid: string, level: IssueLevel, path: string, msg: string): void => {
issues.push({ uid, level, path, msg, text: `${path}:${msg}` });
};
const rec = (items: EditorItem[], path: string): void => {
items.forEach((item) => {
if (item.kind === "approval") {
if (!item.name.trim()) push(item.uid, "error", path, "节点名称不能为空");
const a = item.assignee;
if (!a) {
push(item.uid, "error", path, "未配置审批人");
} else if (a.type === "DEPT_KEY") {
if (!a.deptKey) {
push(item.uid, "error", path, "未选择科室映射");
} else {
const d = depts.find((x) => x.deptKey === a.deptKey);
if (!d) {
push(item.uid, "warn", path, `科室映射「${a.deptKey}」不存在,发布会被拦截`);
} else if (!d.count) {
push(item.uid, "warn", path, `科室「${d.deptName || d.deptKey}」无启用办理人,发布会被拦截`);
}
}
} else if (!a.userIds.length) {
push(item.uid, "error", path, "指定用户不能为空");
}
return;
}
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.branches.forEach((branch, i) => {
const branchPath = `${path}·分支${BRANCH_LABELS[i] || i + 1}`;
if (!branch.length) {
// 结构化模型不允许空分支(删除最后一个节点会被父级拦截),此处仅兜底
push(item.uid, "error", branchPath, "分支为空,请添加审批节点");
return;
}
rec(branch, branchPath);
});
});
};
rec(model.list, "主流程");
return issues;
}
export function countErrors(issues: Issue[]): Issue[] {
return issues.filter((i) => i.level === "error");
}
export function countWarns(issues: Issue[]): Issue[] {
return issues.filter((i) => i.level === "warn");
}