front/src/views/order/placeOrder/components/CustomTemplateModal.vue
zzh 6a7f8f1bcb feat: 用户扩展、Nana双Tab、仓库与客服入口优化
扩展用户管理字段与组织编码生成,顶部改为仓库下拉;Nana 分 AI 与专属顾问 Tab,仓库页移除客服字段,并完善扫描语音与订单相关页面。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-09 11:23:54 +08:00

798 lines
21 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script lang="ts" setup>
import { computed, ref, watch } from 'vue';
import { Message, Modal } from '@arco-design/web-vue';
import { IconUpload, IconFile } from '@arco-design/web-vue/es/icon';
import * as XLSX from 'xlsx';
import {
getOrderTemplateCustomDetail,
getOrderTemplateFieldSchema,
postOrderTemplateCustomUpload,
postOrderTemplateSaveMapping,
type OrderTemplateCustomDetail,
type OrderTemplateFieldSchemaItem,
} from '@/api/order';
const DEFAULT_HEADER_ROW_INDEX = 1;
const DEFAULT_DATA_START_ROW_INDEX = 2;
export interface StandardTemplateOption {
templateKey: string;
label: string;
id?: number;
}
const props = defineProps<{
visible: boolean;
templateId?: number;
standardTemplateOptions: StandardTemplateOption[];
}>();
const emit = defineEmits<{
(e: 'update:visible', value: boolean): void;
(e: 'success', payload?: { templateId: number }): void;
}>();
const loading = ref(false);
const saving = ref(false);
const fieldsLoading = ref(false);
const detail = ref<OrderTemplateCustomDetail | null>(null);
const name = ref('');
const file = ref<File | null>(null);
const dragActive = ref(false);
const fileInputRef = ref<HTMLInputElement | null>(null);
const sourceHeaders = ref<string[]>([]);
const standardTemplateKey = ref('dedicated_small_parcel_v1');
const standardFields = ref<OrderTemplateFieldSchemaItem[]>([]);
const mappingMap = ref<Record<string, string>>({});
const baselineSnapshot = ref('');
const isCreateMode = computed(() => !props.templateId || props.templateId <= 0);
const modalTitle = computed(() => {
if (isCreateMode.value) return '添加自定义模板';
const templateName = detail.value?.name || name.value;
return templateName ? `编辑自定义模板 - ${templateName}` : '编辑自定义模板';
});
const standardSelectOptions = computed(() =>
props.standardTemplateOptions.map((item) => ({
label: item.label,
value: item.templateKey,
})),
);
const mappingFieldsVisible = computed(() => {
if (isCreateMode.value) {
return standardFields.value.length > 0;
}
return !!detail.value && standardFields.value.length > 0;
});
const canSubmitSave = computed(() => {
if (isCreateMode.value) {
return (
!!name.value.trim() &&
!!file.value &&
sourceHeaders.value.length > 0 &&
standardFields.value.length > 0
);
}
return !!detail.value && sourceHeaders.value.length > 0 && standardFields.value.length > 0;
});
const isDirty = computed(() => baselineSnapshot.value !== serializeFormState());
const groupLabels: Record<string, string> = {
basic: '基本信息',
sender: '发件人',
receiver: '收件人',
declaration: '申报信息',
};
const groupedFields = computed(() => {
const groups = new Map<string, OrderTemplateFieldSchemaItem[]>();
standardFields.value.forEach((field) => {
const group = field.group || 'basic';
if (!groups.has(group)) groups.set(group, []);
groups.get(group)!.push(field);
});
return Array.from(groups.entries()).map(([key, items]) => ({
key,
label: groupLabels[key] || key,
items,
}));
});
const sourceHeaderOptions = computed(() => [
{ label: '不映射', value: '' },
...sourceHeaders.value.map((h) => ({ label: h, value: h })),
]);
const missingRequiredLabels = computed(() =>
standardFields.value
.filter((f) => f.required)
.filter((f) => !String(mappingMap.value[f.fieldKey] ?? '').trim())
.map((f) => f.label),
);
const unmappedSourceHeaders = computed(() => {
const used = new Set(Object.values(mappingMap.value).filter(Boolean));
return sourceHeaders.value.filter((h) => !used.has(h));
});
function defaultStandardKey() {
return props.standardTemplateOptions[0]?.templateKey || 'dedicated_small_parcel_v1';
}
function serializeFormState() {
return JSON.stringify({
name: name.value.trim(),
standardTemplateKey: standardTemplateKey.value,
fileName: file.value?.name || '',
sourceHeaders: sourceHeaders.value,
mappingMap: mappingMap.value,
});
}
function captureBaseline() {
baselineSnapshot.value = serializeFormState();
}
function normalizeHeader(text: string) {
return String(text || '')
.trim()
.replace(/[((].*?[))]/g, '')
.replace(/[\s*]/g, '')
.toLowerCase();
}
function buildSuggestedMapping(
fields: OrderTemplateFieldSchemaItem[],
headers: string[],
existing?: Record<string, string>,
) {
const next: Record<string, string> = {};
const used = new Set<string>();
fields.forEach((field) => {
const prev = existing?.[field.fieldKey];
if (prev && headers.includes(prev) && !used.has(prev)) {
next[field.fieldKey] = prev;
used.add(prev);
return;
}
const normalizedLabel = normalizeHeader(field.label);
const matched = headers.find((header) => {
if (used.has(header)) return false;
const normalizedHeader = normalizeHeader(header);
return (
normalizedHeader === normalizedLabel ||
normalizedHeader.includes(normalizedLabel) ||
normalizedLabel.includes(normalizedHeader)
);
});
next[field.fieldKey] = matched ?? '';
if (matched) used.add(matched);
});
return next;
}
function resetCreateForm() {
name.value = '';
file.value = null;
sourceHeaders.value = [];
mappingMap.value = {};
dragActive.value = false;
if (fileInputRef.value) fileInputRef.value.value = '';
standardTemplateKey.value = defaultStandardKey();
}
function resetAll() {
detail.value = null;
standardFields.value = [];
baselineSnapshot.value = '';
resetCreateForm();
}
async function parseFileHeaders(picked: File) {
const buffer = await picked.arrayBuffer();
const workbook = XLSX.read(buffer, { type: 'array' });
const sheetName = workbook.SheetNames[0];
if (!sheetName) {
sourceHeaders.value = [];
return;
}
const sheet = workbook.Sheets[sheetName];
const table = XLSX.utils.sheet_to_json(sheet, { header: 1, defval: '' }) as any[][];
const row = table[0] || [];
sourceHeaders.value = row
.map((cell) => String(cell ?? '').trim())
.filter(Boolean);
mappingMap.value = buildSuggestedMapping(standardFields.value, sourceHeaders.value, mappingMap.value);
}
function acceptFile(picked: File | null | undefined) {
if (!picked) return;
const ext = picked.name.toLowerCase();
if (!ext.endsWith('.xlsx') && !ext.endsWith('.xls')) {
Message.warning('仅支持 .xlsx / .xls 文件');
return;
}
file.value = picked;
if (!name.value.trim()) {
name.value = picked.name.replace(/\.(xlsx|xls)$/i, '');
}
void parseFileHeaders(picked);
}
function triggerPicker() {
if (!isCreateMode.value) return;
fileInputRef.value?.click();
}
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement;
acceptFile(input.files?.[0]);
}
function onDragOver() {
if (!isCreateMode.value) return;
dragActive.value = true;
}
function onDragLeave() {
dragActive.value = false;
}
function onDrop(e: DragEvent) {
if (!isCreateMode.value) return;
dragActive.value = false;
acceptFile(e.dataTransfer?.files?.[0]);
}
async function loadStandardFields(preserveMapping = true) {
if (!standardTemplateKey.value) return;
fieldsLoading.value = true;
try {
const schema = await getOrderTemplateFieldSchema(standardTemplateKey.value);
standardFields.value = schema.fields ?? [];
const existing = preserveMapping ? { ...mappingMap.value } : undefined;
mappingMap.value = buildSuggestedMapping(standardFields.value, sourceHeaders.value, existing);
} catch (e: any) {
Message.error(e?.message || '加载标准模板字段失败');
} finally {
fieldsLoading.value = false;
}
}
function applySavedMappings(data: OrderTemplateCustomDetail) {
sourceHeaders.value = data.sourceHeaders ?? [];
const saved: Record<string, string> = {};
(data.mapping?.mappings ?? []).forEach((item) => {
if (item.standardFieldKey && item.sourceColumn) {
saved[item.standardFieldKey] = item.sourceColumn;
}
});
mappingMap.value = buildSuggestedMapping(standardFields.value, sourceHeaders.value, saved);
}
async function loadDetail() {
if (!props.templateId || props.templateId <= 0) return;
loading.value = true;
try {
const data = await getOrderTemplateCustomDetail(props.templateId);
detail.value = data;
name.value = data.name;
standardTemplateKey.value = data.standardTemplateKey || data.mapping?.standardTemplateKey || defaultStandardKey();
await loadStandardFields(false);
applySavedMappings(data);
captureBaseline();
} catch (e: any) {
Message.error(e?.message || '加载模板详情失败');
} finally {
loading.value = false;
}
}
async function handleStandardTemplateChange() {
await loadStandardFields(true);
}
function buildSaveMappings() {
return Object.entries(mappingMap.value)
.filter(([, sourceColumn]) => String(sourceColumn ?? '').trim())
.map(([standardFieldKey, sourceColumn]) => ({ standardFieldKey, sourceColumn }));
}
async function saveMappingForTemplate(templateId: number) {
return postOrderTemplateSaveMapping({
templateId,
standardTemplateKey: standardTemplateKey.value,
headerRowIndex: DEFAULT_HEADER_ROW_INDEX,
dataStartRowIndex: DEFAULT_DATA_START_ROW_INDEX,
mappings: buildSaveMappings(),
});
}
async function handleSave(): Promise<boolean> {
if (isCreateMode.value) {
const templateName = name.value.trim();
if (!templateName) {
Message.warning('请输入模板名称');
return false;
}
if (!file.value) {
Message.warning('请上传 Excel 样例文件');
return false;
}
if (!standardTemplateKey.value) {
Message.warning('请选择映射的标准模板');
return false;
}
if (!canSubmitSave.value) {
Message.warning('请完成字段映射配置');
return false;
}
} else if (!props.templateId) {
return false;
}
saving.value = true;
try {
let targetId = props.templateId ?? 0;
if (isCreateMode.value && file.value) {
const upload = await postOrderTemplateCustomUpload(name.value.trim(), file.value);
targetId = upload.templateId;
}
if (!Number.isFinite(targetId) || targetId <= 0) {
Message.error('模板 ID 无效,请重新上传样例文件');
return false;
}
const result = await saveMappingForTemplate(targetId);
if (result.missingRequiredFields?.length) {
Message.warning(`仍有必填字段未映射:${result.missingRequiredFields.join('、')}`);
if (!isCreateMode.value) {
await loadDetail();
}
return false;
}
Message.success(isCreateMode.value ? '自定义模板已创建' : '映射已保存');
emit('success', { templateId: targetId });
forceClose();
return true;
} catch (e: any) {
Message.error(e?.message || '保存失败');
return false;
} finally {
saving.value = false;
}
}
function forceClose() {
emit('update:visible', false);
}
function handleCloseRequest() {
if (!isDirty.value) {
forceClose();
return;
}
Modal.confirm({
title: '是否保存?',
content: isCreateMode.value
? '关闭后未保存的内容将不会创建模板及映射关联。'
: '关闭后未保存的映射修改将丢失。',
okText: '保存',
cancelText: '不保存',
maskClosable: false,
onOk: async () => {
const saved = await handleSave();
if (!saved) {
throw new Error('save failed');
}
},
onCancel: () => {
forceClose();
},
});
}
watch(
() => [props.visible, props.templateId] as const,
async ([open, templateId]) => {
if (!open) {
resetAll();
return;
}
if (templateId && templateId > 0) {
await loadDetail();
} else {
standardTemplateKey.value = defaultStandardKey();
await loadStandardFields(false);
captureBaseline();
}
},
);
</script>
<template>
<a-modal
:visible="visible"
:title="modalTitle"
:width="1320"
:mask-closable="false"
unmount-on-close
class="custom-template-modal"
@cancel="handleCloseRequest"
>
<a-spin :loading="loading || fieldsLoading" class="modal-spin">
<div class="modal-layout">
<aside class="panel-left">
<div class="panel-title">模板信息</div>
<a-form layout="vertical" class="left-form">
<a-form-item label="映射标准模板" required>
<a-select
v-model="standardTemplateKey"
:options="standardSelectOptions"
placeholder="选择标准模板"
@change="handleStandardTemplateChange"
/>
</a-form-item>
<a-form-item label="模板名称" :required="isCreateMode">
<a-input
v-if="isCreateMode"
v-model="name"
placeholder="例如:客户A导入模板"
allow-clear
/>
<div v-else class="readonly-text">{{ detail?.name || name }}</div>
</a-form-item>
<a-form-item v-if="isCreateMode" label="Excel 样例" required>
<div
class="upload-zone"
:class="{ 'has-file': !!file, 'is-dragover': dragActive }"
@click="triggerPicker"
@dragover.prevent="onDragOver"
@dragleave.prevent="onDragLeave"
@drop.prevent="onDrop"
>
<icon-upload v-if="!file" class="upload-icon" />
<icon-file v-else class="upload-icon is-file" />
<div v-if="file" class="upload-file-name">{{ file.name }}</div>
<div v-else class="upload-placeholder">点击或拖拽上传 Excel</div>
<div class="upload-tip">第一行需为列名表头,支持 .xlsx / .xls</div>
</div>
<input
ref="fileInputRef"
type="file"
accept=".xlsx,.xls"
class="hidden-input"
@change="onFileChange"
/>
</a-form-item>
</a-form>
<div v-if="!isCreateMode" class="status-row">
<span class="status-label">映射状态</span>
<a-tag v-if="detail?.mappingStatus === 'ready'" color="green" size="small">已就绪</a-tag>
<a-tag v-else color="orangered" size="small">待配置</a-tag>
</div>
</aside>
<section class="panel-right">
<div class="panel-title panel-title-right">
<span>字段映射</span>
<span v-if="sourceHeaders.length" class="header-count">已识别 {{ sourceHeaders.length }} 列</span>
</div>
<div v-if="!mappingFieldsVisible" class="mapping-empty">
<a-empty description="正在加载标准字段…" />
</div>
<template v-else>
<div v-if="isCreateMode && !sourceHeaders.length" class="mapping-hint">
标准字段已加载,上传 Excel 样例后可选择各列映射
</div>
<div v-for="group in groupedFields" :key="group.key" class="mapping-group">
<div class="mapping-group-title">{{ group.label }}</div>
<div class="mapping-table-wrap">
<table class="mapping-table">
<thead>
<tr>
<th>标准字段</th>
<th>上传模板列</th>
</tr>
</thead>
<tbody>
<tr v-for="field in group.items" :key="field.fieldKey">
<td class="field-label-col">
<span v-if="field.required" class="required-star">*</span>
{{ field.label }}
<span class="field-key">{{ field.fieldKey }}</span>
</td>
<td>
<a-select
v-model="mappingMap[field.fieldKey]"
allow-search
allow-clear
:disabled="!sourceHeaders.length"
:placeholder="sourceHeaders.length ? '选择上传模板列' : '请先上传 Excel'"
:options="sourceHeaderOptions"
/>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="mapping-summary">
<div v-if="missingRequiredLabels.length" class="summary-warn">
缺失必填映射:{{ missingRequiredLabels.join('、') }}
</div>
<div v-if="unmappedSourceHeaders.length" class="summary-muted">
未映射的上传列:{{ unmappedSourceHeaders.join('、') }}
</div>
</div>
</template>
</section>
</div>
</a-spin>
<template #footer>
<a-button @click="handleCloseRequest">取消</a-button>
<a-button type="primary" :loading="saving" @click="handleSave">
{{ isCreateMode ? '创建并保存' : '保存映射' }}
</a-button>
</template>
</a-modal>
</template>
<style scoped lang="less">
.modal-spin {
display: block;
width: 100%;
}
.modal-layout {
display: flex;
gap: 0;
min-height: 560px;
max-height: 78vh;
}
.panel-left {
width: 300px;
flex-shrink: 0;
border-right: 1px solid #eef0f3;
padding-right: 20px;
overflow-y: auto;
}
.panel-right {
flex: 1;
min-width: 0;
padding-left: 20px;
overflow-y: auto;
}
.panel-title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 16px;
font-size: 14px;
font-weight: 600;
color: #1d2129;
&::before {
content: '';
width: 3px;
height: 14px;
border-radius: 2px;
background: var(--app-primary-color);
}
}
.panel-title-right {
justify-content: space-between;
}
.header-count {
font-size: 12px;
font-weight: 400;
color: #86909c;
&::before {
display: none;
}
}
.left-form {
:deep(.arco-form-item) {
margin-bottom: 14px;
}
:deep(.arco-form-item-label) {
font-size: 13px;
color: #4e5969;
}
}
.readonly-text {
min-height: 32px;
line-height: 32px;
font-size: 14px;
color: #1d2129;
}
.upload-zone {
border: 1px dashed #c9cdd4;
border-radius: 4px;
padding: 20px 16px;
text-align: center;
cursor: pointer;
background: #f7f8fa;
transition: border-color 0.2s, background 0.2s;
&:hover,
&.is-dragover {
border-color: var(--app-primary-color);
background: rgba(var(--primary-6), 0.04);
}
&.has-file {
border-style: solid;
border-color: rgba(var(--primary-6), 0.35);
background: #fff;
}
}
.upload-icon {
font-size: 28px;
color: #86909c;
margin-bottom: 8px;
&.is-file {
color: var(--app-primary-color);
}
}
.upload-placeholder {
font-size: 13px;
color: #4e5969;
margin-bottom: 4px;
}
.upload-file-name {
font-size: 13px;
font-weight: 500;
color: #1d2129;
margin-bottom: 4px;
word-break: break-all;
}
.upload-tip {
font-size: 12px;
color: #86909c;
}
.hidden-input {
display: none;
}
.status-row {
display: flex;
align-items: center;
gap: 8px;
padding-top: 4px;
border-top: 1px solid #eef0f3;
margin-top: 4px;
}
.status-label {
font-size: 13px;
color: #4e5969;
}
.mapping-empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 420px;
}
.mapping-hint {
margin-bottom: 12px;
padding: 8px 12px;
font-size: 13px;
color: #4e5969;
background: #f7f8fa;
border-radius: 4px;
border: 1px solid #eef0f3;
}
.mapping-group {
margin-bottom: 14px;
}
.mapping-group-title {
font-size: 13px;
font-weight: 600;
color: #1d2129;
margin-bottom: 8px;
}
.mapping-table-wrap {
border: 1px solid #e8eaed;
border-radius: 4px;
overflow: hidden;
}
.mapping-table {
width: 100%;
border-collapse: collapse;
th,
td {
border-bottom: 1px solid #eef0f3;
padding: 8px 12px;
text-align: left;
vertical-align: middle;
}
th {
background: #f7f8fa;
font-size: 12px;
font-weight: 600;
color: #4e5969;
}
tr:last-child td {
border-bottom: none;
}
:deep(.arco-select) {
width: 100%;
min-width: 180px;
}
}
.field-label-col {
width: 40%;
min-width: 200px;
}
.required-star {
color: #f53f3f;
margin-right: 4px;
}
.field-key {
display: block;
margin-top: 2px;
font-size: 12px;
color: #86909c;
}
.mapping-summary {
margin-top: 8px;
font-size: 13px;
}
.summary-warn {
color: #f53f3f;
margin-bottom: 6px;
}
.summary-muted {
color: #86909c;
}
</style>