diff --git a/nginx-default.conf b/nginx-default.conf index 4b1c0fe..dd0e5ba 100644 --- a/nginx-default.conf +++ b/nginx-default.conf @@ -116,6 +116,9 @@ server { location ^~ /order/batchJobs { try_files /index.html =200; } + location ^~ /order/templateMapping { + try_files /index.html =200; + } # ============ 系统管理前端路由(^~ 须比下方 /system/ API 正则代理更优先)============ # 页面路径为 camelCase;API 为 kebab(如 /system/dict/list、/system/batch-email/…),勿用宽泛前缀匹配 dict diff --git a/nginx-docker.conf b/nginx-docker.conf index 555a096..ef23466 100644 --- a/nginx-docker.conf +++ b/nginx-docker.conf @@ -45,6 +45,7 @@ server { location ^~ /order/placeOrder { try_files /index.html =200; } location ^~ /order/myOrders { try_files /index.html =200; } location ^~ /order/batchJobs { try_files /index.html =200; } + location ^~ /order/templateMapping { try_files /index.html =200; } location ^~ /channel/salesProducts { try_files /index.html =200; } location ^~ /channel/serviceChannels { try_files /index.html =200; } diff --git a/src/api/customer/index.ts b/src/api/customer/index.ts index d117e8e..8641c53 100644 --- a/src/api/customer/index.ts +++ b/src/api/customer/index.ts @@ -78,6 +78,12 @@ function mapUpdateBody(id: string | number, data: Partial) { } } +export interface CustomerServiceAdvisorDto { + name?: string + wechatId?: string + wechatQrCode?: string +} + export const CustomerService = { async getList(params: CustomerListParams): Promise { const res = await axiosInstance.get(omsUrl('/customer/list'), { params }) @@ -89,6 +95,11 @@ export const CustomerService = { return unwrapData(res.data) }, + async getServiceAdvisor(): Promise { + const res = await axiosInstance.get(omsUrl('/customer/service-advisor')) + return unwrapData(res.data) + }, + async createSub( data: Partial & { password?: string; permissions?: string[] } ): Promise { diff --git a/src/api/order/index.ts b/src/api/order/index.ts index 205f8e4..cb769ee 100644 --- a/src/api/order/index.ts +++ b/src/api/order/index.ts @@ -54,6 +54,16 @@ function assertBusinessOk(body: any) { } } +/** 兼容 data 包裹或顶层直出(camel/Pascal) */ +function unwrapApiData>(body: any): T { + if (body == null) return {} as T + const wrapped = body.data ?? body.Data + if (wrapped !== undefined && wrapped !== null) { + return wrapped as T + } + return body as T +} + /** 取出分页载荷(兼容 data 包裹或顶层直出、items/list/records、camel/Pascal) */ function parseOrderListPayload(resBody: any): OrderListResult { assertBusinessOk(resBody) @@ -321,7 +331,7 @@ function parseOrderBatchJob(raw: any): { finishedAt?: string createTime?: string } { - const d = raw?.data ?? raw?.Data ?? raw ?? {} + const d = unwrapApiData(raw) return { jobId: Number(d.jobId ?? d.JobId ?? 0), action: String(d.action ?? d.Action ?? ''), @@ -543,7 +553,8 @@ export async function getOrderBatchJobItems( ): Promise>> { const res = await axiosInstance.get(orderUrl(`/order/batch/jobs/${jobId}/items`)) assertBusinessOk(res.data) - const list = res.data?.data ?? res.data?.Data ?? res.data ?? [] + const payload = unwrapApiData(res.data) + const list = Array.isArray(payload) ? payload : payload.items ?? payload.Items ?? [] return (Array.isArray(list) ? list : []).map(parseOrderBatchJobItem) } @@ -816,19 +827,57 @@ export async function getOrderTemplateList(params: { return parseOrderListPayload(res.data) } -/** GET /order/template/download?templateId= → Excel 文件流 */ -export async function downloadOrderTemplate(templateId: number, fileName: string) { - const res = await axiosInstance.get(orderUrl('/order/template/download'), { - params: { templateId }, - responseType: 'blob', - }) - const blob = res.data as Blob - const ct = (res.headers['content-type'] || '').toString() - if (ct.includes('application/json')) { - const text = await blob.text() - const json = JSON.parse(text) - throw new Error(json.msg ?? json.message ?? json.Message ?? '下载失败') +/** 从 axios 响应中解析二进制下载内容 */ +function resolveDownloadBlob(response: { + data?: unknown + headers?: Record +}): { blob: Blob; contentType: string } { + const headers = response?.headers ?? {} + const contentType = String(headers['content-type'] ?? headers['Content-Type'] ?? '') + const raw = response?.data + + if (raw instanceof Blob) { + return { blob: raw, contentType: contentType || raw.type } } + if (raw instanceof ArrayBuffer) { + return { + blob: new Blob([raw], { + type: contentType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }), + contentType, + } + } + if (raw instanceof Uint8Array) { + return { + blob: new Blob([raw], { + type: contentType || 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + }), + contentType, + } + } + if (raw && typeof raw === 'object') { + const body = raw as Record + throw new Error( + String(body.msg ?? body.Msg ?? body.message ?? body.Message ?? '下载失败'), + ) + } + throw new Error('下载响应无效') +} + +async function parseDownloadErrorBlob(blob: Blob): Promise { + const text = await blob.text() + try { + const json = JSON.parse(text) + throw new Error(String(json.msg ?? json.message ?? json.Message ?? '下载失败')) + } catch (error: unknown) { + if (error instanceof Error && error.message !== '下载失败') { + throw error + } + throw new Error(text || '下载失败') + } +} + +function triggerBrowserFileDownload(blob: Blob, fileName: string) { const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url @@ -840,6 +889,146 @@ export async function downloadOrderTemplate(templateId: number, fileName: string URL.revokeObjectURL(url) } +/** GET /order/template/download?templateId= → Excel 文件流 */ +export async function downloadOrderTemplate(templateId: number, fileName: string) { + const response = await axiosInstance.get(orderUrl('/order/template/download'), { + params: { templateId }, + responseType: 'arraybuffer', + }) + const { blob, contentType } = resolveDownloadBlob(response) + const ct = contentType || blob.type || '' + if (ct.includes('application/json') || ct.includes('text/json')) { + await parseDownloadErrorBlob(blob) + } + if (!blob.size) { + throw new Error('下载文件为空') + } + triggerBrowserFileDownload(blob, fileName) +} + +export interface OrderTemplateFieldSchemaItem { + fieldKey: string + label: string + required: boolean + dataType: string + dictType?: string + group: string + example: string +} + +export interface OrderTemplateCustomDetail { + id: number + name: string + type: string + standardTemplateKey?: string + mappingStatus?: string + sourceFileName?: string + sourceHeaders: string[] + mapping?: { + standardTemplateKey?: string + headerRowIndex?: number + dataStartRowIndex?: number + mappings?: Array<{ standardFieldKey: string; sourceColumn: string; required?: boolean }> + unmappedSourceColumns?: string[] + } + standardFields: OrderTemplateFieldSchemaItem[] +} + +/** GET /order/template/field-schema */ +export async function getOrderTemplateFieldSchema(templateKey?: string) { + const res = await axiosInstance.get(orderUrl('/order/template/field-schema'), { + params: { templateKey: templateKey ?? '' }, + }) + assertBusinessOk(res.data) + return (res.data?.data ?? res.data?.Data ?? res.data) as { + templateKey: string + templateName: string + fields: OrderTemplateFieldSchemaItem[] + } +} + +/** POST /order/template/custom/upload */ +export async function postOrderTemplateCustomUpload(name: string, file: File) { + const form = new FormData() + form.append('name', name) + form.append('file', file) + const res = await axiosInstance.post(orderUrl('/order/template/custom/upload'), form) + assertBusinessOk(res.data) + const d = unwrapApiData(res.data) + const templateId = Number(d.templateId ?? d.TemplateId ?? d.id ?? d.Id) + if (!Number.isFinite(templateId) || templateId <= 0) { + throw new Error('上传成功但未返回模板 ID') + } + return { + templateId, + name: String(d.name ?? d.Name ?? name), + mappingStatus: String(d.mappingStatus ?? d.MappingStatus ?? 'draft'), + sourceHeaders: (d.sourceHeaders ?? d.SourceHeaders ?? []) as string[], + } +} + +/** GET /order/template/custom/detail */ +export async function getOrderTemplateCustomDetail(templateId: number): Promise { + const res = await axiosInstance.get(orderUrl('/order/template/custom/detail'), { + params: { templateId }, + }) + assertBusinessOk(res.data) + const d = unwrapApiData(res.data) + return { + id: Number(d.id ?? d.Id ?? templateId), + name: String(d.name ?? d.Name ?? ''), + type: String(d.type ?? d.Type ?? 'custom'), + standardTemplateKey: d.standardTemplateKey ?? d.StandardTemplateKey, + mappingStatus: d.mappingStatus ?? d.MappingStatus, + sourceFileName: d.sourceFileName ?? d.SourceFileName, + sourceHeaders: (d.sourceHeaders ?? d.SourceHeaders ?? []) as string[], + mapping: d.mapping ?? d.Mapping, + standardFields: (d.standardFields ?? d.StandardFields ?? []) as OrderTemplateFieldSchemaItem[], + } +} + +/** POST /order/template/custom/save-mapping */ +export async function postOrderTemplateSaveMapping(body: { + templateId: number + standardTemplateKey?: string + headerRowIndex?: number + dataStartRowIndex?: number + mappings: Array<{ standardFieldKey: string; sourceColumn: string }> +}) { + const templateId = Number(body.templateId) + if (!Number.isFinite(templateId) || templateId <= 0) { + throw new Error('模板 ID 无效') + } + const payload = { + TemplateId: templateId, + StandardTemplateKey: body.standardTemplateKey, + HeaderRowIndex: body.headerRowIndex ?? 1, + DataStartRowIndex: body.dataStartRowIndex ?? 2, + Mappings: body.mappings.map((item) => ({ + StandardFieldKey: item.standardFieldKey, + SourceColumn: item.sourceColumn, + })), + } + const res = await axiosInstance.post(orderUrl('/order/template/custom/save-mapping'), payload) + assertBusinessOk(res.data) + const d = unwrapApiData(res.data) + return { + templateId: Number(d.templateId ?? d.TemplateId ?? templateId), + mappingStatus: String(d.mappingStatus ?? d.MappingStatus ?? 'draft'), + missingRequiredFields: (d.missingRequiredFields ?? d.MissingRequiredFields ?? []) as string[], + } +} + +/** DELETE /order/template/custom/{id} */ +export async function deleteOrderTemplateCustom(templateId: number) { + const res = await axiosInstance.delete(orderUrl(`/order/template/custom/${templateId}`)) + const body = res?.data + if (body == null || body === '') { + return + } + assertBusinessOk(body) +} + /** POST /order/upload multipart: file + templateId */ export async function postOrderUpload( file: File, @@ -850,7 +1039,7 @@ export async function postOrderUpload( form.append('templateId', String(templateId)) const res = await axiosInstance.post(orderUrl('/order/upload'), form) assertBusinessOk(res.data) - const d = res.data?.data ?? res.data?.Data ?? {} + const d = unwrapApiData(res.data) const taskId = d.taskId ?? d.TaskId const jobId = d.jobId ?? d.JobId const asyncFlag = d.async ?? d.Async diff --git a/src/api/system/warehouse.ts b/src/api/system/warehouse.ts index 5635a88..ea0e347 100644 --- a/src/api/system/warehouse.ts +++ b/src/api/system/warehouse.ts @@ -9,9 +9,6 @@ export interface WarehouseDto { recipient?: string contactPhone?: string shippingAddress?: string - servicePhone?: string - serviceWechat?: string - serviceWechatFiles?: string[] createTime?: string updateTime?: string createBy?: string diff --git a/src/components.d.ts b/src/components.d.ts index 49746eb..df3e867 100644 --- a/src/components.d.ts +++ b/src/components.d.ts @@ -19,6 +19,7 @@ declare module 'vue' { CustomLeftMenu: typeof import('./components/CustomLeftMenu/index.vue')['default'] CustomList: typeof import('./components/CustomList/index.vue')['default'] DrawerPro: typeof import('./components/DrawerPro/index.vue')['default'] + ExcelFileIcon: typeof import('./components/icons/ExcelFileIcon.vue')['default'] Footer: typeof import('./components/footer/index.vue')['default'] FormPro: typeof import('./components/FormPro/index.vue')['default'] FormWrapper: typeof import('./components/global-setting/form-wrapper.vue')['default'] @@ -58,5 +59,6 @@ declare module 'vue' { TagsAdd: typeof import('./components/TagsAdd/index.vue')['default'] Timezone: typeof import('./components/Timezone/index.vue')['default'] TopNav: typeof import('./components/top-nav/index.vue')['default'] + UserProfileFields: typeof import('./components/UserProfileFields/index.vue')['default'] } } diff --git a/src/components/ContactService/index.vue b/src/components/ContactService/index.vue index e8ea009..674c0f0 100644 --- a/src/components/ContactService/index.vue +++ b/src/components/ContactService/index.vue @@ -1,151 +1,22 @@ diff --git a/src/components/NanaAssistant/index.vue b/src/components/NanaAssistant/index.vue index f0f5222..d410bb0 100644 --- a/src/components/NanaAssistant/index.vue +++ b/src/components/NanaAssistant/index.vue @@ -5,60 +5,84 @@ class="nana-panel" :style="panelStyle" > -
- {{ t('nanaAssistant.title') }} - -
-
-
-
- Nana -
-
{{ msg.content }}
+ + +
+
+
+ Nana +
+
{{ msg.content }}
+
+
+
+ Nana +
+ + + +
+
-
-
- Nana -
- - - -
-
-
-
- - - {{ t('nanaAssistant.send') }} - -
-

- {{ t('nanaAssistant.noProvider') }} -

-
+
+ + + {{ t('nanaAssistant.send') }} + +
+

+ {{ t('nanaAssistant.noProvider') }} +

+
+ + + +
+
+ +
+ + +
+
+
+ + + + + + + + + + + + + `, +)}`; + const { t } = useI18n(); const visible = ref(false); +const activeTab = ref<'ai' | 'advisor'>('ai'); const inputText = ref(''); const chatLoading = ref(false); const providerLoading = ref(false); @@ -109,6 +156,11 @@ const nanaX = ref(0); const nanaY = ref(0); const viewport = ref({ width: window.innerWidth, height: window.innerHeight }); +const advisorLoading = ref(false); +const advisorLoaded = ref(false); +const advisor = ref({}); +const advisorQrLoadFailed = ref(false); + const dragState = ref({ active: false, moved: false, @@ -153,6 +205,31 @@ const panelStyle = computed(() => { }; }); +function resolveWechatQrUrl(url?: string | null): string { + if (!url?.trim()) return ''; + const value = url.trim(); + if (isValidAvatarUrl(value)) { + if (value.indexOf('//') === -1 && !value.startsWith('data:') && !value.startsWith('/')) { + if (value.indexOf('QiNiuYunFile/') === -1) { + return `/api/upload/${value}`; + } + return `/${value}`; + } + return value; + } + return ''; +} + +const advisorQrSrc = computed(() => { + if (advisorQrLoadFailed.value) return WECHAT_QR_PLACEHOLDER_SVG; + const resolved = resolveWechatQrUrl(advisor.value.wechatQrCode); + return resolved || ''; +}); + +function onAdvisorQrError() { + advisorQrLoadFailed.value = true; +} + function clampNanaPosition() { const maxX = viewport.value.width - FAB_SIZE - EDGE_PADDING; const maxY = viewport.value.height - FAB_SIZE - EDGE_PADDING; @@ -190,10 +267,35 @@ function ensureGreeting() { scrollToBottom(); } +async function loadAdvisor() { + if (advisorLoading.value) return; + advisorLoading.value = true; + advisorQrLoadFailed.value = false; + try { + advisor.value = await CustomerService.getServiceAdvisor(); + advisorLoaded.value = true; + } catch { + advisor.value = {}; + advisorLoaded.value = true; + } finally { + advisorLoading.value = false; + } +} + +function onTabChange(key: string | number) { + if (key === 'advisor' && !advisorLoaded.value) { + void loadAdvisor(); + } +} + function openChat() { visible.value = true; - ensureGreeting(); - scrollToBottom(); + if (activeTab.value === 'ai') { + ensureGreeting(); + scrollToBottom(); + } else if (!advisorLoaded.value) { + void loadAdvisor(); + } } function closeChat() { @@ -291,6 +393,13 @@ watch( () => scrollToBottom(), ); +watch( + () => advisor.value.wechatQrCode, + () => { + advisorQrLoadFailed.value = false; + }, +); + onMounted(() => { initNanaPosition(); loadProvider(); @@ -365,39 +474,25 @@ onUnmounted(() => { overflow: hidden; } -.nana-panel__header { +.nana-panel__tabs { + flex: 1; + min-height: 0; display: flex; - align-items: center; - justify-content: space-between; - height: 48px; - padding: 0 12px 0 16px; - border-bottom: 1px solid #eef2f6; - background: #fff; - flex-shrink: 0; -} + flex-direction: column; -.nana-panel__title { - font-size: 15px; - font-weight: 600; - color: #1f2937; -} + :deep(.arco-tabs-nav) { + padding: 8px 12px 0; + } -.nana-panel__close { - display: inline-flex; - align-items: center; - justify-content: center; - width: 28px; - height: 28px; - padding: 0; - border: none; - border-radius: 4px; - background: transparent; - color: #64748b; - cursor: pointer; + :deep(.arco-tabs-content) { + flex: 1; + min-height: 0; + padding-top: 0; + } - &:hover { - background: #f1f5f9; - color: #334155; + :deep(.arco-tabs-content-list), + :deep(.arco-tabs-pane) { + height: 100%; } } @@ -405,9 +500,10 @@ onUnmounted(() => { display: flex; flex-direction: column; gap: 12px; - flex: 1; + height: 100%; min-height: 0; padding: 12px 16px 16px; + box-sizing: border-box; } .nana-chat__messages { @@ -516,4 +612,81 @@ onUnmounted(() => { color: #94a3b8; text-align: center; } + +.nana-advisor { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-start; + height: 100%; + min-height: 0; + padding: 20px 16px 16px; + box-sizing: border-box; + overflow-y: auto; +} + +.nana-advisor__loading { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + +.nana-advisor__name { + font-size: 18px; + font-weight: 600; + color: #1f2937; + margin-bottom: 16px; + text-align: center; +} + +.nana-advisor__qr-wrap { + width: 168px; + height: 168px; + border: 2px dashed #e5e6eb; + border-radius: 12px; + background: #f7f8fa; + display: flex; + align-items: center; + justify-content: center; + padding: 8px; + box-sizing: border-box; + margin-bottom: 16px; +} + +.nana-advisor__qr { + width: 128px; + height: 128px; + object-fit: contain; + border-radius: 8px; + display: block; +} + +.nana-advisor__qr-placeholder { + font-size: 12px; + color: #94a3b8; + text-align: center; + padding: 0 8px; +} + +.nana-advisor__wechat { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; +} + +.nana-advisor__label { + font-size: 12px; + color: #64748b; +} + +.nana-advisor__value { + font-size: 15px; + font-weight: 500; + color: #1f2937; + word-break: break-all; + text-align: center; +} diff --git a/src/components/UserProfileFields/index.vue b/src/components/UserProfileFields/index.vue new file mode 100644 index 0000000..4e274aa --- /dev/null +++ b/src/components/UserProfileFields/index.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/src/components/icons/ExcelFileIcon.vue b/src/components/icons/ExcelFileIcon.vue new file mode 100644 index 0000000..94771d9 --- /dev/null +++ b/src/components/icons/ExcelFileIcon.vue @@ -0,0 +1,52 @@ + + + + + diff --git a/src/composables/useOperationScanFeedback.ts b/src/composables/useOperationScanFeedback.ts new file mode 100644 index 0000000..822b312 --- /dev/null +++ b/src/composables/useOperationScanFeedback.ts @@ -0,0 +1,51 @@ +import { ref } from 'vue'; +import { useOperationVoiceBroadcast } from './useOperationVoiceBroadcast'; + +export type ScanFeedbackType = 'idle' | 'success' | 'error' | 'warning'; + +/** + * 扫描页左侧反馈区 + 语音播报。 + */ +export function useOperationScanFeedback(pageKey: string) { + const { voiceEnabled, speak, stopSpeak } = useOperationVoiceBroadcast(pageKey); + const feedbackType = ref('idle'); + const feedbackMessage = ref(''); + + function clearFeedback() { + feedbackType.value = 'idle'; + feedbackMessage.value = ''; + } + + function showFeedback(type: ScanFeedbackType, message: string, withVoice = true) { + feedbackType.value = type; + feedbackMessage.value = message; + if (withVoice && (type === 'error' || type === 'success' || type === 'warning')) { + speak(message); + } + } + + function showError(message: string, withVoice = true) { + showFeedback('error', message, withVoice); + } + + function showSuccess(message: string, withVoice = true) { + showFeedback('success', message, withVoice); + } + + function showWarning(message: string, withVoice = true) { + showFeedback('warning', message, withVoice); + } + + return { + voiceEnabled, + feedbackType, + feedbackMessage, + speak, + stopSpeak, + clearFeedback, + showFeedback, + showError, + showSuccess, + showWarning, + }; +} diff --git a/src/composables/useOperationVoiceBroadcast.ts b/src/composables/useOperationVoiceBroadcast.ts new file mode 100644 index 0000000..2692bab --- /dev/null +++ b/src/composables/useOperationVoiceBroadcast.ts @@ -0,0 +1,66 @@ +import { ref, watch } from 'vue'; + +function storageKey(pageKey: string) { + return `oms.operation.voiceBroadcast.${pageKey}`; +} + +function pickChineseVoice(): SpeechSynthesisVoice | undefined { + const voices = window.speechSynthesis?.getVoices?.() ?? []; + return ( + voices.find((v) => v.lang === 'zh-CN') || + voices.find((v) => v.lang.startsWith('zh')) || + voices[0] + ); +} + +/** + * 操作扫描页语音播报(Web Speech API)。 + */ +export function useOperationVoiceBroadcast(pageKey: string) { + const voiceEnabled = ref(true); + + try { + const stored = localStorage.getItem(storageKey(pageKey)); + if (stored != null) voiceEnabled.value = stored === '1'; + } catch { + /* ignore */ + } + + watch(voiceEnabled, (v) => { + try { + localStorage.setItem(storageKey(pageKey), v ? '1' : '0'); + } catch { + /* ignore */ + } + if (!v) { + window.speechSynthesis?.cancel?.(); + } + }); + + if (typeof window !== 'undefined' && window.speechSynthesis) { + window.speechSynthesis.onvoiceschanged = () => { + pickChineseVoice(); + }; + } + + function speak(text: string) { + const content = text?.trim(); + if (!voiceEnabled.value || !content || typeof window === 'undefined') return; + if (!('speechSynthesis' in window)) return; + + window.speechSynthesis.cancel(); + const utterance = new SpeechSynthesisUtterance(content); + utterance.lang = 'zh-CN'; + utterance.rate = 1.05; + utterance.pitch = 1; + const voice = pickChineseVoice(); + if (voice) utterance.voice = voice; + window.speechSynthesis.speak(utterance); + } + + function stopSpeak() { + window.speechSynthesis?.cancel?.(); + } + + return { voiceEnabled, speak, stopSpeak }; +} diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index 938700e..f303afd 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -79,6 +79,7 @@ export default { 'menu.order': 'Order', 'menu.order.priceCheck': 'Price Check', 'menu.order.placeOrder': 'Place Order', + 'menu.order.templateMapping': 'Template Mapping', 'menu.order.myOrders': 'My Orders', 'menu.order.batchJobs': 'Batch Jobs', 'menu.channel': 'Channel', @@ -345,9 +346,14 @@ export default { 'nav.contactService.phone': 'Contact phone', 'nav.contactService.workingTime': 'Service hours: Mon-Sun 9:00-22:00', 'nav.contactService.contactUs': 'Contact support', - 'nanaAssistant.title': 'Nana · Logistics Assistant', - 'nanaAssistant.open': 'Open Nana logistics assistant', - 'nanaAssistant.greeting': "Hi, I'm Nana, your logistics assistant.\n\nAsk me about tracking, shipping times, declared values, customs clearance, and more. How can I help you today?", + 'nanaAssistant.title': 'Nana · Exclusive Advisor', + 'nanaAssistant.open': 'Open Nana exclusive advisor', + 'nanaAssistant.tabAi': 'AI Nana', + 'nanaAssistant.tabAdvisor': 'Exclusive Advisor', + 'nanaAssistant.wechatId': 'WeChat ID', + 'nanaAssistant.noAdvisor': 'No exclusive advisor assigned yet', + 'nanaAssistant.noWechatQr': 'WeChat QR code not uploaded', + 'nanaAssistant.greeting': "Hi, I'm Nana, your AI assistant.\n\nAsk me about tracking, shipping times, declared values, customs clearance, and more. How can I help you today?", 'nanaAssistant.inputPlaceholder': 'Type your logistics question, press Enter to send', 'nanaAssistant.send': 'Send', 'nanaAssistant.close': 'Close', @@ -379,10 +385,10 @@ export default { 'order.myOrders.handleStatus.context.problematic': 'Problematic handling', 'order.myOrders.handleStatus.context.claimed': 'Claim item handling', - 'order.myOrders.actions.pay': 'Pay', - 'order.myOrders.actions.predeclare': 'Pre-declare', + 'order.myOrders.actions.pay': 'Pay Now', + 'order.myOrders.actions.predeclare': 'Re-predeclare', 'order.myOrders.actions.createTrackOrder': 'Create track order', - 'order.myOrders.actions.forwardDownstreamCreate': 'Forward downstream order', + 'order.myOrders.actions.forwardDownstreamCreate': 'Get Tracking No.', 'order.myOrders.modal.downstreamForwardTitle': 'Downstream forward result', 'order.myOrders.actions.intercept': 'Intercept', 'order.myOrders.actions.cancelIntercept': 'Cancel intercept', @@ -449,6 +455,7 @@ export default { 'order.myOrders.table.ioss': 'IOSS', 'order.myOrders.table.eori': 'EORI', 'order.myOrders.table.orderStatus': 'Order Status', + 'order.myOrders.table.predeclareFailReason': 'Predeclare Failure Reason', 'order.myOrders.table.createTime': 'Create Time', 'order.myOrders.table.receiveTime': 'Receive Time', 'order.myOrders.table.shipTime': 'Ship Time', @@ -506,6 +513,7 @@ export default { 'order.myOrders.drawer.cargoAttribute.sensitive': 'Sensitive', 'order.myOrders.table.cargoAttribute': 'Cargo Attribute', 'order.myOrders.drawer.orderStatus': 'Order Status', + 'order.myOrders.drawer.predeclareFailReason': 'Predeclare Failure Reason', 'order.myOrders.drawer.warehouse': 'Warehouse', 'order.myOrders.drawer.receiveTime': 'Receive Time', 'order.myOrders.drawer.shipTime': 'Ship Time', @@ -727,6 +735,12 @@ export default { // Order - Place Order 'order.placeOrder.template.label': 'Standard template', 'order.placeOrder.template.download': 'Download', + 'order.placeOrder.template.edit': 'Edit', + 'order.placeOrder.template.delete': 'Delete', + 'order.placeOrder.template.cancel': 'Cancel', + 'order.placeOrder.template.deleteSuccess': 'Custom template deleted', + 'order.placeOrder.template.deleteConfirmTitle': 'Delete custom template', + 'order.placeOrder.template.deleteConfirmContent': 'Delete template "{name}"? This cannot be undone.', 'order.placeOrder.template.standard1': 'Standard template 1', 'order.placeOrder.template.standard2': 'Standard template 2', 'order.placeOrder.template.custom1': 'Custom template 1', @@ -736,6 +750,34 @@ export default { 'order.placeOrder.upload.tip1': 'Please upload an Excel file and fill in required data according to the template.', 'order.placeOrder.upload.tip2': 'Maximum 2000 orders per upload.', 'order.placeOrder.batchOrder': 'Batch order', + 'order.placeOrder.batch.submitted': 'Batch job #{jobId} submitted ({total} items). Opening Batch Jobs…', + 'order.placeOrder.batch.viewJobs': 'View batch jobs', + 'order.placeOrder.batch.uploadTitle': 'Upload & submit', + 'order.placeOrder.batch.stepTemplate': 'Select template', + 'order.placeOrder.batch.stepUpload': 'Upload Excel', + 'order.placeOrder.batch.stepSubmit': 'Submit', + 'order.placeOrder.upload.downloadTemplate': 'Download template', + 'order.placeOrder.upload.fileReady': 'File ready to submit', + 'order.placeOrder.upload.reselect': 'Reselect', + 'order.placeOrder.upload.clearFile': 'Clear', + 'order.placeOrder.upload.invalidType': 'Only .xlsx / .xls files are supported', + 'order.placeOrder.upload.selectTemplateFirst': 'Please select a template first', + 'order.placeOrder.templatePanel.title': 'Templates', + 'order.placeOrder.templatePanel.hint': 'Click a card to select template', + 'order.placeOrder.templatePanel.emptyCustom': 'No custom templates', + 'order.placeOrder.templatePanel.emptyStandard': 'No standard templates. Contact admin or refresh the page.', + 'order.placeOrder.agreement.label': 'I have read and agree to all terms', + 'order.placeOrder.singleOrder': 'Single order', + 'order.placeOrder.single.formTitle': 'Order details', + 'order.placeOrder.single.sampleOrder': 'Generate sample', + 'order.placeOrder.single.stepBasic': 'Basic info', + 'order.placeOrder.single.stepSender': 'Sender', + 'order.placeOrder.single.stepRecipient': 'Recipient', + 'order.placeOrder.single.stepDeclaration': 'Declaration & submit', + 'order.placeOrder.single.tip1': 'Fields marked * are required. Fill in each section in order.', + 'order.placeOrder.single.tip2': 'Use “Generate sample” to try the full flow quickly.', + 'order.placeOrder.single.submitHint': 'Complete required fields and accept the agreement to submit', + 'order.placeOrder.single.submitReady': 'Ready to submit', 'order.placeOrder.standardTemplates': 'Standard templates', 'order.placeOrder.customTemplates': 'Custom templates', @@ -1132,7 +1174,7 @@ export default { 'system.organization.form.name': 'Organization Name', 'system.organization.form.namePlaceholder': 'Enter organization name', 'system.organization.form.code': 'Organization Code', - 'system.organization.form.codePlaceholder': 'Enter organization code', + 'system.organization.form.codePlaceholder': 'Auto-generated after save', 'system.organization.form.type': 'Organization Type', 'system.organization.form.typePlaceholder': 'Select organization type', 'system.organization.form.type.company': 'Company', @@ -1178,6 +1220,17 @@ export default { 'system.user.form.avatarPlaceholder': 'Enter avatar URL', 'system.user.form.roles': 'Roles', 'system.user.form.rolesPlaceholder': 'Select roles', + 'system.user.form.phone': 'Phone', + 'system.user.form.phonePlaceholder': 'Enter phone number', + 'system.user.form.employeeNo': 'Employee No.', + 'system.user.form.employeeNoPlaceholder': 'Auto-generated', + 'system.user.form.wechatId': 'WeChat ID', + 'system.user.form.wechatIdPlaceholder': 'Enter WeChat ID', + 'system.user.form.wechatQrCode': 'WeChat QR Code', + 'system.user.form.wechatQrUpload': 'Upload QR code', + 'system.user.table.employeeNo': 'Employee No.', + 'system.user.table.phone': 'Phone', + 'system.user.message.wechatQrTooLarge': 'WeChat QR image must be under 2MB', 'system.user.search.organizationPlaceholder': 'Filter by organization', 'system.user.search.accountOrNamePlaceholder': 'Search username or account', 'system.user.rules.name.required': 'Please enter user name', @@ -1268,7 +1321,7 @@ export default { 'system.org.form.containsPosition': 'Include positions', 'system.org.form.placeholder.selectParentOrg': 'Select parent organization', 'system.org.form.placeholder.orgName': 'Enter organization name', - 'system.org.form.placeholder.orgCode': 'Enter organization code, leave blank for random value', + 'system.org.form.placeholder.orgCode': 'Auto-generated after save', 'system.org.form.placeholder.selectCategory': 'Select category', 'system.org.form.placeholder.selectStatus': 'Select status', 'system.org.form.placeholder.selectTargetOrg': 'Select target organization', @@ -1479,6 +1532,10 @@ export default { // Operation Module - Common 'operation.common.keepScanFocus': 'Keep focus on scan field', + 'operation.common.voiceBroadcast': 'Voice broadcast', + 'operation.common.feedback.idle': 'Scan a number — result will appear here', + 'operation.common.message.orderNotFound': 'Order not found', + 'operation.common.message.lookupSuccess': 'Order matched', // Operation Module - Inbound Scan 'operation.inbound.pageTitle': 'Waybill Information', @@ -1512,7 +1569,10 @@ export default { 'operation.inbound.table.operateTime': 'Operate Time', 'operation.inbound.table.operator': 'Operator', 'operation.inbound.message.waybillNumberRequired': 'Please enter or scan waybill number', + 'operation.inbound.message.metricsRequired': 'Please enter received weight and dimensions', 'operation.inbound.message.inboundSuccess': 'Inbound successful', + 'operation.inbound.message.inboundFailed': 'Inbound failed', + 'operation.inbound.message.loadRecentFailed': 'Failed to load recent inbound records', // Operation Module - Outbound Scan 'operation.outbound.pageTitle': 'Waybill Information', @@ -1533,6 +1593,7 @@ export default { 'operation.outbound.form.hint.supportedTypes': 'Supported: Waybill number only', 'operation.outbound.actions.reset': 'Reset', 'operation.outbound.actions.outbound': 'Outbound', + 'operation.outbound.recentOrders.title': 'Recent orders', 'operation.outbound.recentOrders.subtitle': 'Maximum 50 recent orders displayed here', 'operation.outbound.recentOrders.empty': 'No data', 'operation.outbound.table.index': 'Index', @@ -1547,6 +1608,8 @@ export default { 'operation.outbound.table.operator': 'Operator', 'operation.outbound.message.waybillNumberRequired': 'Please enter or scan waybill number', 'operation.outbound.message.outboundSuccess': 'Outbound successful', + 'operation.outbound.message.outboundFailed': 'Outbound failed', + 'operation.outbound.message.loadRecentFailed': 'Failed to load recent outbound records', // Operation Module - Print Label 'operation.printLabel.pageTitle': 'Print Label', diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index d5dd0b7..0c86b12 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -79,6 +79,7 @@ export default { 'menu.order': '订单', 'menu.order.priceCheck': '我要查价', 'menu.order.placeOrder': '我要下单', + 'menu.order.templateMapping': '模板字段映射', 'menu.order.myOrders': '我的订单', 'menu.order.batchJobs': '批量任务', 'menu.channel': '渠道', @@ -172,9 +173,14 @@ export default { 'nav.contactService.phone': '联系电话', 'nav.contactService.workingTime': '工作时间:周一至周日 9:00-22:00', 'nav.contactService.contactUs': '联系客服', - 'nanaAssistant.title': 'Nana · 物流小助手', - 'nanaAssistant.open': '打开 Nana 物流助手', - 'nanaAssistant.greeting': '嗨,我是 Nana,你的物流小助手~\n\n无论是查轨迹、了解渠道时效,还是申报价值和清关问题,都可以问我。今天有什么可以帮你的吗?', + 'nanaAssistant.title': 'Nana 专属顾问', + 'nanaAssistant.open': '打开 Nana 专属顾问', + 'nanaAssistant.tabAi': 'AI Nana', + 'nanaAssistant.tabAdvisor': '专属顾问', + 'nanaAssistant.wechatId': '微信号', + 'nanaAssistant.noAdvisor': '暂未分配专属顾问', + 'nanaAssistant.noWechatQr': '暂未上传微信二维码', + 'nanaAssistant.greeting': '嗨,我是 Nana~,我是你的AI智能客服\n\n无论是查轨迹、了解渠道时效,还是申报价值和清关问题,都可以问我。今天有什么可以帮你的吗?', 'nanaAssistant.inputPlaceholder': '输入你的物流问题,Enter 发送', 'nanaAssistant.send': '发送', 'nanaAssistant.close': '关闭', @@ -264,10 +270,10 @@ export default { 'order.myOrders.handleStatus.context.problematic': '问题件处理进度', 'order.myOrders.handleStatus.context.claimed': '理赔件处理进度', - 'order.myOrders.actions.pay': '支付', - 'order.myOrders.actions.predeclare': '预报', + 'order.myOrders.actions.pay': '立即支付', + 'order.myOrders.actions.predeclare': '重新预报', 'order.myOrders.actions.createTrackOrder': '创建轨迹订单', - 'order.myOrders.actions.forwardDownstreamCreate': '预报下游订单', + 'order.myOrders.actions.forwardDownstreamCreate': '获取跟踪号', 'order.myOrders.modal.downstreamForwardTitle': '下游预报结果', 'order.myOrders.actions.intercept': '拦截', 'order.myOrders.actions.cancelIntercept': '取消拦截', @@ -334,6 +340,7 @@ export default { 'order.myOrders.table.ioss': 'IOSS', 'order.myOrders.table.eori': 'EORI', 'order.myOrders.table.orderStatus': '订单状态', + 'order.myOrders.table.predeclareFailReason': '预报失败原因', 'order.myOrders.table.createTime': '创建时间', 'order.myOrders.table.receiveTime': '收货时间', 'order.myOrders.table.shipTime': '发货时间', @@ -391,6 +398,7 @@ export default { 'order.myOrders.drawer.cargoAttribute.sensitive': '敏感货', 'order.myOrders.table.cargoAttribute': '货物属性', 'order.myOrders.drawer.orderStatus': '订单状态', + 'order.myOrders.drawer.predeclareFailReason': '预报失败原因', 'order.myOrders.drawer.warehouse': '仓库', 'order.myOrders.drawer.receiveTime': '收货时间', 'order.myOrders.drawer.shipTime': '发货时间', @@ -612,6 +620,12 @@ export default { // 订单 - 我要下单 'order.placeOrder.template.label': '标准模板', 'order.placeOrder.template.download': '下载', + 'order.placeOrder.template.edit': '编辑', + 'order.placeOrder.template.delete': '删除', + 'order.placeOrder.template.cancel': '取消', + 'order.placeOrder.template.deleteSuccess': '自定义模板已删除', + 'order.placeOrder.template.deleteConfirmTitle': '删除自定义模板', + 'order.placeOrder.template.deleteConfirmContent': '确定删除模板「{name}」吗?删除后不可恢复。', 'order.placeOrder.template.standard1': '标准模板1', 'order.placeOrder.template.standard2': '标准模板2', 'order.placeOrder.template.custom1': '自定义模板1', @@ -621,6 +635,34 @@ export default { 'order.placeOrder.upload.tip1': '请上传excel文件,根据对应模板填写必填数据。', 'order.placeOrder.upload.tip2': '最大上传订单数量2000单。', 'order.placeOrder.batchOrder': '批量下单', + 'order.placeOrder.batch.submitted': '已提交批量任务 #{jobId}(共 {total} 条),正在跳转批量任务页…', + 'order.placeOrder.batch.viewJobs': '查看批量任务', + 'order.placeOrder.batch.uploadTitle': '上传并提交', + 'order.placeOrder.batch.stepTemplate': '选择模板', + 'order.placeOrder.batch.stepUpload': '上传 Excel', + 'order.placeOrder.batch.stepSubmit': '提交下单', + 'order.placeOrder.upload.downloadTemplate': '下载模板', + 'order.placeOrder.upload.fileReady': '文件已就绪,可提交下单', + 'order.placeOrder.upload.reselect': '重新选择', + 'order.placeOrder.upload.clearFile': '清除', + 'order.placeOrder.upload.invalidType': '仅支持 .xlsx / .xls 文件', + 'order.placeOrder.upload.selectTemplateFirst': '请先选择模板', + 'order.placeOrder.templatePanel.title': '模板库', + 'order.placeOrder.templatePanel.hint': '点击卡片可快速选中模板', + 'order.placeOrder.templatePanel.emptyCustom': '暂无自定义模板', + 'order.placeOrder.templatePanel.emptyStandard': '暂无标准模板,请联系管理员或刷新页面', + 'order.placeOrder.agreement.label': '我已阅读并同意本协议全部条款', + 'order.placeOrder.singleOrder': '单票下单', + 'order.placeOrder.single.formTitle': '填写订单信息', + 'order.placeOrder.single.sampleOrder': '生成示例订单', + 'order.placeOrder.single.stepBasic': '基本信息', + 'order.placeOrder.single.stepSender': '发件人', + 'order.placeOrder.single.stepRecipient': '收件人', + 'order.placeOrder.single.stepDeclaration': '申报并提交', + 'order.placeOrder.single.tip1': '带 * 号为必填项,请按区块依次填写订单信息。', + 'order.placeOrder.single.tip2': '可先点击「生成示例订单」快速体验完整流程。', + 'order.placeOrder.single.submitHint': '请完善必填信息并勾选协议后提交', + 'order.placeOrder.single.submitReady': '信息已完善,可提交下单', 'order.placeOrder.standardTemplates': '标准模板', 'order.placeOrder.customTemplates': '自定义模板', @@ -1017,7 +1059,7 @@ export default { 'system.organization.form.name': '组织名称', 'system.organization.form.namePlaceholder': '请输入组织名称', 'system.organization.form.code': '组织编码', - 'system.organization.form.codePlaceholder': '请输入组织编码', + 'system.organization.form.codePlaceholder': '保存后自动生成', 'system.organization.form.type': '组织类型', 'system.organization.form.typePlaceholder': '请选择组织类型', 'system.organization.form.type.company': '公司', @@ -1063,6 +1105,17 @@ export default { 'system.user.form.avatarPlaceholder': '请输入头像URL', 'system.user.form.roles': '角色', 'system.user.form.rolesPlaceholder': '请选择角色', + 'system.user.form.phone': '电话', + 'system.user.form.phonePlaceholder': '请输入电话', + 'system.user.form.employeeNo': '工号', + 'system.user.form.employeeNoPlaceholder': '自动生成', + 'system.user.form.wechatId': '微信号', + 'system.user.form.wechatIdPlaceholder': '请输入微信号', + 'system.user.form.wechatQrCode': '微信二维码', + 'system.user.form.wechatQrUpload': '上传二维码', + 'system.user.table.employeeNo': '工号', + 'system.user.table.phone': '电话', + 'system.user.message.wechatQrTooLarge': '微信二维码图片不能超过 2MB', 'system.user.search.organizationPlaceholder': '选择组织筛选', 'system.user.search.accountOrNamePlaceholder': '搜索用户名或账号', 'system.user.rules.name.required': '请输入用户名', @@ -1153,7 +1206,7 @@ export default { 'system.org.form.containsPosition': '包含职位', 'system.org.form.placeholder.selectParentOrg': '请选择上级组织', 'system.org.form.placeholder.orgName': '请输入组织名称', - 'system.org.form.placeholder.orgCode': '请填写组织编码,不填则为随机值', + 'system.org.form.placeholder.orgCode': '保存后自动生成', 'system.org.form.placeholder.selectCategory': '请选择分类', 'system.org.form.placeholder.selectStatus': '请选择状态', 'system.org.form.placeholder.selectTargetOrg': '请选择目标组织', @@ -1497,6 +1550,10 @@ export default { // 操作模块 - 通用 'operation.common.keepScanFocus': '焦点维持', + 'operation.common.voiceBroadcast': '语音播报', + 'operation.common.feedback.idle': '请扫描单号,结果将在此显示', + 'operation.common.message.orderNotFound': '未找到对应订单', + 'operation.common.message.lookupSuccess': '订单匹配成功', // 操作模块 - 入库扫描 'operation.inbound.pageTitle': '运单信息', @@ -1530,7 +1587,10 @@ export default { 'operation.inbound.table.operateTime': '操作时间', 'operation.inbound.table.operator': '操作人', 'operation.inbound.message.waybillNumberRequired': '请输入或扫描运单号', + 'operation.inbound.message.metricsRequired': '请填写收货实重与长宽高', 'operation.inbound.message.inboundSuccess': '签入成功', + 'operation.inbound.message.inboundFailed': '入库失败', + 'operation.inbound.message.loadRecentFailed': '加载最近入库记录失败', // 操作模块 - 出库扫描 'operation.outbound.pageTitle': '运单信息', @@ -1551,6 +1611,7 @@ export default { 'operation.outbound.form.hint.supportedTypes': '支持:仅支持运单号', 'operation.outbound.actions.reset': '重置', 'operation.outbound.actions.outbound': '出库', + 'operation.outbound.recentOrders.title': '最近操作的订单', 'operation.outbound.recentOrders.subtitle': '此处最多显示最近操作的50张订单', 'operation.outbound.recentOrders.empty': '暂无数据', 'operation.outbound.table.index': '序号', @@ -1565,6 +1626,8 @@ export default { 'operation.outbound.table.operator': '操作人', 'operation.outbound.message.waybillNumberRequired': '请输入或扫描运单号', 'operation.outbound.message.outboundSuccess': '出库成功', + 'operation.outbound.message.outboundFailed': '出库失败', + 'operation.outbound.message.loadRecentFailed': '加载最近出库记录失败', // 操作模块 - 打印标签 'operation.printLabel.pageTitle': '打印标签', diff --git a/src/router/routes/newModules/order.ts b/src/router/routes/newModules/order.ts index 4aa1b21..7968fc3 100644 --- a/src/router/routes/newModules/order.ts +++ b/src/router/routes/newModules/order.ts @@ -39,6 +39,16 @@ const ORDER: AppRouteRecordRaw = { requiresAuth: true, }, }, + { + path: 'templateMapping', + name: 'OrderTemplateMapping', + component: () => import('@/views/order/templateMapping/index.vue'), + meta: { + locale: 'menu.order.templateMapping', + requiresAuth: true, + hideInMenu: true, + }, + }, { path: 'batchJobs', name: 'OrderBatchJobs', diff --git a/src/types/rbac-user-profile.ts b/src/types/rbac-user-profile.ts new file mode 100644 index 0000000..2125f9b --- /dev/null +++ b/src/types/rbac-user-profile.ts @@ -0,0 +1,49 @@ +export type UserProfileExt = { + phone?: string + employeeNo?: string + wechatId?: string + wechatQrCode?: string +} + +export type UserFormExtState = UserProfileExt & { + wechatQrFiles?: Array<{ + uid?: string + name?: string + url?: string + status?: string + response?: { url?: string } + }> +} + +function pickStr(row: Record, ...keys: string[]): string { + for (const key of keys) { + const v = row[key] + if (v != null && String(v).trim() !== '') return String(v).trim() + } + return '' +} + +export function pickUserProfileExt( + user: Record | null | undefined, +): UserProfileExt { + if (!user || typeof user !== 'object') return {} + return { + phone: pickStr(user, 'phone', 'Phone'), + employeeNo: pickStr(user, 'employeeNo', 'EmployeeNo'), + wechatId: pickStr(user, 'wechatId', 'WechatId'), + wechatQrCode: pickStr(user, 'wechatQrCode', 'WechatQrCode'), + } +} + +export function toUserProfilePayload(state: UserFormExtState): UserProfileExt { + const files = state.wechatQrFiles || [] + const fromFile = files + .map((f) => f.response?.url ?? f.url) + .find((u) => u != null && String(u).trim() !== '') + return { + phone: state.phone?.trim() || undefined, + employeeNo: state.employeeNo?.trim() || undefined, + wechatId: state.wechatId?.trim() || undefined, + wechatQrCode: (fromFile ? String(fromFile) : state.wechatQrCode?.trim()) || undefined, + } +} diff --git a/src/utils/image-data-url.ts b/src/utils/image-data-url.ts new file mode 100644 index 0000000..b125136 --- /dev/null +++ b/src/utils/image-data-url.ts @@ -0,0 +1,30 @@ +export const DEFAULT_MAX_IMAGE_BYTES = 2 * 1024 * 1024 + +export function readFileAsDataUrl( + file: File, + maxBytes = DEFAULT_MAX_IMAGE_BYTES, + tooLargeMessage = 'Image is too large', +): Promise { + if (file.size > maxBytes) { + return Promise.reject(new Error(tooLargeMessage)) + } + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = () => resolve(String(reader.result || '')) + reader.onerror = () => reject(reader.error || new Error('read failed')) + reader.readAsDataURL(file) + }) +} + +export function urlToUploadFileList(url?: string | null, prefix = 'img') { + if (!url || String(url).trim() === '') return [] + const value = String(url) + return [ + { + uid: `${prefix}-${value.slice(0, 24)}`, + name: 'image', + url: value, + status: 'done', + }, + ] +} diff --git a/src/utils/orgLeftMenuTree.ts b/src/utils/orgLeftMenuTree.ts index 17378ee..e4addfa 100644 --- a/src/utils/orgLeftMenuTree.ts +++ b/src/utils/orgLeftMenuTree.ts @@ -1,6 +1,11 @@ import type { OrganizationTreeDto } from '@/api/rbac' import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types' +/** 岗位为末级,不可再挂下级;公司/部门/小组均可新增子组织 */ +export function canOrganizationHaveChildren(type?: number | null): boolean { + return type !== 4 +} + export function buildOrganizationLeftMenuTree( data: OrganizationTreeDto[], getTypeLabel?: (type?: number, depth?: number, childCount?: number) => string, @@ -9,19 +14,23 @@ export function buildOrganizationLeftMenuTree( return data.map((item) => { const hasChildren = Boolean(item.children?.length) const childCount = item.children?.length ?? 0 + const isLeaf = !canOrganizationHaveChildren(item.type) return { key: `org-${item.id}`, title: item.name || item.code || '', - isLeaf: !hasChildren, + isLeaf, id: item.id, name: item.name, code: item.code || '', + type: item.type, depth, childCount, subtitle: getTypeLabel?.(item.type, depth, childCount), children: hasChildren ? buildOrganizationLeftMenuTree(item.children!, getTypeLabel, depth + 1) - : undefined, + : isLeaf + ? undefined + : [], } }) } diff --git a/src/utils/organization-code.ts b/src/utils/organization-code.ts new file mode 100644 index 0000000..9d54d0b --- /dev/null +++ b/src/utils/organization-code.ts @@ -0,0 +1,62 @@ +import type { OrganizationTreeDto } from '@/api/rbac'; + +/** 组织类型 → 编码前缀 */ +export const ORGANIZATION_TYPE_CODES: Record = { + 1: 'GS', // 公司 + 2: 'BM', // 部门 + 3: 'XZ', // 小组 + 4: 'GW', // 岗位 +}; + +export function getOrganizationTypeCode(type?: number | null): string { + return ORGANIZATION_TYPE_CODES[type ?? 0] ?? 'ORG'; +} + +export function flattenOrganizationTree( + nodes: OrganizationTreeDto[] | null | undefined, +): OrganizationTreeDto[] { + const list: OrganizationTreeDto[] = []; + const walk = (items: OrganizationTreeDto[]) => { + for (const item of items) { + list.push(item); + if (item.children?.length) { + walk(item.children); + } + } + }; + if (nodes?.length) { + walk(nodes); + } + return list; +} + +/** + * 按「组织类型编码 + 两位序号」生成唯一组织编码,如 BM01、GW02。 + */ +export function generateOrganizationCode( + type?: number | null, + organizations: Array<{ code?: string | null }> = [], +): string { + const typeCode = getOrganizationTypeCode(type); + const pattern = new RegExp(`^${escapeRegExp(typeCode)}(\\d+)$`, 'i'); + let maxSeq = 0; + + for (const org of organizations) { + const code = org.code?.trim(); + if (!code) continue; + const matched = code.match(pattern); + if (!matched) continue; + const seq = Number.parseInt(matched[1], 10); + if (Number.isFinite(seq) && seq > maxSeq) { + maxSeq = seq; + } + } + + const next = maxSeq + 1; + const width = Math.max(2, String(next).length); + return `${typeCode}${String(next).padStart(width, '0')}`; +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/src/utils/route-name-mapper.ts b/src/utils/route-name-mapper.ts index 01a9cd9..50d751b 100644 --- a/src/utils/route-name-mapper.ts +++ b/src/utils/route-name-mapper.ts @@ -10,6 +10,8 @@ const routeNameMap: Record = { '/order/priceCheck': 'PriceCheck', 'order_my_orders': 'MyOrders', '/order/myOrders': 'MyOrders', + 'order_template_mapping': 'OrderTemplateMapping', + '/order/templateMapping': 'OrderTemplateMapping', 'order_batch_jobs': 'OrderBatchJobs', '/order/batchJobs': 'OrderBatchJobs', 'order': 'Order', diff --git a/src/utils/user-employee-no.ts b/src/utils/user-employee-no.ts new file mode 100644 index 0000000..e035d46 --- /dev/null +++ b/src/utils/user-employee-no.ts @@ -0,0 +1,17 @@ +const EMPLOYEE_NO_PATTERN = /^NE(\d{4})$/i + +export function generateEmployeeNo( + existing: Array<{ employeeNo?: string | null; EmployeeNo?: string | null }>, +): string { + let max = 0 + for (const row of existing) { + const raw = String(row.employeeNo ?? row.EmployeeNo ?? '').trim() + const match = raw.match(EMPLOYEE_NO_PATTERN) + if (match) { + max = Math.max(max, Number.parseInt(match[1], 10)) + } + } + const next = max + 1 + if (next > 9999) return `NE${next}` + return `NE${String(next).padStart(4, '0')}` +} diff --git a/src/views/login/index.vue b/src/views/login/index.vue index 3d6f00e..5e76f97 100644 --- a/src/views/login/index.vue +++ b/src/views/login/index.vue @@ -24,7 +24,7 @@