From 108083d33275d89d91805c9fe1a66fd1fb5fcca0 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Fri, 16 Oct 2026 15:17:47 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E5=BA=97=E9=93=BA=E6=8B=89=E5=8D=95?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E6=8C=89=20TikTok=20=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E7=AD=9B=E9=80=89=EF=BC=8C=E5=B9=B6=E9=81=BF=E5=85=8D=E8=A1=A8?= =?UTF-8?q?=E6=A0=BC=E5=88=B7=E6=96=B0=E6=89=93=E6=96=AD=E5=BC=B9=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- src/components/HandsontablePro/index.vue | 7 + src/locale/en-US.ts | 18 +- src/locale/zh-CN.ts | 20 +- .../platformStoreOrder/PullOrdersModal.vue | 247 ++++++++++++++---- src/views/order/platformStoreOrder/index.vue | 1 + 5 files changed, 237 insertions(+), 56 deletions(-) diff --git a/src/components/HandsontablePro/index.vue b/src/components/HandsontablePro/index.vue index e9af2b3..33cb394 100644 --- a/src/components/HandsontablePro/index.vue +++ b/src/components/HandsontablePro/index.vue @@ -1475,6 +1475,9 @@ manualColumnResize: true, manualColumnMove: props.enableColumnReorder, stretchH: props.stretchColumns ? 'all' : 'none', + // 列宽由列定义给出。开启后 loadData 会走 GhostTable 测量, + // 单元格节点缺失时抛 getBoundingClientRect,并中断外层页面更新。 + autoColumnSize: false, // stretch 时锁定操作/序号/复选框列,避免被均分拉宽 beforeStretchingColumnWidth: lockFixedColumnStretchWidth, ...(props.rowHeight != null && props.rowHeight > 0 @@ -2362,6 +2365,8 @@ hotInstance.loadData([]); hotInstance.render(); publishSelection([], new Set()); + } catch (error) { + console.warn('[HandsontablePro] clear failed', error); } finally { selectionLock -= 1; } @@ -2393,6 +2398,8 @@ } else { applySelectedRowStyles(); } + } catch (error) { + console.warn('[HandsontablePro] refresh failed', error); } finally { selectionLock -= 1; } diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index fd64edb..a8d201f 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -1079,9 +1079,25 @@ export default { 'order.platformStoreOrder.createFulfillWms.success': 'Created {count} fulfill order(s)', 'order.platformStoreOrder.pull.store': 'Store', 'order.platformStoreOrder.pull.storePlaceholder': 'Select stores', + 'order.platformStoreOrder.pull.storeAll': 'All stores', 'order.platformStoreOrder.pull.status': 'Status', + 'order.platformStoreOrder.pull.statusAll': 'All', + 'order.platformStoreOrder.pull.statusClear': 'Clear', + 'order.platformStoreOrder.pull.statusCount': '{count} selected', 'order.platformStoreOrder.pull.time': 'Time', - 'order.platformStoreOrder.pull.timeHint': 'Defaults to today', + 'order.platformStoreOrder.pull.timeHint': 'Filter by order created time. Defaults to today.', + 'order.platformStoreOrder.pull.today': 'Today', + 'order.platformStoreOrder.pull.last7': '7 days', + 'order.platformStoreOrder.pull.last15': '15 days', + 'order.platformStoreOrder.pull.status.UNPAID': 'Unpaid', + 'order.platformStoreOrder.pull.status.ON_HOLD': 'On hold', + 'order.platformStoreOrder.pull.status.PARTIALLY_SHIPPING': 'Partially shipping', + 'order.platformStoreOrder.pull.status.AWAITING_SHIPMENT': 'Awaiting shipment', + 'order.platformStoreOrder.pull.status.AWAITING_COLLECTION': 'Awaiting collection', + 'order.platformStoreOrder.pull.status.IN_TRANSIT': 'In transit', + 'order.platformStoreOrder.pull.status.DELIVERED': 'Delivered', + 'order.platformStoreOrder.pull.status.COMPLETED': 'Completed', + 'order.platformStoreOrder.pull.status.CANCELLED': 'Cancelled', 'order.platformStoreOrder.message.loadFailed': 'Failed to load orders', 'order.platformStoreOrder.message.detailFailed': 'Failed to load detail', 'order.platformStoreOrder.message.matchSuccess': 'Inventory matched', diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index b2de6d1..dcd5440 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -1070,9 +1070,25 @@ export default { 'order.platformStoreOrder.createFulfillWms.success': '成功创建 {count} 张代发订单', 'order.platformStoreOrder.pull.store': '店铺', 'order.platformStoreOrder.pull.storePlaceholder': '请选择店铺', - 'order.platformStoreOrder.pull.status': '类型', + 'order.platformStoreOrder.pull.storeAll': '全选店铺', + 'order.platformStoreOrder.pull.status': '状态', + 'order.platformStoreOrder.pull.statusAll': '全选', + 'order.platformStoreOrder.pull.statusClear': '清空', + 'order.platformStoreOrder.pull.statusCount': '已选 {count} 个', 'order.platformStoreOrder.pull.time': '时间', - 'order.platformStoreOrder.pull.timeHint': '默认时间为当天', + 'order.platformStoreOrder.pull.timeHint': '按订单创建时间拉取,默认当天', + 'order.platformStoreOrder.pull.today': '今天', + 'order.platformStoreOrder.pull.last7': '近7天', + 'order.platformStoreOrder.pull.last15': '近15天', + 'order.platformStoreOrder.pull.status.UNPAID': '未付款', + 'order.platformStoreOrder.pull.status.ON_HOLD': '待处理', + 'order.platformStoreOrder.pull.status.PARTIALLY_SHIPPING': '部分发货', + 'order.platformStoreOrder.pull.status.AWAITING_SHIPMENT': '待发货', + 'order.platformStoreOrder.pull.status.AWAITING_COLLECTION': '待揽收', + 'order.platformStoreOrder.pull.status.IN_TRANSIT': '运输中', + 'order.platformStoreOrder.pull.status.DELIVERED': '已送达', + 'order.platformStoreOrder.pull.status.COMPLETED': '已完成', + 'order.platformStoreOrder.pull.status.CANCELLED': '已取消', 'order.platformStoreOrder.message.loadFailed': '加载订单失败', 'order.platformStoreOrder.message.detailFailed': '加载详情失败', 'order.platformStoreOrder.message.matchSuccess': '库存匹配成功', diff --git a/src/views/order/platformStoreOrder/PullOrdersModal.vue b/src/views/order/platformStoreOrder/PullOrdersModal.vue index b6ea476..3846adc 100644 --- a/src/views/order/platformStoreOrder/PullOrdersModal.vue +++ b/src/views/order/platformStoreOrder/PullOrdersModal.vue @@ -9,6 +9,7 @@ import { pickField } from '@/utils/warehouseListMapper' const props = defineProps<{ visible: boolean customerId?: number + platformCode?: string }>() const emit = defineEmits<{ @@ -16,51 +17,77 @@ const emit = defineEmits<{ success: [] }>() +/** TikTok 订单搜索状态,与平台 order_status 一致 */ +const TIKTOK_PULL_STATUSES = [ + 'UNPAID', + 'ON_HOLD', + 'PARTIALLY_SHIPPING', + 'AWAITING_SHIPMENT', + 'AWAITING_COLLECTION', + 'IN_TRANSIT', + 'DELIVERED', + 'COMPLETED', + 'CANCELLED', +] as const + +type StoreOption = { label: string; value: number; platform: string } + const { t } = useI18n() const loading = ref(false) const storeLoading = ref(false) -const storeOptions = ref>([]) +const storeOptions = ref([]) const storeIds = ref([]) -const orderStatuses = ref([ - 'unpaid', - 'pending', - 'canceled', - 'ready_to_ship', - 'delivered', - 'returned', - 'shipped', - 'failed', - 'topack', - 'toship', - 'shipping', - 'lost', -]) -const dateRange = ref<[string, string] | undefined>() - -const statusOptions = computed(() => - [ - 'unpaid', - 'pending', - 'canceled', - 'ready_to_ship', - 'delivered', - 'returned', - 'shipped', - 'failed', - 'topack', - 'toship', - 'shipping', - 'lost', - ].map((value) => ({ label: value, value })), -) +const orderStatuses = ref([...TIKTOK_PULL_STATUSES]) +const dateRange = ref<[string, string]>(todayRange()) const modalVisible = computed({ get: () => props.visible, set: (v: boolean) => emit('update:visible', v), }) +const visibleStoreOptions = computed(() => { + const platform = (props.platformCode || '').trim().toLowerCase() + if (!platform || platform === 'all') return storeOptions.value + return storeOptions.value.filter((item) => item.platform === platform) +}) + +const allStatusesChecked = computed( + () => orderStatuses.value.length === TIKTOK_PULL_STATUSES.length, +) +const allStoresChecked = computed( + () => + visibleStoreOptions.value.length > 0 + && storeIds.value.length === visibleStoreOptions.value.length, +) +const canSubmit = computed( + () => storeIds.value.length > 0 && orderStatuses.value.length > 0 && !storeLoading.value, +) + +function todayRange(): [string, string] { + return [dayjs().format('YYYY-MM-DD'), dayjs().format('YYYY-MM-DD')] +} + +function applyRange(days: number) { + dateRange.value = [ + dayjs().subtract(days - 1, 'day').format('YYYY-MM-DD'), + dayjs().format('YYYY-MM-DD'), + ] +} + +function toggleAllStatuses() { + orderStatuses.value = allStatusesChecked.value ? [] : [...TIKTOK_PULL_STATUSES] +} + +function toggleAllStores() { + storeIds.value = allStoresChecked.value + ? [] + : visibleStoreOptions.value.map((item) => item.value) +} + async function loadStoreOptions() { storeIds.value = [] + orderStatuses.value = [...TIKTOK_PULL_STATUSES] + dateRange.value = todayRange() storeLoading.value = true try { const { items } = await getPlatformStoreList({ @@ -72,12 +99,12 @@ async function loadStoreOptions() { .map((raw: Record) => { const id = Number(pickField(raw, 'id', 'Id')) const name = String(pickField(raw, 'storeNickname', 'StoreNickname') ?? `#${id}`) - const platform = String(pickField(raw, 'platformCode', 'PlatformCode') ?? '') - return { label: platform ? `${name} (${platform})` : name, value: id } + const platform = String(pickField(raw, 'platformCode', 'PlatformCode') ?? '').toLowerCase() + return { label: platform ? `${name} (${platform})` : name, value: id, platform } }) - .filter((x) => x.value > 0) - if (!storeOptions.value.length) { - $baseMessage(t('order.platformStoreOrder.message.noStore'), 'warning') + .filter((item) => item.value > 0) + if (visibleStoreOptions.value.length === 1) { + storeIds.value = [visibleStoreOptions.value[0].value] } } catch (e: any) { storeOptions.value = [] @@ -145,38 +172,152 @@ async function handleSubmit(): Promise { - + - +
+
+ + + {{ allStoresChecked ? t('order.platformStoreOrder.pull.statusClear') : t('order.platformStoreOrder.pull.storeAll') }} + +
+
+ {{ t('order.platformStoreOrder.message.noStore') }} +
+
- +
+
+ + {{ t('order.platformStoreOrder.pull.statusCount', { count: orderStatuses.length }) }} + + + {{ allStatusesChecked ? t('order.platformStoreOrder.pull.statusClear') : t('order.platformStoreOrder.pull.statusAll') }} + +
+ + + {{ t(`order.platformStoreOrder.pull.status.${status}`) }} + {{ status }} + + +
- -
{{ t('order.platformStoreOrder.pull.timeHint') }}
+
+ +
+ + {{ t('order.platformStoreOrder.pull.today') }} + + + {{ t('order.platformStoreOrder.pull.last7') }} + + + {{ t('order.platformStoreOrder.pull.last15') }} + +
+
{{ t('order.platformStoreOrder.pull.timeHint') }}
+
diff --git a/src/views/order/platformStoreOrder/index.vue b/src/views/order/platformStoreOrder/index.vue index 931a79c..a395695 100644 --- a/src/views/order/platformStoreOrder/index.vue +++ b/src/views/order/platformStoreOrder/index.vue @@ -860,6 +860,7 @@ watch( v-if="pullVisible" v-model:visible="pullVisible" :customer-id="withCustomerId({}).customerId" + :platform-code="platformCode" @success="() => { loadSummary(); loadList() }" /> -- 2.45.2 From b178a2a35d2b2163541fc8179a6a73f6cae9439a Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Mon, 19 Oct 2026 16:34:06 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=94=AF=E4=BB=98=E9=80=89?= =?UTF-8?q?=E5=88=B8=E3=80=81=E4=BC=98=E6=83=A0=E5=85=A5=E5=BA=94=E6=94=B6?= =?UTF-8?q?=E5=B1=95=E7=A4=BA=E3=80=81=E7=94=B3=E6=8A=A5=E4=BA=A7=E5=93=81?= =?UTF-8?q?=E6=A0=87=E8=AF=86=E7=BC=96=E7=A0=81=E3=80=81=E5=88=9B=E5=BB=BA?= =?UTF-8?q?9610=E8=AE=A2=E5=8D=95=E7=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 支付弹窗(PC/H5)支持选券与批量支付体验优化,运单管理复用我的订单支付弹窗;立即支付仅允许待支付订单 - 订单详情费用明细展示优惠券抵扣/会员折扣负数行与「含优惠」;价格配置与手工加费过滤抵扣费用项 - 申报明细新增产品标识编码/非标产品标识编码/标准产品标识编码(PC/H5 下单、改单、详情、开放接口文档、公式目录、Excel 模板) - 9610 页「批量申报」改为「创建9610订单」:选运单并补充出口资质备案、电商平台 - 店铺订单批量按钮按动作独立 loading,获取面单限并发并显示进度 - 另含:国家管理、登录门户切换、监控与工作台、公告弹窗、nginx/vite 配置等改动 --- nginx-default.conf | 3 + nginx-docker.conf | 1 + nginx-k3s.conf | 1 + src/api/monitor.ts | 164 +++- src/api/oms/common.ts | 3 +- src/api/order/customs9610.ts | 11 + src/api/order/index.ts | 89 +- src/api/system/country.ts | 120 ++- src/api/system/notice.ts | 2 + .../icons/workplace/customer-inactive.svg | 7 + src/assets/icons/workplace/customer-new.svg | 13 + .../icons/workplace/customer-online.svg | 8 + src/assets/icons/workplace/customer-total.svg | 7 + src/constants/feeCostItems.ts | 9 + src/constants/pageButtonCatalog.ts | 10 +- src/hooks/orderPayCoupon.ts | 161 ++++ src/locale/en-US.ts | 136 ++- src/locale/zh-CN.ts | 136 ++- .../routes/newModules/system-management.ts | 10 + src/utils/menu-i18n.ts | 1 + src/utils/menu-icon.ts | 1 + src/utils/orderCreateOneBody.ts | 3 + src/utils/runWithConcurrency.ts | 16 + src/utils/syncMenuLevel3Buttons.ts | 19 +- src/views/api/openApi/catalog.ts | 8 +- src/views/channel/autoTrigger/formulaMeta.ts | 3 + src/views/channel/salesProducts/index.vue | 5 +- src/views/channel/serviceChannels/index.vue | 5 +- src/views/h5/admin-center/locale/en-US.ts | 31 +- src/views/h5/admin-center/locale/zh-CN.ts | 31 +- src/views/h5/admin-center/login/index.vue | 83 +- src/views/h5/admin-center/monitor/index.vue | 533 ++++++++--- src/views/h5/admin-center/workplace/index.vue | 190 +++- .../h5/components/H5LoginPortalSwitch.vue | 14 +- .../components/H5NoticePopup.vue | 198 ++++ .../components/H5OrderPaySheet.vue | 338 ++++++- .../components/H5PlaceOrderSheet.vue | 25 + src/views/h5/customer-center/layout.vue | 5 + src/views/h5/customer-center/locale/en-US.ts | 22 + src/views/h5/customer-center/locale/zh-CN.ts | 22 + src/views/home/monitor/index.vue | 202 ++-- src/views/home/monitor/locale/en-US.ts | 19 + src/views/home/monitor/locale/zh-CN.ts | 19 + .../login/components/LoginPortalSwitch.vue | 63 ++ .../login/components/pwd-login/index.vue | 4 +- src/views/login/index.vue | 2 + src/views/login/locale/en-US.ts | 2 + src/views/login/locale/zh-CN.ts | 2 + .../components/orderDetailFieldConfig.ts | 9 + .../components/CreateCustoms9610Modal.vue | 519 ++++++++++ src/views/order/customs9610Order/index.vue | 52 +- .../myOrders/components/AddOrderFeeModal.vue | 3 +- .../myOrders/components/OrderDetailDrawer.vue | 28 +- .../components/OrderPayConfirmModal.vue | 895 +++++++++++++++--- src/views/order/myOrders/index.vue | 4 + .../placeOrder/declarationColumnConfig.ts | 3 + src/views/order/placeOrder/index.vue | 24 + .../CreateLogisticsModal.vue | 40 +- .../StoreOrderDetailDrawer.vue | 24 +- src/views/order/platformStoreOrder/index.vue | 300 +++--- .../components/AddOrderFeeModal.vue | 3 +- .../components/OrderDetailDrawer.vue | 35 +- .../components/OrderPayConfirmModal.vue | 368 ------- src/views/order/waybillManage/index.vue | 6 +- src/views/system/announcement/index.vue | 26 +- .../system/country/CountryImportModal.vue | 232 +++++ src/views/system/country/index.vue | 395 ++++++++ src/views/system/dict/dict-batch-import.ts | 2 + vite.config.ts | 3 +- 69 files changed, 4649 insertions(+), 1079 deletions(-) create mode 100644 src/assets/icons/workplace/customer-inactive.svg create mode 100644 src/assets/icons/workplace/customer-new.svg create mode 100644 src/assets/icons/workplace/customer-online.svg create mode 100644 src/assets/icons/workplace/customer-total.svg create mode 100644 src/constants/feeCostItems.ts create mode 100644 src/hooks/orderPayCoupon.ts create mode 100644 src/utils/runWithConcurrency.ts create mode 100644 src/views/h5/customer-center/components/H5NoticePopup.vue create mode 100644 src/views/login/components/LoginPortalSwitch.vue create mode 100644 src/views/order/customs9610Order/components/CreateCustoms9610Modal.vue delete mode 100644 src/views/order/waybillManage/components/OrderPayConfirmModal.vue create mode 100644 src/views/system/country/CountryImportModal.vue create mode 100644 src/views/system/country/index.vue diff --git a/nginx-default.conf b/nginx-default.conf index c828acf..6e9d274 100644 --- a/nginx-default.conf +++ b/nginx-default.conf @@ -252,6 +252,9 @@ server { location = /system/announcement { try_files /index.html =200; } + location = /system/country { + try_files /index.html =200; + } location ^~ /system/users { try_files /index.html =200; } diff --git a/nginx-docker.conf b/nginx-docker.conf index aa350bc..9cf8ed8 100644 --- a/nginx-docker.conf +++ b/nginx-docker.conf @@ -142,6 +142,7 @@ server { location = /system/dict { try_files /index.html =200; } location = /system/warehouseAddress { try_files /index.html =200; } location = /system/announcement { try_files /index.html =200; } + location = /system/country { try_files /index.html =200; } location ^~ /system/users { try_files /index.html =200; } location ^~ /system/roles { try_files /index.html =200; } location ^~ /system/menus { try_files /index.html =200; } diff --git a/nginx-k3s.conf b/nginx-k3s.conf index 178c991..6b3f6ab 100644 --- a/nginx-k3s.conf +++ b/nginx-k3s.conf @@ -177,6 +177,7 @@ server { location = /system/dict { try_files $uri @spa; } location = /system/warehouseAddress { try_files $uri @spa; } location = /system/announcement { try_files $uri @spa; } + location = /system/country { try_files $uri @spa; } location ^~ /system/users { try_files $uri @spa; } location ^~ /system/roles { try_files $uri @spa; } location ^~ /system/menus { try_files $uri @spa; } diff --git a/src/api/monitor.ts b/src/api/monitor.ts index 4afe118..f00019d 100644 --- a/src/api/monitor.ts +++ b/src/api/monitor.ts @@ -63,6 +63,25 @@ function normalizeRegionTrend(raw: unknown) { } } +export type MonitorDimension = 'day' | 'week' | 'month' + +export interface MonitorOpsStats { + dimension: MonitorDimension + orderCount: number + orderCountGrowthPercent: number | null + revenue: number + revenueGrowthPercent: number | null + grossProfit: number + grossProfitGrowthPercent: number | null +} + +export interface MonitorQueueStats { + pendingPayment: number + pendingOnline: number + pendingSign: number + problematic: number +} + export interface MonitorOverview { order: { todayCreated: number @@ -76,6 +95,8 @@ export interface MonitorOverview { todayRevenue: number todayRevenueGrowthPercent: number | null } + ops: MonitorOpsStats + queue: MonitorQueueStats customer: { totalCustomers: number totalCustomersMonthGrowthPercent: number | null @@ -85,7 +106,11 @@ export interface MonitorOverview { inactiveLogin7d: number inactiveLogin7dDelta: number } + orderTrend: Array<{ label: string; count: number }> + revenueTrend: Array<{ label: string; revenue: number; grossProfit: number }> + /** @deprecated 兼容旧字段;优先用 orderTrend */ orderTrend7d: Array<{ label: string; count: number }> + /** @deprecated 兼容旧字段;优先用 revenueTrend */ revenueTrend7d: Array<{ label: string; revenue: number; grossProfit: number }> regionOrderTrend: ReturnType regionReceivableTrend: ReturnType @@ -97,26 +122,110 @@ export interface MonitorOverview { } } +function normalizeOrderTrend(raw: unknown): Array<{ label: string; count: number }> { + if (!Array.isArray(raw)) return [] + return raw.map((item) => { + const row = item as Record + return { + label: String(row.label ?? row.Label ?? ''), + count: num(row.count ?? row.Count), + } + }) +} + +function normalizeRevenueTrend( + raw: unknown, +): Array<{ label: string; revenue: number; grossProfit: number }> { + if (!Array.isArray(raw)) return [] + return raw.map((item) => { + const row = item as Record + return { + label: String(row.label ?? row.Label ?? ''), + revenue: num(row.revenue ?? row.Revenue), + grossProfit: num(row.grossProfit ?? row.GrossProfit), + } + }) +} + function normalizeOverview(raw: Record): MonitorOverview { const order = (raw.order ?? raw.Order ?? {}) as Record + const ops = (raw.ops ?? raw.Ops ?? {}) as Record + const queue = (raw.queue ?? raw.Queue ?? {}) as Record const customer = (raw.customer ?? raw.Customer ?? {}) as Record const track = (raw.trackRates ?? raw.TrackRates ?? {}) as Record - const orderTrendRaw = raw.orderTrend7d ?? raw.OrderTrend7d - const revenueTrendRaw = raw.revenueTrend7d ?? raw.RevenueTrend7d const signRatesRaw = raw.regionSignRates ?? raw.RegionSignRates + const orderTrend = + normalizeOrderTrend(raw.orderTrend ?? raw.OrderTrend).length > 0 + ? normalizeOrderTrend(raw.orderTrend ?? raw.OrderTrend) + : normalizeOrderTrend(raw.orderTrend7d ?? raw.OrderTrend7d) + const revenueTrend = + normalizeRevenueTrend(raw.revenueTrend ?? raw.RevenueTrend).length > 0 + ? normalizeRevenueTrend(raw.revenueTrend ?? raw.RevenueTrend) + : normalizeRevenueTrend(raw.revenueTrend7d ?? raw.RevenueTrend7d) + + const pendingPayment = num( + queue.pendingPayment ?? queue.PendingPayment ?? order.pendingPayment ?? order.PendingPayment, + ) + const pendingOnline = num( + queue.pendingOnline ?? queue.PendingOnline ?? order.pendingOnline ?? order.PendingOnline, + ) + const pendingSign = num( + queue.pendingSign ?? queue.PendingSign ?? order.pendingSign ?? order.PendingSign, + ) + const problematic = num( + queue.problematic ?? queue.Problematic ?? order.problematic ?? order.Problematic, + ) + + const dimRaw = String(ops.dimension ?? ops.Dimension ?? 'day').toLowerCase() + const dimension: MonitorDimension = + dimRaw === 'week' || dimRaw === 'month' ? dimRaw : 'day' + return { order: { todayCreated: num(order.todayCreated ?? order.TodayCreated), - todayCreatedGrowthPercent: growth(order.todayCreatedGrowthPercent ?? order.TodayCreatedGrowthPercent), + todayCreatedGrowthPercent: growth( + order.todayCreatedGrowthPercent ?? order.TodayCreatedGrowthPercent, + ), total: num(order.total ?? order.Total), - totalMonthGrowthPercent: growth(order.totalMonthGrowthPercent ?? order.TotalMonthGrowthPercent), - pendingPayment: num(order.pendingPayment ?? order.PendingPayment), - pendingOnline: num(order.pendingOnline ?? order.PendingOnline), - pendingSign: num(order.pendingSign ?? order.PendingSign), - problematic: num(order.problematic ?? order.Problematic), + totalMonthGrowthPercent: growth( + order.totalMonthGrowthPercent ?? order.TotalMonthGrowthPercent, + ), + pendingPayment, + pendingOnline, + pendingSign, + problematic, todayRevenue: num(order.todayRevenue ?? order.TodayRevenue), - todayRevenueGrowthPercent: growth(order.todayRevenueGrowthPercent ?? order.TodayRevenueGrowthPercent), + todayRevenueGrowthPercent: growth( + order.todayRevenueGrowthPercent ?? order.TodayRevenueGrowthPercent, + ), + }, + ops: { + dimension, + orderCount: num(ops.orderCount ?? ops.OrderCount ?? order.todayCreated ?? order.TodayCreated), + orderCountGrowthPercent: growth( + ops.orderCountGrowthPercent ?? + ops.OrderCountGrowthPercent ?? + order.todayCreatedGrowthPercent ?? + order.TodayCreatedGrowthPercent, + ), + revenue: num(ops.revenue ?? ops.Revenue ?? order.todayRevenue ?? order.TodayRevenue), + revenueGrowthPercent: growth( + ops.revenueGrowthPercent ?? + ops.RevenueGrowthPercent ?? + order.todayRevenueGrowthPercent ?? + order.TodayRevenueGrowthPercent, + ), + grossProfit: num(ops.grossProfit ?? ops.GrossProfit), + grossProfitGrowthPercent: growth( + ops.grossProfitGrowthPercent ?? ops.GrossProfitGrowthPercent, + ), + }, + queue: { + pendingPayment, + pendingOnline, + pendingSign, + problematic, }, customer: { totalCustomers: num(customer.totalCustomers ?? customer.TotalCustomers), @@ -131,27 +240,14 @@ function normalizeOverview(raw: Record): MonitorOverview { inactiveLogin7d: num(customer.inactiveLogin7d ?? customer.InactiveLogin7d), inactiveLogin7dDelta: num(customer.inactiveLogin7dDelta ?? customer.InactiveLogin7dDelta), }, - orderTrend7d: Array.isArray(orderTrendRaw) - ? orderTrendRaw.map((item) => { - const row = item as Record - return { - label: String(row.label ?? row.Label ?? ''), - count: num(row.count ?? row.Count), - } - }) - : [], - revenueTrend7d: Array.isArray(revenueTrendRaw) - ? revenueTrendRaw.map((item) => { - const row = item as Record - return { - label: String(row.label ?? row.Label ?? ''), - revenue: num(row.revenue ?? row.Revenue), - grossProfit: num(row.grossProfit ?? row.GrossProfit), - } - }) - : [], + orderTrend, + revenueTrend, + orderTrend7d: orderTrend, + revenueTrend7d: revenueTrend, regionOrderTrend: normalizeRegionTrend(raw.regionOrderTrend ?? raw.RegionOrderTrend), - regionReceivableTrend: normalizeRegionTrend(raw.regionReceivableTrend ?? raw.RegionReceivableTrend), + regionReceivableTrend: normalizeRegionTrend( + raw.regionReceivableTrend ?? raw.RegionReceivableTrend, + ), regionSignRates: Array.isArray(signRatesRaw) ? signRatesRaw.map((item) => { const row = item as Record @@ -169,9 +265,13 @@ function normalizeOverview(raw: Record): MonitorOverview { } } -/** 实时监控概览 GET /monitor/overview */ -export async function getMonitorOverview(): Promise { - const res = await axiosInstance.get(monitorUrl('/monitor/overview')) +/** 实时监控概览 GET /monitor/overview?dimension=day|week|month */ +export async function getMonitorOverview( + dimension: MonitorDimension = 'day', +): Promise { + const res = await axiosInstance.get(monitorUrl('/monitor/overview'), { + params: { dimension }, + }) assertBusinessOk(res.data) const payload = res.data as Record const data = (payload.data ?? payload.Data ?? payload) as Record diff --git a/src/api/oms/common.ts b/src/api/oms/common.ts index d0e17d2..7af3918 100644 --- a/src/api/oms/common.ts +++ b/src/api/oms/common.ts @@ -23,7 +23,8 @@ export function omsUrl(path: string): string { } export function assertBusinessOk(body: any) { - if (body == null) throw new Error('空响应') + // Task/void 接口常返回 HTTP 200 空 body,视为成功 + if (body == null || body === '') return const code = body.code ?? body.Code const ok = code === undefined || diff --git a/src/api/order/customs9610.ts b/src/api/order/customs9610.ts index ead6e52..cba578b 100644 --- a/src/api/order/customs9610.ts +++ b/src/api/order/customs9610.ts @@ -35,3 +35,14 @@ export const fetchCustoms9610ManifestBatch = (ids: Array) => export const fetchCustoms9610DeclarationFormBatch = (ids: Array) => postOk('/order/customs-9610/fetch-declaration-form-batch', { ids }) + +export interface Customs9610CreateFromOrdersBody { + orderIds: number[] + /** 不传则按各运单客户的默认出口资质备案 */ + exportQualificationId?: number + platformCode: string +} + +/** 从已有运单批量创建 9610 汇总申报单;逐单返回成功/失败 */ +export const createCustoms9610FromOrders = (body: Customs9610CreateFromOrdersBody) => + postOk('/order/customs-9610/create-from-orders', body as unknown as Record) diff --git a/src/api/order/index.ts b/src/api/order/index.ts index afc8ecb..e9dce91 100644 --- a/src/api/order/index.ts +++ b/src/api/order/index.ts @@ -271,9 +271,94 @@ async function postOrderById(path: string, body: OrderIdRequest): Promise assertBusinessOk(res.data) } +export interface OrderPayRequest extends OrderIdRequest { + /** 不传沿用订单已绑定券;0 不用券;大于 0 使用指定券 */ + couponInstanceId?: number +} + /** POST /order/pay */ -export function postOrderPay(body: OrderIdRequest) { - return postOrderById('/order/pay', body) +export async function postOrderPay(body: OrderPayRequest): Promise { + const payload: Record = { id: body.id } + if (body.couponInstanceId != null) payload.couponInstanceId = body.couponInstanceId + const res = await axiosInstance.post(orderUrl('/order/pay'), payload) + assertBusinessOk(res.data) +} + +export interface OrderPayCouponOption { + couponInstanceId: number + couponCode: string + templateName: string + couponType: string + minOrderAmount: number + faceValue?: number + discountRate?: number + maxDiscountAmount?: number + validEndTime?: string + canUse: boolean + unavailableReason?: string + estimatedDiscount?: number +} + +export interface OrderPayPreview { + id: number + receivableSubtotal: number + baseFreightAmount: number + levelDiscountAmount: number + couponDiscountAmount: number + payableAmount: number + memberLevelName?: string + appliedCouponInstanceId?: number + availableCoupons: OrderPayCouponOption[] +} + +function toOptionalNumber(v: unknown): number | undefined { + if (v === null || v === undefined || v === '') return undefined + const n = Number(v) + return Number.isFinite(n) ? n : undefined +} + +function normalizeOrderPayCoupon(row: any): OrderPayCouponOption { + return { + couponInstanceId: Number(row?.couponInstanceId ?? row?.CouponInstanceId ?? 0), + couponCode: String(row?.couponCode ?? row?.CouponCode ?? ''), + templateName: String(row?.templateName ?? row?.TemplateName ?? ''), + couponType: String(row?.couponType ?? row?.CouponType ?? ''), + minOrderAmount: Number(row?.minOrderAmount ?? row?.MinOrderAmount ?? 0) || 0, + faceValue: toOptionalNumber(row?.faceValue ?? row?.FaceValue), + discountRate: toOptionalNumber(row?.discountRate ?? row?.DiscountRate), + maxDiscountAmount: toOptionalNumber(row?.maxDiscountAmount ?? row?.MaxDiscountAmount), + validEndTime: row?.validEndTime ?? row?.ValidEndTime ?? undefined, + canUse: Boolean(row?.canUse ?? row?.CanUse), + unavailableReason: row?.unavailableReason ?? row?.UnavailableReason ?? undefined, + estimatedDiscount: toOptionalNumber(row?.estimatedDiscount ?? row?.EstimatedDiscount), + } +} + +/** + * 支付预览 GET /order/pay-preview + * couponInstanceId:不传自动推荐券;0 不用券;大于 0 试算指定券 + */ +export async function getOrderPayPreview( + id: number | string, + couponInstanceId?: number, +): Promise { + const params: Record = { id } + if (couponInstanceId != null) params.couponInstanceId = couponInstanceId + const res = await axiosInstance.get(orderUrl('/order/pay-preview'), { params }) + assertBusinessOk(res.data) + const d = res.data?.data ?? res.data?.Data ?? res.data ?? {} + const coupons = d.availableCoupons ?? d.AvailableCoupons + return { + id: Number(d.id ?? d.Id ?? id), + receivableSubtotal: Number(d.receivableSubtotal ?? d.ReceivableSubtotal ?? 0) || 0, + baseFreightAmount: Number(d.baseFreightAmount ?? d.BaseFreightAmount ?? 0) || 0, + levelDiscountAmount: Number(d.levelDiscountAmount ?? d.LevelDiscountAmount ?? 0) || 0, + couponDiscountAmount: Number(d.couponDiscountAmount ?? d.CouponDiscountAmount ?? 0) || 0, + payableAmount: Number(d.payableAmount ?? d.PayableAmount ?? 0) || 0, + memberLevelName: d.memberLevelName ?? d.MemberLevelName ?? undefined, + appliedCouponInstanceId: toOptionalNumber(d.appliedCouponInstanceId ?? d.AppliedCouponInstanceId), + availableCoupons: (Array.isArray(coupons) ? coupons : []).map(normalizeOrderPayCoupon), + } } /** POST /order/pre-declare */ diff --git a/src/api/system/country.ts b/src/api/system/country.ts index 807fe73..3f4be3f 100644 --- a/src/api/system/country.ts +++ b/src/api/system/country.ts @@ -1,8 +1,124 @@ import { axiosInstance } from '@/api/rbac' -import { omsUrl, parseListPayload } from '@/api/oms/common' +import { assertBusinessOk, omsUrl, parseListPayload, unwrapData } from '@/api/oms/common' +import * as XLSX from 'xlsx' -/** GET /system/country/list — data 为数组,含 chineseName、countryCode 等 */ +export interface CountryDto { + id: number + chineseName: string + englishName: string + countryCode: string + createTime?: string + updateTime?: string +} + +export interface CountryImportResult { + successCount: number + skipCount: number + failCount: number + deletedCount: number + failures: Array<{ rowNumber: number; reason: string }> +} + +function pickString(row: Record, ...keys: string[]) { + for (const key of keys) { + const value = row[key] + if (value != null && String(value).trim()) return String(value).trim() + } + return '' +} + +export function mapCountryRow(row: Record): CountryDto { + const id = Number(row.id ?? row.Id) + return { + id: Number.isFinite(id) ? id : 0, + chineseName: pickString(row, 'chineseName', 'ChineseName'), + englishName: pickString(row, 'englishName', 'EnglishName'), + countryCode: pickString(row, 'countryCode', 'CountryCode').toUpperCase(), + createTime: pickString(row, 'createTime', 'CreateTime') || undefined, + updateTime: pickString(row, 'updateTime', 'UpdateTime') || undefined, + } +} + +/** GET /system/country/list */ export async function getCountryList() { const res = await axiosInstance.get(omsUrl('/system/country/list')) return parseListPayload(res.data) } + +export async function createCountry(body: { + chineseName: string + englishName: string + countryCode: string +}) { + const res = await axiosInstance.post(omsUrl('/system/country/create'), body) + assertBusinessOk(res.data) +} + +export async function updateCountry(body: { + id: number + chineseName: string + englishName: string + countryCode: string +}) { + const res = await axiosInstance.post(omsUrl('/system/country/update'), body) + assertBusinessOk(res.data) +} + +export async function deleteCountry(id: number) { + const res = await axiosInstance.post(omsUrl('/system/country/delete'), { id }) + assertBusinessOk(res.data) +} + +const TEMPLATE_HEADERS = ['国家二字码', '中文名', '英文名'] as const + +function writeCountryWorkbook(rows: Array>, fileName: string) { + const wb = XLSX.utils.book_new() + const sheet = XLSX.utils.json_to_sheet(rows, { header: [...TEMPLATE_HEADERS] }) + sheet['!cols'] = [{ wch: 14 }, { wch: 18 }, { wch: 24 }] + XLSX.utils.book_append_sheet(wb, sheet, '导入模板') + XLSX.writeFile(wb, fileName.endsWith('.xlsx') ? fileName : `${fileName}.xlsx`) +} + +export function downloadCountryImportTemplate() { + writeCountryWorkbook( + [ + { 国家二字码: 'CN', 中文名: '中国', 英文名: 'China' }, + { 国家二字码: 'US', 中文名: '美国', 英文名: 'United States' }, + ], + '国家地区导入模板.xlsx' + ) +} + +export function exportCountryExcel(rows: CountryDto[], fileName = '国家地区.xlsx') { + writeCountryWorkbook( + rows.map((row) => ({ + 国家二字码: row.countryCode, + 中文名: row.chineseName, + 英文名: row.englishName, + })), + fileName + ) +} + +export async function importCountry( + file: File, + importMode: 'incremental' | 'full' +): Promise { + const form = new FormData() + form.append('importMode', importMode) + form.append('file', file) + const res = await axiosInstance.post(omsUrl('/system/country/import'), form) + assertBusinessOk(res.data) + const d = unwrapData>(res.data) ?? {} + const rawFailures = (d.failures ?? d.Failures ?? []) as Array> + return { + successCount: Number(d.successCount ?? d.SuccessCount ?? 0), + skipCount: Number(d.skipCount ?? d.SkipCount ?? 0), + failCount: Number(d.failCount ?? d.FailCount ?? 0), + deletedCount: Number(d.deletedCount ?? d.DeletedCount ?? 0), + failures: rawFailures.map((item) => ({ + rowNumber: Number(item.rowNumber ?? item.RowNumber ?? 0), + reason: String(item.reason ?? item.Reason ?? ''), + })), + } +} diff --git a/src/api/system/notice.ts b/src/api/system/notice.ts index 66562e0..b0b5437 100644 --- a/src/api/system/notice.ts +++ b/src/api/system/notice.ts @@ -51,6 +51,8 @@ export async function getNoticeList(params: { receiverType?: string keyword?: string onlyUnread?: boolean + /** 管理端查看全部(管理员);工作台/H5 勿传 */ + manage?: boolean }) { const res = await axiosInstance.get(omsUrl('/system/notice/list'), { params }) const { items, total } = parseListPayload(res.data) diff --git a/src/assets/icons/workplace/customer-inactive.svg b/src/assets/icons/workplace/customer-inactive.svg new file mode 100644 index 0000000..87c0155 --- /dev/null +++ b/src/assets/icons/workplace/customer-inactive.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/icons/workplace/customer-new.svg b/src/assets/icons/workplace/customer-new.svg new file mode 100644 index 0000000..b338111 --- /dev/null +++ b/src/assets/icons/workplace/customer-new.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/assets/icons/workplace/customer-online.svg b/src/assets/icons/workplace/customer-online.svg new file mode 100644 index 0000000..343db82 --- /dev/null +++ b/src/assets/icons/workplace/customer-online.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/assets/icons/workplace/customer-total.svg b/src/assets/icons/workplace/customer-total.svg new file mode 100644 index 0000000..7186292 --- /dev/null +++ b/src/assets/icons/workplace/customer-total.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/constants/feeCostItems.ts b/src/constants/feeCostItems.ts new file mode 100644 index 0000000..57e65ec --- /dev/null +++ b/src/constants/feeCostItems.ts @@ -0,0 +1,9 @@ +/** 营销抵扣费用项:支付时由后端写入的负数应收行,不可在价格配置/手工加费中选择 */ +export const COUPON_DISCOUNT_COST_ITEM = 'coupon_discount' +export const MEMBER_DISCOUNT_COST_ITEM = 'member_discount' + +const DISCOUNT_COST_ITEMS = new Set([COUPON_DISCOUNT_COST_ITEM, MEMBER_DISCOUNT_COST_ITEM]) + +export function isDiscountCostItem(costItem: unknown) { + return DISCOUNT_COST_ITEMS.has(String(costItem ?? '').trim().toLowerCase()) +} diff --git a/src/constants/pageButtonCatalog.ts b/src/constants/pageButtonCatalog.ts index 7e86a43..b053b94 100644 --- a/src/constants/pageButtonCatalog.ts +++ b/src/constants/pageButtonCatalog.ts @@ -626,7 +626,7 @@ const CORE_GROUPS: PageButtonGroup[] = [ { code: 'Reset', name: '重置条件' }, { code: 'Update', name: '编辑' }, { code: 'View', name: '详情' }, - { code: 'DeclareBatch', name: '批量申报' }, + { code: 'DeclareBatch', name: '创建9610订单' }, { code: 'FetchManifestBatch', name: '批量获取清单数据' }, { code: 'FetchDeclarationFormBatch', name: '预报下游' }, ]), @@ -1030,6 +1030,14 @@ const CORE_GROUPS: PageButtonGroup[] = [ { code: 'Delete', name: '删除' }, { code: 'Copy', name: '复制' }, ]), + B('System.Country', [ + { code: 'Query', name: '查询' }, + { code: 'Create', name: '新增' }, + { code: 'Update', name: '修改' }, + { code: 'Delete', name: '删除' }, + { code: 'Import', name: '导入' }, + { code: 'Export', name: '导出' }, + ]), B('System.OtherConfig', [ { code: 'Query', name: '查询' }, { code: 'AddConfig', name: '新增配置' }, diff --git a/src/hooks/orderPayCoupon.ts b/src/hooks/orderPayCoupon.ts new file mode 100644 index 0000000..db53749 --- /dev/null +++ b/src/hooks/orderPayCoupon.ts @@ -0,0 +1,161 @@ +import { computed, ref } from 'vue'; +import { + getOrderPayPreview, + type OrderPayCouponOption, + type OrderPayPreview, +} from '@/api/order'; +import { runWithConcurrency } from '@/utils/runWithConcurrency'; + +type OrderKey = number | string; + +export interface OrderPayCouponEntry { + pricing: OrderPayPreview | null; + /** 0 表示不用券 */ + selectedCouponId: number; + loading: boolean; + error?: string; +} + +export interface UseOrderPayCouponOptions { + /** 选了券但后端按会员折计算(券未生效) */ + onNotApplied?: () => void; + onError?: (message: string) => void; +} + +const PREVIEW_CONCURRENCY = 4; + +export { runWithConcurrency }; + +export function formatPayMoney(amount: number | undefined | null) { + const n = Number(amount); + return `¥${(Number.isFinite(n) ? n : 0).toFixed(2)}`; +} + +/** 券面优惠描述:优先本单预估抵扣,否则按券面值/折扣率 */ +export function formatCouponBenefit(coupon: OrderPayCouponOption) { + if (coupon.estimatedDiscount != null && coupon.estimatedDiscount > 0) { + return `-${formatPayMoney(coupon.estimatedDiscount)}`; + } + if (coupon.couponType === 'percent_off' && coupon.discountRate) { + return `-${Math.round((1 - coupon.discountRate) * 100)}%`; + } + return coupon.faceValue != null ? formatPayMoney(coupon.faceValue) : ''; +} + +export function useOrderPayCoupon(options: UseOrderPayCouponOptions = {}) { + const entries = ref>({}); + + function entryOf(id: OrderKey): OrderPayCouponEntry | undefined { + return entries.value[String(id)]; + } + + function takenByOthers(id: OrderKey) { + const self = String(id); + const taken = new Set(); + for (const [key, entry] of Object.entries(entries.value)) { + if (key !== self && entry.selectedCouponId > 0) taken.add(entry.selectedCouponId); + } + return taken; + } + + function isCouponTakenByOther(id: OrderKey, couponId: number) { + return takenByOthers(id).has(couponId); + } + + async function loadAll(orderIds: OrderKey[]) { + const next: Record = {}; + for (const id of orderIds) { + next[String(id)] = { pricing: null, selectedCouponId: 0, loading: true }; + } + entries.value = next; + + await runWithConcurrency(orderIds, PREVIEW_CONCURRENCY, async (id) => { + const entry = entryOf(id); + if (!entry) return; + try { + const pricing = await getOrderPayPreview(id); + entry.pricing = pricing; + entry.selectedCouponId = pricing.appliedCouponInstanceId ?? 0; + } catch (e: any) { + entry.error = e?.message || String(e); + } finally { + entry.loading = false; + } + }); + + // 各单独立推荐可能撞同一张券:按勾选顺序先到先得,后者改用下一张或不用券 + const taken = new Set(); + for (const id of orderIds) { + const entry = entryOf(id); + if (!entry?.pricing || entry.selectedCouponId <= 0) continue; + if (!taken.has(entry.selectedCouponId)) { + taken.add(entry.selectedCouponId); + continue; + } + const alt = entry.pricing.availableCoupons.find( + (c) => c.canUse && !taken.has(c.couponInstanceId), + ); + entry.loading = true; + try { + const pricing = await getOrderPayPreview(id, alt?.couponInstanceId ?? 0); + entry.pricing = pricing; + entry.selectedCouponId = pricing.appliedCouponInstanceId ?? 0; + } catch (e: any) { + entry.error = e?.message || String(e); + } finally { + entry.loading = false; + } + if (entry.selectedCouponId > 0) taken.add(entry.selectedCouponId); + } + } + + function removeEntry(id: OrderKey) { + const rest = { ...entries.value }; + delete rest[String(id)]; + entries.value = rest; + } + + async function selectCoupon(id: OrderKey, couponId: number) { + const entry = entryOf(id); + if (!entry || entry.loading || entry.selectedCouponId === couponId) return; + entry.loading = true; + try { + const pricing = await getOrderPayPreview(id, couponId); + entry.pricing = pricing; + entry.selectedCouponId = pricing.appliedCouponInstanceId ?? 0; + if (couponId > 0 && entry.selectedCouponId !== couponId) options.onNotApplied?.(); + } catch (e: any) { + options.onError?.(e?.message || String(e)); + } finally { + entry.loading = false; + } + } + + function reset() { + entries.value = {}; + } + + const anyLoading = computed(() => Object.values(entries.value).some((x) => x.loading)); + + const allReady = computed(() => { + const list = Object.values(entries.value); + return list.length > 0 && list.every((x) => !x.loading && !x.error && x.pricing); + }); + + const totalPayable = computed(() => + Object.values(entries.value).reduce((sum, x) => sum + (x.pricing?.payableAmount ?? 0), 0), + ); + + return { + entries, + entryOf, + isCouponTakenByOther, + loadAll, + selectCoupon, + removeEntry, + reset, + anyLoading, + allReady, + totalPayable, + }; +} diff --git a/src/locale/en-US.ts b/src/locale/en-US.ts index a8d201f..c40dcb7 100644 --- a/src/locale/en-US.ts +++ b/src/locale/en-US.ts @@ -654,6 +654,38 @@ export default { 'order.customs9610.table.createTime': 'Created', 'order.customs9610.actions.declare': 'Declare', 'order.customs9610.actions.batchDeclare': 'Batch declare', + 'order.customs9610.actions.createFromOrders': 'Create 9610 orders', + 'order.customs9610.create.title': 'Create 9610 orders', + 'order.customs9610.create.tip': 'Pick orders and fill in the export qualification and e-commerce platform. The orders are switched to 9610 summary declaration and pending declarations are created. Deleted orders, orders that already have a 9610 record, or orders without declaration items will fail without affecting the others.', + 'order.customs9610.create.sectionSupplement': 'Declaration info', + 'order.customs9610.create.sectionOrders': 'Select orders', + 'order.customs9610.create.customer': 'Customer', + 'order.customs9610.create.customerPh': 'Select customer', + 'order.customs9610.create.exportQualification': 'Export qualification', + 'order.customs9610.create.exportQualificationPh': "Leave empty to use the customer's default", + 'order.customs9610.create.manageQualification': 'Manage', + 'order.customs9610.create.defaultSuffix': ' (default)', + 'order.customs9610.create.platform': 'E-commerce platform', + 'order.customs9610.create.platformPh': 'Select platform', + 'order.customs9610.create.keywordPh': 'Waybill / client no. / tracking no.', + 'order.customs9610.create.selected': '{count} selected', + 'order.customs9610.create.clearSelected': 'Clear', + 'order.customs9610.create.col.serviceNo': 'Waybill no.', + 'order.customs9610.create.col.clientNo': 'Client no.', + 'order.customs9610.create.col.customer': 'Customer', + 'order.customs9610.create.col.country': 'Destination', + 'order.customs9610.create.col.status': 'Status', + 'order.customs9610.create.col.createTime': 'Created', + 'order.customs9610.create.col.remark': 'Note', + 'order.customs9610.create.already9610': 'Already 9610', + 'order.customs9610.create.selectCustomerFirst': 'Select a customer first', + 'order.customs9610.create.emptyOrders': 'No orders', + 'order.customs9610.create.submit': 'Create 9610 orders ({count})', + 'order.customs9610.create.failTitle': '{count} failed', + 'order.customs9610.create.message.loadOrdersFailed': 'Failed to load orders', + 'order.customs9610.create.message.allSuccess': '{count} 9610 orders created', + 'order.customs9610.create.message.partial': '{success} succeeded, {fail} failed. See reasons below.', + 'order.customs9610.create.message.failed': 'Create failed', 'order.customs9610.actions.batchFetchManifest': 'Batch fetch manifest', 'order.customs9610.actions.batchFetchDeclarationForm': 'Forward Downstream', 'order.customs9610.modal.edit': 'Edit 9610 declaration', @@ -969,6 +1001,9 @@ export default { 'order.platformStoreOrder.table.customsOrderNo': 'Order No', 'order.platformStoreOrder.table.store': 'Store', 'order.platformStoreOrder.table.status': 'Status', + 'order.platformStoreOrder.table.platformStatus': 'Platform Status', + 'order.platformStoreOrder.table.fulfillmentType': 'Fulfillment Type', + 'order.platformStoreOrder.table.shippingProvider': 'Shipping Provider', 'order.platformStoreOrder.table.amount': 'Amount', 'order.platformStoreOrder.table.orderTime': 'Order Time', 'order.platformStoreOrder.table.linkedServiceNo': 'Linked Service No', @@ -982,8 +1017,8 @@ export default { '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.shippingProvider': 'Shipping Provider', + 'order.platformStoreOrder.drawer.fulfillmentType': 'Fulfillment Type', 'order.platformStoreOrder.drawer.openLabel': 'Open Label', 'order.platformStoreOrder.recipient.name': 'Recipient', 'order.platformStoreOrder.recipient.company': 'Company', @@ -1004,7 +1039,6 @@ export default { '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', @@ -1015,6 +1049,7 @@ 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.fetchLabelProgress': 'Fetching {done}/{total}', 'order.platformStoreOrder.actions.createLogistics': 'Create Waybill', 'order.platformStoreOrder.actions.createFulfill': 'Create Fulfill & Waybill', 'order.platformStoreOrder.actions.createFulfillWms': 'Create Fulfill Order', @@ -1104,6 +1139,8 @@ export default { 'order.platformStoreOrder.message.matchFailed': 'Match failed', 'order.platformStoreOrder.message.warehouseRequired': 'Select warehouse', 'order.platformStoreOrder.message.suspendSuccess': 'Status updated', + 'order.platformStoreOrder.message.suspendConfirm': 'Suspend the selected {count} order(s)?', + 'order.platformStoreOrder.message.resumeConfirm': 'Resume the selected {count} order(s)?', 'order.platformStoreOrder.message.deleteSuccess': 'Deleted', 'order.platformStoreOrder.message.batchEmpty': 'No pending orders to match on this page', 'order.platformStoreOrder.message.batchConfirm': 'Match {count} pending orders on this page and create parcel orders?', @@ -1116,12 +1153,15 @@ export default { 'order.platformStoreOrder.message.syncConfirm': 'Sync status for {count} orders?', 'order.platformStoreOrder.message.rtsConfirm': 'Ready-to-ship {count} orders?', 'order.platformStoreOrder.message.labelConfirm': 'Re-fetch shipping label for this order?', + 'order.platformStoreOrder.message.labelConfirmBatch': 'Re-fetch labels for the selected {count} order(s)?', + 'order.platformStoreOrder.message.labelResult': 'Label fetch done: {success} succeeded, {fail} failed', 'order.platformStoreOrder.message.syncResult': 'Sync done: {success} succeeded, {fail} failed', 'order.platformStoreOrder.message.rtsResult': 'RTS done: {success} succeeded, {fail} failed', 'order.platformStoreOrder.message.pullResult': 'Pull done: {success} stores ok, {fail} failed', 'order.platformStoreOrder.message.pullFailed': 'Pull failed', 'order.platformStoreOrder.message.labelSuccess': 'Label updated', 'order.platformStoreOrder.message.labelFailed': 'Failed to fetch label', + 'order.platformStoreOrder.message.printNoLabel': 'No printable label available', 'order.platformStoreOrder.message.temuNoRts': 'Temu does not support RTS', 'order.platformStoreOrder.message.actionFailed': 'Action failed', 'menu.channel': 'Channel', @@ -1308,6 +1348,7 @@ export default { 'menu.system.interface': 'System Interface', 'menu.system.interface.config': 'Interface Config', 'menu.system.warehouseAddress': 'Warehouse Address', + 'menu.system.country': 'Countries & Regions', 'menu.system.batchEmail': 'Batch Email', 'menu.system.aiAssistant': 'AI Assistant', 'menu.system.aiSkills': 'AI Skills', @@ -1789,6 +1830,40 @@ export default { 'order.myOrders.modal.payFailed': 'Payment failed', 'order.myOrders.modal.payLoadFailed': 'Failed to load fee details', 'order.myOrders.modal.payInvalidAmount': 'Invalid receivable amount', + 'order.myOrders.modal.payPendingOnly': 'Only orders pending payment can be paid. Please reselect.', + 'order.myOrders.modal.payCoupon': 'Coupon', + 'order.myOrders.modal.payCouponNone': 'No coupon', + 'order.myOrders.modal.payCouponEmpty': 'No coupons available', + 'order.myOrders.modal.payCouponUsedByOther': 'Used by another order', + 'order.myOrders.modal.payCouponNotApplied': 'This coupon is not better than your member discount; member discount applied', + 'order.myOrders.modal.paySubtotal': 'Fees total', + 'order.myOrders.modal.payLevelDiscount': 'Member discount', + 'order.myOrders.modal.payCouponDiscount': 'Coupon discount', + 'order.myOrders.modal.payPayable': 'To pay', + 'order.myOrders.modal.payTitleBatch': 'Confirm batch payment ({count} orders)', + 'order.myOrders.modal.paySummaryPayable': 'Total to pay', + 'order.myOrders.modal.paySummarySaved': 'Saved {amount}', + 'order.myOrders.modal.paySummaryOrders': 'Orders', + 'order.myOrders.modal.payOrdersUnit': '{count}', + 'order.myOrders.modal.payBalance': 'Balance', + 'order.myOrders.modal.payBalanceShort': 'Insufficient balance, {amount} short. Top up or pay fewer orders.', + 'order.myOrders.modal.payBalanceShortCustomer': '{name}: insufficient balance, {amount} short', + 'order.myOrders.modal.paySkipHint': '{count} order(s) cannot be paid now and will be skipped', + 'order.myOrders.modal.payOrderList': 'Orders', + 'order.myOrders.modal.payExpandAll': 'Expand all', + 'order.myOrders.modal.payCollapseAll': 'Collapse all', + 'order.myOrders.modal.payCouponUsableCount': '{count} available', + 'order.myOrders.modal.payRemove': 'Remove from this payment', + 'order.myOrders.modal.payStatusPaying': 'Paying', + 'order.myOrders.modal.payStatusSuccess': 'Paid', + 'order.myOrders.modal.payStatusFailed': 'Failed', + 'order.myOrders.modal.payStatusSkipped': 'Skipped', + 'order.myOrders.modal.payConfirmAmount': 'Pay {amount}', + 'order.myOrders.modal.payRetryFailed': 'Retry {amount}', + 'order.myOrders.modal.payProgress': 'Paying {done}/{total}', + 'order.myOrders.modal.payBatchSuccess': '{count} orders paid', + 'order.myOrders.modal.payPartialResult': '{success} paid, {failed} failed. Check the reasons and retry.', + 'order.myOrders.modal.payMethodBalance': 'Payment method: account balance', 'order.myOrders.modal.payEmpty': 'No orders to pay', 'order.myOrders.actions.intercept': 'Intercept', 'order.myOrders.actions.cancelIntercept': 'Cancel intercept', @@ -2374,6 +2449,9 @@ export default { 'order.myOrders.drawer.declarationImHsCode': 'Import HS code', 'order.myOrders.drawer.declarationExHsCode': 'Export HS code', 'order.myOrders.drawer.declarationDeclareElement': 'Export declare element', + 'order.myOrders.drawer.declarationProductId': 'Product ID', + 'order.myOrders.drawer.declarationNonStdProductId': 'Non-standard product ID', + 'order.myOrders.drawer.declarationStdProductId': 'Standard product ID', 'order.myOrders.drawer.declarationSkuImage': 'Image', 'order.myOrders.drawer.declarationSkuImageView': 'View image', 'order.myOrders.drawer.declarationCategory': 'Category', @@ -2463,6 +2541,7 @@ export default { 'order.myOrders.drawer.fee.payableTitle': 'Payable Details', 'order.myOrders.drawer.fee.viewAll': 'All', 'order.myOrders.drawer.fee.itemCount': '{count} items', + 'order.myOrders.drawer.fee.includesDiscount': 'Incl. discount {amount}', 'order.myOrders.drawer.fee.emptyReceivable': 'No receivable fees', 'order.myOrders.drawer.fee.emptyPayable': 'No payable fees', 'order.myOrders.drawer.fee.emptyAll': 'No fee data', @@ -4252,6 +4331,57 @@ export default { 'system.warehouseAddress.message.dictLoadFailed': 'Failed to load warehouse type dictionary', 'system.warehouseAddress.message.wechatQrSaveFailed': 'Failed to save WeChat QR image', + 'system.country.searchPlaceholder': 'Code / Chinese / English name', + 'system.country.fields.countryCode': 'Country Code', + 'system.country.fields.chineseName': 'Chinese Name', + 'system.country.fields.englishName': 'English Name', + 'system.country.fields.createTime': 'Created At', + 'system.country.fields.updateTime': 'Updated At', + 'system.country.table.index': 'No.', + 'system.country.placeholder.input': 'Please enter', + 'system.country.placeholder.countryCode': 'e.g. CN', + 'system.country.modal.addTitle': 'Add Country / Region', + 'system.country.modal.editTitle': 'Edit Country / Region', + 'system.country.actions.edit': 'Edit', + 'system.country.actions.delete': 'Delete', + 'system.country.actions.import': 'Import', + 'system.country.actions.export': 'Export', + 'system.country.rules.countryCodeRequired': 'Please enter the country code', + 'system.country.rules.countryCodeFormat': 'Use 2 letters, e.g. CN, US', + 'system.country.rules.chineseNameRequired': 'Please enter the Chinese name', + 'system.country.rules.englishNameRequired': 'Please enter the English name', + 'system.country.message.createSuccess': 'Created', + 'system.country.message.updateSuccess': 'Saved', + 'system.country.message.saveFailed': 'Save failed', + 'system.country.message.deleteConfirm': 'Delete "{name}"?', + 'system.country.message.deleteSuccess': 'Deleted', + 'system.country.message.deleteFailed': 'Delete failed', + 'system.country.message.loadFailed': 'Load failed', + 'system.country.message.exportEmpty': 'Nothing to export', + 'system.country.message.exportSuccess': 'Exported {count} rows', + 'system.country.message.exportFailed': 'Export failed', + 'system.country.import.modalTitle': 'Import Countries', + 'system.country.import.tip': 'Download the template first. Country code, Chinese name, and English name are required. Code is ISO alpha-2, e.g. CN, US.', + 'system.country.import.modeLabel': 'Import mode', + 'system.country.import.mode.incremental': 'Incremental', + 'system.country.import.mode.full': 'Full', + 'system.country.import.mode.incrementalTip': 'Insert or update by country code. Countries missing from the file are kept.', + 'system.country.import.mode.fullTip': 'Insert or update by country code, then soft-delete countries missing from the file. Export a backup first.', + 'system.country.import.mode.fullConfirm': 'Full import will soft-delete countries that are not in the Excel file. Continue?', + 'system.country.import.warningTitle': 'Confirm full import', + 'system.country.import.downloadTemplate': 'Download template', + 'system.country.import.submit': 'Import', + 'system.country.import.cancel': 'Cancel', + 'system.country.import.downloadSuccess': 'Template downloaded', + 'system.country.import.downloadFailed': 'Template download failed', + 'system.country.import.needFile': 'Please choose an Excel file', + 'system.country.import.success': 'Imported', + 'system.country.import.failed': 'Import failed', + 'system.country.import.partialSuccess': 'Some rows failed. See the details below.', + 'system.country.import.allFailed': 'Import failed. Check the file contents.', + 'system.country.import.resultSummary': 'Success {success}, skipped {skip}, failed {fail}, deleted {deleted}', + 'system.country.import.failRow': 'Row {row}: {reason}', + // System - batch email (parity with desktop emailAuto.py) 'system.batchEmail.import': 'Import file', 'system.batchEmail.template': 'Template', diff --git a/src/locale/zh-CN.ts b/src/locale/zh-CN.ts index dcd5440..5478774 100644 --- a/src/locale/zh-CN.ts +++ b/src/locale/zh-CN.ts @@ -647,6 +647,38 @@ export default { 'order.customs9610.table.createTime': '创建时间', 'order.customs9610.actions.declare': '申报', 'order.customs9610.actions.batchDeclare': '批量申报', + 'order.customs9610.actions.createFromOrders': '创建9610订单', + 'order.customs9610.create.title': '创建9610订单', + 'order.customs9610.create.tip': '选择运单并补充出口资质备案、电商平台后,系统会把运单设为 9610 汇总申报并生成待申报单。已删除、已有 9610 单或没有申报明细的运单会创建失败,不影响其它运单。', + 'order.customs9610.create.sectionSupplement': '补充申报信息', + 'order.customs9610.create.sectionOrders': '选择运单', + 'order.customs9610.create.customer': '客户', + 'order.customs9610.create.customerPh': '请选择客户', + 'order.customs9610.create.exportQualification': '出口资质备案', + 'order.customs9610.create.exportQualificationPh': '不选则使用客户默认备案', + 'order.customs9610.create.manageQualification': '管理备案', + 'order.customs9610.create.defaultSuffix': '(默认)', + 'order.customs9610.create.platform': '电商平台', + 'order.customs9610.create.platformPh': '请选择电商平台', + 'order.customs9610.create.keywordPh': '运单号 / 客户单号 / 跟踪号', + 'order.customs9610.create.selected': '已选 {count} 单', + 'order.customs9610.create.clearSelected': '清空', + 'order.customs9610.create.col.serviceNo': '运单号', + 'order.customs9610.create.col.clientNo': '客户单号', + 'order.customs9610.create.col.customer': '客户', + 'order.customs9610.create.col.country': '目的国', + 'order.customs9610.create.col.status': '订单状态', + 'order.customs9610.create.col.createTime': '创建时间', + 'order.customs9610.create.col.remark': '说明', + 'order.customs9610.create.already9610': '已是9610', + 'order.customs9610.create.selectCustomerFirst': '请先选择客户', + 'order.customs9610.create.emptyOrders': '暂无运单', + 'order.customs9610.create.submit': '创建 9610 订单({count})', + 'order.customs9610.create.failTitle': '{count} 单创建失败', + 'order.customs9610.create.message.loadOrdersFailed': '加载运单失败', + 'order.customs9610.create.message.allSuccess': '已创建 {count} 个 9610 订单', + 'order.customs9610.create.message.partial': '成功 {success} 单,失败 {fail} 单,请查看失败原因', + 'order.customs9610.create.message.failed': '创建失败', 'order.customs9610.actions.batchFetchManifest': '批量获取清单数据', 'order.customs9610.actions.batchFetchDeclarationForm': '预报下游', 'order.customs9610.modal.edit': '编辑 9610 申报', @@ -958,6 +990,9 @@ export default { 'order.platformStoreOrder.table.customsOrderNo': '平台订单号', 'order.platformStoreOrder.table.store': '店铺', 'order.platformStoreOrder.table.status': '状态', + 'order.platformStoreOrder.table.platformStatus': '平台状态', + 'order.platformStoreOrder.table.fulfillmentType': '履约类型', + 'order.platformStoreOrder.table.shippingProvider': '物流商', 'order.platformStoreOrder.table.amount': '金额', 'order.platformStoreOrder.table.orderTime': '下单时间', 'order.platformStoreOrder.table.linkedServiceNo': '关联服务单号', @@ -971,8 +1006,8 @@ export default { 'order.platformStoreOrder.drawer.sectionShipping': '履约与物流', 'order.platformStoreOrder.drawer.sectionLines': '商品明细', 'order.platformStoreOrder.drawer.platformStatus': '平台状态', - 'order.platformStoreOrder.drawer.customTags': '自定义标签', - 'order.platformStoreOrder.drawer.multiLabel': '多面单', + 'order.platformStoreOrder.drawer.shippingProvider': '物流商', + 'order.platformStoreOrder.drawer.fulfillmentType': '履约类型', 'order.platformStoreOrder.drawer.openLabel': '打开面单', 'order.platformStoreOrder.recipient.name': '收件人', 'order.platformStoreOrder.recipient.company': '公司', @@ -993,7 +1028,6 @@ export default { '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': '搁置', @@ -1004,6 +1038,7 @@ export default { 'order.platformStoreOrder.actions.syncStatus': '同步状态', 'order.platformStoreOrder.actions.readyToShip': '准备发货', 'order.platformStoreOrder.actions.fetchLabel': '重新获取面单', + 'order.platformStoreOrder.actions.fetchLabelProgress': '获取中 {done}/{total}', 'order.platformStoreOrder.actions.createLogistics': '创建运单', 'order.platformStoreOrder.actions.createFulfill': '创建代发&运单', 'order.platformStoreOrder.actions.createFulfillWms': '创建代发订单', @@ -1095,6 +1130,8 @@ export default { 'order.platformStoreOrder.message.matchFailed': '库存匹配失败', 'order.platformStoreOrder.message.warehouseRequired': '请选择发货仓库', 'order.platformStoreOrder.message.suspendSuccess': '状态已更新', + 'order.platformStoreOrder.message.suspendConfirm': '确认搁置选中的 {count} 单?', + 'order.platformStoreOrder.message.resumeConfirm': '确认恢复选中的 {count} 单?', 'order.platformStoreOrder.message.deleteSuccess': '删除成功', 'order.platformStoreOrder.message.batchEmpty': '当前页没有可匹配的待处理订单', 'order.platformStoreOrder.message.batchConfirm': '将匹配当前页 {count} 个待处理订单并创建小包单,是否继续?', @@ -1107,12 +1144,15 @@ export default { 'order.platformStoreOrder.message.syncConfirm': '是否手动同步 {count} 个订单的状态?', 'order.platformStoreOrder.message.rtsConfirm': '是否对 {count} 个订单执行准备发货(RTS)?', 'order.platformStoreOrder.message.labelConfirm': '是否重新获取该订单面单?', + 'order.platformStoreOrder.message.labelConfirmBatch': '确认重新获取选中的 {count} 单面单?', + 'order.platformStoreOrder.message.labelResult': '面单获取完成:成功 {success} 单,失败 {fail} 单', 'order.platformStoreOrder.message.syncResult': '同步完成:成功 {success},失败 {fail}', 'order.platformStoreOrder.message.rtsResult': 'RTS 完成:成功 {success},失败 {fail}', 'order.platformStoreOrder.message.pullResult': '拉单完成:成功 {success} 店,失败 {fail} 店', 'order.platformStoreOrder.message.pullFailed': '拉单失败', 'order.platformStoreOrder.message.labelSuccess': '面单已更新', 'order.platformStoreOrder.message.labelFailed': '获取面单失败', + 'order.platformStoreOrder.message.printNoLabel': '暂无可打印面单', 'order.platformStoreOrder.message.temuNoRts': 'Temu 不支持 RTS', 'order.platformStoreOrder.message.actionFailed': '操作失败', @@ -1301,6 +1341,7 @@ export default { 'menu.system.interface.config': '接口配置', 'menu.system.message': '我的信息', 'menu.system.warehouseAddress': '仓库地址', + 'menu.system.country': '国家地区', 'menu.system.batchEmail': '批量发送邮件', 'menu.system.aiAssistant': 'AI 助手', 'menu.system.aiSkills': 'AI Skill', @@ -1617,6 +1658,40 @@ export default { 'order.myOrders.modal.payFailed': '支付失败', 'order.myOrders.modal.payLoadFailed': '加载费用明细失败', 'order.myOrders.modal.payInvalidAmount': '应收金额无效,无法支付', + 'order.myOrders.modal.payPendingOnly': '仅待支付状态的订单可支付,请重新选择', + 'order.myOrders.modal.payCoupon': '优惠券', + 'order.myOrders.modal.payCouponNone': '不使用优惠券', + 'order.myOrders.modal.payCouponEmpty': '暂无可用券', + 'order.myOrders.modal.payCouponUsedByOther': '已用于其他订单', + 'order.myOrders.modal.payCouponNotApplied': '该券优惠不高于会员折扣,已按会员折扣计算', + 'order.myOrders.modal.paySubtotal': '费用合计', + 'order.myOrders.modal.payLevelDiscount': '会员折扣', + 'order.myOrders.modal.payCouponDiscount': '优惠券抵扣', + 'order.myOrders.modal.payPayable': '实付', + 'order.myOrders.modal.payTitleBatch': '批量支付确认({count} 单)', + 'order.myOrders.modal.paySummaryPayable': '实付合计', + 'order.myOrders.modal.paySummarySaved': '已优惠 {amount}', + 'order.myOrders.modal.paySummaryOrders': '本次支付', + 'order.myOrders.modal.payOrdersUnit': '{count} 单', + 'order.myOrders.modal.payBalance': '账户余额', + 'order.myOrders.modal.payBalanceShort': '账户余额不足,还差 {amount},请先充值或减少本次支付的订单', + 'order.myOrders.modal.payBalanceShortCustomer': '{name} 余额不足,还差 {amount}', + 'order.myOrders.modal.paySkipHint': '{count} 单暂不可支付,确认支付时将自动跳过', + 'order.myOrders.modal.payOrderList': '订单明细', + 'order.myOrders.modal.payExpandAll': '全部展开', + 'order.myOrders.modal.payCollapseAll': '全部收起', + 'order.myOrders.modal.payCouponUsableCount': '{count} 张可用', + 'order.myOrders.modal.payRemove': '移出本次支付', + 'order.myOrders.modal.payStatusPaying': '支付中', + 'order.myOrders.modal.payStatusSuccess': '已支付', + 'order.myOrders.modal.payStatusFailed': '支付失败', + 'order.myOrders.modal.payStatusSkipped': '将跳过', + 'order.myOrders.modal.payConfirmAmount': '确认支付 {amount}', + 'order.myOrders.modal.payRetryFailed': '重新支付 {amount}', + 'order.myOrders.modal.payProgress': '支付中 {done}/{total}', + 'order.myOrders.modal.payBatchSuccess': '已成功支付 {count} 单', + 'order.myOrders.modal.payPartialResult': '成功 {success} 单,失败 {failed} 单,请查看失败原因后重试', + 'order.myOrders.modal.payMethodBalance': '支付方式:账户余额', 'order.myOrders.modal.payEmpty': '暂无可支付订单', 'order.myOrders.actions.intercept': '拦截', 'order.myOrders.actions.cancelIntercept': '取消拦截', @@ -2235,6 +2310,9 @@ export default { 'order.myOrders.drawer.declarationImHsCode': '进口HsCode', 'order.myOrders.drawer.declarationExHsCode': '出口HsCode', 'order.myOrders.drawer.declarationDeclareElement': '出口申报要素', + 'order.myOrders.drawer.declarationProductId': '产品标识编码', + 'order.myOrders.drawer.declarationNonStdProductId': '非标产品标识编码', + 'order.myOrders.drawer.declarationStdProductId': '标准产品标识编码', 'order.myOrders.drawer.declarationSkuImage': '图片', 'order.myOrders.drawer.declarationSkuImageView': '查看图片', 'order.myOrders.drawer.declarationCategory': '类别', @@ -2323,6 +2401,7 @@ export default { 'order.myOrders.drawer.fee.payableTitle': '应付费用明细', 'order.myOrders.drawer.fee.viewAll': '全部', 'order.myOrders.drawer.fee.itemCount': '{count} 项', + 'order.myOrders.drawer.fee.includesDiscount': '含优惠 {amount}', 'order.myOrders.drawer.fee.emptyReceivable': '暂无应收费用', 'order.myOrders.drawer.fee.emptyPayable': '暂无应付费用', 'order.myOrders.drawer.fee.emptyAll': '暂无费用数据', @@ -4278,6 +4357,57 @@ export default { 'system.warehouseAddress.message.dictLoadFailed': '仓库类型字典加载失败', 'system.warehouseAddress.message.wechatQrSaveFailed': '客服微信二维码保存失败', + 'system.country.searchPlaceholder': '二字码 / 中文名 / 英文名', + 'system.country.fields.countryCode': '国家二字码', + 'system.country.fields.chineseName': '中文名', + 'system.country.fields.englishName': '英文名', + 'system.country.fields.createTime': '创建时间', + 'system.country.fields.updateTime': '更新时间', + 'system.country.table.index': '序号', + 'system.country.placeholder.input': '请输入', + 'system.country.placeholder.countryCode': '如 CN', + 'system.country.modal.addTitle': '新增国家地区', + 'system.country.modal.editTitle': '编辑国家地区', + 'system.country.actions.edit': '编辑', + 'system.country.actions.delete': '删除', + 'system.country.actions.import': '导入', + 'system.country.actions.export': '导出', + 'system.country.rules.countryCodeRequired': '请输入国家二字码', + 'system.country.rules.countryCodeFormat': '须为 2 位字母,如 CN、US', + 'system.country.rules.chineseNameRequired': '请输入中文名', + 'system.country.rules.englishNameRequired': '请输入英文名', + 'system.country.message.createSuccess': '新增成功', + 'system.country.message.updateSuccess': '保存成功', + 'system.country.message.saveFailed': '保存失败', + 'system.country.message.deleteConfirm': '确定删除「{name}」吗?', + 'system.country.message.deleteSuccess': '删除成功', + 'system.country.message.deleteFailed': '删除失败', + 'system.country.message.loadFailed': '加载失败', + 'system.country.message.exportEmpty': '没有可导出的数据', + 'system.country.message.exportSuccess': '已导出 {count} 条', + 'system.country.message.exportFailed': '导出失败', + 'system.country.import.modalTitle': '导入国家地区', + 'system.country.import.tip': '请先下载模板。国家二字码、中文名、英文名均必填。二字码为 ISO 两位字母,如 CN、US。', + 'system.country.import.modeLabel': '导入方式', + 'system.country.import.mode.incremental': '增量导入', + 'system.country.import.mode.full': '全量导入', + 'system.country.import.mode.incrementalTip': '按二字码新增或更新已有国家,不会删除文件中没有的国家。', + 'system.country.import.mode.fullTip': '按二字码新增或更新,并软删除文件中未出现的国家(请先导出备份)。', + 'system.country.import.mode.fullConfirm': '全量导入将软删除 Excel 中未出现的国家,确认继续?', + 'system.country.import.warningTitle': '确认全量导入', + 'system.country.import.downloadTemplate': '下载模板', + 'system.country.import.submit': '开始导入', + 'system.country.import.cancel': '取消', + 'system.country.import.downloadSuccess': '模板下载成功', + 'system.country.import.downloadFailed': '模板下载失败', + 'system.country.import.needFile': '请选择要上传的 Excel 文件', + 'system.country.import.success': '导入成功', + 'system.country.import.failed': '导入失败', + 'system.country.import.partialSuccess': '部分行导入失败,请查看失败明细', + 'system.country.import.allFailed': '导入失败,请检查文件内容', + 'system.country.import.resultSummary': '成功 {success} 条,跳过重复 {skip} 条,失败 {fail} 条,删除 {deleted} 条', + 'system.country.import.failRow': '第 {row} 行:{reason}', + // 系统 - 批量邮件(对齐桌面 emailAuto.py) 'system.batchEmail.import': '导入文件', 'system.batchEmail.template': '发送模板', diff --git a/src/router/routes/newModules/system-management.ts b/src/router/routes/newModules/system-management.ts index cc3769c..f6eb73e 100644 --- a/src/router/routes/newModules/system-management.ts +++ b/src/router/routes/newModules/system-management.ts @@ -79,6 +79,16 @@ const SYSTEM_MANAGEMENT: AppRouteRecordRaw = { requiresAuth: true, }, }, + { + path: 'country', + name: 'SystemCountry', + component: () => import('@/views/system/country/index.vue'), + meta: { + locale: 'menu.system.country', + requiresAuth: true, + menuPermission: 'System.Country.Query', + }, + }, { path: 'otherConfig', name: 'SystemOtherConfig', diff --git a/src/utils/menu-i18n.ts b/src/utils/menu-i18n.ts index 86025df..3c84a99 100644 --- a/src/utils/menu-i18n.ts +++ b/src/utils/menu-i18n.ts @@ -92,6 +92,7 @@ export const MENU_NAME_TO_LOCALE_KEY: Record = { 系统接口: 'menu.system.interface', 接口配置: 'menu.system.interface.config', 仓库地址: 'menu.system.warehouseAddress', + 国家地区: 'menu.system.country', 批量发送邮件: 'menu.system.batchEmail', 'AI 助手': 'menu.system.aiAssistant', 'AI Skill': 'menu.system.aiSkills', diff --git a/src/utils/menu-icon.ts b/src/utils/menu-icon.ts index d3f6293..9c5d3f5 100644 --- a/src/utils/menu-icon.ts +++ b/src/utils/menu-icon.ts @@ -77,6 +77,7 @@ export const SUB_MENU_DEFAULT_ICONS: Record = { 'menu.system.module': 'IconApps', 'menu.system.menu': 'IconMenu', 'menu.system.warehouseAddress': 'IconHome', + 'menu.system.country': 'IconPublic', 'menu.system.otherConfig': 'IconSettings', 'menu.system.aiAssistant': 'IconRobot', 'menu.system.aiSkills': 'IconBook', diff --git a/src/utils/orderCreateOneBody.ts b/src/utils/orderCreateOneBody.ts index aabc782..e1ff3e9 100644 --- a/src/utils/orderCreateOneBody.ts +++ b/src/utils/orderCreateOneBody.ts @@ -35,6 +35,9 @@ function mapItem(raw: Record) { imHsCode: pick(raw, 'imHsCode', 'ImHsCode') ?? hs, exHsCode: pick(raw, 'exHsCode', 'ExHsCode') ?? hs, declareElement: pick(raw, 'declareElement', 'DeclareElement'), + productId: pick(raw, 'productId', 'ProductId'), + nonStdProductId: pick(raw, 'nonStdProductId', 'NonStdProductId'), + stdProductId: pick(raw, 'stdProductId', 'StdProductId'), skuImage: pick(raw, 'skuImage', 'SkuImage'), category: pick(raw, 'category', 'Category'), brand: pick(raw, 'brand', 'Brand'), diff --git a/src/utils/runWithConcurrency.ts b/src/utils/runWithConcurrency.ts new file mode 100644 index 0000000..9c9b88e --- /dev/null +++ b/src/utils/runWithConcurrency.ts @@ -0,0 +1,16 @@ +/** 限并发执行,避免批量操作一次打出几十个请求 */ +export async function runWithConcurrency( + items: T[], + limit: number, + worker: (item: T, index: number) => Promise, +) { + let cursor = 0 + const runners = Array.from({ length: Math.min(limit, items.length) }, async () => { + while (cursor < items.length) { + const index = cursor + cursor += 1 + await worker(items[index], index) + } + }) + await Promise.all(runners) +} diff --git a/src/utils/syncMenuLevel3Buttons.ts b/src/utils/syncMenuLevel3Buttons.ts index 0c4ae84..4d83e1a 100644 --- a/src/utils/syncMenuLevel3Buttons.ts +++ b/src/utils/syncMenuLevel3Buttons.ts @@ -14,6 +14,14 @@ import { type RoutePageMenuDef, } from '@/utils/syncMenuRouteCatalog'; +function readSyncError(error: unknown): string { + if (error && typeof error === 'object' && 'body' in error) { + const body = (error as { body?: { message?: string } }).body; + if (body?.message) return body.message; + } + return error instanceof Error ? error.message : String(error); +} + export interface SyncLevel3ButtonsResult { created: number; updated: number; @@ -450,7 +458,16 @@ export async function syncMenuTree(menu: MenuTreeDto): Promise mapDictToSelectOptions('fee_item')); + const costItemOptions = computed(() => + mapDictToSelectOptions('fee_item').filter((o) => !isDiscountCostItem(o.value)), + ); /** 同一产品下已占用的费用项(编辑当前条时排除自身) */ function isCostItemAlreadyUsed(costItem: string) { diff --git a/src/views/channel/serviceChannels/index.vue b/src/views/channel/serviceChannels/index.vue index 1d78459..14fe264 100644 --- a/src/views/channel/serviceChannels/index.vue +++ b/src/views/channel/serviceChannels/index.vue @@ -1411,6 +1411,7 @@ import { Message, Modal, Input } from '@arco-design/web-vue' import ModalPro from '@/components/ModalPro/index.vue' import useModal from '@/components/ModalPro/useModal' import PublishDiffPanel from '@/components/PublishDiff/index.vue' +import { isDiscountCostItem } from '@/constants/feeCostItems' import PriceListHotTable, { type PriceListHotColumn, } from '@/views/channel/salesProducts/components/PriceListHotTable.vue' @@ -4414,7 +4415,9 @@ const factorOptions = computed(() => label: pricingFactorColumnLabel(opt.value), })) ) -const costItemOptions = computed(() => mapDictToSelectOptions('fee_item')) +const costItemOptions = computed(() => + mapDictToSelectOptions('fee_item').filter((o) => !isDiscountCostItem(o.value)), +) /** 同一渠道下已占用的费用项(编辑当前条时排除自身) */ function isCostItemAlreadyUsed(costItem: string) { diff --git a/src/views/h5/admin-center/locale/en-US.ts b/src/views/h5/admin-center/locale/en-US.ts index 3fdff75..a21ed96 100644 --- a/src/views/h5/admin-center/locale/en-US.ts +++ b/src/views/h5/admin-center/locale/en-US.ts @@ -16,11 +16,6 @@ export default { 'h5Admin.login.failed': 'Sign-in failed. Check account or password.', 'h5Admin.login.accountRequired': 'Enter account', 'h5Admin.login.passwordRequired': 'Enter password', - 'h5Admin.login.needAgree': 'Please agree to the privacy policy and terms', - 'h5Admin.login.agreePrefix': 'By signing in you agree to the', - 'h5Admin.login.privacy': 'Privacy Policy', - 'h5Admin.login.terms': 'Terms of Service', - 'h5Admin.login.and': 'and', 'h5.login.switchToAdmin': 'Switch to staff login', 'h5.login.switchToCustomer': 'Switch to customer login', @@ -105,9 +100,26 @@ export default { 'h5Admin.monitor.refresh': 'Refresh', 'h5Admin.monitor.tab.order': 'Orders', 'h5Admin.monitor.tab.customer': 'Customers', + 'h5Admin.monitor.dim.day': 'Day', + 'h5Admin.monitor.dim.week': 'Week', + 'h5Admin.monitor.dim.month': 'Month', + 'h5Admin.monitor.ops.title': 'Ops overview', + 'h5Admin.monitor.customer.title': 'Customers', + 'h5Admin.monitor.track.title': 'Track KPIs', + 'h5Admin.monitor.ops.orderCount.day': 'Orders today', + 'h5Admin.monitor.ops.orderCount.week': 'Orders this week', + 'h5Admin.monitor.ops.orderCount.month': 'Orders this month', + 'h5Admin.monitor.ops.revenue.day': 'Revenue today', + 'h5Admin.monitor.ops.revenue.week': 'Revenue this week', + 'h5Admin.monitor.ops.revenue.month': 'Revenue this month', + 'h5Admin.monitor.ops.grossProfit.day': 'Gross profit today', + 'h5Admin.monitor.ops.grossProfit.week': 'Gross profit this week', + 'h5Admin.monitor.ops.grossProfit.month': 'Gross profit this month', + 'h5Admin.monitor.queue.title': 'Live backlog', 'h5Admin.monitor.kpi.todayCreated': 'Created today', 'h5Admin.monitor.kpi.pendingPayment': 'Unpaid', 'h5Admin.monitor.kpi.pendingOnline': 'Pending online', + 'h5Admin.monitor.kpi.pendingSign': 'Pending delivery', 'h5Admin.monitor.kpi.problematic': 'Issues', 'h5Admin.monitor.kpi.todayRevenue': 'Revenue today', 'h5Admin.monitor.kpi.total': 'Total shipments', @@ -116,10 +128,19 @@ export default { 'h5Admin.monitor.kpi.online': 'Online', 'h5Admin.monitor.kpi.inactive7d': 'Inactive 7d', 'h5Admin.monitor.chart.orders7d': 'Orders · last 7 days', + 'h5Admin.monitor.chart.ordersDay': 'Orders · last 7 days', + 'h5Admin.monitor.chart.ordersWeek': 'Orders · last 8 weeks', + 'h5Admin.monitor.chart.ordersMonth': 'Orders · last 6 months', + 'h5Admin.monitor.chart.revenueGrossDay': 'Revenue & gross · last 7 days', + 'h5Admin.monitor.chart.revenueGrossWeek': 'Revenue & gross · last 8 weeks', + 'h5Admin.monitor.chart.revenueGrossMonth': 'Revenue & gross · last 6 months', + 'h5Admin.monitor.series.revenue': 'Revenue', + 'h5Admin.monitor.series.grossProfit': 'Gross profit', 'h5Admin.monitor.track.complete': 'Track complete', 'h5Admin.monitor.track.timely': 'Track on-time', 'h5Admin.monitor.track.otd': 'OTD rate', 'h5Admin.monitor.loadFailed': 'Failed to load monitor data', 'h5Admin.monitor.vsYesterday': 'vs yesterday', + 'h5Admin.monitor.vsLastWeek': 'vs last week', 'h5Admin.monitor.vsLastMonth': 'vs last month', }; diff --git a/src/views/h5/admin-center/locale/zh-CN.ts b/src/views/h5/admin-center/locale/zh-CN.ts index 3a68ff9..50580a3 100644 --- a/src/views/h5/admin-center/locale/zh-CN.ts +++ b/src/views/h5/admin-center/locale/zh-CN.ts @@ -16,11 +16,6 @@ export default { 'h5Admin.login.failed': '登录失败,请检查账号或密码', 'h5Admin.login.accountRequired': '请输入账号', 'h5Admin.login.passwordRequired': '请输入密码', - 'h5Admin.login.needAgree': '请先同意隐私政策与服务条款', - 'h5Admin.login.agreePrefix': '登录即表示同意', - 'h5Admin.login.privacy': '隐私政策', - 'h5Admin.login.terms': '服务条款', - 'h5Admin.login.and': '与', 'h5.login.switchToAdmin': '切换至管理登录', 'h5.login.switchToCustomer': '切换至客户登录', @@ -105,9 +100,26 @@ export default { 'h5Admin.monitor.refresh': '刷新', 'h5Admin.monitor.tab.order': '订单', 'h5Admin.monitor.tab.customer': '客户', + 'h5Admin.monitor.dim.day': '日', + 'h5Admin.monitor.dim.week': '周', + 'h5Admin.monitor.dim.month': '月', + 'h5Admin.monitor.ops.title': '经营概况', + 'h5Admin.monitor.customer.title': '客户概况', + 'h5Admin.monitor.track.title': '轨迹达成', + 'h5Admin.monitor.ops.orderCount.day': '今日订单量', + 'h5Admin.monitor.ops.orderCount.week': '本周订单量', + 'h5Admin.monitor.ops.orderCount.month': '本月订单量', + 'h5Admin.monitor.ops.revenue.day': '今日营收', + 'h5Admin.monitor.ops.revenue.week': '本周营收', + 'h5Admin.monitor.ops.revenue.month': '本月营收', + 'h5Admin.monitor.ops.grossProfit.day': '今日毛利', + 'h5Admin.monitor.ops.grossProfit.week': '本周毛利', + 'h5Admin.monitor.ops.grossProfit.month': '本月毛利', + 'h5Admin.monitor.queue.title': '实时积压', 'h5Admin.monitor.kpi.todayCreated': '今日创建', 'h5Admin.monitor.kpi.pendingPayment': '待支付', 'h5Admin.monitor.kpi.pendingOnline': '待上网', + 'h5Admin.monitor.kpi.pendingSign': '待签收', 'h5Admin.monitor.kpi.problematic': '问题件', 'h5Admin.monitor.kpi.todayRevenue': '今日营收', 'h5Admin.monitor.kpi.total': '运单总量', @@ -116,10 +128,19 @@ export default { 'h5Admin.monitor.kpi.online': '在线客户', 'h5Admin.monitor.kpi.inactive7d': '7日未登录', 'h5Admin.monitor.chart.orders7d': '近7日订单量', + 'h5Admin.monitor.chart.ordersDay': '近7日订单量', + 'h5Admin.monitor.chart.ordersWeek': '近8周订单量', + 'h5Admin.monitor.chart.ordersMonth': '近6月订单量', + 'h5Admin.monitor.chart.revenueGrossDay': '近7日营收 & 毛利', + 'h5Admin.monitor.chart.revenueGrossWeek': '近8周营收 & 毛利', + 'h5Admin.monitor.chart.revenueGrossMonth': '近6月营收 & 毛利', + 'h5Admin.monitor.series.revenue': '营收', + 'h5Admin.monitor.series.grossProfit': '毛利', 'h5Admin.monitor.track.complete': '轨迹完整率', 'h5Admin.monitor.track.timely': '轨迹及时率', 'h5Admin.monitor.track.otd': '准时签收率', 'h5Admin.monitor.loadFailed': '监控数据加载失败', 'h5Admin.monitor.vsYesterday': '较昨日', + 'h5Admin.monitor.vsLastWeek': '较上周', 'h5Admin.monitor.vsLastMonth': '较上月', }; diff --git a/src/views/h5/admin-center/login/index.vue b/src/views/h5/admin-center/login/index.vue index 5b28b52..dc866d2 100644 --- a/src/views/h5/admin-center/login/index.vue +++ b/src/views/h5/admin-center/login/index.vue @@ -46,34 +46,10 @@ > {{ loading ? t('h5Admin.login.submitting') : t('h5Admin.login.submit') }} - - - - - - @@ -101,33 +77,8 @@ const router = useRouter(); const rbacUserStore = useRbacUserStore(); const form = reactive({ account: '', password: '' }); -const agreementChecked = ref(false); const loading = ref(false); const errorBox = ref(''); -const agreementVisible = ref(false); -const agreementTitle = ref(''); -const agreementContent = ref(''); - -const privacyText = `一、隐私保护告知 -员工账号仅用于内部运营处理。系统将按职责范围记录登录与操作日志,用于安全审计与业务追溯,不会向无关第三方出售。 -二、您的义务 -请妥善保管账号与密码,勿共享账号;发现异常请立即联系管理员。`; - -const termsText = `一、使用须知 -仅授权员工可登录运营管理端。禁止将客户数据用于职责以外用途。 -二、服务说明 -展示的待办与监控数据以实时接口为准;处理运单请遵循公司 SOP。`; - -function openAgreement(type: 'privacy' | 'terms') { - if (type === 'privacy') { - agreementTitle.value = t('h5Admin.login.privacy'); - agreementContent.value = privacyText; - } else { - agreementTitle.value = t('h5Admin.login.terms'); - agreementContent.value = termsText; - } - agreementVisible.value = true; -} function resolveRedirectPath() { const redirect = route.query.redirect; @@ -158,10 +109,6 @@ async function handleSubmit() { if (loading.value) return; errorBox.value = ''; - if (!agreementChecked.value) { - Message.warning(t('h5Admin.login.needAgree')); - return; - } if (!form.account.trim()) { errorBox.value = t('h5Admin.login.accountRequired'); return; @@ -330,33 +277,9 @@ onMounted(() => { .h5-login-submit { margin-top: 4px; - height: 48px; - border-radius: 12px; + height: 52px !important; + border-radius: 12px !important; + font-size: 16px !important; font-weight: 600; } - -.h5-login-agree { - margin-top: 14px; - font-size: 12px; - color: #86909c; - align-items: flex-start; - - :deep(.arco-checkbox-label) { - color: #86909c; - line-height: 1.45; - } -} - -.h5-login-link { - color: var(--app-primary-color); - cursor: pointer; -} - -.h5-login-agreement { - max-width: min(360px, 86vw); - white-space: pre-wrap; - font-size: 13px; - line-height: 1.65; - color: #4e5969; -} diff --git a/src/views/h5/admin-center/monitor/index.vue b/src/views/h5/admin-center/monitor/index.vue index 1fcdae3..d69d346 100644 --- a/src/views/h5/admin-center/monitor/index.vue +++ b/src/views/h5/admin-center/monitor/index.vue @@ -32,61 +32,154 @@ @@ -102,12 +195,22 @@ import { formatMonitorCurrency, formatMonitorNumber, getMonitorOverview, + type MonitorDimension, type MonitorOverview, } from '@/api/monitor'; +import iconOrderNew from '@/assets/icons/workplace/order-new.svg?url'; +import iconPayment from '@/assets/icons/workplace/order-payment.svg?url'; +import iconGrowth from '@/assets/icons/workplace/order-growth.svg?url'; +import iconPendingOnline from '@/assets/icons/workplace/order-pending-online.svg?url'; +import iconProblematic from '@/assets/icons/workplace/order-problematic.svg?url'; +import iconShipped from '@/assets/icons/workplace/order-shipped.svg?url'; +import iconCustomerTotal from '@/assets/icons/workplace/customer-total.svg?url'; +import iconCustomerNew from '@/assets/icons/workplace/customer-new.svg?url'; +import iconCustomerOnline from '@/assets/icons/workplace/customer-online.svg?url'; +import iconCustomerInactive from '@/assets/icons/workplace/customer-inactive.svg?url'; function formatPercent(value: number) { const n = Number(value) || 0; - // 接口多为 0–100;兼容 0–1 小数 const pct = n > 0 && n <= 1 ? n * 100 : n; return `${pct.toFixed(1)}%`; } @@ -118,73 +221,106 @@ const { t } = useI18n(); const router = useRouter(); const tab = ref<'order' | 'customer'>('order'); +const opsDimension = ref('day'); +const dimensions: MonitorDimension[] = ['day', 'week', 'month']; const loading = ref(false); const loadError = ref(false); const overview = ref(null); -function growthMeta(value: number | null | undefined, compareKey: string) { +function growthMeta(value: number | null | undefined) { if (value == null || Number.isNaN(Number(value))) { - return { growthText: '', growthUp: true, compareKey }; + return { growthText: '', growthUp: true }; } const n = Number(value); return { growthText: `${n > 0 ? '+' : ''}${n.toFixed(1)}%`, growthUp: n >= 0, - compareKey, }; } -const orderCards = computed(() => { - const o = overview.value?.order; +const opsCompareKey = computed(() => { + if (opsDimension.value === 'week') return 'h5Admin.monitor.vsLastWeek'; + if (opsDimension.value === 'month') return 'h5Admin.monitor.vsLastMonth'; + return 'h5Admin.monitor.vsYesterday'; +}); + +const orderTrendTitleKey = computed(() => { + if (opsDimension.value === 'week') return 'h5Admin.monitor.chart.ordersWeek'; + if (opsDimension.value === 'month') return 'h5Admin.monitor.chart.ordersMonth'; + return 'h5Admin.monitor.chart.ordersDay'; +}); + +const revenueTrendTitleKey = computed(() => { + if (opsDimension.value === 'week') return 'h5Admin.monitor.chart.revenueGrossWeek'; + if (opsDimension.value === 'month') return 'h5Admin.monitor.chart.revenueGrossMonth'; + return 'h5Admin.monitor.chart.revenueGrossDay'; +}); + +const opsCards = computed(() => { + const ops = overview.value?.ops; + const dim = opsDimension.value; return [ { - key: 'todayCreated', - labelKey: 'h5Admin.monitor.kpi.todayCreated', - display: formatMonitorNumber(o?.todayCreated || 0), - warning: false, - path: '/h5/admin/orders', - ...growthMeta(o?.todayCreatedGrowthPercent, 'h5Admin.monitor.vsYesterday'), + key: 'orderCount', + labelKey: `h5Admin.monitor.ops.orderCount.${dim}`, + display: formatMonitorNumber(ops?.orderCount || 0), + icon: iconOrderNew, + ...growthMeta(ops?.orderCountGrowthPercent), }, + { + key: 'revenue', + labelKey: `h5Admin.monitor.ops.revenue.${dim}`, + display: formatMonitorCurrency(ops?.revenue || 0), + icon: iconPayment, + ...growthMeta(ops?.revenueGrowthPercent), + }, + { + key: 'grossProfit', + labelKey: `h5Admin.monitor.ops.grossProfit.${dim}`, + display: formatMonitorCurrency(ops?.grossProfit || 0), + icon: iconGrowth, + ...growthMeta(ops?.grossProfitGrowthPercent), + }, + ]; +}); + +const queueCards = computed(() => { + const queue = overview.value?.queue ?? overview.value?.order; + return [ { key: 'pendingPayment', labelKey: 'h5Admin.monitor.kpi.pendingPayment', - display: formatMonitorNumber(o?.pendingPayment || 0), - warning: (o?.pendingPayment || 0) > 0, + display: formatMonitorNumber(queue?.pendingPayment || 0), + warning: (queue?.pendingPayment || 0) > 0, + icon: iconPayment, path: '/h5/admin/orders', query: { status: 'pendingPayment' }, - ...growthMeta(null, 'h5Admin.monitor.vsYesterday'), }, { key: 'pendingOnline', labelKey: 'h5Admin.monitor.kpi.pendingOnline', - display: formatMonitorNumber(o?.pendingOnline || 0), - warning: (o?.pendingOnline || 0) > 0, + display: formatMonitorNumber(queue?.pendingOnline || 0), + warning: (queue?.pendingOnline || 0) > 0, + icon: iconPendingOnline, path: '/h5/admin/orders', - ...growthMeta(null, 'h5Admin.monitor.vsYesterday'), + }, + { + key: 'pendingSign', + labelKey: 'h5Admin.monitor.kpi.pendingSign', + display: formatMonitorNumber(queue?.pendingSign || 0), + warning: false, + icon: iconShipped, + path: '/h5/admin/orders', + query: { status: 'shipping' }, }, { key: 'problematic', labelKey: 'h5Admin.monitor.kpi.problematic', - display: formatMonitorNumber(o?.problematic || 0), - warning: (o?.problematic || 0) > 0, + display: formatMonitorNumber(queue?.problematic || 0), + warning: (queue?.problematic || 0) > 0, + icon: iconProblematic, path: '/h5/admin/orders', query: { status: 'problematic' }, - ...growthMeta(null, 'h5Admin.monitor.vsYesterday'), - }, - { - key: 'todayRevenue', - labelKey: 'h5Admin.monitor.kpi.todayRevenue', - display: formatMonitorCurrency(o?.todayRevenue || 0), - warning: false, - ...growthMeta(o?.todayRevenueGrowthPercent, 'h5Admin.monitor.vsYesterday'), - }, - { - key: 'total', - labelKey: 'h5Admin.monitor.kpi.total', - display: formatMonitorNumber(o?.total || 0), - warning: false, - path: '/h5/admin/orders', - ...growthMeta(o?.totalMonthGrowthPercent, 'h5Admin.monitor.vsLastMonth'), }, ]; }); @@ -197,42 +333,68 @@ const customerCards = computed(() => { labelKey: 'h5Admin.monitor.kpi.totalCustomers', display: formatMonitorNumber(c?.totalCustomers || 0), warning: false, - ...growthMeta(c?.totalCustomersMonthGrowthPercent, 'h5Admin.monitor.vsLastMonth'), + icon: iconCustomerTotal, + compareKey: 'h5Admin.monitor.vsLastMonth', + ...growthMeta(c?.totalCustomersMonthGrowthPercent), }, { key: 'newToday', labelKey: 'h5Admin.monitor.kpi.newToday', display: formatMonitorNumber(c?.newCustomersToday || 0), warning: false, - ...growthMeta(c?.newCustomersGrowthPercent, 'h5Admin.monitor.vsYesterday'), + icon: iconCustomerNew, + compareKey: 'h5Admin.monitor.vsYesterday', + ...growthMeta(c?.newCustomersGrowthPercent), }, { key: 'online', labelKey: 'h5Admin.monitor.kpi.online', display: formatMonitorNumber(c?.onlineCustomers || 0), warning: false, - ...growthMeta(null, 'h5Admin.monitor.vsYesterday'), + icon: iconCustomerOnline, + compareKey: 'h5Admin.monitor.vsYesterday', + ...growthMeta(null), }, { key: 'inactive7d', labelKey: 'h5Admin.monitor.kpi.inactive7d', display: formatMonitorNumber(c?.inactiveLogin7d || 0), warning: (c?.inactiveLogin7d || 0) > 0, - ...growthMeta(null, 'h5Admin.monitor.vsYesterday'), + icon: iconCustomerInactive, + compareKey: 'h5Admin.monitor.vsYesterday', + ...growthMeta(null), }, ]; }); -const orderTrend = computed(() => { - const points = overview.value?.orderTrend7d || []; +const orderTrendBars = computed(() => { + const points = overview.value?.orderTrend || overview.value?.orderTrend7d || []; const max = Math.max(...points.map((p) => p.count), 1); return points.map((p) => ({ ...p, - label: String(p.label || '').slice(-5), - pct: Math.max(8, Math.round((p.count / max) * 100)), + shortLabel: String(p.label || '').slice(-5), + pct: Math.max(p.count > 0 ? 8 : 0, Math.round((p.count / max) * 100)), })); }); +const revenueTrendBars = computed(() => { + const points = overview.value?.revenueTrend || overview.value?.revenueTrend7d || []; + const max = Math.max( + ...points.map((p) => Math.max(Number(p.revenue) || 0, Number(p.grossProfit) || 0)), + 1, + ); + return points.map((p) => { + const revenue = Number(p.revenue) || 0; + const gross = Number(p.grossProfit) || 0; + return { + label: p.label, + shortLabel: String(p.label || '').slice(-5), + revenuePct: Math.max(revenue > 0 ? 8 : 0, Math.round((revenue / max) * 100)), + grossPct: Math.max(gross > 0 ? 8 : 0, Math.round((gross / max) * 100)), + }; + }); +}); + const trackRates = computed(() => { const r = overview.value?.trackRates; return [ @@ -263,7 +425,7 @@ async function load() { loading.value = true; loadError.value = false; try { - overview.value = await getMonitorOverview(); + overview.value = await getMonitorOverview(opsDimension.value); } catch { loadError.value = true; } finally { @@ -271,6 +433,12 @@ async function load() { } } +function setDimension(dim: MonitorDimension) { + if (opsDimension.value === dim) return; + opsDimension.value = dim; + void load(); +} + onMounted(() => { void load(); }); @@ -345,42 +513,137 @@ onMounted(() => { } } +.h5-mon__section { + margin-top: 16px; + padding: 14px; + border-radius: var(--h5-radius, 16px); + background: var(--h5-surface, #fff); + border: 1px solid var(--h5-border-soft, #f2f3f5); + box-shadow: var(--h5-shadow, 0 4px 16px rgba(15, 23, 42, 0.05)); +} + +.h5-mon__section-head { + margin-bottom: 12px; + + h2 { + margin: 0; + font-size: 15px; + font-weight: 700; + color: var(--h5-text, #1d2129); + } + + &--row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + } +} + +.h5-mon__dims { + display: inline-flex; + padding: 2px; + border-radius: 8px; + background: #eceff3; + flex-shrink: 0; +} + +.h5-mon__dim { + height: 26px; + padding: 0 10px; + border: none; + border-radius: 6px; + background: transparent; + color: var(--h5-text-2, #4e5969); + font-size: 12px; + cursor: pointer; + + &.is-active { + background: #fff; + color: var(--app-primary-color); + font-weight: 600; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); + } +} + .h5-mon__kpi { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; - margin-top: 14px; + + &--queue { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } .h5-mon__kpi-card { display: flex; - flex-direction: column; - align-items: flex-start; - gap: 4px; - min-height: 92px; - padding: 12px; - border-radius: 14px; - border: 1px solid var(--h5-border-soft, #f2f3f5); - background: var(--h5-surface, #fff); - box-shadow: var(--h5-shadow, 0 4px 16px rgba(15, 23, 42, 0.05)); + align-items: center; + min-height: 76px; + padding: 12px 14px; + border-radius: 12px; + border: 1px solid var(--h5-border, #e5e6eb); + background: transparent; text-align: left; - cursor: pointer; + cursor: default; + -webkit-tap-highlight-color: transparent; - &.is-warning { - background: var(--h5-warning-bg, #fff7e8); + &[type='button'] { + cursor: pointer; + + &:active { + transform: scale(0.98); + } + } + + &.is-warning .h5-mon__kpi-value { + color: var(--h5-warning, #ff7d00); } } +.h5-mon__kpi-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + min-width: 0; +} + +.h5-mon__kpi-meta { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + min-width: 0; +} + +.h5-mon__kpi-icon { + flex: 0 0 auto; + width: 32px; + height: 32px; + object-fit: contain; + pointer-events: none; +} + .h5-mon__kpi-label { + max-width: 100%; font-size: 12px; - color: var(--h5-text-3, #86909c); + line-height: 1.3; + color: var(--h5-text-2, #4e5969); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .h5-mon__kpi-value { + flex-shrink: 0; font-size: 22px; font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; color: var(--h5-text, #1d2129); - line-height: 1.15; + line-height: 1; } .h5-mon__kpi-growth { @@ -395,18 +658,29 @@ onMounted(() => { } } -.h5-mon__chart { - margin-top: 14px; - padding: 14px; - border-radius: 16px; - background: var(--h5-surface, #fff); - border: 1px solid var(--h5-border-soft, #f2f3f5); - box-shadow: var(--h5-shadow, 0 4px 16px rgba(15, 23, 42, 0.05)); +.h5-mon__legend { + display: flex; + gap: 14px; + margin: 0 0 10px; + font-size: 11px; + color: var(--h5-text-3, #86909c); - h2 { - margin: 0 0 12px; - font-size: 14px; - font-weight: 700; + span::before { + content: ''; + display: inline-block; + width: 8px; + height: 8px; + margin-right: 6px; + border-radius: 2px; + vertical-align: middle; + } + + .is-revenue::before { + background: #00b42a; + } + + .is-gross::before { + background: #ff7d00; } } @@ -416,6 +690,10 @@ onMounted(() => { justify-content: space-between; gap: 6px; min-height: 140px; + + &--pair { + align-items: flex-end; + } } .h5-mon__bar-col { @@ -427,6 +705,14 @@ onMounted(() => { min-width: 0; } +.h5-mon__bar-pair { + display: flex; + align-items: flex-end; + gap: 3px; + width: 100%; + justify-content: center; +} + .h5-mon__bar-track { width: 100%; max-width: 28px; @@ -438,6 +724,10 @@ onMounted(() => { overflow: hidden; } +.h5-mon__bar-pair .h5-mon__bar-track { + max-width: 12px; +} + .h5-mon__bar-fill { width: 100%; border-radius: 8px 8px 0 0; @@ -447,6 +737,14 @@ onMounted(() => { var(--app-primary-color) ); transition: height 0.35s ease; + + &.is-revenue { + background: #00b42a; + } + + &.is-gross { + background: #ff7d00; + } } .h5-mon__bar-val { @@ -464,7 +762,6 @@ onMounted(() => { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; - margin-top: 12px; } .h5-mon__rate { @@ -473,8 +770,8 @@ onMounted(() => { gap: 6px; padding: 12px 8px; border-radius: 12px; - background: var(--h5-surface, #fff); - border: 1px solid var(--h5-border-soft, #f2f3f5); + background: transparent; + border: 1px solid var(--h5-border, #e5e6eb); text-align: center; span { diff --git a/src/views/h5/admin-center/workplace/index.vue b/src/views/h5/admin-center/workplace/index.vue index 248eff0..e210c0b 100644 --- a/src/views/h5/admin-center/workplace/index.vue +++ b/src/views/h5/admin-center/workplace/index.vue @@ -28,9 +28,16 @@ :class="[`is-${item.tone}`, { 'is-alert': item.alert && item.value > 0 }]" @click="onOpsClick(item)" > - {{ t(item.labelKey) }} - {{ formatNum(item.value) }} - {{ t('h5Admin.workplace.unit') }} + + + + {{ t(item.labelKey) }} + + + {{ formatNum(item.value) }} + {{ t('h5Admin.workplace.unit') }} + + @@ -42,8 +49,13 @@
- {{ t(item.labelKey) }} - {{ formatNum(item.value) }} + + + + {{ t(item.labelKey) }} + + {{ formatNum(item.value) }} +
@@ -54,8 +66,13 @@
- {{ t(item.labelKey) }} - {{ formatNum(item.value) }} + + + + {{ t(item.labelKey) }} + + {{ formatNum(item.value) }} +
@@ -123,6 +140,18 @@ import { } from '@/api/workplace'; import { getRechargeRecordList } from '@/api/recharge/rechargeRecord'; import { useRbacUserStore } from '@/store/modules/rbac-user'; +import iconOrderNew from '@/assets/icons/workplace/order-new.svg?url'; +import iconPendingShip from '@/assets/icons/workplace/order-pending-ship.svg?url'; +import iconPayment from '@/assets/icons/workplace/order-payment.svg?url'; +import iconProblematic from '@/assets/icons/workplace/order-problematic.svg?url'; +import iconGrowth from '@/assets/icons/workplace/order-growth.svg?url'; +import iconBind from '@/assets/icons/workplace/order-bind.svg?url'; +import iconTransit from '@/assets/icons/workplace/order-transit.svg?url'; +import iconPick from '@/assets/icons/workplace/order-pick.svg?url'; +import iconShipped from '@/assets/icons/workplace/order-shipped.svg?url'; +import iconDispatch from '@/assets/icons/workplace/order-dispatch.svg?url'; +import iconPickup from '@/assets/icons/workplace/order-pickup.svg?url'; +import iconInbound from '@/assets/icons/workplace/order-inbound.svg?url'; defineOptions({ name: 'H5AdminWorkplace' }); @@ -171,6 +200,7 @@ type OpsItem = { status: string; tone: string; alert: boolean; + icon: string; action?: 'orders' | 'recharge'; }; @@ -184,6 +214,7 @@ const opsItems = computed(() => { status: '', tone: 'warning', alert: true, + icon: iconPayment, action: 'recharge', }, { @@ -193,6 +224,7 @@ const opsItems = computed(() => { status: 'problematic', tone: 'danger', alert: true, + icon: iconProblematic, action: 'orders', }, { @@ -202,6 +234,7 @@ const opsItems = computed(() => { status: 'claimed', tone: 'warning', alert: true, + icon: iconGrowth, action: 'orders', }, { @@ -211,6 +244,7 @@ const opsItems = computed(() => { status: 'pendingShip', tone: 'warning', alert: true, + icon: iconPendingShip, action: 'orders', }, { @@ -220,6 +254,7 @@ const opsItems = computed(() => { status: 'pendingPayment', tone: 'warning', alert: true, + icon: iconPayment, action: 'orders', }, { @@ -229,6 +264,7 @@ const opsItems = computed(() => { status: '', tone: 'primary', alert: false, + icon: iconOrderNew, action: 'orders', }, ]; @@ -241,21 +277,25 @@ const fulfillItems = computed(() => { key: 'pendingBind', labelKey: 'h5Admin.workplace.fulfill.pendingBind', value: Number(f?.pendingBind) || 0, + icon: iconBind, }, { key: 'inTransit', labelKey: 'h5Admin.workplace.fulfill.inTransit', value: Number(f?.domesticInTransit) || 0, + icon: iconTransit, }, { key: 'arrived', labelKey: 'h5Admin.workplace.fulfill.arrived', value: Number(f?.arrivedPendingPick) || 0, + icon: iconPick, }, { key: 'outbound', labelKey: 'h5Admin.workplace.fulfill.outbound', value: Number(f?.outboundDone) || 0, + icon: iconShipped, }, ]; }); @@ -267,21 +307,25 @@ const pickupItems = computed(() => { key: 'pendingPayment', labelKey: 'h5Admin.workplace.pickup.pendingPayment', value: Number(p?.pendingPayment) || 0, + icon: iconPayment, }, { key: 'pendingSchedule', labelKey: 'h5Admin.workplace.pickup.pendingSchedule', value: Number(p?.pendingSchedule) || 0, + icon: iconDispatch, }, { key: 'pendingPickup', labelKey: 'h5Admin.workplace.pickup.pendingPickup', value: Number(p?.pendingPickup) || 0, + icon: iconPickup, }, { key: 'pendingInbound', labelKey: 'h5Admin.workplace.pickup.pendingInbound', value: Number(p?.pendingInbound) || 0, + icon: iconInbound, }, ]; }); @@ -523,30 +567,22 @@ onMounted(() => { .h5-wp__ops-card { position: relative; display: flex; - flex-direction: column; - align-items: flex-start; - gap: 4px; - min-height: 88px; - padding: 12px; - border: 1px solid var(--h5-border-soft, #f2f3f5); + align-items: center; + min-height: 76px; + padding: 12px 14px; + border: 1px solid var(--h5-border, #e5e6eb); border-radius: 12px; - background: #fafbfc; + background: transparent; text-align: left; cursor: pointer; -webkit-tap-highlight-color: transparent; - &.is-danger.is-alert { - background: var(--h5-danger-bg, #ffece8); - border-color: color-mix(in srgb, var(--h5-danger) 28%, #fff); + &.is-danger.is-alert .h5-wp__ops-value { + color: var(--h5-danger, #f53f3f); } - &.is-warning.is-alert { - background: var(--h5-warning-bg, #fff7e8); - border-color: color-mix(in srgb, var(--h5-warning) 28%, #fff); - } - - &.is-primary { - background: var(--h5-primary-softer); + &.is-warning.is-alert .h5-wp__ops-value { + color: var(--h5-warning, #ff7d00); } &:active { @@ -554,20 +590,61 @@ onMounted(() => { } } +.h5-wp__ops-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + min-width: 0; +} + +.h5-wp__ops-meta { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + min-width: 0; +} + +.h5-wp__ops-icon { + flex: 0 0 auto; + width: 32px; + height: 32px; + object-fit: contain; + pointer-events: none; +} + .h5-wp__ops-label { + max-width: 100%; font-size: 12px; + line-height: 1.3; color: var(--h5-text-2, #4e5969); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.h5-wp__ops-body { + display: flex; + align-items: baseline; + justify-content: flex-end; + gap: 3px; + flex-shrink: 0; } .h5-wp__ops-value { - font-size: 26px; - line-height: 1.1; + font-size: 24px; + line-height: 1; font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; color: var(--h5-text, #1d2129); } .h5-wp__ops-unit { font-size: 11px; + font-weight: 500; color: var(--h5-text-3, #86909c); } @@ -579,22 +656,57 @@ onMounted(() => { .h5-wp__stat { display: flex; - flex-direction: column; - gap: 6px; - padding: 12px; + align-items: center; + min-height: 64px; + padding: 12px 14px; border-radius: 12px; - background: #f7f8fa; + border: 1px solid var(--h5-border, #e5e6eb); + background: transparent; +} - span { - font-size: 12px; - color: var(--h5-text-3, #86909c); - } +.h5-wp__stat-main { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + min-width: 0; +} - strong { - font-size: 20px; - font-weight: 700; - color: var(--h5-text, #1d2129); - } +.h5-wp__stat-meta { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 6px; + min-width: 0; +} + +.h5-wp__stat-icon { + flex: 0 0 auto; + width: 28px; + height: 28px; + object-fit: contain; + pointer-events: none; +} + +.h5-wp__stat-label { + max-width: 100%; + font-size: 12px; + line-height: 1.3; + color: var(--h5-text-3, #86909c); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.h5-wp__stat-value { + flex-shrink: 0; + font-size: 20px; + line-height: 1; + font-weight: 700; + font-variant-numeric: tabular-nums; + letter-spacing: -0.02em; + color: var(--h5-text, #1d2129); } .h5-wp__legend { diff --git a/src/views/h5/components/H5LoginPortalSwitch.vue b/src/views/h5/components/H5LoginPortalSwitch.vue index a40456c..b642b1f 100644 --- a/src/views/h5/components/H5LoginPortalSwitch.vue +++ b/src/views/h5/components/H5LoginPortalSwitch.vue @@ -6,14 +6,14 @@ :title="label" :aria-label="label" > - + + + + + diff --git a/src/views/h5/customer-center/components/H5OrderPaySheet.vue b/src/views/h5/customer-center/components/H5OrderPaySheet.vue index 5519051..699a389 100644 --- a/src/views/h5/customer-center/components/H5OrderPaySheet.vue +++ b/src/views/h5/customer-center/components/H5OrderPaySheet.vue @@ -56,9 +56,96 @@ -
- {{ t('h5.pay.orderTotal') }} - {{ preview.totalText }} +

+ {{ couponEntry(preview.id)?.error }} +

+ + +
+ {{ hasDiscount(preview.id) ? t('h5.pay.payable') : t('h5.pay.orderTotal') }} + {{ orderPayableText(preview) }}
@@ -101,8 +188,19 @@ import { computed, ref, watch } from 'vue'; import { useI18n } from 'vue-i18n'; import { Message } from '@arco-design/web-vue'; -import { IconClose, IconSafe } from '@arco-design/web-vue/es/icon'; -import { getOrderDetail, postOrderPay } from '@/api/order'; +import { + IconCheck, + IconClose, + IconGift, + IconRight, + IconSafe, +} from '@arco-design/web-vue/es/icon'; +import { getOrderDetail, postOrderPay, type OrderPayCouponOption } from '@/api/order'; +import { + formatCouponBenefit, + formatPayMoney, + useOrderPayCoupon, +} from '@/hooks/orderPayCoupon'; import { getDictListAll, type DictDto } from '@/api/system/dict'; import { filterCustomerFacingFees, @@ -171,8 +269,15 @@ const grandTotalText = computed(() => { return formatFeeTotals(totals); }); -/** 单币种时突出显示数字,多币种保留原文 */ +const couponPricing = useOrderPayCoupon({ + onNotApplied: () => Message.warning(t('h5.pay.couponNotApplied')), + onError: (msg) => Message.error(msg), +}); +const expandedCouponOrder = ref(null); + +/** 单币种时突出显示数字,多币种保留原文;营销试算就绪后以后端应付为准 */ const displayGrandTotal = computed(() => { + if (couponPricing.allReady.value) return formatPayMoney(couponPricing.totalPayable.value); const text = grandTotalText.value; if (!text || text === '--') return '--'; if (text.includes('+')) return text; @@ -183,9 +288,60 @@ const canConfirm = computed( () => previews.value.length > 0 && !loading.value && + couponPricing.allReady.value && previews.value.every((item) => !item.error && item.totalAmount > 0), ); +function couponEntry(id: number | string) { + return couponPricing.entryOf(id); +} + +function isCouponSelectable(id: number | string, coupon: OrderPayCouponOption) { + return coupon.canUse && !couponPricing.isCouponTakenByOther(id, coupon.couponInstanceId); +} + +function couponSummaryText(id: number | string) { + const entry = couponEntry(id); + if (!entry?.pricing) return ''; + const { pricing } = entry; + if (entry.selectedCouponId > 0) return `-${formatPayMoney(pricing.couponDiscountAmount)}`; + const count = pricing.availableCoupons.filter((c) => isCouponSelectable(id, c)).length; + return count > 0 ? t('h5.pay.couponAvailableCount', { count }) : t('h5.pay.couponEmpty'); +} + +function couponHintText(id: number | string, coupon: OrderPayCouponOption) { + if (couponPricing.isCouponTakenByOther(id, coupon.couponInstanceId)) { + return t('h5.pay.couponUsedByOther'); + } + if (!coupon.canUse && coupon.unavailableReason) return coupon.unavailableReason; + const threshold = + coupon.minOrderAmount > 0 + ? t('h5.pay.couponMinAmount', { amount: coupon.minOrderAmount }) + : t('h5.pay.couponNoThreshold'); + const until = coupon.validEndTime ? String(coupon.validEndTime).slice(0, 10) : ''; + return until ? `${threshold} · ${t('h5.pay.couponValidUntil', { date: until })}` : threshold; +} + +function hasDiscount(id: number | string) { + const pricing = couponEntry(id)?.pricing; + return !!pricing && (pricing.levelDiscountAmount > 0 || pricing.couponDiscountAmount > 0); +} + +function orderPayableText(preview: PayOrderPreview) { + const pricing = couponEntry(preview.id)?.pricing; + return pricing ? formatPayMoney(pricing.payableAmount) : preview.totalText; +} + +function toggleCouponList(id: number | string) { + const key = String(id); + expandedCouponOrder.value = expandedCouponOrder.value === key ? null : key; +} + +async function handlePickCoupon(id: number | string, couponId: number) { + await couponPricing.selectCoupon(id, couponId); + expandedCouponOrder.value = null; +} + function pick(raw: Record | undefined, ...keys: string[]) { if (!raw) return undefined; for (const k of keys) { @@ -289,6 +445,7 @@ async function loadPreviews() { } } previews.value = next; + await couponPricing.loadAll(next.filter((x) => !x.error).map((x) => x.id)); } finally { loading.value = false; } @@ -304,7 +461,10 @@ async function handleConfirm() { submitting.value = true; try { for (const preview of previews.value) { - await postOrderPay({ id: preview.id }); + await postOrderPay({ + id: preview.id, + couponInstanceId: couponEntry(preview.id)?.selectedCouponId ?? 0, + }); } Message.success(t('h5.pay.success')); emit('success'); @@ -325,6 +485,8 @@ watch( } else { previews.value = []; submitting.value = false; + couponPricing.reset(); + expandedCouponOrder.value = null; } }, { immediate: true }, @@ -545,6 +707,168 @@ watch( } } +.h5-pay__coupon { + margin-top: 4px; + border-top: 1px solid #f2f3f5; +} + +.h5-pay__coupon-row { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 12px 0; + border: none; + background: transparent; + font-size: 14px; + color: #1d2129; + cursor: pointer; + text-align: left; + + &:disabled { + cursor: default; + } +} + +.h5-pay__coupon-label { + display: inline-flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + + .arco-icon { + color: #f53f3f; + font-size: 16px; + } +} + +.h5-pay__coupon-value { + flex: 1; + min-width: 0; + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 6px; + font-size: 13px; + color: #86909c; + text-align: right; + + &.is-active { + color: #f53f3f; + font-weight: 600; + } +} + +.h5-pay__coupon-spin { + width: 12px; + height: 12px; + border: 2px solid #e5e6eb; + border-top-color: var(--app-primary-color); + border-radius: 50%; + animation: h5-pay-spin 0.7s linear infinite; +} + +.h5-pay__coupon-arrow { + flex-shrink: 0; + color: #c9cdd4; + font-size: 14px; + transition: transform 0.2s; + + &.is-open { + transform: rotate(90deg); + } +} + +.h5-pay__coupon-list { + list-style: none; + margin: 0 0 8px; + padding: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.h5-pay__coupon-opt { + width: 100%; + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + border: 1px solid #e5e6eb; + border-radius: 12px; + background: #fff; + text-align: left; + cursor: pointer; + + &.is-selected { + border-color: var(--app-primary-color); + background: color-mix(in srgb, var(--app-primary-color) 6%, #fff); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } +} + +.h5-pay__coupon-opt-benefit { + flex: 0 0 64px; + font-size: 15px; + font-weight: 700; + color: #f53f3f; + font-variant-numeric: tabular-nums; + word-break: break-all; +} + +.h5-pay__coupon-opt-main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + + small { + font-size: 11px; + color: #86909c; + line-height: 1.4; + } +} + +.h5-pay__coupon-opt-name { + font-size: 14px; + color: #1d2129; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.h5-pay__coupon-check { + flex-shrink: 0; + color: var(--app-primary-color); + font-size: 16px; +} + +.h5-pay__breakdown { + list-style: none; + margin: 0; + padding: 8px 0 4px; + border-top: 1px solid #f2f3f5; + + li { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 4px 0; + font-size: 13px; + color: #4e5969; + font-variant-numeric: tabular-nums; + } + + .is-discount { + color: #f53f3f; + } +} + .h5-pay__method { display: flex; align-items: flex-start; diff --git a/src/views/h5/customer-center/components/H5PlaceOrderSheet.vue b/src/views/h5/customer-center/components/H5PlaceOrderSheet.vue index 636bbc3..d97fd7f 100644 --- a/src/views/h5/customer-center/components/H5PlaceOrderSheet.vue +++ b/src/views/h5/customer-center/components/H5PlaceOrderSheet.vue @@ -871,6 +871,22 @@ +
+ + +
+ +
+
+ + +
+
+ + +
+
+
@@ -1477,6 +1493,9 @@ type DeclareItem = { imHsCode: string; exHsCode: string; declareElement: string; + productId: string; + nonStdProductId: string; + stdProductId: string; skuImage: string; brand: string; brandType: string; @@ -1511,6 +1530,9 @@ function createEmptyItem(): DeclareItem { imHsCode: '', exHsCode: '', declareElement: '', + productId: '', + nonStdProductId: '', + stdProductId: '', skuImage: '', brand: '', brandType: '', @@ -2691,6 +2713,9 @@ function mapDetailItems(rawItems: any[]): DeclareItem[] { imHsCode: String(pickDetail(raw, 'imHsCode', 'ImHsCode') ?? hs), exHsCode: String(pickDetail(raw, 'exHsCode', 'ExHsCode') ?? hs), declareElement: String(pickDetail(raw, 'declareElement', 'DeclareElement') ?? ''), + productId: String(pickDetail(raw, 'productId', 'ProductId') ?? ''), + nonStdProductId: String(pickDetail(raw, 'nonStdProductId', 'NonStdProductId') ?? ''), + stdProductId: String(pickDetail(raw, 'stdProductId', 'StdProductId') ?? ''), skuImage: normalizeSkuImageForStorage( String(pickDetail(raw, 'skuImage', 'SkuImage') ?? ''), ), diff --git a/src/views/h5/customer-center/layout.vue b/src/views/h5/customer-center/layout.vue index a7b7bcf..ca243ce 100644 --- a/src/views/h5/customer-center/layout.vue +++ b/src/views/h5/customer-center/layout.vue @@ -25,6 +25,7 @@ v-model:visible="placeOrderVisible" /> +
@@ -58,6 +59,9 @@ const H5CreateOrderFlow = defineAsyncComponent( const H5FollowOfficialModal = defineAsyncComponent( () => import('./components/H5FollowOfficialModal.vue'), ); +const H5NoticePopup = defineAsyncComponent( + () => import('./components/H5NoticePopup.vue'), +); const { t, locale } = useI18n(); const route = useRoute(); @@ -88,6 +92,7 @@ async function openPlaceOrder() { const showTabBar = computed(() => route.meta?.h5HideTabBar !== true); const showHeader = computed(() => route.meta?.h5HideHeader !== true); const followModalReady = computed(() => isLogin()); +const noticePopupReady = computed(() => isLogin()); const headerTitle = computed(() => { const key = String(route.meta?.locale || 'h5.center.title'); diff --git a/src/views/h5/customer-center/locale/en-US.ts b/src/views/h5/customer-center/locale/en-US.ts index 9f243e9..561e3f9 100644 --- a/src/views/h5/customer-center/locale/en-US.ts +++ b/src/views/h5/customer-center/locale/en-US.ts @@ -1,5 +1,11 @@ export default { 'h5.center.title': 'Customer Center', + 'h5.noticePopup.title': 'Notice', + 'h5.noticePopup.markRead': 'Mark as read', + 'h5.noticePopup.later': 'Later', + 'h5.noticePopup.readSuccess': 'Marked as read', + 'h5.noticePopup.readFailed': 'Could not mark as read. Please try again.', + 'h5.noticePopup.createTime': 'Published: ', 'h5.tab.assistant': 'AI Advisor', 'h5.tab.price': 'Rates', 'h5.tab.createOrder': 'Order', @@ -450,6 +456,9 @@ export default { 'h5.placeOrder.imHsCode': 'Import HS', 'h5.placeOrder.exHsCode': 'Export HS', 'h5.placeOrder.declareElement': 'Export declare element', + 'h5.placeOrder.productId': 'Product ID', + 'h5.placeOrder.nonStdProductId': 'Non-standard product ID', + 'h5.placeOrder.stdProductId': 'Standard product ID', 'h5.placeOrder.brand': 'Brand', 'h5.placeOrder.model': 'Model', 'h5.placeOrder.material': 'Material', @@ -921,6 +930,19 @@ export default { 'h5.pay.balanceLow': 'Your balance is not enough for this payment. Recharge first, then come back to pay.', 'h5.pay.goRecharge': 'Recharge', 'h5.pay.later': 'Later', + 'h5.pay.coupon': 'Coupon', + 'h5.pay.couponNone': 'No coupon', + 'h5.pay.couponEmpty': 'No coupons available', + 'h5.pay.couponAvailableCount': '{count} available', + 'h5.pay.couponUsedByOther': 'Used by another order', + 'h5.pay.couponMinAmount': 'Base freight ≥ {amount}', + 'h5.pay.couponNoThreshold': 'No minimum', + 'h5.pay.couponValidUntil': 'Valid until {date}', + 'h5.pay.couponNotApplied': 'This coupon is not better than your member discount; member discount applied', + 'h5.pay.subtotal': 'Fees total', + 'h5.pay.levelDiscount': 'Member discount', + 'h5.pay.couponDiscount': 'Coupon discount', + 'h5.pay.payable': 'To pay', 'h5.me.balance': 'Balance', 'h5.me.recharge': 'Recharge', 'h5.me.points': '12-month points', diff --git a/src/views/h5/customer-center/locale/zh-CN.ts b/src/views/h5/customer-center/locale/zh-CN.ts index 7decac0..69236cc 100644 --- a/src/views/h5/customer-center/locale/zh-CN.ts +++ b/src/views/h5/customer-center/locale/zh-CN.ts @@ -1,5 +1,11 @@ export default { 'h5.center.title': '客户中心', + 'h5.noticePopup.title': '公告', + 'h5.noticePopup.markRead': '已读', + 'h5.noticePopup.later': '稍后', + 'h5.noticePopup.readSuccess': '已标记已读', + 'h5.noticePopup.readFailed': '标记已读失败,请稍后重试', + 'h5.noticePopup.createTime': '发布时间:', 'h5.tab.assistant': '顾问', 'h5.tab.price': '查价', 'h5.tab.createOrder': '下单', @@ -445,6 +451,9 @@ export default { 'h5.placeOrder.imHsCode': '进口HS', 'h5.placeOrder.exHsCode': '出口HS', 'h5.placeOrder.declareElement': '出口申报要素', + 'h5.placeOrder.productId': '产品标识编码', + 'h5.placeOrder.nonStdProductId': '非标产品标识编码', + 'h5.placeOrder.stdProductId': '标准产品标识编码', 'h5.placeOrder.brand': '品牌', 'h5.placeOrder.model': '型号', 'h5.placeOrder.material': '材质', @@ -906,6 +915,19 @@ export default { 'h5.pay.balanceLow': '当前余额不足以完成本次支付,请先充值后再回来付款', 'h5.pay.goRecharge': '去充值', 'h5.pay.later': '稍后再说', + 'h5.pay.coupon': '优惠券', + 'h5.pay.couponNone': '不使用优惠券', + 'h5.pay.couponEmpty': '暂无可用券', + 'h5.pay.couponAvailableCount': '{count} 张可用', + 'h5.pay.couponUsedByOther': '已用于其他订单', + 'h5.pay.couponMinAmount': '基础运费满 {amount} 可用', + 'h5.pay.couponNoThreshold': '无门槛', + 'h5.pay.couponValidUntil': '有效期至 {date}', + 'h5.pay.couponNotApplied': '该券优惠不高于会员折扣,已按会员折扣计算', + 'h5.pay.subtotal': '费用合计', + 'h5.pay.levelDiscount': '会员折扣', + 'h5.pay.couponDiscount': '优惠券抵扣', + 'h5.pay.payable': '实付', 'h5.me.balance': '账户余额', 'h5.me.recharge': '去充值', 'h5.me.points': '近12个月积分', diff --git a/src/views/home/monitor/index.vue b/src/views/home/monitor/index.vue index 3c2b90e..616f064 100644 --- a/src/views/home/monitor/index.vue +++ b/src/views/home/monitor/index.vue @@ -15,30 +15,40 @@ class="monitor-top-tabs" > +
+ {{ $t('monitor.logistics.filter.opsPeriod') }} + + {{ $t('monitor.logistics.filter.periodDay') }} + {{ $t('monitor.logistics.filter.periodWeek') }} + {{ $t('monitor.logistics.filter.periodMonth') }} + +
+
{{ $t(card.labelKey) }}
- + +
-
{{ card.value }}
+
{{ card.value }}
@@ -46,15 +56,46 @@
+

{{ $t('monitor.logistics.queue.title') }}

+ + + +
+
+
{{ $t(card.labelKey) }}
+
+ + +
+
+
+ {{ card.value }} +
+
+
+
+
+ - +
- +
@@ -235,8 +276,15 @@ formatMonitorNumber, formatMonitorCurrency, formatMonitorGrowth, + type MonitorDimension, type MonitorOverview, } from '@/api/monitor'; + import iconOrderNew from '@/assets/icons/workplace/order-new.svg?url'; + import iconPayment from '@/assets/icons/workplace/order-payment.svg?url'; + import iconGrowth from '@/assets/icons/workplace/order-growth.svg?url'; + import iconPendingOnline from '@/assets/icons/workplace/order-pending-online.svg?url'; + import iconProblematic from '@/assets/icons/workplace/order-problematic.svg?url'; + import iconShipped from '@/assets/icons/workplace/order-shipped.svg?url'; defineOptions({ name: 'Monitor' }); @@ -244,11 +292,12 @@ const overview = ref(null); const overviewLoading = ref(true); + const opsDimension = ref('day'); async function loadOverview() { overviewLoading.value = true; try { - overview.value = await getMonitorOverview(); + overview.value = await getMonitorOverview(opsDimension.value); } catch { overview.value = null; } finally { @@ -265,71 +314,82 @@ nextTick(() => onResize()); } - const orderStatCards = computed(() => { - const order = overview.value?.order; + const opsCompareKey = computed(() => { + if (opsDimension.value === 'week') return 'monitor.logistics.vsLastWeek'; + if (opsDimension.value === 'month') return 'monitor.logistics.vsLastMonth'; + return 'monitor.logistics.vsYesterday'; + }); + + const orderTrendTitleKey = computed(() => { + if (opsDimension.value === 'week') return 'monitor.logistics.chart.ordersWeek'; + if (opsDimension.value === 'month') return 'monitor.logistics.chart.ordersMonth'; + return 'monitor.logistics.chart.ordersDay'; + }); + + const revenueTrendTitleKey = computed(() => { + if (opsDimension.value === 'week') return 'monitor.logistics.chart.revenueGrossWeek'; + if (opsDimension.value === 'month') return 'monitor.logistics.chart.revenueGrossMonth'; + return 'monitor.logistics.chart.revenueGrossDay'; + }); + + const opsStatCards = computed(() => { + const ops = overview.value?.ops; + const dim = opsDimension.value; return [ { - key: 'todayOrders', - labelKey: 'monitor.logistics.stat.todayOrders', - value: formatMonitorNumber(order?.todayCreated ?? 0), - growth: formatMonitorGrowth(order?.todayCreatedGrowthPercent), - compareKey: 'monitor.logistics.vsYesterday', - warning: false, - icon: 'calendar', + key: 'orderCount', + labelKey: `monitor.logistics.ops.orderCount.${dim}`, + value: formatMonitorNumber(ops?.orderCount ?? 0), + growth: formatMonitorGrowth(ops?.orderCountGrowthPercent), + iconSrc: iconOrderNew, }, { - key: 'historicalOrders', - labelKey: 'monitor.logistics.stat.historicalOrders', - value: formatMonitorNumber(order?.total ?? 0), - growth: formatMonitorGrowth(order?.totalMonthGrowthPercent), - compareKey: 'monitor.logistics.vsLastMonth', - warning: false, - icon: 'history', + key: 'revenue', + labelKey: `monitor.logistics.ops.revenue.${dim}`, + value: formatMonitorCurrency(ops?.revenue ?? 0), + growth: formatMonitorGrowth(ops?.revenueGrowthPercent), + iconSrc: iconPayment, }, + { + key: 'grossProfit', + labelKey: `monitor.logistics.ops.grossProfit.${dim}`, + value: formatMonitorCurrency(ops?.grossProfit ?? 0), + growth: formatMonitorGrowth(ops?.grossProfitGrowthPercent), + iconSrc: iconGrowth, + }, + ]; + }); + + const queueStatCards = computed(() => { + const queue = overview.value?.queue ?? overview.value?.order; + return [ { key: 'pendingPayment', labelKey: 'monitor.logistics.stat.pendingPayment', - value: formatMonitorNumber(order?.pendingPayment ?? 0), - growth: { show: false, up: true, text: '' }, - compareKey: 'monitor.logistics.vsYesterday', - warning: true, - icon: 'stamp', + value: formatMonitorNumber(queue?.pendingPayment ?? 0), + warning: (queue?.pendingPayment ?? 0) > 0, + iconSrc: iconPayment, }, { key: 'pendingOnline', labelKey: 'monitor.logistics.stat.pendingOnline', - value: formatMonitorNumber(order?.pendingOnline ?? 0), - growth: { show: false, up: true, text: '' }, - compareKey: 'monitor.logistics.vsYesterday', - warning: false, - icon: 'send', + value: formatMonitorNumber(queue?.pendingOnline ?? 0), + warning: (queue?.pendingOnline ?? 0) > 0, + iconSrc: iconPendingOnline, }, { key: 'pendingSign', labelKey: 'monitor.logistics.stat.pendingSign', - value: formatMonitorNumber(order?.pendingSign ?? 0), - growth: { show: false, up: true, text: '' }, - compareKey: 'monitor.logistics.vsYesterday', + value: formatMonitorNumber(queue?.pendingSign ?? 0), warning: false, - icon: 'check', + iconSrc: iconShipped, }, { key: 'orderExceptions', labelKey: 'monitor.logistics.stat.orderExceptions', - value: formatMonitorNumber(order?.problematic ?? 0), - growth: { show: false, up: false, text: '' }, - compareKey: 'monitor.logistics.vsYesterday', - warning: true, - icon: 'exception', - }, - { - key: 'todayRevenue', - labelKey: 'monitor.logistics.stat.todayRevenue', - value: formatMonitorCurrency(order?.todayRevenue ?? 0), - growth: formatMonitorGrowth(order?.todayRevenueGrowthPercent), - compareKey: 'monitor.logistics.vsYesterday', - warning: false, - icon: 'chart', + value: formatMonitorNumber(queue?.problematic ?? 0), + warning: (queue?.problematic ?? 0) > 0, + iconSrc: iconProblematic, }, ]; }); @@ -791,8 +851,8 @@ function ensureOrderLineCharts() { const primary = readCssVar('--app-primary-color', '#2D8C8C'); const axisLine = readCssVar('--color-border-2', '#e5e6eb'); - const orderTrend = overview.value?.orderTrend7d ?? []; - const revenueTrend = overview.value?.revenueTrend7d ?? []; + const orderTrend = overview.value?.orderTrend ?? overview.value?.orderTrend7d ?? []; + const revenueTrend = overview.value?.revenueTrend ?? overview.value?.revenueTrend7d ?? []; const dayLabels = orderTrend.length ? orderTrend.map((item) => item.label) : revenueTrend.map((item) => item.label); @@ -941,6 +1001,10 @@ { immediate: true }, ); + watch(opsDimension, () => { + void loadOverview(); + }); + watch([filterLine, filterSignDays], () => { nextTick(() => { if (monitorTab.value === 'track') renderSignRateChart(); @@ -1079,6 +1143,26 @@ color: var(--color-text-1); } + .monitor-ops-toolbar { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 12px; + } + + .monitor-ops-toolbar__label { + font-size: 13px; + color: var(--color-text-2); + white-space: nowrap; + } + + .monitor-queue-title { + margin: 16px 0 10px; + font-size: 14px; + font-weight: 600; + color: var(--color-text-1); + } + .logistics-monitor-stats { margin-bottom: 10px; @@ -1183,6 +1267,12 @@ font-size: 18px; } + .stat-item__icon-img { + width: 28px; + height: 28px; + object-fit: contain; + } + .stat-item__icon--primary { background: var(--app-primary-color-light); color: var(--app-primary-color); diff --git a/src/views/home/monitor/locale/en-US.ts b/src/views/home/monitor/locale/en-US.ts index c1338eb..c2ee011 100644 --- a/src/views/home/monitor/locale/en-US.ts +++ b/src/views/home/monitor/locale/en-US.ts @@ -8,18 +8,35 @@ export default { 'monitor.logistics.stat.pendingSign': 'Pending delivery', 'monitor.logistics.stat.orderExceptions': 'Order exceptions', 'monitor.logistics.stat.todayRevenue': 'Revenue today', + 'monitor.logistics.ops.orderCount.day': 'Orders today', + 'monitor.logistics.ops.orderCount.week': 'Orders this week', + 'monitor.logistics.ops.orderCount.month': 'Orders this month', + 'monitor.logistics.ops.revenue.day': 'Revenue today', + 'monitor.logistics.ops.revenue.week': 'Revenue this week', + 'monitor.logistics.ops.revenue.month': 'Revenue this month', + 'monitor.logistics.ops.grossProfit.day': 'Gross profit today', + 'monitor.logistics.ops.grossProfit.week': 'Gross profit this week', + 'monitor.logistics.ops.grossProfit.month': 'Gross profit this month', + 'monitor.logistics.queue.title': 'Live backlog', 'monitor.logistics.stat.totalCustomers': 'Total customers', 'monitor.logistics.stat.newCustomers': 'New customers', 'monitor.logistics.stat.onlineCustomers': 'Online customers', 'monitor.logistics.stat.inactiveLogin7d': 'No login 7+ days', 'monitor.logistics.vsYesterday': 'vs yesterday', + 'monitor.logistics.vsLastWeek': 'vs last week', 'monitor.logistics.vsLastMonth': 'vs last month', 'monitor.logistics.hint.newCustomersToday': 'New sign-ups today', 'monitor.logistics.hint.onlineRealtime': 'Active sessions (live)', 'monitor.logistics.hint.inactiveLogin7d': 'Customers with no login in 7+ days', 'monitor.logistics.hint.inactiveLogin7dCompare': '+5 vs last week', 'monitor.logistics.chart.orders7d': 'Order volume (last 7 days)', + 'monitor.logistics.chart.ordersDay': 'Orders · last 7 days', + 'monitor.logistics.chart.ordersWeek': 'Orders · last 8 weeks', + 'monitor.logistics.chart.ordersMonth': 'Orders · last 6 months', 'monitor.logistics.chart.revenueGross7d': 'Revenue & gross profit (last 7 days)', + 'monitor.logistics.chart.revenueGrossDay': 'Revenue & gross · last 7 days', + 'monitor.logistics.chart.revenueGrossWeek': 'Revenue & gross · last 8 weeks', + 'monitor.logistics.chart.revenueGrossMonth': 'Revenue & gross · last 6 months', 'monitor.logistics.chart.customerTrendsSection': 'By lane · week / month trends', 'monitor.logistics.chart.customerOrdersByLine': 'Order volume trend', 'monitor.logistics.chart.customerReceivableByLine': 'Accounts receivable trend', @@ -49,6 +66,8 @@ export default { 'monitor.logistics.axis.d6': 'Day 6', 'monitor.logistics.axis.today': 'Today', 'monitor.logistics.filter.statPeriod': 'Period', + 'monitor.logistics.filter.opsPeriod': 'Ops period', + 'monitor.logistics.filter.periodDay': 'Day', 'monitor.logistics.filter.periodWeek': 'Week', 'monitor.logistics.filter.periodMonth': 'Month', 'monitor.logistics.pie.sectionTitle': 'Track & OTD (by lane / period)', diff --git a/src/views/home/monitor/locale/zh-CN.ts b/src/views/home/monitor/locale/zh-CN.ts index 0218612..191244d 100644 --- a/src/views/home/monitor/locale/zh-CN.ts +++ b/src/views/home/monitor/locale/zh-CN.ts @@ -8,18 +8,35 @@ export default { 'monitor.logistics.stat.pendingSign': '待签收订单', 'monitor.logistics.stat.orderExceptions': '订单异常', 'monitor.logistics.stat.todayRevenue': '今日总营收', + 'monitor.logistics.ops.orderCount.day': '今日订单量', + 'monitor.logistics.ops.orderCount.week': '本周订单量', + 'monitor.logistics.ops.orderCount.month': '本月订单量', + 'monitor.logistics.ops.revenue.day': '今日营收', + 'monitor.logistics.ops.revenue.week': '本周营收', + 'monitor.logistics.ops.revenue.month': '本月营收', + 'monitor.logistics.ops.grossProfit.day': '今日毛利', + 'monitor.logistics.ops.grossProfit.week': '本周毛利', + 'monitor.logistics.ops.grossProfit.month': '本月毛利', + 'monitor.logistics.queue.title': '实时积压', 'monitor.logistics.stat.totalCustomers': '客户总数', 'monitor.logistics.stat.newCustomers': '新增客户数', 'monitor.logistics.stat.onlineCustomers': '在线客户数', 'monitor.logistics.stat.inactiveLogin7d': '超7天未登录', 'monitor.logistics.vsYesterday': '较昨日', + 'monitor.logistics.vsLastWeek': '较上周', 'monitor.logistics.vsLastMonth': '较上月', 'monitor.logistics.hint.newCustomersToday': '统计口径:今日新注册', 'monitor.logistics.hint.onlineRealtime': '当前在线会话数(实时)', 'monitor.logistics.hint.inactiveLogin7d': '超7天未产生登录行为的客户', 'monitor.logistics.hint.inactiveLogin7dCompare': '较上周 +5 人', 'monitor.logistics.chart.orders7d': '近7日订单数趋势', + 'monitor.logistics.chart.ordersDay': '近7日订单量', + 'monitor.logistics.chart.ordersWeek': '近8周订单量', + 'monitor.logistics.chart.ordersMonth': '近6月订单量', 'monitor.logistics.chart.revenueGross7d': '近7日营收 & 毛利趋势', + 'monitor.logistics.chart.revenueGrossDay': '近7日营收 & 毛利', + 'monitor.logistics.chart.revenueGrossWeek': '近8周营收 & 毛利', + 'monitor.logistics.chart.revenueGrossMonth': '近6月营收 & 毛利', 'monitor.logistics.chart.customerTrendsSection': '按线路 · 周/月趋势', 'monitor.logistics.chart.customerOrdersByLine': '单量趋势', 'monitor.logistics.chart.customerReceivableByLine': '应收趋势', @@ -49,6 +66,8 @@ export default { 'monitor.logistics.axis.d6': '6日', 'monitor.logistics.axis.today': '今日', 'monitor.logistics.filter.statPeriod': '统计周期', + 'monitor.logistics.filter.opsPeriod': '经营周期', + 'monitor.logistics.filter.periodDay': '日', 'monitor.logistics.filter.periodWeek': '周', 'monitor.logistics.filter.periodMonth': '月', 'monitor.logistics.pie.sectionTitle': '轨迹与 OTD 达成(按线路 / 周期)', diff --git a/src/views/login/components/LoginPortalSwitch.vue b/src/views/login/components/LoginPortalSwitch.vue new file mode 100644 index 0000000..d26a9c1 --- /dev/null +++ b/src/views/login/components/LoginPortalSwitch.vue @@ -0,0 +1,63 @@ + + + + + diff --git a/src/views/login/components/pwd-login/index.vue b/src/views/login/components/pwd-login/index.vue index 83e8239..a067ef9 100644 --- a/src/views/login/components/pwd-login/index.vue +++ b/src/views/login/components/pwd-login/index.vue @@ -73,7 +73,7 @@ {{ loading ? t('login.submitting') : t('login.form.login') }} -
+
{{ t('login.agreement.readPrefix') }} @@ -344,7 +344,7 @@ function validateForm(): boolean { async function handleSubmit() { if (loading.value) return; - if (!agreementChecked.value) { + if (isCustomerLogin.value && !agreementChecked.value) { Message.warning('请先阅读并勾选同意隐私政策与使用须知'); return; } diff --git a/src/views/login/index.vue b/src/views/login/index.vue index 5f04ae0..415463d 100644 --- a/src/views/login/index.vue +++ b/src/views/login/index.vue @@ -87,6 +87,7 @@ @switch-pwd="setLoginMode('pwd')" />
+
@@ -110,6 +111,7 @@ import { IconSunFill, IconMoonFill } from '@arco-design/web-vue/es/icon'; import LoginForm from './components/pwd-login/index.vue'; import LoginHero from './components/LoginHero.vue'; import LoginLangToggle from './components/LoginLangToggle.vue'; +import LoginPortalSwitch from './components/LoginPortalSwitch.vue'; import { LOGIN_CHARACTERS_KEY, type LoginCharactersApi, diff --git a/src/views/login/locale/en-US.ts b/src/views/login/locale/en-US.ts index 3b7d97e..6a769b6 100644 --- a/src/views/login/locale/en-US.ts +++ b/src/views/login/locale/en-US.ts @@ -29,6 +29,8 @@ 'login.wechat.expired': 'QR code expired', 'login.wechat.expiredTip': 'Refresh before scanning to avoid a failed login', 'login.wechat.longPress': 'In WeChat, long-press the QR code to recognize', + 'login.switchToAdmin': 'Switch to staff login', + 'login.switchToCustomer': 'Switch to customer login', 'login.theme.toDay': 'Switch to day mode', 'login.theme.toNight': 'Switch to night mode', 'login.lang.toEn': 'Switch to English', diff --git a/src/views/login/locale/zh-CN.ts b/src/views/login/locale/zh-CN.ts index 449b64e..6be34a3 100644 --- a/src/views/login/locale/zh-CN.ts +++ b/src/views/login/locale/zh-CN.ts @@ -29,6 +29,8 @@ 'login.wechat.refreshExpired': '点击刷新二维码', 'login.wechat.expired': '二维码已过期', 'login.wechat.expiredTip': '请先刷新后再扫码,避免扫码无效', + 'login.switchToAdmin': '切换至管理登录', + 'login.switchToCustomer': '切换至客户登录', 'login.theme.toDay': '切换为日间模式', 'login.theme.toNight': '切换为夜间模式', 'login.lang.toEn': '切换为 English', diff --git a/src/views/order/components/orderDetailFieldConfig.ts b/src/views/order/components/orderDetailFieldConfig.ts index 00d48a7..0a220bd 100644 --- a/src/views/order/components/orderDetailFieldConfig.ts +++ b/src/views/order/components/orderDetailFieldConfig.ts @@ -216,6 +216,9 @@ export const ORDER_DETAIL_DECLARATION_COLUMNS = [ { key: 'imHsCode', labelKey: 'order.myOrders.drawer.declarationImHsCode', width: 100 }, { key: 'exHsCode', labelKey: 'order.myOrders.drawer.declarationExHsCode', width: 100 }, { key: 'declareElement', labelKey: 'order.myOrders.drawer.declarationDeclareElement', width: 160 }, + { key: 'productId', labelKey: 'order.myOrders.drawer.declarationProductId', width: 130 }, + { key: 'nonStdProductId', labelKey: 'order.myOrders.drawer.declarationNonStdProductId', width: 140 }, + { key: 'stdProductId', labelKey: 'order.myOrders.drawer.declarationStdProductId', width: 140 }, { key: 'skuImage', labelKey: 'order.myOrders.drawer.declarationSkuImage', width: 56 }, { key: 'category', labelKey: 'order.myOrders.drawer.declarationCategory', width: 80 }, { key: 'brand', labelKey: 'order.myOrders.drawer.declarationBrand', width: 80 }, @@ -243,6 +246,9 @@ export function normalizeDeclarationItemRow(row: Record) { imHsCode, exHsCode, declareElement: row.declareElement ?? row.DeclareElement ?? '', + productId: row.productId ?? row.ProductId ?? '', + nonStdProductId: row.nonStdProductId ?? row.NonStdProductId ?? '', + stdProductId: row.stdProductId ?? row.StdProductId ?? '', hsCode: row.hsCode ?? row.HsCode ?? exHsCode ?? imHsCode ?? '', isMagnetic: !!row.isMagnetic, }; @@ -266,6 +272,9 @@ export function buildDeclarationPayloadFromRows(rows: Record[]) { imHsCode: row.imHsCode || row.exHsCode || row.hsCode || undefined, exHsCode: row.exHsCode || row.imHsCode || row.hsCode || undefined, declareElement: row.declareElement || undefined, + productId: row.productId || undefined, + nonStdProductId: row.nonStdProductId || undefined, + stdProductId: row.stdProductId || undefined, skuImage: row.skuImage || undefined, category: row.category || undefined, brand: row.brand || undefined, diff --git a/src/views/order/customs9610Order/components/CreateCustoms9610Modal.vue b/src/views/order/customs9610Order/components/CreateCustoms9610Modal.vue new file mode 100644 index 0000000..bc3f9aa --- /dev/null +++ b/src/views/order/customs9610Order/components/CreateCustoms9610Modal.vue @@ -0,0 +1,519 @@ + + + + + diff --git a/src/views/order/customs9610Order/index.vue b/src/views/order/customs9610Order/index.vue index 0ab1eaa..3765432 100644 --- a/src/views/order/customs9610Order/index.vue +++ b/src/views/order/customs9610Order/index.vue @@ -9,7 +9,6 @@ import useModal from '@/components/ModalPro/useModal' import { useTableContainerHeight } from '@/composables/useTableContainerHeight' import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer' import { - declareCustoms9610Batch, fetchCustoms9610DeclarationFormBatch, fetchCustoms9610ManifestBatch, getCustoms9610Detail, @@ -22,6 +21,7 @@ import { hasPermission } from '@/utils/rbac-permission' import { formatDateTime, normalizeListRow, pickField } from '@/utils/warehouseListMapper' import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer' import SlidingTabs from '@/components/SlidingTabs/index.vue' +import CreateCustoms9610Modal from './components/CreateCustoms9610Modal.vue' defineOptions({ name: 'OrderCustoms9610' }) @@ -44,7 +44,7 @@ const pageSize = ref(50) const totalCount = ref(0) const selectedIds = ref([]) const statusCounts = ref({ total: 0, pending: 0, declared: 0 }) -const declaring = ref(false) +const createModalRef = ref | null>(null) const fetchingManifest = ref(false) const fetchingDeclarationForm = ref(false) const downstreamForwardModalVisible = ref(false) @@ -543,34 +543,12 @@ async function handleSave() { } } -async function handleBatchDeclare() { - const ids = selectedIds.value - if (!ids.length) { - $baseMessage(t('order.customs9610.message.selectFirst'), 'warning') - return - } - Modal.confirm({ - title: t('order.customs9610.confirm.batchDeclareTitle'), - content: t('order.customs9610.confirm.batchDeclareContent', { count: ids.length }), - onOk: async () => { - declaring.value = true - try { - const res = await declareCustoms9610Batch(ids) - const successCount = Number(pickField(res, 'successCount', 'SuccessCount') ?? 0) - const failCount = Number(pickField(res, 'failCount', 'FailCount') ?? 0) - $baseMessage( - t('order.customs9610.message.batchResult', { success: successCount, fail: failCount }), - failCount > 0 ? 'warning' : 'success', - ) - await loadList() - await loadCounts() - } catch (e: any) { - $baseMessage(e?.message || t('order.customs9610.message.declareFailed'), 'error') - } finally { - declaring.value = false - } - }, - }) +function handleOpenCreate() { + void createModalRef.value?.open() +} + +async function handleCreateSuccess() { + await Promise.all([loadList(), loadCounts()]) } async function runBatchFetch( @@ -745,11 +723,9 @@ onMounted(() => { - {{ t('order.customs9610.actions.batchDeclare') }} + {{ t('order.customs9610.actions.createFromOrders') }} { + + !isDiscountCostItem(item.value)) currencyOptions.value = mapDictOptions(currencies) if (!currencyOptions.value.some((item) => item.value === formState.currency)) { formState.currency = currencyOptions.value[0]?.value || 'CNY' diff --git a/src/views/order/myOrders/components/OrderDetailDrawer.vue b/src/views/order/myOrders/components/OrderDetailDrawer.vue index 202d2b3..13eb790 100644 --- a/src/views/order/myOrders/components/OrderDetailDrawer.vue +++ b/src/views/order/myOrders/components/OrderDetailDrawer.vue @@ -404,6 +404,9 @@ {{ $t('order.myOrders.drawer.fee.itemCount', { count: receivableFeeList.length }) }} + + {{ $t('order.myOrders.drawer.fee.includesDiscount', { amount: formatFeeTotals(receivableDiscountTotals) }) }} + @@ -431,7 +434,10 @@ :class="col.key" > @@ -722,6 +728,7 @@ import { import { getDictListAll, type DictDto } from '@/api/system/dict'; import { getWarehouseList } from '@/api/system/warehouse'; import { getOrderStatusMeta, getOrderStatusTagStyle, resolveOrderStatusCode } from '@/constants/orderStatus'; +import { isDiscountCostItem } from '@/constants/feeCostItems'; import { getBooleanTagStyle } from '@/utils/statusTag'; import { resolveOmsAssetUrl } from '@/api/oms/common'; import { useRbacUserStore } from '@/store/modules/rbac-user'; @@ -1711,6 +1718,15 @@ const payableFeeList = computed(() => ); const receivableTotals = computed(() => sumFeesByCurrency(receivableFeeList.value)); + +function isDiscountFeeRow(row: any) { + return isDiscountCostItem(row?.costItem ?? row?.CostItem); +} + +/** 支付写入的优惠券抵扣 / 会员折扣(负数),用于应收卡片「含优惠」提示 */ +const receivableDiscountTotals = computed(() => + sumFeesByCurrency(receivableFeeList.value.filter(isDiscountFeeRow)), +); const payableTotals = computed(() => sumFeesByCurrency(payableFeeList.value)); function calcGrossProfitTotals( @@ -3056,6 +3072,12 @@ defineExpose({ line-height: 20px; } +.fee-summary-discount { + font-size: 12px; + color: #ff7d00; + line-height: 20px; +} + .fee-view-switch { display: flex; justify-content: flex-end; @@ -3197,6 +3219,10 @@ defineExpose({ color: #00b42a; } + &.amount-discount { + color: #ff7d00; + } + &.amount-payable { color: #f53f3f; } diff --git a/src/views/order/myOrders/components/OrderPayConfirmModal.vue b/src/views/order/myOrders/components/OrderPayConfirmModal.vue index 99a117f..de80d82 100644 --- a/src/views/order/myOrders/components/OrderPayConfirmModal.vue +++ b/src/views/order/myOrders/components/OrderPayConfirmModal.vue @@ -2,8 +2,16 @@ import { computed, ref, watch } from 'vue' import { useI18n } from 'vue-i18n' import { Message } from '@arco-design/web-vue' -import { getOrderDetail, postOrderPay } from '@/api/order' +import { IconClose, IconDown, IconRight } from '@arco-design/web-vue/es/icon' +import { getOrderDetail, postOrderPay, type OrderPayCouponOption } from '@/api/order' import { getDictListAll, type DictDto } from '@/api/system/dict' +import { CustomerService } from '@/api/customer' +import { + formatCouponBenefit, + formatPayMoney, + runWithConcurrency, + useOrderPayCoupon, +} from '@/hooks/orderPayCoupon' interface PayOrderInput { id: number | string @@ -20,13 +28,20 @@ interface PayFeeRow { interface PayOrderPreview { id: number | string + key: string waybillNo: string + customerId?: number + customerName: string fees: PayFeeRow[] totalText: string totalAmount: number error?: string } +type PayStatus = 'paying' | 'success' | 'failed' + +const DETAIL_CONCURRENCY = 4 + const props = defineProps<{ visible: boolean orders: PayOrderInput[] @@ -43,33 +58,181 @@ const loading = ref(false) const submitting = ref(false) const previews = ref([]) const feeItemDictRows = ref([]) +const expandedKeys = ref>(new Set()) +const payStatus = ref>({}) +const payErrors = ref>({}) +const progress = ref({ done: 0, total: 0 }) +/** customerId -> 余额;null 表示查询失败 */ +const balances = ref>({}) + +const couponPricing = useOrderPayCoupon({ + onNotApplied: () => Message.warning(t('order.myOrders.modal.payCouponNotApplied')), + onError: (msg) => Message.error(msg), +}) const columns = computed(() => [ - { title: t('order.myOrders.drawer.costItem'), dataIndex: 'costItem', width: 160 }, - { title: t('order.myOrders.drawer.amount'), dataIndex: 'amount', width: 100 }, + { title: t('order.myOrders.drawer.costItem'), dataIndex: 'costItem', width: 180 }, + { title: t('order.myOrders.drawer.amount'), dataIndex: 'amount', width: 110, align: 'right' as const }, { title: t('order.myOrders.drawer.currency'), dataIndex: 'currency', width: 80 }, { title: t('order.myOrders.drawer.chargingStage'), dataIndex: 'chargingStage', ellipsis: true }, ]) -const grandTotalText = computed(() => { - const totals = new Map() - for (const preview of previews.value) { - if (preview.error) continue - for (const fee of preview.fees) { - const amount = Number(fee.amount) - if (!Number.isFinite(amount)) continue - totals.set(fee.currency, (totals.get(fee.currency) ?? 0) + amount) - } +const isMulti = computed(() => previews.value.length > 1) + +const modalTitle = computed(() => + isMulti.value + ? t('order.myOrders.modal.payTitleBatch', { count: previews.value.length }) + : t('order.myOrders.modal.payTitle'), +) + +const customerIds = computed(() => [ + ...new Set(previews.value.map((p) => p.customerId).filter((id): id is number => !!id)), +]) +const isMultiCustomer = computed(() => customerIds.value.length > 1) + +function couponEntry(preview: PayOrderPreview) { + return couponPricing.entryOf(preview.id) +} + +function pricingOf(preview: PayOrderPreview) { + return couponEntry(preview)?.pricing ?? null +} + +/** 本单无法支付的原因;为空表示可支付 */ +function blockReason(preview: PayOrderPreview) { + if (preview.error) return preview.error + const entry = couponEntry(preview) + if (entry?.error) return entry.error + const pricing = entry?.pricing + if (pricing && pricing.receivableSubtotal <= 0) return t('order.myOrders.modal.payInvalidAmount') + return '' +} + +function isPaid(preview: PayOrderPreview) { + return payStatus.value[preview.key] === 'success' +} + +const payableOrders = computed(() => + previews.value.filter((p) => !isPaid(p) && !blockReason(p) && pricingOf(p)), +) +const blockedOrders = computed(() => + previews.value.filter((p) => !isPaid(p) && !!blockReason(p)), +) +const failedOrders = computed(() => + previews.value.filter((p) => payStatus.value[p.key] === 'failed'), +) + +const totals = computed(() => { + let subtotal = 0 + let payable = 0 + let saved = 0 + for (const p of payableOrders.value) { + const pricing = pricingOf(p)! + subtotal += pricing.receivableSubtotal + payable += pricing.payableAmount + saved += pricing.levelDiscountAmount + pricing.couponDiscountAmount } - return formatTotals(totals) + return { subtotal, payable, saved } }) -const canConfirm = computed(() => - previews.value.length > 0 - && !loading.value - && previews.value.every((item) => !item.error && item.totalAmount > 0), +/** 按客户汇总本次应付并与余额比较(管理员批量可能跨客户) */ +const balanceShortfalls = computed(() => { + const need = new Map() + for (const p of payableOrders.value) { + if (!p.customerId) continue + const cur = need.get(p.customerId) ?? { name: p.customerName, amount: 0 } + cur.amount += pricingOf(p)!.payableAmount + need.set(p.customerId, cur) + } + const result: { name: string; shortfall: number }[] = [] + for (const [id, { name, amount }] of need) { + const balance = balances.value[id] + if (balance == null) continue + if (amount > balance + 1e-6) result.push({ name, shortfall: amount - balance }) + } + return result +}) + +const singleBalance = computed(() => { + if (customerIds.value.length !== 1) return null + const b = balances.value[customerIds.value[0]] + return b == null ? null : b +}) + +const pricingLoading = computed(() => couponPricing.anyLoading.value) + +const canConfirm = computed( + () => + !loading.value + && !submitting.value + && !pricingLoading.value + && payableOrders.value.length > 0, ) +const confirmText = computed(() => { + if (submitting.value && progress.value.total > 1) { + return t('order.myOrders.modal.payProgress', progress.value) + } + const amount = formatPayMoney(totals.value.payable) + return failedOrders.value.length + ? t('order.myOrders.modal.payRetryFailed', { amount }) + : t('order.myOrders.modal.payConfirmAmount', { amount }) +}) + +function isCouponSelectable(preview: PayOrderPreview, coupon: OrderPayCouponOption) { + return coupon.canUse && !couponPricing.isCouponTakenByOther(preview.id, coupon.couponInstanceId) +} + +function couponOptionLabel(preview: PayOrderPreview, coupon: OrderPayCouponOption) { + const name = coupon.templateName || coupon.couponCode + const benefit = formatCouponBenefit(coupon) + const base = benefit ? `${name}(${benefit})` : name + if (couponPricing.isCouponTakenByOther(preview.id, coupon.couponInstanceId)) { + return `${base} · ${t('order.myOrders.modal.payCouponUsedByOther')}` + } + if (!coupon.canUse && coupon.unavailableReason) return `${base} · ${coupon.unavailableReason}` + return base +} + +function usableCouponCount(preview: PayOrderPreview) { + return (pricingOf(preview)?.availableCoupons ?? []).filter((c) => isCouponSelectable(preview, c)).length +} + +function hasDiscount(preview: PayOrderPreview) { + const pricing = pricingOf(preview) + return !!pricing && pricing.payableAmount < pricing.receivableSubtotal +} + +function isExpanded(preview: PayOrderPreview) { + return !isMulti.value || expandedKeys.value.has(preview.key) +} + +function toggleExpand(preview: PayOrderPreview) { + if (!isMulti.value) return + const next = new Set(expandedKeys.value) + if (next.has(preview.key)) next.delete(preview.key) + else next.add(preview.key) + expandedKeys.value = next +} + +const allExpanded = computed( + () => previews.value.length > 0 && previews.value.every((p) => expandedKeys.value.has(p.key)), +) + +function toggleExpandAll() { + expandedKeys.value = allExpanded.value ? new Set() : new Set(previews.value.map((p) => p.key)) +} + +function removeOrder(preview: PayOrderPreview) { + previews.value = previews.value.filter((p) => p.key !== preview.key) + couponPricing.removeEntry(preview.id) + if (!previews.value.length) closeModal() +} + +function handleCouponChange(preview: PayOrderPreview, value: unknown) { + void couponPricing.selectCoupon(preview.id, Number(value) || 0) +} + function pick(raw: Record | undefined, ...keys: string[]) { if (!raw) return undefined for (const k of keys) { @@ -86,19 +249,19 @@ function normalizeFeeType(row: any): 'receivable' | 'payable' | '' { } function sumReceivableFees(list: any[]) { - const totals = new Map() + const map = new Map() for (const row of list) { const currency = String(row?.currency ?? 'CNY').trim() || 'CNY' const amount = Number(row?.amount) if (!Number.isFinite(amount)) continue - totals.set(currency, (totals.get(currency) ?? 0) + amount) + map.set(currency, (map.get(currency) ?? 0) + amount) } - return totals + return map } -function formatTotals(totals: Map) { - if (!totals.size) return '--' - return [...totals.entries()] +function formatTotals(map: Map) { + if (!map.size) return '--' + return [...map.entries()] .map(([currency, amount]) => `${currency} ${amount.toFixed(2)}`) .join(' + ') } @@ -151,6 +314,58 @@ async function ensureFeeItemDictLoaded() { } } +async function loadOrderPreview(order: PayOrderInput): Promise { + const id = order.id + const key = String(id) + const fallbackWaybill = order.waybillNo || order.serviceNo || key + try { + const detail = await getOrderDetail(id) + const feesRaw = detail?.feeList ?? detail?.FeeList ?? detail?.orderFees ?? detail?.fees + const receivableFees = (Array.isArray(feesRaw) ? feesRaw : []).filter( + (row) => normalizeFeeType(row) === 'receivable', + ) + const feeTotals = sumReceivableFees(receivableFees) + const totalAmount = [...feeTotals.values()].reduce((sum, val) => sum + val, 0) + const customerId = Number(pick(detail, 'customerId', 'CustomerId')) || undefined + return { + id, + key, + waybillNo: String(pick(detail, 'waybillNo', 'WaybillNo', 'serviceNo', 'ServiceNo') || fallbackWaybill), + customerId, + customerName: String(pick(detail, 'customerName', 'CustomerName', 'customerCode', 'CustomerCode') || ''), + fees: mapFeeRows(receivableFees), + totalText: formatTotals(feeTotals), + totalAmount, + error: totalAmount <= 0 ? t('order.myOrders.modal.payInvalidAmount') : undefined, + } + } catch (e: any) { + return { + id, + key, + waybillNo: fallbackWaybill, + customerName: '', + fees: [], + totalText: '--', + totalAmount: 0, + error: e?.message || t('order.myOrders.modal.payLoadFailed'), + } + } +} + +async function loadBalances() { + const next: Record = {} + await runWithConcurrency(customerIds.value, DETAIL_CONCURRENCY, async (id) => { + try { + const data: any = await CustomerService.getById(id) + const n = Number(data?.accountBalance ?? data?.balance) + next[id] = Number.isFinite(n) ? n : null + } catch { + next[id] = null + } + }) + balances.value = next +} + async function loadPreviews() { if (!props.orders.length) { previews.value = [] @@ -159,66 +374,85 @@ async function loadPreviews() { loading.value = true previews.value = [] + payStatus.value = {} + payErrors.value = {} + expandedKeys.value = new Set() try { await ensureFeeItemDictLoaded() - const next: PayOrderPreview[] = [] - for (const order of props.orders) { - const id = order.id - const fallbackWaybill = order.waybillNo || order.serviceNo || String(id) - try { - const detail = await getOrderDetail(id) - const feesRaw = detail?.feeList ?? detail?.FeeList ?? detail?.orderFees ?? detail?.fees - const receivableFees = (Array.isArray(feesRaw) ? feesRaw : []).filter( - (row) => normalizeFeeType(row) === 'receivable', - ) - const totals = sumReceivableFees(receivableFees) - const totalAmount = [...totals.values()].reduce((sum, val) => sum + val, 0) - const waybillNo = pick(detail, 'waybillNo', 'WaybillNo', 'serviceNo', 'ServiceNo') || fallbackWaybill - next.push({ - id, - waybillNo: String(waybillNo), - fees: mapFeeRows(receivableFees), - totalText: formatTotals(totals), - totalAmount, - error: totalAmount <= 0 ? t('order.myOrders.modal.payInvalidAmount') : undefined, - }) - } catch (e: any) { - next.push({ - id, - waybillNo: fallbackWaybill, - fees: [], - totalText: '--', - totalAmount: 0, - error: e?.message || t('order.myOrders.modal.payLoadFailed'), - }) - } - } + const next: PayOrderPreview[] = new Array(props.orders.length) + await runWithConcurrency(props.orders, DETAIL_CONCURRENCY, async (order, index) => { + next[index] = await loadOrderPreview(order) + }) previews.value = next + await Promise.all([ + couponPricing.loadAll(next.filter((x) => !x.error).map((x) => x.id)), + loadBalances(), + ]) } finally { loading.value = false } } function closeModal() { + if (submitting.value) return emit('update:visible', false) } async function handleConfirm() { - if (!canConfirm.value || submitting.value) return + if (!canConfirm.value) return + const queue = [...payableOrders.value] submitting.value = true + progress.value = { done: 0, total: queue.length } + let successCount = 0 + let lastError: unknown = null try { - for (const preview of previews.value) { - await postOrderPay({ id: preview.id }) + for (const preview of queue) { + payStatus.value = { ...payStatus.value, [preview.key]: 'paying' } + try { + await postOrderPay({ + id: preview.id, + couponInstanceId: couponEntry(preview)?.selectedCouponId ?? 0, + }) + payStatus.value = { ...payStatus.value, [preview.key]: 'success' } + const restErrors = { ...payErrors.value } + delete restErrors[preview.key] + payErrors.value = restErrors + successCount += 1 + } catch (e: any) { + lastError = e + payStatus.value = { ...payStatus.value, [preview.key]: 'failed' } + payErrors.value = { + ...payErrors.value, + [preview.key]: e?.message || t('order.myOrders.modal.payFailed'), + } + } + progress.value = { ...progress.value, done: progress.value.done + 1 } } - Message.success(t('order.myOrders.modal.paySuccess')) - emit('success') - closeModal() - } catch (e: any) { - Message.error(e?.message || t('order.myOrders.modal.payFailed')) - emit('fail', e) } finally { submitting.value = false } + + const failedCount = queue.length - successCount + if (successCount > 0) emit('success') + if (!failedCount) { + Message.success( + queue.length > 1 + ? t('order.myOrders.modal.payBatchSuccess', { count: successCount }) + : t('order.myOrders.modal.paySuccess'), + ) + emit('update:visible', false) + return + } + + if (queue.length === 1) { + Message.error(payErrors.value[queue[0].key] || t('order.myOrders.modal.payFailed')) + } else { + Message.warning( + t('order.myOrders.modal.payPartialResult', { success: successCount, failed: failedCount }), + ) + } + emit('fail', lastError) + if (successCount > 0) void loadBalances() } watch( @@ -229,6 +463,7 @@ watch( } else { previews.value = [] submitting.value = false + couponPricing.reset() } }, { immediate: true }, @@ -238,133 +473,501 @@ watch( diff --git a/src/views/order/myOrders/index.vue b/src/views/order/myOrders/index.vue index ce50005..7b0aa23 100644 --- a/src/views/order/myOrders/index.vue +++ b/src/views/order/myOrders/index.vue @@ -1109,6 +1109,10 @@ // 操作按钮处理(依赖表格多选行;导出按当前 tab 条件,无需选行) function openPayConfirm(rows: any[]) { + if (rows.some((row) => resolveOrderStatusCode(row) !== 'pendingPayment')) { + Message.warning(t('order.myOrders.modal.payPendingOnly')); + return; + } payConfirmOrders.value = rows .filter((row) => getOrderRowId(row) != null) .map((row) => ({ diff --git a/src/views/order/placeOrder/declarationColumnConfig.ts b/src/views/order/placeOrder/declarationColumnConfig.ts index 5d078e9..eac5a9b 100644 --- a/src/views/order/placeOrder/declarationColumnConfig.ts +++ b/src/views/order/placeOrder/declarationColumnConfig.ts @@ -20,6 +20,9 @@ export const PLACE_ORDER_DECLARATION_COLUMN_CATALOG: OrderListColumnDef[] = [ { key: 'imHsCode', label: '进口HsCode' }, { key: 'exHsCode', label: '出口HsCode' }, { key: 'declareElement', label: '出口申报要素' }, + { key: 'productId', label: '产品标识编码' }, + { key: 'nonStdProductId', label: '非标产品标识编码' }, + { key: 'stdProductId', label: '标准产品标识编码' }, { key: 'skuImage', label: '图片' }, { key: 'category', label: '类别' }, { key: 'brand', label: '品牌' }, diff --git a/src/views/order/placeOrder/index.vue b/src/views/order/placeOrder/index.vue index 398a9d8..ac7ddf5 100644 --- a/src/views/order/placeOrder/index.vue +++ b/src/views/order/placeOrder/index.vue @@ -540,6 +540,9 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu { key: 'items[0].imHsCode', desc: '进口HsCode', example: '' }, { key: 'items[0].exHsCode', desc: '出口HsCode', example: '' }, { key: 'items[0].declareElement', desc: '出口申报要素', example: '' }, + { key: 'items[0].productId', desc: '产品标识编码', example: '' }, + { key: 'items[0].nonStdProductId', desc: '非标产品标识编码', example: '' }, + { key: 'items[0].stdProductId', desc: '标准产品标识编码', example: '' }, { key: 'items[0].skuImage', desc: '商品图片URL', example: '' }, { key: 'items[0].category', desc: '类别', example: '' }, { key: 'items[0].brand', desc: '品牌', example: '' }, @@ -668,6 +671,9 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu 出口海关编码: 'items[0].exHsCode', 出口申报要素: 'items[0].declareElement', 申报要素: 'items[0].declareElement', + 产品标识编码: 'items[0].productId', + 非标产品标识编码: 'items[0].nonStdProductId', + 标准产品标识编码: 'items[0].stdProductId', 商品图片URL: 'items[0].skuImage', 图片: 'items[0].skuImage', 类别: 'items[0].category', @@ -733,6 +739,9 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu 'items[0].imHsCode', 'items[0].exHsCode', 'items[0].declareElement', + 'items[0].productId', + 'items[0].nonStdProductId', + 'items[0].stdProductId', 'items[0].skuImage', 'items[0].category', 'items[0].brand', @@ -2148,6 +2157,9 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu imHsCode: '', exHsCode: '', declareElement: '', + productId: '', + nonStdProductId: '', + stdProductId: '', skuImage: '', category: '', brand: '', @@ -2426,6 +2438,9 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu { key: 'imHsCode', label: '进口HsCode' }, { key: 'exHsCode', label: '出口HsCode' }, { key: 'declareElement', label: '出口申报要素' }, + { key: 'productId', label: '产品标识编码' }, + { key: 'nonStdProductId', label: '非标产品标识编码' }, + { key: 'stdProductId', label: '标准产品标识编码' }, { key: 'skuImage', label: '图片' }, { key: 'category', label: '类别' }, { key: 'brand', label: '品牌' }, @@ -3194,6 +3209,9 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu imHsCode: '', exHsCode: '', declareElement: '', + productId: '', + nonStdProductId: '', + stdProductId: '', skuImage: '', category: '', brand: '', @@ -3353,6 +3371,9 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu imHsCode: '', exHsCode: '', declareElement: '', + productId: '', + nonStdProductId: '', + stdProductId: '', skuImage: '', category: 'Accessories', brand: 'DemoBrand', @@ -3426,6 +3447,9 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu imHsCode: '', exHsCode: '', declareElement: '', + productId: '', + nonStdProductId: '', + stdProductId: '', skuImage: '', category: '', brand: '', diff --git a/src/views/order/platformStoreOrder/CreateLogisticsModal.vue b/src/views/order/platformStoreOrder/CreateLogisticsModal.vue index 2280690..e659513 100644 --- a/src/views/order/platformStoreOrder/CreateLogisticsModal.vue +++ b/src/views/order/platformStoreOrder/CreateLogisticsModal.vue @@ -40,6 +40,8 @@ export type BindDraftItem = { type OrderDraft = { storeOrderId: number + customerId?: number + warehouseId?: number platformOrderNo: string orderCountry: string recipientName: string @@ -55,7 +57,6 @@ type OrderDraft = { productName: string quantity: number unitPrice: string - isShipExcluded: boolean }> receiverMissing: boolean linesMissing: boolean @@ -117,9 +118,7 @@ const title = computed(() => { const activeDraft = computed(() => drafts.value.find((d) => d.storeOrderId === activeId.value)) -const activeShipLines = computed(() => - (activeDraft.value?.lines || []).filter((x) => !x.isShipExcluded), -) +const activeShipLines = computed(() => activeDraft.value?.lines || []) const orderQtyTotal = computed(() => activeShipLines.value.reduce((sum, x) => sum + (Number(x.quantity) || 0), 0), @@ -151,7 +150,7 @@ const readyCount = computed(() => { }) function draftOrderQty(d: OrderDraft) { - return d.lines.filter((x) => !x.isShipExcluded).reduce((s, x) => s + (Number(x.quantity) || 0), 0) + return d.lines.reduce((s, x) => s + (Number(x.quantity) || 0), 0) } function draftBindQty(d: OrderDraft) { @@ -204,12 +203,15 @@ function mapDetail(raw: Record): OrderDraft { productName: String(pickField(r, 'productName', 'ProductName') ?? '-'), quantity: Number(pickField(r, 'quantity', 'Quantity') ?? 0), unitPrice: String(pickField(r, 'unitPrice', 'UnitPrice') ?? '-'), - isShipExcluded: Boolean(pickField(r, 'isShipExcluded', 'IsShipExcluded')), } }) - const shipLines = lines.filter((x) => !x.isShipExcluded) + const shipLines = lines + const customerIdRaw = Number(pickField(row, 'customerId', 'CustomerId') ?? 0) + const warehouseIdRaw = Number(pickField(row, 'warehouseId', 'WarehouseId') ?? 0) return { storeOrderId: Number(pickField(row, 'id', 'Id')), + customerId: Number.isFinite(customerIdRaw) && customerIdRaw > 0 ? customerIdRaw : undefined, + warehouseId: Number.isFinite(warehouseIdRaw) && warehouseIdRaw > 0 ? warehouseIdRaw : undefined, platformOrderNo: String(pickField(row, 'platformOrderNo', 'PlatformOrderNo') ?? ''), orderCountry: String(pickField(row, 'orderCountry', 'OrderCountry') ?? ''), recipientName, @@ -227,6 +229,16 @@ function mapDetail(raw: Record): OrderDraft { } } +/** 草稿模式 orderId=0,必须带上店铺订单所属客户,才能查到该客户库存(对齐运单绑定用订单客户)。 */ +function resolveCandidatesCustomerId(): number | undefined { + const fromActive = activeDraft.value?.customerId + if (fromActive && fromActive > 0) return fromActive + const fromDrafts = drafts.value.find((d) => d.customerId && d.customerId > 0)?.customerId + if (fromDrafts && fromDrafts > 0) return fromDrafts + const fromProp = Number(props.customerId ?? 0) + return Number.isFinite(fromProp) && fromProp > 0 ? fromProp : undefined +} + async function loadDrafts() { loading.value = true drafts.value = [] @@ -239,6 +251,11 @@ async function loadDrafts() { } drafts.value = list activeId.value = list[0]?.storeOrderId + // 优先用店铺订单已绑定仓库,避免默认仓与库存仓不一致导致空列表 + const orderWarehouseId = list.find((d) => d.warehouseId)?.warehouseId + if (orderWarehouseId && props.warehouseOptions.some((w) => w.value === orderWarehouseId)) { + warehouseId.value = orderWarehouseId + } } catch (e: any) { $baseMessage(e?.message || t('order.platformStoreOrder.message.detailFailed'), 'error') close() @@ -253,12 +270,13 @@ async function loadCandidates() { parcelCandidates.value = [] return } + const customerId = resolveCandidatesCustomerId() candidatesLoading.value = true try { const body = { orderId: 0, warehouseId: warehouseId.value, - customerId: props.customerId, + customerId, keyword: keyword.value || undefined, page: 1, pageSize: 50, @@ -270,9 +288,10 @@ async function loadCandidates() { const res = await postOrderStockBindParcelCandidates({ ...body, lifecycleStatus: 'all' }) parcelCandidates.value = res.items || [] } - } catch { + } catch (e: any) { inventoryCandidates.value = [] parcelCandidates.value = [] + $baseMessage(e?.message || t('order.myOrders.stockBind.loadFailed'), 'error') } finally { candidatesLoading.value = false } @@ -285,6 +304,7 @@ function scheduleSearch() { function selectOrder(id: number) { activeId.value = id + if (isFulfill.value) void loadCandidates() } function bindKey(item: { @@ -604,6 +624,7 @@ async function handleSubmit(): Promise { } } +/** v-if 挂载时 visible 已是 true,必须 immediate,否则不会拉草稿/库存(对齐 PullOrdersModal / StockBindModal) */ watch( () => props.visible, async (open) => { @@ -618,6 +639,7 @@ watch( await loadDrafts() if (isFulfill.value) await loadCandidates() }, + { immediate: true }, ) watch(warehouseId, () => { diff --git a/src/views/order/platformStoreOrder/StoreOrderDetailDrawer.vue b/src/views/order/platformStoreOrder/StoreOrderDetailDrawer.vue index bcca209..38504f6 100644 --- a/src/views/order/platformStoreOrder/StoreOrderDetailDrawer.vue +++ b/src/views/order/platformStoreOrder/StoreOrderDetailDrawer.vue @@ -47,7 +47,6 @@ const display = computed(() => { const linesRaw = (pickField(row, 'lines', 'Lines') as any[]) || [] const lines = linesRaw.map((line) => { const r = normalizeListRow(line as Record) - const isShipExcluded = Boolean(pickField(r, 'isShipExcluded', 'IsShipExcluded')) return { id: pickField(r, 'id', 'Id'), platformSku: pickField(r, 'platformSku', 'PlatformSku') || '-', @@ -57,8 +56,6 @@ const display = computed(() => { unitPriceDisplay: formatMoney(pickField(r, 'unitPrice', 'UnitPrice'), pickField(r, 'currency', 'Currency') || currency), quantity: pickField(r, 'quantity', 'Quantity') ?? 0, mappedSkuCode: pickField(r, 'mappedSkuCode', 'MappedSkuCode') || '-', - isShipExcluded, - excludedLabel: isShipExcluded ? t('warehouse.common.yes') : t('warehouse.common.no'), } }) @@ -75,12 +72,12 @@ const display = computed(() => { platformOrderTime: formatDateTime(pickField(row, 'platformOrderTime', 'PlatformOrderTime')), lastSyncTime: formatDateTime(pickField(row, 'lastSyncTime', 'LastSyncTime')), platformStatusLabel: pickField(row, 'platformStatusLabel', 'PlatformStatusLabel') || '-', - customTags: pickField(row, 'customTags', 'CustomTags') || '-', + shippingProvider: pickField(row, 'shippingProvider', 'ShippingProvider') || '-', + fulfillmentType: pickField(row, 'fulfillmentType', 'FulfillmentType') || '-', customerName: pickField(row, 'customerName', 'CustomerName') || '-', customerCode: pickField(row, 'customerCode', 'CustomerCode') || '-', shippingLabelNo: pickField(row, 'shippingLabelNo', 'ShippingLabelNo') || '-', shippingLabelUrl: labelUrl, - isMultiLabel: Boolean(pickField(row, 'isMultiLabel', 'IsMultiLabel')), warehouseName: resolveWarehouseLabel(warehouseId, props.warehouseNameMap || new Map()), linkedServiceNo: pickField(row, 'linkedServiceNo', 'LinkedServiceNo') || '-', linkedOrderId: pickField(row, 'linkedOrderId', 'LinkedOrderId'), @@ -110,7 +107,7 @@ const basicFields = computed(() => { { label: t('order.platformStoreOrder.table.itemCount'), value: String(d.itemCount ?? '-') }, { label: t('order.platformStoreOrder.table.orderTime'), value: d.platformOrderTime }, { label: t('order.platformStoreOrder.drawer.platformStatus'), value: d.platformStatusLabel }, - { label: t('order.platformStoreOrder.drawer.customTags'), value: d.customTags }, + { label: t('order.platformStoreOrder.drawer.fulfillmentType'), value: d.fulfillmentType }, { label: t('order.myOrders.table.customerName'), value: d.customerName }, { label: t('order.myOrders.table.customerCode'), value: d.customerCode }, ] @@ -140,7 +137,7 @@ const shippingFields = computed(() => { return [ { label: t('order.platformStoreOrder.table.warehouse'), value: d.warehouseName }, { label: t('order.platformStoreOrder.table.shippingLabel'), value: d.shippingLabelNo }, - { label: t('order.platformStoreOrder.drawer.multiLabel'), value: d.isMultiLabel ? t('warehouse.common.yes') : t('warehouse.common.no') }, + { label: t('order.platformStoreOrder.drawer.shippingProvider'), value: d.shippingProvider }, { label: t('order.platformStoreOrder.table.linkedServiceNo'), value: d.linkedServiceNo }, { label: t('order.platformStoreOrder.table.lastSync'), value: d.lastSyncTime }, ] @@ -154,7 +151,6 @@ const linesHotColumns = computed(() => [ { key: 'unitPriceDisplay', label: t('order.platformStoreOrder.line.price'), width: 100 }, { key: 'quantity', label: t('order.platformStoreOrder.line.qty'), width: 70 }, { key: 'mappedSkuCode', label: t('order.platformStoreOrder.line.mappedSku'), width: 110 }, - { key: 'excludedLabel', label: t('order.platformStoreOrder.line.excluded'), width: 80 }, ]) function linesCellRenderer(_row: number, col: number, _value: any, rowData: any): string | null { @@ -200,11 +196,6 @@ function handleCancel() { detail.value = null } -function openLabelUrl() { - const url = display.value?.shippingLabelUrl - if (url) window.open(url, '_blank') -} - defineExpose({ open, close: handleCancel }) @@ -213,13 +204,6 @@ defineExpose({ open, close: handleCancel }) diff --git a/src/views/order/platformStoreOrder/index.vue b/src/views/order/platformStoreOrder/index.vue index a395695..5d274a7 100644 --- a/src/views/order/platformStoreOrder/index.vue +++ b/src/views/order/platformStoreOrder/index.vue @@ -10,7 +10,6 @@ import { useTableContainerHeight } from '@/composables/useTableContainerHeight' import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer' import { getSalesProductList } from '@/api/channel/salesProduct' import { - batchMatchPlatformStoreOrderInventory, deletePlatformStoreOrder, fetchPlatformStoreOrderLabel, getPlatformStoreOrderList, @@ -23,6 +22,7 @@ import { import { getWarehouseList } from '@/api/system/warehouse' import { $baseMessage } from '@/utils' import { hasPermission } from '@/utils/rbac-permission' +import { runWithConcurrency } from '@/utils/runWithConcurrency' import { buildWarehouseNameMap, formatDateTime, @@ -38,6 +38,7 @@ import { WMS_PRODUCT_GROUP, type CreateLogisticsMode } from './constants' const PullOrdersModal = defineAsyncComponent(() => import('./PullOrdersModal.vue')) const StoreOrderDetailDrawer = defineAsyncComponent(() => import('./StoreOrderDetailDrawer.vue')) const CreateLogisticsModal = defineAsyncComponent(() => import('./CreateLogisticsModal.vue')) +const OrderPrintModal = defineAsyncComponent(() => import('@/components/PrintTemplate/OrderPrintModal.vue')) defineOptions({ name: 'PlatformStoreOrder' }) @@ -95,7 +96,12 @@ const currentPage = ref(1) const pageSize = ref(50) const totalCount = ref(0) const pullVisible = ref(false) -const actionLoading = ref(false) +type StoreOrderBatchAction = 'sync' | 'rts' | 'label' | 'suspend' | 'resume' +/** 正在执行的批量动作:只有对应按钮转圈,其余批量按钮仅禁用 */ +const runningAction = ref(null) +const actionLoading = computed(() => runningAction.value !== null) +const labelProgress = ref({ done: 0, total: 0 }) +const LABEL_FETCH_CONCURRENCY = 3 const matchWarehouseId = ref() const matchProductCode = ref() const detailDrawerMounted = ref(false) @@ -115,6 +121,7 @@ async function ensureStoreDetailDrawer() { } const createModalMode = ref('logistics') const createModalIds = ref([]) +const createModalCustomerId = ref() const platformTabs = computed(() => [ { value: PLATFORM_TAB_ALL, label: t('common.all') }, @@ -161,10 +168,13 @@ const hotColumns = computed(() => { Boolean(row._suspended), ), }, + { key: 'platformStatusLabel', label: t('order.platformStoreOrder.table.platformStatus'), width: 140 }, + { key: 'fulfillmentType', label: t('order.platformStoreOrder.table.fulfillmentType'), width: 160 }, + { key: 'shippingProvider', label: t('order.platformStoreOrder.table.shippingProvider'), width: 120 }, + { key: 'shippingLabelNo', label: t('order.platformStoreOrder.table.shippingLabel'), width: 140 }, { key: 'itemCount', label: t('order.platformStoreOrder.table.itemCount'), width: 70 }, { key: 'totalAmountDisplay', label: t('order.platformStoreOrder.table.amount'), width: 110 }, { key: 'linkedServiceNo', label: t('order.platformStoreOrder.table.linkedServiceNo'), width: 150 }, - { key: 'shippingLabelNo', label: t('order.platformStoreOrder.table.shippingLabel'), width: 140 }, { key: 'warehouseName', label: t('order.platformStoreOrder.table.warehouse'), width: 120 }, { key: 'platformOrderTime', label: t('order.platformStoreOrder.table.orderTime'), width: 160 }, { key: 'lastSyncTime', label: t('order.platformStoreOrder.table.lastSync'), width: 160 }, @@ -203,10 +213,14 @@ function mapRow(raw: Record) { orderCountry: pickField(row, 'orderCountry', 'OrderCountry') ?? '-', recipientName: pickField(row, 'recipientName', 'RecipientName') || '-', fulfillStatus: statusLabel, + platformStatusLabel: pickField(row, 'platformStatusLabel', 'PlatformStatusLabel') || '-', + fulfillmentType: pickField(row, 'fulfillmentType', 'FulfillmentType') || '-', + shippingProvider: pickField(row, 'shippingProvider', 'ShippingProvider') || '-', itemCount: pickField(row, 'itemCount', 'ItemCount') ?? '-', totalAmountDisplay: formatMoney(amount, currency), linkedServiceNo: pickField(row, 'linkedServiceNo', 'LinkedServiceNo') || '-', shippingLabelNo: pickField(row, 'shippingLabelNo', 'ShippingLabelNo') ?? '-', + shippingLabelUrl: String(pickField(row, 'shippingLabelUrl', 'ShippingLabelUrl') ?? ''), warehouseName: resolveWarehouseLabel(whId, warehouseNameMap.value), platformOrderTime: formatDateTime(pickField(row, 'platformOrderTime', 'PlatformOrderTime')), lastSyncTime: formatDateTime(pickField(row, 'lastSyncTime', 'LastSyncTime')), @@ -399,51 +413,6 @@ async function handleMatch(row: any) { } } -async function handleBatchMatch() { - if (!hasPermission('Order.PlatformStoreOrder.MatchInventory')) return - if (!matchWarehouseId.value) { - $baseMessage(t('order.platformStoreOrder.message.warehouseRequired'), 'warning') - return - } - const customerId = mutationCustomerId() - if (isAdmin.value && customerId == null) return - - const pendingIds = tableRows.value - .filter((row) => row._status === 'pending' && !pickField(row._raw, 'linkedOrderId', 'LinkedOrderId')) - .map((row) => Number(row.id)) - - if (!pendingIds.length) { - $baseMessage(t('order.platformStoreOrder.message.batchEmpty'), 'warning') - return - } - - Modal.confirm({ - title: t('order.platformStoreOrder.actions.batchMatch'), - content: t('order.platformStoreOrder.message.batchConfirm', { count: pendingIds.length }), - onOk: async () => { - try { - const result = await batchMatchPlatformStoreOrderInventory({ - ids: pendingIds, - warehouseId: matchWarehouseId.value, - productCode: matchProductCode.value || undefined, - customerId, - }) - $baseMessage( - t('order.platformStoreOrder.message.batchResult', { - success: result?.successCount ?? 0, - fail: result?.failCount ?? 0, - }), - (result?.failCount ?? 0) > 0 ? 'warning' : 'success', - ) - await loadSummary() - await loadList() - } catch (e: any) { - $baseMessage(e?.message || t('order.platformStoreOrder.message.matchFailed'), 'error') - } - }, - }) -} - function resolveCreatableSelectedIds(): number[] | null { const selected = selectedRows.value if (!selected.length) { @@ -498,6 +467,8 @@ function openCreateModal(mode: CreateLogisticsMode) { } createModalMode.value = mode createModalIds.value = ids + // 管理端显式传入客户,避免草稿查库存(orderId=0)丢客户上下文 + createModalCustomerId.value = customerId ?? withCustomerId({}).customerId createModalVisible.value = true } @@ -519,7 +490,7 @@ function handleSyncStatus() { title: t('order.platformStoreOrder.actions.syncStatus'), content: t('order.platformStoreOrder.message.syncConfirm', { count: ids.length }), onOk: async () => { - actionLoading.value = true + runningAction.value = 'sync' try { const result = await syncPlatformStoreOrderStatus(ids, customerId) $baseMessage( @@ -535,7 +506,7 @@ function handleSyncStatus() { } catch (e: any) { $baseMessage(e?.message || t('order.platformStoreOrder.message.actionFailed'), 'error') } finally { - actionLoading.value = false + runningAction.value = null } }, }) @@ -553,7 +524,7 @@ function handleReadyToShip(ids?: number[]) { title: t('order.platformStoreOrder.actions.readyToShip'), content: t('order.platformStoreOrder.message.rtsConfirm', { count: targetIds.length }), onOk: async () => { - actionLoading.value = true + runningAction.value = 'rts' try { const result = await readyToShipPlatformStoreOrder(targetIds, customerId) $baseMessage( @@ -569,54 +540,145 @@ function handleReadyToShip(ids?: number[]) { } catch (e: any) { $baseMessage(e?.message || t('order.platformStoreOrder.message.actionFailed'), 'error') } finally { - actionLoading.value = false + runningAction.value = null } }, }) } -function handleFetchLabel(row: any) { +function handleFetchLabel() { if (!hasPermission('Order.PlatformStoreOrder.Label')) return - const id = Number(row.id ?? pickField(row._raw || row, 'id', 'Id')) - if (!id) return + const ids = getSelectedIds() + if (!ids.length) { + $baseMessage(t('order.platformStoreOrder.message.selectOrders'), 'warning') + return + } const customerId = withCustomerId({}).customerId Modal.confirm({ title: t('order.platformStoreOrder.actions.fetchLabel'), - content: t('order.platformStoreOrder.message.labelConfirm'), - onOk: async () => { - actionLoading.value = true + content: t('order.platformStoreOrder.message.labelConfirmBatch', { count: ids.length }), + onOk: () => { + void runFetchLabels(ids, customerId) + }, + }) +} + +/** 逐单拉面单(限并发),确认框立即关闭,进度显示在按钮上 */ +async function runFetchLabels(ids: number[], customerId?: number) { + runningAction.value = 'label' + labelProgress.value = { done: 0, total: ids.length } + let success = 0 + let fail = 0 + try { + await runWithConcurrency(ids, LABEL_FETCH_CONCURRENCY, async (id) => { try { const result = await fetchPlatformStoreOrderLabel(id, customerId) - if (result?.success === false) { - $baseMessage(result?.message || t('order.platformStoreOrder.message.labelFailed'), 'error') - return + if (result?.success === false) fail += 1 + else success += 1 + } catch { + fail += 1 + } finally { + labelProgress.value = { ...labelProgress.value, done: labelProgress.value.done + 1 } + } + }) + $baseMessage( + t('order.platformStoreOrder.message.labelResult', { success, fail }), + fail > 0 ? 'warning' : 'success', + ) + await loadList() + } finally { + runningAction.value = null + } +} + +function handleSuspendBatch(suspend: boolean) { + if (!hasPermission('Order.PlatformStoreOrder.ToggleSuspend')) return + const customerId = mutationCustomerId() + if (isAdmin.value && customerId == null) return + const ids = getSelectedIds() + if (!ids.length) { + $baseMessage(t('order.platformStoreOrder.message.selectOrders'), 'warning') + return + } + Modal.confirm({ + title: suspend + ? t('order.platformStoreOrder.actions.suspend') + : t('order.platformStoreOrder.actions.resume'), + content: suspend + ? t('order.platformStoreOrder.message.suspendConfirm', { count: ids.length }) + : t('order.platformStoreOrder.message.resumeConfirm', { count: ids.length }), + onOk: async () => { + runningAction.value = suspend ? 'suspend' : 'resume' + try { + for (const id of ids) { + await togglePlatformStoreOrderSuspend({ + id, + isSuspended: suspend, + customerId, + }) } - $baseMessage(result?.message || t('order.platformStoreOrder.message.labelSuccess'), 'success') - const url = result?.shippingLabelUrl || result?.ShippingLabelUrl - if (url) window.open(String(url), '_blank') + $baseMessage(t('order.platformStoreOrder.message.suspendSuccess'), 'success') + await loadSummary() await loadList() } catch (e: any) { - $baseMessage(e?.message || t('order.platformStoreOrder.message.labelFailed'), 'error') + $baseMessage(e?.message || t('order.platformStoreOrder.message.actionFailed'), 'error') } finally { - actionLoading.value = false + runningAction.value = null } }, }) } -async function handleSuspend(row: any) { - if (!hasPermission('Order.PlatformStoreOrder.ToggleSuspend')) return - const customerId = mutationCustomerId() - if (isAdmin.value && customerId == null) return +function isUsableLabelUrl(raw: unknown) { + const value = String(raw ?? '').trim() + if (!value) return false + if (value.includes('获取中')) return false + return /^https?:\/\//i.test(value) +} + +const singlePrintVisible = ref(false) +const singlePrintPreparing = ref(false) +const singlePrintItems = ref>([]) + +async function handleRowPrint(row: any) { const raw = row._raw || row - await togglePlatformStoreOrderSuspend({ - id: Number(pickField(raw, 'id', 'Id')), - isSuspended: !Boolean(pickField(raw, 'isSuspended', 'IsSuspended')), - customerId, - }) - $baseMessage(t('order.platformStoreOrder.message.suspendSuccess'), 'success') - await loadSummary() - await loadList() + const id = Number(row.id ?? pickField(raw, 'id', 'Id')) + if (!id) return + const label = String(row.platformOrderNo || pickField(raw, 'platformOrderNo', 'PlatformOrderNo') || id) + let url = String(row.shippingLabelUrl || pickField(raw, 'shippingLabelUrl', 'ShippingLabelUrl') || '') + + if (isUsableLabelUrl(url)) { + singlePrintVisible.value = true + singlePrintPreparing.value = false + singlePrintItems.value = [{ id, label, url }] + return + } + + if (!hasPermission('Order.PlatformStoreOrder.Label')) { + $baseMessage(t('order.platformStoreOrder.message.printNoLabel'), 'warning') + return + } + + singlePrintVisible.value = true + singlePrintPreparing.value = true + singlePrintItems.value = [{ id, label, url: '' }] + try { + const customerId = withCustomerId({}).customerId + const result = await fetchPlatformStoreOrderLabel(id, customerId) + url = String(result?.shippingLabelUrl || result?.ShippingLabelUrl || '') + singlePrintPreparing.value = false + if (!isUsableLabelUrl(url)) { + $baseMessage(t('order.platformStoreOrder.message.printNoLabel'), 'warning') + singlePrintVisible.value = false + return + } + singlePrintItems.value = [{ id, label, url }] + await loadList() + } catch (e: any) { + singlePrintPreparing.value = false + singlePrintVisible.value = false + $baseMessage(e?.message || t('order.platformStoreOrder.message.labelFailed'), 'error') + } } function handleDelete(row: any) { @@ -635,12 +697,6 @@ function handleDelete(row: any) { }) } -function canRts(row: any) { - if (row._suspended) return false - if (row._platformCode === 'temu') return false - return row._status === 'pending' || row._status === 'toShip' -} - function actionRenderer(row: any) { const parts = [ renderWarehouseActionBtn('detail', t('warehouse.common.detail'), 'view'), @@ -653,19 +709,7 @@ function actionRenderer(row: any) { ) { parts.push(renderWarehouseActionBtn('match', t('order.platformStoreOrder.actions.match'), 'match')) } - if (hasPermission('Order.PlatformStoreOrder.Rts') && canRts(row)) { - parts.push(renderWarehouseActionBtn('rts', t('order.platformStoreOrder.actions.readyToShip'), 'ship')) - } - if (hasPermission('Order.PlatformStoreOrder.Label') && !row._suspended) { - parts.push(renderWarehouseActionBtn('label', t('order.platformStoreOrder.actions.fetchLabel'), 'label')) - } - if (hasPermission('Order.PlatformStoreOrder.ToggleSuspend')) { - parts.push( - row._suspended - ? renderWarehouseActionBtn('suspend', t('order.platformStoreOrder.actions.resume'), 'resume') - : renderWarehouseActionBtn('suspend', t('order.platformStoreOrder.actions.suspend'), 'suspend'), - ) - } + parts.push(renderWarehouseActionBtn('print', t('order.myOrders.actions.print'), 'print')) if (hasPermission('Order.PlatformStoreOrder.Delete')) { parts.push(renderWarehouseActionBtn('delete', t('warehouse.common.delete'))) } @@ -675,15 +719,7 @@ function actionRenderer(row: any) { function handleActionClick(row: any, _i: number, action?: string) { if (action === 'detail') showDetail(row) if (action === 'match') handleMatch(row) - if (action === 'rts') { - if (row._platformCode === 'temu') { - $baseMessage(t('order.platformStoreOrder.message.temuNoRts'), 'warning') - return - } - handleReadyToShip([Number(row.id)]) - } - if (action === 'label') handleFetchLabel(row) - if (action === 'suspend') handleSuspend(row) + if (action === 'print') void handleRowPrint(row) if (action === 'delete') handleDelete(row) } @@ -757,18 +793,10 @@ watch( {{ t('order.platformStoreOrder.actions.pullOrders') }} - - {{ t('order.platformStoreOrder.actions.batchMatch') }} - @@ -777,7 +805,7 @@ watch( @@ -786,12 +814,42 @@ watch( {{ t('order.platformStoreOrder.actions.readyToShip') }} + + + + + + {{ t('order.platformStoreOrder.actions.suspend') }} + + + {{ t('order.platformStoreOrder.actions.resume') }} + + + diff --git a/src/views/order/waybillManage/components/AddOrderFeeModal.vue b/src/views/order/waybillManage/components/AddOrderFeeModal.vue index 3285242..b461c85 100644 --- a/src/views/order/waybillManage/components/AddOrderFeeModal.vue +++ b/src/views/order/waybillManage/components/AddOrderFeeModal.vue @@ -7,6 +7,7 @@ import ModalPro from '@/components/ModalPro/index.vue' import useModal from '@/components/ModalPro/useModal' import { getDictListAll, type DictDto } from '@/api/system/dict' import { postOrderFeeAdd } from '@/api/order' +import { isDiscountCostItem } from '@/constants/feeCostItems' import dayjs from 'dayjs' const props = defineProps<{ @@ -63,7 +64,7 @@ async function ensureDictOptions() { getDictListAll({ dictType: 'fee_item' }), getDictListAll({ dictType: 'currency' }), ]) - feeItemOptions.value = mapDictOptions(feeItems) + feeItemOptions.value = mapDictOptions(feeItems).filter((item) => !isDiscountCostItem(item.value)) currencyOptions.value = mapDictOptions(currencies) if (!currencyOptions.value.some((item) => item.value === formState.currency)) { formState.currency = currencyOptions.value[0]?.value || 'CNY' diff --git a/src/views/order/waybillManage/components/OrderDetailDrawer.vue b/src/views/order/waybillManage/components/OrderDetailDrawer.vue index a2f1b24..f6526aa 100644 --- a/src/views/order/waybillManage/components/OrderDetailDrawer.vue +++ b/src/views/order/waybillManage/components/OrderDetailDrawer.vue @@ -458,6 +458,9 @@ {{ $t('order.myOrders.drawer.fee.itemCount', { count: receivableFeeList.length }) }} + + {{ $t('order.myOrders.drawer.fee.includesDiscount', { amount: formatFeeTotals(receivableDiscountTotals) }) }} +