Compare commits

...

3 Commits

Author SHA1 Message Date
zzh
7f49b26ac7 Merge pull request '1.06' (#8) from test into master
All checks were successful
Build and Deploy Front / deploy (push) Successful in 1m48s
Reviewed-on: #8
2026-10-19 17:04:22 +08:00
zzh
b178a2a35d feat: 支付选券、优惠入应收展示、申报产品标识编码、创建9610订单等
All checks were successful
Build and Deploy Front / deploy (push) Successful in 5m56s
- 支付弹窗(PC/H5)支持选券与批量支付体验优化,运单管理复用我的订单支付弹窗;立即支付仅允许待支付订单
- 订单详情费用明细展示优惠券抵扣/会员折扣负数行与「含优惠」;价格配置与手工加费过滤抵扣费用项
- 申报明细新增产品标识编码/非标产品标识编码/标准产品标识编码(PC/H5 下单、改单、详情、开放接口文档、公式目录、Excel 模板)
- 9610 页「批量申报」改为「创建9610订单」:选运单并补充出口资质备案、电商平台
- 店铺订单批量按钮按动作独立 loading,获取面单限并发并显示进度
- 另含:国家管理、登录门户切换、监控与工作台、公告弹窗、nginx/vite 配置等改动
2026-10-19 16:34:06 +08:00
zzh
108083d332 fix: 店铺拉单弹窗按 TikTok 状态筛选,并避免表格刷新打断弹窗
All checks were successful
Build and Deploy Front / deploy (push) Successful in 6m16s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-16 15:17:47 +08:00
71 changed files with 4886 additions and 1135 deletions

View File

@ -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;
}

View File

@ -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; }

View File

@ -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; }

View File

@ -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<typeof normalizeRegionTrend>
regionReceivableTrend: ReturnType<typeof normalizeRegionTrend>
@ -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<string, unknown>
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<string, unknown>
return {
label: String(row.label ?? row.Label ?? ''),
revenue: num(row.revenue ?? row.Revenue),
grossProfit: num(row.grossProfit ?? row.GrossProfit),
}
})
}
function normalizeOverview(raw: Record<string, unknown>): MonitorOverview {
const order = (raw.order ?? raw.Order ?? {}) as Record<string, unknown>
const ops = (raw.ops ?? raw.Ops ?? {}) as Record<string, unknown>
const queue = (raw.queue ?? raw.Queue ?? {}) as Record<string, unknown>
const customer = (raw.customer ?? raw.Customer ?? {}) as Record<string, unknown>
const track = (raw.trackRates ?? raw.TrackRates ?? {}) as Record<string, unknown>
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<string, unknown>): 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<string, unknown>
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<string, unknown>
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<string, unknown>
@ -169,9 +265,13 @@ function normalizeOverview(raw: Record<string, unknown>): MonitorOverview {
}
}
/** 实时监控概览 GET /monitor/overview */
export async function getMonitorOverview(): Promise<MonitorOverview> {
const res = await axiosInstance.get(monitorUrl('/monitor/overview'))
/** 实时监控概览 GET /monitor/overview?dimension=day|week|month */
export async function getMonitorOverview(
dimension: MonitorDimension = 'day',
): Promise<MonitorOverview> {
const res = await axiosInstance.get(monitorUrl('/monitor/overview'), {
params: { dimension },
})
assertBusinessOk(res.data)
const payload = res.data as Record<string, unknown>
const data = (payload.data ?? payload.Data ?? payload) as Record<string, unknown>

View File

@ -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 ||

View File

@ -35,3 +35,14 @@ export const fetchCustoms9610ManifestBatch = (ids: Array<number | string>) =>
export const fetchCustoms9610DeclarationFormBatch = (ids: Array<number | string>) =>
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<string, unknown>)

View File

@ -271,9 +271,94 @@ async function postOrderById(path: string, body: OrderIdRequest): Promise<void>
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<void> {
const payload: Record<string, unknown> = { 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<OrderPayPreview> {
const params: Record<string, unknown> = { 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 */

View File

@ -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<string, unknown>, ...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<string, unknown>): 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<Record<string, string>>, 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<CountryImportResult> {
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<Record<string, unknown>>(res.data) ?? {}
const rawFailures = (d.failures ?? d.Failures ?? []) as Array<Record<string, unknown>>
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 ?? ''),
})),
}
}

View File

@ -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)

View File

@ -0,0 +1,7 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="24" cy="18" r="8" fill="#86909C"/>
<path d="M9 41c0-7.5 6.7-11.5 15-11.5S39 33.5 39 41v3H9v-3z" fill="#86909C"/>
<circle cx="39" cy="16" r="9" fill="#FF7D00"/>
<path d="M39 11.5v5.2l3.2 2.2" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
<ellipse cx="21.5" cy="15.2" rx="2.2" ry="1.5" fill="#fff" opacity="0.28"/>
</svg>

After

Width:  |  Height:  |  Size: 474 B

View File

@ -0,0 +1,13 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="22" cy="18" r="8" fill="#007979"/>
<path d="M7 41c0-7.5 6.7-11.5 15-11.5S37 33.5 37 41v3H7v-3z" fill="url(#cn-body)"/>
<circle cx="39" cy="36" r="9" fill="#FF7D00"/>
<path d="M39 31.2v9.6M34.2 36h9.6" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/>
<ellipse cx="19.8" cy="15.2" rx="2.2" ry="1.5" fill="#fff" opacity="0.3"/>
<defs>
<linearGradient id="cn-body" x1="7" y1="29" x2="37" y2="44" gradientUnits="userSpaceOnUse">
<stop stop-color="#00A0A0"/>
<stop offset="1" stop-color="#007979"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 677 B

View File

@ -0,0 +1,8 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="27" cy="18" r="8" fill="#00B42A"/>
<path d="M12 41c0-7.5 6.7-11.5 15-11.5S42 33.5 42 41v3H12v-3z" fill="#00B42A"/>
<circle cx="39" cy="14" r="6" fill="#fff"/>
<circle cx="39" cy="14" r="4.2" fill="#00B42A"/>
<circle cx="39" cy="14" r="2" fill="#fff"/>
<ellipse cx="24.5" cy="15.2" rx="2.2" ry="1.5" fill="#fff" opacity="0.35"/>
</svg>

After

Width:  |  Height:  |  Size: 455 B

View File

@ -0,0 +1,7 @@
<svg width="54" height="54" viewBox="0 0 54 54" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="20" cy="18" r="7" fill="#2D8C8C"/>
<path d="M6 40c0-7.2 6.3-11 14-11s14 3.8 14 11v3H6v-3z" fill="#2D8C8C"/>
<circle cx="36" cy="17" r="6" fill="#5BB3B3"/>
<path d="M27 39.5c1.2-4.8 5.6-7.5 9.8-7.5 4.4 0 8.2 2.6 9.2 7.2V43H27v-3.5z" fill="#5BB3B3"/>
<ellipse cx="18.2" cy="15.5" rx="2" ry="1.4" fill="#fff" opacity="0.35"/>
</svg>

After

Width:  |  Height:  |  Size: 448 B

View File

@ -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;
}

View File

@ -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())
}

View File

@ -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: '新增配置' },

161
src/hooks/orderPayCoupon.ts Normal file
View File

@ -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<Record<string, OrderPayCouponEntry>>({});
function entryOf(id: OrderKey): OrderPayCouponEntry | undefined {
return entries.value[String(id)];
}
function takenByOthers(id: OrderKey) {
const self = String(id);
const taken = new Set<number>();
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<string, OrderPayCouponEntry> = {};
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<number>();
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,
};
}

View File

@ -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',
@ -1079,15 +1114,33 @@ 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',
'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?',
@ -1100,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',
@ -1292,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',
@ -1773,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',
@ -2358,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',
@ -2447,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',
@ -4236,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',

View File

@ -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': '创建代发订单',
@ -1070,15 +1105,33 @@ 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': '库存匹配成功',
'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} 个待处理订单并创建小包单,是否继续?',
@ -1091,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': '操作失败',
@ -1285,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',
@ -1601,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': '取消拦截',
@ -2219,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': '类别',
@ -2307,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': '暂无费用数据',
@ -4262,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': '发送模板',

View File

@ -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',

View File

@ -92,6 +92,7 @@ export const MENU_NAME_TO_LOCALE_KEY: Record<string, string> = {
系统接口: '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',

View File

@ -77,6 +77,7 @@ export const SUB_MENU_DEFAULT_ICONS: Record<string, string> = {
'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',

View File

@ -35,6 +35,9 @@ function mapItem(raw: Record<string, any>) {
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'),

View File

@ -0,0 +1,16 @@
/** 限并发执行,避免批量操作一次打出几十个请求 */
export async function runWithConcurrency<T>(
items: T[],
limit: number,
worker: (item: T, index: number) => Promise<void>,
) {
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)
}

View File

@ -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<SyncMenuTreeResul
let currentParent = menu;
for (const pageDef of pageDefs) {
const { menu: pageMenu, created } = await ensurePageMenu(currentParent, pageDef);
let pageMenu: MenuTreeDto;
let created = false;
try {
const ensured = await ensurePageMenu(currentParent, pageDef);
pageMenu = ensured.menu;
created = ensured.created;
} catch (error: unknown) {
level3FailedPages.push(`${pageDef.name}: ${readSyncError(error)}`);
continue;
}
if (created) {
level2Created += 1;
const refreshedParent = await reloadMenuNode(menu.id);

View File

@ -89,6 +89,9 @@ export const ITEM_FIELDS: FieldRow[] = [
{ name: 'imHsCode', type: 'string', required: false, desc: '进口 HS' },
{ name: 'exHsCode', type: 'string', required: false, desc: '出口 HS' },
{ name: 'declareElement', type: 'string', required: false, desc: '出口申报要素' },
{ name: 'productId', type: 'string', required: false, desc: '产品标识编码,最长 100' },
{ name: 'nonStdProductId', type: 'string', required: false, desc: '非标产品标识编码,最长 100' },
{ name: 'stdProductId', type: 'string', required: false, desc: '标准产品标识编码,最长 100' },
{ name: 'skuImage', type: 'string', required: false, desc: 'SKU 图片 URL' },
{ name: 'category', type: 'string', required: false, desc: '品类' },
{ name: 'brand', type: 'string', required: false, desc: '品牌' },
@ -180,7 +183,10 @@ export const SAMPLE_PARCEL_BODY = `{
"enName": "T-Shirt",
"quantity": 1,
"unitPrice": 12.5,
"unitWeight": 0.2
"unitWeight": 0.2,
"productId": "P10001",
"nonStdProductId": "",
"stdProductId": ""
}
]
}`;

View File

@ -153,6 +153,9 @@ export const ORDER_TABLE_FIELDS: OrderTableFieldDef[] = [
{ table: 't_order_declaration', field: 'ImHsCode', label: '进口HS编码', valueType: 'string' },
{ table: 't_order_declaration', field: 'ExHsCode', label: '出口HS编码', valueType: 'string' },
{ table: 't_order_declaration', field: 'DeclareElement', label: '出口申报要素', valueType: 'string' },
{ table: 't_order_declaration', field: 'ProductId', label: '产品标识编码', valueType: 'string' },
{ table: 't_order_declaration', field: 'NonStdProductId', label: '非标产品标识编码', valueType: 'string' },
{ table: 't_order_declaration', field: 'StdProductId', label: '标准产品标识编码', valueType: 'string' },
{ table: 't_order_declaration', field: 'Category', label: '申报类别', valueType: 'string' },
{ table: 't_order_declaration', field: 'Brand', label: '品牌', valueType: 'string' },
{ table: 't_order_declaration', field: 'BrandType', label: '品牌类型', valueType: 'string' },

View File

@ -25,6 +25,7 @@
updateSalesProductPrice,
} from '@/api/channel/salesProduct';
import { getLabelTemplateList } from '@/api/channel/labelTemplate';
import { isDiscountCostItem } from '@/constants/feeCostItems';
import { postForewarningServiceKpiAreaGetKpiAreas } from '@/api/channel/kpiArea';
import { getWarehouseList } from '@/api/system/warehouse';
import { postWeightRuleList } from '@/api/channel/weightRule';
@ -2707,7 +2708,9 @@
chargingCondition: '无',
});
const costItemOptions = computed(() => mapDictToSelectOptions('fee_item'));
const costItemOptions = computed(() =>
mapDictToSelectOptions('fee_item').filter((o) => !isDiscountCostItem(o.value)),
);
/** 同一产品下已占用的费用项(编辑当前条时排除自身) */
function isCostItemAlreadyUsed(costItem: string) {

View File

@ -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) {

View File

@ -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',
};

View File

@ -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': '较上月',
};

View File

@ -46,34 +46,10 @@
>
{{ loading ? t('h5Admin.login.submitting') : t('h5Admin.login.submit') }}
</a-button>
<a-checkbox v-model="agreementChecked" class="h5-login-agree">
<span>
{{ t('h5Admin.login.agreePrefix') }}
<a class="h5-login-link" @click.prevent="openAgreement('privacy')">
{{ t('h5Admin.login.privacy') }}
</a>
{{ t('h5Admin.login.and') }}
<a class="h5-login-link" @click.prevent="openAgreement('terms')">
{{ t('h5Admin.login.terms') }}
</a>
</span>
</a-checkbox>
</form>
</section>
</main>
<a-modal
v-model:visible="agreementVisible"
:title="agreementTitle"
:footer="false"
unmount-on-close
width="auto"
modal-class="h5-login-modal"
>
<div class="h5-login-agreement">{{ agreementContent }}</div>
</a-modal>
<H5LoginPortalSwitch target="customer" />
</div>
</template>
@ -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;
}
</style>

View File

@ -32,61 +32,154 @@
<a-spin :loading="loading && !overview" style="width: 100%">
<template v-if="tab === 'order'">
<section class="h5-mon__kpi h5-enter">
<button
v-for="card in orderCards"
:key="card.key"
type="button"
class="h5-mon__kpi-card"
:class="{ 'is-warning': card.warning }"
@click="onKpiClick(card)"
>
<span class="h5-mon__kpi-label">{{ t(card.labelKey) }}</span>
<strong class="h5-mon__kpi-value">{{ card.display }}</strong>
<span v-if="card.growthText" class="h5-mon__kpi-growth" :class="card.growthUp ? 'up' : 'down'">
{{ card.growthText }} {{ t(card.compareKey) }}
</span>
</button>
</section>
<section class="h5-mon__chart h5-enter">
<h2>{{ t('h5Admin.monitor.chart.orders7d') }}</h2>
<div class="h5-mon__bars">
<section class="h5-mon__section h5-enter">
<div class="h5-mon__section-head h5-mon__section-head--row">
<h2>{{ t('h5Admin.monitor.ops.title') }}</h2>
<div class="h5-mon__dims">
<button
v-for="dim in dimensions"
:key="dim"
type="button"
class="h5-mon__dim"
:class="{ 'is-active': opsDimension === dim }"
@click="setDimension(dim)"
>
{{ t(`h5Admin.monitor.dim.${dim}`) }}
</button>
</div>
</div>
<div class="h5-mon__kpi">
<div
v-for="point in orderTrend"
:key="point.label"
class="h5-mon__bar-col"
v-for="card in opsCards"
:key="card.key"
class="h5-mon__kpi-card"
>
<div class="h5-mon__bar-track">
<div class="h5-mon__bar-fill" :style="{ height: `${point.pct}%` }" />
</div>
<span class="h5-mon__bar-val">{{ point.count }}</span>
<span class="h5-mon__bar-label">{{ point.label }}</span>
<span class="h5-mon__kpi-main">
<span class="h5-mon__kpi-meta">
<img class="h5-mon__kpi-icon" :src="card.icon" alt="" />
<span class="h5-mon__kpi-label">{{ t(card.labelKey) }}</span>
<span
v-if="card.growthText"
class="h5-mon__kpi-growth"
:class="card.growthUp ? 'up' : 'down'"
>
{{ card.growthText }} {{ t(opsCompareKey) }}
</span>
</span>
<strong class="h5-mon__kpi-value">{{ card.display }}</strong>
</span>
</div>
</div>
</section>
<section class="h5-mon__rates h5-enter">
<div v-for="item in trackRates" :key="item.key" class="h5-mon__rate">
<span>{{ t(item.labelKey) }}</span>
<strong>{{ item.display }}</strong>
<section class="h5-mon__section h5-enter">
<div class="h5-mon__section-head">
<h2>{{ t('h5Admin.monitor.queue.title') }}</h2>
</div>
<div class="h5-mon__kpi h5-mon__kpi--queue">
<button
v-for="card in queueCards"
:key="card.key"
type="button"
class="h5-mon__kpi-card"
:class="{ 'is-warning': card.warning }"
@click="onKpiClick(card)"
>
<span class="h5-mon__kpi-main">
<span class="h5-mon__kpi-meta">
<img class="h5-mon__kpi-icon" :src="card.icon" alt="" />
<span class="h5-mon__kpi-label">{{ t(card.labelKey) }}</span>
</span>
<strong class="h5-mon__kpi-value">{{ card.display }}</strong>
</span>
</button>
</div>
</section>
<section class="h5-mon__section h5-enter">
<div class="h5-mon__section-head">
<h2>{{ t(orderTrendTitleKey) }}</h2>
</div>
<div class="h5-mon__bars">
<div v-for="point in orderTrendBars" :key="point.label" class="h5-mon__bar-col">
<div class="h5-mon__bar-track">
<div class="h5-mon__bar-fill" :style="{ height: `${point.pct}%` }" />
</div>
<span class="h5-mon__bar-val">{{ point.count }}</span>
<span class="h5-mon__bar-label">{{ point.shortLabel }}</span>
</div>
</div>
</section>
<section class="h5-mon__section h5-enter">
<div class="h5-mon__section-head">
<h2>{{ t(revenueTrendTitleKey) }}</h2>
</div>
<div class="h5-mon__legend">
<span class="is-revenue">{{ t('h5Admin.monitor.series.revenue') }}</span>
<span class="is-gross">{{ t('h5Admin.monitor.series.grossProfit') }}</span>
</div>
<div class="h5-mon__bars h5-mon__bars--pair">
<div v-for="point in revenueTrendBars" :key="point.label" class="h5-mon__bar-col">
<div class="h5-mon__bar-pair">
<div class="h5-mon__bar-track">
<div
class="h5-mon__bar-fill is-revenue"
:style="{ height: `${point.revenuePct}%` }"
/>
</div>
<div class="h5-mon__bar-track">
<div
class="h5-mon__bar-fill is-gross"
:style="{ height: `${point.grossPct}%` }"
/>
</div>
</div>
<span class="h5-mon__bar-label">{{ point.shortLabel }}</span>
</div>
</div>
</section>
<section class="h5-mon__section h5-enter">
<div class="h5-mon__section-head">
<h2>{{ t('h5Admin.monitor.track.title') }}</h2>
</div>
<div class="h5-mon__rates">
<div v-for="item in trackRates" :key="item.key" class="h5-mon__rate">
<span>{{ t(item.labelKey) }}</span>
<strong>{{ item.display }}</strong>
</div>
</div>
</section>
</template>
<template v-else>
<section class="h5-mon__kpi h5-enter">
<div
v-for="card in customerCards"
:key="card.key"
class="h5-mon__kpi-card"
:class="{ 'is-warning': card.warning }"
>
<span class="h5-mon__kpi-label">{{ t(card.labelKey) }}</span>
<strong class="h5-mon__kpi-value">{{ card.display }}</strong>
<span v-if="card.growthText" class="h5-mon__kpi-growth" :class="card.growthUp ? 'up' : 'down'">
{{ card.growthText }} {{ t(card.compareKey) }}
</span>
<section class="h5-mon__section h5-enter">
<div class="h5-mon__section-head">
<h2>{{ t('h5Admin.monitor.customer.title') }}</h2>
</div>
<div class="h5-mon__kpi h5-mon__kpi--queue">
<div
v-for="card in customerCards"
:key="card.key"
class="h5-mon__kpi-card"
:class="{ 'is-warning': card.warning }"
>
<span class="h5-mon__kpi-main">
<span class="h5-mon__kpi-meta">
<img class="h5-mon__kpi-icon" :src="card.icon" alt="" />
<span class="h5-mon__kpi-label">{{ t(card.labelKey) }}</span>
<span
v-if="card.growthText"
class="h5-mon__kpi-growth"
:class="card.growthUp ? 'up' : 'down'"
>
{{ card.growthText }} {{ t(card.compareKey) }}
</span>
</span>
<strong class="h5-mon__kpi-value">{{ card.display }}</strong>
</span>
</div>
</div>
</section>
</template>
@ -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<MonitorDimension>('day');
const dimensions: MonitorDimension[] = ['day', 'week', 'month'];
const loading = ref(false);
const loadError = ref(false);
const overview = ref<MonitorOverview | null>(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 {

View File

@ -28,9 +28,16 @@
:class="[`is-${item.tone}`, { 'is-alert': item.alert && item.value > 0 }]"
@click="onOpsClick(item)"
>
<span class="h5-wp__ops-label">{{ t(item.labelKey) }}</span>
<strong class="h5-wp__ops-value">{{ formatNum(item.value) }}</strong>
<span class="h5-wp__ops-unit">{{ t('h5Admin.workplace.unit') }}</span>
<span class="h5-wp__ops-main">
<span class="h5-wp__ops-meta">
<img class="h5-wp__ops-icon" :src="item.icon" alt="" />
<span class="h5-wp__ops-label">{{ t(item.labelKey) }}</span>
</span>
<span class="h5-wp__ops-body">
<strong class="h5-wp__ops-value">{{ formatNum(item.value) }}</strong>
<span class="h5-wp__ops-unit">{{ t('h5Admin.workplace.unit') }}</span>
</span>
</span>
</button>
</div>
</a-spin>
@ -42,8 +49,13 @@
</div>
<div class="h5-wp__grid">
<div v-for="item in fulfillItems" :key="item.key" class="h5-wp__stat">
<span>{{ t(item.labelKey) }}</span>
<strong>{{ formatNum(item.value) }}</strong>
<span class="h5-wp__stat-main">
<span class="h5-wp__stat-meta">
<img class="h5-wp__stat-icon" :src="item.icon" alt="" />
<span class="h5-wp__stat-label">{{ t(item.labelKey) }}</span>
</span>
<strong class="h5-wp__stat-value">{{ formatNum(item.value) }}</strong>
</span>
</div>
</div>
</section>
@ -54,8 +66,13 @@
</div>
<div class="h5-wp__grid">
<div v-for="item in pickupItems" :key="item.key" class="h5-wp__stat">
<span>{{ t(item.labelKey) }}</span>
<strong>{{ formatNum(item.value) }}</strong>
<span class="h5-wp__stat-main">
<span class="h5-wp__stat-meta">
<img class="h5-wp__stat-icon" :src="item.icon" alt="" />
<span class="h5-wp__stat-label">{{ t(item.labelKey) }}</span>
</span>
<strong class="h5-wp__stat-value">{{ formatNum(item.value) }}</strong>
</span>
</div>
</div>
</section>
@ -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<OpsItem[]>(() => {
status: '',
tone: 'warning',
alert: true,
icon: iconPayment,
action: 'recharge',
},
{
@ -193,6 +224,7 @@ const opsItems = computed<OpsItem[]>(() => {
status: 'problematic',
tone: 'danger',
alert: true,
icon: iconProblematic,
action: 'orders',
},
{
@ -202,6 +234,7 @@ const opsItems = computed<OpsItem[]>(() => {
status: 'claimed',
tone: 'warning',
alert: true,
icon: iconGrowth,
action: 'orders',
},
{
@ -211,6 +244,7 @@ const opsItems = computed<OpsItem[]>(() => {
status: 'pendingShip',
tone: 'warning',
alert: true,
icon: iconPendingShip,
action: 'orders',
},
{
@ -220,6 +254,7 @@ const opsItems = computed<OpsItem[]>(() => {
status: 'pendingPayment',
tone: 'warning',
alert: true,
icon: iconPayment,
action: 'orders',
},
{
@ -229,6 +264,7 @@ const opsItems = computed<OpsItem[]>(() => {
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 {

View File

@ -6,14 +6,14 @@
:title="label"
:aria-label="label"
>
<component :is="icon" :size="18" />
<icon-swap :size="18" />
</router-link>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { IconSettings, IconUser } from '@arco-design/web-vue/es/icon';
import { IconSwap } from '@arco-design/web-vue/es/icon';
const props = defineProps<{
/** 当前页面对侧:admin=去管理登录;customer=去客户登录 */
@ -26,10 +26,6 @@ const to = computed(() =>
props.target === 'admin' ? '/h5/admin/login' : '/h5/login',
);
const icon = computed(() =>
props.target === 'admin' ? IconSettings : IconUser,
);
const label = computed(() =>
props.target === 'admin'
? t('h5.login.switchToAdmin')
@ -53,13 +49,13 @@ const label = computed(() =>
text-decoration: none;
background: transparent;
border: none;
opacity: 0.55;
opacity: 0.28;
-webkit-tap-highlight-color: transparent;
transition: opacity 0.15s ease, color 0.15s ease;
&:active {
opacity: 0.85;
color: #86909c;
opacity: 0.55;
color: #c9cdd4;
}
}
</style>

View File

@ -0,0 +1,198 @@
<template>
<a-modal
:visible="visible"
:title="current?.title || t('h5.noticePopup.title')"
:mask-closable="false"
:esc-to-close="false"
:closable="false"
unmount-on-close
width="auto"
modal-class="h5-notice-popup-modal"
>
<div class="h5-notice-popup">
<p v-if="formattedCreateTime" class="h5-notice-popup__time">
{{ t('h5.noticePopup.createTime') }}{{ formattedCreateTime }}
</p>
<div class="h5-notice-popup__content">{{ current?.content }}</div>
<div class="h5-notice-popup__actions">
<a-button long size="large" :disabled="readLoading" @click="handleLater">
{{ t('h5.noticePopup.later') }}
</a-button>
<a-button
type="primary"
long
size="large"
class="h5-notice-popup__primary"
:loading="readLoading"
@click="handleMarkRead"
>
{{ t('h5.noticePopup.markRead') }}
</a-button>
</div>
</div>
</a-modal>
</template>
<script lang="ts" setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import dayjs from 'dayjs';
import { Message } from '@arco-design/web-vue';
import { confirmNoticeRead, getNoticeList, type NoticeDto } from '@/api/system/notice';
import { isLogin } from '@/utils/auth';
import { getNoticePopupSnoozeIds, snoozeNoticePopup } from '@/utils/notice-unread';
defineOptions({ name: 'H5NoticePopup' });
const { t } = useI18n();
const visible = ref(false);
const current = ref<NoticeDto | null>(null);
const queue = ref<NoticeDto[]>([]);
const readLoading = ref(false);
const formattedCreateTime = computed(() => {
const raw = current.value?.createTime;
if (!raw) return '';
const d = dayjs(raw);
return d.isValid() ? d.format('YYYY-MM-DD HH:mm') : String(raw);
});
function isPopupNotice(row: NoticeDto) {
return Number(row.isPopup) === 1;
}
function showNext() {
if (queue.value.length === 0) {
current.value = null;
visible.value = false;
return;
}
current.value = queue.value.shift() ?? null;
visible.value = !!current.value;
}
async function loadPopupQueue() {
if (!isLogin()) return;
try {
const snoozed = getNoticePopupSnoozeIds();
const { items } = await getNoticeList({
page: 1,
pageSize: 50,
noticeType: 'notice',
onlyUnread: true,
});
const list = (items ?? []).filter(
(row) =>
row.id != null &&
isPopupNotice(row) &&
!row.isRead &&
!snoozed.has(String(row.id)),
);
list.sort((a, b) => (a.createTime || '').localeCompare(b.createTime || ''));
queue.value = list;
showNext();
} catch {
queue.value = [];
visible.value = false;
current.value = null;
}
}
function handleLater() {
const row = current.value;
if (row?.id != null) {
snoozeNoticePopup(row.id);
}
showNext();
}
async function handleMarkRead() {
const row = current.value;
if (row?.id == null) {
showNext();
return;
}
readLoading.value = true;
try {
await confirmNoticeRead(row.id);
Message.success(t('h5.noticePopup.readSuccess'));
showNext();
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : t('h5.noticePopup.readFailed');
Message.error(msg);
} finally {
readLoading.value = false;
}
}
onMounted(() => {
void loadPopupQueue();
});
</script>
<style lang="less">
.h5-notice-popup-modal {
.arco-modal {
max-width: min(420px, 92vw);
border-radius: 16px;
}
.arco-modal-header {
padding: 16px 16px 8px;
border-bottom: none;
}
.arco-modal-title {
font-size: 17px;
font-weight: 700;
color: var(--h5-text, #1d2129);
}
.arco-modal-body {
padding: 0 16px 16px;
}
.arco-modal-footer {
display: none;
}
}
</style>
<style lang="less" scoped>
.h5-notice-popup {
display: flex;
flex-direction: column;
gap: 12px;
}
.h5-notice-popup__time {
margin: 0;
font-size: 12px;
color: var(--h5-text-3, #86909c);
}
.h5-notice-popup__content {
max-height: min(52vh, 420px);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
font-size: 15px;
line-height: 1.65;
color: var(--h5-text-2, #4e5969);
white-space: pre-wrap;
word-break: break-word;
}
.h5-notice-popup__actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 4px;
}
.h5-notice-popup__primary {
height: 44px !important;
border-radius: 10px !important;
font-weight: 600;
}
</style>

View File

@ -56,9 +56,96 @@
</li>
</ul>
<div v-if="previews.length > 1" class="h5-pay__subtotal">
<span>{{ t('h5.pay.orderTotal') }}</span>
<strong>{{ preview.totalText }}</strong>
<p v-if="!preview.error && couponEntry(preview.id)?.error" class="h5-pay__error">
{{ couponEntry(preview.id)?.error }}
</p>
<template v-else-if="!preview.error && couponEntry(preview.id)?.pricing">
<div class="h5-pay__coupon">
<button
type="button"
class="h5-pay__coupon-row"
:disabled="!couponEntry(preview.id)?.pricing?.availableCoupons.length"
@click="toggleCouponList(preview.id)"
>
<span class="h5-pay__coupon-label">
<icon-gift />
{{ t('h5.pay.coupon') }}
</span>
<span
class="h5-pay__coupon-value"
:class="{ 'is-active': (couponEntry(preview.id)?.selectedCouponId ?? 0) > 0 }"
>
<span v-if="couponEntry(preview.id)?.loading" class="h5-pay__coupon-spin" aria-hidden="true" />
{{ couponSummaryText(preview.id) }}
</span>
<icon-right
v-if="couponEntry(preview.id)?.pricing?.availableCoupons.length"
class="h5-pay__coupon-arrow"
:class="{ 'is-open': expandedCouponOrder === String(preview.id) }"
/>
</button>
<ul v-if="expandedCouponOrder === String(preview.id)" class="h5-pay__coupon-list">
<li>
<button
type="button"
class="h5-pay__coupon-opt"
:class="{ 'is-selected': couponEntry(preview.id)?.selectedCouponId === 0 }"
:disabled="couponEntry(preview.id)?.loading"
@click="handlePickCoupon(preview.id, 0)"
>
<span class="h5-pay__coupon-opt-main">
<span class="h5-pay__coupon-opt-name">{{ t('h5.pay.couponNone') }}</span>
</span>
<icon-check v-if="couponEntry(preview.id)?.selectedCouponId === 0" class="h5-pay__coupon-check" />
</button>
</li>
<li v-for="c in couponEntry(preview.id)?.pricing?.availableCoupons ?? []" :key="c.couponInstanceId">
<button
type="button"
class="h5-pay__coupon-opt"
:class="{ 'is-selected': couponEntry(preview.id)?.selectedCouponId === c.couponInstanceId }"
:disabled="couponEntry(preview.id)?.loading || !isCouponSelectable(preview.id, c)"
@click="handlePickCoupon(preview.id, c.couponInstanceId)"
>
<span class="h5-pay__coupon-opt-benefit">{{ formatCouponBenefit(c) }}</span>
<span class="h5-pay__coupon-opt-main">
<span class="h5-pay__coupon-opt-name">{{ c.templateName || c.couponCode }}</span>
<small>{{ couponHintText(preview.id, c) }}</small>
</span>
<icon-check
v-if="couponEntry(preview.id)?.selectedCouponId === c.couponInstanceId"
class="h5-pay__coupon-check"
/>
</button>
</li>
</ul>
</div>
<ul v-if="hasDiscount(preview.id)" class="h5-pay__breakdown">
<li>
<span>{{ t('h5.pay.subtotal') }}</span>
<span>{{ formatPayMoney(couponEntry(preview.id)?.pricing?.receivableSubtotal) }}</span>
</li>
<li v-if="(couponEntry(preview.id)?.pricing?.levelDiscountAmount ?? 0) > 0">
<span>
{{ t('h5.pay.levelDiscount') }}
<template v-if="couponEntry(preview.id)?.pricing?.memberLevelName">
({{ couponEntry(preview.id)?.pricing?.memberLevelName }})
</template>
</span>
<span class="is-discount">-{{ formatPayMoney(couponEntry(preview.id)?.pricing?.levelDiscountAmount) }}</span>
</li>
<li v-if="(couponEntry(preview.id)?.pricing?.couponDiscountAmount ?? 0) > 0">
<span>{{ t('h5.pay.couponDiscount') }}</span>
<span class="is-discount">-{{ formatPayMoney(couponEntry(preview.id)?.pricing?.couponDiscountAmount) }}</span>
</li>
</ul>
</template>
<div v-if="previews.length > 1 || hasDiscount(preview.id)" class="h5-pay__subtotal">
<span>{{ hasDiscount(preview.id) ? t('h5.pay.payable') : t('h5.pay.orderTotal') }}</span>
<strong>{{ orderPayableText(preview) }}</strong>
</div>
</div>
@ -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<string | null>(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<string, any> | 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;

View File

@ -871,6 +871,22 @@
<a-textarea v-model="row.declareElement" :auto-size="{ minRows: 2, maxRows: 3 }" />
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.productId') }}</label>
<a-input v-model="row.productId" allow-clear :max-length="100" />
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.nonStdProductId') }}</label>
<a-input v-model="row.nonStdProductId" allow-clear :max-length="100" />
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.stdProductId') }}</label>
<a-input v-model="row.stdProductId" allow-clear :max-length="100" />
</div>
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.brand') }}</label>
@ -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') ?? ''),
),

View File

@ -25,6 +25,7 @@
v-model:visible="placeOrderVisible"
/>
<H5FollowOfficialModal v-if="followModalReady" />
<H5NoticePopup v-if="noticePopupReady" />
</div>
</template>
@ -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');

View File

@ -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',

View File

@ -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个月积分',

View File

@ -15,30 +15,40 @@
class="monitor-top-tabs"
>
<a-tab-pane key="order" :title="$t('monitor.logistics.tab.order')">
<div class="monitor-ops-toolbar">
<span class="monitor-ops-toolbar__label">{{ $t('monitor.logistics.filter.opsPeriod') }}</span>
<a-radio-group v-model="opsDimension" type="button" size="small">
<a-radio value="day">{{ $t('monitor.logistics.filter.periodDay') }}</a-radio>
<a-radio value="week">{{ $t('monitor.logistics.filter.periodWeek') }}</a-radio>
<a-radio value="month">{{ $t('monitor.logistics.filter.periodMonth') }}</a-radio>
</a-radio-group>
</div>
<a-row :gutter="[12, 12]" align="stretch" class="logistics-monitor-stats">
<a-col
v-for="card in orderStatCards"
v-for="card in opsStatCards"
:key="card.key"
:xs="24"
:sm="12"
:md="12"
:lg="12"
:xl="6"
:md="8"
:lg="8"
:xl="8"
>
<a-card hoverable class="stat-card" :body-style="{ padding: '12px', height: '100%' }">
<div class="stat-item">
<div class="stat-item__head">
<div class="stat-label">{{ $t(card.labelKey) }}</div>
<div class="stat-item__icon stat-item__icon--primary">
<icon-bar-chart />
<img v-if="card.iconSrc" :src="card.iconSrc" alt="" class="stat-item__icon-img" />
<icon-bar-chart v-else />
</div>
</div>
<div class="stat-value" :class="{ 'stat-value--warning': card.warning }">{{ card.value }}</div>
<div class="stat-value">{{ card.value }}</div>
<div v-if="card.growth.show" class="stat-footer">
<div class="stat-trend" :class="card.growth.up ? 'up' : 'down'">
<icon-arrow-up v-if="card.growth.up" />
<icon-arrow-down v-else />
{{ card.growth.text }} {{ $t(card.compareKey) }}
{{ card.growth.text }} {{ $t(opsCompareKey) }}
</div>
</div>
</div>
@ -46,15 +56,46 @@
</a-col>
</a-row>
<p class="monitor-queue-title">{{ $t('monitor.logistics.queue.title') }}</p>
<a-row :gutter="[12, 12]" align="stretch" class="logistics-monitor-stats">
<a-col
v-for="card in queueStatCards"
:key="card.key"
:xs="24"
:sm="12"
:md="12"
:lg="6"
:xl="6"
>
<a-card hoverable class="stat-card" :body-style="{ padding: '12px', height: '100%' }">
<div class="stat-item">
<div class="stat-item__head">
<div class="stat-label">{{ $t(card.labelKey) }}</div>
<div
class="stat-item__icon"
:class="card.warning ? 'stat-item__icon--warning' : 'stat-item__icon--primary'"
>
<img v-if="card.iconSrc" :src="card.iconSrc" alt="" class="stat-item__icon-img" />
<icon-bar-chart v-else />
</div>
</div>
<div class="stat-value" :class="{ 'stat-value--warning': card.warning }">
{{ card.value }}
</div>
</div>
</a-card>
</a-col>
</a-row>
<a-row :gutter="[16, 16]" class="logistics-monitor-trend-charts">
<a-col :xs="24" :lg="12">
<a-card :title="$t('monitor.logistics.chart.orders7d')" class="chart-card">
<a-card :title="$t(orderTrendTitleKey)" class="chart-card">
<div ref="orderChartRef" class="chart-host"></div>
</a-card>
</a-col>
<a-col :xs="24" :lg="12">
<a-card :title="$t('monitor.logistics.chart.revenueGross7d')" class="chart-card">
<a-card :title="$t(revenueTrendTitleKey)" class="chart-card">
<div ref="profitChartRef" class="chart-host"></div>
</a-card>
</a-col>
@ -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<MonitorOverview | null>(null);
const overviewLoading = ref(true);
const opsDimension = ref<MonitorDimension>('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);

View File

@ -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)',

View File

@ -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 达成(按线路 / 周期)',

View File

@ -0,0 +1,63 @@
<template>
<a-tooltip :content="label" position="tl">
<router-link
class="login-portal-switch"
:to="to"
replace
:aria-label="label"
>
<icon-swap :size="16" />
</router-link>
</a-tooltip>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { IconSwap } from '@arco-design/web-vue/es/icon';
import { ADMIN_LOGIN_PATH, CUSTOMER_LOGIN_PATH } from '@/utils/login-portal';
const props = defineProps<{
/** 当前页面对侧:admin=去管理登录;customer=去客户登录 */
target: 'admin' | 'customer';
}>();
const { t } = useI18n();
const to = computed(() =>
props.target === 'admin' ? ADMIN_LOGIN_PATH : CUSTOMER_LOGIN_PATH,
);
const label = computed(() =>
props.target === 'admin'
? t('login.switchToAdmin')
: t('login.switchToCustomer'),
);
</script>
<style lang="less" scoped>
.login-portal-switch {
position: absolute;
right: 14px;
bottom: 14px;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 50%;
color: rgba(26, 45, 74, 0.22);
text-decoration: none;
background: transparent;
border: none;
opacity: 0.7;
transition: color 0.15s ease, opacity 0.15s ease;
&:hover {
color: rgba(26, 45, 74, 0.42);
opacity: 1;
background: transparent;
}
}
</style>

View File

@ -73,7 +73,7 @@
{{ loading ? t('login.submitting') : t('login.form.login') }}
</button>
<div class="agreement-area">
<div v-if="isCustomerLogin" class="agreement-area">
<a-checkbox v-model="agreementChecked">
<span class="agreement-line">
{{ 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;
}

View File

@ -87,6 +87,7 @@
@switch-pwd="setLoginMode('pwd')"
/>
</div>
<LoginPortalSwitch :target="isCustomerLogin ? 'admin' : 'customer'" />
</div>
</div>
</div>
@ -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,

View File

@ -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',

View File

@ -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',

View File

@ -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<string, any>) {
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<string, any>[]) {
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,

View File

@ -0,0 +1,519 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import type { TableColumnData, TableRowSelection } from '@arco-design/web-vue'
import ModalPro from '@/components/ModalPro/index.vue'
import useModal from '@/components/ModalPro/useModal'
import { postOrderList } from '@/api/order'
import { createCustoms9610FromOrders } from '@/api/order/customs9610'
import { getExportQualificationList } from '@/api/customer/exportQualification'
import { getDictListAll } from '@/api/system/dict'
import { getOrderStatusMeta, resolveOrderStatusCode } from '@/constants/orderStatus'
import { $baseMessage } from '@/utils'
import { formatDateTime, normalizeListRow, pickField } from '@/utils/warehouseListMapper'
interface OrderPickRow {
id: number
serviceNo: string
clientNo: string
customerName: string
country: string
statusName: string
createTime: string
/** 已是 9610 汇总申报的运单不可再选 */
is9610: boolean
/** a-table 行级禁用勾选 */
disabled: boolean
}
interface FailRow {
orderId: number
serviceNo: string
message: string
}
const ORDER_PAGE_SIZE = 30
const SUMMARY_MODE = '9610summary'
const props = defineProps<{
isAdmin: boolean
customerOptions: Array<{ label: string; value: number }>
/** 列表页已选客户,作为管理端默认客户 */
defaultCustomerId?: number
}>()
const emit = defineEmits<{
success: []
}>()
const { t } = useI18n()
const router = useRouter()
const [registerModal, { openModal, closeModal }] = useModal() as any
const submitting = ref(false)
const form = reactive({
customerId: undefined as number | undefined,
exportQualificationId: undefined as number | undefined,
platformCode: '',
})
const qualificationOptions = ref<Array<{ label: string; value: number }>>([])
const qualificationLoading = ref(false)
const platformOptions = ref<Array<{ label: string; value: string }>>([])
const keyword = ref('')
const orderLoading = ref(false)
const orderRows = ref<OrderPickRow[]>([])
const orderPage = ref(1)
const orderTotal = ref(0)
const selectedKeys = ref<number[]>([])
/** 跨页保留已选运单信息,用于结果展示 */
const selectedMap = ref<Map<number, OrderPickRow>>(new Map())
const failRows = ref<FailRow[]>([])
const needCustomer = computed(() => props.isAdmin && !form.customerId)
const orderColumns = computed<TableColumnData[]>(() => [
{ title: t('order.customs9610.create.col.serviceNo'), dataIndex: 'serviceNo', width: 180, ellipsis: true, tooltip: true },
{ title: t('order.customs9610.create.col.clientNo'), dataIndex: 'clientNo', width: 150, ellipsis: true, tooltip: true },
...(props.isAdmin
? [{ title: t('order.customs9610.create.col.customer'), dataIndex: 'customerName', width: 140, ellipsis: true, tooltip: true }]
: []),
{ title: t('order.customs9610.create.col.country'), dataIndex: 'country', width: 90 },
{ title: t('order.customs9610.create.col.status'), dataIndex: 'statusName', width: 100 },
{ title: t('order.customs9610.create.col.createTime'), dataIndex: 'createTime', width: 160 },
{ title: t('order.customs9610.create.col.remark'), slotName: 'remark', width: 120 },
])
const rowSelection = computed<TableRowSelection>(() => ({
type: 'checkbox',
showCheckedAll: true,
onlyCurrent: false,
}))
const orderPagination = computed(() => ({
current: orderPage.value,
pageSize: ORDER_PAGE_SIZE,
total: orderTotal.value,
size: 'small' as const,
showTotal: true,
}))
const canSubmit = computed(
() =>
!submitting.value
&& !needCustomer.value
&& selectedKeys.value.length > 0
&& !!form.platformCode,
)
function statusName(code: string) {
const meta = getOrderStatusMeta(code)
return meta ? t(meta.labelKey) : code
}
function mapOrderRow(raw: any): OrderPickRow | null {
const row = normalizeListRow(raw)
const id = Number(pickField(row, 'id', 'Id') || 0)
if (!id) return null
const status = resolveOrderStatusCode(row) || String(pickField(row, 'status', 'Status') || '')
if (status === 'deleted') return null
const mode = String(pickField(row, 'exportCustomsMode', 'ExportCustomsMode') || '').toLowerCase()
const is9610 = mode === SUMMARY_MODE
return {
id,
serviceNo: String(pickField(row, 'serviceNo', 'ServiceNo') || '-'),
clientNo: String(pickField(row, 'clientNo', 'ClientNo') || '-'),
customerName: String(pickField(row, 'customerName', 'CustomerName', 'customerCode', 'CustomerCode') || '-'),
country: String(pickField(row, 'consigneeCountry', 'ConsigneeCountry') || '-'),
statusName: statusName(status),
createTime: formatDateTime(pickField(row, 'createTime', 'CreateTime')) || '-',
is9610,
disabled: is9610,
}
}
async function loadOrders(page = 1) {
if (needCustomer.value) {
orderRows.value = []
orderTotal.value = 0
return
}
orderLoading.value = true
orderPage.value = page
try {
const body: Record<string, unknown> = {
page,
pageSize: ORDER_PAGE_SIZE,
status: 'all',
keyword: keyword.value.trim(),
}
if (props.isAdmin && form.customerId) body.customerId = form.customerId
const res: any = await postOrderList(body as any)
orderRows.value = (res.items || [])
.map((raw: any) => mapOrderRow(raw))
.filter((x: OrderPickRow | null): x is OrderPickRow => !!x)
orderTotal.value = Number(res.total ?? res.totalCount ?? orderRows.value.length) || 0
} catch (e: any) {
orderRows.value = []
orderTotal.value = 0
$baseMessage(e?.message || t('order.customs9610.create.message.loadOrdersFailed'), 'error')
} finally {
orderLoading.value = false
}
}
function handleSelectionChange(keys: Array<string | number>) {
const ids = (keys || []).map(Number).filter((id) => id > 0)
const next = new Map<number, OrderPickRow>()
for (const id of ids) {
const row = orderRows.value.find((r) => r.id === id) ?? selectedMap.value.get(id)
if (row && !row.is9610) next.set(id, row)
}
selectedMap.value = next
selectedKeys.value = [...next.keys()]
}
function clearSelection() {
selectedMap.value = new Map()
selectedKeys.value = []
}
async function loadQualifications() {
qualificationOptions.value = []
form.exportQualificationId = undefined
if (needCustomer.value) return
qualificationLoading.value = true
try {
const body: Record<string, unknown> = { page: 1, pageSize: 200 }
if (props.isAdmin && form.customerId) body.customerId = form.customerId
const res: any = await getExportQualificationList(body)
const items = Array.isArray(res?.items) ? res.items : []
let defaultId: number | undefined
qualificationOptions.value = items
.map((raw: any) => {
const id = Number(pickField(raw, 'id', 'Id') || 0)
if (!id) return null
const name = String(pickField(raw, 'companyName', 'CompanyName') || pickField(raw, 'socialCreditCode', 'SocialCreditCode') || `#${id}`)
const isDefault = Boolean(pickField(raw, 'isDefault', 'IsDefault'))
if (isDefault) defaultId = id
return {
label: isDefault ? `${name}${t('order.customs9610.create.defaultSuffix')}` : name,
value: id,
}
})
.filter((x: any): x is { label: string; value: number } => !!x)
form.exportQualificationId = defaultId ?? qualificationOptions.value[0]?.value
} catch {
qualificationOptions.value = []
} finally {
qualificationLoading.value = false
}
}
async function ensurePlatformOptions() {
if (platformOptions.value.length) return
try {
const rows = await getDictListAll({ dictType: 'e-commerce_platform' })
platformOptions.value = rows
.map((r: any) => ({
label: String(r.dictName ?? r.dictCode ?? '').trim(),
value: String(r.dictCode ?? '').trim(),
}))
.filter((x) => x.value)
} catch {
platformOptions.value = []
}
}
function goQualificationManage() {
router.push({ path: '/user/exportQualification' })
}
async function open() {
form.customerId = props.isAdmin ? props.defaultCustomerId : undefined
form.platformCode = ''
keyword.value = ''
failRows.value = []
clearSelection()
openModal()
await Promise.all([ensurePlatformOptions(), loadQualifications(), loadOrders(1)])
}
function close() {
if (submitting.value) return
closeModal()
}
async function handleSubmit() {
if (!canSubmit.value) return
submitting.value = true
try {
const res: any = await createCustoms9610FromOrders({
orderIds: [...selectedKeys.value],
exportQualificationId: form.exportQualificationId,
platformCode: form.platformCode,
})
const items: any[] = Array.isArray(res?.items ?? res?.Items) ? (res.items ?? res.Items) : []
const successCount = Number(pickField(res, 'successCount', 'SuccessCount') ?? 0)
const failed = items.filter((x) => !pickField(x, 'success', 'Success'))
if (successCount > 0) emit('success')
if (!failed.length) {
$baseMessage(t('order.customs9610.create.message.allSuccess', { count: successCount }), 'success')
closeModal()
return
}
failRows.value = failed.map((x) => {
const orderId = Number(pickField(x, 'orderId', 'OrderId') || 0)
return {
orderId,
serviceNo: String(pickField(x, 'serviceNo', 'ServiceNo') || selectedMap.value.get(orderId)?.serviceNo || orderId),
message: String(pickField(x, 'message', 'Message') || t('order.customs9610.create.message.failed')),
}
})
// 成功的运单从已选中移除,保留失败的便于调整后重试
const failedIds = new Set(failRows.value.map((x) => x.orderId))
const next = new Map([...selectedMap.value].filter(([id]) => failedIds.has(id)))
selectedMap.value = next
selectedKeys.value = [...next.keys()]
$baseMessage(
t('order.customs9610.create.message.partial', { success: successCount, fail: failed.length }),
'warning',
)
if (successCount > 0) void loadOrders(orderPage.value)
} catch (e: any) {
$baseMessage(e?.message || t('order.customs9610.create.message.failed'), 'error')
} finally {
submitting.value = false
}
}
watch(
() => form.customerId,
(val, old) => {
if (val === old) return
clearSelection()
failRows.value = []
void loadQualifications()
void loadOrders(1)
},
)
defineExpose({ open })
</script>
<template>
<ModalPro
@register="registerModal"
:title="t('order.customs9610.create.title')"
width="1000px"
:mask-closable="false"
:closable="!submitting"
@cancel="close"
>
<a-alert type="info" :show-icon="false" class="c9610-create__tip">
{{ t('order.customs9610.create.tip') }}
</a-alert>
<div class="c9610-create__section-title">{{ t('order.customs9610.create.sectionSupplement') }}</div>
<a-form :model="form" layout="vertical" class="c9610-create__form">
<a-row :gutter="20">
<a-col v-if="isAdmin" :span="8">
<a-form-item :label="t('order.customs9610.create.customer')" required>
<a-select
v-model="form.customerId"
allow-search
:options="customerOptions"
:placeholder="t('order.customs9610.create.customerPh')"
/>
</a-form-item>
</a-col>
<a-col :span="isAdmin ? 8 : 12">
<a-form-item :label="t('order.customs9610.create.exportQualification')">
<a-select
v-model="form.exportQualificationId"
allow-clear
:loading="qualificationLoading"
:disabled="needCustomer"
:options="qualificationOptions"
:placeholder="t('order.customs9610.create.exportQualificationPh')"
/>
<template #extra>
<a-link :hoverable="false" @click="goQualificationManage">
{{ t('order.customs9610.create.manageQualification') }}
</a-link>
</template>
</a-form-item>
</a-col>
<a-col :span="isAdmin ? 8 : 12">
<a-form-item :label="t('order.customs9610.create.platform')" required>
<a-select
v-model="form.platformCode"
allow-search
:options="platformOptions"
:placeholder="t('order.customs9610.create.platformPh')"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
<div class="c9610-create__section-title">
{{ t('order.customs9610.create.sectionOrders') }}
</div>
<div class="c9610-create__toolbar">
<a-input
v-model="keyword"
allow-clear
:disabled="needCustomer"
:placeholder="t('order.customs9610.create.keywordPh')"
style="width: 260px"
@press-enter="loadOrders(1)"
/>
<a-button class="app-btn-search" :loading="orderLoading" :disabled="needCustomer" @click="loadOrders(1)">
{{ t('order.myOrders.search.actions.search') }}
</a-button>
<span class="c9610-create__selected">
{{ t('order.customs9610.create.selected', { count: selectedKeys.length }) }}
<a-link v-if="selectedKeys.length" :hoverable="false" @click="clearSelection">
{{ t('order.customs9610.create.clearSelected') }}
</a-link>
</span>
</div>
<a-table
row-key="id"
size="small"
:loading="orderLoading"
:data="orderRows"
:columns="orderColumns"
:row-selection="rowSelection"
:selected-keys="selectedKeys"
:pagination="orderPagination"
:scroll="{ y: 300 }"
:row-class="(record: OrderPickRow) => (record.is9610 ? 'c9610-create__row--disabled' : '')"
@selection-change="handleSelectionChange"
@page-change="loadOrders"
>
<template #remark="{ record }">
<a-tag v-if="record.is9610" size="small" color="gray">
{{ t('order.customs9610.create.already9610') }}
</a-tag>
</template>
<template #empty>
<div class="c9610-create__empty">
{{ needCustomer ? t('order.customs9610.create.selectCustomerFirst') : t('order.customs9610.create.emptyOrders') }}
</div>
</template>
</a-table>
<div v-if="failRows.length" class="c9610-create__fail">
<div class="c9610-create__fail-title">
{{ t('order.customs9610.create.failTitle', { count: failRows.length }) }}
</div>
<ul>
<li v-for="row in failRows" :key="row.orderId">
<b>{{ row.serviceNo }}</b>
<span>{{ row.message }}</span>
</li>
</ul>
</div>
<template #footer>
<div class="c9610-create__footer">
<a-button :disabled="submitting" @click="close">{{ t('common.cancel') }}</a-button>
<a-button type="primary" :loading="submitting" :disabled="!canSubmit" @click="handleSubmit">
{{ t('order.customs9610.create.submit', { count: selectedKeys.length }) }}
</a-button>
</div>
</template>
</ModalPro>
</template>
<style scoped lang="less">
.c9610-create__tip {
margin-bottom: 16px;
}
.c9610-create__section-title {
margin-bottom: 12px;
padding-left: 8px;
border-left: 3px solid var(--app-primary-color);
font-size: 14px;
font-weight: 600;
color: var(--color-text-1);
line-height: 1.2;
}
.c9610-create__form {
margin-bottom: 4px;
}
.c9610-create__toolbar {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.c9610-create__selected {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--color-text-2);
}
.c9610-create__empty {
padding: 24px 12px;
text-align: center;
font-size: 13px;
color: var(--color-text-3);
}
:deep(.c9610-create__row--disabled) {
color: var(--color-text-4);
}
.c9610-create__fail {
margin-top: 12px;
padding: 10px 12px;
border-radius: 4px;
background: rgb(var(--danger-1));
ul {
margin: 6px 0 0;
padding: 0;
list-style: none;
max-height: 120px;
overflow-y: auto;
}
li {
display: flex;
gap: 12px;
padding: 2px 0;
font-size: 12px;
color: rgb(var(--danger-6));
}
}
.c9610-create__fail-title {
font-size: 13px;
font-weight: 600;
color: rgb(var(--danger-6));
}
.c9610-create__footer {
display: flex;
justify-content: flex-end;
gap: 8px;
}
:deep(.arco-table-th) {
background: var(--color-fill-1);
}
</style>

View File

@ -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<number[]>([])
const statusCounts = ref({ total: 0, pending: 0, declared: 0 })
const declaring = ref(false)
const createModalRef = ref<InstanceType<typeof CreateCustoms9610Modal> | 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(() => {
<a-button
v-if="hasPermission('Order.Customs9610.DeclareBatch')"
class="app-btn-toolbar"
:loading="declaring"
:disabled="!selectedIds.length || declaring"
@click="handleBatchDeclare"
@click="handleOpenCreate"
>
{{ t('order.customs9610.actions.batchDeclare') }}
{{ t('order.customs9610.actions.createFromOrders') }}
</a-button>
<a-button
v-if="hasPermission('Order.Customs9610.FetchManifestBatch')"
@ -810,6 +786,14 @@ onMounted(() => {
</div>
</div>
<CreateCustoms9610Modal
ref="createModalRef"
:is-admin="isAdmin"
:customer-options="customerOptions"
:default-customer-id="adminCustomerId"
@success="handleCreateSuccess"
/>
<ModalPro
@register="registerEditModal"
:title="modalTitle"

View File

@ -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'

View File

@ -404,6 +404,9 @@
<span class="fee-summary-meta">
{{ $t('order.myOrders.drawer.fee.itemCount', { count: receivableFeeList.length }) }}
</span>
<span v-if="receivableDiscountTotals.size" class="fee-summary-discount">
{{ $t('order.myOrders.drawer.fee.includesDiscount', { amount: formatFeeTotals(receivableDiscountTotals) }) }}
</span>
</div>
</div>
@ -431,7 +434,10 @@
:class="col.key"
>
<template v-if="col.key === 'amount'">
<span class="fee-amount amount-receivable">
<span
class="fee-amount"
:class="isDiscountFeeRow(row) ? 'amount-discount' : 'amount-receivable'"
>
{{ formatFeeAmount(row.amount) }}
</span>
</template>
@ -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;
}

File diff suppressed because it is too large Load Diff

View File

@ -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) => ({

View File

@ -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: '品牌' },

View File

@ -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: '',

View File

@ -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<string, unknown>): 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<string, unknown>): 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<boolean> {
}
}
/** 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, () => {

View File

@ -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<Array<{ label: string; value: number }>>([])
const storeOptions = ref<StoreOption[]>([])
const storeIds = ref<number[]>([])
const orderStatuses = ref<string[]>([
'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<string[]>([...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<string, unknown>) => {
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<boolean> {
<a-modal
v-model:visible="modalVisible"
:title="t('order.platformStoreOrder.actions.pullOrders')"
:width="720"
:ok-loading="loading"
:ok-text="t('order.platformStoreOrder.actions.startPull')"
:ok-button-props="{ disabled: !canSubmit }"
unmount-on-close
:on-before-ok="handleSubmit"
>
<a-form :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 16 }">
<a-form class="pull-form" :label-col-props="{ span: 6 }" :wrapper-col-props="{ span: 16 }">
<a-form-item :label="t('order.platformStoreOrder.pull.store')" required>
<a-select
v-model="storeIds"
multiple
allow-clear
:loading="storeLoading"
:options="storeOptions"
:placeholder="t('order.platformStoreOrder.pull.storePlaceholder')"
:max-tag-count="3"
/>
<div class="pull-block">
<div class="pull-row">
<a-select
v-model="storeIds"
class="pull-grow"
multiple
allow-clear
:loading="storeLoading"
:options="visibleStoreOptions"
:placeholder="t('order.platformStoreOrder.pull.storePlaceholder')"
:max-tag-count="2"
/>
<a-button
type="text"
size="small"
:disabled="!visibleStoreOptions.length"
@click="toggleAllStores"
>
{{ allStoresChecked ? t('order.platformStoreOrder.pull.statusClear') : t('order.platformStoreOrder.pull.storeAll') }}
</a-button>
</div>
<div v-if="!storeLoading && !visibleStoreOptions.length" class="hint">
{{ t('order.platformStoreOrder.message.noStore') }}
</div>
</div>
</a-form-item>
<a-form-item :label="t('order.platformStoreOrder.pull.status')" required>
<a-checkbox-group v-model="orderStatuses" :options="statusOptions" />
<div class="pull-block">
<div class="pull-row pull-row--between">
<span class="hint">
{{ t('order.platformStoreOrder.pull.statusCount', { count: orderStatuses.length }) }}
</span>
<a-button type="text" size="small" @click="toggleAllStatuses">
{{ allStatusesChecked ? t('order.platformStoreOrder.pull.statusClear') : t('order.platformStoreOrder.pull.statusAll') }}
</a-button>
</div>
<a-checkbox-group v-model="orderStatuses" class="status-grid">
<a-checkbox v-for="status in TIKTOK_PULL_STATUSES" :key="status" :value="status">
<span class="status-name">{{ t(`order.platformStoreOrder.pull.status.${status}`) }}</span>
<span class="status-code">{{ status }}</span>
</a-checkbox>
</a-checkbox-group>
</div>
</a-form-item>
<a-form-item :label="t('order.platformStoreOrder.pull.time')">
<a-range-picker v-model="dateRange" style="width: 100%" />
<div class="hint">{{ t('order.platformStoreOrder.pull.timeHint') }}</div>
<div class="pull-block">
<a-range-picker
v-model="dateRange"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
<div class="pull-row">
<a-button type="text" size="small" @click="applyRange(1)">
{{ t('order.platformStoreOrder.pull.today') }}
</a-button>
<a-button type="text" size="small" @click="applyRange(7)">
{{ t('order.platformStoreOrder.pull.last7') }}
</a-button>
<a-button type="text" size="small" @click="applyRange(15)">
{{ t('order.platformStoreOrder.pull.last15') }}
</a-button>
</div>
<div class="hint">{{ t('order.platformStoreOrder.pull.timeHint') }}</div>
</div>
</a-form-item>
</a-form>
</a-modal>
</template>
<style scoped lang="less">
.hint {
margin-top: 8px;
.pull-form {
:deep(.arco-form-item) {
align-items: flex-start;
margin-bottom: 16px;
}
:deep(.arco-form-item-label-col) {
line-height: 32px;
}
:deep(.arco-form-item-content) {
min-width: 0;
}
}
.pull-block {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 8px;
width: 100%;
}
.pull-row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
.pull-row--between {
justify-content: space-between;
}
.pull-grow {
flex: 1;
min-width: 0;
}
.status-grid {
display: grid !important;
grid-template-columns: 1fr 1fr;
gap: 10px 12px;
width: 100%;
:deep(.arco-checkbox) {
margin: 0;
align-items: center;
line-height: 22px;
white-space: nowrap;
}
}
.status-name {
color: var(--color-text-1);
}
.status-code {
margin-left: 6px;
color: var(--color-text-3);
font-size: 12px;
}
.hint {
color: var(--color-text-3);
font-size: 12px;
line-height: 20px;
}
</style>

View File

@ -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<string, unknown>)
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 })
</script>
@ -213,13 +204,6 @@ defineExpose({ open, close: handleCancel })
<template #footer>
<div class="drawer-footer-banner">
<a-button @click="handleCancel">{{ t('warehouse.common.cancel') }}</a-button>
<a-button
v-if="display?.shippingLabelUrl"
type="primary"
@click="openLabelUrl"
>
{{ t('order.platformStoreOrder.drawer.openLabel') }}
</a-button>
</div>
</template>

View File

@ -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<StoreOrderBatchAction | null>(null)
const actionLoading = computed(() => runningAction.value !== null)
const labelProgress = ref({ done: 0, total: 0 })
const LABEL_FETCH_CONCURRENCY = 3
const matchWarehouseId = ref<number>()
const matchProductCode = ref<string>()
const detailDrawerMounted = ref(false)
@ -115,6 +121,7 @@ async function ensureStoreDetailDrawer() {
}
const createModalMode = ref<CreateLogisticsMode>('logistics')
const createModalIds = ref<number[]>([])
const createModalCustomerId = ref<number>()
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<string, unknown>) {
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<number>(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<Array<{ id: number | string; label: string; url: string }>>([])
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(
<a-button
v-if="hasPermission('Order.PlatformStoreOrder.Pull')"
class="app-btn-toolbar"
:loading="actionLoading"
@click="openPullModal"
>
{{ t('order.platformStoreOrder.actions.pullOrders') }}
</a-button>
<a-button
v-if="hasPermission('Order.PlatformStoreOrder.MatchInventory')"
class="app-btn-toolbar"
@click="handleBatchMatch"
>
{{ t('order.platformStoreOrder.actions.batchMatch') }}
</a-button>
</div>
</div>
</div>
@ -777,7 +805,7 @@ watch(
<a-button
v-if="hasPermission('Order.PlatformStoreOrder.Sync')"
class="app-btn-toolbar"
:loading="actionLoading"
:loading="runningAction === 'sync'"
:disabled="!hasSelectedRows || actionLoading"
@click="handleSyncStatus"
>
@ -786,12 +814,42 @@ watch(
<a-button
v-if="hasPermission('Order.PlatformStoreOrder.Rts')"
class="app-btn-toolbar"
:loading="actionLoading"
:loading="runningAction === 'rts'"
:disabled="!hasSelectedRows || actionLoading"
@click="handleReadyToShip()"
>
{{ t('order.platformStoreOrder.actions.readyToShip') }}
</a-button>
<a-button
v-if="hasPermission('Order.PlatformStoreOrder.Label')"
class="app-btn-toolbar"
:loading="runningAction === 'label'"
:disabled="!hasSelectedRows || actionLoading"
@click="handleFetchLabel"
>
<template v-if="runningAction === 'label' && labelProgress.total > 1">
{{ t('order.platformStoreOrder.actions.fetchLabelProgress', labelProgress) }}
</template>
<template v-else>{{ t('order.platformStoreOrder.actions.fetchLabel') }}</template>
</a-button>
<a-button
v-if="hasPermission('Order.PlatformStoreOrder.ToggleSuspend')"
class="app-btn-toolbar"
:loading="runningAction === 'suspend'"
:disabled="!hasSelectedRows || actionLoading"
@click="handleSuspendBatch(true)"
>
{{ t('order.platformStoreOrder.actions.suspend') }}
</a-button>
<a-button
v-if="hasPermission('Order.PlatformStoreOrder.ToggleSuspend')"
class="app-btn-toolbar"
:loading="runningAction === 'resume'"
:disabled="!hasSelectedRows || actionLoading"
@click="handleSuspendBatch(false)"
>
{{ t('order.platformStoreOrder.actions.resume') }}
</a-button>
<a-button
v-if="hasPermission('Order.PlatformStoreOrder.CreateLogistics')"
class="app-btn-toolbar"
@ -860,6 +918,7 @@ watch(
v-if="pullVisible"
v-model:visible="pullVisible"
:customer-id="withCustomerId({}).customerId"
:platform-code="platformCode"
@success="() => { loadSummary(); loadList() }"
/>
@ -872,9 +931,17 @@ watch(
:warehouse-options="warehouseOptions"
:default-product-code="createModalDefaultProductCode"
:default-warehouse-id="matchWarehouseId"
:customer-id="withCustomerId({}).customerId"
:customer-id="createModalCustomerId"
@success="onCreateLogisticsSuccess"
/>
<OrderPrintModal
v-if="singlePrintVisible"
v-model:visible="singlePrintVisible"
mode="single"
:items="singlePrintItems"
:preparing="singlePrintPreparing"
/>
</div>
</template>

View File

@ -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'

View File

@ -458,6 +458,9 @@
<span class="fee-summary-meta">
{{ $t('order.myOrders.drawer.fee.itemCount', { count: receivableFeeList.length }) }}
</span>
<span v-if="receivableDiscountTotals.size" class="fee-summary-discount">
{{ $t('order.myOrders.drawer.fee.includesDiscount', { amount: formatFeeTotals(receivableDiscountTotals) }) }}
</span>
</button>
<button
type="button"
@ -595,7 +598,10 @@
:class="col.key"
>
<template v-if="col.key === 'amount'">
<span class="fee-amount" :class="section.amountClass">
<span
class="fee-amount"
:class="isDiscountFeeRow(row) ? 'amount-discount' : section.amountClass"
>
{{ formatFeeAmount(row.amount) }}
</span>
</template>
@ -999,6 +1005,7 @@ import { getSalesProductDetail, getSalesProductList } from '@/api/channel/salesP
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 SlidingTabs from '@/components/SlidingTabs/index.vue';
@ -1946,6 +1953,20 @@ 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 receivableGrossTotals = computed(() =>
sumFeesByCurrency(receivableFeeList.value.filter((row) => !isDiscountFeeRow(row))),
);
const payableTotals = computed(() => sumFeesByCurrency(payableFeeList.value));
function calcGrossProfitTotals(
@ -2000,7 +2021,7 @@ const showForecastDiff = computed(() => forecastReceivableAmount.value != null);
const currentReceivablePrimary = computed(() =>
pickPrimaryTotal(
receivableTotals.value,
receivableGrossTotals.value,
detail.value?.receivableAmount ?? detail.value?.ReceivableAmount
)
);
@ -3621,6 +3642,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;
@ -3762,6 +3789,10 @@ defineExpose({
color: #00b42a;
}
&.amount-discount {
color: #ff7d00;
}
&.amount-payable {
color: #f53f3f;
}

View File

@ -1,368 +0,0 @@
<script setup lang="ts">
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 { getDictListAll, type DictDto } from '@/api/system/dict'
interface PayOrderInput {
id: number | string
waybillNo?: string
serviceNo?: string
}
interface PayFeeRow {
costItem: string
amount: string
currency: string
chargingStage: string
}
interface PayOrderPreview {
id: number | string
waybillNo: string
fees: PayFeeRow[]
totalText: string
totalAmount: number
error?: string
}
const props = defineProps<{
visible: boolean
orders: PayOrderInput[]
}>()
const emit = defineEmits<{
'update:visible': [value: boolean]
success: []
}>()
const { t } = useI18n()
const loading = ref(false)
const submitting = ref(false)
const previews = ref<PayOrderPreview[]>([])
const feeItemDictRows = ref<DictDto[]>([])
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.currency'), dataIndex: 'currency', width: 80 },
{ title: t('order.myOrders.drawer.chargingStage'), dataIndex: 'chargingStage', ellipsis: true },
])
const grandTotalText = computed(() => {
const totals = new Map<string, number>()
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)
}
}
return formatTotals(totals)
})
const canConfirm = computed(() =>
previews.value.length > 0
&& !loading.value
&& previews.value.every((item) => !item.error && item.totalAmount > 0),
)
function pick(raw: Record<string, any> | undefined, ...keys: string[]) {
if (!raw) return undefined
for (const k of keys) {
const v = raw[k]
if (v !== undefined && v !== null && v !== '') return v
}
return undefined
}
function normalizeFeeType(row: any): 'receivable' | 'payable' | '' {
const raw = String(row?.type ?? row?.feeType ?? row?.FeeType ?? '').toLowerCase()
if (raw === 'receivable' || raw === 'payable') return raw
return ''
}
function sumReceivableFees(list: any[]) {
const totals = new Map<string, number>()
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)
}
return totals
}
function formatTotals(totals: Map<string, number>) {
if (!totals.size) return '--'
return [...totals.entries()]
.map(([currency, amount]) => `${currency} ${amount.toFixed(2)}`)
.join(' + ')
}
function getCostItemLabel(row: any) {
const fromApi = row?.costItemName ?? row?.CostItemName
if (fromApi != null && fromApi !== '') return String(fromApi)
const code = row?.costItem ?? row?.CostItem
if (code == null || code === '') return '--'
const c = String(code).trim()
const hit = feeItemDictRows.value.find(
(r) => String(r.dictCode ?? (r as any).DictCode ?? '').trim() === c,
)
if (hit?.dictName) return hit.dictName
const byName = feeItemDictRows.value.find(
(r) => String(r.dictName ?? (r as any).DictName ?? '').trim() === c,
)
return byName?.dictName || c
}
function getChargingStageLabel(stage: string) {
if (!stage) return '--'
const key = `finance.receivablePayable.chargingStage.${stage}`
const translated = t(key)
return translated !== key ? translated : stage
}
function mapFeeRows(list: any[]): PayFeeRow[] {
return list.map((row) => {
const stage = String(row?.chargingStage ?? row?.ChargingStage ?? '').trim()
const amount = Number(row?.amount)
return {
costItem: getCostItemLabel(row),
amount: Number.isFinite(amount) ? amount.toFixed(2) : '--',
currency: String(row?.currency ?? 'CNY').trim() || 'CNY',
chargingStage: getChargingStageLabel(stage),
}
})
}
async function ensureFeeItemDictLoaded() {
if (feeItemDictRows.value.length) return
try {
feeItemDictRows.value = await getDictListAll({ dictType: 'fee_item' })
} catch {
feeItemDictRows.value = []
}
}
async function loadPreviews() {
if (!props.orders.length) {
previews.value = []
return
}
loading.value = true
previews.value = []
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'),
})
}
}
previews.value = next
} finally {
loading.value = false
}
}
function closeModal() {
emit('update:visible', false)
}
async function handleConfirm() {
if (!canConfirm.value || submitting.value) return
submitting.value = true
try {
for (const preview of previews.value) {
await postOrderPay({ id: preview.id })
}
Message.success(t('order.myOrders.modal.paySuccess'))
emit('success')
closeModal()
} catch (e: any) {
Message.error(e?.message || t('order.myOrders.modal.payFailed'))
} finally {
submitting.value = false
}
}
watch(
() => [props.visible, props.orders] as const,
([visible]) => {
if (visible) {
loadPreviews()
} else {
previews.value = []
submitting.value = false
}
},
{ immediate: true },
)
</script>
<template>
<a-modal
:visible="visible"
:title="t('order.myOrders.modal.payTitle')"
:width="760"
:mask-closable="false"
unmount-on-close
@cancel="closeModal"
>
<a-spin :loading="loading" class="pay-confirm-spin">
<div v-if="previews.length" class="pay-confirm-body">
<div
v-for="preview in previews"
:key="String(preview.id)"
class="pay-order-block"
>
<div class="pay-order-head">
<span class="pay-order-label">{{ t('order.myOrders.table.waybillNo') }}</span>
<span class="pay-order-value">{{ preview.waybillNo }}</span>
<span class="pay-order-total">
{{ t('order.myOrders.modal.payOrderTotal') }}:{{ preview.totalText }}
</span>
</div>
<a-alert
v-if="preview.error"
type="warning"
show-icon
class="pay-order-alert"
>
{{ preview.error }}
</a-alert>
<a-table
v-else
:columns="columns"
:data="preview.fees"
:pagination="false"
size="small"
bordered
class="pay-fee-table"
/>
</div>
<div v-if="previews.length > 1" class="pay-grand-total">
<span>{{ t('order.myOrders.modal.payGrandTotal') }}</span>
<strong>{{ grandTotalText }}</strong>
</div>
</div>
<a-empty v-else-if="!loading" :description="t('order.myOrders.modal.payEmpty')" />
</a-spin>
<template #footer>
<a-button @click="closeModal">{{ t('warehouse.common.cancel') }}</a-button>
<a-button
type="primary"
:loading="submitting"
:disabled="!canConfirm"
@click="handleConfirm"
>
{{ t('order.myOrders.modal.payConfirm') }}
</a-button>
</template>
</a-modal>
</template>
<style scoped lang="less">
.pay-confirm-spin {
display: block;
width: 100%;
min-height: 120px;
}
.pay-confirm-body {
display: flex;
flex-direction: column;
gap: 16px;
}
.pay-order-block {
padding: 12px;
border: 1px solid var(--color-border-2);
border-radius: 8px;
background: var(--color-fill-1);
}
.pay-order-head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.pay-order-label {
color: var(--color-text-3);
font-size: 13px;
}
.pay-order-value {
color: var(--color-text-1);
font-weight: 600;
}
.pay-order-total {
margin-left: auto;
color: var(--app-primary-color);
font-size: 13px;
font-weight: 500;
}
.pay-order-alert {
margin-bottom: 0;
}
.pay-fee-table {
background: var(--app-card-bg);
}
.pay-grand-total {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding-top: 4px;
font-size: 14px;
strong {
color: var(--app-primary-color);
font-size: 16px;
}
}
</style>

View File

@ -110,7 +110,7 @@
() => import('./components/OrderDetailDrawer.vue'),
);
const OrderPayConfirmModal = defineAsyncComponent(
() => import('./components/OrderPayConfirmModal.vue'),
() => import('../myOrders/components/OrderPayConfirmModal.vue'),
);
const MarkProblematicModal = defineAsyncComponent(
() => import('./components/MarkProblematicModal.vue'),
@ -1193,6 +1193,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) => ({

View File

@ -344,7 +344,8 @@ const formRules = computed(() => ({
}))
function customCellRenderer(_row: number, col: number, _value: any, rowData: NoticeDto): string | null {
const dataColIndex = col - 1
// 固定列序:操作 + 序号,数据列从 index 2 起
const dataColIndex = col - 2
if (dataColIndex < 0 || dataColIndex >= hotColumns.value.length) return null
const key = hotColumns.value[dataColIndex].key
if (key === 'title') {
@ -362,15 +363,16 @@ function customCellRenderer(_row: number, col: number, _value: any, rowData: Not
return rowData.isPopup === 1 ? t('system.announcement.popup.yes') : t('system.announcement.popup.no')
}
if (key === 'receiverTypeName') return formatReceiverTypeName(rowData)
if (key === 'readCount' || key === 'receiverCount') {
if (key === 'receiverCount') {
const ids = rowData.receiverSelectionIds ?? rowData.receiverIds ?? []
if (!Array.isArray(ids) || ids.length === 0) {
return t('system.announcement.receiverType.all')
}
if (key === 'readCount') {
return String(rowData.readCount ?? 0)
}
if (key === 'receiverCount') {
const ids = rowData.receiverSelectionIds ?? rowData.receiverIds ?? []
// 广播:接收对象列已显示「全部」,人数列显示 —,避免与已读人数混淆
if (!Array.isArray(ids) || ids.length === 0) {
return '—'
}
const val = rowData[key as keyof NoticeDto]
return val != null ? String(val) : '0'
return String(rowData.receiverCount ?? ids.length)
}
return null
}
@ -407,6 +409,7 @@ async function loadData() {
pageSize: pageSize.value,
noticeType: activeTab.value,
keyword: keyword.value.trim() || undefined,
manage: canManage.value,
})
dataSource.value = items || []
totalCount.value = total || 0
@ -640,6 +643,11 @@ async function handleModalOk() {
}
modalVisible.value = false
reloadTable()
} catch (err) {
const msg = err instanceof Error ? err.message : String(err)
if (msg && !/cancel|abort/i.test(msg)) {
Message.error(msg)
}
} finally {
modalLoading.value = false
}

View File

@ -0,0 +1,232 @@
<template>
<a-modal
v-model:visible="visible"
:title="t('system.country.import.modalTitle')"
:width="520"
:ok-loading="importing"
:ok-text="t('system.country.import.submit')"
:cancel-text="t('system.country.import.cancel')"
unmount-on-close
@before-ok="handleBeforeOk"
@cancel="handleCancel"
>
<div class="country-import-modal">
<div class="import-tip">{{ t('system.country.import.tip') }}</div>
<div class="import-mode">
<div class="import-mode-label">{{ t('system.country.import.modeLabel') }}</div>
<SlidingTabs
v-model="importMode"
size="mini"
:aria-label="t('system.country.import.modeLabel')"
:items="importModeTabs"
/>
<div class="import-mode-tip">
{{
importMode === 'full'
? t('system.country.import.mode.fullTip')
: t('system.country.import.mode.incrementalTip')
}}
</div>
</div>
<div class="import-actions">
<a-button class="app-btn-primary-soft" @click="handleDownloadTemplate">
<template #icon>
<IconDownload />
</template>
{{ t('system.country.import.downloadTemplate') }}
</a-button>
</div>
<a-upload
draggable
:auto-upload="false"
:show-file-list="true"
:limit="1"
accept=".xlsx,.xls"
:file-list="fileList"
@change="handleFileChange"
/>
<div v-if="importResult" class="import-result">
<a-alert type="info" :show-icon="false">
{{
t('system.country.import.resultSummary', {
success: importResult.successCount,
skip: importResult.skipCount,
fail: importResult.failCount,
deleted: importResult.deletedCount,
})
}}
</a-alert>
<ul v-if="importResult.failures.length" class="import-fail-list">
<li v-for="item in importResult.failures" :key="`${item.rowNumber}-${item.reason}`">
{{ t('system.country.import.failRow', { row: item.rowNumber, reason: item.reason }) }}
</li>
</ul>
</div>
</div>
</a-modal>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import type { FileItem } from '@arco-design/web-vue'
import { Message, Modal } from '@arco-design/web-vue'
import { IconDownload } from '@arco-design/web-vue/es/icon'
import SlidingTabs from '@/components/SlidingTabs/index.vue'
import {
downloadCountryImportTemplate,
importCountry,
type CountryImportResult,
} from '@/api/system/country'
const emit = defineEmits<{
success: []
}>()
const { t } = useI18n()
const visible = ref(false)
const fileList = ref<FileItem[]>([])
const selectedFile = ref<File | null>(null)
const importing = ref(false)
const importMode = ref<'incremental' | 'full'>('incremental')
const importResult = ref<CountryImportResult | null>(null)
const importModeTabs = computed(() => [
{ key: 'incremental', label: t('system.country.import.mode.incremental') },
{ key: 'full', label: t('system.country.import.mode.full') },
])
function open() {
fileList.value = []
selectedFile.value = null
importResult.value = null
importMode.value = 'incremental'
visible.value = true
}
function handleFileChange(_: FileItem[], fileItem: FileItem) {
fileList.value = fileItem ? [fileItem] : []
selectedFile.value = (fileItem?.file as File) ?? null
importResult.value = null
}
function handleDownloadTemplate() {
try {
downloadCountryImportTemplate()
Message.success(t('system.country.import.downloadSuccess'))
} catch (e: unknown) {
const err = e as { message?: string }
Message.error(err?.message || t('system.country.import.downloadFailed'))
}
}
function confirmFullImport(): Promise<boolean> {
return new Promise((resolve) => {
Modal.warning({
title: t('system.country.import.warningTitle'),
content: t('system.country.import.mode.fullConfirm'),
hideCancel: false,
okText: t('system.country.import.submit'),
cancelText: t('system.country.import.cancel'),
onOk: () => resolve(true),
onCancel: () => resolve(false),
})
})
}
async function handleBeforeOk() {
if (!selectedFile.value) {
Message.warning(t('system.country.import.needFile'))
return false
}
if (importMode.value === 'full') {
const confirmed = await confirmFullImport()
if (!confirmed) return false
}
importing.value = true
try {
const result = await importCountry(selectedFile.value, importMode.value)
importResult.value = result
if (result.successCount > 0 || result.skipCount > 0 || result.deletedCount > 0) {
emit('success')
}
if (result.failCount === 0) {
Message.success(t('system.country.import.success'))
visible.value = false
return true
}
if (result.successCount > 0 || result.skipCount > 0) {
Message.warning(t('system.country.import.partialSuccess'))
} else {
Message.error(t('system.country.import.allFailed'))
}
return false
} catch (e: unknown) {
const err = e as { message?: string }
Message.error(err?.message || t('system.country.import.failed'))
return false
} finally {
importing.value = false
}
}
function handleCancel() {
visible.value = false
}
defineExpose({ open })
</script>
<style scoped lang="less">
.country-import-modal {
display: flex;
flex-direction: column;
gap: 16px;
}
.import-tip {
font-size: 13px;
color: var(--color-text-2);
line-height: 1.6;
}
.import-mode {
display: flex;
flex-direction: column;
gap: 8px;
}
.import-mode-label {
font-size: 13px;
color: var(--color-text-1);
font-weight: 500;
}
.import-mode-tip {
font-size: 12px;
color: var(--color-text-3);
line-height: 1.5;
}
.import-actions {
display: flex;
gap: 8px;
}
.import-result {
display: flex;
flex-direction: column;
gap: 8px;
}
.import-fail-list {
margin: 0;
padding-left: 18px;
max-height: 160px;
overflow-y: auto;
font-size: 12px;
color: #f53f3f;
}
</style>

View File

@ -0,0 +1,395 @@
<template>
<div class="country-page">
<div class="country-page-card">
<div class="table-toolbar">
<div class="toolbar-left">
<a-input
v-model="keyword"
:placeholder="t('system.country.searchPlaceholder')"
allow-clear
style="width: 280px"
@press-enter="handleSearch"
@clear="handleSearch"
/>
<a-button class="app-btn-search" @click="handleSearch">
{{ t('common.search') }}
</a-button>
<a-button class="btn-add app-btn-primary-soft" @click="handleAdd">
{{ t('common.add') }}
</a-button>
<a-button @click="openImport">
{{ t('system.country.actions.import') }}
</a-button>
<a-button :loading="exporting" @click="handleExport">
{{ t('system.country.actions.export') }}
</a-button>
</div>
<div class="toolbar-right">
<PaginationPro
:total-count="totalCount"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</div>
</div>
<div ref="tableContainerRef" class="list-wrap">
<HandsontablePro
:columns="hotColumns"
:data="pageRows"
:total="totalCount"
:current-page="currentPage"
:page-size="pageSize"
:show-action-column="true"
:action-renderer="actionRenderer"
:stretch-columns="true"
:height="handsontableHeight"
:read-only="true"
:enable-column-filter="false"
:serial-no-header="t('system.country.table.index')"
:action-column-header="t('common.actions')"
@action-click="handleActionClick"
/>
</div>
</div>
<a-modal
v-model:visible="modalVisible"
:title="isEdit ? t('system.country.modal.editTitle') : t('system.country.modal.addTitle')"
:confirm-loading="modalLoading"
width="560px"
:mask-closable="false"
unmount-on-close
@before-ok="handleModalOk"
@cancel="handleModalCancel"
>
<a-form
ref="formRef"
:model="formState"
:rules="formRules"
:label-col-props="{ span: 6 }"
:wrapper-col-props="{ span: 16 }"
>
<a-form-item :label="t('system.country.fields.countryCode')" field="countryCode">
<a-input
v-model="formState.countryCode"
:placeholder="t('system.country.placeholder.countryCode')"
:max-length="2"
/>
</a-form-item>
<a-form-item :label="t('system.country.fields.chineseName')" field="chineseName">
<a-input v-model="formState.chineseName" :placeholder="t('system.country.placeholder.input')" />
</a-form-item>
<a-form-item :label="t('system.country.fields.englishName')" field="englishName">
<a-input v-model="formState.englishName" :placeholder="t('system.country.placeholder.input')" />
</a-form-item>
</a-form>
</a-modal>
<CountryImportModal ref="importModalRef" @success="loadList" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Message, Modal } from '@arco-design/web-vue'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
import {
createCountry,
deleteCountry,
exportCountryExcel,
getCountryList,
mapCountryRow,
updateCountry,
type CountryDto,
} from '@/api/system/country'
import CountryImportModal from './CountryImportModal.vue'
defineOptions({ name: 'SystemCountry' })
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 400))
const keyword = ref('')
const appliedKeyword = ref('')
const allRows = ref<CountryDto[]>([])
const currentPage = ref(1)
const pageSize = ref(50)
const exporting = ref(false)
const modalVisible = ref(false)
const modalLoading = ref(false)
const isEdit = ref(false)
const editingId = ref<number | null>(null)
const formRef = ref()
const importModalRef = ref<{ open: () => void } | null>(null)
const formState = reactive({
countryCode: '',
chineseName: '',
englishName: '',
})
const formRules = computed(() => ({
countryCode: [
{ required: true, message: t('system.country.rules.countryCodeRequired') },
{
validator: (value: string, callback: (error?: string) => void) => {
if (!/^[A-Za-z]{2}$/.test(String(value || '').trim())) {
callback(t('system.country.rules.countryCodeFormat'))
return
}
callback()
},
},
],
chineseName: [{ required: true, message: t('system.country.rules.chineseNameRequired') }],
englishName: [{ required: true, message: t('system.country.rules.englishNameRequired') }],
}))
const filteredRows = computed(() => {
const key = appliedKeyword.value.trim().toLowerCase()
if (!key) return allRows.value
return allRows.value.filter((row) => {
return (
row.countryCode.toLowerCase().includes(key) ||
row.chineseName.toLowerCase().includes(key) ||
row.englishName.toLowerCase().includes(key)
)
})
})
const totalCount = computed(() => filteredRows.value.length)
const pageRows = computed(() => {
const start = (currentPage.value - 1) * pageSize.value
return filteredRows.value.slice(start, start + pageSize.value)
})
const hotColumns = computed(() => [
{ key: 'countryCode', label: t('system.country.fields.countryCode'), width: 120 },
{ key: 'chineseName', label: t('system.country.fields.chineseName'), width: 180 },
{ key: 'englishName', label: t('system.country.fields.englishName'), width: 220 },
{ key: 'createTime', label: t('system.country.fields.createTime'), width: 170 },
{ key: 'updateTime', label: t('system.country.fields.updateTime'), width: 170 },
])
function actionRenderer() {
return wrapWarehouseActions([
renderWarehouseActionBtn('edit', t('system.country.actions.edit')),
renderWarehouseActionBtn('delete', t('system.country.actions.delete')),
])
}
function handleActionClick(row: CountryDto, _rowIndex: number, action?: string) {
if (action === 'edit') {
handleEdit(row)
return
}
if (action === 'delete') {
handleDelete(row)
}
}
function handleSearch() {
appliedKeyword.value = keyword.value
currentPage.value = 1
}
function handlePageChange(page: number) {
currentPage.value = page
}
function handlePageSizeChange(size: number) {
pageSize.value = size
currentPage.value = 1
}
function resetForm() {
formState.countryCode = ''
formState.chineseName = ''
formState.englishName = ''
editingId.value = null
isEdit.value = false
}
function handleAdd() {
resetForm()
modalVisible.value = true
}
function handleEdit(row: CountryDto) {
isEdit.value = true
editingId.value = row.id
formState.countryCode = row.countryCode
formState.chineseName = row.chineseName
formState.englishName = row.englishName
modalVisible.value = true
}
function handleModalCancel() {
modalVisible.value = false
}
async function handleModalOk() {
try {
await formRef.value?.validate()
} catch {
return false
}
modalLoading.value = true
const payload = {
countryCode: formState.countryCode.trim().toUpperCase(),
chineseName: formState.chineseName.trim(),
englishName: formState.englishName.trim(),
}
try {
if (isEdit.value && editingId.value) {
await updateCountry({ id: editingId.value, ...payload })
Message.success(t('system.country.message.updateSuccess'))
} else {
await createCountry(payload)
Message.success(t('system.country.message.createSuccess'))
}
modalVisible.value = false
await loadList()
return true
} catch (e: unknown) {
const err = e as { message?: string }
Message.error(err?.message || t('system.country.message.saveFailed'))
return false
} finally {
modalLoading.value = false
}
}
function handleDelete(row: CountryDto) {
Modal.warning({
title: t('system.country.actions.delete'),
content: t('system.country.message.deleteConfirm', {
name: row.chineseName || row.countryCode,
}),
hideCancel: false,
okText: t('common.delete'),
cancelText: t('system.country.import.cancel'),
onOk: async () => {
try {
await deleteCountry(row.id)
Message.success(t('system.country.message.deleteSuccess'))
await loadList()
} catch (e: unknown) {
const err = e as { message?: string }
Message.error(err?.message || t('system.country.message.deleteFailed'))
}
},
})
}
function openImport() {
importModalRef.value?.open()
}
function handleExport() {
if (!filteredRows.value.length) {
Message.warning(t('system.country.message.exportEmpty'))
return
}
exporting.value = true
try {
exportCountryExcel(filteredRows.value)
Message.success(t('system.country.message.exportSuccess', { count: filteredRows.value.length }))
} catch (e: unknown) {
const err = e as { message?: string }
Message.error(err?.message || t('system.country.message.exportFailed'))
} finally {
exporting.value = false
}
}
async function loadList() {
try {
const { items } = await getCountryList()
allRows.value = (items as Record<string, unknown>[]).map(mapCountryRow).filter((row) => row.id > 0)
const maxPage = Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value) || 1)
if (currentPage.value > maxPage) currentPage.value = maxPage
} catch (e: unknown) {
const err = e as { message?: string }
Message.error(err?.message || t('system.country.message.loadFailed'))
}
}
onMounted(() => {
loadList()
})
</script>
<style scoped lang="less">
.country-page {
width: 100%;
box-sizing: border-box;
padding: 16px 16px 24px;
display: flex;
flex-direction: column;
background: var(--app-page-bg);
flex: 1;
min-height: 0;
overflow: hidden;
}
.country-page-card {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
background: var(--app-card-bg);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
overflow: hidden;
}
.table-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 16px 0;
margin-bottom: 16px;
flex-shrink: 0;
flex-wrap: wrap;
gap: 8px;
.toolbar-left {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.toolbar-right {
display: flex;
align-items: center;
}
}
.list-wrap {
flex: 1;
min-height: 0;
padding: 0 16px 16px;
display: flex;
flex-direction: column;
overflow: hidden;
}
.list-wrap :deep(.handsontable-pro-container) {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
</style>

View File

@ -344,6 +344,8 @@ export const OMS_DICT_BATCH_SEED: DictSeedRow[] = [
['proxyPacking', '代打包费'],
['fuelSurcharge', '燃油附加费'],
['remoteFee', '偏远费'],
['coupon_discount', '优惠券抵扣'],
['member_discount', '会员折扣'],
]),
...pack('order_handle_status', [
['pending', '待处理'],

View File

@ -354,7 +354,7 @@ export default defineConfig(({ mode }) => {
secure: false,
bypass(req) {
const path = req.url?.split('?')[0] ?? '';
const systemSpaExact = ['/system/dict', '/system/warehouseAddress', '/system/announcement'];
const systemSpaExact = ['/system/dict', '/system/warehouseAddress', '/system/announcement', '/system/country'];
if (systemSpaExact.some((p) => path === p || path === `${p}/`)) {
return '/index.html';
}
@ -547,6 +547,7 @@ export default defineConfig(({ mode }) => {
'/system/dict',
'/system/warehouseAddress',
'/system/announcement',
'/system/country',
// 仓储页面路径与 API 同段,必须精确匹配(/warehouse/parcel/list 走代理)
'/warehouse/parcel',
'/warehouse/inventory',