feat: 数据字典左右分栏并拆分类型/明细分页接口

左侧仅拉取类型列表与项数,右侧按选中类型服务端分页;修复批量导入重复项识别、字段兼容及类型数量统计。
This commit is contained in:
zzh 2026-07-22 09:25:00 +08:00
parent 00285ac6a8
commit d4447cc7d4
6 changed files with 1277 additions and 186 deletions

View File

@ -1,9 +1,23 @@
/**
* OMS admin 接口通用:与 /order 同源(开发环境走 Vite 代理路径前缀)
*/
import type { AxiosError } from 'axios'
import { axiosInstance } from '@/api/rbac'
import { resolveAdminApiPath, resolveAdminAssetOrigin } from '@/utils/adminApi'
/** 从 axios / 业务响应中提取可读错误信息 */
export function extractOmsRequestError(err: unknown): string {
const axiosErr = err as AxiosError<{ msg?: string; Msg?: string; message?: string; Message?: string }>
const data = axiosErr?.response?.data
if (data && typeof data === 'object') {
const msg = data.msg ?? data.Msg ?? data.message ?? data.Message
if (msg != null && String(msg).trim()) {
return String(msg).trim()
}
}
return err instanceof Error ? err.message : String(err)
}
export function omsUrl(path: string): string {
return resolveAdminApiPath(path)
}

View File

@ -1,5 +1,11 @@
import { axiosInstance } from '@/api/rbac'
import { assertBusinessOk, omsUrl, parseListPayload } from '@/api/oms/common'
import {
assertBusinessOk,
extractOmsRequestError,
omsUrl,
parseListPayload,
unwrapData,
} from '@/api/oms/common'
export interface DictDto {
id?: number | string
@ -11,6 +17,14 @@ export interface DictDto {
updateTime?: string
}
export interface DictTypeDto {
id?: number | string
dictType?: string
remark?: string
typeSort?: number
itemCount?: number
}
export type DictListParams = {
page?: number
pageSize?: number
@ -18,9 +32,26 @@ export type DictListParams = {
keyword?: string
}
function normalizeDictDto(row: Record<string, unknown>): DictDto {
const dictSortRaw = row.dictSort ?? row.DictSort
return {
id: (row.id ?? row.Id) as number | string | undefined,
dictType: String(row.dictType ?? row.DictType ?? '').trim(),
dictSort: typeof dictSortRaw === 'number' ? dictSortRaw : Number(dictSortRaw) || 0,
dictCode: String(row.dictCode ?? row.DictCode ?? '').trim(),
dictName: String(row.dictName ?? row.DictName ?? '').trim(),
createTime: (row.createTime ?? row.CreateTime) as string | undefined,
updateTime: (row.updateTime ?? row.UpdateTime) as string | undefined,
}
}
export async function getDictList(params: DictListParams) {
const res = await axiosInstance.get(omsUrl('/system/dict/list'), { params })
return parseListPayload(res.data)
const { items, total } = parseListPayload(res.data)
return {
items: (items || []).map((row) => normalizeDictDto(row as Record<string, unknown>)),
total,
}
}
/** 按当前筛选条件拉取全部字典(分页拼接),用于前端按类型排序后再分页展示 */
@ -48,8 +79,17 @@ export async function getDictListAll(params: Pick<DictListParams, 'dictType' | '
}
export async function createDict(body: Omit<DictDto, 'id' | 'createTime' | 'updateTime'>) {
const res = await axiosInstance.post(omsUrl('/system/dict/create'), body)
assertBusinessOk(res.data)
try {
const res = await axiosInstance.post(omsUrl('/system/dict/create'), {
...body,
dictType: String(body.dictType ?? '').trim(),
dictCode: String(body.dictCode ?? '').trim(),
dictName: String(body.dictName ?? '').trim(),
})
assertBusinessOk(res.data)
} catch (e) {
throw new Error(extractOmsRequestError(e))
}
}
export type DictUpdateBody = {
@ -69,3 +109,41 @@ export async function deleteDict(id: number | string) {
const res = await axiosInstance.post(omsUrl('/system/dict/delete'), { id })
assertBusinessOk(res.data)
}
function normalizeDictTypeDto(row: Record<string, unknown>): DictTypeDto {
const itemCountRaw = row.itemCount ?? row.ItemCount
const typeSortRaw = row.typeSort ?? row.TypeSort
return {
id: (row.id ?? row.Id) as number | string | undefined,
dictType: String(row.dictType ?? row.DictType ?? '').trim(),
remark: (row.remark ?? row.Remark) as string | undefined,
typeSort: typeof typeSortRaw === 'number' ? typeSortRaw : Number(typeSortRaw) || 0,
itemCount: typeof itemCountRaw === 'number' ? itemCountRaw : Number(itemCountRaw) || 0,
}
}
export async function getDictTypeList(keyword?: string) {
const res = await axiosInstance.get(omsUrl('/system/dict/type/list'), {
params: keyword ? { keyword } : undefined,
})
const payload = unwrapData<Record<string, unknown>[] | null>(res.data)
if (!Array.isArray(payload)) return []
return payload.map((row) => normalizeDictTypeDto(row))
}
export type DictTypeSaveBody = {
id?: number | string
dictType: string
remark?: string
typeSort?: number
}
export async function saveDictType(body: DictTypeSaveBody) {
const res = await axiosInstance.post(omsUrl('/system/dict/type/save'), body)
assertBusinessOk(res.data)
}
export async function deleteDictType(id: number | string) {
const res = await axiosInstance.post(omsUrl('/system/dict/type/delete'), { id })
assertBusinessOk(res.data)
}

View File

@ -38,6 +38,7 @@ export default {
// Menus (keep existing demo keys)
'menu.home': 'Home',
'menu.assistant': 'Logistics Advisor',
'menu.server.home': 'Home-Server',
'menu.server.workplace': 'Workplace-Server',
'menu.server.monitor': 'Monitor-Server',
@ -470,6 +471,8 @@ export default {
'order.platformStoreOrder.status.toShip': 'To Ship',
'order.platformStoreOrder.status.shipped': 'Shipped',
'order.platformStoreOrder.status.suspended': 'Suspended',
'order.platformStoreOrder.status.canceled': 'Canceled',
'order.platformStoreOrder.status.completed': 'Completed',
'order.platformStoreOrder.table.platform': 'Platform',
'order.platformStoreOrder.table.country': 'Country',
'order.platformStoreOrder.table.itemCount': 'Items',
@ -487,10 +490,34 @@ export default {
'order.platformStoreOrder.summary.pending': 'Pending',
'order.platformStoreOrder.summary.toShip': 'To Ship',
'order.platformStoreOrder.drawer.detail': 'Order Detail',
'order.platformStoreOrder.drawer.sectionBasic': 'Basic Info',
'order.platformStoreOrder.drawer.sectionRecipient': 'Recipient',
'order.platformStoreOrder.drawer.sectionShipping': 'Fulfillment & Shipping',
'order.platformStoreOrder.drawer.sectionLines': 'Line Items',
'order.platformStoreOrder.drawer.platformStatus': 'Platform Status',
'order.platformStoreOrder.drawer.customTags': 'Custom Tags',
'order.platformStoreOrder.drawer.multiLabel': 'Multi Label',
'order.platformStoreOrder.drawer.openLabel': 'Open Label',
'order.platformStoreOrder.recipient.name': 'Recipient',
'order.platformStoreOrder.recipient.company': 'Company',
'order.platformStoreOrder.recipient.phone': 'Phone',
'order.platformStoreOrder.recipient.telePhone': 'Tel',
'order.platformStoreOrder.recipient.email': 'Email',
'order.platformStoreOrder.recipient.country': 'Country',
'order.platformStoreOrder.recipient.province': 'State/Province',
'order.platformStoreOrder.recipient.city': 'City',
'order.platformStoreOrder.recipient.zipCode': 'Zip',
'order.platformStoreOrder.recipient.address1': 'Address 1',
'order.platformStoreOrder.recipient.address2': 'Address 2',
'order.platformStoreOrder.table.recipient': 'Recipient',
'order.platformStoreOrder.line.sku': 'Platform SKU',
'order.platformStoreOrder.line.name': 'Product',
'order.platformStoreOrder.line.qty': 'Qty',
'order.platformStoreOrder.line.mappedSku': 'Mapped SKU',
'order.platformStoreOrder.line.image': 'Image',
'order.platformStoreOrder.line.variant': 'Variant',
'order.platformStoreOrder.line.price': 'Unit Price',
'order.platformStoreOrder.line.excluded': 'Excluded',
'order.platformStoreOrder.actions.match': 'Match Inventory',
'order.platformStoreOrder.actions.batchMatch': 'Batch Match',
'order.platformStoreOrder.actions.suspend': 'Suspend',
@ -501,6 +528,56 @@ export default {
'order.platformStoreOrder.actions.syncStatus': 'Sync Status',
'order.platformStoreOrder.actions.readyToShip': 'Ready to Ship',
'order.platformStoreOrder.actions.fetchLabel': 'Re-fetch Label',
'order.platformStoreOrder.actions.createLogistics': 'Create Logistics Order',
'order.platformStoreOrder.actions.createFulfill': 'Create Logistics & Fulfill',
'order.platformStoreOrder.createLogistics.title': 'Create Logistics Order',
'order.platformStoreOrder.createLogistics.tip': 'Review recipient and line items for each order, then confirm the shipping product before submitting.',
'order.platformStoreOrder.createLogistics.product': 'Shipping Product',
'order.platformStoreOrder.createLogistics.productRequired': 'Please select a shipping product',
'order.platformStoreOrder.createLogistics.orderList': 'Selected Orders',
'order.platformStoreOrder.createLogistics.receiverTitle': 'Recipient Check',
'order.platformStoreOrder.createLogistics.linesTitle': 'Line Items',
'order.platformStoreOrder.createLogistics.readyMeta': '{total} selected · {ready} ready',
'order.platformStoreOrder.createLogistics.tagReceiver': 'Missing recipient',
'order.platformStoreOrder.createLogistics.tagLines': 'No lines',
'order.platformStoreOrder.createLogistics.tagUnbound': 'Unbound',
'order.platformStoreOrder.createLogistics.tagBound': '{n} bound',
'order.platformStoreOrder.createLogistics.tagOk': 'Ready',
'order.platformStoreOrder.createLogistics.selectRequired': 'Please select pending store orders first',
'order.platformStoreOrder.createLogistics.maxLimit': 'Up to 50 orders per batch',
'order.platformStoreOrder.createLogistics.invalidSelection': '{count} order(s) cannot be created (need pending, not suspended, no linked logistics order)',
'order.platformStoreOrder.createLogistics.fixIncomplete': 'Please fix the highlighted order before submit',
'order.platformStoreOrder.createLogistics.confirm': 'Create {count} logistics order(s) with product {product}. Continue?',
'order.platformStoreOrder.createLogistics.submit': 'Confirm Create',
'order.platformStoreOrder.createLogistics.success': 'Created {count} logistics order(s)',
'order.platformStoreOrder.createLogistics.partial': 'Success {success}, failed {fail}. {detail}',
'order.platformStoreOrder.createLogistics.failed': 'Failed to create logistics orders',
'order.platformStoreOrder.createLogistics.unitOrders': 'orders',
'order.platformStoreOrder.createFulfill.title': 'Create Logistics & Fulfill',
'order.platformStoreOrder.createFulfill.tip': 'Compare order line items with bound inventory/parcels: check product name, SKU and quantity before submit.',
'order.platformStoreOrder.createFulfill.bindTitle': 'Bind Parcel / Inventory',
'order.platformStoreOrder.createFulfill.bindHint': 'Pick on the left, confirm qty on the right; review each order before submit',
'order.platformStoreOrder.createFulfill.bindEmpty': 'Pick inventory or parcels below to bind on the right',
'order.platformStoreOrder.createFulfill.orderSide': 'Order qty',
'order.platformStoreOrder.createFulfill.bindSide': 'Bound qty',
'order.platformStoreOrder.createFulfill.pcs': 'pcs',
'order.platformStoreOrder.createFulfill.lineCount': '{n} line(s)',
'order.platformStoreOrder.createFulfill.bindCount': '{n} resource(s)',
'order.platformStoreOrder.createFulfill.compareNeedBind': 'Nothing bound yet — pick stock/parcels to match order lines',
'order.platformStoreOrder.createFulfill.compareQtyDiff': 'Qty mismatch: order {order} vs bound {bind} — please recheck',
'order.platformStoreOrder.createFulfill.compareQtyOk': 'Quantities match — still verify product names/SKUs',
'order.platformStoreOrder.createFulfill.orderGoodsTitle': 'Order line items',
'order.platformStoreOrder.createFulfill.orderGoodsHint': 'From the store order — check name and qty',
'order.platformStoreOrder.createFulfill.boundTitle': 'Bound inventory / parcels',
'order.platformStoreOrder.createFulfill.boundHint': 'What will ship from warehouse — match against the left',
'order.platformStoreOrder.createFulfill.needQty': 'Need',
'order.platformStoreOrder.createFulfill.pickTitle': 'Pick inventory / parcels',
'order.platformStoreOrder.createFulfill.orderQtyShort': 'Need {n}',
'order.platformStoreOrder.createFulfill.bindQtyShort': 'Bound {n}',
'order.platformStoreOrder.createFulfill.tagQtyDiff': 'Qty mismatch',
'order.platformStoreOrder.createFulfill.tagQtyOk': 'Qty aligned',
'order.platformStoreOrder.createFulfill.confirm': 'Create {count} logistics order(s) and bind stock. Product: {product}; Warehouse: {warehouse}. Continue?',
'order.platformStoreOrder.createFulfill.submit': 'Confirm Create & Bind',
'order.platformStoreOrder.pull.store': 'Store',
'order.platformStoreOrder.pull.storePlaceholder': 'Select stores',
'order.platformStoreOrder.pull.status': 'Status',
@ -800,13 +877,56 @@ export default {
'nanaAssistant.open': 'Open Nana exclusive advisor',
'nanaAssistant.tabAi': 'AI Nana',
'nanaAssistant.tabAdvisor': 'Exclusive Advisor',
'nanaAssistant.badge': 'Logistics Advisor',
'nanaAssistant.welcomeTitle': 'What do you need help shipping?',
'nanaAssistant.welcomeSub': 'Quotes, routes, tracking, or a tough order — ask here. One dedicated advisor from quote to delivery.',
'nanaAssistant.quickLabel': 'Common questions',
'nanaAssistant.welcomeMsg': "Hi — I'm your IDE logistics advisor. Pick a question below or type your own.",
'nanaAssistant.historyTitle': 'Chat history',
'nanaAssistant.newChat': 'New chat',
'nanaAssistant.emptyHistory': 'No conversations yet',
'nanaAssistant.searchHistory': 'Search chats or waybill no.',
'nanaAssistant.noSearchResults': 'No matching conversations',
'nanaAssistant.deleteThread': 'Delete conversation',
'nanaAssistant.hint': 'Common questions show reference answers. Type in the box to ask more.',
'nanaAssistant.hintLive': 'Smart advisor is connected — type a question or pick one below.',
'nanaAssistant.human': 'Talk to a human advisor →',
'nanaAssistant.you': 'You',
'nanaAssistant.backToChat': 'Back to chat',
'nanaAssistant.quickSmall': 'Small or difficult orders?',
'nanaAssistant.quickPrice': 'US / EU shipping cost?',
'nanaAssistant.quickQuoteSpeed': 'How fast is a quote?',
'nanaAssistant.quickTrack': 'Track my shipment',
'nanaAssistant.quickVisibility': 'Real-time tracking?',
'nanaAssistant.quickAfterSales': 'Something went wrong?',
'nanaAssistant.quickHuman': 'Talk to human advisor',
'nanaAssistant.replySmall': 'Yes. IDE focuses on SMB sellers and shipments others decline — special routes, complex cargo, and low-volume orders are in scope. Tell me origin, destination, and cargo type to get started.',
'nanaAssistant.replyPrice': 'Pricing depends on origin, weight, dimensions, and channel (air / sea / express). Share those details and we return a route-specific quote with transit options within 24 hours.',
'nanaAssistant.replyQuoteSpeed': 'Route-specific quotes are typically returned within 24 hours after you share shipment details. Urgent lanes can be prioritized — mention your target ship date.',
'nanaAssistant.trackHint': "Paste your tracking number in the box below. I'll walk you through what each milestone usually means.",
'nanaAssistant.replyVisibility': 'You get milestone updates from pickup to delivery — pickup, export, line-haul, customs, and final delivery. We proactively flag delays before they escalate.',
'nanaAssistant.replyAfterSales': "Your dedicated advisor handles exceptions, claims, and after-sales follow-up — you won't be left chasing tickets alone. Describe the issue and tracking number if you have one.",
'nanaAssistant.replyHuman': 'Reach us via the contact page — phone 18123959245 or email bd01@1st56.com. Mention your route and cargo so we assign the right advisor.',
'nanaAssistant.fallback': "Got it. Share origin, destination, cargo type, and weight for a route-specific quote — or talk to a human advisor and we'll respond within 24 hours.",
'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.inputPlaceholder': 'Type your question…',
'nanaAssistant.send': 'Send',
'nanaAssistant.close': 'Close',
'nanaAssistant.back': 'Back',
'nanaAssistant.askAboutWaybill': 'Ask logistics advisor',
'nanaAssistant.actionAdvisor': 'Advisor',
'nanaAssistant.waybillContext': 'Current waybill: {no}',
'nanaAssistant.viewWaybill': 'View waybill →',
'nanaAssistant.queryTrack': 'Track shipment',
'nanaAssistant.endWaybillConsult': 'End waybill consult',
'nanaAssistant.waybillAutoAsk': 'Please track waybill {no} for me',
'nanaAssistant.trackWaybillDemo': 'Got your request for waybill {no}. When the smart service is configured, we prefer the live API; this is a reference reply: milestones usually include pickup, export, line-haul, customs, and delivery. Your advisor will follow up on exceptions.',
'nanaAssistant.copyWechat': 'Copy WeChat ID',
'nanaAssistant.copyWechatSuccess': 'WeChat ID copied',
'nanaAssistant.copyWechatFailed': 'Copy failed — please copy manually',
'nanaAssistant.noProvider': 'AI service is not configured yet. Please contact your administrator.',
'nanaAssistant.sendFailed': 'Failed to send. Please try again later.',
'nav.balance.label': 'Balance',
@ -2141,13 +2261,35 @@ export default {
'system.dict.search.placeholder.dictNameOrValue': 'Enter dictionary name or value',
'system.dict.search.placeholder': 'Enter dictionary code or name',
'system.dict.filter.dictType': 'Dictionary Type',
'system.dict.left.category': 'Dictionary Types',
'system.dict.left.searchPlaceholder': 'Search name or type code',
'system.dict.left.refresh': 'Refresh types',
'system.dict.left.addType': 'Add type',
'system.dict.left.editType': 'Edit type',
'system.dict.left.deleteType': 'Delete type',
'system.dict.left.loading': 'Loading dictionary types...',
'system.dict.left.emptySelect': 'Select a dictionary type from the left',
'system.dict.left.emptySelectDesc': 'Then view and manage entries under that type',
'system.dict.left.emptyTypeList': 'No dictionary types yet. Click + to add one',
'system.dict.panel.itemCount': '{count} entries',
'system.dict.panel.editType': 'Edit type',
'system.dict.form.typeRemark': 'Display name',
'system.dict.form.typeSort': 'Type sort',
'system.dict.placeholder.typeRemark': 'e.g. Order status',
'system.dict.typeModal.addTitle': 'Add Dictionary Type',
'system.dict.typeModal.editTitle': 'Edit Dictionary Type',
'system.dict.typeModal.addSuccess': 'Dictionary type added',
'system.dict.typeModal.editSuccess': 'Dictionary type updated',
'system.dict.typeModal.deleteSuccess': 'Dictionary type metadata deleted',
'system.dict.typeModal.deleteConfirm': 'Delete metadata for type [{dictType}]? Dictionary items will not be removed.',
'system.dict.message.loadTypeFailed': 'Failed to load dictionary types',
'system.dict.actions.add': 'Add',
'system.dict.actions.edit': 'Edit',
'system.dict.actions.delete': 'Delete',
'system.dict.actions.batchImport': 'Batch import',
'system.dict.batchImport.confirmTitle': 'Batch import dictionaries',
'system.dict.batchImport.confirmContent':
'Predefined dictionary rows from the OMS design spec will be created; existing rows with the same dictType and dictCode are skipped. Import order: dict types in alphabetical order; each type is fully written before the next. Rules: many rows may share one dictType; dictCode must be unique within that dictType (the same code may appear under another dictType, e.g. 0/1). Use yes_no for yes/no; use enable_status for disabled/enabled. Continue?',
'Predefined dictionary rows from the OMS design spec will be created; existing rows with the same dictType and dictCode are skipped. Import order: dict types in alphabetical order; each type is fully written before the next. Rules: many rows may share one dictType; dictCode must be unique within that dictType (the same code may appear under another dictType, e.g. 1/2). Numeric dictCode starts at 1; 0 means None and is not seeded. Use yes_no for yes/no; use enable_status for disabled/enabled. Continue?',
'system.dict.batchImport.ok': 'Import',
'system.dict.batchImport.resultDetail': 'Done: created {created}, skipped {skipped}, failed {failed}.',
'system.dict.batchImport.failed': 'Batch import failed: {error}',
@ -3206,18 +3348,30 @@ export default {
'channel.labelDesign.actions.create': 'New template',
'channel.labelDesign.actions.edit': 'Design',
'channel.labelDesign.actions.delete': 'Delete',
'channel.labelDesign.actions.refresh': 'Refresh',
'channel.labelDesign.actions.back': 'Back',
'channel.labelDesign.actions.preview': 'Preview',
'channel.labelDesign.actions.print': 'Print',
'channel.labelDesign.actions.save': 'Save',
'channel.labelDesign.actions.openTemplate': 'Open template',
'channel.labelDesign.actions.open': 'Open',
'channel.labelDesign.actions.startDesign': 'Start design',
'channel.labelDesign.actions.cancel': 'Cancel',
'channel.labelDesign.loadingList': 'Loading templates…',
'channel.labelDesign.emptySelect': 'Select a template on the left, or create one',
'channel.labelDesign.openModal.title': 'Open system template',
'channel.labelDesign.openModal.confirmTitle': 'Switch template',
'channel.labelDesign.openModal.confirmContent': 'Unsaved canvas changes will be lost. Open the selected template?',
'channel.labelDesign.message.loadFailed': 'Failed to load templates',
'channel.labelDesign.message.saveSuccess': 'Template saved',
'channel.labelDesign.message.saveFailed': 'Save failed',
'channel.labelDesign.message.deleteSuccess': 'Deleted',
'channel.labelDesign.message.nameRequired': 'Template name is required',
'channel.labelDesign.message.alreadyOpen': 'This template is already open',
'channel.labelDesign.message.openSuccess': 'Opened: {name}',
'channel.labelDesign.fieldDictionary': 'Order fields',
'channel.labelDesign.fieldsInjectedHint': 'Order fields injected (platform/custom groups hidden)',
'channel.labelDesign.fieldsInjectFailed': 'Failed to inject order fields into the designer. Please refresh.',
'channel.labelDesign.message.fieldCopied': 'Copied field: {field}',
'channel.labelDesign.message.previewEmpty': 'Nothing to preview',
'channel.labelDesign.message.previewFailed': 'PDF preview failed',

View File

@ -38,6 +38,7 @@ export default {
// 菜单(示例 / 保留原有)
'menu.home': '首页',
'menu.assistant': '物流顾问',
'menu.server.home': '首页-服务端',
'menu.server.workplace': '工作台-服务端',
'menu.server.monitor': '实时监控-服务端',
@ -470,6 +471,8 @@ export default {
'order.platformStoreOrder.status.toShip': '待发货',
'order.platformStoreOrder.status.shipped': '已发货',
'order.platformStoreOrder.status.suspended': '已搁置',
'order.platformStoreOrder.status.canceled': '已取消',
'order.platformStoreOrder.status.completed': '已完成',
'order.platformStoreOrder.table.platform': '平台',
'order.platformStoreOrder.table.country': '国家',
'order.platformStoreOrder.table.itemCount': '商品数',
@ -487,10 +490,34 @@ export default {
'order.platformStoreOrder.summary.pending': '待处理',
'order.platformStoreOrder.summary.toShip': '待发货',
'order.platformStoreOrder.drawer.detail': '订单详情',
'order.platformStoreOrder.drawer.sectionBasic': '基本信息',
'order.platformStoreOrder.drawer.sectionRecipient': '收件人信息',
'order.platformStoreOrder.drawer.sectionShipping': '履约与物流',
'order.platformStoreOrder.drawer.sectionLines': '商品明细',
'order.platformStoreOrder.drawer.platformStatus': '平台状态',
'order.platformStoreOrder.drawer.customTags': '自定义标签',
'order.platformStoreOrder.drawer.multiLabel': '多面单',
'order.platformStoreOrder.drawer.openLabel': '打开面单',
'order.platformStoreOrder.recipient.name': '收件人',
'order.platformStoreOrder.recipient.company': '公司',
'order.platformStoreOrder.recipient.phone': '手机',
'order.platformStoreOrder.recipient.telePhone': '电话',
'order.platformStoreOrder.recipient.email': '邮箱',
'order.platformStoreOrder.recipient.country': '国家',
'order.platformStoreOrder.recipient.province': '省/州',
'order.platformStoreOrder.recipient.city': '城市',
'order.platformStoreOrder.recipient.zipCode': '邮编',
'order.platformStoreOrder.recipient.address1': '地址1',
'order.platformStoreOrder.recipient.address2': '地址2',
'order.platformStoreOrder.table.recipient': '收件人',
'order.platformStoreOrder.line.sku': '平台 SKU',
'order.platformStoreOrder.line.name': '商品',
'order.platformStoreOrder.line.qty': '数量',
'order.platformStoreOrder.line.mappedSku': '库内 SKU',
'order.platformStoreOrder.line.image': '图片',
'order.platformStoreOrder.line.variant': '规格',
'order.platformStoreOrder.line.price': '单价',
'order.platformStoreOrder.line.excluded': '拒发',
'order.platformStoreOrder.actions.match': '匹配库存',
'order.platformStoreOrder.actions.batchMatch': '批量匹配',
'order.platformStoreOrder.actions.suspend': '搁置',
@ -501,6 +528,56 @@ export default {
'order.platformStoreOrder.actions.syncStatus': '同步状态',
'order.platformStoreOrder.actions.readyToShip': '准备发货',
'order.platformStoreOrder.actions.fetchLabel': '重新获取面单',
'order.platformStoreOrder.actions.createLogistics': '创建物流订单',
'order.platformStoreOrder.actions.createFulfill': '创建物流&代发订单',
'order.platformStoreOrder.createLogistics.title': '创建物流订单',
'order.platformStoreOrder.createLogistics.tip': '请逐单核对收件人与商品明细,确认运输方式后再提交,避免下错单。',
'order.platformStoreOrder.createLogistics.product': '运输方式',
'order.platformStoreOrder.createLogistics.productRequired': '请选择运输方式',
'order.platformStoreOrder.createLogistics.orderList': '待创建清单',
'order.platformStoreOrder.createLogistics.receiverTitle': '收件人校对',
'order.platformStoreOrder.createLogistics.linesTitle': '商品明细',
'order.platformStoreOrder.createLogistics.readyMeta': '共 {total} 单 · 可提交 {ready} 单',
'order.platformStoreOrder.createLogistics.tagReceiver': '缺收件人',
'order.platformStoreOrder.createLogistics.tagLines': '无商品行',
'order.platformStoreOrder.createLogistics.tagUnbound': '未绑定',
'order.platformStoreOrder.createLogistics.tagBound': '已绑 {n} 项',
'order.platformStoreOrder.createLogistics.tagOk': '可创建',
'order.platformStoreOrder.createLogistics.selectRequired': '请先勾选待履约店铺订单',
'order.platformStoreOrder.createLogistics.maxLimit': '单次最多创建 50 单',
'order.platformStoreOrder.createLogistics.invalidSelection': '有 {count} 单不可创建(需 pending、未搁置、未关联物流单)',
'order.platformStoreOrder.createLogistics.fixIncomplete': '请先补全当前标红订单的校对项',
'order.platformStoreOrder.createLogistics.confirm': '将创建 {count} 张物流单,运输方式:{product}。确认提交?',
'order.platformStoreOrder.createLogistics.submit': '确认创建',
'order.platformStoreOrder.createLogistics.success': '成功创建 {count} 张物流单',
'order.platformStoreOrder.createLogistics.partial': '成功 {success},失败 {fail}。{detail}',
'order.platformStoreOrder.createLogistics.failed': '创建物流订单失败',
'order.platformStoreOrder.createLogistics.unitOrders': '单',
'order.platformStoreOrder.createFulfill.title': '创建物流&代发订单',
'order.platformStoreOrder.createFulfill.tip': '请对照「订单要发的商品」与「已绑定的库存/包裹」:核对品名、SKU、数量是否一致,避免绑错货或数量不对。',
'order.platformStoreOrder.createFulfill.bindTitle': '绑定包裹 / 库存',
'order.platformStoreOrder.createFulfill.bindHint': '左侧挑选资源,右侧确认数量;请逐单核对后再提交',
'order.platformStoreOrder.createFulfill.bindEmpty': '请从下方挑选库存或包裹加入右侧',
'order.platformStoreOrder.createFulfill.orderSide': '订单应发',
'order.platformStoreOrder.createFulfill.bindSide': '已绑定',
'order.platformStoreOrder.createFulfill.pcs': '件',
'order.platformStoreOrder.createFulfill.lineCount': '{n} 个商品行',
'order.platformStoreOrder.createFulfill.bindCount': '{n} 项资源',
'order.platformStoreOrder.createFulfill.compareNeedBind': '尚未绑定,请先对照订单商品挑选库存/包裹',
'order.platformStoreOrder.createFulfill.compareQtyDiff': '数量不一致:订单 {order} 件,已绑 {bind} 件,请再核对',
'order.platformStoreOrder.createFulfill.compareQtyOk': '数量一致,请再确认品名/SKU 是否对应',
'order.platformStoreOrder.createFulfill.orderGoodsTitle': '订单要发的商品',
'order.platformStoreOrder.createFulfill.orderGoodsHint': '以店铺订单明细为准,请核对品名与数量',
'order.platformStoreOrder.createFulfill.boundTitle': '已绑定的库存 / 包裹',
'order.platformStoreOrder.createFulfill.boundHint': '实际从仓发出的资源,请与左侧逐项对照',
'order.platformStoreOrder.createFulfill.needQty': '应发数量',
'order.platformStoreOrder.createFulfill.pickTitle': '挑选库存 / 包裹',
'order.platformStoreOrder.createFulfill.orderQtyShort': '应发 {n}',
'order.platformStoreOrder.createFulfill.bindQtyShort': '已绑 {n}',
'order.platformStoreOrder.createFulfill.tagQtyDiff': '数量不一致',
'order.platformStoreOrder.createFulfill.tagQtyOk': '数量已对齐',
'order.platformStoreOrder.createFulfill.confirm': '将创建 {count} 张物流单并绑定代发资源。运输方式:{product};仓库:{warehouse}。确认提交?',
'order.platformStoreOrder.createFulfill.submit': '确认创建并绑定',
'order.platformStoreOrder.pull.store': '店铺',
'order.platformStoreOrder.pull.storePlaceholder': '请选择店铺',
'order.platformStoreOrder.pull.status': '类型',
@ -628,13 +705,56 @@ export default {
'nanaAssistant.open': '打开 Nana 专属顾问',
'nanaAssistant.tabAi': 'AI Nana',
'nanaAssistant.tabAdvisor': '专属顾问',
'nanaAssistant.badge': '物流顾问',
'nanaAssistant.welcomeTitle': '这次发货,需要什么帮助?',
'nanaAssistant.welcomeSub': '报价、线路、查单、疑难单 — 都可以从这里开始。专属顾问从报价跟到签收。',
'nanaAssistant.quickLabel': '常见问题',
'nanaAssistant.welcomeMsg': '你好,我是 IDE 物流顾问。可以点选下方问题,或直接输入。',
'nanaAssistant.historyTitle': '历史记录',
'nanaAssistant.newChat': '新会话',
'nanaAssistant.emptyHistory': '暂无会话',
'nanaAssistant.searchHistory': '搜索会话或运单号',
'nanaAssistant.noSearchResults': '未找到匹配的会话',
'nanaAssistant.deleteThread': '删除会话',
'nanaAssistant.hint': '以下为常见问题的参考回复,输入框可继续提问。',
'nanaAssistant.hintLive': '已接入智能顾问,可直接输入或点选下方问题。',
'nanaAssistant.human': '联系人工顾问 →',
'nanaAssistant.you': '你',
'nanaAssistant.backToChat': '返回对话',
'nanaAssistant.quickSmall': '小批量 / 疑难单能发吗?',
'nanaAssistant.quickPrice': '到美国 / 欧洲多少钱?',
'nanaAssistant.quickQuoteSpeed': '多久出报价?',
'nanaAssistant.quickTrack': '查物流单号',
'nanaAssistant.quickVisibility': '能实时追踪吗?',
'nanaAssistant.quickAfterSales': '出问题了怎么办?',
'nanaAssistant.quickHuman': '联系人工顾问',
'nanaAssistant.replySmall': '可以。IDE 专注中小卖家和别人不接的单 — 特殊线路、复杂货型、小批量都在服务范围内。告诉我起运地、目的地和货型即可开始。',
'nanaAssistant.replyPrice': '价格取决于起运地、重量、尺寸和渠道(空运 / 海运 / 专线)。提供这些信息后,我们会在 24 小时内给出含时效选项的线路报价。',
'nanaAssistant.replyQuoteSpeed': '提交货物信息后,通常 24 小时内返回线路报价。若有时效要求,请注明目标发货日期,我们会优先处理。',
'nanaAssistant.trackHint': '请在下方输入框粘贴单号,我会说明各节点通常代表什么状态。',
'nanaAssistant.replyVisibility': '从揽收到签收有关键节点更新 — 揽收、出口、干线、清关、妥投。异常升级前我们会主动预警。',
'nanaAssistant.replyAfterSales': '专属顾问负责异常、理赔与售后跟进,不用自己排队工单。请说明问题,如有单号一并提供。',
'nanaAssistant.replyHuman': '请前往联系页 — 电话 18123959245 或邮箱 bd01@1st56.com。说明线路和货型,我们会安排对应顾问。',
'nanaAssistant.fallback': '收到。请补充起运地、目的地、货型与重量以便报价;也可联系人工顾问,24 小时内回复。',
'nanaAssistant.wechatId': '微信号',
'nanaAssistant.noAdvisor': '暂未分配专属顾问',
'nanaAssistant.noWechatQr': '暂未上传微信二维码',
'nanaAssistant.greeting': '嗨,我是 Nana~,我是你的AI智能客服\n\n无论是查轨迹、了解渠道时效,还是申报价值和清关问题,都可以问我。今天有什么可以帮你的吗?',
'nanaAssistant.inputPlaceholder': '输入你的物流问题,Enter 发送',
'nanaAssistant.inputPlaceholder': '输入您的问题…',
'nanaAssistant.send': '发送',
'nanaAssistant.close': '关闭',
'nanaAssistant.back': '返回',
'nanaAssistant.askAboutWaybill': '咨询物流顾问',
'nanaAssistant.actionAdvisor': '问顾问',
'nanaAssistant.waybillContext': '当前运单:{no}',
'nanaAssistant.viewWaybill': '查看运单 →',
'nanaAssistant.queryTrack': '查轨迹',
'nanaAssistant.endWaybillConsult': '结束运单咨询',
'nanaAssistant.waybillAutoAsk': '请帮我查询运单 {no} 的物流轨迹',
'nanaAssistant.trackWaybillDemo': '已收到运单 {no} 的查询。若已配置智能服务,将优先走真实接口;当前为参考回复:一般可在揽收、出口、干线、清关、妥投等节点看到更新。如有异常,专属顾问会主动跟进。',
'nanaAssistant.copyWechat': '复制微信号',
'nanaAssistant.copyWechatSuccess': '微信号已复制',
'nanaAssistant.copyWechatFailed': '复制失败,请手动复制',
'nanaAssistant.noProvider': 'AI 服务暂未配置,请联系管理员开启',
'nanaAssistant.sendFailed': '发送失败,请稍后重试',
'nav.balance.label': '余额',
@ -2027,6 +2147,28 @@ export default {
'system.dict.search.placeholder.dictNameOrValue': '请输入字典名称或值',
'system.dict.search.placeholder': '请输入字典编码或名称',
'system.dict.filter.dictType': '字典类型',
'system.dict.left.category': '字典类型',
'system.dict.left.searchPlaceholder': '搜索类型名称或编码',
'system.dict.left.refresh': '刷新类型列表',
'system.dict.left.addType': '新增类型',
'system.dict.left.editType': '编辑类型',
'system.dict.left.deleteType': '删除类型',
'system.dict.left.loading': '正在加载字典类型...',
'system.dict.left.emptySelect': '请从左侧选择字典类型',
'system.dict.left.emptySelectDesc': '选择后可查看并维护该类型下的字典项',
'system.dict.left.emptyTypeList': '暂无字典类型,点击右上角 + 新增',
'system.dict.panel.itemCount': '共 {count} 项',
'system.dict.panel.editType': '编辑类型',
'system.dict.form.typeRemark': '中文名称',
'system.dict.form.typeSort': '类型排序',
'system.dict.placeholder.typeRemark': '请输入中文名称,如:订单状态',
'system.dict.typeModal.addTitle': '新增字典类型',
'system.dict.typeModal.editTitle': '编辑字典类型',
'system.dict.typeModal.addSuccess': '字典类型新增成功',
'system.dict.typeModal.editSuccess': '字典类型更新成功',
'system.dict.typeModal.deleteSuccess': '字典类型元数据已删除',
'system.dict.typeModal.deleteConfirm': '确定删除字典类型【{dictType}】的元数据吗?字典项不会被删除。',
'system.dict.message.loadTypeFailed': '加载字典类型失败',
'system.dict.actions.add': '新增',
'system.dict.actions.edit': '修改',
'system.dict.actions.delete': '删除',
@ -2166,7 +2308,7 @@ export default {
'system.dict.export.failed': '导出失败',
'system.dict.batchImport.confirmTitle': '批量导入字典',
'system.dict.batchImport.confirmContent':
'将按 OMS 设计文档写入预置字典项;已存在的「字典类型+字典编码」会自动跳过。导入顺序:字典类型按字母序,同一类型全部写入后再写下一类型。规则:字典类型可多条共用;同一字典类型下字典编码必须唯一(不同字典类型下可出现相同编码,如均为 0/1)。通用是否类请用 yes_no;启用/禁用类请用 enable_status。是否继续?',
'将按 OMS 设计文档写入预置字典项;已存在的「字典类型+字典编码」会自动跳过。导入顺序:字典类型按字母序,同一类型全部写入后再写下一类型。规则:字典类型可多条共用;同一字典类型下字典编码必须唯一(不同字典类型下可出现相同编码,如均为 1/2)。数值 dictCode 从 1 起编,0 表示 None 不写入种子。通用是否类请用 yes_no;启用/禁用类请用 enable_status。是否继续?',
'system.dict.batchImport.ok': '开始导入',
'system.dict.batchImport.resultDetail': '导入完成:新增 {created} 条,跳过 {skipped} 条,失败 {failed} 条。',
'system.dict.batchImport.failed': '批量导入失败:{error}',
@ -3226,18 +3368,30 @@ export default {
'channel.labelDesign.actions.create': '新建模板',
'channel.labelDesign.actions.edit': '设计',
'channel.labelDesign.actions.delete': '删除',
'channel.labelDesign.actions.refresh': '刷新列表',
'channel.labelDesign.actions.back': '返回列表',
'channel.labelDesign.actions.preview': '预览',
'channel.labelDesign.actions.print': '打印',
'channel.labelDesign.actions.save': '保存',
'channel.labelDesign.actions.openTemplate': '打开模板',
'channel.labelDesign.actions.open': '打开',
'channel.labelDesign.actions.startDesign': '开始设计',
'channel.labelDesign.actions.cancel': '取消',
'channel.labelDesign.loadingList': '正在加载模板…',
'channel.labelDesign.emptySelect': '请在左侧选择模板,或新建一个',
'channel.labelDesign.openModal.title': '打开系统模板',
'channel.labelDesign.openModal.confirmTitle': '切换模板',
'channel.labelDesign.openModal.confirmContent': '当前画布未保存的修改将丢失,确定打开所选模板?',
'channel.labelDesign.message.loadFailed': '加载模板失败',
'channel.labelDesign.message.saveSuccess': '模板已保存',
'channel.labelDesign.message.saveFailed': '保存失败',
'channel.labelDesign.message.deleteSuccess': '已删除',
'channel.labelDesign.message.nameRequired': '请填写模板名称',
'channel.labelDesign.message.alreadyOpen': '该模板已在设计器中',
'channel.labelDesign.message.openSuccess': '已打开:{name}',
'channel.labelDesign.fieldDictionary': '订单字段',
'channel.labelDesign.fieldsInjectedHint': '已注入订单字段(已隐藏平台/自定义分组)',
'channel.labelDesign.fieldsInjectFailed': '订单字段未能注入设计器,请刷新后重试',
'channel.labelDesign.message.fieldCopied': '已复制字段:{field}',
'channel.labelDesign.message.previewEmpty': '暂无可预览的模板',
'channel.labelDesign.message.previewFailed': '预览 PDF 失败',

View File

@ -1,14 +1,16 @@
/**
* OMS 设计文档(oms表结构与接口设计)字典种子数据 + 批量导入。
* - 多条记录可共用同一字典类型(dictType);**同一 dictType 下字典编码(dictCode)唯一**,不允许重复。
* - 全表业务唯一键为 **dictType + dictCode**;不同 dictType 下可出现相同 dictCode(如多个类型均有 `0`/`1`)。
* - 全表业务唯一键为 **dictType + dictCode**;不同 dictType 下可出现相同 dictCode(如多个类型均有 `1`/`2`)。
* - 通用是否类:`yes_no`;禁用/启用类:`enable_status`;发布与快照 operator:`operator_type`;
* 子账号状态与客户状态编码一致,复用 `customer_status`。
* - **数值 dictCode 从 1 起编**;`0` 保留表示 None/未设置,不写入字典种子。
*/
import * as XLSX from 'xlsx'
import { createDict, getDictList, type DictDto } from '@/api/system/dict'
import { createDict, getDictListAll, getDictTypeList, saveDictType, type DictDto } from '@/api/system/dict'
export const DICT_IMPORT_HEADERS = ['字典类型', '字典排序', '字典编码', '字典名称'] as const
export const DICT_TYPE_IMPORT_HEADERS = ['字典类型', '类型排序', '类型备注'] as const
const HEADER_FIELD_MAP: Record<string, keyof DictSeedRow> = {
字典类型: 'dictType',
@ -30,6 +32,89 @@ export interface DictSeedRow {
dictSort: number
}
export interface DictTypeRemarkRow {
dictType: string
typeSort: number
remark: string
}
/** OMS 预置字典类型的中文名称(Remark 字段),供左侧类型菜单展示与批量导入同步 */
export const OMS_DICT_TYPE_REMARKS: Record<string, string> = {
yes_no: '通用是否',
enable_status: '启用状态',
order_status: '订单状态',
cargo_attribute: '货物属性',
order_weight_type: '件重尺类型',
order_fee_type: '费用类型',
currency: '币种',
order_template_type: '订单模板类型',
scheme: '排货方案',
area_type: '分区类型',
operator_type: '发布操作类型',
forecast_type: '预报类型',
cost_unit: '计费单位',
calculation_formula: '计算公式',
billable_weight: '计重取值',
weight_rule_condition: '计重条件',
sales_product_group: '卖价分组',
customer_waybill: '客户面单',
label_template_type: '面单模板类型',
number_type: '打印号码类型',
auto_trigger_timing: '自动触发时机',
auto_trigger_operation: '自动触发操作',
pricing_factor: '定价因素',
customer_status: '客户状态',
customer_group: '客户组',
recharge_status: '充值审核状态',
pay_type: '支付方式',
principal_type: '主体类型',
notice_type: '通知类型',
receiver_type: '接收方类型',
warehouse_type: '仓库类型',
es_snapshot_status: '快照生效状态',
publish_record_type: '发布记录类型',
order_task_status: '订单导入任务状态',
export_customs_mode: '出口报关方式',
fee_item: '费用项',
order_handle_status: '订单处理子状态',
order_source: '订单来源',
issue_type: '问题件类型',
issue_notify_method: '问题通知方式',
claim_type: '理赔类型',
order_fee_charging_stage: '费用计费阶段',
customer_address_type: '客户地址类型',
label_generate_status: '面单生成状态',
order_stock_bind_type: '库存绑定类型',
order_stock_bind_status: '库存绑定状态',
warehouse_inbound_type: '入库类型',
warehouse_receipt_status: '收货状态',
warehouse_parcel_lifecycle: '包裹生命周期',
sales_store_auth_status: '店铺授权状态',
sales_store_fulfill_status: '平台店铺履约状态',
order_batch_job_status: '批量任务状态',
order_batch_job_item_status: '批量任务明细状态',
order_template_mapping_status: '模板映射状态',
customer_account_flow_biz_type: '账户流水业务类型',
secret_level: '密级',
data_scope: '数据范围',
org_scope: '组织范围',
}
/** 按预置种子中的 dictType 生成类型备注行(含排序) */
export function getOmsDictTypeRemarkRows(
seedRows: DictSeedRow[] = OMS_DICT_BATCH_SEED,
remarks: Record<string, string> = OMS_DICT_TYPE_REMARKS,
): DictTypeRemarkRow[] {
const types = [...new Set(seedRows.map((row) => row.dictType.trim()).filter(Boolean))].sort((a, b) =>
a.localeCompare(b, 'en', { sensitivity: 'base' }),
)
return types.map((dictType, index) => ({
dictType,
typeSort: index + 1,
remark: remarks[dictType] ?? '',
}))
}
function pack(dictType: string, pairs: [string, string][]): DictSeedRow[] {
return pairs.map(([dictCode, dictName], i) => ({
dictType,
@ -42,8 +127,8 @@ function pack(dictType: string, pairs: [string, string][]): DictSeedRow[] {
/** 与设计文档附录及表字段说明对齐;可按需增删后重新导入 */
export const OMS_DICT_BATCH_SEED: DictSeedRow[] = [
...pack('yes_no', [
['0', '否'],
['1', '是'],
['1', '否'],
['2', '是'],
]),
...pack('order_status', [
['draft', '草稿'],
@ -92,7 +177,7 @@ export const OMS_DICT_BATCH_SEED: DictSeedRow[] = [
...pack('area_type', [
['blacklist', '黑名单区'],
['remote', '偏远分区'],
['price', '价格分区'],
['warning', '警告区'],
]),
/** 发布记录 OperatorType、ES 快照 operatorType 等均可复用 */
...pack('operator_type', [
@ -114,10 +199,10 @@ export const OMS_DICT_BATCH_SEED: DictSeedRow[] = [
['unit', '单价'],
['continue_unit', '续单价'],
]),
/** 卖价项/买价项启用、认证账号状态等:0-禁用 1-启用 */
/** 卖价项/买价项启用、认证账号状态等:1-禁用 2-启用(0=None 不落库) */
...pack('enable_status', [
['0', '禁用'],
['1', '启用'],
['1', '禁用'],
['2', '启用'],
]),
...pack('billable_weight', [
['vw', '体积重'],
@ -152,6 +237,9 @@ export const OMS_DICT_BATCH_SEED: DictSeedRow[] = [
['customerOrder', '客户单号'],
['tracking', '跟踪号'],
['other', '其他单号'],
['parcel', '包裹跟踪号'],
['sku', 'SKU'],
['auto', '自动识别'],
]),
...pack('auto_trigger_operation', [
['intercept', '拦截'],
@ -170,24 +258,24 @@ export const OMS_DICT_BATCH_SEED: DictSeedRow[] = [
['warehouse', '仓库'],
['customer', '客户'],
]),
/** 客户主表 Status;子账号 SubStatus 编码一致(0/1),可共用本类型 */
/** 客户主表 Status;子账号 SubStatus 编码一致,可共用本类型(1 起编) */
...pack('customer_status', [
['0', '禁用'],
['1', '正常'],
['2', '冻结'],
['1', '禁用'],
['2', '正常'],
['3', '冻结'],
]),
...pack('customer_group', [
['0', '普通客户'],
['1', 'VIP1'],
['2', 'VIP2'],
['3', 'VIP3'],
['4', 'VIP4'],
['5', 'VIP5'],
['1', '普通客户'],
['2', 'VIP1'],
['3', 'VIP2'],
['4', 'VIP3'],
['5', 'VIP4'],
['6', 'VIP5'],
]),
...pack('recharge_status', [
['0', '待审核'],
['1', '审核通过'],
['2', '审核驳回'],
['1', '待审核'],
['2', '审核通过'],
['3', '审核驳回'],
]),
...pack('pay_type', [
['1', '微信'],
@ -224,6 +312,147 @@ export const OMS_DICT_BATCH_SEED: DictSeedRow[] = [
['processing', '处理中'],
['completed', '已完成'],
]),
...pack('export_customs_mode', [
['9610Simple', '9610简易申报'],
['9610Summary', '9610汇总申报'],
['9610Separate', '9610分离申报'],
['9710', '9710'],
['9810', '9810'],
['0110', '0110'],
]),
/** 费用项:落库 CostItem 字段,编码与渠道/卖价配置一致 */
...pack('fee_item', [
['freight', '运费'],
['handling', '操作费'],
['insurance', '保险费'],
['fuelSurcharge', '燃油附加费'],
]),
...pack('order_handle_status', [
['pending', '待处理'],
['processed', '已处理'],
]),
...pack('order_source', [
['manual', '手工录入'],
['pickup', '揽收'],
['upload', '上传'],
['batch', '批量'],
['platform', '平台店铺'],
]),
...pack('issue_type', [
['address', '地址异常'],
['customs', '清关问题'],
['weight', '重量异常'],
['document', '单证不全'],
['cargo', '货物异常'],
['channel', '渠道异常'],
['payment', '费用异常'],
['other', '其他'],
]),
...pack('issue_notify_method', [
['inApp', '站内信'],
['email', '邮件'],
['sms', '短信'],
['feishu', '飞书'],
['wechat', '微信'],
]),
...pack('claim_type', [
['lost', '丢件'],
['damaged', '破损'],
['delay', '延误'],
['customs', '清关理赔'],
['fee', '费用争议'],
['other', '其他'],
]),
...pack('order_fee_charging_stage', [
['forecast', '预报'],
['inbound', '入库'],
['outbound', '出库'],
]),
...pack('customer_address_type', [
['sender', '发件'],
['return', '退件'],
['receiver', '收件'],
]),
...pack('label_generate_status', [
['Pending', '待生成'],
['Ready', '已就绪'],
['Failed', '生成失败'],
]),
...pack('order_stock_bind_type', [
['inventory', '库存'],
['parcelItem', '包裹明细'],
]),
...pack('order_stock_bind_status', [
['bound', '已绑定'],
['deducted', '已扣减'],
['released', '已释放'],
]),
...pack('warehouse_inbound_type', [
['normal', '正常入库'],
['return', '退货入库'],
]),
...pack('warehouse_receipt_status', [
['forecasted', '已预报'],
['received', '已收货'],
['completed', '已完成'],
]),
...pack('warehouse_parcel_lifecycle', [
['inTransit', '在途'],
['arrived', '已到仓'],
['stored', '已上架'],
]),
...pack('sales_store_auth_status', [
['active', '有效'],
['expired', '已过期'],
['revoked', '已撤销'],
]),
...pack('sales_store_fulfill_status', [
['pending', '待处理'],
['toShip', '待发货'],
['canceled', '已取消'],
['suspended', '挂起'],
['shipped', '已发货'],
['completed', '已完成'],
]),
...pack('order_batch_job_status', [
['pending', '待处理'],
['running', '执行中'],
['completed', '已完成'],
['failed', '失败'],
['cancelled', '已取消'],
]),
...pack('order_batch_job_item_status', [
['pending', '待处理'],
['running', '执行中'],
['success', '成功'],
['failed', '失败'],
['cancelled', '已取消'],
]),
...pack('order_template_mapping_status', [
['draft', '草稿'],
['ready', '就绪'],
]),
...pack('customer_account_flow_biz_type', [
['recharge', '充值入账'],
['orderPay', '订单支付'],
]),
...pack('secret_level', [
['1', '公开'],
['2', '内部'],
['3', '机密'],
['4', '绝密'],
]),
...pack('data_scope', [
['1', '个人'],
['2', '小组'],
['3', '部门'],
['4', '组织树'],
['5', '全局'],
]),
...pack('org_scope', [
['1', '仅本级'],
['2', '本级及下级'],
]),
]
/** 与库表唯一约束一致:dictType + dictCode */
@ -262,23 +491,24 @@ export function sortRowsByDictTypeAlphabetical(rows: DictSeedRow[]): DictSeedRow
function isDuplicateError(err: unknown): boolean {
const msg = err instanceof Error ? err.message : String(err)
return /重复|已存在|唯一|duplicate|unique|exist/i.test(msg)
return /重复|已存在|唯一|duplicate|unique|exist|1062|Duplicate entry/i.test(msg)
}
function normalizeDictRowKey(row: DictDto): { dictType: string; dictCode: string } | null {
const dictType = String(row.dictType ?? (row as Record<string, unknown>).DictType ?? '').trim()
const dictCode = String(row.dictCode ?? (row as Record<string, unknown>).DictCode ?? '').trim()
if (!dictType || !dictCode) return null
return { dictType, dictCode }
}
async function fetchExistingDictKeys(): Promise<Set<string>> {
const set = new Set<string>()
let page = 1
const pageSize = 500
for (;;) {
const { items, total } = await getDictList({ page, pageSize })
const list: DictDto[] = items || []
for (const row of list) {
if (row.dictType != null && row.dictCode != null && row.dictCode !== '') {
set.add(dictKey({ dictType: String(row.dictType), dictCode: String(row.dictCode) }))
}
const rows = await getDictListAll()
for (const row of rows) {
const normalized = normalizeDictRowKey(row)
if (normalized) {
set.add(dictKey(normalized))
}
if (list.length < pageSize || page * pageSize >= total) break
page += 1
}
return set
}
@ -288,6 +518,62 @@ export interface BatchImportResult {
skipped: number
failed: number
errors: string[]
/** 本次同步写入的类型备注数量(仅补空备注,不覆盖已有) */
typeRemarksSynced?: number
}
/**
* 为字典类型补全中文备注(仅当库中该类型无备注时写入,避免覆盖人工维护内容)。
*/
export async function syncDictTypeRemarks(
dictTypes: string[],
remarks: Record<string, string> = OMS_DICT_TYPE_REMARKS,
): Promise<{ synced: number; errors: string[] }> {
const uniqueTypes = [...new Set(dictTypes.map((t) => t.trim()).filter(Boolean))].sort((a, b) =>
a.localeCompare(b, 'en', { sensitivity: 'base' }),
)
if (!uniqueTypes.length) {
return { synced: 0, errors: [] }
}
const existing = await getDictTypeList()
const existingMap = new Map(
existing.map((item) => [String(item.dictType ?? '').trim(), item]),
)
const sortSeed = getOmsDictTypeRemarkRows()
const sortMap = new Map(sortSeed.map((row) => [row.dictType, row.typeSort]))
let synced = 0
const errors: string[] = []
for (const dictType of uniqueTypes) {
const remark = remarks[dictType]?.trim()
if (!remark) continue
const meta = existingMap.get(dictType)
if (meta?.remark?.trim()) {
continue
}
try {
await saveDictType({
id: meta?.id,
dictType,
remark,
typeSort: meta?.typeSort ?? sortMap.get(dictType) ?? 0,
})
synced += 1
if (meta) {
meta.remark = remark
} else {
existingMap.set(dictType, { dictType, remark })
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
errors.push(`${dictType}: ${msg}`)
}
}
return { synced, errors }
}
/**
@ -301,8 +587,15 @@ export async function runOmsDictBatchImport(
): Promise<BatchImportResult> {
assertSeedUniqueDictCodePerType(rows)
const ordered = sortRowsByDictTypeAlphabetical(rows)
const typeSync = await syncDictTypeRemarks([...new Set(ordered.map((row) => row.dictType))])
const existing = await fetchExistingDictKeys()
const result: BatchImportResult = { created: 0, skipped: 0, failed: 0, errors: [] }
const result: BatchImportResult = {
created: 0,
skipped: 0,
failed: 0,
errors: [...typeSync.errors],
typeRemarksSynced: typeSync.synced,
}
const total = ordered.length
let i = 0
for (const row of ordered) {
@ -442,6 +735,14 @@ function dictRowsToSheetRows(rows: DictSeedRow[]) {
}))
}
function dictTypeRowsToSheetRows(rows: DictTypeRemarkRow[]) {
return rows.map((row) => ({
字典类型: row.dictType,
类型排序: row.typeSort,
类型备注: row.remark,
}))
}
function writeDictWorkbook(rows: DictSeedRow[], sheetName = '字典') {
const wb = XLSX.utils.book_new()
const sheet = XLSX.utils.json_to_sheet(dictRowsToSheetRows(rows), {
@ -451,6 +752,16 @@ function writeDictWorkbook(rows: DictSeedRow[], sheetName = '字典') {
return wb
}
function writePresetDictWorkbook(rows: DictSeedRow[]) {
const wb = writeDictWorkbook(rows, '预置字典')
const typeRows = getOmsDictTypeRemarkRows(rows)
const typeSheet = XLSX.utils.json_to_sheet(dictTypeRowsToSheetRows(typeRows), {
header: [...DICT_TYPE_IMPORT_HEADERS],
})
XLSX.utils.book_append_sheet(wb, typeSheet, '字典类型')
return wb
}
/** 下载空白导入模板(含示例行) */
export function downloadDictImportTemplate() {
const wb = writeDictWorkbook([
@ -458,15 +769,15 @@ export function downloadDictImportTemplate() {
dictType: 'yes_no',
dictSort: 1,
dictCode: '1',
dictName: '是',
dictName: '否',
},
])
XLSX.writeFile(wb, '数据字典导入模板.xlsx')
}
/** 下载 OMS 设计文档预置字典(可编辑后重新导入) */
/** 下载 OMS 设计文档预置字典(含「字典类型」备注 sheet,可编辑后重新导入) */
export function downloadDictPresetTemplate() {
const wb = writeDictWorkbook(sortRowsByDictTypeAlphabetical(OMS_DICT_BATCH_SEED), '预置字典')
const wb = writePresetDictWorkbook(sortRowsByDictTypeAlphabetical(OMS_DICT_BATCH_SEED))
XLSX.writeFile(wb, 'OMS预置字典模板.xlsx')
}

View File

@ -1,66 +1,131 @@
<template>
<div class="dict-container">
<div class="dict-card">
<div class="page-header">
<a-space>
<a-button type="primary" @click="handleAdd">
<template #icon><icon-plus /></template>
{{ t('system.dict.actions.add') }}
</a-button>
<a-button @click="openBatchImportModal">
<template #icon><icon-upload /></template>
{{ t('system.dict.actions.batchImport') }}
</a-button>
<a-button :loading="exportLoading" @click="handleBatchExport">
<template #icon><icon-download /></template>
{{ t('system.dict.actions.batchExport') }}
</a-button>
</a-space>
<div class="rbac-split-layout dict-split-layout">
<CustomLeftMenu
class="left-sidebar dict-left-menu"
:tree-data="dictTypeTreeData"
:selected-keys="selectedTypeKeys"
v-model:expanded-keys="expandedKeys"
v-model:search-keyword="typeSearchKeyword"
:loading="typeLoading"
:search-placeholder="t('system.dict.left.searchPlaceholder')"
:refresh-title="t('system.dict.left.refresh')"
:add-title="t('system.dict.left.addType')"
:delete-label="t('system.dict.left.deleteType')"
:loading-text="t('system.dict.left.loading')"
:show-method="false"
:show-node-index="false"
:show-map-count="true"
:show-node-code="true"
:show-node-actions="true"
:show-pagination="true"
:page-size="30"
@search="handleTypeSearch"
@refresh="() => loadDictTypes(undefined, { loadDetails: false })"
@add="handleAddType"
@select="handleTypeSelect"
@node-click="handleTypeNodeClick"
>
<template #node-menu="{ nodeData }">
<a-doption @click="handleEditType(nodeData)">
<icon-edit />
{{ t('system.dict.left.editType') }}
</a-doption>
<a-doption
v-if="nodeData?.id"
@click="handleDeleteType(nodeData)"
>
<icon-delete />
{{ t('system.dict.left.deleteType') }}
</a-doption>
</template>
</CustomLeftMenu>
<a-space>
<a-input
v-model="query.dictType"
:placeholder="t('system.dict.filter.dictType')"
style="width: 180px"
allow-clear
@change="handleSearch"
/>
<a-input-search
v-model="query.keyword"
:placeholder="t('system.dict.search.placeholder')"
style="width: 240px"
@search="handleSearch"
/>
<a-button @click="loadData">
<template #icon><icon-refresh /></template>
</a-button>
<PaginationPro
:total-count="pagination.total"
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</a-space>
</div>
<div class="right-panel">
<div class="dict-page-panel">
<div v-if="!selectedDictType" class="panel-empty">
<a-empty :description="t('system.dict.left.emptySelect')">
<template #image>
<icon-book class="panel-empty-icon" />
</template>
<p class="panel-empty-desc">{{ t('system.dict.left.emptySelectDesc') }}</p>
</a-empty>
</div>
<div ref="tableContainerRef" class="list-wrap">
<HandsontablePro
:columns="hotColumns"
:data="dataSource"
:total="pagination.total"
:current-page="pagination.current"
:page-size="pagination.pageSize"
:show-action-column="true"
:action-column-position="'end'"
:action-column-width="100"
:action-renderer="actionRenderer"
:custom-cell-renderer="customCellRenderer"
:stretch-columns="true"
:height="handsontableHeight"
:read-only="true"
@action-click="handleActionClick"
/>
<template v-else>
<div class="dict-type-header">
<div class="dict-type-header-main">
<h3 class="panel-title">{{ displayTypeName }}</h3>
<a-tag size="small" color="arcoblue" class="type-code-tag">{{ selectedDictType }}</a-tag>
<span class="type-item-count">
{{ t('system.dict.panel.itemCount', { count: selectedTypeMeta?.itemCount ?? pagination.total }) }}
</span>
</div>
<a-button type="text" size="small" class="edit-type-btn" @click="handleEditSelectedType">
<template #icon><icon-edit /></template>
{{ t('system.dict.panel.editType') }}
</a-button>
</div>
<div class="table-toolbar">
<div class="toolbar-left">
<a-input-search
v-model="query.keyword"
allow-clear
:placeholder="t('system.dict.search.placeholder')"
style="width: 240px"
@search="handleSearch"
@clear="handleSearch"
/>
</div>
<div class="toolbar-right">
<a-space wrap :size="8">
<a-button type="primary" @click="handleAdd">
<template #icon><icon-plus /></template>
{{ t('system.dict.actions.add') }}
</a-button>
<a-button @click="openBatchImportModal">
<template #icon><icon-upload /></template>
{{ t('system.dict.actions.batchImport') }}
</a-button>
<a-button :loading="exportLoading" @click="handleBatchExport">
<template #icon><icon-download /></template>
{{ t('system.dict.actions.batchExport') }}
</a-button>
<a-button @click="loadData">
<template #icon><icon-refresh /></template>
</a-button>
<PaginationPro
:total-count="pagination.total"
v-model:current-page="pagination.current"
v-model:page-size="pagination.pageSize"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</a-space>
</div>
</div>
<div ref="tableContainerRef" class="list-wrap">
<a-spin :loading="listLoading" class="list-spin">
<HandsontablePro
:columns="hotColumns"
:data="dataSource"
:total="pagination.total"
:current-page="pagination.current"
:page-size="pagination.pageSize"
:show-action-column="true"
:action-column-position="'end'"
:action-column-width="100"
:action-renderer="actionRenderer"
:custom-cell-renderer="customCellRenderer"
:stretch-columns="true"
:height="handsontableHeight"
:read-only="true"
@action-click="handleActionClick"
/>
</a-spin>
</div>
</template>
</div>
</div>
@ -73,7 +138,7 @@
>
<a-form ref="formRef" :model="formState" :rules="formRules" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 16 }">
<a-form-item :label="t('system.dict.form.dictType')" field="dictType">
<a-input v-model="formState.dictType" :placeholder="t('system.dict.placeholder.input')" />
<a-input v-model="formState.dictType" disabled :placeholder="t('system.dict.placeholder.input')" />
</a-form-item>
<a-form-item :label="t('system.dict.form.dictSort')" field="dictSort">
<a-input-number v-model="formState.dictSort" :placeholder="t('system.dict.placeholder.input')" :min="0" :precision="0" style="width: 100%" />
@ -87,7 +152,31 @@
</a-form>
</a-modal>
<DictBatchImportModal ref="batchImportModalRef" @success="loadData" />
<a-modal
v-model:visible="typeModalVisible"
:title="typeIsEdit ? t('system.dict.typeModal.editTitle') : t('system.dict.typeModal.addTitle')"
:confirm-loading="typeModalLoading"
width="520px"
@ok="handleTypeModalOk"
>
<a-form ref="typeFormRef" :model="typeFormState" :rules="typeFormRules" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 16 }">
<a-form-item :label="t('system.dict.form.dictType')" field="dictType">
<a-input
v-model="typeFormState.dictType"
:disabled="typeIsEdit"
:placeholder="t('system.dict.placeholder.input')"
/>
</a-form-item>
<a-form-item :label="t('system.dict.form.typeRemark')" field="remark">
<a-input v-model="typeFormState.remark" allow-clear :placeholder="t('system.dict.placeholder.typeRemark')" />
</a-form-item>
<a-form-item :label="t('system.dict.form.typeSort')" field="typeSort">
<a-input-number v-model="typeFormState.typeSort" :min="0" :precision="0" style="width: 100%" />
</a-form-item>
</a-form>
</a-modal>
<DictBatchImportModal ref="batchImportModalRef" @success="handleImportSuccess" />
</div>
</template>
@ -96,27 +185,49 @@ import { computed, reactive, ref, onMounted } from 'vue'
import dayjs from 'dayjs'
import { useI18n } from 'vue-i18n'
import { Message, Modal } from '@arco-design/web-vue'
import { IconPlus, IconRefresh, IconUpload, IconDownload } from '@arco-design/web-vue/es/icon'
import {
IconPlus,
IconRefresh,
IconUpload,
IconDownload,
IconEdit,
IconDelete,
IconBook,
} from '@arco-design/web-vue/es/icon'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import CustomLeftMenu from '@/components/CustomLeftMenu/index.vue'
import type { CustomLeftMenuTreeNode } from '@/components/CustomLeftMenu/types'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import {
createDict,
deleteDict,
deleteDictType,
getDictList,
getDictListAll,
getDictTypeList,
saveDictType,
updateDict,
type DictDto,
type DictTypeDto,
} from '@/api/system/dict'
import { exportDictRowsToExcel } from './dict-batch-import'
import DictBatchImportModal from './DictBatchImportModal.vue'
defineOptions({ name: 'SystemDict' })
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 400))
const typeLoading = ref(false)
const listLoading = ref(false)
const dictTypeList = ref<DictTypeDto[]>([])
const typeSearchKeyword = ref('')
const selectedDictType = ref('')
const expandedKeys = ref<string[]>([])
const dataSource = ref<DictDto[]>([])
/** 筛选条件下的全量排序结果,翻页只切片不重请求 */
const allSortedRows = ref<DictDto[]>([])
const batchImportModalRef = ref<InstanceType<typeof DictBatchImportModal> | null>(null)
const exportLoading = ref(false)
const modalVisible = ref(false)
@ -125,14 +236,18 @@ const isEdit = ref(false)
const editingId = ref<number | string>()
const formRef = ref()
const typeModalVisible = ref(false)
const typeModalLoading = ref(false)
const typeIsEdit = ref(false)
const typeFormRef = ref()
const query = reactive({
dictType: '',
keyword: '',
})
const pagination = reactive({
current: 1,
pageSize: 50,
pageSize: 30,
total: 0,
})
@ -143,6 +258,13 @@ const formState = reactive({
dictName: '',
})
const typeFormState = reactive({
id: undefined as number | string | undefined,
dictType: '',
typeSort: 0,
remark: '',
})
const formRules = computed(() => ({
dictType: [{ required: true, message: t('system.dict.rule.dictTypeRequired') }],
dictSort: [{ required: true, message: t('system.dict.rule.dictSortRequired') }],
@ -150,34 +272,75 @@ const formRules = computed(() => ({
dictName: [{ required: true, message: t('system.dict.rule.dictNameRequired') }],
}))
const typeFormRules = computed(() => ({
dictType: [{ required: true, message: t('system.dict.rule.dictTypeRequired') }],
}))
const selectedTypeMeta = computed(() =>
dictTypeList.value.find((item) => item.dictType === selectedDictType.value),
)
const displayTypeName = computed(
() => selectedTypeMeta.value?.remark?.trim() || selectedDictType.value,
)
const filteredTypeList = computed(() => {
const kw = typeSearchKeyword.value.trim().toLowerCase()
if (!kw) return dictTypeList.value
return dictTypeList.value.filter((item) => {
const type = String(item.dictType ?? '').toLowerCase()
const remark = String(item.remark ?? '').toLowerCase()
return type.includes(kw) || remark.includes(kw)
})
})
function buildTypeTreeNode(item: DictTypeDto): CustomLeftMenuTreeNode {
const dictType = String(item.dictType ?? '').trim()
const remark = String(item.remark ?? '').trim()
return {
key: `type-${dictType}`,
title: remark || dictType,
code: remark ? dictType : undefined,
mapCount: item.itemCount ?? 0,
isLeaf: true,
id: item.id,
dictType,
remark,
typeSort: item.typeSort,
}
}
const dictTypeTreeData = computed<CustomLeftMenuTreeNode[]>(() =>
filteredTypeList.value.map((item) => buildTypeTreeNode(item)),
)
const selectedTypeKeys = computed(() =>
selectedDictType.value ? [`type-${selectedDictType.value}`] : [],
)
const hotColumns = computed(() => [
{ key: 'dictType', label: t('system.dict.table.dictType'), width: 160 },
{ key: 'dictSort', label: t('system.dict.table.dictSort'), width: 100 },
{ key: 'dictCode', label: t('system.dict.table.dictCode'), width: 180 },
{ key: 'dictName', label: t('system.dict.table.dictName'), width: 180 },
{ key: 'dictName', label: t('system.dict.table.dictName'), width: 200 },
{ key: 'createTime', label: t('system.dict.table.createTime'), width: 170 },
])
function sortDictRows(rows: DictDto[]): DictDto[] {
return [...rows].sort((a, b) => {
const ta = String(a.dictType ?? '').trim()
const tb = String(b.dictType ?? '').trim()
const c = ta.localeCompare(tb, 'en', { sensitivity: 'base' })
if (c !== 0) return c
const sa = Number(a.dictSort ?? 0)
const sb = Number(b.dictSort ?? 0)
if (sa !== sb) return sa - sb
return String(a.dictCode ?? '').localeCompare(String(b.dictCode ?? ''), 'en')
})
function syncSelectedTypeItemCount(count: number) {
if (!selectedDictType.value) return
const idx = dictTypeList.value.findIndex((item) => item.dictType === selectedDictType.value)
if (idx < 0) return
dictTypeList.value[idx] = { ...dictTypeList.value[idx], itemCount: count }
}
function syncTablePage() {
const total = allSortedRows.value.length
pagination.total = total
const maxPage = Math.max(1, Math.ceil(total / pagination.pageSize) || 1)
if (pagination.current > maxPage) pagination.current = maxPage
const start = (pagination.current - 1) * pagination.pageSize
dataSource.value = allSortedRows.value.slice(start, start + pagination.pageSize)
function handlePageChange(page: number) {
pagination.current = page
loadData()
}
function handlePageSizeChange(size: number) {
pagination.pageSize = size
pagination.current = 1
loadData()
}
const actionEditIcon = '<svg viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="2" style="width:16px;height:16px"><path d="m30.48 19.038 5.733-5.734a1 1 0 0 0 0-1.414l-5.586-5.586a1 1 0 0 0-1.414 0l-5.734 5.734m7 7L15.763 33.754a1 1 0 0 1-.59.286l-6.048.708a1 1 0 0 1-1.113-1.069l.477-6.31a1 1 0 0 1 .29-.631l14.7-14.7m7 7-7-7M6 42h36"/></svg>'
@ -193,7 +356,7 @@ function customCellRenderer(_row: number, col: number, _value: any, rowData: Dic
function actionRenderer(_row: DictDto, rowIndex: number): string {
const actions = [
`<span data-action="edit" data-row-index="${rowIndex}" style="cursor:pointer;color:#165dff;display:inline-flex;" title="${t('system.dict.actions.edit')}">${actionEditIcon}</span>`,
`<span data-action="edit" data-row-index="${rowIndex}" style="cursor:pointer;color:var(--app-primary-color,#165dff);display:inline-flex;" title="${t('system.dict.actions.edit')}">${actionEditIcon}</span>`,
`<span data-action="delete" data-row-index="${rowIndex}" style="cursor:pointer;color:#f53f3f;display:inline-flex;" title="${t('system.dict.actions.delete')}">${actionDeleteIcon}</span>`,
]
return `<div style="display:flex;align-items:center;justify-content:center;gap:20px;width:100%;">${actions.join('')}</div>`
@ -212,35 +375,55 @@ function handleActionClick(row: DictDto, _rowIndex: number, action?: string) {
}
}
function handleSearch() {
function selectDictTypeFromKey(key?: string) {
if (!key || !key.startsWith('type-')) return
const dictType = key.slice(5)
if (!dictType) return
if (selectedDictType.value === dictType) return
selectedDictType.value = dictType
pagination.current = 1
query.keyword = ''
loadData()
}
function handlePageChange(page: number) {
pagination.current = page
syncTablePage()
function handleTypeSelect(keys: string[]) {
selectDictTypeFromKey(keys[0])
}
function handlePageSizeChange(size: number) {
pagination.pageSize = size
function handleTypeNodeClick(key: string) {
selectDictTypeFromKey(key)
}
function handleTypeSearch() {
// filteredTypeList 客户端过滤
}
function handleSearch() {
pagination.current = 1
syncTablePage()
loadData()
}
function openBatchImportModal() {
batchImportModalRef.value?.open()
}
function handleBatchExport() {
if (!allSortedRows.value.length) {
Message.warning(t('system.dict.export.empty'))
async function handleBatchExport() {
if (!selectedDictType.value) {
Message.warning(t('system.dict.left.emptySelect'))
return
}
exportLoading.value = true
try {
const suffix = query.dictType?.trim() ? `_${query.dictType.trim()}` : ''
exportDictRowsToExcel(allSortedRows.value, `数据字典${suffix}_${dayjs().format('YYYY-MM-DD_HH-mm-ss')}.xlsx`)
const rows = await getDictListAll({
dictType: selectedDictType.value,
keyword: query.keyword || undefined,
})
if (!rows.length) {
Message.warning(t('system.dict.export.empty'))
return
}
const suffix = selectedDictType.value ? `_${selectedDictType.value}` : ''
exportDictRowsToExcel(rows, `数据字典${suffix}_${dayjs().format('YYYY-MM-DD_HH-mm-ss')}.xlsx`)
Message.success(t('system.dict.export.success'))
} catch (e: any) {
Message.error(e?.message || t('system.dict.export.failed'))
@ -250,11 +433,15 @@ function handleBatchExport() {
}
function handleAdd() {
if (!selectedDictType.value) {
Message.warning(t('system.dict.left.emptySelect'))
return
}
isEdit.value = false
editingId.value = undefined
Object.assign(formState, {
dictType: '',
dictSort: 1,
dictType: selectedDictType.value,
dictSort: (selectedTypeMeta.value?.itemCount ?? 0) + 1,
dictCode: '',
dictName: '',
})
@ -265,7 +452,7 @@ function handleEdit(row: DictDto) {
isEdit.value = true
editingId.value = row.id
Object.assign(formState, {
dictType: row.dictType || '',
dictType: row.dictType || selectedDictType.value,
dictSort: row.dictSort ?? 1,
dictCode: row.dictCode || '',
dictName: row.dictName || '',
@ -273,11 +460,55 @@ function handleEdit(row: DictDto) {
modalVisible.value = true
}
function handleAddType() {
typeIsEdit.value = false
Object.assign(typeFormState, {
id: undefined,
dictType: '',
typeSort: dictTypeList.value.length + 1,
remark: '',
})
typeModalVisible.value = true
}
function handleEditType(nodeData: CustomLeftMenuTreeNode | null) {
if (!nodeData?.dictType) return
typeIsEdit.value = true
Object.assign(typeFormState, {
id: nodeData.id,
dictType: nodeData.dictType,
typeSort: nodeData.typeSort ?? 0,
remark: nodeData.remark ?? '',
})
typeModalVisible.value = true
}
function handleEditSelectedType() {
handleEditType(buildTypeTreeNode(selectedTypeMeta.value || { dictType: selectedDictType.value }))
}
async function handleDeleteType(nodeData: CustomLeftMenuTreeNode | null) {
if (!nodeData?.id) return
Modal.confirm({
content: t('system.dict.typeModal.deleteConfirm', { dictType: nodeData.dictType || '' }),
onOk: async () => {
await deleteDictType(nodeData.id!)
Message.success(t('system.dict.typeModal.deleteSuccess'))
if (selectedDictType.value === nodeData.dictType) {
selectedDictType.value = ''
dataSource.value = []
pagination.total = 0
}
await loadDictTypes(undefined, { loadDetails: false })
},
})
}
async function handleDelete(id?: number | string) {
if (!id) return
await deleteDict(id)
Message.success(t('system.dict.message.deleteSuccess'))
loadData()
await Promise.all([loadData(), loadDictTypes(undefined, { loadDetails: false })])
}
async function handleModalOk() {
@ -298,69 +529,218 @@ async function handleModalOk() {
Message.success(t('system.dict.message.addSuccess'))
}
modalVisible.value = false
loadData()
await Promise.all([loadData(), loadDictTypes(undefined, { loadDetails: false })])
} finally {
modalLoading.value = false
}
}
async function loadData() {
const list = await getDictListAll({
dictType: query.dictType || undefined,
keyword: query.keyword || undefined,
})
allSortedRows.value = sortDictRows(list)
syncTablePage()
async function handleTypeModalOk() {
try {
await typeFormRef.value.validate()
typeModalLoading.value = true
await saveDictType({
id: typeFormState.id,
dictType: typeFormState.dictType.trim(),
remark: typeFormState.remark.trim() || undefined,
typeSort: Number(typeFormState.typeSort) || 0,
})
Message.success(typeIsEdit.value ? t('system.dict.typeModal.editSuccess') : t('system.dict.typeModal.addSuccess'))
typeModalVisible.value = false
const savedType = typeFormState.dictType.trim()
await loadDictTypes(savedType, { loadDetails: false })
} finally {
typeModalLoading.value = false
}
}
onMounted(() => {
loadData()
async function loadDictTypes(
preferSelectType?: string,
options: { loadDetails?: boolean } = {},
) {
const { loadDetails = false } = options
typeLoading.value = true
try {
dictTypeList.value = await getDictTypeList()
const types = dictTypeList.value.map((item) => item.dictType).filter(Boolean) as string[]
if (preferSelectType && types.includes(preferSelectType)) {
selectedDictType.value = preferSelectType
} else if (!selectedDictType.value || !types.includes(selectedDictType.value)) {
selectedDictType.value = types[0] || ''
}
if (loadDetails && selectedDictType.value) {
await loadData()
} else if (!selectedDictType.value) {
dataSource.value = []
pagination.total = 0
}
} catch (e: any) {
dictTypeList.value = []
Message.error(e?.message || t('system.dict.message.loadTypeFailed'))
} finally {
typeLoading.value = false
}
}
async function loadData() {
if (!selectedDictType.value) {
dataSource.value = []
pagination.total = 0
return
}
listLoading.value = true
try {
const { items, total } = await getDictList({
page: pagination.current,
pageSize: pagination.pageSize,
dictType: selectedDictType.value,
keyword: query.keyword.trim() || undefined,
})
dataSource.value = items
pagination.total = total
if (!query.keyword.trim()) {
syncSelectedTypeItemCount(total)
}
} catch (e: any) {
dataSource.value = []
pagination.total = 0
Message.error(e?.message || t('system.dict.message.loadDataFailed', { error: '' }))
} finally {
listLoading.value = false
}
}
async function handleImportSuccess() {
await loadDictTypes(undefined, { loadDetails: false })
if (selectedDictType.value) {
await loadData()
}
}
onMounted(async () => {
await loadDictTypes(undefined, { loadDetails: true })
})
</script>
<style scoped>
.dict-container {
width: 100%;
<style scoped lang="less">
@import '@/components/RbacSplit/rbac-split-layout.less';
.dict-split-layout {
box-sizing: border-box;
padding: 16px 16px 24px;
background-color: #f0f3f4;
flex: 1;
min-height: 0;
overflow-x: hidden;
overflow-y: hidden;
display: flex;
flex-direction: column;
}
.dict-card {
.dict-page-panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: #fff;
border-radius: 8px;
padding: 0;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
padding: 16px;
box-sizing: border-box;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.page-header {
.dict-type-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 16px 16px 0;
gap: 12px;
margin-bottom: 12px;
flex-shrink: 0;
}
.dict-type-header-main {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
min-width: 0;
}
.panel-title {
margin: 0;
font-size: 16px;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.4;
}
.type-code-tag {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.type-item-count {
font-size: 13px;
color: var(--color-text-3);
}
.edit-type-btn {
flex-shrink: 0;
color: var(--app-primary-color);
}
.table-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
flex-shrink: 0;
gap: 12px;
flex-wrap: nowrap;
flex-wrap: wrap;
}
.toolbar-left,
.toolbar-right {
display: flex;
align-items: center;
}
.toolbar-left {
flex: 1;
min-width: 200px;
}
.panel-empty {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 48px 16px;
}
.panel-empty-icon {
font-size: 56px;
color: var(--color-text-4);
}
.panel-empty-desc {
margin: 8px 0 0;
font-size: 13px;
color: var(--color-text-3);
text-align: center;
}
.list-wrap {
width: 100%;
flex: 1;
min-height: 0;
padding: 0 16px 16px;
box-sizing: border-box;
overflow: hidden;
}
.list-spin {
height: 100%;
display: flex;
flex-direction: column;
}
.list-spin :deep(.arco-spin-container) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.list-spin :deep(.handsontable-pro-container) {
flex: 1;
min-height: 0;
}
</style>