增强审批流管理能力,新增审批环节的 stageKey 区分关键环节与过路审批节点,完善钉钉回调日志记录,停用部分 HTTP 回调接口,改由集成方案驱动审批流,优化审批注册中心的查询逻辑。

This commit is contained in:
geht
2026-06-05 19:05:48 +08:00
parent fc4e3211ad
commit 1d0b4c9fbb
95 changed files with 8385 additions and 457 deletions

View File

@@ -2,8 +2,8 @@
全局审批流程设计悬浮按钮
拥有 approval:flow:design 权限的用户在任意功能页点击即可
1后端按当前页路由反查绑定的业务表
2解析该表字段识别校对/审核/审批/分发/抄送等阶段字段不存在不报错
3进入可视化设计器可点选识别到的阶段字段按顺序生成审批流程并保存发布
2从审批注册中心读取该单据已启用的审批环节
3进入可视化设计器可点选启用环节按顺序生成审批节点也支持手动添加节点
@author GHT
@date 2026-05-29 forQH-MES审批流设计全局审批流程设计悬浮按钮
-->

View File

@@ -36,7 +36,13 @@
import { useListPage } from '/@/hooks/system/useListPage';
import { useMessage } from '/@/hooks/web/useMessage';
import { columns, searchFormSchema } from './approvalFlow.data';
import { getApprovalFlowList, deleteApprovalFlow, batchDeleteApprovalFlow, updateApprovalFlowStatus } from './approvalFlow.api';
import {
getApprovalFlowList,
deleteApprovalFlow,
batchDeleteApprovalFlow,
updateApprovalFlowStatus,
getApprovalRegistryStages,
} from './approvalFlow.api';
import ApprovalFlowModal from './ApprovalFlowModal.vue';
import FlowDesign from './components/FlowDesign.vue';
@@ -73,9 +79,17 @@
openModal(true, { isUpdate: true, record });
}
// 打开可视化设计器
function handleDesign(record, readonly = false) {
openDesign(true, { record, readonly });
// 打开可视化设计器(加载审批注册中心启用环节)
async function handleDesign(record, readonly = false) {
let paletteStages: any[] = [];
if (record?.bizTable) {
try {
paletteStages = (await getApprovalRegistryStages(record.bizTable)) || [];
} catch {
paletteStages = [];
}
}
openDesign(true, { record, readonly, paletteStages });
}
function handleDelete(record) {

View File

@@ -16,6 +16,7 @@ enum Api {
deleteBatch = '/xslmes/approvalFlow/deleteBatch',
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页设计上下文-----
designContext = '/xslmes/approvalFlow/designContext',
registryStages = '/xslmes/approvalFlow/registryStages',
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页设计上下文-----
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】业务表可选回调动作-----
bizActions = '/xslmes/approvalFlow/bizActions',
@@ -72,9 +73,12 @@ export const batchDeleteApprovalFlow = (params, handleSuccess) => {
/**
* 获取当前功能页的审批流设计上下文:
* 返回 { routePath, bizTable, bizTableName, stages[], flow }
* stages 为识别到的阶段字段(校对/审核/审批/分发/抄送)flow 为可直接进入设计器的流程记录。
* stages 为审批注册中心已启用环节flow 为可直接进入设计器的流程记录。
*/
export const getApprovalDesignContext = (routePath: string) => defHttp.get({ url: Api.designContext, params: { routePath } });
/** 按业务表查询审批注册中心启用环节(列表页「设计」入口用) */
export const getApprovalRegistryStages = (bizTable: string) => defHttp.get<any[]>({ url: Api.registryStages, params: { bizTable } });
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页设计上下文(解析阶段字段+取/建草稿流程)-----
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】业务表可选回调动作(后端@ApprovalBizAction注解扫描)-----
@@ -84,3 +88,12 @@ export const getApprovalDesignContext = (routePath: string) => defHttp.get({ url
*/
export const getApprovalBizActions = (table: string) => defHttp.get({ url: Api.bizActions, params: { table } });
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】业务表可选回调动作-----
// update-begin---author:GHT ---date:2026-06-05 for【XSLMES-20260605-K8R2】流程节点绑定已发布集成方案-----
/** 查询某业务表、某触发时机下已发布的集成方案,供节点配置下拉 */
export const listPublishedIntegrationPlans = (params: { sourceTable: string; triggerPhase: string }) =>
defHttp.get({
url: '/xslmes/mesXslIntegrationPlan/list',
params: { ...params, status: '1', pageNo: 1, pageSize: 200 },
});
// update-end---author:GHT ---date:2026-06-05 for【XSLMES-20260605-K8R2】流程节点绑定已发布集成方案-----

View File

@@ -11,12 +11,17 @@
<span class="fd-tb-item">审批流<b>{{ record.flowName }}</b></span>
<span class="fd-tb-tip">点击节点可配置点击节点间的+可插入审批人 / 抄送人 / 条件分支</span>
</div>
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计当前页识别到的阶段字段作为候选,点选追加为流程节点----- -->
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计审批注册中心启用环节作为候选,点选追加为流程节点----- -->
<div class="fd-body">
<div class="fd-palette" v-if="!readonly && paletteStages.length">
<div class="fd-palette-title">当前页识别到的审批阶段</div>
<div class="fd-palette-tip">点击下方阶段按顺序追加到流程末尾处理人将取自单据对应字段</div>
<div class="fd-palette-list">
<div class="fd-palette" v-if="!readonly">
<div class="fd-palette-title">审批注册中心启用环节</div>
<div class="fd-palette-tip" v-if="paletteStages.length">
点击下方环节按顺序追加到流程末尾处理人取自注册中心配置的人员字段
</div>
<div class="fd-palette-tip fd-palette-empty" v-else>
该单据未在审批注册中心配置启用环节您仍可通过节点间+手动添加审批人节点仅发起审批时生效
</div>
<div class="fd-palette-list" v-if="paletteStages.length">
<div v-for="s in paletteStages" :key="s.stageKey" class="fd-palette-item" :class="'fd-palette-' + s.nodeType" @click="appendStageNode(s)">
<div class="fd-palette-item-name">{{ s.stageName }}</div>
<div class="fd-palette-item-field">{{ s.fieldComment || s.field }}</div>
@@ -34,7 +39,7 @@
</div>
</div>
</div>
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计当前页识别到的阶段字段作为候选,点选追加为流程节点----- -->
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计审批注册中心启用环节作为候选,点选追加为流程节点----- -->
</div>
<NodeConfigDrawer ref="drawerRef" :readonly="readonly" />
</BasicModal>
@@ -71,9 +76,10 @@
// 当前审批流绑定的业务表,供节点配置按表查可选回调动作
const bizTableRef = ref('');
provide('approvalBizTable', bizTableRef);
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页识别到的候选阶段字段----- -->
provide('approvalFlowRoot', root);
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】审批注册中心候选环节----- -->
const paletteStages = ref<StageField[]>([]);
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】当前页识别到的候选阶段字段----- -->
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】审批注册中心候选环节----- -->
const modalTitle = computed(() => (readonly.value ? '查看审批流' : '设计审批流'));
@@ -108,9 +114,9 @@
readonly.value = !!data?.readonly;
flowCtx.readonly = readonly.value;
bizTableRef.value = data?.record?.bizTable || '';
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】接收当前页解析出的候选阶段字段----- -->
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】接收审批注册中心候选环节----- -->
paletteStages.value = Array.isArray(data?.paletteStages) ? data.paletteStages : [];
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】接收当前页解析出的候选阶段字段----- -->
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】接收审批注册中心候选环节----- -->
const id = data?.record?.id || '';
Object.assign(record, {
id,

View File

@@ -87,48 +87,76 @@
</a-radio-group>
</a-form-item>
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计节点回调接口可视化配置(自动识别页面按钮)----- -->
<a-divider style="margin: 16px 0 12px">回调接口审批联动业务</a-divider>
<!-- update-begin---author:GHT ---date:2026-06-05 forXSLMES-20260605-K8R3新增绑定审批环节配置区分关键节点与纯过路审批节点----- -->
<a-divider style="margin: 16px 0 12px">审批环节绑定</a-divider>
<a-form-item label="绑定审批环节">
<a-select
v-model:value="form.props.stageKey"
:disabled="readonly"
allow-clear
placeholder="未设置(按节点名/单据状态自动推断)"
style="width: 260px"
@change="onStageKeyChange"
>
<a-select-option value="">不绑定纯过路审批不改变单据状态</a-select-option>
<a-select-option value="proofread">校对</a-select-option>
<a-select-option value="audit">审核</a-select-option>
<a-select-option value="approve">批准</a-select-option>
</a-select>
<div style="font-size: 12px; color: #888; margin-top: 4px; line-height: 1.5">
<span v-if="form.props.stageKey == null || form.props.stageKey === undefined">
未设置钉钉回调时按节点名称或单据状态自动匹配旧版兼容模式
</span>
<span v-else-if="form.props.stageKey === ''" style="color: #ff7a00">
纯过路审批此节点通过后不触发任何集成动作不改变单据状态
</span>
<span v-else style="color: #389e0d">
关键节点此节点通过后触发{{ stageKeyLabel(form.props.stageKey) }}环节的集成方案
</span>
</div>
</a-form-item>
<!-- update-end---author:GHT ---date:2026-06-05 forXSLMES-20260605-K8R3新增绑定审批环节配置区分关键节点与纯过路审批节点----- -->
<!-- update-begin---author:GHT ---date:2026-06-05 forXSLMES-20260605-K8R2节点改绑集成方案,停用 HTTP 回调接口配置----- -->
<a-divider style="margin: 16px 0 12px">集成方案审批联动业务</a-divider>
<a-alert
type="info"
show-icon
style="margin-bottom: 12px"
:message="
pageActionOptions.length
? '以下为该业务已标注(@ApprovalBizAction的可选接口。审批到对应时机时系统会以「当前处理人」身份调用所选接口自动带上单据ID。'
: '该业务暂未标注可选接口(在后端 Controller 方法上加 @ApprovalBizAction 注解即可出现在此),也可手动填写接口路径。'
"
message="审批到对应时机时,由「集成方案管理」中已发布方案自动执行业务效果(如环节状态同步)。此处选择便于设计与核对;实际执行按 source_table + trigger_phase + trigger_stage 匹配。"
/>
<div v-for="phase in callbackPhases" :key="phase.key" class="fd-cb-block">
<div class="fd-cb-title">{{ phase.label }}</div>
<div v-for="(a, i) in form.props.callbackActions[phase.key]" :key="i" class="fd-cb-row">
<a-input v-model:value="a.name" placeholder="动作名" :disabled="readonly" style="width: 88px" />
<a-select v-model:value="a.method" :disabled="readonly" style="width: 82px" :options="methodOptions" />
<a-input v-model:value="a.url" placeholder="接口路径 /xxx" :disabled="readonly" style="flex: 1; min-width: 120px" />
<Icon v-if="!readonly" icon="ant-design:minus-circle-outlined" class="fd-cb-del" @click="removeAction(phase.key, i)" />
</div>
<a-space v-if="!readonly" style="margin-top: 4px" :size="6" wrap>
<a-select
v-if="pageActionOptions.length"
style="width: 240px"
placeholder="从页面按钮中选择…"
:options="pageActionOptions"
v-model:value="actionPicker[phase.key]"
show-search
option-filter-prop="label"
@select="(v) => addFromButton(phase.key, v)"
/>
<a-button size="small" @click="addAction(phase.key)">手动添加</a-button>
</a-space>
<a-alert
v-if="isLastApproverNode"
type="warning"
show-icon
style="margin-bottom: 12px"
message="当前为流程最后一个审批节点:「批准」类方案触发时机为「审批通过」,已合并到下方下拉(带 [流程最终通过] 前缀)。"
/>
<div class="fd-ip-block">
<div class="fd-ip-title">{{ isLastApproverNode ? '本节点通过 / 流程最终通过时执行' : '本节点通过时执行' }}</div>
<a-select
:value="primaryPlanValue"
:disabled="readonly"
allow-clear
show-search
option-filter-prop="label"
placeholder="选择已发布的集成方案(可留空,由引擎自动匹配)"
style="width: 100%"
:options="primaryPlanOptions"
@change="onPrimaryPlanChange"
/>
</div>
<div v-if="!isLastApproverNode && planOptions.onApprove.length" class="fd-ip-block">
<div class="fd-ip-title">流程最终通过时执行</div>
<a-alert type="info" show-icon style="margin-bottom: 8px" message="仅流程最后一个审批节点需要配置;中间节点请只配置「本节点通过」。" />
</div>
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计驳回统一回退,无需逐节点配置----- -->
<a-alert
type="success"
show-icon
style="margin-top: 4px"
message="驳回 / 撤销 已全局统一:系统会自动执行该业务标注为「驳回时执行(@ApprovalBizAction onReject)」的接口完成回退,无需在此逐节点、逐流程配置。"
style="margin-top: 8px"
message="驳回 / 撤销:在「集成方案管理」配置 trigger_phase=onReject 的方案(如 REGISTRY_STAGE_REVERT无需在节点单独配置。"
/>
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计驳回统一回退,无需逐节点配置----- -->
<!-- update-end---author:GHT ---date:2026-06-05 forXSLMES-20260605-K8R2节点改绑集成方案,停用 HTTP 回调接口配置----- -->
</template>
<!-- 抄送人 -->
@@ -200,55 +228,132 @@
</template>
<script lang="ts" setup>
import { computed, ref, inject, watch, nextTick } from 'vue';
import { computed, ref, inject, watch, nextTick, reactive } from 'vue';
import type { Ref } from 'vue';
import { cloneDeep } from 'lodash-es';
import { defHttp } from '/@/utils/http/axios';
import { ApiSelect } from '/@/components/Form';
import JSelectUser from '/@/components/Form/src/jeecg/components/JSelectUser.vue';
import { OPERATOR_OPTIONS } from './flowTypes';
import { OPERATOR_OPTIONS, collectApproverNodes } from './flowTypes';
import type { FlowNode } from './flowTypes';
import { useMessage } from '/@/hooks/web/useMessage';
import { getApprovalBizActions } from '../approvalFlow.api';
import { listPublishedIntegrationPlans } from '../approvalFlow.api';
const props = defineProps<{ readonly?: boolean }>();
const emit = defineEmits(['confirm']);
const { createMessage } = useMessage();
// 当前审批流绑定的业务表(由 FlowDesign 注入),据此向后端查可选回调动作
const bizTable = inject<Ref<string>>('approvalBizTable', ref(''));
// 后端 @ApprovalBizAction 标注的可选业务动作
const bizActions = ref<any[]>([]);
const bizActionsTable = ref('');
const flowRoot = inject<Ref<FlowNode | null>>('approvalFlowRoot', ref(null));
const integrationPlansCacheKey = ref('');
async function loadBizActions() {
const STAGE_LABELS: Record<string, string> = { proofread: '校对', audit: '审核', approve: '批准' };
const integrationPhases = [
{ key: 'onNodeApprove', label: '本节点通过时执行' },
{ key: 'onApprove', label: '流程最终通过时执行' },
];
const planOptions = reactive<Record<string, { label: string; value: string }[]>>({
onNodeApprove: [],
onApprove: [],
});
const isLastApproverNode = computed(() => {
if (!node.value || node.value.type !== 'approver') return false;
const approvers = collectApproverNodes(flowRoot.value);
return approvers.length > 0 && approvers[approvers.length - 1].id === node.value.id;
});
const primaryPlanOptions = computed(() => {
const opts = (planOptions.onNodeApprove || []).map((o) => ({
label: o.label,
value: `onNodeApprove:${o.value}`,
}));
if (isLastApproverNode.value) {
(planOptions.onApprove || []).forEach((o) => {
opts.push({
label: `[流程最终通过] ${o.label}`,
value: `onApprove:${o.value}`,
});
});
}
return opts;
});
const primaryPlanValue = computed(() => {
const ip = form.value?.props?.integrationPlans;
if (!ip) return undefined;
if (ip.onNodeApprove) return `onNodeApprove:${ip.onNodeApprove}`;
if (isLastApproverNode.value && ip.onApprove) return `onApprove:${ip.onApprove}`;
return undefined;
});
// update-begin---author:GHT ---date:2026-06-05 for【XSLMES-20260605-K8R3】绑定审批环节辅助函数-----
function stageKeyLabel(key: string): string {
const map: Record<string, string> = { proofread: '校对', audit: '审核', approve: '批准' };
return map[key] || key;
}
function onStageKeyChange(val: string | undefined) {
if (!form.value) return;
// allow-clear 触发时 val=undefined表示「未设置」null → 启发式兼容)
// 选了空串选项表示「明确不绑定」("" → 纯过路审批)
form.value.props.stageKey = val === undefined ? undefined : val;
}
// update-end---author:GHT ---date:2026-06-05 for【XSLMES-20260605-K8R3】绑定审批环节辅助函数-----
function onPrimaryPlanChange(val?: string) {
if (!form.value) return;
form.value.props.integrationPlans.onNodeApprove = undefined;
form.value.props.integrationPlans.onApprove = undefined;
if (!val) return;
const sep = val.indexOf(':');
if (sep <= 0) return;
const phase = val.slice(0, sep);
const planId = val.slice(sep + 1);
form.value.props.integrationPlans[phase] = planId;
}
async function loadIntegrationPlans() {
const table = bizTable.value || '';
if (!table || bizActionsTable.value === table) {
if (!table) {
planOptions.onNodeApprove = [];
planOptions.onApprove = [];
return;
}
const cacheKey = table;
if (integrationPlansCacheKey.value === cacheKey) {
return;
}
try {
const res = await getApprovalBizActions(table);
bizActions.value = Array.isArray(res) ? res : [];
bizActionsTable.value = table;
} catch (e) {
bizActions.value = [];
for (const phase of integrationPhases) {
const res = await listPublishedIntegrationPlans({ sourceTable: table, triggerPhase: phase.key });
const records = res?.records ?? [];
planOptions[phase.key] = records.map((p: any) => ({
label: formatPlanLabel(p),
value: p.id,
}));
}
integrationPlansCacheKey.value = cacheKey;
} catch (_e) {
planOptions.onNodeApprove = [];
planOptions.onApprove = [];
}
}
// 可选动作下拉项(含真实 url/method
const pageActionOptions = computed(() =>
(bizActions.value || []).map((a) => ({
label: `${a.name}${a.method} ${a.url}`,
value: a.url,
raw: a,
})),
);
function formatPlanLabel(plan: any) {
const stage = plan?.triggerStage ? STAGE_LABELS[plan.triggerStage] || plan.triggerStage : '';
const code = plan?.planCode ? ` [${plan.planCode}]` : '';
return `${plan.planName || plan.planCode || plan.id}${stage ? '' + stage + '' : ''}${code}`;
}
watch(
bizTable,
() => {
loadBizActions();
integrationPlansCacheKey.value = '';
loadIntegrationPlans();
},
{ immediate: true },
);
@@ -256,24 +361,10 @@
const open = ref(false);
const node = ref<FlowNode | null>(null);
const form = ref<any>(null);
// 「从页面按钮中选择」下拉的临时选中值(仅作选择器,选完即清空,避免跨节点残留上次的选项)
const actionPicker = ref<Record<string, any>>({ onNodeApprove: undefined, onApprove: undefined, onReject: undefined });
const operatorOptions = OPERATOR_OPTIONS;
const readonly = computed(() => !!props.readonly);
// 回调接口配置:通过类时机需按节点配置;驳回类(onReject)已全局统一(后端按 @ApprovalBizAction 自动执行),无需在此逐节点维护
const callbackPhases = [
{ key: 'onNodeApprove', label: '本节点通过时执行' },
{ key: 'onApprove', label: '流程最终通过时执行' },
];
const methodOptions = [
{ label: 'POST', value: 'POST' },
{ label: 'GET', value: 'GET' },
{ label: 'PUT', value: 'PUT' },
{ label: 'DELETE', value: 'DELETE' },
];
const title = computed(() => {
const map: Record<string, string> = { start: '发起人设置', approver: '审批人设置', cc: '抄送人设置', branch: '条件设置' };
return map[node.value?.type || ''] || '节点设置';
@@ -283,48 +374,19 @@
function openDrawer(n: FlowNode) {
node.value = n;
// 切换节点时清空下拉选择器的残留值
actionPicker.value = { onNodeApprove: undefined, onApprove: undefined, onReject: undefined };
// 编辑副本,确定时回写,避免取消后脏数据
form.value = { name: n.name, props: cloneDeep(n.props) };
// 审批人节点确保回调接口配置结构存在
if (n.type === 'approver') {
const cb = form.value.props.callbackActions || {};
form.value.props.callbackActions = {
onNodeApprove: Array.isArray(cb.onNodeApprove) ? cb.onNodeApprove : [],
onApprove: Array.isArray(cb.onApprove) ? cb.onApprove : [],
onReject: Array.isArray(cb.onReject) ? cb.onReject : [],
const ip = form.value.props.integrationPlans || {};
form.value.props.integrationPlans = {
onNodeApprove: ip.onNodeApprove || undefined,
onApprove: ip.onApprove || undefined,
};
// 清空历史 HTTP 回调配置,避免与集成方案双写
form.value.props.callbackActions = { onNodeApprove: [], onApprove: [], onReject: [] };
}
open.value = true;
}
function addAction(phaseKey: string) {
form.value.props.callbackActions[phaseKey].push({ name: '', method: 'POST', url: '' });
}
function removeAction(phaseKey: string, i: number) {
form.value.props.callbackActions[phaseKey].splice(i, 1);
}
/** 从「已标注的业务动作」中选择一个接口加入回调(按 url 值查回原始动作) */
function addFromButton(phaseKey: string, url: string) {
const opt = pageActionOptions.value.find((o) => o.value === url);
const raw = opt?.raw;
if (!raw || !raw.url) {
return;
}
const list = form.value.props.callbackActions[phaseKey];
// 选完即清空下拉,使其回到 placeholder仅作选择器用
actionPicker.value[phaseKey] = undefined;
if (list.some((a) => a.url === raw.url)) {
createMessage.info('该接口已添加');
return;
}
list.push({ name: raw.name, method: raw.method || 'POST', url: raw.url });
}
/** 切换审批方式时,若切为单人则自动裁剪 userText 至第一个 */
function onMultiModeChange() {
if (!form.value) return;
if (form.value.props.multiMode === 'none') {
@@ -332,7 +394,6 @@
}
}
/** userText 变化时,若当前是单人模式则裁剪 */
function onUserTextChange() {
if (!form.value) return;
if (form.value.props.multiMode === 'none') {
@@ -355,7 +416,6 @@
function onConfirm() {
if (node.value && form.value) {
// 单人审批最终兜底校验
if (
node.value.type === 'approver' &&
form.value.props.multiMode === 'none' &&
@@ -397,29 +457,17 @@
cursor: pointer;
}
/* 回调接口配置 */
.fd-cb-block {
.fd-ip-block {
margin-bottom: 14px;
padding: 10px;
background: #fafafa;
border: 1px solid #f0f0f0;
border-radius: 6px;
}
.fd-cb-title {
.fd-ip-title {
font-size: 13px;
font-weight: 500;
color: #595959;
margin-bottom: 8px;
}
.fd-cb-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
}
.fd-cb-del {
color: #ff4d4f;
cursor: pointer;
flex-shrink: 0;
}
</style>

View File

@@ -56,6 +56,14 @@
color: #999;
line-height: 1.5;
margin-bottom: 12px;
&.fd-palette-empty {
color: #d48806;
background: #fffbe6;
border: 1px solid #ffe58f;
border-radius: 6px;
padding: 8px 10px;
}
}
.fd-palette-list {

View File

@@ -81,8 +81,8 @@ export function createCcNode(): FlowNode {
};
}
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】按当前页解析的字段生成审批阶段节点-----
/** 解析出的页面阶段字段 */
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】按审批注册中心启用环节生成审批阶段节点-----
/** 审批注册中心启用环节 */
export interface StageField {
stageKey: string;
stageName: string;
@@ -92,9 +92,8 @@ export interface StageField {
}
/**
* 由"当前页字段"生成阶段节点:
* 审批阶段(校对/审核/审批/分发) -> 审批人节点,处理人=取单据该字段中的人员;
* 抄送阶段 -> 抄送节点,抄送人=取单据该字段中的人员。
* 由审批注册中心启用环节生成阶段节点:
* 审批环节(校对/审核/批准) -> 审批人节点,处理人=取注册中心配置的人员字段。
*/
export function createStageNode(stage: StageField): FlowNode {
const fieldLabel = stage.fieldComment || stage.field;
@@ -111,9 +110,10 @@ export function createStageNode(stage: StageField): FlowNode {
node.props.approverType = 'field';
node.props.fieldName = stage.field;
node.props.fieldLabel = fieldLabel;
node.props.stageKey = stage.stageKey;
return node;
}
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】按当前页解析的字段生成审批阶段节点-----
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】按审批注册中心启用环节生成审批阶段节点-----
/** 条件分支节点(默认两条分支:条件 + 其它情况) */
export function createConditionNode(): FlowNode {
@@ -202,6 +202,25 @@ function operatorText(op: string): string {
return OPERATOR_OPTIONS.find((o) => o.value === op)?.label || op;
}
/** 按主流程顺序收集审批人节点(不含条件分支内的审批人) */
export function collectApproverNodes(node: FlowNode | null | undefined): FlowNode[] {
const result: FlowNode[] = [];
let current = node;
while (current) {
if (current.type === 'approver') {
result.push(current);
}
if (current.type === 'condition' && current.conditionNodes?.length) {
const firstBranch = current.conditionNodes[0];
if (firstBranch?.childNode) {
result.push(...collectApproverNodes(firstBranch.childNode));
}
}
current = current.childNode || null;
}
return result;
}
/** 深度遍历每个节点(含分支) */
export function eachNode(node: FlowNode | null | undefined, cb: (n: FlowNode) => void) {
if (!node) return;

View File

@@ -0,0 +1,15 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
list = '/xslmes/mesXslApprovalTrace/list',
queryById = '/xslmes/mesXslApprovalTrace/queryById',
queryByBiz = '/xslmes/mesXslApprovalTrace/queryByBiz',
}
export const list = (params) => defHttp.get({ url: Api.list, params });
export const queryById = (params: { id: string }) => defHttp.get({ url: Api.queryById, params });
/** 按业务表 + 单据ID 查询痕迹(供业务页关联展示) */
export const queryByBiz = (params: { bizTable: string; bizDataId: string }) =>
defHttp.get({ url: Api.queryByBiz, params });

View File

@@ -0,0 +1,25 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '业务表', dataIndex: 'bizTable', width: 200, align: 'left', ellipsis: true },
{ title: '单据ID', dataIndex: 'bizDataId', width: 200, align: 'left', ellipsis: true },
{ title: '校对人', dataIndex: 'proofreadBy', width: 100 },
{ title: '校对时间', dataIndex: 'proofreadTime', width: 165 },
{ title: '审核人', dataIndex: 'auditBy', width: 100 },
{ title: '审核时间', dataIndex: 'auditTime', width: 165 },
{ title: '批准人', dataIndex: 'approveBy', width: 100 },
{ title: '批准时间', dataIndex: 'approveTime', width: 165 },
{ title: '更新时间', dataIndex: 'updateTime', width: 165 },
];
export const searchFormSchema: FormSchema[] = [
{ label: '业务表', field: 'bizTable', component: 'JInput', colProps: { span: 8 } },
{ label: '单据ID', field: 'bizDataId', component: 'JInput', colProps: { span: 8 } },
];
/** 注册中心抽屉内明细列表(已按业务表过滤,不重复展示业务表列) */
export const drawerColumns: BasicColumn[] = columns.filter((col) => col.dataIndex !== 'bizTable');
export const drawerSearchFormSchema: FormSchema[] = [
{ label: '单据ID', field: 'bizDataId', component: 'JInput', colProps: { span: 12 } },
];

View File

@@ -0,0 +1,42 @@
<template>
<div>
<BasicTable @register="registerTable" />
</div>
</template>
<script lang="ts" name="xslmes-mesXslApprovalTrace" setup>
import { onMounted } from 'vue';
import { useRoute } from 'vue-router';
import { BasicTable } from '/@/components/Table';
import { useListPage } from '/@/hooks/system/useListPage';
import { columns, searchFormSchema } from './MesXslApprovalTrace.data';
import { list } from './MesXslApprovalTrace.api';
const route = useRoute();
const { tableContext } = useListPage({
tableProps: {
title: '审批痕迹',
api: list,
columns,
canResize: true,
formConfig: {
schemas: searchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: false,
},
actionColumn: { width: 0, ifShow: false },
},
});
const [registerTable, { getForm, reload }] = tableContext;
onMounted(async () => {
const bizTable = route.query.bizTable as string;
if (bizTable) {
const form = await getForm();
await form.setFieldsValue({ bizTable });
reload();
}
});
</script>

View File

@@ -0,0 +1,32 @@
import { defHttp } from '/@/utils/http/axios';
import { useMessage } from '/@/hooks/web/useMessage';
const { createConfirm } = useMessage();
enum Api {
list = '/xslmes/mesXslBizDocRegistry/list',
save = '/xslmes/mesXslBizDocRegistry/add',
edit = '/xslmes/mesXslBizDocRegistry/edit',
deleteOne = '/xslmes/mesXslBizDocRegistry/delete',
deleteBatch = '/xslmes/mesXslBizDocRegistry/deleteBatch',
}
export const list = (params) => defHttp.get({ url: Api.list, params });
export const saveOrUpdate = (params, isUpdate) =>
isUpdate ? defHttp.put({ url: Api.edit, params }) : defHttp.post({ url: Api.save, params });
export const deleteOne = (params, handleSuccess) =>
defHttp.delete({ url: Api.deleteOne, params }, { joinParamsToUrl: true }).then(() => handleSuccess());
export const batchDelete = (params, handleSuccess) => {
createConfirm({
iconType: 'warning',
title: '确认删除',
content: '是否删除选中数据',
okText: '确认',
cancelText: '取消',
onOk: () =>
defHttp.delete({ url: Api.deleteBatch, params }, { joinParamsToUrl: true }).then(() => handleSuccess()),
});
};

View File

@@ -0,0 +1,134 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
const STAGE_DICT = 'mes_xsl_approval_stage';
function hasStage(values: Recordable, stage: string) {
const raw = values?.enabledStages;
if (!raw) return false;
if (Array.isArray(raw)) return raw.includes(stage);
return String(raw).split(',').includes(stage);
}
export const columns: BasicColumn[] = [
{ title: '业务编码', dataIndex: 'docCode', width: 140, align: 'left' },
{ title: '物理表名', dataIndex: 'tableName', width: 200, align: 'left' },
{ title: '中文名称', dataIndex: 'displayName', width: 140 },
{ title: '启用环节', dataIndex: 'enabledStages_dictText', width: 180, ellipsis: true },
{ title: '启用', dataIndex: 'enabled_dictText', width: 70 },
{ title: '备注', dataIndex: 'remark', ellipsis: true },
{ title: '创建时间', dataIndex: 'createTime', width: 160 },
];
export const searchFormSchema: FormSchema[] = [
{ label: '业务编码', field: 'docCode', component: 'JInput', colProps: { span: 6 } },
{ label: '表名/中文名', field: 'displayName', component: 'JInput', colProps: { span: 6 } },
{
label: '启用',
field: 'enabled',
component: 'JDictSelectTag',
componentProps: { dictCode: 'yn' },
colProps: { span: 6 },
},
];
export const formSchema: FormSchema[] = [
{ label: '', field: 'id', component: 'Input', show: false },
{
label: '业务编码',
field: 'docCode',
component: 'Input',
componentProps: { placeholder: '唯一标识,如 mixer_ps_compile' },
dynamicRules: () => [{ required: true, message: '请输入业务编码!' }],
},
{
label: '物理表名',
field: 'tableName',
component: 'Input',
componentProps: { placeholder: '数据库表名,如 mes_xsl_mixer_ps_compile' },
dynamicRules: () => [{ required: true, message: '请输入物理表名!' }],
},
{
label: '中文名称',
field: 'displayName',
component: 'Input',
componentProps: { placeholder: '如 密炼PS编制' },
},
{
label: '启用',
field: 'enabled',
component: 'Switch',
defaultValue: 1,
componentProps: {
checkedValue: 1,
unCheckedValue: 0,
checkedChildren: '是',
unCheckedChildren: '否',
},
},
{
label: '启用环节',
field: 'enabledStages',
component: 'JDictSelectTag',
componentProps: {
dictCode: STAGE_DICT,
mode: 'multiple',
placeholder: '多选:校对 / 审核 / 批准',
},
helpMessage: '勾选后该业务表才允许执行对应环节,并写入审批痕迹明细',
},
{
label: '状态字段',
field: 'statusField',
component: 'Input',
defaultValue: 'status',
componentProps: { placeholder: '默认 status' },
},
{
label: '校对人字段',
field: 'proofreadByField',
component: 'Input',
defaultValue: 'proofread_by',
ifShow: ({ values }) => hasStage(values, 'proofread'),
},
{
label: '校对时间字段',
field: 'proofreadTimeField',
component: 'Input',
defaultValue: 'proofread_time',
ifShow: ({ values }) => hasStage(values, 'proofread'),
},
{
label: '审核人字段',
field: 'auditByField',
component: 'Input',
defaultValue: 'audit_by',
ifShow: ({ values }) => hasStage(values, 'audit'),
},
{
label: '审核时间字段',
field: 'auditTimeField',
component: 'Input',
defaultValue: 'audit_time',
ifShow: ({ values }) => hasStage(values, 'audit'),
},
{
label: '批准人字段',
field: 'approveByField',
component: 'Input',
defaultValue: 'approve_by',
ifShow: ({ values }) => hasStage(values, 'approve'),
},
{
label: '批准时间字段',
field: 'approveTimeField',
component: 'Input',
defaultValue: 'approve_time',
ifShow: ({ values }) => hasStage(values, 'approve'),
},
{
label: '备注',
field: 'remark',
component: 'InputTextArea',
componentProps: { rows: 3 },
},
];

View File

@@ -0,0 +1,104 @@
<template>
<div>
<BasicTable @register="registerTable" :rowSelection="rowSelection">
<template #tableTitle>
<a-button type="primary" v-auth="'xslmes:mes_xsl_biz_doc_registry:add'" preIcon="ant-design:plus-outlined" @click="handleAdd">
新增
</a-button>
<a-dropdown v-if="selectedRowKeys.length > 0">
<template #overlay>
<a-menu>
<a-menu-item key="1" @click="batchHandleDelete">
<Icon icon="ant-design:delete-outlined" />
删除
</a-menu-item>
</a-menu>
</template>
<a-button v-auth="'xslmes:mes_xsl_biz_doc_registry:delete'">
批量操作
<Icon icon="mdi:chevron-down" />
</a-button>
</a-dropdown>
</template>
<template #action="{ record }">
<TableAction
:actions="[
{ label: '编辑', onClick: handleEdit.bind(null, record), auth: 'xslmes:mes_xsl_biz_doc_registry:edit' },
{
label: '查看明细',
onClick: handleViewDetail.bind(null, record),
auth: 'xslmes:mes_xsl_biz_doc_registry:trace',
},
{
label: '删除',
auth: 'xslmes:mes_xsl_biz_doc_registry:delete',
popConfirm: {
title: '确认删除该审批注册?',
confirm: handleDelete.bind(null, record),
placement: 'topLeft',
},
},
]"
/>
</template>
</BasicTable>
<MesXslBizDocRegistryModal @register="registerModal" @success="handleSuccess" />
<MesXslApprovalTraceDrawer @register="registerTraceDrawer" />
</div>
</template>
<script lang="ts" name="xslmes-mesXslBizDocRegistry" setup>
import { BasicTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useDrawer } from '/@/components/Drawer';
import { useListPage } from '/@/hooks/system/useListPage';
import Icon from '/@/components/Icon';
import MesXslBizDocRegistryModal from './components/MesXslBizDocRegistryModal.vue';
import MesXslApprovalTraceDrawer from './components/MesXslApprovalTraceDrawer.vue';
import { columns, searchFormSchema } from './MesXslBizDocRegistry.data';
import { list, deleteOne, batchDelete } from './MesXslBizDocRegistry.api';
const [registerModal, { openModal }] = useModal();
const [registerTraceDrawer, { openDrawer: openTraceDrawer }] = useDrawer();
const { tableContext } = useListPage({
tableProps: {
title: '审批注册中心',
api: list,
columns,
canResize: true,
formConfig: {
schemas: searchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: false,
},
actionColumn: { width: 220, fixed: 'right' },
},
});
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
function handleAdd() {
openModal(true, { isUpdate: false });
}
function handleEdit(record: Recordable) {
openModal(true, { record, isUpdate: true });
}
function handleViewDetail(record: Recordable) {
openTraceDrawer(true, { record });
}
async function handleDelete(record: Recordable) {
await deleteOne({ id: record.id }, handleSuccess);
}
async function batchHandleDelete() {
await batchDelete({ ids: selectedRowKeys.value }, handleSuccess);
}
function handleSuccess() {
(selectedRowKeys.value = []) && reload();
}
</script>

View File

@@ -0,0 +1,9 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
list = '/xslmes/mesXslIntegrationLog/list',
retry = '/xslmes/mesXslIntegrationLog/retry',
}
export const list = (params) => defHttp.get({ url: Api.list, params });
export const retry = (id) => defHttp.post({ url: Api.retry, params: { id } }, { joinParamsToUrl: true });

View File

@@ -0,0 +1,30 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '源单表', dataIndex: 'sourceBizTable', width: 180, align: 'left' },
{ title: '源单ID', dataIndex: 'sourceBizId', width: 120 },
{ title: '状态', dataIndex: 'status_dictText', width: 90 },
{ title: '耗时(ms)', dataIndex: 'execTimeMs', width: 90 },
{ title: '重试次数', dataIndex: 'retryCount', width: 80 },
{ title: '错误信息', dataIndex: 'errorMessage', ellipsis: true },
{ title: '幂等键', dataIndex: 'idempotentKey', width: 200, ellipsis: true },
{ title: '创建时间', dataIndex: 'createTime', width: 160 },
];
export const searchFormSchema: FormSchema[] = [
{ label: '源单表', field: 'sourceBizTable', component: 'JInput', colProps: { span: 6 } },
{ label: '源单ID', field: 'sourceBizId', component: 'JInput', colProps: { span: 6 } },
{
label: '执行状态',
field: 'status',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_integration_log_status' },
colProps: { span: 6 },
},
{
label: '创建时间',
field: 'createTime',
component: 'RangePicker',
colProps: { span: 8 },
},
];

View File

@@ -0,0 +1,87 @@
<template>
<div>
<BasicTable @register="registerTable">
<template #action="{ record }">
<TableAction
:actions="[
{
label: '重试',
icon: 'ant-design:redo-outlined',
auth: 'xslmes:mes_xsl_integration_log:retry',
disabled: record.status === 'success',
tooltip: record.status === 'success' ? '已成功,无需重试' : '重新执行该动作',
popConfirm: {
title: '确认重试该集成动作?',
confirm: handleRetry.bind(null, record),
placement: 'topLeft',
},
},
]"
/>
</template>
<!-- 详情展开 -->
<template #expandedRowRender="{ record }">
<a-descriptions :column="2" size="small" bordered>
<a-descriptions-item label="方案ID">{{ record.planId || '—' }}</a-descriptions-item>
<a-descriptions-item label="动作ID">{{ record.actionId || '—' }}</a-descriptions-item>
<a-descriptions-item label="台账ID">{{ record.recordId || '—' }}</a-descriptions-item>
<a-descriptions-item label="幂等键">{{ record.idempotentKey || '—' }}</a-descriptions-item>
<a-descriptions-item label="错误信息" :span="2">
<span style="color: #f5222d; white-space: pre-wrap">{{ record.errorMessage || '—' }}</span>
</a-descriptions-item>
<a-descriptions-item label="请求快照" :span="2">
<pre style="margin: 0; font-size: 12px; max-height: 100px; overflow: auto">{{ record.requestSnapshot || '—' }}</pre>
</a-descriptions-item>
<a-descriptions-item label="响应快照" :span="2">
<pre style="margin: 0; font-size: 12px; max-height: 100px; overflow: auto">{{ record.responseSnapshot || '—' }}</pre>
</a-descriptions-item>
</a-descriptions>
</template>
</BasicTable>
</div>
</template>
<script lang="ts" name="xslmes-mesXslIntegrationLog" setup>
import { BasicTable, TableAction } from '/@/components/Table';
import { useListPage } from '/@/hooks/system/useListPage';
import { useMessage } from '/@/hooks/web/useMessage';
import { columns, searchFormSchema } from './MesXslIntegrationLog.data';
import { list, retry } from './MesXslIntegrationLog.api';
const { createMessage } = useMessage();
const { tableContext } = useListPage({
tableProps: {
title: '集成执行日志',
api: list,
columns,
canResize: true,
expandRowByClick: true,
formConfig: {
schemas: searchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: true,
labelWidth: 80,
},
actionColumn: { width: 90, fixed: 'right' },
},
});
const [registerTable, { reload }] = tableContext;
async function handleRetry(record: Recordable) {
try {
await retry(record.id);
createMessage.success('重试任务已提交');
reload();
} catch (e: any) {
createMessage.error(e?.message || '重试失败');
}
}
</script>
<style lang="less" scoped>
:deep(.ant-picker-range) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,57 @@
import { defHttp } from '/@/utils/http/axios';
import { useMessage } from '/@/hooks/web/useMessage';
const { createConfirm } = useMessage();
enum Api {
list = '/xslmes/mesXslIntegrationPlan/list',
save = '/xslmes/mesXslIntegrationPlan/add',
edit = '/xslmes/mesXslIntegrationPlan/edit',
deleteOne = '/xslmes/mesXslIntegrationPlan/delete',
publish = '/xslmes/mesXslIntegrationPlan/publish',
disable = '/xslmes/mesXslIntegrationPlan/disable',
tableColumns = '/xslmes/mesXslIntegrationPlan/tableColumns',
actionList = '/xslmes/mesXslIntegrationPlan/action/listByPlanId',
actionAdd = '/xslmes/mesXslIntegrationPlan/action/add',
actionEdit = '/xslmes/mesXslIntegrationPlan/action/edit',
actionDelete = '/xslmes/mesXslIntegrationPlan/action/delete',
bizDocList = '/xslmes/mesXslBizDocRegistry/list',
registryByTable = '/xslmes/mesXslIntegrationPlan/registryByTable',
previewDefaultFromFlow = '/xslmes/mesXslIntegrationPlan/previewDefaultFromFlow',
generateDefaultFromFlow = '/xslmes/mesXslIntegrationPlan/generateDefaultFromFlow',
}
export const list = (params) => defHttp.get({ url: Api.list, params });
export const saveOrUpdate = (params, isUpdate) =>
isUpdate ? defHttp.put({ url: Api.edit, params }) : defHttp.post({ url: Api.save, params });
export const deleteOne = (params, handleSuccess) =>
defHttp.delete({ url: Api.deleteOne, params }, { joinParamsToUrl: true }).then(() => handleSuccess());
export const publishPlan = (id) => defHttp.post({ url: Api.publish, params: { id } }, { joinParamsToUrl: true });
export const disablePlan = (id) => defHttp.post({ url: Api.disable, params: { id } }, { joinParamsToUrl: true });
// 可视化向导专用
export const savePlan = (params) => defHttp.post<any>({ url: Api.save, params });
export const getTableColumns = (tableName: string) => defHttp.get<any[]>({ url: Api.tableColumns, params: { tableName } });
export const listBizDocRegistry = () => defHttp.get<any>({ url: Api.bizDocList, params: { pageNo: 1, pageSize: 200 } });
export const getRegistryByTable = (tableName: string) => defHttp.get<any>({ url: Api.registryByTable, params: { tableName } });
export const getDictItems = (dictCode: string) => defHttp.get<any[]>({ url: `/sys/dict/getDictItems/${dictCode}` });
export const previewDefaultFromFlow = (params: { sourceTable: string; flowId?: string }) =>
defHttp.get<any>({ url: Api.previewDefaultFromFlow, params });
export const generateDefaultFromFlow = (params: {
sourceTable: string;
flowId?: string;
overwriteDraft?: boolean;
nodeBindings?: Array<{ nodeId: string; stage?: string | null }>;
}) => defHttp.post<any>({ url: Api.generateDefaultFromFlow, params });
// 动作管理
export const listActions = (planId) => defHttp.get({ url: Api.actionList, params: { planId } });
export const saveAction = (params) => defHttp.post({ url: Api.actionAdd, params });
export const editAction = (params) => defHttp.put({ url: Api.actionEdit, params });
export const deleteAction = (params, handleSuccess) =>
defHttp.delete({ url: Api.actionDelete, params }, { joinParamsToUrl: true }).then(() => handleSuccess());

View File

@@ -0,0 +1,156 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '方案编码', dataIndex: 'planCode', width: 150, align: 'left' },
{ title: '方案名称', dataIndex: 'planName', width: 180, align: 'left' },
{ title: '源单表', dataIndex: 'sourceTable', width: 200, align: 'left' },
{ title: '触发时机', dataIndex: 'triggerPhase_dictText', width: 110 },
{ title: '绑定环节', dataIndex: 'triggerStage_dictText', width: 90 },
{ title: '执行模式', dataIndex: 'execMode_dictText', width: 100 },
{ title: '状态', dataIndex: 'status_dictText', width: 90 },
{ title: '创建时间', dataIndex: 'createTime', width: 160 },
];
export const searchFormSchema: FormSchema[] = [
{ label: '方案编码', field: 'planCode', component: 'JInput', colProps: { span: 6 } },
{ label: '方案名称', field: 'planName', component: 'JInput', colProps: { span: 6 } },
{
label: '状态',
field: 'status',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_integration_plan_status' },
colProps: { span: 6 },
},
{
label: '触发时机',
field: 'triggerPhase',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_integration_trigger_phase' },
colProps: { span: 6 },
},
];
export const formSchema: FormSchema[] = [
{ label: '', field: 'id', component: 'Input', show: false },
{
label: '方案编码',
field: 'planCode',
component: 'Input',
componentProps: { placeholder: '唯一编码,如 formula_approve_sync' },
dynamicRules: () => [{ required: true, message: '请输入方案编码!' }],
},
{
label: '方案名称',
field: 'planName',
component: 'Input',
componentProps: { placeholder: '如 配合示方审批通过同步ERP' },
dynamicRules: () => [{ required: true, message: '请输入方案名称!' }],
},
{
label: '源单表名',
field: 'sourceTable',
component: 'Input',
componentProps: { placeholder: '触发的业务表,如 mes_xsl_formula_spec' },
dynamicRules: () => [{ required: true, message: '请输入源单表名!' }],
},
{
label: '触发时机',
field: 'triggerPhase',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_integration_trigger_phase', type: 'select' },
dynamicRules: () => [{ required: true, message: '请选择触发时机!' }],
},
{
label: '绑定环节',
field: 'triggerStage',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_approval_stage', type: 'select', placeholder: '节点通过时必选' },
helpMessage: '须为审批注册中心已启用的环节;节点通过时必选,全流程通过默认批准',
},
{
label: '执行模式',
field: 'execMode',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_integration_exec_mode', type: 'select' },
defaultValue: 'async',
},
{
label: '匹配条件',
field: 'matchCondition',
component: 'InputTextArea',
componentProps: { rows: 2, placeholder: '可选留空表示无条件匹配。如status = \'approved\'' },
},
{
label: '备注',
field: 'remark',
component: 'InputTextArea',
componentProps: { rows: 2 },
},
];
// 动作表格列
export const actionColumns: BasicColumn[] = [
{ title: '动作名称', dataIndex: 'actionName', width: 140, align: 'left' },
{ title: '动作类型', dataIndex: 'actionType_dictText', width: 110 },
{ title: '失败策略', dataIndex: 'onFail_dictText', width: 90 },
{ title: '执行顺序', dataIndex: 'execOrder', width: 80 },
{ title: '启用', dataIndex: 'enabled_dictText', width: 70 },
{ title: 'SQL 模板', dataIndex: 'sqlTemplate', ellipsis: true },
];
// 动作表单
export const actionFormSchema: FormSchema[] = [
{ label: '', field: 'id', component: 'Input', show: false },
{ label: '', field: 'planId', component: 'Input', show: false },
{
label: '动作名称',
field: 'actionName',
component: 'Input',
componentProps: { placeholder: '如 更新ERP状态' },
dynamicRules: () => [{ required: true, message: '请输入动作名称!' }],
},
{
label: '动作类型',
field: 'actionType',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_integration_action_type', type: 'select' },
defaultValue: 'SQL_UPDATE',
dynamicRules: () => [{ required: true, message: '请选择动作类型!' }],
},
{
label: 'SQL 模板',
field: 'sqlTemplate',
component: 'InputTextArea',
componentProps: {
rows: 5,
placeholder: 'UPDATE mes_xsl_xxx SET status=\'approved\' WHERE id=#{source.id}',
},
ifShow: ({ values }) => values.actionType === 'SQL_UPDATE',
},
{
label: '执行顺序',
field: 'execOrder',
component: 'InputNumber',
componentProps: { min: 0, max: 999 },
defaultValue: 0,
},
{
label: '失败策略',
field: 'onFail',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_integration_on_fail', type: 'select' },
defaultValue: 'stop',
},
{
label: '幂等键',
field: 'idempotentKey',
component: 'Input',
componentProps: { placeholder: '留空默认使用 台账ID_动作ID' },
},
{
label: '启用',
field: 'enabled',
component: 'Switch',
defaultValue: true,
},
];

View File

@@ -0,0 +1,150 @@
<template>
<div>
<BasicTable @register="registerTable">
<template #tableTitle>
<a-button type="primary" v-auth="'xslmes:mes_xsl_integration_plan:add'" preIcon="ant-design:plus-outlined" @click="handleAdd">
新增方案
</a-button>
<a-button
v-auth="'xslmes:mes_xsl_integration_plan:edit'"
preIcon="ant-design:thunderbolt-outlined"
style="margin-left: 8px"
@click="handleGenerateDefault"
>
按流程生成默认方案
</a-button>
</template>
<template #action="{ record }">
<TableAction :actions="getTableActions(record)" :dropDownActions="getDropDownActions(record)" />
</template>
</BasicTable>
<MesXslIntegrationPlanModal @register="registerModal" @success="handleSuccess" />
<MesXslIntegrationActionDrawer ref="actionDrawerRef" />
<MesXslIntegrationPlanWizard ref="wizardRef" @success="handleSuccess" />
<GenerateDefaultPlanModal ref="generateModalRef" @success="handleSuccess" />
</div>
</template>
<script lang="ts" name="xslmes-mesXslIntegrationPlan" setup>
import { ref } from 'vue';
import { BasicTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
import { useMessage } from '/@/hooks/web/useMessage';
import MesXslIntegrationPlanModal from './components/MesXslIntegrationPlanModal.vue';
import MesXslIntegrationActionDrawer from './components/MesXslIntegrationActionDrawer.vue';
import MesXslIntegrationPlanWizard from './MesXslIntegrationPlanWizard.vue';
import GenerateDefaultPlanModal from './components/GenerateDefaultPlanModal.vue';
import { columns, searchFormSchema } from './MesXslIntegrationPlan.data';
import { list, deleteOne, publishPlan, disablePlan } from './MesXslIntegrationPlan.api';
const { createMessage } = useMessage();
const [registerModal, { openModal }] = useModal();
const actionDrawerRef = ref();
const wizardRef = ref();
const generateModalRef = ref();
const { tableContext } = useListPage({
tableProps: {
title: '集成方案管理',
api: list,
columns: [
...columns,
// 状态列用 tag 渲染覆盖默认
],
canResize: true,
formConfig: {
schemas: searchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: false,
},
actionColumn: { width: 200, fixed: 'right' },
customRow: () => ({}),
},
});
const [registerTable, { reload }] = tableContext;
function handleAdd() {
wizardRef.value?.open();
}
function handleGenerateDefault() {
generateModalRef.value?.open();
}
function handleEdit(record: Recordable) {
openModal(true, { record, isUpdate: true });
}
function handleManageActions(record: Recordable) {
actionDrawerRef.value?.open(record);
}
async function handlePublish(record: Recordable) {
try {
await publishPlan(record.id);
createMessage.success('发布成功');
reload();
} catch (e: any) {
createMessage.error(e?.message || '发布失败');
}
}
async function handleDisable(record: Recordable) {
try {
await disablePlan(record.id);
createMessage.success('已停用');
reload();
} catch (e: any) {
createMessage.error(e?.message || '停用失败');
}
}
async function handleDelete(record: Recordable) {
await deleteOne({ id: record.id }, handleSuccess);
}
function handleSuccess() {
reload();
}
function getTableActions(record: Recordable) {
const actions: any[] = [
{ label: '编辑', onClick: handleEdit.bind(null, record), auth: 'xslmes:mes_xsl_integration_plan:edit', disabled: record.status === '1' },
{ label: '管理动作', icon: 'ant-design:setting-outlined', onClick: handleManageActions.bind(null, record) },
];
if (record.status === '0' || record.status === '2') {
actions.push({
label: '发布',
color: 'success',
auth: 'xslmes:mes_xsl_integration_plan:publish',
onClick: handlePublish.bind(null, record),
});
}
if (record.status === '1') {
actions.push({
label: '停用',
color: 'error',
auth: 'xslmes:mes_xsl_integration_plan:publish',
onClick: handleDisable.bind(null, record),
});
}
return actions;
}
function getDropDownActions(record: Recordable) {
return [
{
label: '删除',
auth: 'xslmes:mes_xsl_integration_plan:delete',
disabled: record.status === '1',
popConfirm: {
title: '确认删除该方案(同时删除所有动作)?',
confirm: handleDelete.bind(null, record),
placement: 'topLeft',
},
},
];
}
</script>

View File

@@ -0,0 +1,466 @@
<template>
<a-drawer
v-model:open="visible"
title="新建集成方案"
width="1120"
:body-style="{ padding: '16px 20px', display: 'flex', flexDirection: 'column', height: 'calc(100vh - 55px)' }"
@close="visible = false"
>
<!-- 基本信息 -->
<a-card size="small" title="基本信息" style="margin-bottom: 14px; flex-shrink: 0">
<a-form ref="planFormRef" :model="planForm" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="方案名称" name="planName" :rules="[{ required: true, message: '请输入方案名称' }]">
<a-input v-model:value="planForm.planName" placeholder="如 密炼PS审批通过→同步配合示方" />
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="方案编码" name="planCode" :rules="[{ required: true, message: '请输入方案编码' }]">
<a-input v-model:value="planForm.planCode" placeholder="如 mixer_ps_on_approve英文下划线" />
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16">
<a-col :span="12">
<a-form-item label="触发时机" name="triggerPhase" :rules="[{ required: true }]">
<a-radio-group v-model:value="planForm.triggerPhase" @change="onTriggerPhaseChange">
<a-radio value="onApprove">审批通过</a-radio>
<a-radio value="onReject">审批驳回</a-radio>
<a-radio value="onNodeApprove">节点通过</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item label="执行模式">
<a-radio-group v-model:value="planForm.execMode">
<a-radio value="async">异步推荐</a-radio>
<a-radio value="sync">同步</a-radio>
</a-radio-group>
</a-form-item>
</a-col>
</a-row>
<a-row :gutter="16" v-if="planForm.sourceTable">
<a-col :span="24">
<a-form-item
label="绑定环节"
name="triggerStage"
:rules="stageRules"
:help="stageHelpText"
>
<a-radio-group v-model:value="planForm.triggerStage" :disabled="!enabledStageOptions.length">
<a-radio v-if="planForm.triggerPhase === 'onReject'" :value="''">任意环节</a-radio>
<a-radio v-for="opt in enabledStageOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</a-radio>
</a-radio-group>
<div v-if="!enabledStageOptions.length" style="color: #fa8c16; font-size: 12px; margin-top: 4px">
该单据在审批注册中心未配置启用环节请先到审批注册中心勾选环节
</div>
</a-form-item>
</a-col>
</a-row>
</a-form>
</a-card>
<!-- 主体左侧选表 + 右侧动作 -->
<a-row :gutter="14" style="flex: 1; min-height: 0">
<!-- 左侧触发业务表 -->
<a-col :span="7" style="height: 100%">
<a-card
size="small"
title="触发业务表"
:body-style="{ padding: '10px', overflowY: 'auto', maxHeight: 'calc(100% - 46px)' }"
style="height: 100%"
:loading="loadingBizDocs"
>
<div style="color: #888; font-size: 12px; margin-bottom: 10px; line-height: 1.5">
选择当哪个业务表的记录完成审批时触发右侧的动作列表
</div>
<div
v-for="doc in bizDocList"
:key="doc.tableName"
:style="{
padding: '10px 12px',
border: '2px solid',
borderColor: planForm.sourceTable === doc.tableName ? '#1677ff' : '#e8e8e8',
borderRadius: '6px',
marginBottom: '8px',
cursor: 'pointer',
background: planForm.sourceTable === doc.tableName ? '#e6f4ff' : 'white',
transition: 'all 0.2s',
}"
@click="selectSourceTable(doc)"
>
<div style="font-weight: 500; color: rgba(0, 0, 0, 0.85); margin-bottom: 2px">
{{ getDocLabel(doc) || doc.tableName }}
</div>
<div style="font-size: 11px; color: #999; font-family: monospace">{{ doc.tableName }}</div>
<div v-if="planForm.sourceTable === doc.tableName && sourceColumns.length" style="font-size: 11px; color: #1677ff; margin-top: 3px">
{{ sourceColumns.length }} 个字段
</div>
<div
v-if="doc.enabledStages_dictText || doc.enabledStages"
style="font-size: 11px; color: #52c41a; margin-top: 3px"
>
环节{{ doc.enabledStages_dictText || formatStages(doc.enabledStages) }}
</div>
</div>
<div v-if="!bizDocList.length && !loadingBizDocs" style="text-align: center; padding: 20px 0; color: #bbb">
<div style="font-size: 24px">📭</div>
<div style="font-size: 12px; margin-top: 6px">暂无注册的业务表<br />请先在审批注册中心中添加</div>
</div>
</a-card>
</a-col>
<!-- 右侧集成动作列表 -->
<a-col :span="17" style="height: 100%">
<a-card
size="small"
:title="`集成动作(触发表:${planForm.sourceTable || '—'}`"
:body-style="{ padding: '12px', overflowY: 'auto', maxHeight: 'calc(100% - 46px)' }"
style="height: 100%"
>
<template #extra>
<a-button
type="primary"
size="small"
:disabled="!planForm.sourceTable"
:title="planForm.sourceTable ? '添加动作' : '请先选择左侧触发业务表'"
@click="openActionEditor()"
>
<template #icon><PlusOutlined /></template>
添加动作
</a-button>
</template>
<!-- 空状态 -->
<div v-if="!actions.length" style="text-align: center; padding: 50px 0; color: #bbb">
<div style="font-size: 40px; margin-bottom: 10px">📋</div>
<div style="font-size: 14px">{{ planForm.sourceTable ? '点击右上角「添加动作」开始配置' : '请先在左侧选择触发业务表' }}</div>
</div>
<!-- 动作卡片列表 -->
<div v-for="(action, idx) in actions" :key="idx" style="border: 1px solid #e8e8e8; border-radius: 8px; padding: 12px 14px; margin-bottom: 10px; background: white">
<div style="display: flex; justify-content: space-between; align-items: flex-start">
<div style="display: flex; align-items: center; gap: 8px; flex-wrap: wrap">
<a-tag color="blue" style="font-size: 13px; font-weight: 600">{{ idx + 1 }}</a-tag>
<span style="font-size: 13px; font-weight: 600">{{ getActionIcon(action) }} {{ action.actionName }}</span>
<a-tag :color="action.onFail === 'stop' ? 'orange' : 'default'" size="small">
{{ action.onFail === 'stop' ? '失败终止' : '失败继续' }}
</a-tag>
</div>
<a-space>
<a-button size="small" @click="openActionEditor(action, idx)">编辑</a-button>
<a-popconfirm title="确认删除该动作?" @confirm="actions.splice(idx, 1)">
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</div>
<!-- 可视化配置摘要 -->
<div v-if="action.actionConfig" style="margin-top: 8px; font-size: 12px">
{{ getVisualSummary(action) }}
</div>
<!-- SQL 预览 -->
<div v-if="action.sqlTemplate" style="margin-top: 8px">
<pre style="font-size: 11px; color: #666; margin: 0; white-space: pre-wrap; background: #f5f7fa; padding: 6px 8px; border-radius: 4px; max-height: 56px; overflow: hidden; font-family: monospace; line-height: 1.5">{{ action.sqlTemplate }}</pre>
</div>
</div>
</a-card>
</a-col>
</a-row>
<!-- 底部操作按钮 -->
<div
style="flex-shrink: 0; margin-top: 14px; padding: 12px 0; border-top: 1px solid #e8e8e8; display: flex; justify-content: flex-end; gap: 8px"
>
<a-button @click="visible = false">取消</a-button>
<a-button :loading="saving" @click="handleSave(false)">保存为草稿</a-button>
<a-button type="primary" :loading="saving" @click="handleSave(true)">保存并发布</a-button>
</div>
<VisualActionEditor ref="actionEditorRef" @success="handleActionSaved" />
</a-drawer>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { useMessage } from '/@/hooks/web/useMessage';
import {
savePlan,
publishPlan,
getTableColumns,
listBizDocRegistry,
getRegistryByTable,
getDictItems,
saveAction,
} from './MesXslIntegrationPlan.api';
import VisualActionEditor from './components/VisualActionEditor.vue';
const STAGE_DICT = 'mes_xsl_approval_stage';
const STAGE_LABELS: Record<string, string> = { proofread: '校对', audit: '审核', approve: '批准' };
const emit = defineEmits(['success']);
const { createMessage } = useMessage();
const visible = ref(false);
const saving = ref(false);
const loadingBizDocs = ref(false);
const planFormRef = ref();
const actionEditorRef = ref();
const bizDocList = ref<any[]>([]);
const sourceColumns = ref<any[]>([]);
const actions = ref<any[]>([]);
const editingIdx = ref(-1);
const planForm = ref({
planCode: '',
planName: '',
sourceTable: '',
registryId: '',
triggerPhase: 'onApprove',
triggerStage: 'approve',
execMode: 'async',
remark: '',
});
const stageDictItems = ref<{ label: string; value: string }[]>([]);
const selectedRegistry = ref<any>(null);
const enabledStageOptions = computed(() => {
const raw = selectedRegistry.value?.enabledStages;
if (!raw) return [];
const enabled = String(raw)
.split(',')
.map((s) => s.trim())
.filter(Boolean);
const dictMap = new Map(stageDictItems.value.map((d) => [d.value, d.label]));
return enabled.map((v) => ({ value: v, label: dictMap.get(v) || STAGE_LABELS[v] || v }));
});
const stageRules = computed(() => {
if (planForm.value.triggerPhase === 'onNodeApprove') {
return [{ required: true, message: '节点通过时必须选择绑定环节' }];
}
return [];
});
const stageHelpText = computed(() => {
if (!planForm.value.sourceTable) return '请先选择左侧触发业务表';
if (planForm.value.triggerPhase === 'onApprove') return '全流程最终通过时触发,默认绑定「批准」环节';
if (planForm.value.triggerPhase === 'onReject') return '驳回时触发;选「任意环节」表示任一节点驳回均执行';
return '仅当该审批环节通过时触发集成动作';
});
async function loadBizDocs() {
loadingBizDocs.value = true;
try {
const [res, dictRes] = await Promise.all([listBizDocRegistry(), getDictItems(STAGE_DICT)]);
bizDocList.value = (res as any)?.records || (Array.isArray(res) ? res : []);
stageDictItems.value = (dictRes || []).map((d: any) => ({
label: d.text || d.label || d.itemText,
value: d.value || d.itemValue,
}));
} catch {
bizDocList.value = [];
stageDictItems.value = Object.entries(STAGE_LABELS).map(([value, label]) => ({ value, label }));
} finally {
loadingBizDocs.value = false;
}
}
async function selectSourceTable(doc: any) {
planForm.value.sourceTable = doc.tableName;
planForm.value.registryId = doc.id || '';
sourceColumns.value = [];
selectedRegistry.value = doc;
try {
const [cols, registry] = await Promise.all([
getTableColumns(doc.tableName),
getRegistryByTable(doc.tableName).catch(() => doc),
]);
sourceColumns.value = (cols as any) || [];
if (registry) {
selectedRegistry.value = registry;
planForm.value.registryId = registry.id || doc.id || '';
}
} catch {
sourceColumns.value = [];
}
syncDefaultTriggerStage();
}
function formatStages(stages: string) {
return String(stages)
.split(',')
.map((s) => STAGE_LABELS[s.trim()] || s.trim())
.join(' / ');
}
function onTriggerPhaseChange() {
syncDefaultTriggerStage();
}
function syncDefaultTriggerStage() {
const opts = enabledStageOptions.value;
if (!opts.length) {
planForm.value.triggerStage = '';
return;
}
if (planForm.value.triggerPhase === 'onApprove') {
planForm.value.triggerStage = opts.some((o) => o.value === 'approve') ? 'approve' : opts[opts.length - 1].value;
} else if (planForm.value.triggerPhase === 'onReject') {
if (!opts.some((o) => o.value === planForm.value.triggerStage)) {
planForm.value.triggerStage = '';
}
} else if (planForm.value.triggerPhase === 'onNodeApprove') {
if (!opts.some((o) => o.value === planForm.value.triggerStage)) {
planForm.value.triggerStage = opts[0].value;
}
}
}
function getDocLabel(doc: any): string {
return doc?.displayName || doc?.docName || doc?.bizName || '';
}
function getActionIcon(action: any): string {
if (action.actionType === 'REGISTRY_STAGE_SYNC') return '✅';
if (action.actionType === 'REGISTRY_STAGE_REVERT') return '↩️';
if (!action.actionConfig) return '🔧';
try {
const cfg = JSON.parse(action.actionConfig);
if (cfg.visualType === 'REGISTRY_STAGE_SYNC') return '✅';
if (cfg.visualType === 'REGISTRY_STAGE_REVERT') return '↩️';
return cfg.visualType === 'STATUS_MODIFY' ? '📋' : '🔄';
} catch {
return '🔧';
}
}
function getVisualSummary(action: any): string {
if (action.actionType === 'REGISTRY_STAGE_SYNC') {
return '审批注册中心环节同步(自动写状态/操作人/痕迹)';
}
if (action.actionType === 'REGISTRY_STAGE_REVERT') {
return '审批注册中心环节回退(驳回回编制态)';
}
if (!action.actionConfig) return '';
try {
const cfg = JSON.parse(action.actionConfig);
if (cfg.visualType === 'REGISTRY_STAGE_SYNC') {
const stage = cfg.registryStage?.stage || cfg.stage || '?';
return `环节同步 → ${stage},前置=${cfg.registryStage?.expectedFrom || cfg.expectedFrom || '自动'}`;
}
if (cfg.visualType === 'REGISTRY_STAGE_REVERT') {
return `环节回退 → ${cfg.registryStage?.targetStage || cfg.targetStage || 'compile'}`;
}
const lbl = cfg.targetTableLabel || cfg.targetTable || '目标表';
if (cfg.visualType === 'STATUS_MODIFY') {
return `修改【${lbl}】.${cfg.statusConfig?.targetField || '?'} → '${cfg.statusConfig?.newValue || '?'}',关联:${cfg.targetTable}.${cfg.linkCondition?.targetField} = ${planForm.value.sourceTable}.${cfg.linkCondition?.sourceField}`;
}
if (cfg.visualType === 'DATA_SYNC') {
return `数据带入【${lbl}】,${cfg.fieldMappings?.length || 0} 个字段映射,关联:${cfg.targetTable}.${cfg.linkCondition?.targetField} = ${planForm.value.sourceTable}.${cfg.linkCondition?.sourceField}`;
}
} catch {/**/ }
return '';
}
function openActionEditor(action?: any, idx?: number) {
editingIdx.value = idx ?? -1;
actionEditorRef.value?.open({
sourceTable: planForm.value.sourceTable,
sourceColumns: sourceColumns.value,
bizDocList: bizDocList.value,
sourceRegistry: selectedRegistry.value,
action,
execOrder: idx === undefined ? actions.value.length + 1 : action?.execOrder,
});
}
function handleActionSaved(actionData: any) {
if (editingIdx.value >= 0) {
actions.value[editingIdx.value] = actionData;
} else {
actions.value.push(actionData);
}
}
async function handleSave(publish: boolean) {
try {
await planFormRef.value?.validate();
} catch {
return;
}
if (!planForm.value.sourceTable) {
createMessage.warning('请在左侧选择触发业务表');
return;
}
if (!enabledStageOptions.value.length) {
createMessage.warning('该单据未在审批注册中心配置启用环节');
return;
}
if (planForm.value.triggerPhase === 'onNodeApprove' && !planForm.value.triggerStage) {
createMessage.warning('节点通过时必须选择绑定环节');
return;
}
if (!actions.value.length) {
createMessage.warning('请至少添加一个集成动作');
return;
}
saving.value = true;
try {
// 1. 保存方案,返回含 id 的实体
const savedPlan = await savePlan({ ...planForm.value });
const planId = (savedPlan as any)?.id;
if (!planId) {
createMessage.error('保存方案失败无法获取方案ID请重试');
return;
}
// 2. 逐个保存动作enabled 须转 0/1后端字段为 Integer
for (let i = 0; i < actions.value.length; i++) {
const a = actions.value[i];
await saveAction({ ...a, planId, execOrder: i + 1, enabled: a.enabled ? 1 : 0 });
}
// 3. 发布(可选)
if (publish) {
await publishPlan(planId);
}
createMessage.success(publish ? '方案已保存并发布' : '方案已保存为草稿');
emit('success');
visible.value = false;
} catch (e: any) {
createMessage.error(e?.message || '保存失败,请检查配置后重试');
} finally {
saving.value = false;
}
}
function open() {
planForm.value = {
planCode: '',
planName: '',
sourceTable: '',
registryId: '',
triggerPhase: 'onApprove',
triggerStage: 'approve',
execMode: 'async',
remark: '',
};
actions.value = [];
sourceColumns.value = [];
selectedRegistry.value = null;
visible.value = true;
loadBizDocs();
}
defineExpose({ open });
</script>

View File

@@ -0,0 +1,332 @@
<template>
<a-modal
v-model:open="visible"
title="按审批流程生成默认方案"
width="900px"
:confirm-loading="generating"
ok-text="确认生成"
cancel-text="取消"
@ok="handleGenerate"
@cancel="visible = false"
>
<a-form :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }" style="margin-top: 12px">
<a-form-item label="业务单据" required>
<a-select
v-model:value="form.sourceTable"
placeholder="选择审批注册中心已启用的业务表"
show-search
:filter-option="filterDoc"
:options="docOptions"
@change="onTableChange"
/>
</a-form-item>
<a-form-item label="审批流程" required>
<a-select
v-model:value="form.flowId"
placeholder="选择该业务表对应的审批流"
:loading="loadingFlows"
:options="flowOptions"
@change="loadPreview"
/>
</a-form-item>
<a-form-item label="覆盖草稿">
<a-checkbox v-model:checked="form.overwriteDraft">
删除同前缀的草稿方案后重新生成已发布方案不受影响
</a-checkbox>
</a-form-item>
</a-form>
<a-spin :spinning="loadingPreview">
<template v-if="preview">
<a-alert
type="info"
show-icon
style="margin-bottom: 12px"
:message="`流程节点 ${preview.flowNodeCount || 0} 个;已配置环节 ${preview.configuredNodeCount || 0} 个;未配置 ${preview.unconfiguredNodeCount || 0} 个`"
:description="`状态字典:${preview.statusDictCode || '-'};驳回回退至:${preview.initialStatusLabel}${preview.initialStatus}`"
/>
<a-alert
v-if="(preview.unconfiguredNodeCount || 0) > 0"
type="warning"
show-icon
style="margin-bottom: 12px"
message="存在未配置或未选择环节的流程节点,这些节点不会生成集成方案。"
/>
<a-table
size="small"
:pagination="false"
:data-source="preview.nodeBindings || []"
:columns="previewColumns"
row-key="nodeId"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'nodeName'">
<span>{{ record.nodeName }}</span>
<a-tag :color="record.stageConfigured ? 'success' : 'warning'" style="margin-left: 6px">
{{ record.configuredText }}
</a-tag>
</template>
<template v-else-if="column.dataIndex === 'stage'">
<a-select
v-model:value="record.stage"
allow-clear
placeholder="可不选"
style="width: 110px"
:options="stageOptions"
@change="() => onStageChange(record)"
/>
</template>
<template v-else-if="column.dataIndex === 'willGenerate'">
<a-tag v-if="record.willGenerate" color="processing">将生成</a-tag>
<span v-else style="color: #bbb">-</span>
</template>
<template v-else-if="column.dataIndex === 'unconfiguredReason'">
<span style="color: #fa8c16; font-size: 12px">{{ record.unconfiguredReason || '-' }}</span>
</template>
</template>
</a-table>
<div style="margin-top: 8px; font-size: 12px; color: #888">
将生成 {{ planCount }} 个方案 1 个驳回回退方案默认状态为草稿生成后请核对并发布
</div>
</template>
<a-empty v-else-if="!loadingPreview" description="请选择业务表与审批流程后预览" />
</a-spin>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { listBizDocRegistry, previewDefaultFromFlow, generateDefaultFromFlow } from '../MesXslIntegrationPlan.api';
import { getApprovalFlowList } from '/@/views/approval/flow/approvalFlow.api';
const emit = defineEmits<{ success: [] }>();
const { createMessage } = useMessage();
const visible = ref(false);
const generating = ref(false);
const loadingFlows = ref(false);
const loadingPreview = ref(false);
const preview = ref<any>(null);
const docList = ref<any[]>([]);
const flowList = ref<any[]>([]);
const form = ref({
sourceTable: undefined as string | undefined,
flowId: undefined as string | undefined,
overwriteDraft: true,
});
const previewColumns = [
{ title: '序号', dataIndex: 'nodeIndex', width: 52 },
{ title: '流程节点', dataIndex: 'nodeName', width: 200 },
{ title: '识别环节', dataIndex: 'stage', width: 130 },
{ title: '前置状态', dataIndex: 'expectedFromLabel', width: 90 },
{ title: '生成方案', dataIndex: 'willGenerate', width: 88 },
{ title: '未配置原因', dataIndex: 'unconfiguredReason', ellipsis: true },
];
const stageOptions = computed(() =>
(preview.value?.stageOptions || []).map((opt: any) => ({
value: opt.value,
label: opt.label,
})),
);
const planCount = computed(() => {
const bindings = preview.value?.nodeBindings || [];
const configured = bindings.filter((b: any) => b.willGenerate).length;
return configured > 0 ? configured + 1 : 0;
});
const docOptions = computed(() =>
docList.value
.filter((d) => d.enabled === 1)
.map((d) => ({
value: d.tableName,
label: `${d.displayName || d.tableName}${d.tableName}`,
})),
);
const flowOptions = computed(() =>
flowList.value.map((f) => ({
value: f.id,
label: `${f.flowName || f.id}${f.status === '1' ? ' [已发布]' : ''}`,
})),
);
function filterDoc(input: string, option: any) {
return (option?.label || '').toLowerCase().includes(input.toLowerCase());
}
function labelOfStatusChain(chain: any[], value?: string) {
if (!value) return '-';
const hit = (chain || []).find((item) => item.value === value);
return hit?.label || value;
}
function buildUnconfiguredReason(stageMeta: Record<string, any>, stage?: string) {
if (!stage) {
return '未选择审批环节';
}
const meta = stageMeta?.[stage];
if (!meta) {
return '未选择审批环节';
}
if (!meta.enabled) {
return `环节「${meta.label || stage}」未在注册中心启用`;
}
if (!meta.configured) {
return `环节「${meta.label || stage}」未配置操作人字段`;
}
return '环节未完整配置';
}
function resolveExpectedFrom(bindings: any[], index: number, statusChain: any[], initialStatus: string) {
const current = bindings[index];
if (!current?.stage) {
return initialStatus;
}
const stageIdx = (statusChain || []).findIndex((item) => item.value === current.stage);
if (stageIdx > 0) {
return statusChain[stageIdx - 1].value;
}
for (let j = index - 1; j >= 0; j--) {
const prev = bindings[j];
if (prev.stageConfigured && prev.stage) {
return prev.stage;
}
}
return initialStatus;
}
function recalcBindings() {
if (!preview.value?.nodeBindings) {
return;
}
const bindings = preview.value.nodeBindings;
const stageMeta = preview.value.stageMeta || {};
const statusChain = preview.value.statusChain || [];
const initialStatus = preview.value.initialStatus;
bindings.forEach((record: any) => {
const stage = record.stage || undefined;
const meta = stage ? stageMeta[stage] : null;
const configured = !!(stage && meta?.enabled && meta?.configured);
record.stageConfigured = configured;
record.configuredText = configured ? '已配置该环节' : '未配置该环节';
record.stageLabel = stage ? labelOfStatusChain(statusChain, stage) : '-';
record.unconfiguredReason = configured ? undefined : buildUnconfiguredReason(stageMeta, stage);
});
const configuredBindings = bindings.filter((b: any) => b.stageConfigured);
bindings.forEach((record: any) => {
if (!record.stageConfigured) {
record.willGenerate = false;
record.triggerPhase = null;
record.expectedFrom = null;
record.expectedFromLabel = '-';
return;
}
const cfgIdx = configuredBindings.indexOf(record);
record.willGenerate = cfgIdx >= 0;
record.triggerPhase = 'onNodeApprove';
const expectedFrom = resolveExpectedFrom(bindings, bindings.indexOf(record), statusChain, initialStatus);
record.expectedFrom = expectedFrom;
record.expectedFromLabel = labelOfStatusChain(statusChain, expectedFrom);
});
preview.value.configuredNodeCount = configuredBindings.length;
preview.value.unconfiguredNodeCount = bindings.length - configuredBindings.length;
}
function onStageChange(record: any) {
if (record.stage === undefined || record.stage === null || record.stage === '') {
record.stage = undefined;
}
recalcBindings();
}
async function open() {
visible.value = true;
preview.value = null;
form.value = { sourceTable: undefined, flowId: undefined, overwriteDraft: true };
const res = await listBizDocRegistry();
docList.value = res?.records || res || [];
}
async function onTableChange(table: string) {
form.value.flowId = undefined;
preview.value = null;
flowList.value = [];
if (!table) return;
loadingFlows.value = true;
try {
const res = await getApprovalFlowList({ bizTable: table, pageNo: 1, pageSize: 50 });
flowList.value = res?.records || [];
if (flowList.value.length === 1) {
form.value.flowId = flowList.value[0].id;
await loadPreview();
}
} finally {
loadingFlows.value = false;
}
}
async function loadPreview() {
if (!form.value.sourceTable || !form.value.flowId) {
preview.value = null;
return;
}
loadingPreview.value = true;
try {
preview.value = await previewDefaultFromFlow({
sourceTable: form.value.sourceTable,
flowId: form.value.flowId,
});
recalcBindings();
} catch (e: any) {
preview.value = null;
createMessage.error(e?.message || '预览失败');
} finally {
loadingPreview.value = false;
}
}
function buildNodeBindingsPayload() {
return (preview.value?.nodeBindings || []).map((item: any) => ({
nodeId: item.nodeId,
stage: item.stage || null,
}));
}
async function handleGenerate() {
if (!form.value.sourceTable || !form.value.flowId) {
createMessage.warning('请选择业务表和审批流程');
return;
}
recalcBindings();
if ((preview.value?.configuredNodeCount || 0) === 0) {
createMessage.warning('没有已配置环节的流程节点,无法生成方案');
return;
}
generating.value = true;
try {
const res = await generateDefaultFromFlow({
sourceTable: form.value.sourceTable,
flowId: form.value.flowId,
overwriteDraft: form.value.overwriteDraft,
nodeBindings: buildNodeBindingsPayload(),
});
createMessage.success(res?.message || '生成成功');
visible.value = false;
emit('success');
} catch (e: any) {
createMessage.error(e?.message || '生成失败');
} finally {
generating.value = false;
}
}
defineExpose({ open });
</script>

View File

@@ -0,0 +1,49 @@
<template>
<BasicDrawer @register="registerDrawer" :title="drawerTitle" width="960" destroyOnClose>
<BasicTable @register="registerTable" />
</BasicDrawer>
</template>
<script lang="ts" setup>
import { ref, computed } from 'vue';
import { BasicDrawer, useDrawerInner } from '/@/components/Drawer';
import { BasicTable, useTable } from '/@/components/Table';
import { drawerColumns, drawerSearchFormSchema } from '../MesXslApprovalTrace.data';
import { list } from '../MesXslApprovalTrace.api';
const registryRecord = ref<Recordable>({});
const drawerTitle = computed(() => {
const record = registryRecord.value;
const name = record.displayName || record.docCode || record.tableName || '';
return name ? `审批明细 — ${name}` : '审批明细';
});
const [registerTable, { reload, setProps }] = useTable({
title: '审批痕迹明细',
api: list,
columns: drawerColumns,
canResize: true,
useSearchForm: true,
formConfig: {
schemas: drawerSearchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: false,
},
showTableSetting: true,
bordered: true,
showIndexColumn: true,
immediate: false,
actionColumn: { width: 0, ifShow: false },
});
const [registerDrawer, { setDrawerProps }] = useDrawerInner(async (data) => {
registryRecord.value = data?.record || {};
const tableName = registryRecord.value.tableName;
setProps({
searchInfo: tableName ? { bizTable: tableName } : {},
});
setDrawerProps({ confirmLoading: false });
await reload();
});
</script>

View File

@@ -0,0 +1,69 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" destroyOnClose :title="title" :width="640" @ok="handleSubmit">
<BasicForm @register="registerForm" />
</BasicModal>
</template>
<script lang="ts" setup>
import { ref, computed, unref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form/index';
import { formSchema } from '../MesXslBizDocRegistry.data';
import { saveOrUpdate } from '../MesXslBizDocRegistry.api';
const emit = defineEmits(['register', 'success']);
const isUpdate = ref(false);
const [registerForm, { resetFields, setFieldsValue, validate }] = useForm({
labelWidth: 100,
schemas: formSchema,
showActionButtonGroup: false,
baseColProps: { span: 24 },
});
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
await resetFields();
setModalProps({ confirmLoading: false });
isUpdate.value = !!data?.isUpdate;
if (unref(isUpdate) && data.record) {
const record = { ...data.record };
// 启用开关:后端为 0/1 整数Switch 需 checkedValue/unCheckedValue 对齐
if (record.enabled === true || record.enabled === '1') {
record.enabled = 1;
} else if (record.enabled === false || record.enabled === '0') {
record.enabled = 0;
}
if (typeof record.enabledStages === 'string') {
record.enabledStages = record.enabledStages.split(',').filter(Boolean);
} else if (!Array.isArray(record.enabledStages)) {
record.enabledStages = [];
}
await setFieldsValue(record);
}
});
const title = computed(() => (unref(isUpdate) ? '编辑审批注册' : '新增审批注册'));
async function handleSubmit() {
try {
const values = await validate();
// 空数组须显式传空串,后端才能清空 enabled_stages
if (Array.isArray(values.enabledStages)) {
values.enabledStages = values.enabledStages.join(',');
} else if (values.enabledStages == null) {
values.enabledStages = '';
}
if (typeof values.enabled === 'boolean') {
values.enabled = values.enabled ? 1 : 0;
} else if (values.enabled !== 0 && values.enabled !== 1) {
values.enabled = values.enabled ? 1 : 0;
}
setModalProps({ confirmLoading: true });
await saveOrUpdate(values, unref(isUpdate));
closeModal();
emit('success');
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>

View File

@@ -0,0 +1,195 @@
<template>
<a-drawer v-model:open="visible" :title="`动作管理 — ${planName}`" width="900" destroy-on-close @close="visible = false">
<div style="margin-bottom: 12px">
<a-button type="primary" size="small" @click="handleAddAction">
<template #icon><PlusOutlined /></template>
添加动作
</a-button>
</div>
<a-table :dataSource="actions" :columns="tableColumns" :loading="loading" :pagination="false" row-key="id" size="small" bordered>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'enabled'">
<a-tag :color="record.enabled ? 'green' : 'default'">{{ record.enabled ? '启用' : '停用' }}</a-tag>
</template>
<template v-if="column.dataIndex === 'actionType'">
<a-tag color="blue">{{ actionTypeLabel(record.actionType) }}</a-tag>
</template>
<template v-if="column.dataIndex === 'onFail'">
<a-tag :color="record.onFail === 'stop' ? 'orange' : 'default'">{{ record.onFail === 'stop' ? '终止' : '继续' }}</a-tag>
</template>
<template v-if="column.dataIndex === 'summary'">
<span style="font-size: 12px; color: #666">{{ formatActionSummary(record) }}</span>
</template>
<template v-if="column.key === 'operation'">
<a-space>
<a-button size="small" @click="handleEditAction(record)">编辑</a-button>
<a-popconfirm title="确认删除该动作?" @confirm="handleDeleteAction(record)">
<a-button size="small" danger>删除</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
<!-- update-begin---author:GHT ---date:2026-06-05 forXSLMES-20260605-K8R2动作管理复用可视化编辑器,与向导添加动作一致 -->
<VisualActionEditor ref="visualEditorRef" @success="handleActionSaved" />
<!-- update-end---author:GHT ---date:2026-06-05 forXSLMES-20260605-K8R2动作管理复用可视化编辑器 -->
</a-drawer>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
import { PlusOutlined } from '@ant-design/icons-vue';
import { useMessage } from '/@/hooks/web/useMessage';
import {
listActions,
saveAction,
editAction,
deleteAction,
getTableColumns,
listBizDocRegistry,
getRegistryByTable,
} from '../MesXslIntegrationPlan.api';
import VisualActionEditor from './VisualActionEditor.vue';
const STAGE_LABELS: Record<string, string> = { proofread: '校对', audit: '审核', approve: '批准' };
const ACTION_TYPE_LABELS: Record<string, string> = {
REGISTRY_STAGE_SYNC: '审批环节同步',
REGISTRY_STAGE_REVERT: '审批环节回退',
SQL_UPDATE: 'SQL更新',
};
const { createMessage } = useMessage();
const visible = ref(false);
const loading = ref(false);
const planId = ref('');
const planName = ref('');
const planRecord = ref<Recordable>({});
const actions = ref<any[]>([]);
const visualEditorRef = ref();
const bizDocList = ref<any[]>([]);
const sourceColumns = ref<any[]>([]);
const sourceRegistry = ref<any>(null);
const tableColumns = [
{ title: '顺序', dataIndex: 'execOrder', width: 60, align: 'center' },
{ title: '动作名称', dataIndex: 'actionName', width: 140 },
{ title: '类型', dataIndex: 'actionType', width: 120 },
{ title: '配置摘要', dataIndex: 'summary', ellipsis: true },
{ title: '失败策略', dataIndex: 'onFail', width: 90 },
{ title: '启用', dataIndex: 'enabled', width: 70 },
{ title: '操作', key: 'operation', width: 130, align: 'center' },
];
function actionTypeLabel(type: string) {
return ACTION_TYPE_LABELS[type] || type || '-';
}
function formatActionSummary(record: Recordable) {
if (record.actionType === 'REGISTRY_STAGE_SYNC' || record.actionType === 'REGISTRY_STAGE_REVERT') {
try {
const cfg = JSON.parse(record.actionConfig || '{}');
if (record.actionType === 'REGISTRY_STAGE_SYNC') {
const stage = cfg.registryStage?.stage || cfg.stage;
const from = cfg.registryStage?.expectedFrom || cfg.expectedFrom;
return `环节→${STAGE_LABELS[stage] || stage || '?'}${from ? `,前置=${from}` : ''}`;
}
const target = cfg.registryStage?.targetStage || cfg.targetStage || 'compile';
return `回退→${target}`;
} catch {
return record.actionConfig || '-';
}
}
return record.sqlTemplate || '-';
}
async function loadEditorContext() {
const sourceTable = planRecord.value.sourceTable;
if (!sourceTable) return;
try {
const [cols, registryRes, docsRes] = await Promise.all([
getTableColumns(sourceTable),
getRegistryByTable(sourceTable),
listBizDocRegistry(),
]);
sourceColumns.value = (cols as any) || [];
sourceRegistry.value = registryRes || null;
bizDocList.value = (docsRes as any)?.records || (Array.isArray(docsRes) ? docsRes : []);
} catch {
sourceColumns.value = [];
sourceRegistry.value = null;
bizDocList.value = [];
}
}
async function open(plan: Recordable) {
planId.value = plan.id;
planName.value = plan.planName;
planRecord.value = plan;
visible.value = true;
await Promise.all([loadActions(), loadEditorContext()]);
}
async function loadActions() {
loading.value = true;
try {
actions.value = (await listActions(planId.value)) || [];
} finally {
loading.value = false;
}
}
function openVisualEditor(action?: Recordable) {
if (!planRecord.value.sourceTable) {
createMessage.warning('方案未配置触发业务表');
return;
}
visualEditorRef.value?.open({
sourceTable: planRecord.value.sourceTable,
sourceColumns: sourceColumns.value,
bizDocList: bizDocList.value,
sourceRegistry: sourceRegistry.value,
action: action
? { ...action, planId: planId.value }
: { planId: planId.value, execOrder: actions.value.length + 1 },
execOrder: action?.execOrder ?? actions.value.length + 1,
});
}
function handleAddAction() {
openVisualEditor();
}
function handleEditAction(record: Recordable) {
openVisualEditor(record);
}
async function handleDeleteAction(record: Recordable) {
await deleteAction({ id: record.id }, loadActions);
}
async function handleActionSaved(actionData: Recordable) {
const payload = {
...actionData,
planId: planId.value,
enabled: actionData.enabled !== false && actionData.enabled !== 0 ? 1 : 0,
};
try {
if (actionData.id) {
await editAction(payload);
} else {
await saveAction(payload);
}
createMessage.success('保存成功');
await loadActions();
} catch (e: any) {
createMessage.error(e?.message || '保存失败');
}
}
defineExpose({ open });
</script>

View File

@@ -0,0 +1,46 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" destroyOnClose :title="title" :width="720" @ok="handleSubmit">
<BasicForm @register="registerForm" />
</BasicModal>
</template>
<script lang="ts" setup>
import { ref, computed, unref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form/index';
import { formSchema } from '../MesXslIntegrationPlan.data';
import { saveOrUpdate } from '../MesXslIntegrationPlan.api';
const emit = defineEmits(['register', 'success']);
const isUpdate = ref(false);
const [registerForm, { resetFields, setFieldsValue, validate }] = useForm({
labelWidth: 100,
schemas: formSchema,
showActionButtonGroup: false,
baseColProps: { span: 24 },
});
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
await resetFields();
setModalProps({ confirmLoading: false });
isUpdate.value = !!data?.isUpdate;
if (unref(isUpdate) && data.record) {
await setFieldsValue({ ...data.record });
}
});
const title = computed(() => (unref(isUpdate) ? '编辑集成方案' : '新增集成方案'));
async function handleSubmit() {
try {
const values = await validate();
setModalProps({ confirmLoading: true });
await saveOrUpdate(values, unref(isUpdate));
closeModal();
emit('success');
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>

View File

@@ -0,0 +1,753 @@
<template>
<a-modal
v-model:open="visible"
:title="isUpdate ? '编辑动作' : '添加动作'"
width="860px"
:confirm-loading="saving"
ok-text="确认"
cancel-text="取消"
@ok="handleConfirm"
@cancel="visible = false"
>
<a-form ref="formRef" :model="form" :label-col="{ span: 5 }" :wrapper-col="{ span: 18 }" style="margin-top: 12px">
<a-row :gutter="16">
<a-col :span="15">
<a-form-item label="动作名称" name="actionName" :rules="[{ required: true, message: '请输入动作名称' }]">
<a-input v-model:value="form.actionName" placeholder="如 配合示方→认定通过" />
</a-form-item>
</a-col>
<a-col :span="9">
<a-form-item label="执行顺序" :label-col="{ span: 7 }" :wrapper-col="{ span: 17 }">
<a-input-number v-model:value="form.execOrder" :min="0" style="width: 90px" />
</a-form-item>
</a-col>
</a-row>
<!-- 动作类型卡片 -->
<div style="margin-bottom: 20px">
<div style="font-weight: 500; margin-bottom: 10px; color: rgba(0, 0, 0, 0.85)">动作类型</div>
<div style="display: flex; gap: 12px">
<div
v-for="t in ACTION_TYPES"
:key="t.value"
:style="{
flex: '1', border: '2px solid',
borderColor: vc.visualType === t.value ? '#1677ff' : t.disabled ? '#f0f0f0' : '#d9d9d9',
borderRadius: '8px', padding: '14px 12px', cursor: t.disabled ? 'not-allowed' : 'pointer',
background: vc.visualType === t.value ? '#e6f4ff' : t.disabled ? '#fafafa' : 'white',
opacity: t.disabled ? 0.5 : 1, transition: 'all 0.2s', userSelect: 'none',
}"
@click="!t.disabled && selectVisualType(t.value)"
>
<div style="font-size: 22px; margin-bottom: 6px">{{ t.icon }}</div>
<div style="font-weight: 600; margin-bottom: 4px; color: rgba(0, 0, 0, 0.85)">{{ t.label }}</div>
<div style="font-size: 12px; color: #888; line-height: 1.4">{{ t.desc }}</div>
</div>
</div>
</div>
<!-- 审批注册中心环节同步无需选目标表无需写 SQL -->
<template v-if="vc.visualType === 'REGISTRY_STAGE_SYNC' || vc.visualType === 'REGISTRY_STAGE_REVERT'">
<a-alert
type="info"
show-icon
style="margin-bottom: 14px"
message="按审批注册中心配置自动更新源单状态、操作人/时间,并双写审批痕迹明细,无需绑定 Java 校对/审核/批准接口。"
/>
<template v-if="vc.registryStage">
<a-form-item v-if="vc.visualType === 'REGISTRY_STAGE_SYNC'" label="审批环节" required>
<a-select
v-model:value="vc.registryStage.stage"
:options="registryStageOptions"
placeholder="从注册中心启用环节选择"
show-search
option-filter-prop="label"
style="width: 100%"
/>
<div v-if="!registryStageOptions.length" style="font-size: 12px; color: #faad14; margin-top: 4px">
未配置启用环节请先在审批注册中心配置 enabled_stages
</div>
</a-form-item>
<a-form-item v-if="vc.visualType === 'REGISTRY_STAGE_SYNC'" label="前置状态">
<a-select
v-if="sourceStatusDictItems.length"
v-model:value="vc.registryStage.expectedFrom"
:options="sourceStatusDictItems"
placeholder="留空则按环节自动推断"
allow-clear
show-search
option-filter-prop="label"
style="width: 100%"
/>
<a-input
v-else
v-model:value="vc.registryStage.expectedFrom"
placeholder="未解析到状态字典,可手填 compile / proofread / audit"
/>
<div style="font-size: 12px; color: #888; margin-top: 4px">
取自触发表{{ sourceStatusFieldName }}字段字典{{ sourceStatusDictCode ? `${sourceStatusDictCode}` : '' }}留空则自动推断仅当前状态等于此前置值时才执行
</div>
</a-form-item>
<a-form-item v-if="vc.visualType === 'REGISTRY_STAGE_REVERT'" label="回退目标">
<a-select
v-if="sourceStatusDictItems.length"
v-model:value="vc.registryStage.targetStage"
:options="sourceStatusDictItems"
placeholder="默认 compile编制态"
allow-clear
show-search
option-filter-prop="label"
style="width: 100%"
/>
<a-input v-else v-model:value="vc.registryStage.targetStage" placeholder="默认 compile编制态" />
</a-form-item>
</template>
</template>
<!-- 操作目标表 -->
<a-form-item v-if="vc.visualType === 'STATUS_MODIFY' || vc.visualType === 'DATA_SYNC'" label="操作目标表">
<div style="display: flex; gap: 8px; align-items: center">
<a-select
v-model:value="vc.targetTable"
placeholder="选择要被操作的业务表"
show-search
:filter-option="filterBizDoc"
@change="onTargetTableChange"
style="flex: 1"
>
<a-select-option v-for="doc in bizDocList" :key="doc.tableName" :value="doc.tableName">
<span>{{ getDocLabel(doc) }}</span>
<span style="color: #bbb; margin-left: 8px; font-size: 12px">{{ doc.tableName }}</span>
</a-select-option>
</a-select>
<a-spin v-if="loadingTargetCols" size="small" />
<span v-else-if="targetColumns.length" style="color: #888; font-size: 12px; white-space: nowrap">
{{ targetColumns.length }} 个字段
</span>
</div>
</a-form-item>
<template v-if="vc.targetTable">
<!-- 关联条件 -->
<div style="background: #f9f9f9; border: 1px solid #e8e8e8; border-radius: 6px; padding: 14px; margin-bottom: 16px">
<div style="font-weight: 500; margin-bottom: 10px; font-size: 13px">
关联条件
<span style="font-weight: 400; color: #888; font-size: 12px; margin-left: 6px">通过哪个字段定位目标表记录</span>
</div>
<div style="display: flex; align-items: flex-end; gap: 12px">
<div style="flex: 1">
<div style="font-size: 12px; color: #888; margin-bottom: 4px">触发表字段{{ sourceTable }}</div>
<a-select v-model:value="vc.linkCondition.sourceField" :options="sourceFieldOpts" placeholder="触发表关联字段" show-search style="width: 100%" />
</div>
<div style="font-size: 15px; color: #1677ff; padding-bottom: 6px; flex-shrink: 0; font-weight: 500"> 等于 </div>
<div style="flex: 1">
<div style="font-size: 12px; color: #888; margin-bottom: 4px">目标表字段{{ vc.targetTable }}</div>
<a-select v-model:value="vc.linkCondition.targetField" :options="targetFieldOpts" placeholder="目标表关联字段" show-search style="width: 100%" />
</div>
</div>
</div>
<!-- ============ 状态修改全新设计 ============ -->
<template v-if="vc.visualType === 'STATUS_MODIFY'">
<!-- 触发表节点识别可选 -->
<div style="background: #fffbe6; border: 1px solid #ffe58f; border-radius: 6px; padding: 12px 14px; margin-bottom: 14px">
<div style="font-size: 12px; font-weight: 600; color: #d48806; margin-bottom: 8px">
🎯 触发表节点识别可选
<span style="font-weight: 400; color: #888; margin-left: 6px">仅在 onNodeApprove 时需要用于区分哪个节点触发了此动作</span>
</div>
<div style="display: flex; align-items: flex-end; gap: 8px">
<span style="font-size: 12px; color: #888; padding-bottom: 5px; white-space: nowrap"> {{ sourceTable }} </span>
<div style="flex: 1">
<a-select
v-model:value="vc.statusConfig!.srcConditionField"
:options="sourceFieldOpts"
placeholder="字段名,如 status"
allow-clear
show-search
style="width: 100%"
@change="onSrcConditionFieldChange"
/>
</div>
<span style="font-size: 14px; color: #d48806; font-weight: 600; padding-bottom: 5px"></span>
<div style="flex: 1">
<a-select
v-if="srcConditionDictCode && vc.statusConfig!.srcConditionField"
v-model:value="vc.statusConfig!.srcConditionValue"
:options="srcConditionDictItems"
placeholder="选择状态值"
allow-clear
show-search
style="width: 100%"
/>
<a-input
v-else
v-model:value="vc.statusConfig!.srcConditionValue"
:placeholder="vc.statusConfig!.srcConditionField ? '如 compile' : '选择左侧字段后填写'"
:disabled="!vc.statusConfig!.srcConditionField"
/>
</div>
</div>
</div>
<!-- 目标表状态变更字段选择 + fromto 箭头 -->
<div style="font-weight: 500; margin-bottom: 10px; font-size: 13px">
目标表状态变更
</div>
<a-form-item label="修改字段" :label-col="{ span: 5 }" :wrapper-col="{ span: 14 }">
<a-select
v-model:value="vc.statusConfig!.targetField"
:options="targetFieldOpts"
placeholder="选择要更新的字段(如 status"
show-search
@change="onTargetStatusFieldChange"
/>
</a-form-item>
<!-- from to 箭头区 -->
<div v-if="vc.statusConfig!.targetField"
style="display: flex; align-items: stretch; gap: 0; background: #f5f5f5; border: 1px solid #e0e0e0; border-radius: 8px; overflow: hidden; margin-bottom: 12px; margin-left: 20%">
<!-- 前置状态 -->
<div style="flex: 1; padding: 14px 16px; text-align: center">
<div style="font-size: 11px; color: #888; margin-bottom: 6px; line-height: 1.4">
前置状态可选<br>
<span style="color: #aaa">仅当字段当前值为此值时才执行</span>
</div>
<a-select
v-if="targetStatusDictCode"
v-model:value="vc.statusConfig!.fromValue"
:options="[{ value: '', label: '不限(留空)' }, ...targetStatusDictItems]"
placeholder="不限(留空)"
allow-clear
show-search
style="width: 100%"
/>
<a-input
v-else
v-model:value="vc.statusConfig!.fromValue"
placeholder="不限(留空则不检查)"
allow-clear
/>
</div>
<!-- 箭头分隔 -->
<div style="display: flex; align-items: center; padding: 0 10px; background: #e8e8e8; flex-shrink: 0">
<div style="text-align: center; color: #1677ff">
<div style="font-size: 22px; line-height: 1"></div>
<div style="font-size: 10px; color: #888; margin-top: 2px">改为</div>
</div>
</div>
<!-- 新状态 -->
<div style="flex: 1; padding: 14px 16px; text-align: center; background: #e6f4ff">
<div style="font-size: 11px; color: #1677ff; margin-bottom: 6px; line-height: 1.4">
新状态 *<br>
<span style="color: #888">执行后字段将被改为此值</span>
</div>
<a-select
v-if="targetStatusDictCode"
v-model:value="vc.statusConfig!.newValue"
:options="targetStatusDictItems"
placeholder="请选择新状态"
show-search
style="width: 100%"
/>
<a-input
v-else
v-model:value="vc.statusConfig!.newValue"
placeholder="如 approve"
/>
</div>
</div>
<div v-if="vc.statusConfig!.targetField" style="margin-left: 20%; margin-bottom: 12px">
<a-checkbox v-model:checked="vc.statusConfig!.addUpdateTime">同时更新 update_time = NOW()</a-checkbox>
</div>
</template>
<!-- ============ 数据带入 ============ -->
<template v-if="vc.visualType === 'DATA_SYNC'">
<div style="font-weight: 500; margin-bottom: 10px; font-size: 13px">
字段映射
<span style="font-weight: 400; color: #888; font-size: 12px; margin-left: 6px">目标字段 数据来源</span>
</div>
<div
v-for="(m, idx) in vc.fieldMappings"
:key="idx"
style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px"
>
<a-select v-model:value="m.targetField" :options="targetFieldOpts" placeholder="目标字段" show-search style="width: 160px" />
<span style="color: #1677ff; flex-shrink: 0; font-weight: 600"></span>
<a-select v-model:value="m.sourceType" style="width: 110px">
<a-select-option value="source_field">触发表字段</a-select-option>
<a-select-option value="constant">固定值</a-select-option>
<a-select-option value="expression">SQL表达式</a-select-option>
</a-select>
<a-select v-if="m.sourceType === 'source_field'" v-model:value="m.sourceValue" :options="sourceFieldOpts" placeholder="来源字段" show-search style="flex: 1" />
<a-input v-else v-model:value="m.sourceValue" :placeholder="getMappingPlaceholder(m.sourceType)" style="flex: 1" />
<a-button size="small" danger type="text" @click="vc.fieldMappings!.splice(idx, 1)">删除</a-button>
</div>
<a-button type="dashed" size="small" @click="addMappingRow" style="width: 100%; margin-top: 4px">
+ 添加字段映射
</a-button>
</template>
<!-- SQL 预览 -->
<div v-if="previewSql" style="margin-top: 16px; background: #f0f5ff; border: 1px solid #d6e4ff; padding: 12px; border-radius: 6px">
<div style="font-size: 12px; color: #1677ff; font-weight: 500; margin-bottom: 6px">生成的 SQL预览</div>
<pre style="margin: 0; font-size: 12px; font-family: monospace; white-space: pre-wrap; word-break: break-all; color: #333; line-height: 1.6">{{ previewSql }}</pre>
</div>
</template>
<a-form-item label="失败策略" style="margin-top: 16px">
<a-radio-group v-model:value="form.onFail">
<a-radio value="stop">失败后终止后续动作</a-radio>
<a-radio value="continue">失败后继续执行</a-radio>
</a-radio-group>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, computed, watch } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { getTableColumns, getDictItems } from '../MesXslIntegrationPlan.api';
interface ColMeta { columnName: string; comment: string; dataType: string; columnKey: string; }
interface FieldMapping { targetField: string; sourceType: 'source_field' | 'constant' | 'expression'; sourceValue: string; }
interface StatusConfig {
targetField: string;
fromValue: string;
newValue: string;
addUpdateTime: boolean;
srcConditionField: string;
srcConditionValue: string;
}
interface RegistryStageConfig {
stage?: string;
expectedFrom?: string;
targetStage?: string;
}
interface VisualConfig {
visualType: 'STATUS_MODIFY' | 'DATA_SYNC' | 'REGISTRY_STAGE_SYNC' | 'REGISTRY_STAGE_REVERT';
targetTable: string;
targetTableLabel: string;
linkCondition: { sourceField: string; targetField: string };
statusConfig: StatusConfig;
fieldMappings: FieldMapping[];
registryStage?: RegistryStageConfig;
}
const emit = defineEmits<{ success: [action: any] }>();
const { createMessage } = useMessage();
/** 触发表 status 字段未带字典注释时的兜底映射 */
const SOURCE_TABLE_STATUS_DICT: Record<string, string> = {
mes_xsl_mixer_ps_compile: 'xslmes_mixer_ps_status',
mes_xsl_formula_spec: 'xslmes_formula_spec_status',
};
const ACTION_TYPES = [
{ value: 'REGISTRY_STAGE_SYNC', icon: '✅', label: '审批环节同步', desc: '按注册中心更新源单状态+操作人+痕迹', disabled: false },
{ value: 'REGISTRY_STAGE_REVERT', icon: '↩️', label: '审批环节回退', desc: '驳回时回退源单并清空痕迹', disabled: false },
{ value: 'STATUS_MODIFY', icon: '📋', label: '状态修改', desc: '手写SQL修改目标表状态', disabled: false },
{ value: 'DATA_SYNC', icon: '🔄', label: '数据带入', desc: '将触发表字段映射到目标表', disabled: false },
{ value: 'CREATE_DOC', icon: '', label: '下推生成单据', desc: '在目标表创建新记录Phase 1', disabled: true },
];
const visible = ref(false);
const saving = ref(false);
const isUpdate = ref(false);
const formRef = ref();
const loadingTargetCols = ref(false);
const sourceTable = ref('');
const sourceColumns = ref<ColMeta[]>([]);
const bizDocList = ref<any[]>([]);
const sourceRegistry = ref<any>(null);
/** 审批环节:注册中心 enabled_stages + 业务 status 字典标签(不写死校对/审核/批准) */
const registryStageOptions = computed(() => {
const raw = sourceRegistry.value?.enabledStages;
if (!raw) return [];
const dictLabelMap = Object.fromEntries(sourceStatusDictItems.value.map((i) => [i.value, i.label]));
return String(raw)
.split(',')
.map((s) => s.trim())
.filter(Boolean)
.map((v) => ({ value: v, label: dictLabelMap[v] || v }));
});
const targetColumns = ref<ColMeta[]>([]);
// 字典相关
const dictCache = ref<Record<string, any[]>>({});
const targetStatusDictCode = ref('');
const targetStatusDictItems = ref<any[]>([]);
const srcConditionDictCode = ref('');
const srcConditionDictItems = ref<any[]>([]);
const sourceStatusDictCode = ref('');
const sourceStatusDictItems = ref<any[]>([]);
const sourceStatusFieldName = computed(
() => sourceRegistry.value?.statusField || 'status',
);
const defaultStatusConfig = (): StatusConfig => ({
targetField: '', fromValue: '', newValue: '', addUpdateTime: true,
srcConditionField: '', srcConditionValue: '',
});
const defaultRegistryStage = (): RegistryStageConfig => ({
stage: '',
expectedFrom: '',
targetStage: '',
});
const defaultVc = (): VisualConfig => ({
visualType: 'REGISTRY_STAGE_SYNC',
targetTable: '', targetTableLabel: '',
linkCondition: { sourceField: '', targetField: '' },
statusConfig: defaultStatusConfig(),
fieldMappings: [],
registryStage: defaultRegistryStage(),
});
/** 兼容 Flyway 扁平格式stage/expectedFrom 在顶层与向导嵌套格式registryStage 对象) */
function normalizeParsedConfig(parsed: any, actionType?: string): VisualConfig {
const base = defaultVc();
if (!parsed || typeof parsed !== 'object') {
if (actionType === 'REGISTRY_STAGE_REVERT') {
base.visualType = 'REGISTRY_STAGE_REVERT';
} else if (actionType === 'REGISTRY_STAGE_SYNC') {
base.visualType = 'REGISTRY_STAGE_SYNC';
}
return base;
}
const visualType = (parsed.visualType || actionType || base.visualType) as VisualConfig['visualType'];
const merged: VisualConfig = {
...base,
...parsed,
visualType,
linkCondition: { ...base.linkCondition, ...(parsed.linkCondition || {}) },
statusConfig: { ...defaultStatusConfig(), ...(parsed.statusConfig || {}) },
fieldMappings: Array.isArray(parsed.fieldMappings) ? parsed.fieldMappings : [],
registryStage: {
...defaultRegistryStage(),
...(parsed.registryStage || {}),
},
};
// Flyway 预置:{"visualType":"REGISTRY_STAGE_SYNC","stage":"proofread","expectedFrom":"compile"}
if (parsed.stage) merged.registryStage!.stage = parsed.stage;
if (parsed.expectedFrom !== undefined && parsed.expectedFrom !== null) {
merged.registryStage!.expectedFrom = parsed.expectedFrom;
}
if (parsed.targetStage) merged.registryStage!.targetStage = parsed.targetStage;
return merged;
}
const defaultForm = () => ({ id: '', actionName: '', execOrder: 0, onFail: 'stop', enabled: true });
const form = ref(defaultForm());
const vc = ref(defaultVc());
const sourceFieldOpts = computed(() =>
sourceColumns.value.map((c) => ({
value: c.columnName,
label: c.comment ? `${c.columnName}${c.comment}` : c.columnName,
}))
);
const targetFieldOpts = computed(() =>
targetColumns.value.map((c) => ({
value: c.columnName,
label: c.comment ? `${c.columnName}${c.comment}` : c.columnName,
}))
);
const previewSql = computed(() => buildSql(vc.value));
// 监听目标状态字段变化 → 加载字典
watch(
() => vc.value.statusConfig?.targetField,
async (field) => {
if (!field) { targetStatusDictCode.value = ''; targetStatusDictItems.value = []; return; }
const dc = extractDictCode(targetColumns.value.find((c) => c.columnName === field)?.comment || '');
targetStatusDictCode.value = dc || '';
targetStatusDictItems.value = dc ? await loadDict(dc) : [];
}
);
// 审批环节变化时,前置状态留空则填入默认推断值
watch(
() => vc.value.registryStage?.stage,
(stage) => {
if (!vc.value.registryStage || !stage) return;
if (!vc.value.registryStage.expectedFrom) {
vc.value.registryStage.expectedFrom = defaultExpectedFromForStage(stage);
}
},
);
// 监听触发表节点字段变化 → 加载字典
watch(
() => vc.value.statusConfig?.srcConditionField,
async (field) => {
if (!field) { srcConditionDictCode.value = ''; srcConditionDictItems.value = []; return; }
const dc = extractDictCode(sourceColumns.value.find((c) => c.columnName === field)?.comment || '');
srcConditionDictCode.value = dc || '';
srcConditionDictItems.value = dc ? await loadDict(dc) : [];
}
);
function extractDictCode(comment: string): string | null {
if (!comment) return null;
// 匹配 "字典xslmes_xxx" 或 "字典:xslmes_xxx" 或 "字典 xslmes_xxx"
const m = comment.match(/字典[:\s]?([a-zA-Z][a-zA-Z0-9_]*)/);
return m ? m[1] : null;
}
async function loadDict(dictCode: string): Promise<any[]> {
if (dictCache.value[dictCode]) return dictCache.value[dictCode];
try {
const items = await getDictItems(dictCode);
const opts = ((items as any) || []).map((it: any) => ({
value: it.value,
label: it.title || it.text || it.value,
}));
dictCache.value[dictCode] = opts;
return opts;
} catch {
return [];
}
}
/** 从触发表 status 字段解析字典,供「前置状态」「回退目标」下拉 */
async function loadSourceStatusDict() {
const field = sourceStatusFieldName.value;
const col = sourceColumns.value.find((c) => c.columnName === field);
let dictCode = extractDictCode(col?.comment || '');
if (!dictCode && sourceTable.value) {
dictCode = SOURCE_TABLE_STATUS_DICT[sourceTable.value] || '';
}
sourceStatusDictCode.value = dictCode || '';
sourceStatusDictItems.value = dictCode ? await loadDict(dictCode) : [];
}
/** 按 status 字典顺序推断前置状态(字典第一项为驳回回退初始态) */
function defaultExpectedFromForStage(stage?: string): string {
if (!stage) return '';
const items = sourceStatusDictItems.value;
if (items.length) {
const idx = items.findIndex((i) => i.value === stage);
if (idx > 0) return items[idx - 1].value;
}
const opts = registryStageOptions.value;
const sidx = opts.findIndex((o) => o.value === stage);
if (sidx > 0) return opts[sidx - 1].value;
return items.length ? items[0].value : '';
}
function defaultRevertTargetStage(): string {
const items = sourceStatusDictItems.value;
if (!items.length) return 'compile';
const enabled = new Set(registryStageOptions.value.map((o) => o.value));
const firstStageIdx = items.findIndex((i) => enabled.has(i.value));
if (firstStageIdx > 0) return items[firstStageIdx - 1].value;
const nonStage = items.find((i) => !enabled.has(i.value));
return nonStage?.value || items[0].value;
}
function buildSql(config: VisualConfig): string {
const { targetTable, linkCondition, visualType, statusConfig, fieldMappings } = config;
if (!targetTable || !linkCondition.sourceField || !linkCondition.targetField) return '';
const baseWhere = `${linkCondition.targetField}=#{source.${linkCondition.sourceField}}`;
if (visualType === 'STATUS_MODIFY') {
const s = statusConfig;
if (!s.targetField || !s.newValue) return '';
const sets = [`${s.targetField}='${s.newValue}'`];
if (s.addUpdateTime) sets.push('update_time=NOW()');
const conditions = [baseWhere];
if (s.fromValue) conditions.push(`${s.targetField}='${s.fromValue}'`);
if (s.srcConditionField && s.srcConditionValue) {
conditions.push(`#{source.${s.srcConditionField}}='${s.srcConditionValue}'`);
}
return `UPDATE ${targetTable} SET ${sets.join(', ')} WHERE ${conditions.join(' AND ')}`;
}
if (visualType === 'DATA_SYNC') {
const filled = fieldMappings.filter((m) => m.targetField && m.sourceValue);
if (!filled.length) return '';
const sets = filled.map((m) => {
if (m.sourceType === 'source_field') return `${m.targetField}=#{source.${m.sourceValue}}`;
if (m.sourceType === 'expression') return `${m.targetField}=${m.sourceValue}`;
return `${m.targetField}='${m.sourceValue}'`;
});
sets.push('update_time=NOW()');
return `UPDATE ${targetTable} SET ${sets.join(', ')} WHERE ${baseWhere}`;
}
return '';
}
function selectVisualType(type: VisualConfig['visualType']) {
vc.value.visualType = type;
if ((type === 'REGISTRY_STAGE_SYNC' || type === 'REGISTRY_STAGE_REVERT') && !vc.value.registryStage) {
vc.value.registryStage = defaultRegistryStage();
}
if (type === 'REGISTRY_STAGE_SYNC' && registryStageOptions.value.length && !vc.value.registryStage?.stage) {
vc.value.registryStage!.stage = registryStageOptions.value[0].value;
vc.value.registryStage!.expectedFrom = defaultExpectedFromForStage(vc.value.registryStage!.stage);
}
if (type === 'REGISTRY_STAGE_REVERT' && !vc.value.registryStage?.targetStage) {
vc.value.registryStage!.targetStage = defaultRevertTargetStage();
}
}
function getDocLabel(doc: any): string {
return doc?.displayName || doc?.docName || doc?.bizName || doc?.tableName || '';
}
function filterBizDoc(input: string, option: any): boolean {
return (option.value?.toLowerCase() || '').includes(input.toLowerCase());
}
async function onTargetTableChange(tableName: string) {
if (!tableName) { targetColumns.value = []; return; }
loadingTargetCols.value = true;
try {
const cols = await getTableColumns(tableName);
targetColumns.value = (cols as any) || [];
const doc = bizDocList.value.find((d) => d.tableName === tableName);
vc.value.targetTableLabel = getDocLabel(doc);
// 重置状态字段的字典缓存
targetStatusDictCode.value = '';
targetStatusDictItems.value = [];
} catch {
targetColumns.value = [];
} finally {
loadingTargetCols.value = false;
}
}
async function onTargetStatusFieldChange(field: string) {
if (!field) { targetStatusDictCode.value = ''; targetStatusDictItems.value = []; return; }
// 清空旧值,防止值不匹配新字典
vc.value.statusConfig!.fromValue = '';
vc.value.statusConfig!.newValue = '';
}
async function onSrcConditionFieldChange(field: string) {
if (!field) { srcConditionDictCode.value = ''; srcConditionDictItems.value = []; return; }
vc.value.statusConfig!.srcConditionValue = '';
}
function getMappingPlaceholder(sourceType: string): string {
return sourceType === 'expression' ? 'NOW()、REPLACE(UUID(),\'-\',\'\') 等SQL表达式' : '固定值字符串';
}
function addMappingRow() {
if (!vc.value.fieldMappings) vc.value.fieldMappings = [];
vc.value.fieldMappings.push({ targetField: '', sourceType: 'source_field', sourceValue: '' });
}
async function handleConfirm() {
try { await formRef.value?.validate(); } catch { return; }
if (vc.value.visualType === 'REGISTRY_STAGE_SYNC') {
if (!vc.value.registryStage?.stage) {
createMessage.warning('请选择审批环节');
return;
}
emit('success', {
...form.value,
actionType: 'REGISTRY_STAGE_SYNC',
sqlTemplate: null,
actionConfig: JSON.stringify(vc.value),
});
visible.value = false;
return;
}
if (vc.value.visualType === 'REGISTRY_STAGE_REVERT') {
emit('success', {
...form.value,
actionType: 'REGISTRY_STAGE_REVERT',
sqlTemplate: null,
actionConfig: JSON.stringify(vc.value),
});
visible.value = false;
return;
}
if (!vc.value.targetTable) { createMessage.warning('请选择操作目标表'); return; }
if (!vc.value.linkCondition.sourceField || !vc.value.linkCondition.targetField) { createMessage.warning('请配置关联条件'); return; }
const sql = buildSql(vc.value);
if (!sql) { createMessage.warning('配置不完整,请检查字段设置'); return; }
emit('success', {
...form.value,
actionType: 'SQL_UPDATE',
sqlTemplate: sql,
actionConfig: JSON.stringify(vc.value),
});
visible.value = false;
}
async function open(opts: { sourceTable: string; sourceColumns: ColMeta[]; bizDocList: any[]; sourceRegistry?: any; action?: any; execOrder?: number }) {
sourceTable.value = opts.sourceTable;
sourceColumns.value = opts.sourceColumns || [];
bizDocList.value = opts.bizDocList || [];
sourceRegistry.value = opts.sourceRegistry || null;
targetColumns.value = [];
targetStatusDictCode.value = '';
targetStatusDictItems.value = [];
srcConditionDictCode.value = '';
srcConditionDictItems.value = [];
sourceStatusDictCode.value = '';
sourceStatusDictItems.value = [];
if (opts.action) {
isUpdate.value = true;
const a = opts.action;
form.value = {
id: a.id || '',
actionName: a.actionName || '',
execOrder: a.execOrder ?? 0,
onFail: a.onFail || 'stop',
enabled: a.enabled !== false && a.enabled !== 0,
};
if (a.actionConfig) {
try {
const parsed = JSON.parse(a.actionConfig);
vc.value = normalizeParsedConfig(parsed, a.actionType);
if (vc.value.targetTable) {
onTargetTableChange(vc.value.targetTable);
}
} catch {
vc.value = normalizeParsedConfig(null, a.actionType);
}
} else {
vc.value = normalizeParsedConfig(null, a.actionType);
}
} else {
isUpdate.value = false;
form.value = { ...defaultForm(), execOrder: opts.execOrder ?? 0 };
vc.value = defaultVc();
}
await loadSourceStatusDict();
if (!isUpdate.value) {
if (registryStageOptions.value.length && !vc.value.registryStage?.stage) {
vc.value.registryStage!.stage = registryStageOptions.value[0].value;
vc.value.registryStage!.expectedFrom = defaultExpectedFromForStage(vc.value.registryStage!.stage);
}
if (!vc.value.registryStage?.targetStage) {
vc.value.registryStage!.targetStage = defaultRevertTargetStage();
}
}
visible.value = true;
}
defineExpose({ open });
</script>

View File

@@ -9,9 +9,11 @@ enum Api {
importExcel = '/xslmes/mesXslMixerPsCompile/importExcel',
exportXls = '/xslmes/mesXslMixerPsCompile/exportXls',
queryById = '/xslmes/mesXslMixerPsCompile/queryById',
proofread = '/xslmes/mesXslMixerPsCompile/proofread',
audit = '/xslmes/mesXslMixerPsCompile/audit',
approve = '/xslmes/mesXslMixerPsCompile/approve',
// update-begin---author:GHT ---date:2026-06-05 for【XSLMES-20260605-K8R2】校对/审核/批准接口停用,改由审批流+集成方案驱动
// proofread = '/xslmes/mesXslMixerPsCompile/proofread',
// audit = '/xslmes/mesXslMixerPsCompile/audit',
// approve = '/xslmes/mesXslMixerPsCompile/approve',
// update-end---author:GHT ---date:2026-06-05 for【XSLMES-20260605-K8R2】
}
export const list = (params) => defHttp.get({ url: Api.list, params });
@@ -32,8 +34,8 @@ export const saveOrUpdate = (params, isUpdate) => {
export const getExportUrl = Api.exportXls;
export const getImportUrl = Api.importExcel;
export const proofread = (params: { ids: string }) => defHttp.post({ url: Api.proofread, params }, { joinParamsToUrl: true });
export const audit = (params: { ids: string }) => defHttp.post({ url: Api.audit, params }, { joinParamsToUrl: true });
export const approve = (params: { ids: string }) => defHttp.post({ url: Api.approve, params }, { joinParamsToUrl: true });
// update-begin---author:GHT ---date:2026-06-05 for【XSLMES-20260605-K8R2】校对/审核/批准接口停用,保留代码备后期恢复
// export const proofread = (params: { ids: string }) => defHttp.post({ url: Api.proofread, params }, { joinParamsToUrl: true });
// export const audit = (params: { ids: string }) => defHttp.post({ url: Api.audit, params }, { joinParamsToUrl: true });
// export const approve = (params: { ids: string }) => defHttp.post({ url: Api.approve, params }, { joinParamsToUrl: true });
// update-end---author:GHT ---date:2026-06-05 for【XSLMES-20260605-K8R2】

View File

@@ -10,30 +10,8 @@
>
新增
</a-button>
<a-button
v-auth="'xslmes:mes_xsl_mixer_ps_compile:proofread'"
:disabled="selectedRowKeys.length === 0"
preIcon="ant-design:check-circle-outlined"
@click="handleProofread"
>
校对
</a-button>
<a-button
v-auth="'xslmes:mes_xsl_mixer_ps_compile:audit'"
:disabled="selectedRowKeys.length === 0"
preIcon="ant-design:audit-outlined"
@click="handleAudit"
>
审核
</a-button>
<a-button
v-auth="'xslmes:mes_xsl_mixer_ps_compile:approve'"
:disabled="selectedRowKeys.length === 0"
preIcon="ant-design:safety-certificate-outlined"
@click="handleApprove"
>
批准
</a-button>
<!-- update-begin---author:GHT ---date:2026-06-05 forXSLMES-20260605-K8R2校对/审核/批准改由审批流+集成方案驱动列表不再提供手工操作入口 -->
<!-- update-end---author:GHT ---date:2026-06-05 forXSLMES-20260605-K8R2校对/审核/批准改由审批流+集成方案驱动 -->
<a-button
type="primary"
v-auth="'xslmes:mes_xsl_mixer_ps_compile:exportXls'"
@@ -86,7 +64,6 @@
<script lang="ts" name="xslmes-mesXslMixerPsCompile" setup>
import { computed, reactive } from 'vue';
import { Modal } from 'ant-design-vue';
import { BasicTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
@@ -100,9 +77,6 @@
batchDelete,
getExportUrl,
getImportUrl,
proofread,
audit,
approve,
} from './MesXslMixerPsCompile.api';
const { createMessage } = useMessage();
@@ -171,38 +145,6 @@
openModal(true, { record, isUpdate: true, showFooter: true });
}
function handleStatusAction(action: 'proofread' | 'audit' | 'approve', label: string) {
if (selectedRowKeys.value.length === 0) {
createMessage.warning('请先选择要' + label + '的记录');
return;
}
Modal.confirm({
title: '确认' + label,
content: `确定对选中的 ${selectedRowKeys.value.length} 条记录执行${label}吗?`,
okText: '确认',
cancelText: '取消',
onOk: async () => {
const ids = selectedRowKeys.value.join(',');
const fn = action === 'proofread' ? proofread : action === 'audit' ? audit : approve;
await fn({ ids });
createMessage.success(label + '成功');
handleSuccess();
},
});
}
function handleProofread() {
handleStatusAction('proofread', '校对');
}
function handleAudit() {
handleStatusAction('audit', '审核');
}
function handleApprove() {
handleStatusAction('approve', '批准');
}
function handleDetail(record: Recordable) {
openModal(true, { record, isUpdate: true, showFooter: false });
}