20261008-ide1.0发布 #1

Merged
zzh merged 24 commits from test into master 2026-10-08 09:19:21 +08:00
37 changed files with 1442 additions and 251 deletions
Showing only changes of commit f823f67a49 - Show all commits

View File

@ -1,10 +1,13 @@
# =============================================================================
# 本地开发默认 → 生产环境(Vite 代理到 api.ideship.com,无浏览器 CORS)
# 切测试:pnpm 用 .env.nanafa 构建,或临时改回 api.nanafa.com
# 本地开发 → ideship 测试环境(禁止本地直连生产)
# Vite 代理:https://api.test.ideship.com/{admin|itfx|track}
# 站点对照:https://test.ideship.com
# =============================================================================
VITE_DEPLOY_TARGET=ideship
VITE_OMS_ADMIN_URL=https://api.ideship.com/admin
VITE_API_ORIGIN=https://api.ideship.com
VITE_OMS_ADMIN_URL=https://api.test.ideship.com/admin
VITE_API_ORIGIN=https://api.test.ideship.com
VITE_API_ADMIN_URL=https://api.test.ideship.com/admin
VITE_API_ASSET_ORIGIN=https://api.test.ideship.com/admin
VITE_WECOM_KF_URL=https://work.weixin.qq.com/kfid/kfcae09c206f6c7045a

View File

@ -1,9 +0,0 @@
# =============================================================================
# 本地开发直连生产 API(ideship)— pnpm dev:ideship
# 浏览器仍请求 localhost,由 Vite 代理到 api.ideship.com(避免 CORS)
# =============================================================================
VITE_DEPLOY_TARGET=ideship
VITE_OMS_ADMIN_URL=https://api.ideship.com/admin
VITE_API_ORIGIN=https://api.ideship.com
VITE_WECOM_KF_URL=https://work.weixin.qq.com/kfid/kfcae09c206f6c7045a

12
.env.ideship-test Normal file
View File

@ -0,0 +1,12 @@
# =============================================================================
# ideship 测试环境 — pnpm dev:test / pnpm build:test
# API:https://api.test.ideship.com/{admin|itfx|track}
# 站点:https://test.ideship.com
# =============================================================================
VITE_DEPLOY_TARGET=ideship
VITE_API_ORIGIN=https://api.test.ideship.com
VITE_API_ADMIN_URL=https://api.test.ideship.com/admin
VITE_API_ASSET_ORIGIN=https://api.test.ideship.com/admin
VITE_OMS_ADMIN_URL=https://api.test.ideship.com/admin
VITE_WECOM_KF_URL=https://work.weixin.qq.com/kfid/kfcae09c206f6c7045a

View File

@ -1,7 +1,8 @@
# =============================================================================
# 生产环境(ideship.com)— vite build / vite build --mode production
# 生产构建专用(pnpm build:prod)— 请勿用于本地开发直连
# 三套 API:https://api.ideship.com/{admin|itfx|track}
# 前端站点:https://ideship.com
# 本地开发请用 .env.development → api.test.ideship.com
# =============================================================================
VITE_DEPLOY_TARGET=ideship
VITE_API_ORIGIN=https://api.ideship.com
@ -9,5 +10,4 @@ VITE_API_ADMIN_URL=https://api.ideship.com/admin
VITE_API_ASSET_ORIGIN=https://api.ideship.com/admin
VITE_OMS_ADMIN_URL=https://api.ideship.com/admin
# 企业微信客服
VITE_WECOM_KF_URL=https://work.weixin.qq.com/kfid/kfcae09c206f6c7045a

Binary file not shown.

View File

@ -11,11 +11,12 @@
"preinstall": "node -e \"if (!process.env.npm_config_user_agent || !process.env.npm_config_user_agent.includes('pnpm')) { console.error('Please use pnpm to install dependencies.'); process.exit(1); }\"",
"generate:order-template": "node scripts/generate-order-template.js",
"dev": "vite",
"dev:test": "vite --mode nanafa",
"dev:ideship": "vite --mode ideship",
"dev:test": "vite --mode ideship-test",
"dev:nanafa": "vite --mode nanafa",
"build": "cross-env BROWSERSLIST_IGNORE_OLD_DATA=1 node --max-old-space-size=4096 node_modules/vite/bin/vite.js build",
"build:prod": "cross-env BROWSERSLIST_IGNORE_OLD_DATA=1 node --max-old-space-size=4096 node_modules/vite/bin/vite.js build --mode production",
"build:test": "cross-env BROWSERSLIST_IGNORE_OLD_DATA=1 node --max-old-space-size=4096 node_modules/vite/bin/vite.js build --mode nanafa",
"build:test": "cross-env BROWSERSLIST_IGNORE_OLD_DATA=1 node --max-old-space-size=4096 node_modules/vite/bin/vite.js build --mode ideship-test",
"build:nanafa": "cross-env BROWSERSLIST_IGNORE_OLD_DATA=1 node --max-old-space-size=4096 node_modules/vite/bin/vite.js build --mode nanafa",
"postbuild": "node scripts/postbuild-cleanup-dist.js",
"report": "cross-env REPORT=true pnpm run build",
"preview": "npm run build && vite preview --host",

View File

@ -0,0 +1,35 @@
import { axiosInstance } from '@/api/rbac'
import { assertBusinessOk, omsUrl, parseListPayload, unwrapData } from '@/api/oms/common'
function postList(path: string, body: Record<string, unknown>) {
return axiosInstance.post(omsUrl(path), body).then((res) => parseListPayload(res.data))
}
function postOk(path: string, body: Record<string, unknown>) {
return axiosInstance.post(omsUrl(path), body).then((res) => {
assertBusinessOk(res.data)
return unwrapData(res.data)
})
}
function getData(path: string, params?: Record<string, unknown>) {
return axiosInstance.get(omsUrl(path), { params }).then((res) => unwrapData(res.data))
}
export const getExportQualificationList = (body: Record<string, unknown>) =>
postList('/customer/export-qualification/list', body)
export const getExportQualificationDetail = (id: number) =>
getData('/customer/export-qualification/detail', { id })
export const createExportQualification = (body: Record<string, unknown>) =>
postOk('/customer/export-qualification/create', body)
export const updateExportQualification = (body: Record<string, unknown>) =>
postOk('/customer/export-qualification/update', body)
export const deleteExportQualification = (id: number) =>
postOk('/customer/export-qualification/delete', { id })
export const setDefaultExportQualification = (id: number) =>
postOk('/customer/export-qualification/set-default', { id })

View File

@ -2175,11 +2175,16 @@ export interface OrderAllocationTrace {
selectedChannelName?: string
failReason?: string
traceTime?: string
poolCount?: number
conditionPassCount?: number
candidateCount?: number
candidates: OrderAllocationCandidate[]
skipped: Array<{
serviceChannelId?: number
channelCode?: string
channelName?: string
stage?: string
matchCondition?: string
reason: string
}>
}
@ -2222,11 +2227,16 @@ export async function getOrderAllocationTrace(params: {
selectedChannelName: raw?.selectedChannelName ?? raw?.SelectedChannelName,
failReason: raw?.failReason ?? raw?.FailReason,
traceTime: raw?.traceTime ?? raw?.TraceTime,
poolCount: Number(raw?.poolCount ?? raw?.PoolCount ?? 0),
conditionPassCount: Number(raw?.conditionPassCount ?? raw?.ConditionPassCount ?? 0),
candidateCount: Number(raw?.candidateCount ?? raw?.CandidateCount ?? 0),
candidates: (raw?.candidates ?? raw?.Candidates ?? []).map(mapCandidate),
skipped: (raw?.skipped ?? raw?.Skipped ?? []).map((s: any) => ({
serviceChannelId: s.serviceChannelId ?? s.ServiceChannelId,
channelCode: s.channelCode ?? s.ChannelCode,
channelName: s.channelName ?? s.ChannelName,
stage: s.stage ?? s.Stage,
matchCondition: s.matchCondition ?? s.MatchCondition,
reason: String(s.reason ?? s.Reason ?? ''),
})),
}

View File

@ -78,3 +78,19 @@ export const markPickupPicked = (id: number | string) =>
export const markPickupInbound = (id: number | string) =>
postOk('/order/pickup-order/mark-inbound', { id })
/** POST /order/pickup-order/downstream-forward → 从揽收单表组装 createOrderOne 并转发 */
export async function postPickupOrderDownstreamForward(id: number | string) {
const data = await postOk('/order/pickup-order/downstream-forward', { id })
const d = (data ?? {}) as Record<string, any>
return {
success: d.success ?? d.Success,
supplierCode: d.supplierCode ?? d.SupplierCode,
url: d.url ?? d.Url,
requestJson: d.requestJson ?? d.RequestJson,
statusCode: d.statusCode ?? d.StatusCode,
responseBody: d.responseBody ?? d.ResponseBody,
downstreamServiceNo: d.downstreamServiceNo ?? d.DownstreamServiceNo,
trackNo: d.trackNo ?? d.TrackNo,
}
}

View File

@ -6,7 +6,7 @@ import { useRbacUserStore } from '@/store/modules/rbac-user'
import { resolvePostLogoutLogin } from '@/utils/login-portal'
// 创建自定义 axios 实例
// baseURL 为空;生产 URL 由 OpenAPI.BASE / omsUrl 拼到 api.ideship.com
// baseURL 为空;远端由 OpenAPI.BASE / omsUrl 按 env 拼接(本地走 Vite 代理到测试环境)
export const axiosInstance = axios.create({
baseURL: '', // 始终为空,URL 由 OpenAPI.BASE + path 构建
timeout: 30000,

View File

@ -482,6 +482,14 @@
</a-dropdown>
</div>
</td>
<td
v-else-if="getNodeData(key)?.isLeaf !== true && slots['branch-node-extra']"
class="node-actions-cell branch-node-extra-cell"
>
<span class="branch-node-extra" @click.stop>
<slot name="branch-node-extra" :node-data="getNodeData(key)" :node-key="key"></slot>
</span>
</td>
</tr>
<!-- 第二行始终占位,保证单行/双行卡片高度一致(以两行为准) -->
<tr class="node-second-row">
@ -761,6 +769,16 @@
display: none !important;
}
:deep(.branch-node-extra .arco-icon) {
display: inline-block !important;
width: 14px !important;
height: 14px !important;
min-width: 14px !important;
flex: none !important;
font-size: 14px;
vertical-align: middle;
}
:deep(.arco-tree-node-wrapper),
:deep(.arco-tree-node-selected),
:deep(.arco-tree-node-selected > .arco-tree-node-title) {
@ -1288,6 +1306,31 @@
}
}
.branch-node-extra-cell {
width: 28px;
.branch-node-extra {
display: inline-flex;
align-items: center;
justify-content: flex-end;
:deep(.arco-btn) {
width: 22px !important;
min-width: 22px !important;
height: 22px !important;
min-height: 22px !important;
padding: 0 !important;
color: var(--clm-muted);
border-radius: 5px;
&:hover {
color: var(--clm-text);
background: var(--clm-hover);
}
}
}
}
.node-second-row-extra-cell {
text-align: right;
padding: 2px 0 0;

View File

@ -3,11 +3,12 @@
*
* | 目标 | 前端 | API |
* |------|------|-----|
* | ideship(生产) | https://ideship.com | https://api.ideship.com/{admin,itfx,track} |
* | nanafa(测试) | https://oms.nanafa.com | http://api.nanafa.com/{admin,itfx,track} |
* | ideship(生产构建) | https://ideship.com | https://api.ideship.com/{admin,itfx,track} |
* | ideship 测试(本地默认) | https://test.ideship.com | https://api.test.ideship.com/{admin,itfx,track} |
* | nanafa(旧测试) | https://oms.nanafa.com | http://api.nanafa.com/{admin,itfx,track} |
*
* 本地:pnpm dev(生产 API) / pnpm dev:test(测试 API)
* 构建:pnpm build(生产) / pnpm build:test(测试)
* 本地:pnpm dev / pnpm dev:test → 仅测试 API(禁止直连生产)
* 构建:pnpm build:prod(生产) / pnpm build:test(测试)
*/
export type DeployTarget = 'ideship' | 'nanafa'
@ -28,10 +29,19 @@ export const DEPLOY_TARGETS = {
},
} as const
/** 本地开发 / 测试构建默认的 ideship 测试网关(与 DEPLOY_TARGETS.ideship 生产区分) */
export const IDESHIP_TEST_API = {
siteOrigin: 'https://test.ideship.com',
apiOrigin: 'https://api.test.ideship.com',
adminBase: 'https://api.test.ideship.com/admin',
itfxBase: 'https://api.test.ideship.com/itfx',
trackBase: 'https://api.test.ideship.com/track',
} as const
export function resolveDeployTarget(): DeployTarget {
const raw = String(import.meta.env.VITE_DEPLOY_TARGET ?? '').trim().toLowerCase()
if (raw === 'nanafa') return 'nanafa'
if (raw === 'ideship') return 'ideship'
// 未显式配置时:生产构建默认 ideship,开发默认 nanafa
// 未显式配置:仅生产构建默认 ideship;开发默认走测试(由 .env.development 注入 URL)
return import.meta.env.PROD ? 'ideship' : 'nanafa'
}

View File

@ -285,6 +285,7 @@ export const PAGE_BUTTON_PARENT_ALIASES: Record<string, string> = {
'Order.Pickup.Query': 'Order.Pickup',
'Order.Customs9610.Query': 'Order.Customs9610',
'Order.SenderAddress.Query': 'Order.SenderAddress',
'User.ExportQualification.Query': 'User.ExportQualification',
'Order.PlatformStore.Query': 'Order.PlatformStore',
'Order.PlatformStoreOrder.Query': 'Order.PlatformStoreOrder',
'Warehouse.Sku.Query': 'Warehouse.Sku',
@ -606,7 +607,7 @@ const CORE_GROUPS: PageButtonGroup[] = [
{ code: 'Declare', name: '申报' },
{ code: 'DeclareBatch', name: '批量申报' },
{ code: 'FetchManifestBatch', name: '批量获取清单数据' },
{ code: 'FetchDeclarationFormBatch', name: '批量获取报关单' },
{ code: 'FetchDeclarationFormBatch', name: '预报下游' },
]),
B('Order.SenderAddress', [
{ code: 'Query', name: '查询' },
@ -798,6 +799,13 @@ const CORE_GROUPS: PageButtonGroup[] = [
{ code: 'ExpandAll', name: '全部展开' },
{ code: 'CollapseAll', name: '全部收起' },
]),
B('User.ExportQualification', [
{ code: 'Query', name: '查询' },
{ code: 'Create', name: '新增' },
{ code: 'Update', name: '修改' },
{ code: 'Delete', name: '删除' },
{ code: 'SetDefault', name: '设默认' },
]),
B('User.Marketing.Invite', [
{ code: 'Query', name: '查询' },
{ code: 'Copy', name: '复制邀请链接' },

8
src/env.d.ts vendored
View File

@ -37,13 +37,13 @@ declare module 'vue-pdf-embed' {
interface ImportMetaEnv {
readonly VITE_API_BASE_URL: string;
readonly VITE_OMS_URL?: string;
/** 部署目标:ideship(生产)| nanafa(测试) */
/** 部署目标:ideship | nanafa(本地开发请指向测试 API) */
readonly VITE_DEPLOY_TARGET?: 'ideship' | 'nanafa' | string;
/** 生产 Admin 基址,默认 https://api.ideship.com/admin */
/** Admin 基址;本地默认测试 https://api.test.ideship.com/admin */
readonly VITE_API_ADMIN_URL?: string;
readonly VITE_OMS_ADMIN_URL?: string;
/** 生产 API 根,默认 https://api.ideship.com */
/** API 根;本地默认测试 https://api.test.ideship.com */
readonly VITE_API_ORIGIN?: string;
/** 静态资源 origin,默认生产 https://api.ideship.com/admin */
/** 静态资源 origin;本地默认测试 admin */
readonly VITE_API_ASSET_ORIGIN?: string;
}

View File

@ -56,6 +56,7 @@ export default {
'menu.user.customer': 'Customer Management',
'menu.user.subAccount': 'Sub Accounts',
'menu.user.senderAddress': 'Address Management',
'menu.user.exportQualification': 'Export Qualification',
'menu.user.profile': 'Personal Center',
'menu.user.recharge': 'Recharge',
'menu.finance': 'Finance',
@ -153,6 +154,7 @@ export default {
'warehouse.sku.table.unitPrice': 'Unit Price',
'warehouse.sku.table.exHsCode': 'Export HSCode',
'warehouse.sku.table.imHsCode': 'Import HSCode',
'warehouse.sku.table.declareElement': 'Export Declare Element',
'warehouse.sku.table.totalQty': 'Qty',
'warehouse.sku.table.weightG': 'Weight(g)',
'warehouse.sku.table.remark': 'Remark',
@ -170,7 +172,9 @@ export default {
'warehouse.sku.form.exHsCodePlaceholder': 'Export HS code (optional)',
'warehouse.sku.form.imHsCode': 'Import HS',
'warehouse.sku.form.imHsCodePlaceholder': 'Import HS code (optional)',
'warehouse.sku.form.showExtra': 'Show declaration extras (EN name / HS / remark)',
'warehouse.sku.form.declareElement': 'Export Declare Element',
'warehouse.sku.form.declareElementPlaceholder': 'Customs export declare elements (optional)',
'warehouse.sku.form.showExtra': 'Show declaration extras (EN name / HS / declare element / remark)',
'warehouse.sku.form.hideExtra': 'Hide declaration extras',
'warehouse.modal.section.declarationExtra': 'Declaration extras',
'warehouse.sku.form.weightG': 'Weight',
@ -517,6 +521,50 @@ export default {
'order.platformStore.oauth.missingPlatform': 'Missing platform',
'order.platformStore.oauth.close': 'Close',
'user.senderAddress.search.keyword': 'Alias / contact / phone / address',
'user.exportQualification.searchPlaceholder':
'Company / credit code / consignor / producer',
'user.exportQualification.actions.add': 'Add Filing',
'user.exportQualification.modal.add': 'Add Export Qualification',
'user.exportQualification.modal.edit': 'Edit Export Qualification',
'user.exportQualification.fields.companyName': 'Company Name',
'user.exportQualification.fields.socialCreditCode': 'Social Credit Code',
'user.exportQualification.fields.domesticConsignor': 'Domestic Consignor',
'user.exportQualification.fields.domesticConsignorCustomsCode':
'Domestic Consignor Customs Code',
'user.exportQualification.fields.producerName': 'Producer / Seller',
'user.exportQualification.fields.producerCustomsCode': 'Producer Customs Code',
'user.exportQualification.fields.customsPort': 'Customs Port of Filing',
'user.exportQualification.table.companyName': 'Company Name',
'user.exportQualification.table.socialCreditCode': 'Social Credit Code',
'user.exportQualification.table.domesticConsignor': 'Domestic Consignor',
'user.exportQualification.table.domesticConsignorCustomsCode':
'Domestic Consignor Customs Code',
'user.exportQualification.table.producerName': 'Producer / Seller',
'user.exportQualification.table.producerCustomsCode': 'Producer Customs Code',
'user.exportQualification.table.customsPort': 'Customs Port of Filing',
'user.exportQualification.table.isDefault': 'Default',
'user.exportQualification.table.remark': 'Remark',
'user.exportQualification.table.createTime': 'Created',
'user.exportQualification.fields.isDefault': 'Set as Default',
'user.exportQualification.fields.remark': 'Remark',
'user.exportQualification.actions.setDefault': 'Set Default',
'user.exportQualification.tip.isDefault':
'Only one default filing per customer; used for customs autofill later',
'user.exportQualification.hint.defaultOn': 'Default',
'user.exportQualification.hint.defaultOff': 'Not default',
'user.exportQualification.placeholder.input': 'Please enter',
'user.exportQualification.placeholder.remark': 'Optional',
'user.exportQualification.message.loadFailed': 'Failed to load filings',
'user.exportQualification.message.saveSuccess': 'Saved',
'user.exportQualification.message.saveFailed': 'Save failed',
'user.exportQualification.message.deleteSuccess': 'Deleted',
'user.exportQualification.message.setDefaultSuccess': 'Default filing updated',
'user.exportQualification.message.companyNameRequired': 'Company name is required',
'user.exportQualification.message.socialCreditCodeRequired':
'Social credit code is required',
'user.exportQualification.message.domesticConsignorRequired':
'Domestic consignor is required',
'user.exportQualification.message.producerNameRequired': 'Producer name is required',
'user.senderAddress.modal.add': 'Add Address',
'user.senderAddress.modal.edit': 'Edit Address',
'user.senderAddress.actions.setDefault': 'Set Default',
@ -597,7 +645,7 @@ export default {
'order.customs9610.actions.declare': 'Declare',
'order.customs9610.actions.batchDeclare': 'Batch declare',
'order.customs9610.actions.batchFetchManifest': 'Batch fetch manifest',
'order.customs9610.actions.batchFetchDeclarationForm': 'Batch fetch declaration form',
'order.customs9610.actions.batchFetchDeclarationForm': 'Forward Downstream',
'order.customs9610.modal.edit': 'Edit 9610 declaration',
'order.customs9610.modal.detail': '9610 declaration detail',
'order.customs9610.modal.tipEdit':
@ -631,7 +679,7 @@ export default {
'order.customs9610.items.sku': 'SKU',
'order.customs9610.items.cnName': 'CN name',
'order.customs9610.items.exHsCode': 'Export HS code',
'order.customs9610.items.declareElement': 'Declare element',
'order.customs9610.items.declareElement': 'Export declare element',
'order.customs9610.items.unitPrice': 'Unit price',
'order.customs9610.items.quantity': 'Quantity',
'order.customs9610.confirm.declareTitle': 'Confirm declare',
@ -641,9 +689,9 @@ export default {
'order.customs9610.confirm.batchManifestTitle': 'Batch fetch manifest',
'order.customs9610.confirm.batchManifestContent':
'Fetch manifest data for {count} selected declared records?',
'order.customs9610.confirm.batchDeclarationFormTitle': 'Batch fetch declaration form',
'order.customs9610.confirm.batchDeclarationFormTitle': 'Forward Downstream',
'order.customs9610.confirm.batchDeclarationFormContent':
'Fetch declaration forms for {count} selected declared records?',
'Forward {count} pending record(s) downstream (create9610Order / BH). Continue?',
'order.customs9610.message.loadFailed': 'Load failed',
'order.customs9610.message.saveSuccess': 'Saved',
'order.customs9610.message.saveFailed': 'Save failed',
@ -651,7 +699,8 @@ export default {
'order.customs9610.message.declareFailed': 'Declare failed',
'order.customs9610.message.batchResult': 'Success {success}, failed {fail}',
'order.customs9610.message.fetchManifestFailed': 'Failed to fetch manifest',
'order.customs9610.message.fetchDeclarationFormFailed': 'Failed to fetch declaration form',
'order.customs9610.message.fetchDeclarationFormFailed': 'Downstream forward failed',
'order.customs9610.modal.downstreamForwardTitle': 'Downstream forward result',
'order.customs9610.message.selectFirst': 'Select records first',
'order.customs9610.message.platformCodeRequired': 'Platform is required',
'order.customs9610.message.clientServiceRequired': 'Client No. and Service No. are required',
@ -682,8 +731,18 @@ export default {
'order.pickupOrder.actions.markInbound': 'Confirm Inbound',
'order.pickupOrder.actions.cancel': 'Cancel Order',
'order.pickupOrder.actions.create': 'Create Pickup Order',
'order.pickupOrder.actions.createForward': 'Create Forward Pickup',
'order.pickupOrder.actions.createReverse': 'Create Reverse Pickup',
'order.pickupOrder.actions.submit': 'Submit',
'order.pickupOrder.modal.create': 'Create Pickup Order',
'order.pickupOrder.modal.createForward': 'Create Forward Pickup',
'order.pickupOrder.modal.createReverse': 'Create Reverse Pickup',
'order.pickupOrder.route.from': 'Pickup from',
'order.pickupOrder.route.to': 'Deliver to',
'order.pickupOrder.route.customer': 'Customer address',
'order.pickupOrder.route.warehouse': 'Warehouse',
'order.pickupOrder.route.forwardLabel': 'Customer address → Warehouse',
'order.pickupOrder.route.reverseLabel': 'Warehouse → Customer address',
'order.pickupOrder.hint.createFromMyOrders': 'Bind pre-declared waybills or create a new one; multi-select must share a warehouse',
'order.pickupOrder.create.lead': 'Fill pickup and cargo details. A waybill and pickup sheet will be created.',
'order.pickupOrder.create.leadBind': 'Select unbound pre-declared waybills (same warehouse), then fill pickup details.',
@ -830,8 +889,8 @@ export default {
'order.pickupOrder.message.batchScheduleHint': 'Draft-forecast {count} pickup sheet(s)',
'order.pickupOrder.message.batchScheduleResult': 'Batch schedule: {success} succeeded, {fail} failed',
'order.pickupOrder.message.downstreamNeedSelect': 'Select pickup sheets to forward downstream',
'order.pickupOrder.message.downstreamNoBoundOrders': 'Selected pickup sheets have no bound orders to forward',
'order.pickupOrder.message.downstreamPartialEmpty': 'Skipped pickup sheets with no bound orders: {pickups}',
'order.pickupOrder.message.downstreamNoBoundOrders': 'Selected pickup sheets cannot be forwarded',
'order.pickupOrder.message.downstreamPartialEmpty': 'Skipped pickup sheets: {pickups}',
'order.platformStoreOrder.status.all': 'All',
'order.platformStoreOrder.status.pending': 'Pending',
'order.platformStoreOrder.status.toShip': 'To Ship',
@ -1939,6 +1998,11 @@ export default {
'order.myOrders.allocationTrace.selected': 'Selected',
'order.myOrders.allocationTrace.emptyCandidates': 'No candidate channels',
'order.myOrders.allocationTrace.skippedTitle': 'Skipped',
'order.myOrders.allocationTrace.poolSummary': 'Pool {pool}, condition pass {pass}, candidates {cand}',
'order.myOrders.allocationTrace.stage.condition': 'Match condition',
'order.myOrders.allocationTrace.stage.customer': 'Customer list',
'order.myOrders.allocationTrace.stage.channel': 'Service channel',
'order.myOrders.allocationTrace.stage.buyPrice': 'Buy price',
'order.myOrders.allocationTrace.traceTime': 'Recorded at',
'order.myOrders.allocationTrace.loadFailed': 'Failed to load allocation process',
'order.myOrders.allocationTrace.noOrder': 'Invalid order',
@ -2028,6 +2092,7 @@ export default {
'order.myOrders.drawer.declarationHsCode': 'HsCode',
'order.myOrders.drawer.declarationImHsCode': 'Import HS code',
'order.myOrders.drawer.declarationExHsCode': 'Export HS code',
'order.myOrders.drawer.declarationDeclareElement': 'Export declare element',
'order.myOrders.drawer.declarationSkuImage': 'Image',
'order.myOrders.drawer.declarationSkuImageView': 'View image',
'order.myOrders.drawer.declarationCategory': 'Category',
@ -2532,6 +2597,9 @@ export default {
'system.serviceProviders.dropdown.authConfig': 'Auth config',
'system.serviceProviders.dropdown.accountManage': 'Account management',
'system.serviceProviders.dropdown.export': 'Export',
'system.serviceProviders.dropdown.import': 'Import provider',
'system.serviceProviders.import.success': 'Imported',
'system.serviceProviders.import.fail': 'Import failed',
'system.serviceProviders.dropdown.fixedParams': 'Common params',
'system.serviceProviders.dropdown.delete': 'Delete',
@ -4760,7 +4828,13 @@ export default {
'channel.salesProducts.table.providers.providerName': 'Provider name',
'channel.salesProducts.table.providers.serviceChannel': 'Service channel',
'channel.salesProducts.table.providers.weight': 'Weight',
'channel.salesProducts.table.providers.customers': 'Customers',
'channel.salesProducts.table.providers.matchCondition': 'Match condition',
'channel.salesProducts.table.providers.matchConditionAll': 'All orders',
'channel.salesProducts.table.providers.matchConditionEdit': 'Edit condition',
'channel.salesProducts.table.providers.matchConditionLegacy': 'Legacy customer list — switch to match condition',
'channel.salesProducts.table.providers.matchConditionModalTitle': 'Allocation match condition',
'channel.salesProducts.table.providers.matchConditionHint':
'Decide whether the order can use this service channel. Empty means all orders. Customer, cargo attribute, etc. belong in the formula. Eligible channels are filtered first, then the allocation scheme picks one.',
'channel.salesProducts.table.providers.actions': 'Action',
'channel.salesProducts.table.providers.placeholder.providerName': 'Enter provider name',
'channel.salesProducts.table.providers.placeholder.serviceChannel': 'Enter service channel',

View File

@ -56,6 +56,7 @@ export default {
'menu.user.customer': '客户管理',
'menu.user.subAccount': '子账号',
'menu.user.senderAddress': '地址管理',
'menu.user.exportQualification': '出口资质备案',
'menu.user.profile': '个人中心',
'menu.user.recharge': '资金充值',
'menu.finance': '财务',
@ -154,6 +155,7 @@ export default {
'warehouse.sku.table.unitPrice': '单价',
'warehouse.sku.table.exHsCode': 'HSCode 出口',
'warehouse.sku.table.imHsCode': 'HSCode 进口',
'warehouse.sku.table.declareElement': '出口申报要素',
'warehouse.sku.table.totalQty': '库存',
'warehouse.sku.table.weightG': '重量(g)',
'warehouse.sku.table.remark': '备注',
@ -171,7 +173,9 @@ export default {
'warehouse.sku.form.exHsCodePlaceholder': '出口海关编码(选填)',
'warehouse.sku.form.imHsCode': '进口编码',
'warehouse.sku.form.imHsCodePlaceholder': '进口海关编码(选填)',
'warehouse.sku.form.showExtra': '展开申报扩展(英文名 / HS / 备注)',
'warehouse.sku.form.declareElement': '出口申报要素',
'warehouse.sku.form.declareElementPlaceholder': '海关出口申报要素(选填)',
'warehouse.sku.form.showExtra': '展开申报扩展(英文名 / HS / 申报要素 / 备注)',
'warehouse.sku.form.hideExtra': '收起申报扩展',
'warehouse.modal.section.declarationExtra': '申报扩展信息',
'warehouse.sku.form.weightG': '重量',
@ -518,6 +522,44 @@ export default {
'order.platformStore.oauth.missingPlatform': '缺少平台信息',
'order.platformStore.oauth.close': '关闭',
'user.senderAddress.search.keyword': '别名/联系人/手机/地址',
'user.exportQualification.searchPlaceholder': '企业名称/信用代码/发货人/生产销售单位',
'user.exportQualification.actions.add': '新增备案',
'user.exportQualification.modal.add': '新增出口资质备案',
'user.exportQualification.modal.edit': '编辑出口资质备案',
'user.exportQualification.fields.companyName': '企业名称',
'user.exportQualification.fields.socialCreditCode': '企业社会信用代码',
'user.exportQualification.fields.domesticConsignor': '境内发货人',
'user.exportQualification.fields.domesticConsignorCustomsCode': '境内发货人海关代码',
'user.exportQualification.fields.producerName': '生产销售单位',
'user.exportQualification.fields.producerCustomsCode': '生产销售单位海关代码',
'user.exportQualification.fields.customsPort': '备案海关港口地',
'user.exportQualification.table.companyName': '企业名称',
'user.exportQualification.table.socialCreditCode': '企业社会信用代码',
'user.exportQualification.table.domesticConsignor': '境内发货人',
'user.exportQualification.table.domesticConsignorCustomsCode': '境内发货人海关代码',
'user.exportQualification.table.producerName': '生产销售单位',
'user.exportQualification.table.producerCustomsCode': '生产销售单位海关代码',
'user.exportQualification.table.customsPort': '备案海关港口地',
'user.exportQualification.table.isDefault': '默认',
'user.exportQualification.table.remark': '备注',
'user.exportQualification.table.createTime': '创建时间',
'user.exportQualification.fields.isDefault': '设为默认',
'user.exportQualification.fields.remark': '备注',
'user.exportQualification.actions.setDefault': '设默认',
'user.exportQualification.tip.isDefault': '同客户仅保留一条默认备案,用于后续报关自动带入',
'user.exportQualification.hint.defaultOn': '默认',
'user.exportQualification.hint.defaultOff': '非默认',
'user.exportQualification.placeholder.input': '请输入',
'user.exportQualification.placeholder.remark': '选填',
'user.exportQualification.message.loadFailed': '加载出口资质备案失败',
'user.exportQualification.message.saveSuccess': '保存成功',
'user.exportQualification.message.saveFailed': '保存失败',
'user.exportQualification.message.deleteSuccess': '删除成功',
'user.exportQualification.message.setDefaultSuccess': '已设为默认备案',
'user.exportQualification.message.companyNameRequired': '请输入企业名称',
'user.exportQualification.message.socialCreditCodeRequired': '请输入企业社会信用代码',
'user.exportQualification.message.domesticConsignorRequired': '请输入境内发货人',
'user.exportQualification.message.producerNameRequired': '请输入生产销售单位',
'user.senderAddress.modal.add': '新增地址',
'user.senderAddress.modal.edit': '编辑地址',
'user.senderAddress.actions.setDefault': '设默认',
@ -597,7 +639,7 @@ export default {
'order.customs9610.actions.declare': '申报',
'order.customs9610.actions.batchDeclare': '批量申报',
'order.customs9610.actions.batchFetchManifest': '批量获取清单数据',
'order.customs9610.actions.batchFetchDeclarationForm': '批量获取报关单',
'order.customs9610.actions.batchFetchDeclarationForm': '预报下游',
'order.customs9610.modal.edit': '编辑 9610 申报',
'order.customs9610.modal.detail': '9610 申报详情',
'order.customs9610.modal.tipEdit':
@ -631,7 +673,7 @@ export default {
'order.customs9610.items.sku': 'SKU',
'order.customs9610.items.cnName': '中文品名',
'order.customs9610.items.exHsCode': '出口海关编码',
'order.customs9610.items.declareElement': '申报要素',
'order.customs9610.items.declareElement': '出口申报要素',
'order.customs9610.items.unitPrice': '单价',
'order.customs9610.items.quantity': '数量',
'order.customs9610.confirm.declareTitle': '确认申报',
@ -640,8 +682,9 @@ export default {
'order.customs9610.confirm.batchDeclareContent': '将对选中的 {count} 条待申报单据发起申报,是否继续?',
'order.customs9610.confirm.batchManifestTitle': '批量获取清单数据',
'order.customs9610.confirm.batchManifestContent': '将对选中的 {count} 条已申报单据获取清单数据,是否继续?',
'order.customs9610.confirm.batchDeclarationFormTitle': '批量获取报关单',
'order.customs9610.confirm.batchDeclarationFormContent': '将对选中的 {count} 条已申报单据获取报关单,是否继续?',
'order.customs9610.confirm.batchDeclarationFormTitle': '预报下游',
'order.customs9610.confirm.batchDeclarationFormContent':
'将对选中的 {count} 条待申报单据预报下游(create9610Order / BH),是否继续?',
'order.customs9610.message.loadFailed': '加载失败',
'order.customs9610.message.saveSuccess': '保存成功',
'order.customs9610.message.saveFailed': '保存失败',
@ -649,7 +692,8 @@ export default {
'order.customs9610.message.declareFailed': '申报失败',
'order.customs9610.message.batchResult': '成功 {success},失败 {fail}',
'order.customs9610.message.fetchManifestFailed': '获取清单数据失败',
'order.customs9610.message.fetchDeclarationFormFailed': '获取报关单失败',
'order.customs9610.message.fetchDeclarationFormFailed': '预报下游失败',
'order.customs9610.modal.downstreamForwardTitle': '下游预报结果',
'order.customs9610.message.selectFirst': '请先勾选单据',
'order.customs9610.message.platformCodeRequired': '请填写电商平台',
'order.customs9610.message.clientServiceRequired': '请填写客户单号与运单号',
@ -759,8 +803,18 @@ export default {
'order.pickupOrder.message.inboundOk': '已确认到仓',
'order.pickupOrder.message.actionFailed': '操作失败',
'order.pickupOrder.actions.create': '创建揽收订单',
'order.pickupOrder.actions.createForward': '创建正向揽收',
'order.pickupOrder.actions.createReverse': '创建逆向揽收',
'order.pickupOrder.actions.submit': '提交创建',
'order.pickupOrder.modal.create': '创建揽收订单',
'order.pickupOrder.modal.createForward': '创建正向揽收',
'order.pickupOrder.modal.createReverse': '创建逆向揽收',
'order.pickupOrder.route.from': '取件地',
'order.pickupOrder.route.to': '送达地',
'order.pickupOrder.route.customer': '客户地址',
'order.pickupOrder.route.warehouse': '仓库',
'order.pickupOrder.route.forwardLabel': '客户地址 → 仓库',
'order.pickupOrder.route.reverseLabel': '仓库 → 客户地址',
'order.pickupOrder.table.productCode': '运输方式',
'order.pickupOrder.table.pickupTime': '揽收时间',
'order.pickupOrder.table.cargoName': '货物名称',
@ -826,8 +880,8 @@ export default {
'order.pickupOrder.message.batchScheduleHint': '将对已选 {count} 张揽收单统一草稿预报',
'order.pickupOrder.message.batchScheduleResult': '批量预报:成功 {success},失败 {fail}',
'order.pickupOrder.message.downstreamNeedSelect': '请先选中要预报下游的揽收单',
'order.pickupOrder.message.downstreamNoBoundOrders': '所选揽收单没有可预报的绑定运单',
'order.pickupOrder.message.downstreamPartialEmpty': '以下揽收单无绑定运单或拉取失败,已跳过:{pickups}',
'order.pickupOrder.message.downstreamNoBoundOrders': '所选揽收单无法预报下游',
'order.pickupOrder.message.downstreamPartialEmpty': '以下揽收单预报已跳过:{pickups}',
'order.platformStoreOrder.status.all': '全部',
'order.platformStoreOrder.status.pending': '待处理',
'order.platformStoreOrder.status.toShip': '待发货',
@ -1832,6 +1886,11 @@ export default {
'order.myOrders.allocationTrace.selected': '已选',
'order.myOrders.allocationTrace.emptyCandidates': '暂无可用候选渠道',
'order.myOrders.allocationTrace.skippedTitle': '未进入候选',
'order.myOrders.allocationTrace.poolSummary': '池内 {pool},条件过 {pass},候选 {cand}',
'order.myOrders.allocationTrace.stage.condition': '判断条件',
'order.myOrders.allocationTrace.stage.customer': '客户名单',
'order.myOrders.allocationTrace.stage.channel': '服务渠道',
'order.myOrders.allocationTrace.stage.buyPrice': '渠道买价',
'order.myOrders.allocationTrace.traceTime': '记录时间',
'order.myOrders.allocationTrace.loadFailed': '加载排货过程失败',
'order.myOrders.allocationTrace.noOrder': '订单无效',
@ -1921,6 +1980,7 @@ export default {
'order.myOrders.drawer.declarationHsCode': 'HsCode',
'order.myOrders.drawer.declarationImHsCode': '进口HsCode',
'order.myOrders.drawer.declarationExHsCode': '出口HsCode',
'order.myOrders.drawer.declarationDeclareElement': '出口申报要素',
'order.myOrders.drawer.declarationSkuImage': '图片',
'order.myOrders.drawer.declarationSkuImageView': '查看图片',
'order.myOrders.drawer.declarationCategory': '类别',
@ -2429,6 +2489,9 @@ export default {
'system.serviceProviders.dropdown.authConfig': '授权配置',
'system.serviceProviders.dropdown.accountManage': '账户管理',
'system.serviceProviders.dropdown.export': '导出',
'system.serviceProviders.dropdown.import': '导入服务商',
'system.serviceProviders.import.success': '导入成功',
'system.serviceProviders.import.fail': '导入失败',
'system.serviceProviders.dropdown.fixedParams': '公共参数',
'system.serviceProviders.dropdown.delete': '删除',
@ -4806,7 +4869,13 @@ export default {
'channel.salesProducts.table.providers.providerName': '服务商名称',
'channel.salesProducts.table.providers.serviceChannel': '服务渠道',
'channel.salesProducts.table.providers.weight': '权重',
'channel.salesProducts.table.providers.customers': '客户',
'channel.salesProducts.table.providers.matchCondition': '判断条件',
'channel.salesProducts.table.providers.matchConditionAll': '全部订单',
'channel.salesProducts.table.providers.matchConditionEdit': '编辑条件',
'channel.salesProducts.table.providers.matchConditionLegacy': '旧客户名单,请改为判断条件',
'channel.salesProducts.table.providers.matchConditionModalTitle': '排货判断条件',
'channel.salesProducts.table.providers.matchConditionHint':
'用于判断当前订单是否可用该服务渠道;空表示全部订单。客户、货物属性等都写在公式里。先筛出满足条件的渠道,再按排货方案选最终渠道。',
'channel.salesProducts.table.providers.actions': '操作',
'channel.salesProducts.table.providers.placeholder.providerName': '请输入服务商名称',
'channel.salesProducts.table.providers.placeholder.serviceChannel': '请输入服务渠道',

View File

@ -58,6 +58,18 @@ const USER: AppRouteRecordRaw = {
permission: 'Order.SenderAddress.Query',
},
},
{
path: 'exportQualification',
name: 'UserExportQualification',
component: () => import('@/views/user/exportQualification/index.vue'),
meta: {
locale: 'menu.user.exportQualification',
requiresAuth: true,
roles: ['*'],
order: 4,
permission: 'User.ExportQualification.Query',
},
},
{
path: 'recharge',
name: 'VipRechargeDeposit',

View File

@ -1,6 +1,6 @@
/**
* Admin API 地址(方案 A)
* - 开发:始终相对路径,由 Vite 代理到 VITE_OMS_ADMIN_URL(避免 localhost→api 跨域)
* - 开发:始终相对路径,由 Vite 代理到 VITE_OMS_ADMIN_URL(测试环境,禁止直连生产)
* - 生产构建:VITE_API_ADMIN_URL / 默认 https://api.ideship.com/admin
*/
@ -13,7 +13,7 @@ function ensureLeadingSlash(path: string): string {
}
const DEFAULT_PROD_ADMIN = 'https://api.ideship.com/admin'
const DEFAULT_TEST_ADMIN = 'http://api.nanafa.com/admin'
const DEFAULT_DEV_ADMIN = 'https://api.test.ideship.com/admin'
function configuredAdminBase(): string | undefined {
const override = import.meta.env.VITE_API_ADMIN_URL as string | undefined
@ -45,7 +45,7 @@ export function resolveDirectAdminApiUrl(): string {
const configured = configuredAdminBase()
if (configured) return configured
if (import.meta.env.DEV) {
return DEFAULT_TEST_ADMIN
return DEFAULT_DEV_ADMIN
}
return DEFAULT_PROD_ADMIN
}

View File

@ -11,6 +11,7 @@ export const MENU_NAME_TO_LOCALE_KEY: Record<string, string> = {
客户管理: 'menu.user.customer',
子账号: 'menu.user.subAccount',
地址管理: 'menu.user.senderAddress',
出口资质备案: 'menu.user.exportQualification',
个人中心: 'menu.user.profile',
资金充值: 'menu.user.recharge',
财务: 'menu.finance',

View File

@ -34,6 +34,7 @@ function mapItem(raw: Record<string, any>) {
weight: pick(raw, 'weight', 'Weight'),
imHsCode: pick(raw, 'imHsCode', 'ImHsCode') ?? hs,
exHsCode: pick(raw, 'exHsCode', 'ExHsCode') ?? hs,
declareElement: pick(raw, 'declareElement', 'DeclareElement'),
skuImage: pick(raw, 'skuImage', 'SkuImage'),
category: pick(raw, 'category', 'Category'),
brand: pick(raw, 'brand', 'Brand'),

View File

@ -1,6 +1,6 @@
/**
* 方案 A:按 VITE_API_ORIGIN 改写到 {admin|itfx|track} PATHBASE。
* 生产默认 https://api.ideship.com;测试用 .env.nanafa → http://api.nanafa.com。
* 生产构建默认 https://api.ideship.com;本地开发走 Vite 代理(.env → api.test.ideship.com)。
* 开发且未配置 VITE_API_ORIGIN 时不改写(走 Vite 代理)。
*/

View File

@ -193,6 +193,7 @@ export function mapWarehouseSkuToDeclarationFields(raw: Row) {
const unitPrice = pickField(row, 'unitPrice', 'UnitPrice')
const exHsCode = String(pickField(row, 'exHsCode', 'ExHsCode') ?? '')
const imHsCode = String(pickField(row, 'imHsCode', 'ImHsCode') ?? '')
const declareElement = String(pickField(row, 'declareElement', 'DeclareElement') ?? '')
const image = normalizeSkuImageForStorage(
String(pickField(row, 'skuImageUrl', 'SkuImageUrl') ?? ''),
)
@ -218,6 +219,7 @@ export function mapWarehouseSkuToDeclarationFields(raw: Row) {
quantity: '1',
exHsCode,
imHsCode,
declareElement,
skuImage: image,
comment: remark,
}
@ -242,6 +244,9 @@ export function buildWarehouseSkuSelectPayload(raw: Row, mapped?: Row): Row {
imHsCode: pickField(merged, 'imHsCode', 'ImHsCode')
?? pickField(normalized, 'imHsCode', 'ImHsCode')
?? pickField(raw, 'imHsCode', 'ImHsCode'),
declareElement: pickField(merged, 'declareElement', 'DeclareElement')
?? pickField(normalized, 'declareElement', 'DeclareElement')
?? pickField(raw, 'declareElement', 'DeclareElement'),
skuImageUrl: pickField(merged, 'skuImageUrl', 'SkuImageUrl')
?? pickField(normalized, 'skuImageUrl', 'SkuImageUrl')
?? pickField(raw, 'skuImageUrl', 'SkuImageUrl'),

View File

@ -45,6 +45,7 @@
IconCopy,
IconUser,
IconDownload,
IconUpload,
IconSearch,
IconPlus,
IconRefresh,
@ -239,7 +240,7 @@
// 直接选择文件并调用导入接口
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json,.xlsx,.xls,.csv,.txt';
input.accept = '.json';
input.onchange = async () => {
const file = input.files && input.files[0];
if (!file) return;
@ -247,13 +248,13 @@
loading.value = true;
const ok = await postServiceProviderImport({}, file);
if (ok) {
$baseMessage(t('system.serviceProviders.interfaceList.refresh'), 'success');
$baseMessage(t('system.serviceProviders.import.success'), 'success');
await loadServiceProviderList();
} else {
$baseMessage(t('system.serviceProviders.list.deleteFail'), 'error');
$baseMessage(t('system.serviceProviders.import.fail'), 'error');
}
} catch (e) {
$baseMessage(t('system.serviceProviders.list.deleteFail'), 'error');
$baseMessage(t('system.serviceProviders.import.fail'), 'error');
} finally {
loading.value = false;
}
@ -1696,6 +1697,16 @@
@select="handleProviderSelectFromTree"
@node-click="(key) => { const id = key.startsWith('item-') ? Number(key.slice(5)) : null; if (id != null) { const p = serviceProviderList.find((x: any) => x.id === id); if (p) handleProviderSelect(p); } }"
>
<template #branch-node-extra>
<a-button
type="text"
size="mini"
:title="t('system.serviceProviders.dropdown.import')"
@click.stop="openImportModal"
>
<IconUpload />
</a-button>
</template>
<template #node-menu="{ nodeData }">
<a-doption @click="handleParameterConfigWithSelect(nodeData)">
<IconSettings style="margin-right: 8px;" />

View File

@ -152,6 +152,7 @@ export const ORDER_TABLE_FIELDS: OrderTableFieldDef[] = [
{ table: 't_order_declaration', field: 'HsCode', label: 'HS编码', valueType: 'string' },
{ 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: 'Category', label: '申报类别', valueType: 'string' },
{ table: 't_order_declaration', field: 'Brand', label: '品牌', valueType: 'string' },
{ table: 't_order_declaration', field: 'BrandType', label: '品牌类型', valueType: 'string' },

View File

@ -1172,6 +1172,7 @@
? Number(p.serviceChannelId ?? p.ServiceChannelId)
: undefined,
weight: p.weight ?? p.Weight ?? 0,
matchCondition: String(p.matchCondition ?? p.MatchCondition ?? '').trim(),
customers: p.customerIds ?? p.CustomerIds ?? p.customers ?? p.Customers ?? [],
}));
resetRulesFormBaseline();
@ -1505,7 +1506,8 @@
? Number(row.serviceChannelId)
: 0,
weight: row.weight,
customerIds: row.customers || [],
matchCondition: String(row.matchCondition ?? '').trim(),
customerIds: [],
})),
});
resetRulesFormBaseline();
@ -1952,7 +1954,7 @@
serialNumber: number;
serviceChannelId: number | undefined;
weight: number;
customers: string[];
matchCondition: string;
}>;
};
@ -1968,7 +1970,7 @@
? Number(row.serviceChannelId)
: undefined,
weight: Number(row.weight ?? 0),
customers: [...(row.customers || [])].map(String).sort(),
matchCondition: String(row.matchCondition ?? '').trim(),
})),
};
}
@ -1992,6 +1994,7 @@
providerName: '',
serviceChannelId: undefined,
weight: shippingProviderData.value.length,
matchCondition: '',
customers: [],
});
};
@ -2005,16 +2008,39 @@
});
};
// 删除客户标签
const handleDeleteCustomer = (row: any, customerIndex: number) => {
row.customers.splice(customerIndex, 1);
};
const matchConditionModalVisible = ref(false);
const matchConditionDraft = ref('');
const matchConditionEditingRow = ref<any>(null);
// 添加客户
const handleAddCustomer = (row: any) => {
// 这里可以弹出一个选择框来选择客户
Message.info('添加客户');
};
function matchConditionSummary(row: any) {
const formula = String(row?.matchCondition ?? '').trim();
if (formula) return formula;
const legacy = row?.customers;
if (Array.isArray(legacy) && legacy.length > 0) {
return t('channel.salesProducts.table.providers.matchConditionLegacy');
}
return t('channel.salesProducts.table.providers.matchConditionAll');
}
function openMatchConditionEditor(row: any) {
matchConditionEditingRow.value = row;
matchConditionDraft.value = String(row?.matchCondition ?? '').trim();
matchConditionModalVisible.value = true;
}
function confirmMatchConditionEditor() {
if (matchConditionEditingRow.value) {
matchConditionEditingRow.value.matchCondition = String(matchConditionDraft.value ?? '').trim();
matchConditionEditingRow.value.customers = [];
}
matchConditionModalVisible.value = false;
matchConditionEditingRow.value = null;
}
function closeMatchConditionEditor() {
matchConditionModalVisible.value = false;
matchConditionEditingRow.value = null;
}
// 发布记录相关数据
const publishRecordActiveIndex = ref(0);
@ -3688,7 +3714,7 @@
<col class="shipping-col-serial" />
<col class="shipping-col-channel" />
<col class="shipping-col-weight" />
<col class="shipping-col-customers" />
<col class="shipping-col-match-condition" />
<col class="shipping-col-actions" />
</colgroup>
<thead>
@ -3696,7 +3722,7 @@
<th>{{ t('channel.salesProducts.table.providers.serialNo') }}</th>
<th>{{ t('channel.salesProducts.table.providers.serviceChannel') }}</th>
<th>{{ t('channel.salesProducts.table.providers.weight') }}</th>
<th>{{ t('channel.salesProducts.table.providers.customers') }}</th>
<th>{{ t('channel.salesProducts.table.providers.matchCondition') }}</th>
<th>{{ t('channel.salesProducts.table.providers.actions') }}</th>
</tr>
</thead>
@ -3726,24 +3752,29 @@
:min="0"
/>
</td>
<td class="shipping-col-customers-cell">
<div class="customers-cell">
<a-tag
v-for="(customer, customerIndex) in row.customers"
:key="customerIndex"
closable
@close="handleDeleteCustomer(row, customerIndex)"
style="margin-right: 8px; margin-bottom: 4px"
<td class="shipping-col-match-condition-cell">
<div class="match-condition-cell">
<span
class="match-condition-summary"
:class="{
'is-empty': !String(row.matchCondition || '').trim(),
'is-legacy':
!String(row.matchCondition || '').trim() &&
Array.isArray(row.customers) &&
row.customers.length > 0,
}"
:title="matchConditionSummary(row)"
>
{{ customer }}
</a-tag>
{{ matchConditionSummary(row) }}
</span>
<a-button
type="text"
size="small"
@click="handleAddCustomer(row)"
class="add-customer-btn"
class="match-condition-edit-btn"
@click="openMatchConditionEditor(row)"
>
<icon-plus />
<icon-edit />
{{ t('channel.salesProducts.table.providers.matchConditionEdit') }}
</a-button>
</div>
</td>
@ -4112,6 +4143,21 @@
</div>
</div>
<!-- 排货判断条件编辑 -->
<a-modal
v-model:visible="matchConditionModalVisible"
:title="t('channel.salesProducts.table.providers.matchConditionModalTitle')"
:width="720"
unmount-on-close
@ok="confirmMatchConditionEditor"
@cancel="closeMatchConditionEditor"
>
<p class="match-condition-modal-hint">
{{ t('channel.salesProducts.table.providers.matchConditionHint') }}
</p>
<BillingConditionEditor v-model="matchConditionDraft" />
</a-modal>
<!-- 发布日期选择弹窗 -->
<a-modal
v-model:visible="publishDateVisible"
@ -6045,7 +6091,7 @@
width: 100%;
table-layout: fixed;
border-collapse: collapse;
/* 权重固定 100px;服务渠道 : 客户 = 3 : 7(占除序号 80px、权重、操作 100px 外的宽度) */
/* 权重固定 100px;服务渠道 : 判断条件 = 3 : 7 */
--shipping-fixed-cols: 280px;
col.shipping-col-serial {
@ -6060,13 +6106,13 @@
col.shipping-col-channel {
width: calc((100% - var(--shipping-fixed-cols)) * 3 / 10);
}
col.shipping-col-customers {
col.shipping-col-match-condition {
width: calc((100% - var(--shipping-fixed-cols)) * 7 / 10);
}
.shipping-col-channel-cell,
.shipping-col-weight-cell,
.shipping-col-customers-cell {
.shipping-col-match-condition-cell {
min-width: 0;
}
@ -6119,29 +6165,36 @@
}
}
.customers-cell {
.match-condition-cell {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 4px;
gap: 8px;
min-width: 0;
.add-customer-btn {
.match-condition-summary {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: var(--color-text-1);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
&.is-empty {
color: var(--color-text-3);
font-family: inherit;
}
&.is-legacy {
color: var(--h5-warning, #ff7d00);
font-family: inherit;
}
}
.match-condition-edit-btn {
flex-shrink: 0;
color: var(--app-primary-color);
:deep(svg) {
color: var(--app-primary-color);
fill: var(--app-primary-color);
}
&:hover {
color: #1a6b6b;
background-color: #f0f9f9;
:deep(svg) {
color: #1a6b6b;
fill: #1a6b6b;
}
}
}
}
@ -6166,6 +6219,13 @@
}
}
.match-condition-modal-hint {
margin: 0 0 12px;
font-size: 13px;
line-height: 1.5;
color: var(--color-text-2);
}
// 发布记录样式
.publish-records-section {
flex: 1;

View File

@ -650,6 +650,11 @@
</div>
</div>
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.declareElement') }}</label>
<a-textarea v-model="row.declareElement" :auto-size="{ minRows: 2, maxRows: 3 }" />
</div>
<div class="h5-po__row">
<div class="h5-po__field">
<label class="h5-po__label">{{ t('h5.placeOrder.brand') }}</label>
@ -1192,6 +1197,7 @@ type DeclareItem = {
unit: string;
imHsCode: string;
exHsCode: string;
declareElement: string;
brand: string;
model: string;
material: string;
@ -1220,6 +1226,7 @@ function createEmptyItem(): DeclareItem {
unit: 'pcs',
imHsCode: '',
exHsCode: '',
declareElement: '',
brand: '',
model: '',
material: '',
@ -2074,6 +2081,7 @@ function mapDetailItems(rawItems: any[]): DeclareItem[] {
weight: String(pickDetail(raw, 'weight', 'Weight') ?? ''),
imHsCode: String(pickDetail(raw, 'imHsCode', 'ImHsCode') ?? hs),
exHsCode: String(pickDetail(raw, 'exHsCode', 'ExHsCode') ?? hs),
declareElement: String(pickDetail(raw, 'declareElement', 'DeclareElement') ?? ''),
brand: String(pickDetail(raw, 'brand', 'Brand') ?? ''),
model: String(pickDetail(raw, 'model', 'Model') ?? ''),
material: String(pickDetail(raw, 'material', 'Material') ?? ''),

View File

@ -425,6 +425,7 @@ export default {
'h5.placeOrder.itemWeight': 'Weight',
'h5.placeOrder.imHsCode': 'Import HS',
'h5.placeOrder.exHsCode': 'Export HS',
'h5.placeOrder.declareElement': 'Export declare element',
'h5.placeOrder.brand': 'Brand',
'h5.placeOrder.model': 'Model',
'h5.placeOrder.material': 'Material',

View File

@ -420,6 +420,7 @@ export default {
'h5.placeOrder.itemWeight': '总重',
'h5.placeOrder.imHsCode': '进口HS',
'h5.placeOrder.exHsCode': '出口HS',
'h5.placeOrder.declareElement': '出口申报要素',
'h5.placeOrder.brand': '品牌',
'h5.placeOrder.model': '型号',
'h5.placeOrder.material': '材质',

View File

@ -211,6 +211,7 @@ export const ORDER_DETAIL_DECLARATION_COLUMNS = [
{ key: 'weight', labelKey: 'order.myOrders.drawer.declarationWeight', width: 80 },
{ 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: '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 },
@ -237,6 +238,7 @@ export function normalizeDeclarationItemRow(row: Record<string, any>) {
...row,
imHsCode,
exHsCode,
declareElement: row.declareElement ?? row.DeclareElement ?? '',
hsCode: row.hsCode ?? row.HsCode ?? exHsCode ?? imHsCode ?? '',
isMagnetic: !!row.isMagnetic,
};
@ -259,6 +261,7 @@ export function buildDeclarationPayloadFromRows(rows: Record<string, any>[]) {
weight: row.weight != null ? Number(row.weight) : undefined,
imHsCode: row.imHsCode || row.exHsCode || row.hsCode || undefined,
exHsCode: row.exHsCode || row.imHsCode || row.hsCode || undefined,
declareElement: row.declareElement || undefined,
skuImage: row.skuImage || undefined,
category: row.category || undefined,
brand: row.brand || undefined,

View File

@ -47,6 +47,8 @@ const statusCounts = ref({ total: 0, pending: 0, declared: 0 })
const declaring = ref(false)
const fetchingManifest = ref(false)
const fetchingDeclarationForm = ref(false)
const downstreamForwardModalVisible = ref(false)
const downstreamForwardResultText = ref('')
const saving = ref(false)
const detailLoading = ref(false)
@ -525,8 +527,63 @@ function handleBatchFetchManifest() {
return runBatchFetch('manifest', fetchCustoms9610ManifestBatch)
}
function formatDownstreamForwardResult(item: Record<string, unknown>): string {
const id = pickField(item, 'id', 'Id')
const serviceNo = pickField(item, 'serviceNo', 'ServiceNo') || '-'
const clientNo = pickField(item, 'clientNo', 'ClientNo') || '-'
const success = Boolean(pickField(item, 'success', 'Success'))
const lines = [
`【${serviceNo} / ${clientNo} / #${id}】`,
success ? '成功' : '失败',
`standardCode:create9610Order`,
`supplierCode:BH`,
`消息:${pickField(item, 'message', 'Message') || '-'}`,
`请求:${pickField(item, 'url', 'Url') || 'https://api.ideship.com/itfx/InterfaceForward/ForwardWithSubscribe?standardCode=create9610Order&supplierCode=BH'}`,
]
const requestJson = pickField(item, 'requestJson', 'RequestJson')
if (requestJson) {
lines.push(`请求体:\n${requestJson}`)
}
lines.push(`HTTP:${pickField(item, 'httpStatusCode', 'HttpStatusCode') ?? '-'}`)
const responseBody = pickField(item, 'responseBody', 'ResponseBody')
if (responseBody) {
lines.push(`响应:\n${responseBody}`)
}
return lines.join('\n')
}
function handleBatchFetchDeclarationForm() {
return runBatchFetch('declarationForm', fetchCustoms9610DeclarationFormBatch)
const ids = selectedIds.value
if (!ids.length) {
$baseMessage(t('order.customs9610.message.selectFirst'), 'warning')
return
}
Modal.confirm({
title: t('order.customs9610.confirm.batchDeclarationFormTitle'),
content: t('order.customs9610.confirm.batchDeclarationFormContent', { count: ids.length }),
onOk: async () => {
fetchingDeclarationForm.value = true
try {
const res = await fetchCustoms9610DeclarationFormBatch(ids)
const items = (pickField(res, 'items', 'Items') as any[]) || []
const successCount = Number(pickField(res, 'successCount', 'SuccessCount') ?? 0)
const failCount = Number(pickField(res, 'failCount', 'FailCount') ?? 0)
downstreamForwardResultText.value = items.length
? items.map((item) => formatDownstreamForwardResult(item as Record<string, unknown>)).join('\n\n')
: t('order.customs9610.message.batchResult', {
success: successCount,
fail: failCount,
})
downstreamForwardModalVisible.value = true
await loadList()
await loadCounts()
} catch (e: any) {
$baseMessage(e?.message || t('order.customs9610.message.fetchDeclarationFormFailed'), 'error')
} finally {
fetchingDeclarationForm.value = false
}
},
})
}
watch(adminCustomerId, () => {
@ -834,6 +891,15 @@ onMounted(() => {
</div>
</a-spin>
</ModalPro>
<a-modal
v-model:visible="downstreamForwardModalVisible"
:title="t('order.customs9610.modal.downstreamForwardTitle')"
:width="800"
:footer="false"
>
<pre class="downstream-forward-result">{{ downstreamForwardResultText }}</pre>
</a-modal>
</div>
</template>
@ -1096,4 +1162,17 @@ onMounted(() => {
min-height: 0;
}
}
.downstream-forward-result {
margin: 0;
max-height: 60vh;
overflow: auto;
padding: 12px;
background: var(--color-fill-2);
border-radius: 4px;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
</style>

View File

@ -1,13 +1,12 @@
<template>
<ModalPro
:title="t('order.pickupOrder.modal.create')"
:title="modalTitle"
:width="800"
:mask-closable="false"
@register="registerModal"
@cancel="closeModal"
>
<div class="wh-form-modal pk-create-modal">
<div class="form-section-title">{{ t('order.pickupOrder.create.sectionMode') }}</div>
<div class="pk-create-mode">
<SlidingTabs
v-model="createMode"
@ -84,15 +83,18 @@
<div class="form-section-title">
{{ t('order.pickupOrder.create.sectionPickup') }}
<span class="pk-route-hint">{{ routeDirectionLabel }}</span>
<a-tooltip :content="t('order.pickupOrder.tip.pickup')">
<IconQuestionCircleFill class="field-tip-icon" />
</a-tooltip>
</div>
<a-form :model="bindForm" layout="vertical" class="pk-create-form">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item :label="t('order.pickupOrder.form.senderAddress')" required>
<div class="pk-route">
<div class="pk-route-side">
<a-form layout="vertical" :model="bindForm" class="pk-create-form">
<a-form-item :label="t('order.pickupOrder.route.from')" required>
<a-select
v-if="!isReverse"
v-model="bindForm.senderAddressId"
:options="senderOptions"
allow-search
@ -100,16 +102,45 @@
:loading="refLoading"
:placeholder="t('order.pickupOrder.form.senderAddressPlaceholder')"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('order.pickupOrder.form.warehouse')" required>
<a-input
:model-value="selectedWarehouseLabel || t('order.pickupOrder.create.warehouseFromOrders')"
v-else
:model-value="
selectedWarehouseLabel || t('order.pickupOrder.create.warehouseFromOrders')
"
disabled
/>
</a-form-item>
</a-col>
</a-form>
</div>
<div class="pk-route-arrow" aria-hidden="true">
<IconArrowRight />
</div>
<div class="pk-route-side">
<a-form layout="vertical" :model="bindForm" class="pk-create-form">
<a-form-item :label="t('order.pickupOrder.route.to')" required>
<a-select
v-if="isReverse"
v-model="bindForm.senderAddressId"
:options="senderOptions"
allow-search
allow-clear
:loading="refLoading"
:placeholder="t('order.pickupOrder.form.senderAddressPlaceholder')"
/>
<a-input
v-else
:model-value="
selectedWarehouseLabel || t('order.pickupOrder.create.warehouseFromOrders')
"
disabled
/>
</a-form-item>
</a-form>
</div>
</div>
<a-form :model="bindForm" layout="vertical" class="pk-create-form">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item :label="t('order.pickupOrder.form.pickupTime')" required>
<a-date-picker
@ -152,15 +183,18 @@
<template v-else>
<div class="form-section-title">
{{ t('order.pickupOrder.create.sectionPickup') }}
<span class="pk-route-hint">{{ routeDirectionLabel }}</span>
<a-tooltip :content="t('order.pickupOrder.tip.pickup')">
<IconQuestionCircleFill class="field-tip-icon" />
</a-tooltip>
</div>
<a-form :model="newForm" layout="vertical" class="pk-create-form">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item :label="t('order.pickupOrder.form.senderAddress')" required>
<div class="pk-route">
<div class="pk-route-side">
<a-form layout="vertical" :model="newForm" class="pk-create-form">
<a-form-item :label="t('order.pickupOrder.route.from')" required>
<a-select
v-if="!isReverse"
v-model="newForm.senderAddressId"
:options="senderOptions"
allow-search
@ -168,11 +202,8 @@
:loading="refLoading"
:placeholder="t('order.pickupOrder.form.senderAddressPlaceholder')"
/>
</a-form-item>
</a-col>
<a-col :span="12">
<a-form-item :label="t('order.pickupOrder.form.warehouse')" required>
<a-select
v-else
v-model="newForm.warehouseId"
:options="warehouseOptions"
allow-search
@ -181,7 +212,39 @@
:placeholder="t('order.pickupOrder.form.warehousePlaceholder')"
/>
</a-form-item>
</a-col>
</a-form>
</div>
<div class="pk-route-arrow" aria-hidden="true">
<IconArrowRight />
</div>
<div class="pk-route-side">
<a-form layout="vertical" :model="newForm" class="pk-create-form">
<a-form-item :label="t('order.pickupOrder.route.to')" required>
<a-select
v-if="isReverse"
v-model="newForm.senderAddressId"
:options="senderOptions"
allow-search
allow-clear
:loading="refLoading"
:placeholder="t('order.pickupOrder.form.senderAddressPlaceholder')"
/>
<a-select
v-else
v-model="newForm.warehouseId"
:options="warehouseOptions"
allow-search
allow-clear
:loading="refLoading"
:placeholder="t('order.pickupOrder.form.warehousePlaceholder')"
/>
</a-form-item>
</a-form>
</div>
</div>
<a-form :model="newForm" layout="vertical" class="pk-create-form">
<a-row :gutter="16">
<a-col :span="12">
<a-form-item :label="t('order.pickupOrder.form.pickupTime')" required>
<a-date-picker
@ -289,7 +352,7 @@ import {
import type { CustomerProfileCompleteness } from '@/api/customer'
import { resolveOrderStatusCode, getOrderStatusMeta } from '@/constants/orderStatus'
import { useRbacUserStore } from '@/store/modules/rbac-user'
import { IconQuestionCircleFill } from '@arco-design/web-vue/es/icon'
import { IconQuestionCircleFill, IconArrowRight } from '@arco-design/web-vue/es/icon'
defineOptions({ name: 'CreatePickupSheetModal' })
@ -338,11 +401,22 @@ const [registerModal, { openModal, closeModal }] = useModal() as any
/** 当前创建的揽收方向(由列表页 Tab 传入) */
const createDirection = ref<'forward' | 'reverse'>('forward')
const isReverse = computed(() => createDirection.value === 'reverse')
const createDirectionHint = computed(() =>
createDirection.value === 'reverse'
isReverse.value
? t('order.pickupOrder.direction.reverseHint')
: t('order.pickupOrder.direction.forwardHint'),
)
const modalTitle = computed(() =>
isReverse.value
? t('order.pickupOrder.modal.createReverse')
: t('order.pickupOrder.modal.createForward'),
)
const routeDirectionLabel = computed(() =>
isReverse.value
? t('order.pickupOrder.route.reverseLabel')
: t('order.pickupOrder.route.forwardLabel'),
)
const createMode = ref<CreateMode>('bind')
const createModeTabs = computed(() => [
@ -839,4 +913,39 @@ defineExpose({ open })
height: 32px;
}
}
.pk-route-hint {
margin-left: 4px;
font-size: 12px;
font-weight: 400;
color: var(--color-text-3);
}
.pk-route {
display: flex;
align-items: flex-end;
gap: 12px;
margin-bottom: 8px;
}
.pk-route-side {
flex: 1;
min-width: 0;
:deep(.arco-form-item) {
margin-bottom: 16px;
}
}
.pk-route-arrow {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 32px;
margin-bottom: 16px;
color: var(--color-text-3);
font-size: 16px;
}
</style>

View File

@ -23,19 +23,12 @@ import {
markPickupPicked,
payPickupOrder,
payPickupOrderBatch,
postPickupOrderDownstreamForward,
removePickupOrderOrders,
reschedulePickupOrder,
schedulePickupOrder,
} from '@/api/order/pickupOrder'
import {
postOrderDownstreamForwardCreateOne,
postOrderBatchJob,
waitOrderBatchJob,
formatBatchJobDownstreamForwardResultText,
isOrderBatchQueueDisabledError,
formatBatchJobSummaryMessage,
type OrderDownstreamForwardResult,
} from '@/api/order'
import { type OrderDownstreamForwardResult } from '@/api/order'
import { CustomerService } from '@/api/customer'
import { resolveCurrentCustomer } from '@/api/customer/resolve'
import { $baseMessage } from '@/utils'
@ -602,66 +595,9 @@ function handleBatchPay() {
})()
}
function extractBoundOrderRows(detailRaw: any, pickupNo?: string): Array<{ id: number; serviceNo: string; pickupNo?: string }> {
const list = detailRaw?.orders ?? detailRaw?.Orders ?? []
if (!Array.isArray(list)) return []
const rows: Array<{ id: number; serviceNo: string; pickupNo?: string }> = []
for (const raw of list) {
const row = normalizeListRow(raw)
const orderId = Number(pickField(row, 'orderId', 'OrderId', 'id', 'Id') || 0)
if (!Number.isFinite(orderId) || orderId <= 0) continue
rows.push({
id: orderId,
serviceNo: String(pickField(row, 'serviceNo', 'ServiceNo') || orderId),
pickupNo,
})
}
return rows
}
async function resolveSelectedBoundOrders(): Promise<Array<{ id: number; serviceNo: string; pickupNo?: string }>> {
const pickupRows = selectedRows.value.filter((row) => row?.id != null)
if (!pickupRows.length) {
$baseMessage(t('order.pickupOrder.message.downstreamNeedSelect'), 'warning')
return []
}
const orderMap = new Map<number, { id: number; serviceNo: string; pickupNo?: string }>()
const failedPickups: string[] = []
for (const pickup of pickupRows) {
const pickupNo = String(pickup.pickupNo || pickup.id)
try {
const detailRaw = await getPickupOrderDetail(pickup.id)
const bound = extractBoundOrderRows(detailRaw, pickupNo)
for (const item of bound) {
if (!orderMap.has(item.id)) orderMap.set(item.id, item)
}
if (!bound.length) {
failedPickups.push(pickupNo)
}
} catch {
failedPickups.push(pickupNo)
}
}
const orders = [...orderMap.values()]
if (!orders.length) {
$baseMessage(t('order.pickupOrder.message.downstreamNoBoundOrders'), 'warning')
return []
}
if (failedPickups.length) {
$baseMessage(
t('order.pickupOrder.message.downstreamPartialEmpty', { pickups: failedPickups.join(', ') }),
'warning',
)
}
return orders
}
function formatDownstreamForwardResult(waybillNo: string, result: OrderDownstreamForwardResult): string {
function formatDownstreamForwardResult(pickupNo: string, result: OrderDownstreamForwardResult): string {
const lines = [
`【${waybillNo || '-'}】`,
`【${pickupNo || '-'}】`,
result.success ? '成功' : '失败',
`standardCode:createOrderOne`,
]
@ -675,47 +611,6 @@ function formatDownstreamForwardResult(waybillNo: string, result: OrderDownstrea
return lines.join('\n')
}
async function runDownstreamForwardBatch(orderRows: Array<{ id: number; serviceNo: string }>) {
const orderIds = orderRows.map((row) => row.id)
try {
const job = await postOrderBatchJob({ action: 'downstream-forward-create-one', orderIds })
const result = await waitOrderBatchJob(job.jobId)
if (result.timedOut) {
downstreamForwardResultText.value = `${formatBatchJobSummaryMessage(result.job, {
timedOut: true,
jobId: job.jobId,
})}\n\n${formatBatchJobDownstreamForwardResultText('预报下游', result.job, result.items, orderRows)}`
downstreamForwardModalVisible.value = true
return
}
const { job: finishedJob, items } = result
downstreamForwardResultText.value = formatBatchJobDownstreamForwardResultText(
'预报下游',
finishedJob,
items,
orderRows,
)
downstreamForwardModalVisible.value = true
return
} catch (e: any) {
if (!isOrderBatchQueueDisabledError(e)) {
throw e
}
}
const resultBlocks: string[] = []
for (const row of orderRows) {
try {
const result = await postOrderDownstreamForwardCreateOne({ id: row.id })
resultBlocks.push(formatDownstreamForwardResult(row.serviceNo, result))
} catch (err: any) {
resultBlocks.push(`【${row.serviceNo}】失败:${err?.message || '请求异常'}`)
}
}
downstreamForwardResultText.value = resultBlocks.join('\n\n')
downstreamForwardModalVisible.value = true
}
async function handleForwardDownstreamCreate() {
if (!canForwardDownstream.value) {
$baseMessage(t('order.pickupOrder.message.noPermission'), 'warning')
@ -723,11 +618,27 @@ async function handleForwardDownstreamCreate() {
}
if (batchDownstreamForwarding.value) return
const pickupRows = selectedRows.value.filter((row) => row?.id != null)
if (!pickupRows.length) {
$baseMessage(t('order.pickupOrder.message.downstreamNeedSelect'), 'warning')
return
}
batchDownstreamForwarding.value = true
try {
const orderRows = await resolveSelectedBoundOrders()
if (!orderRows.length) return
await runDownstreamForwardBatch(orderRows)
const resultBlocks: string[] = []
for (const pickup of pickupRows) {
const pickupNo = String(pickup.pickupNo || pickup.id)
try {
const result = await postPickupOrderDownstreamForward(pickup.id)
resultBlocks.push(formatDownstreamForwardResult(pickupNo, result))
} catch (err: any) {
resultBlocks.push(`【${pickupNo}】失败:${err?.message || '请求异常'}`)
}
}
downstreamForwardResultText.value = resultBlocks.join('\n\n')
downstreamForwardModalVisible.value = true
await loadData()
} catch (e: any) {
$baseMessage(e?.message || t('order.pickupOrder.message.actionFailed'), 'error')
} finally {
@ -1457,7 +1368,11 @@ onMounted(async () => {
class="btn-add app-btn-primary-soft"
@click="handleOpenCreate"
>
{{ t('order.pickupOrder.actions.create') }}
{{
directionFilter === DIRECTION_REVERSE
? t('order.pickupOrder.actions.createReverse')
: t('order.pickupOrder.actions.createForward')
}}
</a-button>
<a-button
v-if="hasPermission('Order.Pickup.Create')"

View File

@ -53,6 +53,7 @@ function mapRow(raw: Record<string, unknown>) {
unitPrice: pickField(row, 'unitPrice', 'UnitPrice'),
exHsCode: String(pickField(row, 'exHsCode', 'ExHsCode') ?? ''),
imHsCode: String(pickField(row, 'imHsCode', 'ImHsCode') ?? ''),
declareElement: String(pickField(row, 'declareElement', 'DeclareElement') ?? ''),
skuImageUrl: String(pickField(row, 'skuImageUrl', 'SkuImageUrl') ?? ''),
weightG: Number(pickField(row, 'weightG', 'WeightG') ?? 0),
remark: String(pickField(row, 'remark', 'Remark') ?? ''),

View File

@ -445,6 +445,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
{ key: 'items[0].weight', desc: '总重量(kg,可选,不填自动计算=单重×数量)', example: '0.2' },
{ key: 'items[0].imHsCode', desc: '进口HsCode', example: '' },
{ key: 'items[0].exHsCode', desc: '出口HsCode', example: '' },
{ key: 'items[0].declareElement', desc: '出口申报要素', example: '' },
{ key: 'items[0].skuImage', desc: '商品图片URL', example: '' },
{ key: 'items[0].category', desc: '类别', example: '' },
{ key: 'items[0].brand', desc: '品牌', example: '' },
@ -563,6 +564,8 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
出口HS编码: 'items[0].exHsCode',
进口海关编码: 'items[0].imHsCode',
出口海关编码: 'items[0].exHsCode',
出口申报要素: 'items[0].declareElement',
申报要素: 'items[0].declareElement',
商品图片URL: 'items[0].skuImage',
图片: 'items[0].skuImage',
类别: 'items[0].category',
@ -626,6 +629,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
'items[0].unit',
'items[0].imHsCode',
'items[0].exHsCode',
'items[0].declareElement',
'items[0].skuImage',
'items[0].category',
'items[0].brand',
@ -1999,6 +2003,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
weight: '',
imHsCode: '',
exHsCode: '',
declareElement: '',
skuImage: '',
category: '',
brand: '',
@ -2198,6 +2203,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
{ key: 'unit', label: '单位' },
{ key: 'imHsCode', label: '进口HsCode', extra: true },
{ key: 'exHsCode', label: '出口HsCode', extra: true },
{ key: 'declareElement', label: '出口申报要素', extra: true },
{ key: 'skuImage', label: '图片', extra: true },
{ key: 'category', label: '类别', extra: true },
{ key: 'brand', label: '品牌', extra: true },
@ -2851,6 +2857,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
weight: '',
imHsCode: '',
exHsCode: '',
declareElement: '',
skuImage: '',
category: '',
brand: '',
@ -2908,6 +2915,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
if (
formTrim(mapped.exHsCode)
|| formTrim(mapped.imHsCode)
|| formTrim(mapped.declareElement)
|| formTrim(mapped.skuImage)
|| formTrim(mapped.comment)
) {
@ -3003,6 +3011,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
weight: '0.3',
imHsCode: '',
exHsCode: '',
declareElement: '',
skuImage: '',
category: 'Accessories',
brand: 'DemoBrand',
@ -3075,6 +3084,7 @@ import SelectDeclarationSkuModal from './components/SelectDeclarationSkuModal.vu
weight: '',
imHsCode: '',
exHsCode: '',
declareElement: '',
skuImage: '',
category: '',
brand: '',

View File

@ -53,6 +53,21 @@ function formatWeight(n?: number | null) {
return `${Number(n)} KG`
}
function stageLabel(stage?: string) {
switch (stage) {
case 'condition':
return t('order.myOrders.allocationTrace.stage.condition')
case 'customer':
return t('order.myOrders.allocationTrace.stage.customer')
case 'channel':
return t('order.myOrders.allocationTrace.stage.channel')
case 'buyPrice':
return t('order.myOrders.allocationTrace.stage.buyPrice')
default:
return stage || ''
}
}
async function loadTrace(live = false) {
if (!props.orderId) return
loading.value = true
@ -137,6 +152,16 @@ function toggleExpand(id: number) {
<span class="label">{{ $t('order.myOrders.allocationTrace.stage') }}</span>
<span class="value">{{ trace.chargingStageName || trace.chargingStage || '-' }}</span>
</div>
<div
v-if="trace.poolCount != null || trace.conditionPassCount != null"
class="alloc-trace__meta-item"
>
<span class="label">{{ $t('order.myOrders.allocationTrace.poolSummary', {
pool: trace.poolCount ?? 0,
pass: trace.conditionPassCount ?? 0,
cand: trace.candidateCount ?? trace.candidates.length,
}) }}</span>
</div>
</div>
<a-alert
@ -214,13 +239,21 @@ function toggleExpand(id: number) {
:key="idx"
class="alloc-trace__skipped-row"
>
<span>{{ s.channelName || s.channelCode || s.serviceChannelId || '-' }}</span>
<div class="skipped-main">
<span class="skipped-channel">
{{ s.channelName || s.channelCode || s.serviceChannelId || '-' }}
</span>
<a-tag v-if="s.stage" size="small" color="gray">{{ stageLabel(s.stage) }}</a-tag>
<span class="reason">{{ s.reason }}</span>
</div>
<div v-if="s.matchCondition" class="skipped-formula" :title="s.matchCondition">
{{ s.matchCondition }}
</div>
</div>
</div>
<p v-if="trace.traceTime" class="alloc-trace__time">
{{ $t('order.myOrders.allocationTrace.traceTime') }}:{{ trace.traceTime }}
{{ $t('order.myOrders.allocationTrace.traceTime') }}?{{ trace.traceTime }}
</p>
</div>
</a-spin>
@ -383,16 +416,37 @@ function toggleExpand(id: number) {
.alloc-trace__skipped-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 6px 0;
flex-direction: column;
gap: 4px;
padding: 8px 0;
font-size: 12px;
color: var(--color-text-2);
border-bottom: 1px dashed var(--color-border-2);
.skipped-main {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.skipped-channel {
min-width: 0;
font-weight: 500;
color: var(--color-text-1);
}
.reason {
color: #f77234;
text-align: right;
}
.skipped-formula {
padding-left: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
color: var(--color-text-3);
}
}

View File

@ -0,0 +1,591 @@
<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 { IconQuestionCircle } from '@arco-design/web-vue/es/icon'
import HandsontablePro from '@/components/HandsontablePro/index.vue'
import PaginationPro from '@/components/PaginationPro/index.vue'
import { useTableContainerHeight } from '@/composables/useTableContainerHeight'
import { useWarehouseAdminCustomer } from '@/composables/useWarehouseAdminCustomer'
import {
createExportQualification,
deleteExportQualification,
getExportQualificationList,
setDefaultExportQualification,
updateExportQualification,
} from '@/api/customer/exportQualification'
import { hasPermission } from '@/utils/rbac-permission'
import { formatDateTime, normalizeListRow, pickField } from '@/utils/warehouseListMapper'
import { renderWarehouseActionBtn, wrapWarehouseActions } from '@/utils/warehouseActionRenderer'
defineOptions({ name: 'UserExportQualification' })
const { t } = useI18n()
const { tableContainerRef, tableHeight } = useTableContainerHeight()
const handsontableHeight = computed(() => Math.max(tableHeight.value || 0, 400))
const {
isAdmin,
customerId: adminCustomerId,
customerOptions,
withCustomerId,
ensureMutationCustomerId,
} = useWarehouseAdminCustomer()
const keyword = ref('')
const listLoading = ref(false)
const tableRows = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(50)
const totalCount = ref(0)
const modalVisible = ref(false)
const modalLoading = ref(false)
const formRef = ref()
const formState = reactive({
id: 0,
customerId: undefined as number | undefined,
companyName: '',
socialCreditCode: '',
domesticConsignor: '',
domesticConsignorCustomsCode: '',
producerName: '',
producerCustomsCode: '',
customsPort: '',
isDefault: false,
remark: '',
})
const isEdit = computed(() => formState.id > 0)
const modalTitle = computed(() =>
isEdit.value
? t('user.exportQualification.modal.edit')
: t('user.exportQualification.modal.add'),
)
const formRules = computed(() => ({
companyName: [{ required: true, message: t('user.exportQualification.message.companyNameRequired') }],
socialCreditCode: [
{ required: true, message: t('user.exportQualification.message.socialCreditCodeRequired') },
],
domesticConsignor: [
{ required: true, message: t('user.exportQualification.message.domesticConsignorRequired') },
],
producerName: [{ required: true, message: t('user.exportQualification.message.producerNameRequired') }],
}))
const hotColumns = computed(() => {
const cols = [
{ key: 'companyName', label: t('user.exportQualification.table.companyName'), width: 160 },
{
key: 'socialCreditCode',
label: t('user.exportQualification.table.socialCreditCode'),
width: 180,
},
{
key: 'domesticConsignor',
label: t('user.exportQualification.table.domesticConsignor'),
width: 140,
},
{
key: 'domesticConsignorCustomsCode',
label: t('user.exportQualification.table.domesticConsignorCustomsCode'),
width: 150,
},
{ key: 'producerName', label: t('user.exportQualification.table.producerName'), width: 140 },
{
key: 'producerCustomsCode',
label: t('user.exportQualification.table.producerCustomsCode'),
width: 150,
},
{ key: 'customsPort', label: t('user.exportQualification.table.customsPort'), width: 140 },
{ key: 'isDefaultText', label: t('user.exportQualification.table.isDefault'), width: 80 },
{ key: 'remark', label: t('user.exportQualification.table.remark'), width: 140 },
{ key: 'createTime', label: t('user.exportQualification.table.createTime'), width: 160 },
]
if (isAdmin.value) {
cols.unshift({
key: 'customerId',
label: t('warehouse.common.customer'),
width: 90,
})
}
return cols
})
function mapRow(raw: Record<string, unknown>) {
const row = normalizeListRow(raw)
const isDefault = Boolean(pickField(row, 'isDefault', 'IsDefault'))
return {
id: pickField<number>(row, 'id', 'Id'),
customerId: pickField(row, 'customerId', 'CustomerId') ?? '-',
companyName: pickField(row, 'companyName', 'CompanyName') || '-',
socialCreditCode: pickField(row, 'socialCreditCode', 'SocialCreditCode') || '-',
domesticConsignor: pickField(row, 'domesticConsignor', 'DomesticConsignor') || '-',
domesticConsignorCustomsCode:
pickField(row, 'domesticConsignorCustomsCode', 'DomesticConsignorCustomsCode') || '-',
producerName: pickField(row, 'producerName', 'ProducerName') || '-',
producerCustomsCode: pickField(row, 'producerCustomsCode', 'ProducerCustomsCode') || '-',
customsPort: pickField(row, 'customsPort', 'CustomsPort') || '-',
isDefaultText: isDefault ? t('warehouse.common.yes') : t('warehouse.common.no'),
remark: pickField(row, 'remark', 'Remark') || '-',
createTime: formatDateTime(pickField(row, 'createTime', 'CreateTime')),
_raw: row,
}
}
async function loadList() {
listLoading.value = true
try {
const { items, total } = await getExportQualificationList(
withCustomerId({
page: currentPage.value,
pageSize: pageSize.value,
keyword: keyword.value || undefined,
}),
)
tableRows.value = (items || []).map((item: Record<string, unknown>) => mapRow(item))
totalCount.value = Number(total || 0)
} catch (e: any) {
Message.error(e?.message || t('user.exportQualification.message.loadFailed'))
} finally {
listLoading.value = false
}
}
function handlePageChange(page: number) {
currentPage.value = page
loadList()
}
function handlePageSizeChange(size: number) {
pageSize.value = size
currentPage.value = 1
loadList()
}
function handleSearch() {
currentPage.value = 1
loadList()
}
function handleCustomerChange() {
currentPage.value = 1
loadList()
}
function resetForm() {
Object.assign(formState, {
id: 0,
customerId: isAdmin.value ? adminCustomerId.value : undefined,
companyName: '',
socialCreditCode: '',
domesticConsignor: '',
domesticConsignorCustomsCode: '',
producerName: '',
producerCustomsCode: '',
customsPort: '',
isDefault: false,
remark: '',
})
}
function handleAdd() {
if (!hasPermission('User.ExportQualification.Create')) return
if (isAdmin.value && ensureMutationCustomerId() == null) return
resetForm()
modalVisible.value = true
}
function handleEdit(row: any) {
if (!hasPermission('User.ExportQualification.Update')) return
const raw = row._raw || row
Object.assign(formState, {
id: Number(pickField(raw, 'id', 'Id') ?? 0),
customerId: Number(pickField(raw, 'customerId', 'CustomerId') ?? 0) || undefined,
companyName: String(pickField(raw, 'companyName', 'CompanyName') ?? ''),
socialCreditCode: String(pickField(raw, 'socialCreditCode', 'SocialCreditCode') ?? ''),
domesticConsignor: String(pickField(raw, 'domesticConsignor', 'DomesticConsignor') ?? ''),
domesticConsignorCustomsCode: String(
pickField(raw, 'domesticConsignorCustomsCode', 'DomesticConsignorCustomsCode') ?? '',
),
producerName: String(pickField(raw, 'producerName', 'ProducerName') ?? ''),
producerCustomsCode: String(pickField(raw, 'producerCustomsCode', 'ProducerCustomsCode') ?? ''),
customsPort: String(pickField(raw, 'customsPort', 'CustomsPort') ?? ''),
isDefault: Boolean(pickField(raw, 'isDefault', 'IsDefault')),
remark: String(pickField(raw, 'remark', 'Remark') ?? ''),
})
modalVisible.value = true
}
async function handleModalOk() {
const errors = await formRef.value?.validate()
if (errors) return
const mutationCustomerId = isEdit.value
? formState.customerId
: ensureMutationCustomerId()
if (isAdmin.value && (mutationCustomerId == null || mutationCustomerId <= 0)) {
return
}
modalLoading.value = true
try {
const body = {
id: formState.id,
companyName: formState.companyName.trim(),
socialCreditCode: formState.socialCreditCode.trim(),
domesticConsignor: formState.domesticConsignor.trim(),
domesticConsignorCustomsCode: formState.domesticConsignorCustomsCode.trim() || undefined,
producerName: formState.producerName.trim(),
producerCustomsCode: formState.producerCustomsCode.trim() || undefined,
customsPort: formState.customsPort.trim() || undefined,
isDefault: formState.isDefault,
remark: formState.remark.trim() || undefined,
customerId: isAdmin.value ? mutationCustomerId : undefined,
}
if (isEdit.value) {
await updateExportQualification(body)
} else {
await createExportQualification(body)
}
Message.success(t('user.exportQualification.message.saveSuccess'))
modalVisible.value = false
await loadList()
} catch (e: any) {
Message.error(e?.message || t('user.exportQualification.message.saveFailed'))
} finally {
modalLoading.value = false
}
}
function handleModalCancel() {
modalVisible.value = false
}
function handleDelete(row: any) {
if (!hasPermission('User.ExportQualification.Delete')) return
Modal.confirm({
title: t('warehouse.common.delete'),
content: row.companyName,
onOk: async () => {
await deleteExportQualification(Number(row.id))
Message.success(t('user.exportQualification.message.deleteSuccess'))
await loadList()
},
})
}
async function handleSetDefault(row: any) {
if (
!hasPermission('User.ExportQualification.SetDefault') &&
!hasPermission('User.ExportQualification.Update')
) {
return
}
try {
await setDefaultExportQualification(Number(row.id))
Message.success(t('user.exportQualification.message.setDefaultSuccess'))
await loadList()
} catch (e: any) {
Message.error(e?.message || t('user.exportQualification.message.saveFailed'))
}
}
function actionRenderer() {
const parts: string[] = []
if (hasPermission('User.ExportQualification.Update')) {
parts.push(renderWarehouseActionBtn('edit', t('warehouse.common.edit')))
}
if (
hasPermission('User.ExportQualification.SetDefault') ||
hasPermission('User.ExportQualification.Update')
) {
parts.push(
renderWarehouseActionBtn('default', t('user.exportQualification.actions.setDefault'), 'star'),
)
}
if (hasPermission('User.ExportQualification.Delete')) {
parts.push(renderWarehouseActionBtn('delete', t('warehouse.common.delete')))
}
return wrapWarehouseActions(parts)
}
function handleActionClick(row: any, _i: number, action?: string) {
if (action === 'edit') handleEdit(row)
if (action === 'default') handleSetDefault(row)
if (action === 'delete') handleDelete(row)
}
onMounted(loadList)
</script>
<template>
<div class="export-qualification-page">
<div class="export-qualification-page-card">
<div class="table-toolbar">
<div class="toolbar-left">
<a-select
v-if="isAdmin"
v-model="adminCustomerId"
allow-clear
allow-search
:placeholder="t('warehouse.common.selectCustomer')"
:options="customerOptions"
style="width: 220px"
@change="handleCustomerChange"
/>
<a-input
v-model="keyword"
:placeholder="t('user.exportQualification.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
v-if="hasPermission('User.ExportQualification.Create')"
class="btn-add app-btn-primary-soft"
@click="handleAdd"
>
{{ t('user.exportQualification.actions.add') }}
</a-button>
</div>
<div class="toolbar-right">
<PaginationPro
:total-count="totalCount"
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 30, 50, 100]"
@page-change="handlePageChange"
@page-size-change="handlePageSizeChange"
/>
</div>
</div>
<div ref="tableContainerRef" class="list-wrap">
<HandsontablePro
:columns="hotColumns"
:data="tableRows"
:total="totalCount"
:current-page="currentPage"
:page-size="pageSize"
:stretch-columns="true"
:height="handsontableHeight"
:loading="listLoading"
:read-only="true"
show-action-column
:action-renderer="actionRenderer"
@action-click="handleActionClick"
/>
</div>
</div>
<a-modal
v-model:visible="modalVisible"
:title="modalTitle"
:confirm-loading="modalLoading"
width="720px"
@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('user.exportQualification.fields.companyName')"
field="companyName"
>
<a-input
v-model="formState.companyName"
:placeholder="t('user.exportQualification.placeholder.input')"
allow-clear
/>
</a-form-item>
<a-form-item
:label="t('user.exportQualification.fields.socialCreditCode')"
field="socialCreditCode"
>
<a-input
v-model="formState.socialCreditCode"
:placeholder="t('user.exportQualification.placeholder.input')"
allow-clear
/>
</a-form-item>
<a-form-item
:label="t('user.exportQualification.fields.domesticConsignor')"
field="domesticConsignor"
>
<a-input
v-model="formState.domesticConsignor"
:placeholder="t('user.exportQualification.placeholder.input')"
allow-clear
/>
</a-form-item>
<a-form-item
:label="t('user.exportQualification.fields.domesticConsignorCustomsCode')"
field="domesticConsignorCustomsCode"
>
<a-input
v-model="formState.domesticConsignorCustomsCode"
:placeholder="t('user.exportQualification.placeholder.input')"
allow-clear
/>
</a-form-item>
<a-form-item
:label="t('user.exportQualification.fields.producerName')"
field="producerName"
>
<a-input
v-model="formState.producerName"
:placeholder="t('user.exportQualification.placeholder.input')"
allow-clear
/>
</a-form-item>
<a-form-item
:label="t('user.exportQualification.fields.producerCustomsCode')"
field="producerCustomsCode"
>
<a-input
v-model="formState.producerCustomsCode"
:placeholder="t('user.exportQualification.placeholder.input')"
allow-clear
/>
</a-form-item>
<a-form-item
:label="t('user.exportQualification.fields.customsPort')"
field="customsPort"
>
<a-input
v-model="formState.customsPort"
:placeholder="t('user.exportQualification.placeholder.input')"
allow-clear
/>
</a-form-item>
<a-form-item field="isDefault">
<template #label>
<span class="label-with-tip">
{{ t('user.exportQualification.fields.isDefault') }}
<a-tooltip :content="t('user.exportQualification.tip.isDefault')">
<icon-question-circle class="tip-icon" />
</a-tooltip>
</span>
</template>
<div class="switch-with-text">
<a-switch v-model="formState.isDefault" />
<span class="switch-text">
{{
formState.isDefault
? t('user.exportQualification.hint.defaultOn')
: t('user.exportQualification.hint.defaultOff')
}}
</span>
</div>
</a-form-item>
<a-form-item :label="t('user.exportQualification.fields.remark')" field="remark">
<a-textarea
v-model="formState.remark"
:placeholder="t('user.exportQualification.placeholder.remark')"
:auto-size="{ minRows: 2 }"
allow-clear
/>
</a-form-item>
</a-form>
</a-modal>
</div>
</template>
<style scoped lang="less">
.export-qualification-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;
}
.export-qualification-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;
}
.label-with-tip {
display: inline-flex;
align-items: center;
gap: 4px;
}
.tip-icon {
color: var(--color-text-3);
cursor: help;
font-size: 14px;
}
.switch-with-text {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 32px;
}
.switch-text {
color: var(--color-text-2);
font-size: 13px;
}
</style>

View File

@ -16,6 +16,7 @@ import {
} from '@/api/warehouse'
import { getWarehouseList } from '@/api/system/warehouse'
import { resolveOmsAssetUrl } from '@/api/oms/common'
import { streamFileBlob } from '@/api/fileStorage'
import { $baseMessage } from '@/utils'
import { hasPermission } from '@/utils/rbac-permission'
import {
@ -68,6 +69,7 @@ const formModel = reactive({
unitPrice: undefined as number | undefined,
exHsCode: '',
imHsCode: '',
declareElement: '',
weightG: undefined as number | undefined,
lengthCm: undefined as number | undefined,
widthCm: undefined as number | undefined,
@ -97,12 +99,48 @@ const skuImagePreview = computed(
() => skuImagePreviewOverride.value || resolveOmsAssetUrl(formModel.skuImageUrl),
)
function guessSkuImageMime(pathOrUrl: string): string {
const lower = pathOrUrl.toLowerCase().split('?')[0] || ''
if (lower.endsWith('.png')) return 'image/png'
if (lower.endsWith('.webp')) return 'image/webp'
if (lower.endsWith('.gif')) return 'image/gif'
if (lower.endsWith('.bmp')) return 'image/bmp'
return 'image/jpeg'
}
/** COS 自定义域名签名 URL 偶发直开失败时,经鉴权 stream 转 blob 预览 */
async function ensureSkuImagePreviewVisible(keyOrUrl?: string) {
const raw = String(keyOrUrl || formModel.skuImageUrl || '').trim()
if (!raw || raw.startsWith('blob:') || raw.startsWith('data:')) return
const current = skuImagePreview.value
if (current.startsWith('blob:')) return
if (!/^https?:\/\/cos\./i.test(current) && !/uploads\//i.test(raw)) return
try {
const blob = await streamFileBlob(raw)
const typed = blob.type?.startsWith('image/')
? blob
: new Blob([blob], { type: guessSkuImageMime(raw) })
if (skuImagePreviewOverride.value.startsWith('blob:')) {
URL.revokeObjectURL(skuImagePreviewOverride.value)
}
skuImagePreviewOverride.value = URL.createObjectURL(typed)
syncImageFileList()
} catch {
// 保留原预览地址
}
}
async function onSkuImagePreviewError() {
await ensureSkuImagePreviewVisible(formModel.skuImageUrl || skuImagePreview.value)
}
function hasExtraFieldValues() {
return !!(
formModel.skuNameEn.trim()
|| formModel.unitPrice != null
|| formModel.exHsCode.trim()
|| formModel.imHsCode.trim()
|| formModel.declareElement.trim()
|| formModel.remark.trim()
)
}
@ -124,6 +162,7 @@ const hotColumns = computed(() => {
{ key: 'unitPrice', label: t('warehouse.sku.table.unitPrice'), width: 90 },
{ key: 'exHsCode', label: t('warehouse.sku.table.exHsCode'), width: 120 },
{ key: 'imHsCode', label: t('warehouse.sku.table.imHsCode'), width: 120 },
{ key: 'declareElement', label: t('warehouse.sku.table.declareElement'), width: 160 },
{ key: 'weightG', label: t('warehouse.sku.table.weightG'), width: 90 },
{ key: 'dimensions', label: t('warehouse.sku.table.dimensions'), width: 120 },
{ key: 'remark', label: t('warehouse.sku.table.remark'), width: 140 },
@ -147,6 +186,7 @@ function resetForm() {
formModel.unitPrice = undefined
formModel.exHsCode = ''
formModel.imHsCode = ''
formModel.declareElement = ''
formModel.weightG = undefined
formModel.lengthCm = undefined
formModel.widthCm = undefined
@ -220,6 +260,9 @@ async function handleSkuImageUpload(options: any) {
formModel.skuImageUrl = key
skuImagePreviewOverride.value = previewUrl
syncImageFileList()
if (/^https?:\/\/cos\./i.test(previewUrl)) {
await ensureSkuImagePreviewVisible(key)
}
options?.onSuccess?.(key)
$baseMessage(t('warehouse.sku.message.uploadSuccess'), 'success')
} catch (e: any) {
@ -243,6 +286,7 @@ function mapRow(raw: Record<string, unknown>) {
unitPrice: pickField(row, 'unitPrice', 'UnitPrice') ?? '-',
exHsCode: pickField(row, 'exHsCode', 'ExHsCode') ?? '-',
imHsCode: pickField(row, 'imHsCode', 'ImHsCode') ?? '-',
declareElement: pickField(row, 'declareElement', 'DeclareElement') ?? '-',
weightG: pickField(row, 'weightG', 'WeightG') ?? '-',
dimensions: formatDimensions(row),
remark: pickField(row, 'remark', 'Remark') ?? '',
@ -327,6 +371,7 @@ function handleEdit(row: any) {
formModel.unitPrice = pickField<number>(raw, 'unitPrice', 'UnitPrice')
formModel.exHsCode = String(pickField(raw, 'exHsCode', 'ExHsCode') ?? '')
formModel.imHsCode = String(pickField(raw, 'imHsCode', 'ImHsCode') ?? '')
formModel.declareElement = String(pickField(raw, 'declareElement', 'DeclareElement') ?? '')
formModel.weightG = pickField<number>(raw, 'weightG', 'WeightG')
formModel.lengthCm = pickField<number>(raw, 'lengthCm', 'LengthCm')
formModel.widthCm = pickField<number>(raw, 'widthCm', 'WidthCm')
@ -335,13 +380,14 @@ function handleEdit(row: any) {
if (skuImagePreviewOverride.value.startsWith('blob:')) {
URL.revokeObjectURL(skuImagePreviewOverride.value)
}
// 详情接口已返回临时访问 URL,直接用作预览
// 详情接口已返回临时访问 URL,直接用作预览;COS 签名直开失败时再走 stream
skuImagePreviewOverride.value = /^https?:\/\//i.test(formModel.skuImageUrl)
? formModel.skuImageUrl
: ''
syncImageFileList()
showExtraFields.value = hasExtraFieldValues()
openModal(true)
void ensureSkuImagePreviewVisible(formModel.skuImageUrl)
}
async function handleSave() {
@ -548,7 +594,7 @@ onMounted(async () => {
>
<a-spin v-if="imageUploading" :size="18" />
<template v-else-if="skuImagePreview">
<img :src="skuImagePreview" alt="sku" />
<img :src="skuImagePreview" alt="sku" @error="onSkuImagePreviewError" />
<span class="sku-image-upload__mask">
<IconImage :size="16" />
<em>{{ t('warehouse.sku.form.reuploadImage') }}</em>
@ -641,6 +687,16 @@ onMounted(async () => {
</a-form-item>
</a-col>
</a-row>
<a-form-item>
<template #label>{{ t('warehouse.sku.form.declareElement') }}</template>
<a-textarea
v-model="formModel.declareElement"
:max-length="255"
show-word-limit
:placeholder="t('warehouse.sku.form.declareElementPlaceholder')"
:auto-size="{ minRows: 1, maxRows: 3 }"
/>
</a-form-item>
<a-form-item>
<template #label>{{ t('warehouse.sku.form.remark') }}</template>
<a-textarea