新增钉钉 Stream SDK 依赖,支持无 HTTP 上下文的后台线程显式传入 token 进行审批回调。同时,完善了 MES 审批台账功能,新增审批记录同步、批量发起审批时的门禁与台账写入逻辑,增强了系统的审批流管理能力。
This commit is contained in:
@@ -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>
|
||||
257
jeecgboot-vue3/src/components/DingTplLaunch/index.vue
Normal file
257
jeecgboot-vue3/src/components/DingTplLaunch/index.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<!--
|
||||
全局「发起钉钉审批」按钮
|
||||
路由变化时检查当前页面是否配置了钉钉审批模板绑定,有则显示。
|
||||
默认停靠在列表页「查询条件」区域最右侧,可拖拽移动(位置按路由 localStorage 记忆)。
|
||||
|
||||
@author GHT
|
||||
@date 2026-06-04 for:【MESToDing审批配置】审批模板绑定-发起钉钉审批
|
||||
-->
|
||||
<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>
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -13,6 +13,9 @@
|
||||
<!-- update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】全局发起审批悬浮按钮----- -->
|
||||
<ApprovalLaunchFloat />
|
||||
<!-- update-end---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】全局发起审批悬浮按钮----- -->
|
||||
<!-- update-begin---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】全局钉钉审批模板绑定发起按钮----- -->
|
||||
<DingTplLaunchFloat />
|
||||
<!-- update-end---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】全局钉钉审批模板绑定发起按钮----- -->
|
||||
<!-- update-begin---author:GHT ---date:2026-05-29 for:【QH-MES审批流设计】全局审批流程设计悬浮按钮----- -->
|
||||
<ApprovalDesignFloat />
|
||||
<!-- update-end---author:GHT ---date:2026-05-29 for:【QH-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,
|
||||
|
||||
31
jeecgboot-vue3/src/views/approval/gate/approvalGate.api.ts
Normal file
31
jeecgboot-vue3/src/views/approval/gate/approvalGate.api.ts
Normal 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 });
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 });
|
||||
@@ -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 } },
|
||||
];
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 } });
|
||||
1132
jeecgboot-vue3/src/views/xslmes/dingtalk/dingTplBind/index.vue
Normal file
1132
jeecgboot-vue3/src/views/xslmes/dingtalk/dingTplBind/index.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -15,6 +15,7 @@ enum Api {
|
||||
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',
|
||||
@@ -46,6 +47,10 @@ export const batchDelete = (params, 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' });
|
||||
|
||||
@@ -51,8 +51,9 @@ export const formSchema: FormSchema[] = [
|
||||
label: 'processCode',
|
||||
field: 'processCode',
|
||||
component: 'Input',
|
||||
componentProps: { placeholder: '请输入钉钉processCode,如 PROC-XXXXXXXX-...' },
|
||||
dynamicRules: () => [{ required: true, message: '请输入processCode!' }],
|
||||
componentProps: {
|
||||
placeholder: '钉钉返回的 processCode;「新增审批模板」流程中可留空,设计器创建钉钉模板后自动回填',
|
||||
},
|
||||
},
|
||||
{
|
||||
label: '业务类型标识',
|
||||
|
||||
@@ -2,8 +2,18 @@
|
||||
<div>
|
||||
<BasicTable @register="registerTable" :rowSelection="rowSelection">
|
||||
<template #tableTitle>
|
||||
<a-button type="primary" v-auth="'xslmes:mes_xsl_ding_process_tpl:add'" preIcon="ant-design:plus-outlined" @click="handleAdd">
|
||||
新增
|
||||
<!--update-begin---author:GHT ---date:2026-06-04 for:【MESToDing审批配置】新增审批模板(创建草稿+打开设计器)-->
|
||||
<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 for:【MESToDing审批配置】新增审批模板(创建草稿+打开设计器)-->
|
||||
<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">
|
||||
导出
|
||||
@@ -88,6 +98,7 @@
|
||||
|
||||
<!--update-begin---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】表单设计器-->
|
||||
<DingTplDesigner ref="designerRef" @success="handleSuccess" />
|
||||
<DingTplCreateModal ref="createModalRef" @success="onNewTemplateCreated" />
|
||||
<!--update-end---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】表单设计器-->
|
||||
|
||||
<!--update-begin---author:GHT ---date:2026-06-03 for:【MESToDing审批配置】手动填表发起钉钉审批-->
|
||||
@@ -119,7 +130,8 @@
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'imported'">
|
||||
<a-tag :color="record.imported ? 'green' : 'default'">{{ record.imported ? '已导入' : '未导入' }}</a-tag>
|
||||
<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>
|
||||
@@ -138,6 +150,7 @@
|
||||
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审批配置】手动填表发起钉钉审批
|
||||
@@ -184,6 +197,19 @@
|
||||
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 });
|
||||
}
|
||||
@@ -221,10 +247,20 @@
|
||||
}
|
||||
|
||||
function getDropDownAction(record) {
|
||||
return [
|
||||
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审批配置】新增设计模板入口
|
||||
{
|
||||
@@ -232,7 +268,8 @@
|
||||
popConfirm: { title: '是否确认删除', confirm: handleDelete.bind(null, record), placement: 'topLeft' },
|
||||
auth: 'xslmes:mes_xsl_ding_process_tpl:delete',
|
||||
},
|
||||
];
|
||||
);
|
||||
return actions;
|
||||
}
|
||||
|
||||
// ===== 手动填表发起钉钉审批 =====
|
||||
@@ -306,7 +343,9 @@
|
||||
try {
|
||||
const data = await syncFromDingtalk();
|
||||
syncList.value = data || [];
|
||||
syncSelectedKeys.value = (syncList.value as any[]).filter((r) => !r.imported).map((r) => r.processCode);
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<!--
|
||||
新增审批模板:创建本地草稿并可选打开表单设计器,在设计器中推送到钉钉
|
||||
@author GHT
|
||||
@date 2026-06-04 for:【MESToDing审批配置】新增审批模板入口
|
||||
-->
|
||||
<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>
|
||||
@@ -747,7 +747,11 @@
|
||||
try {
|
||||
await saveFieldMapping({ id: tplData.value.id, formFields: serializeComponents() });
|
||||
const res: any = await createDingTemplate({ id: tplData.value.id });
|
||||
const processCode = res?.processCode || res?.result?.processCode;
|
||||
const processCode = res?.processCode;
|
||||
if (!processCode) {
|
||||
createMessage.warning('钉钉已创建,但未拿到 processCode,请稍后使用「从钉钉同步」回填');
|
||||
return;
|
||||
}
|
||||
createMessage.success('钉钉模板创建成功,processCode=' + processCode);
|
||||
if (processCode) tplData.value = { ...tplData.value, processCode };
|
||||
emit('success');
|
||||
|
||||
@@ -12,7 +12,6 @@ enum Api {
|
||||
proofread = '/xslmes/mesXslMixerPsCompile/proofread',
|
||||
audit = '/xslmes/mesXslMixerPsCompile/audit',
|
||||
approve = '/xslmes/mesXslMixerPsCompile/approve',
|
||||
ddApprovalTest = '/xslmes/mesXslMixerPsCompile/ddApprovalTest',
|
||||
}
|
||||
|
||||
export const list = (params) => defHttp.get({ url: Api.list, params });
|
||||
@@ -38,5 +37,3 @@ export const proofread = (params: { ids: string }) => defHttp.post({ url: Api.pr
|
||||
export const audit = (params: { ids: string }) => defHttp.post({ url: Api.audit, params }, { joinParamsToUrl: true });
|
||||
|
||||
export const approve = (params: { ids: string }) => defHttp.post({ url: Api.approve, params }, { joinParamsToUrl: true });
|
||||
|
||||
export const ddApprovalTest = () => defHttp.post({ url: Api.ddApprovalTest }, { successMessageMode: 'none' });
|
||||
|
||||
@@ -65,11 +65,6 @@
|
||||
</a-button>
|
||||
</a-dropdown>
|
||||
<super-query :config="superQueryConfig" @search="handleSuperQuery" />
|
||||
<!--update-begin---author:GHT ---date:2026-06-03 for:【钉钉PROC-GENERIC可行性验证】测试按钮-->
|
||||
<a-button :loading="ddTestLoading" preIcon="ant-design:dingtalk-outlined" @click="handleDdApprovalTest">
|
||||
钉钉审批测试
|
||||
</a-button>
|
||||
<!--update-end---author:GHT ---date:2026-06-03 for:【钉钉PROC-GENERIC可行性验证】测试按钮-->
|
||||
</template>
|
||||
<template #action="{ record }">
|
||||
<TableAction
|
||||
@@ -86,19 +81,11 @@
|
||||
</template>
|
||||
</BasicTable>
|
||||
<MesXslMixerPsCompileModal @register="registerModal" @success="handleSuccess" />
|
||||
|
||||
<!--update-begin---author:GHT ---date:2026-06-03 for:【钉钉PROC-GENERIC可行性验证】结果弹窗-->
|
||||
<a-modal v-model:open="ddTestVisible" title="钉钉审批测试结果" width="680px" :footer="null">
|
||||
<a-spin :spinning="ddTestLoading">
|
||||
<pre style="max-height: 520px; overflow: auto; font-size: 12px; background: #f6f8fa; padding: 14px; border-radius: 6px; white-space: pre-wrap; word-break: break-all">{{ ddTestResult }}</pre>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
<!--update-end---author:GHT ---date:2026-06-03 for:【钉钉PROC-GENERIC可行性验证】结果弹窗-->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" name="xslmes-mesXslMixerPsCompile" setup>
|
||||
import { computed, reactive, ref } from 'vue';
|
||||
import { computed, reactive } from 'vue';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
import { BasicTable, TableAction } from '/@/components/Table';
|
||||
import { useModal } from '/@/components/Modal';
|
||||
@@ -116,7 +103,6 @@
|
||||
proofread,
|
||||
audit,
|
||||
approve,
|
||||
ddApprovalTest,
|
||||
} from './MesXslMixerPsCompile.api';
|
||||
|
||||
const { createMessage } = useMessage();
|
||||
@@ -242,26 +228,6 @@
|
||||
selectedRowKeys.value = [];
|
||||
}
|
||||
|
||||
//update-begin---author:GHT ---date:2026-06-03 for:【钉钉PROC-GENERIC可行性验证】测试逻辑
|
||||
const ddTestVisible = ref(false);
|
||||
const ddTestLoading = ref(false);
|
||||
const ddTestResult = ref('');
|
||||
|
||||
async function handleDdApprovalTest() {
|
||||
ddTestVisible.value = true;
|
||||
ddTestLoading.value = true;
|
||||
ddTestResult.value = '正在请求,请稍候...';
|
||||
try {
|
||||
const res = await ddApprovalTest();
|
||||
ddTestResult.value = JSON.stringify(res, null, 2);
|
||||
} catch (e: any) {
|
||||
ddTestResult.value = '请求失败:\n' + (e?.message || JSON.stringify(e));
|
||||
} finally {
|
||||
ddTestLoading.value = false;
|
||||
}
|
||||
}
|
||||
//update-end---author:GHT ---date:2026-06-03 for:【钉钉PROC-GENERIC可行性验证】测试逻辑
|
||||
|
||||
function getDropDownAction(record: Recordable) {
|
||||
return [
|
||||
{ label: '详情', onClick: handleDetail.bind(null, record) },
|
||||
|
||||
Reference in New Issue
Block a user