Merge branch '20260519-3.9.2版本-葛昊天分支'

This commit is contained in:
geht
2026-06-05 10:45:42 +08:00
86 changed files with 9783 additions and 54 deletions

View File

@@ -0,0 +1,807 @@
<template>
<a-modal
v-model:open="visible"
:title="`发起钉钉审批 · ${tplData?.templateName || ''}`"
:width="multiRow ? 1120 : 940"
:confirm-loading="submitting"
ok-text="发起审批"
cancel-text="取消"
destroy-on-close
:body-style="{ padding: 0 }"
@ok="handleSubmit"
@cancel="visible = false"
>
<div class="dal-body">
<!-- 左侧审批流时间轴 -->
<div class="dal-timeline-panel">
<div class="dal-panel-title">审批流程</div>
<div v-if="!selectedFlowId" class="dal-timeline-empty">
<div class="dal-timeline-empty-icon">🔗</div>
<div>请在右侧审批流配置<br>页签中选择审批流</div>
</div>
<a-spin v-else-if="previewLoading" style="display:flex;justify-content:center;padding:32px 0" />
<div v-else class="dal-timeline">
<div class="dal-ts-step">
<div class="dal-ts-left">
<div class="dal-ts-dot dal-ts-dot--start"></div>
<div class="dal-ts-line" v-if="approverPreview.length > 0"></div>
</div>
<div class="dal-ts-content">
<div class="dal-ts-name">发起人</div>
<div class="dal-ts-sub">所有人可发起</div>
</div>
</div>
<div v-for="(node, ni) in approverPreview" :key="node.nodeId || ni" class="dal-ts-step">
<div class="dal-ts-left">
<div class="dal-ts-dot"
:class="[node.nodeType==='cc'?'dal-ts-dot--cc':'dal-ts-dot--approver', !node.allResolved?'dal-ts-dot--warn':'']">
</div>
<div class="dal-ts-line" v-if="ni < approverPreview.length - 1"></div>
</div>
<div class="dal-ts-content">
<div class="dal-ts-tags">
<span class="dal-ts-badge" :class="node.nodeType==='cc'?'dal-ts-badge--cc':'dal-ts-badge--approver'">
{{ node.nodeType==='cc'?'抄送':'审批' }}
</span>
<span v-if="node.nodeType!=='cc'" class="dal-ts-mode">{{ modeLabel(node.multiMode) }}</span>
</div>
<div class="dal-ts-name">{{ node.nodeName }}</div>
<div class="dal-ts-users">
<template v-for="(u, ui) in node.users" :key="u.username">
<span :class="u.resolved?'dal-ts-user--ok':'dal-ts-user--err'">{{ u.realname }}</span>
<span v-if="ui < node.users.length-1" style="color:#ccc;margin:0 2px">·</span>
</template>
</div>
<div v-if="!node.allResolved" class="dal-ts-unresolved"> 有未解析成员请补充手机号</div>
</div>
</div>
<div class="dal-ts-step" v-if="approverPreview.length > 0">
<div class="dal-ts-left"><div class="dal-ts-dot dal-ts-dot--end"></div></div>
<div class="dal-ts-content"><div class="dal-ts-name" style="color:#888">结束</div></div>
</div>
</div>
</div>
<div class="dal-panel-divider"></div>
<!-- 中间主内容表单字段 + 审批流配置 -->
<div class="dal-content-panel">
<a-tabs v-model:activeKey="activeTab" size="small" class="dal-tabs">
<!-- 表单展示只读 -->
<a-tab-pane key="form" tab="表单字段">
<div class="dal-form-scroll">
<a-spin :spinning="loading" tip="加载表单字段中...">
<a-alert v-if="loadError" type="error" :message="loadError" show-icon style="margin-bottom:12px" />
<template v-else-if="!loading">
<a-alert
type="info"
show-icon
style="margin-bottom:12px"
message="字段已根据绑定配置从业务单据自动填充,不可手动修改。如需调整请先更新审批模板绑定中的字段映射。"
/>
<div v-if="dingFields.length === 0" class="dal-form-empty">该模板暂无表单字段</div>
<template v-for="field in dingFields" :key="field.label">
<div v-if="field.componentName === 'TextNote'" class="dal-form-note">{{ field.label }}</div>
<!-- 明细表只读展示 -->
<template v-else-if="field.componentName === 'TableField'">
<div class="dal-form-item">
<div class="dal-field-label" :class="{'dal-field-label--required': field.required}">{{ field.label }}</div>
<div class="dal-table-wrap">
<table class="dal-table">
<thead>
<tr>
<th style="width:40px;text-align:center">#</th>
<th v-for="child in field.children||[]" :key="child.label">{{ child.label }}</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIdx) in getTableRows(field.label)" :key="rowIdx">
<td style="text-align:center;color:#aaa">{{ rowIdx+1 }}</td>
<td v-for="child in field.children||[]" :key="child.label">
<span class="dal-readonly-cell">{{ row[child.label] ?? '—' }}</span>
</td>
</tr>
<tr v-if="getTableRows(field.label).length === 0">
<td :colspan="(field.children?.length||0)+1" style="text-align:center;color:#bbb;padding:10px 0">暂无数据</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<!-- 普通字段只读 -->
<div v-else class="dal-form-item">
<div class="dal-field-label" :class="{'dal-field-label--required': field.required}">{{ field.label }}</div>
<a-input
:value="displayValue(field)"
disabled
class="dal-readonly-input"
/>
</div>
</template>
</template>
</a-spin>
</div>
</a-tab-pane>
<!-- 审批流配置只读查看 -->
<a-tab-pane key="flow">
<template #tab>
审批流配置
<a-badge v-if="hasUnresolved" color="red" style="margin-left:4px" />
</template>
<div class="dal-form-scroll">
<a-alert
type="info"
show-icon
style="margin-bottom:14px"
>
<template #message>
审批流程仅供查看不可在此修改如需调整请前往
<a class="flow-readonly-link" @click="goToTplConfig">钉钉审批模板配置</a>
中更改绑定的审批流
</template>
</a-alert>
<div class="flow-select-row">
<a-select
v-model:value="selectedFlowId"
style="flex:1;min-width:0"
placeholder="(未绑定审批流)"
:loading="flowLoading"
:options="flowSelectOptions"
disabled
>
<template #option="{ label, status, remark }">
<div class="flow-opt-item">
<span class="flow-opt-name">{{ label }}</span>
<span style="display:flex;align-items:center;gap:6px;flex-shrink:0">
<span v-if="remark" class="flow-opt-remark">{{ remark }}</span>
<a-tag :color="status==='1'?'green':status==='2'?'default':'orange'"
style="margin:0;font-size:11px;line-height:16px;padding:0 5px">
{{ status==='1'?'已发布':status==='2'?'已停用':'草稿' }}
</a-tag>
</span>
</div>
</template>
</a-select>
</div>
<template v-if="selectedFlowId">
<a-divider style="margin:14px 0 10px" />
<div class="preview-title">
审批节点 · 人员解析
<a-spin :spinning="previewLoading" size="small" style="margin-left:8px" />
</div>
<div v-if="!previewLoading && approverPreview.length === 0" style="color:#bbb;font-size:12px;margin-top:6px">
该审批流暂无审批人节点
</div>
<div v-for="(node, ni) in approverPreview" :key="node.nodeId||ni" class="preview-node"
:class="{'preview-node--cc': node.nodeType==='cc'}">
<div class="preview-node-hd">
<a-tag :color="node.nodeType==='cc'?'blue':'orange'" style="margin:0 6px 0 0;font-size:11px">
{{ node.nodeType==='cc'?'抄送':'审批' }}
</a-tag>
<span class="preview-node-name">{{ node.nodeName }}</span>
<span class="preview-node-mode">{{ node.nodeType==='cc'?'位置自动判断':modeLabel(node.multiMode) }}</span>
</div>
<div v-for="u in node.users" :key="u.username" class="preview-user">
<a-tag v-if="u.resolved" color="success" style="margin:0">{{ u.realname }}{{ u.username }}</a-tag>
<a-tag v-else-if="u.unsupported" color="default" style="margin:0">{{ u.realname }}不支持自动解析</a-tag>
<a-tag v-else color="error" style="margin:0">{{ u.realname }}{{ u.username }}未找到钉钉账号</a-tag>
</div>
<div v-if="!node.allResolved" class="preview-supplement">
<a-input
v-model:value="supplementPhones[node.nodeId||String(ni)]"
:placeholder="node.nodeType==='cc'?'补充抄送人手机号,多个用逗号分隔':'补充审批人手机号,多个用逗号分隔'"
allow-clear size="small"
/>
<div class="dal-field-hint" style="margin-top:3px">手机号需在企业钉钉注册与自动解析的成员合并</div>
</div>
</div>
<a-alert v-if="hasUnresolved" type="warning" show-icon style="margin-top:10px;font-size:12px"
message="部分节点有未解析成员,请补充手机号后再发起审批" />
</template>
</div>
</a-tab-pane>
</a-tabs>
</div>
<!-- 右侧已选数据列表多条时显示 -->
<template v-if="multiRow">
<div class="dal-panel-divider"></div>
<div class="dal-rows-panel">
<div class="dal-panel-title">
已选数据
<span class="dal-rows-badge">{{ allRows.length }}</span>
</div>
<div class="dal-rows-list">
<div
v-for="(row, idx) in allRows"
:key="idx"
class="dal-row-item"
:class="{ 'dal-row-item--active': idx === currentRowIndex }"
@click="switchRow(idx)"
>
<div class="dal-row-index">{{ idx + 1 }}</div>
<div class="dal-row-label">{{ getRowLabel(row, idx) }}</div>
</div>
</div>
</div>
</template>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '/@/hooks/web/useMessage';
import {
getTemplateDetail,
queryById as queryTplById,
launchApproval,
getApprovalFlowList,
previewFlowApprovers,
} from '/@/views/xslmes/dingtalk/mesXslDingProcessTpl/MesXslDingProcessTpl.api';
import { checkCanLaunch } from '/@/views/approval/gate/approvalGate.api';
const emit = defineEmits(['success']);
const { createMessage } = useMessage();
const router = useRouter();
const visible = ref(false);
const loading = ref(false);
const submitting = ref(false);
const loadError = ref('');
const activeTab = ref('form');
interface BindInfo { id: string; templateId: string; templateName: string; fieldMappingJson?: string; bizCode?: string }
const tplData = ref<BindInfo | null>(null);
// 多条数据支持
const allRows = ref<any[]>([]);
const currentRowIndex = ref(0);
const multiRow = computed(() => allRows.value.length > 0);
const dingFields = ref<any[]>([]);
const formValues = reactive<Record<string, any>>({});
const tableValues = reactive<Record<string, Record<string, string>[]>>({});
const flowLoading = ref(false);
const flowList = ref<any[]>([]);
const selectedFlowId = ref('');
const previewLoading = ref(false);
const approverPreview = ref<any[]>([]);
const supplementPhones = reactive<Record<string, string>>({});
const hasUnresolved = computed(() => approverPreview.value.some(n => !n.allResolved));
const flowSelectOptions = computed(() =>
flowList.value.map(f => ({
value: f.id, label: f.flowName || f.name,
status: f.status, remark: f.remark || '',
}))
);
function modeLabel(mode: string) {
if (mode === 'none') return '单人';
if (mode === 'or') return '或签';
if (mode === 'sequence') return '依次';
return '会签';
}
// ══ 打开弹窗,支持单条或多条 ══
async function open(bindInfo: BindInfo, rowsOrRow: any[] | any) {
tplData.value = bindInfo;
allRows.value = Array.isArray(rowsOrRow) ? rowsOrRow : [rowsOrRow];
currentRowIndex.value = 0;
resetFieldValues();
loadError.value = '';
activeTab.value = 'form';
selectedFlowId.value = '';
approverPreview.value = [];
visible.value = true;
loading.value = true;
try {
const [detail, flows, tplRecord] = await Promise.all([
getTemplateDetail(bindInfo.templateId),
getApprovalFlowList({ pageSize: 200 }),
queryTplById(bindInfo.templateId),
]);
dingFields.value = detail?.dingFields || [];
if (detail?.schemaError) loadError.value = detail.schemaError;
flowList.value = flows?.records || flows || [];
for (const f of dingFields.value) {
if (f.componentName === 'TableField') tableValues[f.label] = [];
}
applyPrefillForRow(currentRowIndex.value);
const presetFlowId = tplRecord?.flowId;
if (presetFlowId) {
selectedFlowId.value = presetFlowId;
loadPreview(presetFlowId);
}
} catch (e: any) {
loadError.value = e?.message || '加载模板字段失败';
} finally {
loading.value = false;
}
}
function goToTplConfig() {
visible.value = false;
router.push('/xslmes/mesXslDingProcessTplList');
}
defineExpose({ open });
// ══ 切换数据行 ══
function switchRow(idx: number) {
if (idx === currentRowIndex.value) return;
currentRowIndex.value = idx;
resetFieldValues();
applyPrefillForRow(idx);
activeTab.value = 'form';
}
function resetFieldValues() {
Object.keys(formValues).forEach(k => delete formValues[k]);
Object.keys(tableValues).forEach(k => delete tableValues[k]);
Object.keys(supplementPhones).forEach(k => delete supplementPhones[k]);
for (const f of dingFields.value) {
if (f.componentName === 'TableField') tableValues[f.label] = [];
}
}
function applyPrefillForRow(idx: number) {
const rowData = allRows.value[idx];
if (rowData && tplData.value?.fieldMappingJson) {
applyPrefill(dingFields.value, tplData.value.fieldMappingJson, rowData);
}
}
// ══ 行标签(用于右侧列表显示) ══
const LABEL_SKIP = new Set([
'id', 'createBy', 'createTime', 'updateBy', 'updateTime',
'delFlag', 'sysOrgCode', 'tenantId', 'version',
]);
function getRowLabel(row: any, index: number): string {
for (const [key, val] of Object.entries(row ?? {})) {
if (LABEL_SKIP.has(key)) continue;
if (val && typeof val === 'string' && val.length <= 40) return val;
if (val !== null && val !== undefined && typeof val === 'number') return String(val);
}
return `条目 ${index + 1}`;
}
// ══ 预填充 ══
interface MappingItem {
componentId: string;
componentLabel: string;
componentName: string;
parentId?: string;
bizField?: string;
}
function applyPrefill(fields: any[], mappingJson: string, rowData: any) {
let mapping: MappingItem[] = [];
try { mapping = JSON.parse(mappingJson); } catch { return; }
const byId = new Map(mapping.map(m => [m.componentId, m]));
for (const field of fields) {
if (field.componentName === 'TextNote') continue;
const cid = field.id || field.label;
const m = byId.get(cid);
if (field.componentName === 'TableField') {
const slotName = m?.bizField;
if (!slotName) continue;
const arr: any[] = getNestedValue(rowData, slotName);
if (!Array.isArray(arr) || !arr.length) continue;
const childMappings = mapping.filter(x => x.parentId === cid && x.bizField);
tableValues[field.label] = arr.map(element => {
const row: Record<string, string> = {};
for (const child of childMappings) {
const parts = (child.bizField || '').split('.');
const colKey = parts.slice(1).join('.');
const val = colKey ? getNestedValue(element, colKey) : undefined;
row[child.componentLabel] = val !== undefined && val !== null ? String(val) : '';
}
for (const child of (field.children || [])) {
if (!(child.label in row)) row[child.label] = '';
}
return row;
});
} else {
if (!m?.bizField) continue;
const val = getNestedValue(rowData, m.bizField);
if (val === undefined || val === null) continue;
formValues[field.label] = formatForDisplay(val, field.componentName);
}
}
}
function getNestedValue(obj: any, path: string): any {
if (!obj || !path) return undefined;
return path.split('.').reduce((acc: any, k: string) => acc?.[k], obj);
}
function formatForDisplay(v: any, componentName: string): any {
if (v === null || v === undefined) return '';
if (['NumberField', 'MoneyField'].includes(componentName)) {
return typeof v === 'number' ? v : Number(v) || 0;
}
if (componentName === 'DDDateField') {
const s = String(v);
return s.includes('T') ? s.split('T')[0] : s.split(' ')[0];
}
if (Array.isArray(v)) return v.join(',');
return String(v);
}
function getTableRows(label: string): Record<string, string>[] {
return tableValues[label] || [];
}
function displayValue(field: any): string {
const v = formValues[field.label];
if (v === undefined || v === null || v === '') return '';
if (field.componentName === 'DDDateRangeField' && Array.isArray(v)) return v.join(' ~ ');
if (field.componentName === 'DDMultiSelectField' && Array.isArray(v)) return v.join(', ');
return String(v);
}
// ══ 审批流预览 ══
async function loadPreview(flowId: string) {
previewLoading.value = true;
try {
const res = await previewFlowApprovers(flowId);
approverPreview.value = Array.isArray(res) ? res : [];
} catch { approverPreview.value = []; }
finally { previewLoading.value = false; }
}
// ══ 提交 ══
async function handleSubmit() {
if (!selectedFlowId.value) {
activeTab.value = 'flow';
createMessage.warning('请在「审批流配置」页签中选择一个审批流');
return Promise.reject();
}
const unresolvedNodes = approverPreview.value.filter(n => !n.allResolved);
for (const node of unresolvedNodes) {
const key = node.nodeId || String(approverPreview.value.indexOf(node));
if (!supplementPhones[key]?.trim()) {
activeTab.value = 'flow';
createMessage.warning(`${node.nodeType==='cc'?'抄送节点':'审批节点'}${node.nodeName}」有未解析成员,请补充手机号`);
return Promise.reject();
}
}
const fvList: { name: string; value: string }[] = [];
for (const field of dingFields.value) {
if (field.componentName === 'TextNote') continue;
const label = field.label;
if (field.componentName === 'TableField') {
const validRows = getTableRows(label).filter(r => Object.values(r).some(v => v !== ''));
if (validRows.length === 0) continue;
fvList.push({
name: label,
value: JSON.stringify(validRows.map(row =>
Object.entries(row).map(([k, v]) => ({ name: k, value: String(v ?? '') }))
)),
});
continue;
}
let val = formValues[label];
if (field.componentName === 'DDDateRangeField' && Array.isArray(val)) {
val = val.join('~');
} else if (field.componentName === 'DDMultiSelectField' && Array.isArray(val)) {
val = val.length > 0 ? JSON.stringify(val) : null;
} else if (['InnerContactField', 'RelateField', 'DDPhotoField'].includes(field.componentName)) {
const raw = val ? String(val).trim() : '';
val = raw ? JSON.stringify(raw.split(',').map((s: string) => s.trim()).filter(Boolean)) : null;
} else {
val = val !== undefined && val !== null ? String(val) : null;
}
if (val === null || val === '') { if (!field.required) continue; val = ''; }
fvList.push({ name: label, value: val as string });
}
const approverOverrides = Object.entries(supplementPhones)
.filter(([, phones]) => phones?.trim())
.map(([nodeId, phones]) => ({ nodeId, phones: phones.trim() }));
const row = allRows.value[currentRowIndex.value];
const flow = flowList.value.find((f) => f.id === selectedFlowId.value);
const bizTable = flow?.bizTable;
const bizDataId = row?.id != null ? String(row.id) : '';
const bizTitle = getRowLabel(row, currentRowIndex.value);
//update-begin---author:GHT ---date:2026-06-04 for【QH-MES审批台账】钉钉发起前统一门禁校验-----
if (bizTable && bizDataId) {
const gate = await checkCanLaunch({ bizTable, bizDataId });
if (!gate?.allowed) {
createMessage.warning(gate?.reason || '当前不允许发起审批');
return Promise.reject();
}
}
//update-end---author:GHT ---date:2026-06-04 for【QH-MES审批台账】钉钉发起前统一门禁校验-----
submitting.value = true;
try {
const result = await launchApproval({
id: tplData.value!.templateId,
formValues: fvList,
flowId: selectedFlowId.value,
approverOverrides,
bizTable,
bizDataId,
bizTitle,
bizCode: tplData.value?.bizCode,
});
createMessage.success(typeof result === 'string' ? result : '审批发起成功!审批人将在钉钉「待我审批」中收到任务');
visible.value = false;
emit('success', result);
} catch (e: any) {
createMessage.error(e?.message || '发起失败');
return Promise.reject(e);
} finally {
submitting.value = false;
}
}
</script>
<style lang="less" scoped>
.dal-body {
display: flex;
min-height: 480px;
max-height: 70vh;
}
.dal-timeline-panel {
width: 210px;
flex-shrink: 0;
background: #fafafa;
border-right: 1px solid #f0f0f0;
padding: 16px 14px;
overflow-y: auto;
}
.dal-panel-title {
font-size: 12px;
font-weight: 600;
color: #888;
letter-spacing: .5px;
text-transform: uppercase;
margin-bottom: 14px;
display: flex;
align-items: center;
gap: 6px;
}
.dal-timeline-empty {
text-align: center; color: #bbb; font-size: 12px; padding-top: 32px; line-height: 1.8;
.dal-timeline-empty-icon { font-size: 24px; margin-bottom: 8px; }
}
.dal-ts-step { display: flex; align-items: flex-start; gap: 8px; }
.dal-ts-left { display: flex; flex-direction: column; align-items: center; flex-shrink: 0; width: 12px; padding-top: 2px; }
.dal-ts-dot {
width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0;
position: relative; z-index: 1; border: 2px solid currentColor; background: #fff;
&--start { color: #1677ff; background: #1677ff; border-color: #1677ff; }
&--end { color: #d9d9d9; background: #d9d9d9; border-color: #d9d9d9; width: 8px; height: 8px; }
&--approver { color: #fa8c16; }
&--cc { color: #1677ff; }
&--warn { color: #ff4d4f !important; }
}
.dal-ts-line { width: 2px; flex: 1; min-height: 22px; background: linear-gradient(to bottom, #e0e0e0 50%, transparent 100%); margin: 3px 0 0; }
.dal-ts-content { flex: 1; padding-bottom: 18px; min-width: 0; }
.dal-ts-sub { font-size: 11px; color: #aaa; }
.dal-ts-tags { display: flex; align-items: center; gap: 4px; margin-bottom: 2px; }
.dal-ts-badge {
font-size: 10px; padding: 0 5px; border-radius: 3px; line-height: 16px; font-weight: 500;
&--approver { background: #fff7e6; color: #d46b08; border: 1px solid #ffd591; }
&--cc { background: #e6f4ff; color: #0958d9; border: 1px solid #91caff; }
}
.dal-ts-mode { font-size: 10px; color: #aaa; background: #f5f5f5; padding: 0 4px; border-radius: 2px; line-height: 14px; }
.dal-ts-name { font-size: 12px; font-weight: 500; color: #333; line-height: 1.4; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dal-ts-users { font-size: 11px; color: #888; margin-top: 2px; line-height: 1.5; }
.dal-ts-user--ok { color: #52c41a; }
.dal-ts-user--err { color: #ff4d4f; }
.dal-ts-unresolved { font-size: 10px; color: #ff7a00; margin-top: 2px; }
.dal-panel-divider { width: 1px; background: #f0f0f0; flex-shrink: 0; }
.dal-content-panel {
flex: 1; min-width: 0; display: flex; flex-direction: column; overflow: hidden;
}
.dal-tabs {
height: 100%; display: flex; flex-direction: column;
:deep(.ant-tabs-nav) { padding: 0 16px; margin-bottom: 0; }
:deep(.ant-tabs-content-holder) { flex: 1; overflow: hidden; }
:deep(.ant-tabs-content) { height: 100%; }
:deep(.ant-tabs-tabpane) { height: 100%; }
}
.dal-form-scroll { height: 100%; overflow-y: auto; padding: 14px 18px; }
.dal-form-item { margin-bottom: 14px; }
.dal-form-empty { color: #bbb; text-align: center; padding: 32px 0; font-size: 13px; }
.dal-field-label {
font-size: 13px; color: #555; margin-bottom: 5px; font-weight: 500;
&--required::before { content: '* '; color: #ff4d4f; }
}
.dal-field-hint { font-size: 11px; color: #aaa; margin-top: 3px; }
.dal-form-note {
background: #f8f8f8; border-left: 3px solid #ddd; padding: 6px 10px;
font-size: 12px; color: #777; margin-bottom: 12px; border-radius: 0 4px 4px 0;
}
.dal-readonly-input {
:deep(.ant-input[disabled]) {
color: rgba(0,0,0,.75) !important;
background: #f8f8f8 !important;
cursor: default;
}
}
.dal-readonly-cell {
display: block;
padding: 2px 6px;
color: rgba(0,0,0,.75);
font-size: 13px;
line-height: 1.5;
}
.dal-table-wrap { border: 1px solid #ebebeb; border-radius: 6px; overflow: hidden; }
.dal-table {
width: 100%; border-collapse: collapse; font-size: 13px;
th { background: #fafafa; padding: 7px 10px; border-bottom: 1px solid #ebebeb; font-weight: 500; color: #666; font-size: 12px; }
td { padding: 4px 6px; border-bottom: 1px solid #f5f5f5; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
}
// 审批流配置
.flow-select-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
.flow-readonly-link {
color: inherit;
text-decoration: none;
border-bottom: 1px dashed currentColor;
cursor: pointer;
transition: color .15s;
&:hover { color: #1677ff; }
}
.flow-opt-item { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.flow-opt-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.flow-opt-remark { font-size: 11px; color: #aaa; }
.preview-title { font-size: 12px; font-weight: 600; color: #555; margin-bottom: 8px; display: flex; align-items: center; }
.preview-node {
background: #fafafa; border: 1px solid #f0f0f0; border-radius: 6px; padding: 8px 10px; margin-bottom: 8px;
&--cc { background: #f0f7ff; border-color: #bae0ff; }
}
.preview-node-hd { display: flex; align-items: center; gap: 4px; margin-bottom: 6px; }
.preview-node-name { font-size: 12px; font-weight: 500; flex: 1; }
.preview-node-mode { font-size: 11px; color: #aaa; }
.preview-user { margin-bottom: 4px; }
.preview-supplement { margin-top: 8px; }
// ══ 右侧:已选数据列表 ══
.dal-rows-panel {
width: 170px;
flex-shrink: 0;
background: #fafafa;
padding: 16px 10px;
overflow-y: auto;
}
.dal-rows-badge {
display: inline-flex;
align-items: center;
justify-content: center;
background: #ff6900;
color: #fff;
border-radius: 10px;
font-size: 10px;
font-weight: 700;
min-width: 16px;
height: 16px;
padding: 0 4px;
line-height: 1;
}
.dal-rows-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.dal-row-item {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 7px 8px;
border-radius: 6px;
cursor: pointer;
border: 1px solid transparent;
transition: all .15s;
background: #fff;
border-color: #f0f0f0;
&:hover {
border-color: #ff6900;
background: #fff8f3;
}
&--active {
border-color: #ff6900 !important;
background: #fff3eb !important;
.dal-row-index {
background: #ff6900;
color: #fff;
}
}
}
.dal-row-index {
flex-shrink: 0;
width: 18px;
height: 18px;
border-radius: 50%;
background: #e8e8e8;
color: #666;
font-size: 10px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
transition: all .15s;
margin-top: 1px;
}
.dal-row-label {
flex: 1;
min-width: 0;
font-size: 12px;
color: #333;
line-height: 1.5;
word-break: break-all;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>

View File

@@ -0,0 +1,257 @@
<!--
全局发起钉钉审批按钮
路由变化时检查当前页面是否配置了钉钉审批模板绑定有则显示
默认停靠在列表页查询条件区域最右侧可拖拽移动位置按路由 localStorage 记忆
@author GHT
@date 2026-06-04 forMESToDing审批配置审批模板绑定-发起钉钉审批
-->
<template>
<Teleport to="body">
<div
v-if="binding"
ref="floatRef"
class="dtl-float"
:class="{ 'dtl-float--dragging': isDragging }"
:style="floatStyle"
>
<div
class="dtl-float-btn"
:class="{ 'dtl-float-btn--active': hasRows }"
:title="btnTitle"
@pointerdown="onBtnPointerDown"
@click="onBtnClick"
>
<Icon icon="ant-design:dingtalk-outlined" :size="18" class="dtl-float-icon" />
<span class="dtl-float-text">钉钉审批</span>
<span v-if="hasRows" class="dtl-float-badge">{{ selectedRows.length }}</span>
</div>
</div>
</Teleport>
<DingBindLaunchModal ref="modalRef" />
</template>
<script lang="ts" setup>
import { ref, computed, watch, nextTick, onMounted, onUnmounted } from 'vue';
import { useRouter } from 'vue-router';
import { useMessage } from '/@/hooks/web/useMessage';
import { useApprovalSelection } from '/@/components/ApprovalLaunch/useApprovalSelection';
import { getBindingByRoute } from '/@/views/xslmes/dingtalk/dingTplBind/dingTplBind.api';
import DingBindLaunchModal from './DingBindLaunchModal.vue';
import { useDraggablePosition } from './useDraggablePosition';
defineOptions({ name: 'DingTplLaunchFloat' });
const { createMessage } = useMessage();
const router = useRouter();
const { getRowsByPath } = useApprovalSelection();
const binding = ref<any>(null);
const modalRef = ref<InstanceType<typeof DingBindLaunchModal> | null>(null);
const floatRef = ref<HTMLElement | null>(null);
const selectedRows = computed(() => getRowsByPath(router.currentRoute.value.path));
const hasRows = computed(() => selectedRows.value.length > 0);
const btnTitle = computed(() =>
hasRows.value
? `已选 ${selectedRows.value.length} 条,点击发起钉钉审批(可拖拽移动)`
: '请先在列表中勾选数据(可拖拽移动)',
);
const { pos, isDragging, initPosition, applyDefaultPosition, onPointerDown, wasDragged, clampPosition } =
useDraggablePosition(computed(() => router.currentRoute.value.path));
const floatStyle = computed(() => ({
left: `${pos.left}px`,
top: `${pos.top}px`,
}));
let resizeObserver: ResizeObserver | null = null;
let layoutTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleLayout(preferDefault = false) {
if (layoutTimer) clearTimeout(layoutTimer);
layoutTimer = setTimeout(() => {
layoutTimer = null;
const path = router.currentRoute.value.path;
if (!binding.value || !path) return;
initPosition(path, preferDefault);
nextTick(() => {
if (floatRef.value) {
const rect = floatRef.value.getBoundingClientRect();
clampPosition();
}
});
}, 80);
}
function bindFormResize() {
unbindFormResize();
const formEl = document.querySelector('.jeecg-basic-table-form-container');
if (!formEl || typeof ResizeObserver === 'undefined') return;
resizeObserver = new ResizeObserver(() => {
const path = router.currentRoute.value.path;
const saved = localStorage.getItem('mes_ding_tpl_launch_pos');
const hasSaved = saved && JSON.parse(saved || '{}')[path];
if (!hasSaved) applyDefaultPosition();
});
resizeObserver.observe(formEl);
}
function unbindFormResize() {
resizeObserver?.disconnect();
resizeObserver = null;
}
watch(
() => router.currentRoute.value.path,
async (path) => {
binding.value = null;
unbindFormResize();
if (!path || path === '/' || path.startsWith('/login')) return;
try {
const result = await getBindingByRoute(path);
binding.value = result || null;
if (binding.value) {
await nextTick();
scheduleLayout(false);
bindFormResize();
}
} catch {
binding.value = null;
}
},
{ immediate: true },
);
onMounted(() => {
window.addEventListener('resize', onWindowResize);
});
onUnmounted(() => {
window.removeEventListener('resize', onWindowResize);
unbindFormResize();
if (layoutTimer) clearTimeout(layoutTimer);
});
function onWindowResize() {
if (!binding.value) return;
clampPosition();
const path = router.currentRoute.value.path;
try {
const all = JSON.parse(localStorage.getItem('mes_ding_tpl_launch_pos') || '{}');
if (path && all[path]) {
all[path] = { left: pos.left, top: pos.top };
localStorage.setItem('mes_ding_tpl_launch_pos', JSON.stringify(all));
}
} catch {
/* ignore */
}
}
function onBtnPointerDown(e: PointerEvent) {
onPointerDown(e, floatRef.value);
}
function onBtnClick() {
if (wasDragged()) return;
handleClick();
}
function handleClick() {
if (!binding.value) return;
const rows = selectedRows.value;
if (!rows.length) {
createMessage.warning('请先在列表中勾选要发起审批的数据');
return;
}
modalRef.value?.open(
{
id: binding.value.id,
templateId: binding.value.templateId,
templateName: binding.value.templateName || '',
fieldMappingJson: binding.value.fieldMappingJson,
bizCode: binding.value.bizCode,
},
rows,
);
}
</script>
<style scoped>
.dtl-float {
position: fixed;
z-index: 1001;
user-select: none;
touch-action: none;
}
.dtl-float--dragging {
z-index: 1002;
}
.dtl-float-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 7px 14px;
background: rgba(255, 105, 0, 0.12);
border: 1.5px solid #ff6900;
border-radius: 24px;
color: #cc5500;
font-size: 13px;
font-weight: 600;
cursor: grab;
transition:
background 0.2s,
color 0.2s,
box-shadow 0.2s,
opacity 0.2s;
box-shadow: 0 2px 8px rgba(255, 105, 0, 0.15);
opacity: 0.85;
}
.dtl-float--dragging .dtl-float-btn {
cursor: grabbing;
opacity: 1;
box-shadow: 0 6px 18px rgba(255, 105, 0, 0.35);
}
.dtl-float-btn:hover,
.dtl-float-btn--active {
opacity: 1;
background: #ff6900;
color: #fff;
box-shadow: 0 4px 14px rgba(255, 105, 0, 0.4);
}
.dtl-float-btn:hover .dtl-float-icon,
.dtl-float-btn--active .dtl-float-icon {
color: #fff;
}
.dtl-float-icon {
color: #ff6900;
flex-shrink: 0;
}
.dtl-float-badge {
display: inline-flex;
align-items: center;
justify-content: center;
background: #fff;
color: #ff6900;
border-radius: 10px;
font-size: 11px;
font-weight: 700;
min-width: 18px;
height: 18px;
padding: 0 4px;
}
.dtl-float-btn--active .dtl-float-badge {
color: #ff6900;
}
</style>

View File

@@ -0,0 +1,157 @@
import { reactive, ref, type Ref } from 'vue';
const STORAGE_KEY = 'mes_ding_tpl_launch_pos';
export interface FloatPosition {
left: number;
top: number;
}
function readAllPositions(): Record<string, FloatPosition> {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}') || {};
} catch {
return {};
}
}
/** 查询条件区域BasicTable 搜索表单)右侧的默认坐标 */
export function calcDefaultPosition(btnWidth: number, btnHeight: number): FloatPosition {
const formEl =
document.querySelector<HTMLElement>('.jeecg-basic-table-form-container .ant-form') ||
document.querySelector<HTMLElement>('.jeecg-basic-table-form-container');
if (!formEl) {
return {
left: Math.max(8, window.innerWidth - btnWidth - 24),
top: 100,
};
}
const rect = formEl.getBoundingClientRect();
return {
left: Math.max(8, rect.right - btnWidth - 12),
top: Math.max(8, rect.top + (rect.height - btnHeight) / 2),
};
}
/**
* 可拖拽悬浮按钮位置:默认对齐查询区右侧,拖拽后按路由持久化。
*/
export function useDraggablePosition(routePath: Ref<string>) {
const pos = reactive<FloatPosition>({ left: 0, top: 0 });
const isDragging = ref(false);
let moved = false;
let pointerId: number | null = null;
let startX = 0;
let startY = 0;
let startLeft = 0;
let startTop = 0;
let btnWidth = 120;
let btnHeight = 36;
function setButtonSize(width: number, height: number) {
btnWidth = width;
btnHeight = height;
}
function loadPosition(path: string): boolean {
const saved = readAllPositions()[path];
if (saved && typeof saved.left === 'number' && typeof saved.top === 'number') {
pos.left = saved.left;
pos.top = saved.top;
return true;
}
return false;
}
function savePosition(path: string) {
const all = readAllPositions();
all[path] = { left: pos.left, top: pos.top };
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
}
function applyDefaultPosition() {
const p = calcDefaultPosition(btnWidth, btnHeight);
pos.left = p.left;
pos.top = p.top;
}
function initPosition(path: string, preferDefault = false) {
if (!path) return;
if (!preferDefault && loadPosition(path)) return;
applyDefaultPosition();
}
function clampPosition() {
const maxLeft = window.innerWidth - btnWidth - 8;
const maxTop = window.innerHeight - btnHeight - 8;
pos.left = Math.min(Math.max(8, pos.left), maxLeft);
pos.top = Math.min(Math.max(8, pos.top), maxTop);
}
function onPointerMove(e: PointerEvent) {
if (pointerId !== e.pointerId) return;
const dx = e.clientX - startX;
const dy = e.clientY - startY;
if (!moved && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) {
moved = true;
isDragging.value = true;
}
if (!moved) return;
pos.left = startLeft + dx;
pos.top = startTop + dy;
clampPosition();
}
function endDrag(path: string) {
document.removeEventListener('pointermove', onPointerMove);
document.removeEventListener('pointerup', onPointerUp);
document.removeEventListener('pointercancel', onPointerUp);
if (moved && path) {
savePosition(path);
}
pointerId = null;
setTimeout(() => {
isDragging.value = false;
moved = false;
}, 0);
}
function onPointerUp(e: PointerEvent) {
if (pointerId !== e.pointerId) return;
endDrag(routePath.value);
}
function onPointerDown(e: PointerEvent, el: HTMLElement | null) {
if (e.button !== 0) return;
if (el) {
const rect = el.getBoundingClientRect();
setButtonSize(rect.width, rect.height);
}
moved = false;
isDragging.value = false;
pointerId = e.pointerId;
startX = e.clientX;
startY = e.clientY;
startLeft = pos.left;
startTop = pos.top;
(e.currentTarget as HTMLElement)?.setPointerCapture?.(e.pointerId);
document.addEventListener('pointermove', onPointerMove);
document.addEventListener('pointerup', onPointerUp);
document.addEventListener('pointercancel', onPointerUp);
}
function wasDragged() {
return moved;
}
return {
pos,
isDragging,
setButtonSize,
initPosition,
applyDefaultPosition,
onPointerDown,
wasDragged,
clampPosition,
};
}

View File

@@ -13,6 +13,9 @@
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计全局发起审批悬浮按钮----- -->
<ApprovalLaunchFloat />
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计全局发起审批悬浮按钮----- -->
<!-- update-begin---author:GHT ---date:2026-06-04 forMESToDing审批配置全局钉钉审批模板绑定发起按钮----- -->
<DingTplLaunchFloat />
<!-- update-end---author:GHT ---date:2026-06-04 forMESToDing审批配置全局钉钉审批模板绑定发起按钮----- -->
<!-- update-begin---author:GHT ---date:2026-05-29 forQH-MES审批流设计全局审批流程设计悬浮按钮----- -->
<ApprovalDesignFloat />
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计全局审批流程设计悬浮按钮----- -->
@@ -48,6 +51,9 @@
//update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】全局审批流程设计悬浮按钮-----
ApprovalDesignFloat: createAsyncComponent(() => import('/@/components/ApprovalDesign/index.vue')),
//update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】全局审批流程设计悬浮按钮-----
//update-begin---author:GHT ---date:2026-06-04 for【MESToDing审批配置】全局钉钉审批模板绑定发起按钮-----
DingTplLaunchFloat: createAsyncComponent(() => import('/@/components/DingTplLaunch/index.vue')),
//update-end---author:GHT ---date:2026-06-04 for【MESToDing审批配置】全局钉钉审批模板绑定发起按钮-----
LayoutHeader,
LayoutContent,
LayoutSideBar,

View File

@@ -53,7 +53,10 @@
</template>
<!-- update-end---author:GHT ---date:2026-05-29 forQH-MES审批流设计取单据字段审批人配置----- -->
<a-form-item v-if="form.props.approverType === 'user'" label="指定成员">
<JSelectUser v-model:value="form.props.userText" :disabled="readonly" />
<JSelectUser v-model:value="form.props.userText" :disabled="readonly" @change="onUserTextChange" />
<div v-if="form.props.multiMode === 'none'" style="font-size:12px;color:#888;margin-top:3px">
单人审批只能指定一位已自动保留第一位
</div>
</a-form-item>
<a-form-item v-if="form.props.approverType === 'role'" label="指定角色">
<ApiSelect mode="multiple" v-model:value="form.props.roleList" :api="roleApi" :params="{ pageSize: 1000 }" resultField="records" labelField="roleName" valueField="id" :disabled="readonly" placeholder="请选择角色" />
@@ -65,12 +68,16 @@
<a-select-option :value="3">第3级主管</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="多人审批方式">
<a-radio-group v-model:value="form.props.multiMode" :disabled="readonly">
<a-radio value="and">会签需全部同意</a-radio>
<a-form-item label="审批方式">
<a-radio-group v-model:value="form.props.multiMode" :disabled="readonly" @change="onMultiModeChange">
<a-radio value="none">单人审批</a-radio>
<a-radio value="and">会签全部同意</a-radio>
<a-radio value="or">或签一人同意</a-radio>
<a-radio value="sequence">依次审批</a-radio>
</a-radio-group>
<div v-if="form.props.multiMode === 'none'" style="font-size:12px;color:#ff7a00;margin-top:4px">
单人审批仅允许指定一位审批人对应钉钉 actionType = NONE
</div>
</a-form-item>
<a-form-item label="审批人为空时">
<a-radio-group v-model:value="form.props.emptyStrategy" :disabled="readonly">
@@ -193,7 +200,7 @@
</template>
<script lang="ts" setup>
import { computed, ref, inject, watch } from 'vue';
import { computed, ref, inject, watch, nextTick } from 'vue';
import type { Ref } from 'vue';
import { cloneDeep } from 'lodash-es';
import { defHttp } from '/@/utils/http/axios';
@@ -317,12 +324,49 @@
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') {
trimToSingleUser();
}
}
/** userText 变化时,若当前是单人模式则裁剪 */
function onUserTextChange() {
if (!form.value) return;
if (form.value.props.multiMode === 'none') {
nextTick(trimToSingleUser);
}
}
function trimToSingleUser() {
if (!form.value) return;
const ut: string = form.value.props.userText || '';
const parts = ut.split(',').map((s) => s.trim()).filter(Boolean);
if (parts.length > 1) {
form.value.props.userText = parts[0];
}
}
function onClose() {
open.value = false;
}
function onConfirm() {
if (node.value && form.value) {
// 单人审批最终兜底校验
if (
node.value.type === 'approver' &&
form.value.props.multiMode === 'none' &&
form.value.props.approverType === 'user'
) {
const names = (form.value.props.userText || '').split(',').filter(Boolean);
if (names.length > 1) {
form.value.props.userText = names[0];
createMessage.warning('单人审批已自动保留第一位审批人');
}
}
node.value.name = form.value.name;
node.value.props = cloneDeep(form.value.props);
emit('confirm', node.value);

View File

@@ -55,7 +55,7 @@ export function createApproverNode(): FlowNode {
userText: '',
roleList: [],
leaderLevel: 1,
// 多人审批方式 and会签(需全部同意) / or或签(一人同意) / sequence依次审批
// 多人审批方式 and会签 / or或签 / sequence依次 / none单人审批(仅一人)
multiMode: 'and',
// 审批人为空时 pass自动通过 / admin转交管理员 / stop终止
emptyStrategy: 'admin',
@@ -169,13 +169,19 @@ export function nodeSummary(node: FlowNode): string {
}
if (node.type === 'approver') {
const t = node.props.approverType;
if (t === 'self') return '发起人自己';
if (t === 'leader') return `${node.props.leaderLevel || 1}级主管`;
if (t === 'role') return node.props.roleList?.length ? `角色审批${node.props.roleList.length}` : '请设置审批角色';
const mode = node.props.multiMode;
const modeTag = mode === 'none' ? '单人' : mode === 'or' ? '或签' : mode === 'sequence' ? '依次' : '会签';
if (t === 'self') return `发起人自己${modeTag}`;
if (t === 'leader') return `${node.props.leaderLevel || 1}级主管(${modeTag}`;
if (t === 'role') return node.props.roleList?.length ? `角色审批(${node.props.roleList.length}人)` : '请设置审批角色';
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】取单据字段审批人摘要-----
if (t === 'field') return `取单据字段:${node.props.fieldLabel || node.props.fieldName || '未指定字段'}`;
// update-end---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】取单据字段审批人摘要-----
return node.props.userText ? `指定成员:${node.props.userText}` : '请设置审批人';
if (!node.props.userText) return '请设置审批人';
const names = node.props.userText.split(',').filter(Boolean);
return mode === 'none'
? `单人审批:${names[0] || '请选择'}`
: `${modeTag}${names.length}人):${names.slice(0, 2).join('、')}${names.length > 2 ? '...' : ''}`;
}
if (node.type === 'cc') {
// update-begin---author:GHT ---date:2026-05-29 for【QH-MES审批流设计】取单据字段抄送人摘要-----

View File

@@ -0,0 +1,31 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
canLaunch = '/xslmes/approvalGate/canLaunch',
canLaunchBatch = '/xslmes/approvalGate/canLaunchBatch',
history = '/xslmes/approvalGate/history',
}
export interface ApprovalGateVo {
allowed?: boolean;
reason?: string;
bizTable?: string;
bizDataId?: string;
latestRecordId?: string;
latestStatus?: string;
latestChannel?: string;
latestChannelText?: string;
latestStatusText?: string;
}
/** 检查是否允许发起审批 */
export const checkCanLaunch = (params: { bizTable: string; bizDataId: string }) =>
defHttp.get<ApprovalGateVo>({ url: Api.canLaunch, params });
/** 批量检查是否允许发起审批 */
export const checkCanLaunchBatch = (params: { bizTable: string; bizDataIds: string[] }) =>
defHttp.post<ApprovalGateVo[]>({ url: Api.canLaunchBatch, params });
/** 查询业务单据审批台账历史 */
export const getApprovalHistory = (params: { bizTable: string; bizDataId: string }) =>
defHttp.get({ url: Api.history, params });

View File

@@ -64,6 +64,19 @@ export const thirdAppFormSchema: FormSchema[] = [
unCheckedValue: 0
},
defaultValue: 1
},{
label: 'Stream事件推送',
field: 'streamEnabled',
component: 'Switch',
ifShow: ({ values }) => values.thirdType === 'dingtalk',
componentProps: {
checkedChildren: '已启用',
checkedValue: 1,
unCheckedChildren: '未启用',
unCheckedValue: 0,
},
helpMessage: '开启后,此应用的 AppKey/AppSecret 将用于接收钉钉 Stream 事件推送(审批结果等)。同一企业只需开启一个',
defaultValue: 0,
},{
label: '租户id',
field: 'tenantId',

View File

@@ -45,6 +45,14 @@
<a-input-password v-model:value="appConfigData.clientSecret" readonly />
</div>
</div>
<div class="flex-flow">
<div class="base-title">Stream推送</div>
<div class="base-message" style="display:flex;align-items:center;height:50px;">
<a-tag :color="appConfigData.streamEnabled === 1 ? 'green' : 'default'">
{{ appConfigData.streamEnabled === 1 ? '已设为Stream主配置' : '未启用' }}
</a-tag>
</div>
</div>
<div style="margin-top: 20px; width: 100%; text-align: right">
<a-button @click="dingEditClick">编辑</a-button>
<a-button v-if="appConfigData.id" @click="cancelBindClick" danger style="margin-left: 10px">取消绑定</a-button>

View File

@@ -0,0 +1,13 @@
import { defHttp } from '/@/utils/http/axios';
enum Api {
list = '/xslmes/mesXslApprovalRecord/list',
queryById = '/xslmes/mesXslApprovalRecord/queryById',
exportXls = '/xslmes/mesXslApprovalRecord/exportXls',
}
export const getExportUrl = Api.exportXls;
export const list = (params) => defHttp.get({ url: Api.list, params });
export const queryById = (params: { id: string }) => defHttp.get({ url: Api.queryById, params });

View File

@@ -0,0 +1,78 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '业务单据', align: 'center', dataIndex: 'bizTableName', width: 120, ellipsis: true },
{ title: '业务标题', align: 'center', dataIndex: 'bizTitle', width: 180, ellipsis: true },
{ title: '审批通道', align: 'center', dataIndex: 'channel_dictText', width: 100 },
{ title: '状态', align: 'center', dataIndex: 'status_dictText', width: 100 },
{ title: '发起次数', align: 'center', dataIndex: 'launchNo', width: 80 },
{ title: '发起人', align: 'center', dataIndex: 'applyUserName', width: 100 },
{
title: '发起时间',
align: 'center',
dataIndex: 'applyTime',
width: 165,
customRender: ({ text }) => (!text ? '' : String(text).length > 19 ? String(text).substring(0, 19) : text),
},
{
title: '办结时间',
align: 'center',
dataIndex: 'finishTime',
width: 165,
customRender: ({ text }) => (!text ? '' : String(text).length > 19 ? String(text).substring(0, 19) : text),
},
{ title: 'MES审批流', align: 'center', dataIndex: 'flowName', width: 140, ellipsis: true },
{ title: '钉钉模板', align: 'center', dataIndex: 'templateName', width: 140, ellipsis: true },
{ title: '外部实例ID', align: 'center', dataIndex: 'externalInstanceId', width: 160, ellipsis: true },
{ title: '备注', align: 'center', dataIndex: 'remark', width: 160, ellipsis: true },
];
export const searchFormSchema: FormSchema[] = [
{ label: '业务单据', field: 'bizTableName', component: 'Input', colProps: { span: 6 } },
{ label: '业务标题', field: 'bizTitle', component: 'Input', colProps: { span: 6 } },
{
label: '审批通道',
field: 'channel',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_approval_channel', placeholder: '请选择' },
colProps: { span: 6 },
},
{
label: '状态',
field: 'status',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_approval_record_status', placeholder: '请选择' },
colProps: { span: 6 },
},
{ label: '发起人', field: 'applyUserName', component: 'Input', colProps: { span: 6 } },
];
export const detailFormSchema: FormSchema[] = [
{ label: '', field: 'id', component: 'Input', show: false },
{ label: '业务单据', field: 'bizTableName', component: 'Input', componentProps: { readonly: true } },
{ label: '业务表名', field: 'bizTable', component: 'Input', componentProps: { readonly: true } },
{ label: '业务数据ID', field: 'bizDataId', component: 'Input', componentProps: { readonly: true } },
{ label: '业务标题', field: 'bizTitle', component: 'Input', componentProps: { readonly: true } },
{ label: '业务编码', field: 'bizCode', component: 'Input', componentProps: { readonly: true } },
{
label: '审批通道',
field: 'channel',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_approval_channel', disabled: true },
},
{
label: '状态',
field: 'status',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_xsl_approval_record_status', disabled: true },
},
{ label: '发起次数', field: 'launchNo', component: 'InputNumber', componentProps: { disabled: true, style: { width: '100%' } } },
{ label: '发起人账号', field: 'applyUser', component: 'Input', componentProps: { readonly: true } },
{ label: '发起人姓名', field: 'applyUserName', component: 'Input', componentProps: { readonly: true } },
{ label: '发起时间', field: 'applyTime', component: 'Input', componentProps: { readonly: true } },
{ label: '办结时间', field: 'finishTime', component: 'Input', componentProps: { readonly: true } },
{ label: 'MES审批流', field: 'flowName', component: 'Input', componentProps: { readonly: true } },
{ label: '钉钉模板', field: 'templateName', component: 'Input', componentProps: { readonly: true } },
{ label: '外部实例ID', field: 'externalInstanceId', component: 'Input', componentProps: { readonly: true } },
{ label: '备注', field: 'remark', component: 'InputTextArea', componentProps: { rows: 3, readonly: true } },
];

View File

@@ -0,0 +1,68 @@
<template>
<div>
<BasicTable @register="registerTable">
<template #tableTitle>
<a-button
type="primary"
v-auth="'xslmes:mes_xsl_approval_record:exportXls'"
preIcon="ant-design:export-outlined"
@click="onExportXls"
>
导出
</a-button>
</template>
<template #action="{ record }">
<TableAction
:actions="[
{
label: '详情',
onClick: handleDetail.bind(null, record),
auth: 'xslmes:mes_xsl_approval_record:list',
},
]"
/>
</template>
</BasicTable>
<MesXslApprovalRecordDetailModal @register="registerModal" />
</div>
</template>
<script lang="ts" name="xslmes-mesXslApprovalRecord" setup>
import { BasicTable, TableAction } from '/@/components/Table';
import { useModal } from '/@/components/Modal';
import { useListPage } from '/@/hooks/system/useListPage';
import MesXslApprovalRecordDetailModal from './components/MesXslApprovalRecordDetailModal.vue';
import { columns, searchFormSchema } from './MesXslApprovalRecord.data';
import { list, getExportUrl } from './MesXslApprovalRecord.api';
const [registerModal, { openModal }] = useModal();
const { tableContext, onExportXls } = useListPage({
tableProps: {
title: '审批台账',
api: list,
columns,
canResize: true,
formConfig: {
schemas: searchFormSchema,
labelWidth: 100,
autoSubmitOnEnter: true,
showAdvancedButton: true,
},
actionColumn: {
width: 100,
fixed: 'right',
},
},
exportConfig: {
name: 'MES审批台账',
url: getExportUrl,
},
});
const [registerTable] = tableContext;
function handleDetail(record: Recordable) {
openModal(true, { record });
}
</script>

View File

@@ -0,0 +1,33 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" title="审批台账详情" width="720px" :showOkBtn="false" cancelText="关闭">
<BasicForm @register="registerForm" />
</BasicModal>
</template>
<script lang="ts" setup>
import { ref, unref } from 'vue';
import { BasicModal, useModalInner } from '/@/components/Modal';
import { BasicForm, useForm } from '/@/components/Form';
import { detailFormSchema } from '../MesXslApprovalRecord.data';
import { queryById } from '../MesXslApprovalRecord.api';
const [registerForm, { setFieldsValue, resetFields }] = useForm({
labelWidth: 110,
schemas: detailFormSchema,
showActionButtonGroup: false,
baseColProps: { span: 24 },
});
const recordId = ref('');
const [registerModal, { setModalProps }] = useModalInner(async (data) => {
await resetFields();
setModalProps({ confirmLoading: false });
recordId.value = data?.record?.id || '';
if (!unref(recordId)) {
return;
}
const res = await queryById({ id: unref(recordId) });
await setFieldsValue({ ...res });
});
</script>

View File

@@ -0,0 +1,38 @@
import { defHttp } from '/@/utils/http/axios';
const BASE = '/xslmes/dingTplBind';
export const getMenuTree = () => defHttp.get({ url: `${BASE}/menuTree` });
export const getTplList = () => defHttp.get({ url: `${BASE}/tplList` });
export const getBizFields = (bizCode: string) =>
defHttp.get({ url: `${BASE}/bizFields`, params: { bizCode } });
export const getDetailSlots = (bizCode: string) =>
defHttp.get({ url: `${BASE}/detailSlots`, params: { bizCode } });
export const getDetailFields = (bizCode: string, detailProperty: string, slotKind = 'LIST') =>
defHttp.get({ url: `${BASE}/detailFields`, params: { bizCode, detailProperty, slotKind } });
export const getBindList = () => defHttp.get({ url: `${BASE}/list` });
export const getBindingByRoute = (routePath: string) =>
defHttp.get({ url: `${BASE}/bindingByRoute`, params: { routePath } }, { errorMessageMode: 'none' });
export const getByBizCode = (bizCode: string) =>
defHttp.get({ url: `${BASE}/getByBizCode`, params: { bizCode } });
export const saveBind = (data: {
bizCode: string;
bizName?: string;
templateId: string;
fieldMappingJson: string;
}) => defHttp.post({ url: `${BASE}/save`, data });
export const deleteBind = (id: string) =>
defHttp.delete({ url: `${BASE}/delete`, params: { id } });
/** 复用现有接口:拉取钉钉模板表单字段(含 dingFields */
export const getTemplateDetail = (id: string) =>
defHttp.get({ url: '/xslmes/mesXslDingProcessTpl/getTemplateDetail', params: { id } });

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,83 @@
import { defHttp } from '/@/utils/http/axios';
import { useMessage } from '/@/hooks/web/useMessage';
const { createConfirm } = useMessage();
enum Api {
list = '/xslmes/mesXslDingProcessTpl/list',
save = '/xslmes/mesXslDingProcessTpl/add',
edit = '/xslmes/mesXslDingProcessTpl/edit',
deleteOne = '/xslmes/mesXslDingProcessTpl/delete',
deleteBatch = '/xslmes/mesXslDingProcessTpl/deleteBatch',
importExcel = '/xslmes/mesXslDingProcessTpl/importExcel',
exportXls = '/xslmes/mesXslDingProcessTpl/exportXls',
syncFromDingtalk = '/xslmes/mesXslDingProcessTpl/syncFromDingtalk',
batchImport = '/xslmes/mesXslDingProcessTpl/batchImport',
getTemplateDetail = '/xslmes/mesXslDingProcessTpl/getTemplateDetail',
saveFieldMapping = '/xslmes/mesXslDingProcessTpl/saveFieldMapping',
addNewTemplate = '/xslmes/mesXslDingProcessTpl/addNewTemplate',
createDingTemplate = '/xslmes/mesXslDingProcessTpl/createDingTemplate',
updateDingTemplate = '/xslmes/mesXslDingProcessTpl/updateDingTemplate',
launchApproval = '/xslmes/mesXslDingProcessTpl/launchApproval',
bindFlow = '/xslmes/mesXslDingProcessTpl/bindFlow',
approvalFlowList = '/xslmes/approvalFlow/list',
previewFlowApprovers = '/xslmes/mesXslDingProcessTpl/previewFlowApprovers',
}
export const getExportUrl = Api.exportXls;
export const getImportUrl = Api.importExcel;
export const list = (params) => defHttp.get({ url: Api.list, 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, data: params }, { joinParamsToUrl: true }).then(() => handleSuccess()),
});
};
export const saveOrUpdate = (params, isUpdate) =>
defHttp.post({ url: isUpdate ? Api.edit : Api.save, params }, { successMessageMode: 'none' });
/** 新增审批模板草稿(返回含 id 的完整记录) */
export const addNewTemplate = (params) =>
defHttp.post({ url: Api.addNewTemplate, params }, { successMessageMode: 'none' });
export const syncFromDingtalk = () => defHttp.get({ url: Api.syncFromDingtalk }, { successMessageMode: 'none' });
export const batchImport = (params) => defHttp.post({ url: Api.batchImport, params }, { successMessageMode: 'none' });
export const getTemplateDetail = (id: string) =>
defHttp.get({ url: Api.getTemplateDetail, params: { id } }, { successMessageMode: 'none' });
export const queryById = (id: string) =>
defHttp.get({ url: '/xslmes/mesXslDingProcessTpl/queryById', params: { id } }, { successMessageMode: 'none' });
export const saveFieldMapping = (params) =>
defHttp.post({ url: Api.saveFieldMapping, params }, { successMessageMode: 'none' });
export const createDingTemplate = (params) =>
defHttp.post({ url: Api.createDingTemplate, params }, { successMessageMode: 'none' });
export const updateDingTemplate = (params) =>
defHttp.post({ url: Api.updateDingTemplate, params }, { successMessageMode: 'none' });
export const launchApproval = (params) =>
defHttp.post({ url: Api.launchApproval, params }, { successMessageMode: 'none' });
export const bindApprovalFlow = (params: { id: string; flowId: string }) =>
defHttp.post({ url: Api.bindFlow, params }, { successMessageMode: 'none' });
export const getApprovalFlowList = (params?) =>
defHttp.get({ url: Api.approvalFlowList, params }, { successMessageMode: 'none' });
export const previewFlowApprovers = (flowId: string) =>
defHttp.get({ url: Api.previewFlowApprovers, params: { flowId } }, { successMessageMode: 'none' });

View File

@@ -0,0 +1,103 @@
import { BasicColumn, FormSchema } from '/@/components/Table';
export const columns: BasicColumn[] = [
{ title: '模板名称', align: 'center', dataIndex: 'tplName', width: 180 },
{ title: '钉钉processCode', align: 'center', dataIndex: 'processCode', width: 280 },
{ title: '业务类型标识', align: 'center', dataIndex: 'bizType', width: 140 },
{ title: '状态', align: 'center', dataIndex: 'status_dictText', width: 90 },
{ title: '排序', align: 'center', dataIndex: 'sortNo', width: 80 },
{ title: '备注', align: 'center', dataIndex: 'remark', width: 200 },
{ title: '创建时间', align: 'center', dataIndex: 'createTime', width: 160 },
];
export const searchFormSchema: FormSchema[] = [
{
label: '模板名称',
field: 'tplName',
component: 'JInput',
colProps: { span: 6 },
},
{
label: '钉钉processCode',
field: 'processCode',
component: 'JInput',
colProps: { span: 6 },
},
{
label: '业务类型标识',
field: 'bizType',
component: 'JInput',
colProps: { span: 6 },
},
{
label: '状态',
field: 'status',
component: 'JDictSelectTag',
componentProps: { dictCode: 'mes_ding_tpl_status' },
colProps: { span: 6 },
},
];
export const formSchema: FormSchema[] = [
{ label: '', field: 'id', component: 'Input', show: false },
{
label: '模板名称',
field: 'tplName',
component: 'Input',
componentProps: { placeholder: '请输入模板名称' },
dynamicRules: () => [{ required: true, message: '请输入模板名称!' }],
},
{
label: 'processCode',
field: 'processCode',
component: 'Input',
componentProps: {
placeholder: '钉钉返回的 processCode「新增审批模板」流程中可留空设计器创建钉钉模板后自动回填',
},
},
{
label: '业务类型标识',
field: 'bizType',
component: 'Input',
componentProps: { placeholder: '供审批流关联使用,如 mixer_ps、formula_spec' },
},
{
label: '表单字段映射',
field: 'formFields',
component: 'InputTextArea',
componentProps: {
placeholder: '{"PS编码":"psCode","类型":"type"} —— 钉钉模板字段名→MES字段名',
rows: 4,
},
},
{
label: '状态',
field: 'status',
component: 'JDictSelectTag',
componentProps: {
dictCode: 'mes_ding_tpl_status',
placeholder: '请选择状态',
getPopupContainer: () => document.body,
},
},
{
label: '排序',
field: 'sortNo',
component: 'InputNumber',
componentProps: { placeholder: '请输入排序值', style: 'width:100%' },
},
{
label: '备注',
field: 'remark',
component: 'InputTextArea',
componentProps: { placeholder: '请输入备注', rows: 2 },
},
];
export const superQuerySchema = {
tplName: { title: '模板名称', order: 0, view: 'text', type: 'string' },
processCode: { title: 'processCode', order: 1, view: 'text', type: 'string' },
bizType: { title: '业务类型标识', order: 2, view: 'text', type: 'string' },
status: { title: '状态', order: 3, view: 'list', type: 'string', dictCode: 'mes_ding_tpl_status' },
sortNo: { title: '排序', order: 4, view: 'number', type: 'number' },
};

View File

@@ -0,0 +1,385 @@
<template>
<div>
<BasicTable @register="registerTable" :rowSelection="rowSelection">
<template #tableTitle>
<!--update-begin---author:GHT ---date:2026-06-04 forMESToDing审批配置新增审批模板创建草稿+打开设计器-->
<a-button
type="primary"
v-auth="'xslmes:mes_xsl_ding_process_tpl:add'"
preIcon="ant-design:dingtalk-outlined"
@click="handleAddNewTemplate"
>
新增审批模板
</a-button>
<!--update-end---author:GHT ---date:2026-06-04 forMESToDing审批配置新增审批模板创建草稿+打开设计器-->
<a-button v-auth="'xslmes:mes_xsl_ding_process_tpl:add'" preIcon="ant-design:plus-outlined" @click="handleAdd">
快速录入
</a-button>
<a-button type="primary" v-auth="'xslmes:mes_xsl_ding_process_tpl:exportXls'" preIcon="ant-design:export-outlined" @click="onExportXls">
导出
</a-button>
<j-upload-button type="primary" v-auth="'xslmes:mes_xsl_ding_process_tpl:importExcel'" preIcon="ant-design:import-outlined" @click="onImportXls">
导入
</j-upload-button>
<!--update-begin---author:GHT ---date:2026-06-03 forMESToDing审批配置从钉钉同步按钮-->
<a-button preIcon="ant-design:sync-outlined" :loading="syncLoading" @click="handleSyncFromDingtalk">
从钉钉同步
</a-button>
<!--update-end---author:GHT ---date:2026-06-03 forMESToDing审批配置从钉钉同步按钮-->
<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_ding_process_tpl:deleteBatch'">
批量操作
<Icon icon="mdi:chevron-down" />
</a-button>
</a-dropdown>
<super-query :config="superQueryConfig" @search="handleSuperQuery" />
</template>
<template #action="{ record }">
<TableAction :actions="getTableAction(record)" :dropDownActions="getDropDownAction(record)" />
</template>
</BasicTable>
<MesXslDingProcessTplModal @register="registerModal" @success="handleSuccess" />
<!--update-begin---author:GHT ---date:2026-06-03 forMESToDing审批配置钉钉字段详情弹窗只读-->
<a-modal
v-model:open="schemaVisible"
title="钉钉模板字段详情"
width="720px"
:footer="null"
destroy-on-close
@cancel="schemaVisible = false"
>
<a-spin :spinning="schemaLoading">
<template v-if="schemaData">
<a-descriptions :column="2" bordered size="small" style="margin-bottom:14px">
<a-descriptions-item label="模板名称">{{ schemaData.tplName }}</a-descriptions-item>
<a-descriptions-item label="业务类型">{{ schemaData.bizType || '—' }}</a-descriptions-item>
<a-descriptions-item label="processCode" :span="2">
<a-typography-text v-if="schemaData.processCode" code copyable>{{ schemaData.processCode }}</a-typography-text>
<a-tag v-else color="orange">未创建</a-tag>
</a-descriptions-item>
</a-descriptions>
<a-alert v-if="schemaData.schemaError" type="warning" :message="schemaData.schemaError" show-icon style="margin-bottom:12px" />
<template v-if="schemaData.dingFields?.length">
<div style="font-weight:600;margin-bottom:8px">
钉钉表单字段
<a-tag color="blue" style="margin-left:6px;font-weight:400">{{ schemaData.dingFields.length }} </a-tag>
</div>
<a-table
:dataSource="schemaData.dingFields"
:columns="dingFieldColumns"
:pagination="false"
size="small"
:rowKey="(_, i) => i"
:scroll="{ y: 300 }"
/>
</template>
<div v-else-if="!schemaData.schemaError" style="color:#999;text-align:center;padding:20px">
未从钉钉获取到字段模板可能无 processCode 或字段为空
</div>
<a-collapse style="margin-top:14px" :bordered="false">
<a-collapse-panel key="json" header="原始 JSON 数据" style="background:#fafafa">
<pre style="font-size:12px;margin:0;max-height:200px;overflow:auto;white-space:pre-wrap;word-break:break-all">{{ JSON.stringify(schemaData, null, 2) }}</pre>
</a-collapse-panel>
</a-collapse>
</template>
</a-spin>
</a-modal>
<!--update-end---author:GHT ---date:2026-06-03 forMESToDing审批配置钉钉字段详情弹窗只读-->
<!--update-begin---author:GHT ---date:2026-06-03 forMESToDing审批配置表单设计器-->
<DingTplDesigner ref="designerRef" @success="handleSuccess" />
<DingTplCreateModal ref="createModalRef" @success="onNewTemplateCreated" />
<!--update-end---author:GHT ---date:2026-06-03 forMESToDing审批配置表单设计器-->
<!--update-begin---author:GHT ---date:2026-06-03 forMESToDing审批配置手动填表发起钉钉审批-->
<DingApprovalLaunchModal ref="launchModalRef" @success="handleLaunchSuccess" />
<!--update-end---author:GHT ---date:2026-06-03 forMESToDing审批配置手动填表发起钉钉审批-->
<!--update-begin---author:GHT ---date:2026-06-03 forMESToDing审批配置钉钉同步结果弹窗-->
<a-modal
v-model:open="syncVisible"
title="从钉钉同步审批模板"
width="780px"
:confirmLoading="importLoading"
okText="导入选中"
cancelText="取消"
@ok="handleBatchImport"
@cancel="syncVisible = false"
>
<a-spin :spinning="syncLoading">
<a-alert v-if="syncList.length === 0 && !syncLoading" message="未获取到钉钉审批模板,请确认钉钉配置及账号绑定" type="warning" show-icon style="margin-bottom:12px" />
<a-table
v-if="syncList.length > 0"
:dataSource="syncList"
:columns="syncColumns"
:rowSelection="{ type: 'checkbox', selectedRowKeys: syncSelectedKeys, onChange: onSyncSelectChange }"
:rowKey="(r) => r.processCode"
:pagination="false"
size="small"
:scroll="{ y: 380 }"
>
<template #bodyCell="{ column, record }">
<template v-if="column.dataIndex === 'imported'">
<a-tag v-if="record.linkDraft" color="orange">待回填本地</a-tag>
<a-tag v-else :color="record.imported ? 'green' : 'default'">{{ record.imported ? '已导入' : '未导入' }}</a-tag>
</template>
</template>
</a-table>
</a-spin>
</a-modal>
<!--update-end---author:GHT ---date:2026-06-03 forMESToDing审批配置钉钉同步结果弹窗-->
</div>
</template>
<script lang="ts" name="xslmes-mesXslDingProcessTpl" setup>
import { ref, reactive } 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 Icon from '/@/components/Icon';
import MesXslDingProcessTplModal from './components/MesXslDingProcessTplModal.vue';
import DingTplDesigner from './components/DingTplDesigner.vue';
import DingTplCreateModal from './components/DingTplCreateModal.vue';
//update-begin---author:GHT ---date:2026-06-03 for【MESToDing审批配置】手动填表发起钉钉审批
import DingApprovalLaunchModal from './components/DingApprovalLaunchModal.vue';
//update-end---author:GHT ---date:2026-06-03 for【MESToDing审批配置】手动填表发起钉钉审批
import { columns, searchFormSchema, superQuerySchema } from './MesXslDingProcessTpl.data';
import { list, deleteOne, batchDelete, getImportUrl, getExportUrl, syncFromDingtalk, batchImport, getTemplateDetail } from './MesXslDingProcessTpl.api';
const { createMessage } = useMessage();
const queryParam = reactive<any>({});
const [registerModal, { openModal }] = useModal();
const { tableContext, onExportXls, onImportXls } = useListPage({
tableProps: {
title: '钉钉审批模板配置',
api: list,
columns,
canResize: true,
formConfig: {
schemas: searchFormSchema,
autoSubmitOnEnter: true,
showAdvancedButton: true,
},
actionColumn: {
title: '操作',
dataIndex: 'action',
width: 220,
fixed: 'right',
slots: { customRender: 'action' },
},
beforeFetch: (params) => Object.assign(params, queryParam),
},
exportConfig: { name: '钉钉审批模板配置', url: getExportUrl, params: queryParam },
importConfig: { url: getImportUrl, success: handleSuccess },
});
const [registerTable, { reload }, { rowSelection, selectedRowKeys }] = tableContext;
const superQueryConfig = reactive(superQuerySchema);
function handleSuperQuery(params) {
Object.keys(params).forEach((k) => (queryParam[k] = params[k]));
reload();
}
function handleAdd() {
openModal(true, { isUpdate: false, showFooter: true });
}
const createModalRef = ref();
function handleAddNewTemplate() {
createModalRef.value?.open();
}
function onNewTemplateCreated({ record, openDesigner }: { record: Recordable; openDesigner: boolean }) {
reload();
if (openDesigner && record?.id) {
designerRef.value?.open(record);
}
}
function handleEdit(record: Recordable) {
openModal(true, { record, isUpdate: true, showFooter: true });
}
function handleDetail(record: Recordable) {
openModal(true, { record, isUpdate: true, showFooter: false });
}
async function handleDelete(record) {
await deleteOne({ id: record.id }, handleSuccess);
}
async function batchHandleDelete() {
await batchDelete({ ids: selectedRowKeys.value }, handleSuccess);
}
function handleSuccess() {
(selectedRowKeys.value = []) && reload();
}
function getTableAction(record) {
return [
{ label: '编辑', onClick: handleEdit.bind(null, record), auth: 'xslmes:mes_xsl_ding_process_tpl:edit' },
//update-begin---author:GHT ---date:2026-06-03 for【MESToDing审批配置】操作列新增发起审批按钮
{
label: '发起审批',
icon: 'ant-design:send-outlined',
color: 'success',
disabled: !record.processCode,
tooltip: record.processCode ? '手动填表后发起钉钉审批' : '请先配置 processCode',
onClick: handleLaunchApproval.bind(null, record),
},
//update-end---author:GHT ---date:2026-06-03 for【MESToDing审批配置】操作列新增发起审批按钮
];
}
function getDropDownAction(record) {
const actions: any[] = [
{ label: '详情', onClick: handleDetail.bind(null, record) },
//update-begin---author:GHT ---date:2026-06-03 for【MESToDing审批配置】新增设计模板入口
{ label: '设计模板', onClick: handleDesignTemplate.bind(null, record), icon: 'ant-design:layout-outlined' },
];
if (!record.processCode) {
actions.push({
label: '创建钉钉模板',
icon: 'ant-design:dingtalk-outlined',
onClick: handleDesignTemplate.bind(null, record),
auth: 'xslmes:mes_xsl_ding_process_tpl:edit',
});
}
actions.push(
{ label: '查看钉钉字段', onClick: handleShowDingSchema.bind(null, record), icon: 'ant-design:dingtalk-outlined' },
//update-end---author:GHT ---date:2026-06-03 for【MESToDing审批配置】新增设计模板入口
{
label: '删除',
popConfirm: { title: '是否确认删除', confirm: handleDelete.bind(null, record), placement: 'topLeft' },
auth: 'xslmes:mes_xsl_ding_process_tpl:delete',
},
);
return actions;
}
// ===== 手动填表发起钉钉审批 =====
//update-begin---author:GHT ---date:2026-06-03 for【MESToDing审批配置】手动填表发起钉钉审批
const launchModalRef = ref();
function handleLaunchApproval(record: Recordable) {
if (!record.processCode) {
createMessage.warning('该模板尚未配置 processCode请先完成模板配置');
return;
}
launchModalRef.value?.open(record);
}
function handleLaunchSuccess() {
// 发起成功后可按需刷新列表(本期无需刷新,审批实例不在此列表)
}
//update-end---author:GHT ---date:2026-06-03 for【MESToDing审批配置】手动填表发起钉钉审批
// ===== 表单设计器 =====
const designerRef = ref();
function handleDesignTemplate(record: Recordable) {
designerRef.value?.open(record);
}
// ===== 钉钉字段详情(只读 schema 查看器)=====
const schemaVisible = ref(false);
const schemaLoading = ref(false);
const schemaData = ref<any>(null);
const dingFieldColumns = [
{ title: '控件标题(钉钉字段名)', dataIndex: 'label' },
{ title: '控件类型', dataIndex: 'componentName', width: 160 },
{ title: '必填', dataIndex: 'required', width: 70 },
];
async function handleShowDingSchema(record: Recordable) {
schemaVisible.value = true;
schemaLoading.value = true;
schemaData.value = null;
try {
schemaData.value = await getTemplateDetail(record.id);
} catch (e: any) {
createMessage.error(e?.message || '获取模板字段失败');
schemaVisible.value = false;
} finally {
schemaLoading.value = false;
}
}
// ===== 从钉钉同步 =====
const syncVisible = ref(false);
const syncLoading = ref(false);
const importLoading = ref(false);
const syncList = ref<any[]>([]);
const syncSelectedKeys = ref<string[]>([]);
const syncColumns = [
{ title: '模板名称', dataIndex: 'name', width: 200 },
{ title: 'processCode', dataIndex: 'processCode', width: 300 },
{ title: '描述', dataIndex: 'description', ellipsis: true },
{ title: '状态', dataIndex: 'imported', width: 90 },
];
async function handleSyncFromDingtalk() {
syncVisible.value = true;
syncLoading.value = true;
syncList.value = [];
syncSelectedKeys.value = [];
try {
const data = await syncFromDingtalk();
syncList.value = data || [];
syncSelectedKeys.value = (syncList.value as any[])
.filter((r) => !r.imported || r.linkDraft)
.map((r) => r.processCode);
} catch (e: any) {
createMessage.error(e?.message || '从钉钉同步失败');
syncVisible.value = false;
} finally {
syncLoading.value = false;
}
}
function onSyncSelectChange(keys: string[]) {
syncSelectedKeys.value = keys;
}
async function handleBatchImport() {
if (syncSelectedKeys.value.length === 0) {
createMessage.warning('请勾选要导入的模板');
return;
}
const selected = syncList.value.filter((r) => syncSelectedKeys.value.includes(r.processCode));
importLoading.value = true;
try {
const msg = await batchImport(selected);
createMessage.success(typeof msg === 'string' ? msg : '导入成功');
syncVisible.value = false;
reload();
} catch (e: any) {
createMessage.error(e?.message || '批量导入失败');
} finally {
importLoading.value = false;
}
}
</script>
<style lang="less" scoped>
:deep(.ant-picker-range) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,776 @@
<template>
<a-modal
v-model:open="visible"
:title="`发起审批 · ${tplData?.tplName || ''}`"
width="940px"
:confirm-loading="submitting"
ok-text="发起审批"
cancel-text="取消"
destroy-on-close
:body-style="{ padding: 0 }"
@ok="handleSubmit"
@cancel="handleClose"
>
<div class="dal-body">
<!-- 左侧审批流时间轴 -->
<div class="dal-timeline-panel">
<div class="dal-panel-title">审批流程</div>
<div v-if="!selectedFlowId" class="dal-timeline-empty">
<div class="dal-timeline-empty-icon">🔗</div>
<div>请先在审批流配置<br>页签中选择审批流</div>
</div>
<a-spin v-else-if="previewLoading" style="display:flex;justify-content:center;padding:32px 0" />
<div v-else class="dal-timeline">
<!-- 发起人节点固定头 -->
<div class="dal-ts-step">
<div class="dal-ts-left">
<div class="dal-ts-dot dal-ts-dot--start"></div>
<div class="dal-ts-line" v-if="approverPreview.length > 0"></div>
</div>
<div class="dal-ts-content">
<div class="dal-ts-name">发起人</div>
<div class="dal-ts-sub">所有人可发起</div>
</div>
</div>
<!-- 动态节点 -->
<div
v-for="(node, ni) in approverPreview"
:key="node.nodeId || ni"
class="dal-ts-step"
>
<div class="dal-ts-left">
<div
class="dal-ts-dot"
:class="[
node.nodeType === 'cc' ? 'dal-ts-dot--cc' : 'dal-ts-dot--approver',
!node.allResolved ? 'dal-ts-dot--warn' : ''
]"
></div>
<div class="dal-ts-line" v-if="ni < approverPreview.length - 1"></div>
</div>
<div class="dal-ts-content">
<div class="dal-ts-tags">
<span class="dal-ts-badge" :class="node.nodeType === 'cc' ? 'dal-ts-badge--cc' : 'dal-ts-badge--approver'">
{{ node.nodeType === 'cc' ? '抄送' : '审批' }}
</span>
<span v-if="node.nodeType !== 'cc'" class="dal-ts-mode">{{ modeLabel(node.multiMode) }}</span>
</div>
<div class="dal-ts-name">{{ node.nodeName }}</div>
<div class="dal-ts-users">
<template v-for="(u, ui) in node.users" :key="u.username">
<span :class="u.resolved ? 'dal-ts-user--ok' : 'dal-ts-user--err'">
{{ u.realname }}
</span>
<span v-if="ui < node.users.length - 1" style="color:#ccc;margin:0 2px">·</span>
</template>
</div>
<div v-if="!node.allResolved" class="dal-ts-unresolved">
有未解析成员请补充手机号
</div>
</div>
</div>
<!-- 结束节点 -->
<div class="dal-ts-step" v-if="approverPreview.length > 0">
<div class="dal-ts-left">
<div class="dal-ts-dot dal-ts-dot--end"></div>
</div>
<div class="dal-ts-content">
<div class="dal-ts-name" style="color:#888">结束</div>
</div>
</div>
</div>
</div>
<!-- 分隔线 -->
<div class="dal-panel-divider"></div>
<!-- 右侧主内容 -->
<div class="dal-content-panel">
<a-tabs v-model:activeKey="activeTab" size="small" class="dal-tabs">
<!-- 表单填写 -->
<a-tab-pane key="form" tab="表单填写">
<div class="dal-form-scroll">
<a-spin :spinning="loading" tip="加载表单字段中...">
<a-alert v-if="loadError" type="error" :message="loadError" show-icon style="margin-bottom:12px" />
<template v-else-if="!loading">
<div v-if="dingFields.length === 0" class="dal-form-empty">
该模板暂无表单字段可直接发起仅通知审批人
</div>
<template v-for="field in dingFields" :key="field.label">
<div v-if="field.componentName === 'TextNote'" class="dal-form-note">{{ field.label }}</div>
<template v-else-if="field.componentName === 'TableField'">
<div class="dal-form-item">
<div class="dal-field-label" :class="{ 'dal-field-label--required': field.required }">{{ field.label }}</div>
<div class="dal-table-wrap">
<table class="dal-table">
<thead>
<tr>
<th style="width:40px;text-align:center">#</th>
<th v-for="child in field.children||[]" :key="child.label">{{ child.label }}</th>
<th style="width:88px;text-align:center">操作</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, rowIdx) in getTableRows(field.label)" :key="rowIdx">
<td style="text-align:center;color:#aaa">{{ rowIdx + 1 }}</td>
<td v-for="child in field.children||[]" :key="child.label">
<a-input v-model:value="row[child.label]" placeholder="请输入" size="small" :bordered="false" />
</td>
<td style="text-align:center">
<a-space :size="4">
<a style="color:#ff4d4f;font-size:12px" @click="deleteTableRow(field.label, rowIdx)">删除</a>
<a style="font-size:12px" @click="copyTableRow(field.label, rowIdx)">复制</a>
</a-space>
</td>
</tr>
<tr v-if="getTableRows(field.label).length === 0">
<td :colspan="(field.children?.length||0)+2" style="text-align:center;color:#bbb;padding:10px 0">暂无数据</td>
</tr>
</tbody>
</table>
<div class="dal-table-add" @click="addTableRow(field.label, field.children||[])">+ 添加</div>
</div>
</div>
</template>
<div v-else class="dal-form-item">
<div class="dal-field-label" :class="{ 'dal-field-label--required': field.required }">{{ field.label }}</div>
<a-range-picker v-if="field.componentName==='DDDateRangeField'" v-model:value="formValues[field.label]" style="width:100%" value-format="YYYY-MM-DD HH:mm" show-time :placeholder="['开始时间','结束时间']" />
<a-date-picker v-else-if="field.componentName==='DDDateField'" v-model:value="formValues[field.label]" style="width:100%" value-format="YYYY-MM-DD" placeholder="请选择日期" />
<a-input-number v-else-if="['NumberField','MoneyField'].includes(field.componentName)" v-model:value="formValues[field.label]" style="width:100%" placeholder="请输入" />
<a-textarea v-else-if="field.componentName==='TextareaField'" v-model:value="formValues[field.label]" :rows="3" placeholder="请输入" />
<a-select v-else-if="field.componentName==='DDSelectField'" v-model:value="formValues[field.label]" style="width:100%" placeholder="请选择" allow-clear>
<a-select-option v-for="opt in field.options||[]" :key="opt.key" :value="opt.value">{{ opt.value }}</a-select-option>
</a-select>
<a-select v-else-if="field.componentName==='DDMultiSelectField'" v-model:value="formValues[field.label]" style="width:100%" mode="multiple" placeholder="请选择" allow-clear>
<a-select-option v-for="opt in field.options||[]" :key="opt.key" :value="opt.value">{{ opt.value }}</a-select-option>
</a-select>
<template v-else-if="field.componentName==='InnerContactField'">
<a-input v-model:value="formValues[field.label]" placeholder="填写钉钉 userId多人用英文逗号分隔" allow-clear />
<div class="dal-field-hint">多人逗号分隔系统自动转 JSON 数组格式</div>
</template>
<template v-else-if="field.componentName==='RelateField'">
<a-input v-model:value="formValues[field.label]" placeholder="填写关联审批实例 ID多个用英文逗号分隔" allow-clear />
<div class="dal-field-hint">多个逗号分隔系统自动转 JSON 数组格式</div>
</template>
<template v-else-if="field.componentName==='DDPhotoField'">
<a-input v-model:value="formValues[field.label]" placeholder="填写图片 URL多张用英文逗号分隔" allow-clear />
<div class="dal-field-hint">多张逗号分隔系统自动转 JSON 数组格式</div>
</template>
<template v-else-if="field.componentName==='DepartmentField'">
<a-input v-model:value="formValues[field.label]" placeholder="填写钉钉部门 ID多个用英文逗号分隔" allow-clear />
<div class="dal-field-hint">部门 ID 逗号分隔直接传入</div>
</template>
<template v-else-if="field.componentName==='DDAttachment'">
<a-input v-model:value="formValues[field.label]" placeholder='[{"spaceId":"...","fileId":"...","fileName":"...","fileSize":"...","fileType":"..."}]' allow-clear />
<div class="dal-field-hint">需先上传到钉钉云盘获取 fileId直接填写 JSON 数组字符串</div>
</template>
<a-input v-else v-model:value="formValues[field.label]" placeholder="请输入" allow-clear />
</div>
</template>
</template>
</a-spin>
</div>
</a-tab-pane>
<!-- 审批流配置 -->
<a-tab-pane key="flow">
<template #tab>
审批流配置
<a-badge v-if="hasUnresolved" color="red" style="margin-left:4px" />
</template>
<div class="dal-form-scroll">
<div class="flow-tab-header">
<span class="flow-tab-hint">选择审批流发起时按流程节点指定钉钉审批人</span>
<a-button size="small" type="primary" ghost @click="handleNewFlow">+ 新建审批流</a-button>
</div>
<!-- 下拉选择审批流 -->
<div class="flow-select-row">
<a-select
v-model:value="selectedFlowId"
style="flex:1;min-width:0"
placeholder="请选择审批流"
:loading="flowLoading"
:options="flowSelectOptions"
show-search
:filter-option="filterFlowOption"
allow-clear
@change="handleFlowSelected"
>
<template #option="{ label, status, remark }">
<div class="flow-opt-item">
<span class="flow-opt-name">{{ label }}</span>
<span style="display:flex;align-items:center;gap:6px;flex-shrink:0">
<span v-if="remark" class="flow-opt-remark">{{ remark }}</span>
<a-tag
:color="status==='1'?'green':status==='2'?'default':'orange'"
style="margin:0;font-size:11px;line-height:16px;padding:0 5px"
>{{ status==='1'?'已发布':status==='2'?'已停用':'草稿' }}</a-tag>
</span>
</div>
</template>
</a-select>
<a-button
v-if="selectedFlowId"
size="small"
type="link"
style="flex-shrink:0;padding-left:8px"
@click="handleDesignSelectedFlow"
>设计</a-button>
</div>
<!-- 审批人解析预览 -->
<template v-if="selectedFlowId">
<a-divider style="margin:14px 0 10px" />
<div class="preview-title">
审批节点 · 人员解析
<a-spin :spinning="previewLoading" size="small" style="margin-left:8px" />
</div>
<div v-if="!previewLoading && approverPreview.length === 0" style="color:#bbb;font-size:12px;margin-top:6px">
该审批流暂无审批人节点
</div>
<div v-for="(node, ni) in approverPreview" :key="node.nodeId||ni" class="preview-node" :class="{'preview-node--cc': node.nodeType==='cc'}">
<div class="preview-node-hd">
<a-tag :color="node.nodeType==='cc'?'blue':'orange'" style="margin:0 6px 0 0;font-size:11px">
{{ node.nodeType === 'cc' ? '抄送' : '审批' }}
</a-tag>
<span class="preview-node-name">{{ node.nodeName }}</span>
<span class="preview-node-mode">
{{ node.nodeType === 'cc' ? '位置自动判断' : modeLabel(node.multiMode) }}
</span>
</div>
<div v-for="u in node.users" :key="u.username" class="preview-user">
<a-tag v-if="u.resolved" color="success" style="margin:0">{{ u.realname }}{{ u.username }}</a-tag>
<a-tag v-else-if="u.unsupported" color="default" style="margin:0">{{ u.realname }}不支持自动解析</a-tag>
<a-tag v-else color="error" style="margin:0">{{ u.realname }}{{ u.username }}未找到钉钉账号</a-tag>
</div>
<div v-if="!node.allResolved" class="preview-supplement">
<a-input
v-model:value="supplementPhones[node.nodeId||String(ni)]"
:placeholder="node.nodeType==='cc' ? '补充抄送人手机号,多个用逗号分隔' : '补充审批人手机号,多个用逗号分隔'"
allow-clear
size="small"
/>
<div class="dal-field-hint" style="margin-top:3px">
手机号需在企业钉钉注册与自动解析的成员合并
</div>
</div>
</div>
<a-alert v-if="hasUnresolved" type="warning" show-icon style="margin-top:10px;font-size:12px"
message="部分节点有未解析成员,请补充手机号后再发起审批" />
</template>
</div>
</a-tab-pane>
</a-tabs>
</div>
</div>
<ApprovalFlowModal @register="registerFlowModal" @success="handleFlowCreated" />
<FlowDesign @register="registerFlowDesign" @success="loadFlowList" />
</a-modal>
</template>
<script lang="ts" setup>
import { ref, reactive, computed } from 'vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { useModal } from '/@/components/Modal';
import { getTemplateDetail, launchApproval, getApprovalFlowList, previewFlowApprovers, bindApprovalFlow } from '../MesXslDingProcessTpl.api';
import ApprovalFlowModal from '/@/views/approval/flow/ApprovalFlowModal.vue';
import FlowDesign from '/@/views/approval/flow/components/FlowDesign.vue';
const emit = defineEmits(['success']);
const { createMessage } = useMessage();
const visible = ref(false);
const loading = ref(false);
const submitting = ref(false);
const loadError = ref('');
const activeTab = ref('form');
const tplData = ref<any>(null);
const dingFields = ref<any[]>([]);
const formValues = reactive<Record<string, any>>({});
const tableValues = reactive<Record<string, Record<string, string>[]>>({});
const flowLoading = ref(false);
const flowList = ref<any[]>([]);
const selectedFlowId = ref('');
const previewLoading = ref(false);
const approverPreview = ref<any[]>([]);
const supplementPhones = reactive<Record<string, string>>({});
const hasUnresolved = computed(() => approverPreview.value.some((n) => !n.allResolved));
const flowSelectOptions = computed(() =>
flowList.value.map((f) => ({
value: f.id,
label: f.flowName,
status: f.status,
remark: f.remark || '',
})),
);
function filterFlowOption(input: string, option: any) {
return (option?.label ?? '').toLowerCase().includes(input.toLowerCase());
}
const [registerFlowModal, { openModal: openFlowModal }] = useModal();
const [registerFlowDesign, { openModal: openFlowDesign }] = useModal();
function modeLabel(mode: string) {
if (mode === 'none') return '单人';
if (mode === 'or') return '或签';
if (mode === 'sequence') return '依次';
return '会签';
}
function resetForm() {
Object.keys(formValues).forEach((k) => delete formValues[k]);
Object.keys(tableValues).forEach((k) => delete tableValues[k]);
Object.keys(supplementPhones).forEach((k) => delete supplementPhones[k]);
loadError.value = '';
activeTab.value = 'form';
selectedFlowId.value = '';
approverPreview.value = [];
}
async function open(record: any) {
resetForm();
tplData.value = record;
dingFields.value = [];
visible.value = true;
loading.value = true;
if (record.flowId) selectedFlowId.value = record.flowId;
try {
const detail = await getTemplateDetail(record.id);
tplData.value = detail;
dingFields.value = detail.dingFields || [];
if (detail.schemaError) loadError.value = detail.schemaError;
for (const f of dingFields.value) {
if (f.componentName === 'TableField') tableValues[f.label] = [buildEmptyRow(f.children || [])];
}
} catch (e: any) {
loadError.value = e?.message || '加载模板字段失败';
} finally {
loading.value = false;
}
await loadFlowList();
if (selectedFlowId.value) loadPreview(selectedFlowId.value);
}
function handleClose() { visible.value = false; }
async function loadFlowList() {
flowLoading.value = true;
try {
const res = await getApprovalFlowList({ pageSize: 200 });
flowList.value = res?.records || res || [];
} catch { flowList.value = []; }
finally { flowLoading.value = false; }
}
function handleFlowSelected() {
// 清空补充手机号和预览(无论选中还是清空)
Object.keys(supplementPhones).forEach((k) => delete supplementPhones[k]);
approverPreview.value = [];
// 立刻持久化绑定关系选中或清空均保存flowId 为空表示解绑)
if (tplData.value?.id) {
bindApprovalFlow({ id: tplData.value.id, flowId: selectedFlowId.value || '' }).catch(() => {
// 静默失败,不影响主流程;发起时后端会再次兜底保存
});
}
if (selectedFlowId.value) loadPreview(selectedFlowId.value);
}
async function loadPreview(flowId: string) {
previewLoading.value = true;
try {
const res = await previewFlowApprovers(flowId);
approverPreview.value = Array.isArray(res) ? res : [];
} catch { approverPreview.value = []; }
finally { previewLoading.value = false; }
}
function handleNewFlow() { openFlowModal(true, { isUpdate: false }); }
async function handleFlowCreated() {
await loadFlowList();
if (flowList.value.length > 0) {
const last = flowList.value[flowList.value.length - 1];
selectedFlowId.value = last.id;
loadPreview(last.id);
}
}
function handleDesignFlow(flow: any) { openFlowDesign(true, { record: flow, readonly: false }); }
function handleDesignSelectedFlow() {
const flow = flowList.value.find((f) => f.id === selectedFlowId.value);
if (flow) handleDesignFlow(flow);
}
// ─── 表格操作 ───
function buildEmptyRow(children: any[]): Record<string, string> {
const row: Record<string, string> = {};
for (const c of children) row[c.label] = '';
return row;
}
function getTableRows(label: string) {
if (!tableValues[label]) tableValues[label] = [];
return tableValues[label];
}
function addTableRow(label: string, children: any[]) { getTableRows(label).push(buildEmptyRow(children)); }
function deleteTableRow(label: string, idx: number) { getTableRows(label).splice(idx, 1); }
function copyTableRow(label: string, idx: number) {
const rows = getTableRows(label);
rows.splice(idx + 1, 0, { ...rows[idx] });
}
// ─── 提交 ───
async function handleSubmit() {
if (!selectedFlowId.value) {
activeTab.value = 'flow';
createMessage.warning('请在「审批流配置」页签中选择一个审批流');
return;
}
const unresolvedNodes = approverPreview.value.filter((n) => !n.allResolved);
for (const node of unresolvedNodes) {
const nodeKey = node.nodeId || String(approverPreview.value.indexOf(node));
if (!supplementPhones[nodeKey]?.trim()) {
activeTab.value = 'flow';
createMessage.warning(`${node.nodeType === 'cc' ? '抄送节点' : '审批节点'}${node.nodeName}」有未解析成员,请补充手机号`);
return;
}
}
for (const field of dingFields.value) {
if (!field.required || field.componentName === 'TextNote') continue;
if (field.componentName === 'TableField') {
if (getTableRows(field.label).length === 0) { activeTab.value = 'form'; createMessage.warning(`${field.label}」至少需要填写一行`); return; }
continue;
}
const val = formValues[field.label];
if (val === undefined || val === null || val === '' || (Array.isArray(val) && val.length === 0)) {
activeTab.value = 'form'; createMessage.warning(`${field.label}」为必填项`); return;
}
}
const fvList: { name: string; value: string }[] = [];
for (const field of dingFields.value) {
if (field.componentName === 'TextNote') continue;
const label = field.label;
if (field.componentName === 'TableField') {
const validRows = getTableRows(label).filter((r) => Object.values(r).some((v) => v !== ''));
if (validRows.length === 0) continue;
fvList.push({ name: label, value: JSON.stringify(validRows.map((row) => Object.entries(row).map(([k, v]) => ({ name: k, value: String(v ?? '') })))) });
continue;
}
let val = formValues[label];
if (field.componentName === 'DDDateRangeField' && Array.isArray(val)) {
val = val.join('~');
} else if (field.componentName === 'DDMultiSelectField' && Array.isArray(val)) {
val = val.length > 0 ? JSON.stringify(val) : null;
} else if (['InnerContactField', 'RelateField', 'DDPhotoField'].includes(field.componentName)) {
const raw = val !== undefined && val !== null ? String(val).trim() : '';
val = raw ? JSON.stringify(raw.split(',').map((s) => s.trim()).filter(Boolean)) : null;
} else {
val = val !== undefined && val !== null ? String(val) : null;
}
if (val === null || val === '') { if (!field.required) continue; val = ''; }
fvList.push({ name: label, value: val as string });
}
const approverOverrides = Object.entries(supplementPhones)
.filter(([, phones]) => phones?.trim())
.map(([nodeId, phones]) => ({ nodeId, phones: phones.trim() }));
submitting.value = true;
try {
const result = await launchApproval({ id: tplData.value?.id, formValues: fvList, flowId: selectedFlowId.value, approverOverrides });
createMessage.success(typeof result === 'string' ? result : '审批发起成功!审批人将在钉钉「待我审批」中收到任务');
visible.value = false;
emit('success', result);
} catch (e: any) {
createMessage.error(e?.message || '发起失败');
} finally {
submitting.value = false;
}
}
defineExpose({ open });
</script>
<style lang="less" scoped>
// ─── 整体布局 ───
.dal-body {
display: flex;
min-height: 480px;
max-height: 70vh;
}
// ─── 左侧时间轴面板 ───
.dal-timeline-panel {
width: 210px;
flex-shrink: 0;
background: #fafafa;
border-right: 1px solid #f0f0f0;
padding: 16px 14px;
overflow-y: auto;
}
.dal-panel-title {
font-size: 12px;
font-weight: 600;
color: #888;
letter-spacing: .5px;
text-transform: uppercase;
margin-bottom: 14px;
}
.dal-timeline-empty {
text-align: center;
color: #bbb;
font-size: 12px;
padding-top: 32px;
line-height: 1.8;
.dal-timeline-empty-icon { font-size: 24px; margin-bottom: 8px; }
}
// ─── 时间轴 ───
.dal-timeline { }
.dal-ts-step {
display: flex;
align-items: flex-start;
gap: 8px;
}
.dal-ts-left {
display: flex;
flex-direction: column;
align-items: center;
flex-shrink: 0;
width: 12px;
padding-top: 2px;
}
.dal-ts-dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
position: relative;
z-index: 1;
border: 2px solid currentColor;
background: #fff;
}
.dal-ts-dot--start { color: #1677ff; background: #1677ff; border-color: #1677ff; }
.dal-ts-dot--end { color: #d9d9d9; background: #d9d9d9; border-color: #d9d9d9; width: 8px; height: 8px; }
.dal-ts-dot--approver { color: #fa8c16; }
.dal-ts-dot--cc { color: #1677ff; }
.dal-ts-dot--warn { color: #ff4d4f !important; }
.dal-ts-line {
width: 2px;
flex: 1;
min-height: 22px;
background: linear-gradient(to bottom, #e0e0e0 50%, transparent 100%);
margin: 3px 0 0;
}
.dal-ts-content {
flex: 1;
padding-bottom: 18px;
min-width: 0;
}
.dal-ts-tags {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 2px;
}
.dal-ts-badge {
font-size: 10px;
padding: 0 5px;
border-radius: 3px;
line-height: 16px;
font-weight: 500;
&--approver { background: #fff7e6; color: #d46b08; border: 1px solid #ffd591; }
&--cc { background: #e6f4ff; color: #0958d9; border: 1px solid #91caff; }
}
.dal-ts-mode {
font-size: 10px;
color: #aaa;
background: #f5f5f5;
padding: 0 4px;
border-radius: 2px;
line-height: 14px;
}
.dal-ts-name {
font-size: 12px;
font-weight: 500;
color: #333;
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dal-ts-users {
font-size: 11px;
color: #888;
margin-top: 2px;
line-height: 1.5;
}
.dal-ts-user--ok { color: #52c41a; }
.dal-ts-user--err { color: #ff4d4f; }
.dal-ts-unresolved {
font-size: 10px;
color: #ff7a00;
margin-top: 2px;
}
// ─── 分隔线 ───
.dal-panel-divider {
width: 1px;
background: #f0f0f0;
flex-shrink: 0;
}
// ─── 右侧内容面板 ───
.dal-content-panel {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.dal-tabs {
height: 100%;
display: flex;
flex-direction: column;
:deep(.ant-tabs-nav) { padding: 0 16px; margin-bottom: 0; }
:deep(.ant-tabs-content-holder) { flex: 1; overflow: hidden; }
:deep(.ant-tabs-content) { height: 100%; }
:deep(.ant-tabs-tabpane) { height: 100%; }
}
.dal-form-scroll {
height: 100%;
overflow-y: auto;
padding: 14px 18px;
}
// ─── 表单元素 ───
.dal-form-item {
margin-bottom: 14px;
}
.dal-form-empty {
color: #bbb;
text-align: center;
padding: 32px 0;
font-size: 13px;
}
.dal-field-label {
font-size: 13px;
color: #555;
margin-bottom: 5px;
font-weight: 500;
&--required::before { content: '* '; color: #ff4d4f; }
}
.dal-field-hint { font-size: 11px; color: #aaa; margin-top: 3px; }
.dal-form-note {
background: #f8f8f8;
border-left: 3px solid #ddd;
padding: 6px 10px;
font-size: 12px;
color: #777;
margin-bottom: 12px;
border-radius: 0 4px 4px 0;
}
// ─── 表格 ───
.dal-table-wrap { border: 1px solid #ebebeb; border-radius: 6px; overflow: hidden; }
.dal-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th { background: #fafafa; padding: 7px 10px; border-bottom: 1px solid #ebebeb; font-weight: 500; color: #666; font-size: 12px; }
td { padding: 3px 6px; border-bottom: 1px solid #f5f5f5; vertical-align: middle; }
tr:last-child td { border-bottom: none; }
:deep(.ant-input) { padding: 2px 6px; }
}
.dal-table-add {
display: flex; align-items: center; justify-content: center; padding: 7px 0;
color: #1677ff; font-size: 13px; cursor: pointer;
border-top: 1px dashed #ddd; background: #fafcff;
&:hover { background: #e6f0ff; }
}
// ─── 审批流配置页签 ───
.flow-tab-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
.flow-tab-hint { font-size: 12px; color: #999; }
.flow-select-row {
display: flex;
align-items: center;
gap: 4px;
margin-bottom: 2px;
}
// 下拉选项内容
.flow-opt-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.flow-opt-name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.flow-opt-remark {
font-size: 11px;
color: #aaa;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview-title { font-size: 12px; font-weight: 600; color: #555; margin-bottom: 8px; display: flex; align-items: center; }
.preview-node { border: 1px solid #f0f0f0; border-radius: 8px; padding: 10px 12px; margin-bottom: 8px; &--cc { border-color: #bae0ff; background: #f6fbff; } }
.preview-node-hd { display: flex; align-items: center; gap: 6px; margin-bottom: 7px; }
.preview-node-name { font-size: 13px; font-weight: 500; color: #333; }
.preview-node-mode { font-size: 11px; color: #aaa; background: #f5f5f5; padding: 1px 6px; border-radius: 3px; }
.preview-user { display: inline-block; margin: 0 5px 5px 0; }
.preview-supplement { margin-top: 8px; }
</style>

View File

@@ -0,0 +1,110 @@
<!--
新增审批模板创建本地草稿并可选打开表单设计器在设计器中推送到钉钉
@author GHT
@date 2026-06-04 forMESToDing审批配置新增审批模板入口
-->
<template>
<a-modal
v-model:open="visible"
title="新增审批模板"
width="520px"
:confirmLoading="loading"
okText="创建并设计表单"
cancelText="取消"
destroy-on-close
@ok="handleSubmit"
@cancel="visible = false"
>
<a-alert
type="info"
show-icon
style="margin-bottom: 16px"
message="将先在 MES 创建模板配置随后在表单设计器中添加字段并点击「创建钉钉模板」推送到钉钉processCode 由钉钉返回)。"
/>
<a-form ref="formRef" :model="formState" :rules="rules" layout="vertical">
<a-form-item label="模板名称" name="tplName">
<a-input v-model:value="formState.tplName" placeholder="如密炼PS编制审批" allow-clear />
</a-form-item>
<a-form-item label="业务类型标识" name="bizType">
<a-input v-model:value="formState.bizType" placeholder="供审批流关联,如 mixer_ps" allow-clear />
</a-form-item>
<a-form-item label="备注" name="remark">
<a-textarea v-model:value="formState.remark" :rows="2" placeholder="可选" />
</a-form-item>
<a-form-item name="openDesigner">
<a-checkbox v-model:checked="formState.openDesigner">创建成功后打开表单设计器</a-checkbox>
</a-form-item>
</a-form>
</a-modal>
</template>
<script lang="ts" setup>
import { reactive, ref } from 'vue';
import type { FormInstance } from 'ant-design-vue';
import { useMessage } from '/@/hooks/web/useMessage';
import { addNewTemplate } from '../MesXslDingProcessTpl.api';
const emit = defineEmits<{
(e: 'success', payload: { record: Recordable; openDesigner: boolean }): void;
}>();
const { createMessage } = useMessage();
const visible = ref(false);
const loading = ref(false);
const formRef = ref<FormInstance>();
const formState = reactive({
tplName: '',
bizType: '',
remark: '',
openDesigner: true,
});
const rules = {
tplName: [{ required: true, message: '请输入模板名称', trigger: 'blur' }],
};
function resetForm() {
formState.tplName = '';
formState.bizType = '';
formState.remark = '';
formState.openDesigner = true;
formRef.value?.clearValidate();
}
function open() {
resetForm();
visible.value = true;
}
async function handleSubmit() {
try {
await formRef.value?.validate();
} catch {
return;
}
loading.value = true;
try {
const record: any = await addNewTemplate({
tplName: formState.tplName.trim(),
bizType: formState.bizType?.trim() || undefined,
remark: formState.remark?.trim() || undefined,
status: '1',
sortNo: 0,
});
if (!record?.id) {
createMessage.error('创建失败:未返回模板 ID');
return;
}
createMessage.success('审批模板已创建');
visible.value = false;
emit('success', { record, openDesigner: formState.openDesigner });
} catch (e: any) {
createMessage.error(e?.message || '创建失败');
} finally {
loading.value = false;
}
}
defineExpose({ open });
</script>

View File

@@ -0,0 +1,61 @@
<template>
<BasicModal v-bind="$attrs" @register="registerModal" destroyOnClose :title="title" :width="800" @ok="handleSubmit">
<BasicForm @register="registerForm" name="MesXslDingProcessTplForm" />
</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 '../MesXslDingProcessTpl.data';
import { saveOrUpdate } from '../MesXslDingProcessTpl.api';
const emit = defineEmits(['register', 'success']);
const isUpdate = ref(true);
const isDetail = ref(false);
const [registerForm, { setProps, resetFields, setFieldsValue, validate, scrollToField }] = useForm({
labelWidth: 120,
schemas: formSchema,
showActionButtonGroup: false,
baseColProps: { span: 24 },
});
const [registerModal, { setModalProps, closeModal }] = useModalInner(async (data) => {
await resetFields();
setModalProps({ confirmLoading: false, showCancelBtn: !!data?.showFooter, showOkBtn: !!data?.showFooter });
isUpdate.value = !!data?.isUpdate;
isDetail.value = !!data?.showFooter;
if (unref(isUpdate)) {
await setFieldsValue({ ...data.record });
}
setProps({ disabled: !data?.showFooter });
});
const title = computed(() => (!unref(isUpdate) ? '新增' : !unref(isDetail) ? '详情' : '编辑'));
async function handleSubmit() {
try {
const values = await validate();
setModalProps({ confirmLoading: true });
await saveOrUpdate(values, isUpdate.value);
closeModal();
emit('success');
} catch ({ errorFields }: any) {
if (errorFields) {
const firstField = errorFields[0];
if (firstField) scrollToField(firstField.name, { behavior: 'smooth', block: 'center' });
}
return Promise.reject(errorFields);
} finally {
setModalProps({ confirmLoading: false });
}
}
</script>
<style lang="less" scoped>
:deep(.ant-input-number) {
width: 100%;
}
</style>