B3-1 编辑器右信息面板 + 校验清单定位(先加面板,保存链路不动)

- index.vue:画布与面板包进 .ed-body(flex row),新增 aside.ed-info 五分区——
  流程信息(名称/编码/归属应用,与画布同源同一 ref,编码沿用 !!editId||!!baseGuid 禁用)/
  状态与版本(状态 Tag + v 版本号 + flowGuid 等宽 + 复制,新建态显示「保存后生成」)/
  校验问题清单(错误在前警告在后,项内 path+msg)/ 结构统计(walkItems 递归四类计数)/
  快捷入口(流程设置/源码/图示预览,:disabled=!editId 同现状)
- sourceFlowGuid ref:load() 里 defGet 后取 d.flowGuid,新建态空串
- locateIssue:清单项点击 scrollIntoView 居中 + .nd-locate 高亮 2.8s;三张卡片根元素加 :id="'nd-'+node.uid",
  uid 为空的清单项 class=fix 不可点;高亮样式走 :deep(卡片在子组件内)
- 画布左对齐:.ed-canvas align-items center→flex-start

验证:npm run build 通过;npx tsc --noEmit 48 条=基线无新增;convert 往返 ALL PASS
This commit is contained in:
chenxing
2026-10-12 16:19:51 +08:00
parent 5cf1ce8d1f
commit f0ec500348
4 changed files with 279 additions and 15 deletions
@@ -38,7 +38,7 @@ function editCondition(i: number, v: string): void {
</script> </script>
<template> <template>
<div class="condition-box" :class="{ error: hasError }"> <div :id="'nd-' + node.uid" class="condition-box" :class="{ error: hasError }">
<div class="cb-title" @click="ctx.openNode(node.uid)"> <div class="cb-title" @click="ctx.openNode(node.uid)">
条件分支(按出口互斥选路) 条件分支(按出口互斥选路)
<span class="cb-hint">(点击标题配置{{ expression ? "表达式" : "判断服务" }})</span> <span class="cb-hint">(点击标题配置{{ expression ? "表达式" : "判断服务" }})</span>
@@ -20,7 +20,7 @@ function onOpen(e: MouseEvent): void {
</script> </script>
<template> <template>
<div class="node-card" :class="{ error: hasError }" @click="onOpen"> <div :id="'nd-' + node.uid" class="node-card" :class="{ error: hasError }" @click="onOpen">
<div class="nc-head"> <div class="nc-head">
<span class="nc-icon">审</span> <span class="nc-icon">审</span>
<span class="nc-name">{{ node.name || "未命名节点" }}</span> <span class="nc-name">{{ node.name || "未命名节点" }}</span>
@@ -19,7 +19,7 @@ function onOpen(e: MouseEvent): void {
</script> </script>
<template> <template>
<div class="notify-card" :class="{ error: hasError }" @click="onOpen"> <div :id="'nd-' + node.uid" class="notify-card" :class="{ error: hasError }" @click="onOpen">
<div class="nc-head"> <div class="nc-head">
<span class="nc-icon">抄</span> <span class="nc-icon">抄</span>
<span class="nc-name">{{ node.name || "未命名节点" }}</span> <span class="nc-name">{{ node.name || "未命名节点" }}</span>
+276 -12
View File
@@ -55,6 +55,8 @@ const baseGuid = ref("");
/** 载入源定义的状态(已发布/停用版本进入编辑器时提示保存会新建草稿版本) */ /** 载入源定义的状态(已发布/停用版本进入编辑器时提示保存会新建草稿版本) */
const sourceStatus = ref(0); const sourceStatus = ref(0);
const sourceVersion = ref(0); const sourceVersion = ref(0);
/** 源流程线 flowGuid(新建态为空,右面板显示「保存后生成」) */
const sourceFlowGuid = ref("");
/** 归属应用(flw_def.app_key;按应用筛选维度,空=未分配) */ /** 归属应用(flw_def.app_key;按应用筛选维度,空=未分配) */
const appKey = ref(""); const appKey = ref("");
/** 应用清单下拉(应用设置白名单 flw_app enabled=1,显示应用名),拉取失败降级为仅当前值 */ /** 应用清单下拉(应用设置白名单 flw_app enabled=1,显示应用名),拉取失败降级为仅当前值 */
@@ -71,6 +73,44 @@ const routers = reactive<RouterOption[]>([]);
const issues = computed<Issue[]>(() => validateModel(model, depts, routers)); const issues = computed<Issue[]>(() => validateModel(model, depts, routers));
const defJson = computed(() => toContentJson(model)); const defJson = computed(() => toContentJson(model));
/** 结构统计(右面板分区4):walkItems 已递归条件/并行分支的子树 */
const stats = computed(() => {
const s: Record<string, number> = { approval: 0, notify: 0, condition: 0, parallel: 0 };
walkItems(model.list, (item) => {
if (s[item.kind] !== undefined) s[item.kind] += 1;
});
return s;
});
/** 校验清单(右面板分区3):错误项在前、警告项在后(原型 E2 顺序) */
const issueList = computed<Issue[]>(() => [
...issues.value.filter((i) => i.level === "error"),
...issues.value.filter((i) => i.level !== "error"),
]);
/** 点清单项 → 画布滚动到对应节点并高亮 ~3s;uid 为空(空容器类)不可定位 */
function locateIssue(uid: string): void {
const el = document.getElementById("nd-" + uid);
if (!el) return;
el.scrollIntoView({ behavior: "smooth", block: "center" });
el.classList.add("nd-locate");
window.setTimeout(() => el.classList.remove("nd-locate"), 2800);
}
/** flowGuid 复制:clipboard 不可用/被拒时降级 console,不打断编辑 */
function copyGuid(): void {
const g = sourceFlowGuid.value;
if (!g) return;
if (navigator.clipboard?.writeText) {
navigator.clipboard.writeText(g).then(
() => Message.success("flowGuid 已复制"),
() => console.warn("flowGuid 复制失败,请手工选取:", g),
);
return;
}
console.warn("flowGuid 复制失败,请手工选取:", g);
}
/** 归一化(去 layout 坐标、节点按 id 排序):卡片树投影与画布序列化表示不同但内容等价,脏比较必须同表示 */ /** 归一化(去 layout 坐标、节点按 id 排序):卡片树投影与画布序列化表示不同但内容等价,脏比较必须同表示 */
function canonicalJson(content: any): string { function canonicalJson(content: any): string {
const copy: any = JSON.parse(JSON.stringify(content)); const copy: any = JSON.parse(JSON.stringify(content));
@@ -201,6 +241,7 @@ async function load(): Promise<void> {
const d: any = await flowApi.defGet(queryId).send(); const d: any = await flowApi.defGet(queryId).send();
sourceStatus.value = d?.status ?? 0; sourceStatus.value = d?.status ?? 0;
sourceVersion.value = d?.version ?? 0; sourceVersion.value = d?.version ?? 0;
sourceFlowGuid.value = d?.flowGuid || queryGuid || "";
// 非草稿进入提示(2026-10-10 议题2):结构改动保存将升版本;仅改名称/归属应用请回列表用「编辑信息」 // 非草稿进入提示(2026-10-10 议题2):结构改动保存将升版本;仅改名称/归属应用请回列表用「编辑信息」
if (d?.status === 1 || d?.status === 2) { if (d?.status === 1 || d?.status === 2) {
Message.info("结构改动保存将升版本;仅改名称/归属应用请返回列表用「编辑信息」(不升版本)"); Message.info("结构改动保存将升版本;仅改名称/归属应用请返回列表用「编辑信息」(不升版本)");
@@ -536,17 +577,98 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</div> </div>
</header> </header>
<main v-if="!loading" class="ed-canvas"> <div class="ed-body">
<div class="ed-hint">点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 抄送 / 并行分支)</div> <main v-if="!loading" class="ed-canvas">
<div class="ed-flow"> <div class="ed-hint">点击卡片配置节点 · 悬停虚线处 + 插入节点(审批 / 抄送 / 并行分支)</div>
<div class="capsule start">发起开始</div> <div class="ed-flow">
<NodeList :items="model.list" container="root" /> <div class="capsule start">发起开始</div>
<div class="capsule end">流程结束</div> <NodeList :items="model.list" container="root" />
</div> <div class="capsule end">流程结束</div>
</main> </div>
<main v-else-if="loading" class="ed-canvas loading"> </main>
<Spin size="large" fix /> <main v-else class="ed-canvas loading">
</main> <Spin size="large" fix />
</main>
<!-- 议题一:右信息面板(五分区,规格对齐原型 E1) -->
<aside class="ed-info">
<section class="ip-sec">
<h4>流程信息<span class="ip-mini">可编辑 · 保存走现有 defSave</span></h4>
<div class="ip-fld">
<label>名称</label>
<Input v-model="model.name" placeholder="流程名称" :maxlength="30" />
</div>
<div class="ip-fld">
<label>编码<span class="ip-mini">升版本态禁用(发布后不可改)</span></label>
<Input v-model="model.code" placeholder="内部管理键,发布后不可改"
:disabled="!!editId || !!baseGuid" :maxlength="40" />
</div>
<div class="ip-fld">
<label>归属应用 <span style="color: #ed4014">*</span></label>
<Select v-model="appKey" placeholder="归属应用(必选)" filterable>
<Option v-for="a in appOptions" :key="a.appKey" :value="a.appKey">{{ a.appName }}</Option>
</Select>
</div>
</section>
<section class="ip-sec">
<h4>状态与版本<span class="ip-mini">只读</span></h4>
<div class="ip-tags">
<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 }}</Tag>
<Tag v-else color="green">新建</Tag>
<Tag v-if="sourceVersion" color="gray">v{{ sourceVersion }}</Tag>
</div>
<div class="ip-guid">
<span v-if="sourceFlowGuid" class="ip-mono">{{ sourceFlowGuid }}</span>
<span v-else class="ip-mini">保存后生成</span>
<Button v-if="sourceFlowGuid" size="small" @click="copyGuid">复制</Button>
</div>
<div class="ip-tip">flowGuid 发布后不变,业务方绑定流程用此键</div>
</section>
<section class="ip-sec">
<h4>校验问题清单
<span class="ip-mini">{{ countErrors(issues).length ? `${issueList.length} 项待处理`
: (countWarns(issues).length ? `${countWarns(issues).length} 个警告` : "无问题") }}</span>
</h4>
<div v-if="!issueList.length" class="ip-ok">✓ 校验通过</div>
<div v-else-if="!countErrors(issues).length" class="ip-ok">
✓ 校验通过({{ countWarns(issues).length }} 个警告)
</div>
<div v-for="(i, idx) in issueList" :key="`${i.uid}-${idx}`" class="ip-issue"
:class="[(i.level === 'error' ? 'err' : 'warn'), (i.uid ? 'loc' : 'fix')]"
@click="i.uid && locateIssue(i.uid)">
<span class="ip-ic">{{ i.level === "error" ? "✗" : "!" }}</span>
<span class="ip-tx">{{ i.path }}:{{ i.msg }}
<span v-if="i.level !== 'error'" class="ip-sub">(警告,不阻塞保存)</span>
</span>
<span v-if="i.uid" class="ip-go">定位 →</span>
</div>
</section>
<section class="ip-sec">
<h4>结构统计</h4>
<div class="ip-stats">
<div class="ip-stat"><b>{{ stats.approval }}</b><span>审批节点</span></div>
<div class="ip-stat"><b>{{ stats.notify }}</b><span>抄送节点</span></div>
<div class="ip-stat"><b>{{ stats.condition }}</b><span>条件分支</span></div>
<div class="ip-stat"><b>{{ stats.parallel }}</b><span>并行分支</span></div>
</div>
</section>
<section class="ip-sec">
<h4>快捷入口</h4>
<div class="ip-quick">
<Button size="small" @click="settingsDrawer = true">流程设置</Button>
<Button size="small" @click="jsonDrawer = true">源码</Button>
<Button size="small" :disabled="!editId" @click="onOpenDiagram">图示预览</Button>
</div>
<div class="ip-tip">「图示预览」仅已载入的草稿/版本可用(同现状 :disabled="!editId")</div>
</section>
</aside>
</div>
<footer class="ed-status" :class="{ bad: countErrors(issues).length }"> <footer class="ed-status" :class="{ bad: countErrors(issues).length }">
<span v-if="countErrors(issues).length" class="st-bad"> <span v-if="countErrors(issues).length" class="st-bad">
@@ -645,7 +767,7 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: flex-start;
padding: 20px 16px 60px; padding: 20px 16px 60px;
overflow: auto; overflow: auto;
position: relative; position: relative;
@@ -756,4 +878,146 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
color: #b0b6c1; color: #b0b6c1;
line-height: 32px; line-height: 32px;
} }
/* 议题一:左画布 + 右信息面板(原型 E1,面板宽 340) */
.ed-body {
flex: 1;
display: flex;
min-height: 0;
}
.ed-info {
width: 340px;
flex: none;
overflow: auto;
border-left: 1px solid #e8eaec;
background: #fff;
padding: 4px 14px 20px;
}
.ip-sec {
padding: 12px 0;
border-bottom: 1px solid #f0f1f3;
}
.ip-sec:last-child {
border-bottom: none;
}
.ip-sec h4 {
font-size: 13px;
font-weight: 600;
color: #17233d;
margin: 0 0 10px;
display: flex;
align-items: baseline;
gap: 8px;
}
.ip-mini {
font-weight: 400;
font-size: 12px;
color: #a5abb8;
}
.ip-fld {
margin-bottom: 10px;
}
.ip-fld label {
display: block;
font-size: 12px;
color: #808695;
margin-bottom: 4px;
}
.ip-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.ip-guid {
display: flex;
align-items: center;
gap: 8px;
}
.ip-mono {
font-family: Consolas, Monaco, "Courier New", monospace;
font-size: 12px;
color: #515a6e;
word-break: break-all;
}
.ip-tip {
margin-top: 6px;
font-size: 12px;
color: #a5abb8;
line-height: 1.6;
}
.ip-ok {
font-size: 12.5px;
color: #19be6b;
}
.ip-issue {
display: flex;
gap: 6px;
align-items: flex-start;
font-size: 12.5px;
line-height: 1.6;
padding: 5px 6px;
border-radius: 4px;
margin-bottom: 4px;
}
.ip-issue.err {
background: #fff5f5;
color: #ed4014;
}
.ip-issue.warn {
background: #fff9f0;
color: #ff9900;
}
.ip-issue.loc {
cursor: pointer;
}
.ip-issue.loc:hover {
filter: brightness(0.97);
}
.ip-issue.fix {
cursor: default;
}
.ip-ic {
flex: none;
font-weight: 700;
}
.ip-tx {
flex: 1;
}
.ip-sub {
color: #a5abb8;
}
.ip-go {
flex: none;
font-size: 12px;
}
.ip-stats {
display: flex;
gap: 8px;
}
.ip-stat {
flex: 1;
text-align: center;
background: #f7f9fc;
border-radius: 6px;
padding: 8px 0;
}
.ip-stat b {
display: block;
font-size: 16px;
color: #17233d;
}
.ip-stat span {
font-size: 12px;
color: #808695;
}
.ip-quick {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
/* 校验清单定位高亮(原型 E2,用项目错误红不做原型紫);卡片在子组件内,需 :deep 命中 */
:deep(.nd-locate) {
box-shadow: 0 0 0 4px rgba(237, 64, 20, 0.18) !important;
}
</style> </style>