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
+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>