- Maps.vue:一键拉取教师组按钮 + 预览弹窗(科室/成员明细)+ 确认导入 - flow.ts:mapGroupPreview/mapImportGroups API - 含流程编辑器(def-editor)、路由 catch-all、示意图入口存量
This commit is contained in:
@@ -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>
|
||||
Reference in New Issue
Block a user