diff --git a/docker-compose.development.yml b/docker-compose.development.yml
index 03d86d9..d52a739 100644
--- a/docker-compose.development.yml
+++ b/docker-compose.development.yml
@@ -7,10 +7,15 @@ services:
- "127.0.0.1:9002:80"
environment:
API_UPSTREAM: "http://firstleg-development-gateway:9000"
+ OVERSEAS_API_UPSTREAM: "http://firstleg-development-gateway-overseas:9300"
networks:
- development-backend
+ - development-overseas-backend
networks:
development-backend:
external: true
name: firstleg-development-network
+ development-overseas-backend:
+ external: true
+ name: firstleg-development-overseas-network
diff --git a/src/deployment/nginxConfig.test.mjs b/src/deployment/nginxConfig.test.mjs
index c32b26a..44dbcd0 100644
--- a/src/deployment/nginxConfig.test.mjs
+++ b/src/deployment/nginxConfig.test.mjs
@@ -4,6 +4,7 @@ import test from 'node:test'
const config = readFileSync(new URL('../../nginx/default.conf.template', import.meta.url), 'utf8')
const testCompose = readFileSync(new URL('../../docker-compose.test.yml', import.meta.url), 'utf8')
+const developmentCompose = readFileSync(new URL('../../docker-compose.development.yml', import.meta.url), 'utf8')
const jenkinsfile = readFileSync(new URL('../../Jenkinsfile', import.meta.url), 'utf8')
const dockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8')
@@ -22,6 +23,15 @@ test('routes domestic and overseas browser APIs to their test gateways without d
assert.doesNotMatch(config, /\/api\/api/)
})
+test('connects the development client proxy to both development gateways', () => {
+ assert.match(developmentCompose, /API_UPSTREAM: "http:\/\/firstleg-development-gateway:9000"/)
+ assert.match(developmentCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/firstleg-development-gateway-overseas:9300"/)
+ assert.match(developmentCompose, /- development-backend/)
+ assert.match(developmentCompose, /- development-overseas-backend/)
+ assert.match(developmentCompose, /development-overseas-backend:[\s\S]*?external: true[\s\S]*?name: firstleg-development-overseas-network/)
+ assert.doesNotMatch(developmentCompose, /test-api|prod(?:uction)?/i)
+})
+
test('runs each deployment on its target node without controller-local SSH files', () => {
assert.match(jenkinsfile, /env\.JOB_BASE_NAME\?\.endsWith\('-dev'\) \? 'java-dev' : 'java-test'/)
assert.match(jenkinsfile, /DEVELOPMENT_COMPOSE_PROJECT = 'client-web'/)
diff --git a/src/modules/account/utils/accountProfile.test.mjs b/src/modules/account/utils/accountProfile.test.mjs
index 3b5d62c..85fe484 100644
--- a/src/modules/account/utils/accountProfile.test.mjs
+++ b/src/modules/account/utils/accountProfile.test.mjs
@@ -3,20 +3,26 @@ import test from 'node:test'
import {
getPasswordStrength,
validateEditableAccountProfile,
+ validateLoginIdentifierChange,
validatePasswordChange,
} from './accountProfile.ts'
test('accepts a complete editable account profile', () => {
assert.deepEqual(validateEditableAccountProfile({
realName: '企业管理员',
- phone: '13800006601',
email: 'admin@example.com',
}), [])
})
test('reports invalid profile fields together', () => {
- const errors = validateEditableAccountProfile({ realName: '', phone: '1'.repeat(33), email: 'bad' })
- assert.equal(errors.length, 3)
+ const errors = validateEditableAccountProfile({ realName: '', email: 'bad' })
+ assert.equal(errors.length, 2)
+})
+
+test('validates the frozen username, mainland mobile and current-password rules', () => {
+ assert.deepEqual(validateLoginIdentifierChange({ username: 'Merchant.Admin', phone: '13800006601', currentPassword: 'Current123' }), [])
+ assert.equal(validateLoginIdentifierChange({ username: '1bad', phone: '10086', currentPassword: '' }).length, 3)
+ assert.match(validateLoginIdentifierChange({ username: 'Merchant.Admin', phone: '11111111111', currentPassword: 'Current123' }).join('|'), /有效的中国大陆手机号/)
})
test('validates password length, equality and confirmation', () => {
diff --git a/src/modules/account/utils/accountProfile.ts b/src/modules/account/utils/accountProfile.ts
index 5405125..a9413c8 100644
--- a/src/modules/account/utils/accountProfile.ts
+++ b/src/modules/account/utils/accountProfile.ts
@@ -1,9 +1,14 @@
export interface EditableAccountProfile {
realName: string
- phone: string
email: string
}
+export interface LoginIdentifierChangePayload {
+ username: string
+ phone: string
+ currentPassword: string
+}
+
export interface PasswordChangePayload {
currentPassword: string
newPassword: string
@@ -11,20 +16,30 @@ export interface PasswordChangePayload {
}
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
+const USERNAME_PATTERN = /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/
+const MAINLAND_MOBILE_PATTERN = /^1[3-9]\d{9}$/
export function validateEditableAccountProfile(profile: EditableAccountProfile): string[] {
const errors: string[] = []
const realName = profile.realName.trim()
- const phone = profile.phone.trim()
const email = profile.email.trim()
if (realName.length < 1 || realName.length > 64) errors.push('姓名需为 1–64 个字符')
- if (phone.length > 32) errors.push('联系电话不能超过 32 个字符')
if (email && !EMAIL_PATTERN.test(email)) errors.push('请输入有效的邮箱地址')
return errors
}
+export function validateLoginIdentifierChange(payload: LoginIdentifierChangePayload): string[] {
+ const errors: string[] = []
+ if (!USERNAME_PATTERN.test(payload.username.trim())) {
+ errors.push('用户名须以字母开头,并由 4–64 位字母、数字或 _.@- 组成')
+ }
+ if (!MAINLAND_MOBILE_PATTERN.test(payload.phone.trim())) errors.push('请输入有效的中国大陆手机号')
+ if (!payload.currentPassword) errors.push('请输入当前密码')
+ return errors
+}
+
export function validatePasswordChange(payload: PasswordChangePayload): string[] {
const errors: string[] = []
diff --git a/src/modules/account/views/AccountProfileView.test.mjs b/src/modules/account/views/AccountProfileView.test.mjs
index d4327b8..5ae5610 100644
--- a/src/modules/account/views/AccountProfileView.test.mjs
+++ b/src/modules/account/views/AccountProfileView.test.mjs
@@ -6,6 +6,7 @@ const routerSource = fs.readFileSync(new URL('../routes.ts', import.meta.url), '
const viewSource = fs.readFileSync(new URL('./AccountProfileView.vue', import.meta.url), 'utf8')
const authSource = fs.readFileSync(new URL('../../auth/store/auth.ts', import.meta.url), 'utf8')
const authApiSource = fs.readFileSync(new URL('../../auth/api/auth.ts', import.meta.url), 'utf8')
+const iamApiSource = fs.readFileSync(new URL('../../tenant-management/api/merchantIam.ts', import.meta.url), 'utf8')
test('registers the account profile route and navigation entry', () => {
assert.match(routerSource, /path: 'account\/profile'[\s\S]*?component: \(\) => import\('\.\/views\/AccountProfileView\.vue'\)/)
@@ -21,12 +22,29 @@ test('implements profile view, edit, save and cancel states', () => {
assert.match(viewSource, /function startEditing\(\)/)
assert.match(viewSource, /function cancelEditing\(\)/)
assert.match(viewSource, /async function saveProfile\(\)/)
- assert.match(viewSource, /账号标识暂不支持在客户端修改/)
+ assert.match(viewSource, /请在“登录标识”中修改/)
+ assert.match(viewSource, /
登录手机号<\/dt>\{\{ auth\.profile\.phone/)
+ assert.doesNotMatch(viewSource, /联系电话<\/dt>\{\{ auth\.profile\.phone/)
assert.match(viewSource, /企业归属由平台审核关系确定/)
assert.match(authSource, /async function savePersonalProfile\(/)
assert.match(viewSource, /await auth\.savePersonalProfile/)
})
+test('changes username and mobile with current-password verification then clears every local session', () => {
+ assert.match(viewSource, /name="identifiers"/)
+ assert.match(viewSource, /validateLoginIdentifierChange/)
+ assert.match(viewSource, /await updateMerchantLoginIdentifiers/)
+ assert.match(iamApiSource, /url: '\/merchant\/v1\/iam\/profile\/login-identifiers'/)
+ assert.match(iamApiSource, /data: payload/)
+ assert.match(viewSource, /error\.code === 'A0409'/)
+ assert.match(viewSource, /error\.code === 'A0401'/)
+ assert.match(viewSource, /auth\.clearSession\(\)/)
+ assert.match(viewSource, /reason: 'identifiers-updated'/)
+ assert.match(viewSource, /label="登录手机号" prop="phone"/)
+ assert.match(viewSource, /全部登录会话都会立即退出/)
+ assert.doesNotMatch(viewSource, /auth\.logout\(\)[\s\S]*identifiers-updated/)
+})
+
test('implements password validation, strength feedback and double-submit protection', () => {
assert.match(viewSource, /async function changePassword\(\)/)
assert.match(viewSource, /validatePasswordChange/)
diff --git a/src/modules/account/views/AccountProfileView.vue b/src/modules/account/views/AccountProfileView.vue
index e678bd7..9b2d4e1 100644
--- a/src/modules/account/views/AccountProfileView.vue
+++ b/src/modules/account/views/AccountProfileView.vue
@@ -13,14 +13,18 @@ import {
X,
} from '@lucide/vue'
import { changeCurrentPassword, useAuthStore } from '@/modules/auth/public'
+import { ApiError } from '@/api/http'
+import { updateMerchantLoginIdentifiers } from '@/modules/tenant-management/public'
import {
getPasswordStrength,
validateEditableAccountProfile,
+ validateLoginIdentifierChange,
validatePasswordChange,
+ type LoginIdentifierChangePayload,
type PasswordChangePayload,
} from '@/modules/account/utils/accountProfile'
-type AccountTab = 'profile' | 'password'
+type AccountTab = 'profile' | 'identifiers' | 'password'
interface ProfileForm {
username: string
@@ -35,9 +39,12 @@ const router = useRouter()
const activeTab = ref('profile')
const editing = ref(false)
const profileSaving = ref(false)
+const identifiersSaving = ref(false)
+const identifiersError = ref('')
const passwordSaving = ref(false)
const passwordChangedAt = ref('')
const profileFormRef = ref()
+const identifiersFormRef = ref()
const passwordFormRef = ref()
const profileForm = reactive({ ...auth.profile })
@@ -46,20 +53,38 @@ const passwordForm = reactive({
newPassword: '',
confirmPassword: '',
})
+const identifiersForm = reactive({
+ username: auth.profile.username,
+ phone: auth.profile.phone,
+ currentPassword: '',
+})
const profileRules: FormRules = {
realName: [
{ required: true, message: '请输入真实姓名', trigger: 'blur' },
{ min: 1, max: 64, message: '姓名需为 1–64 个字符', trigger: 'blur' },
],
- phone: [
- { max: 32, message: '联系电话不能超过 32 个字符', trigger: 'blur' },
- ],
email: [
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' },
],
}
+const identifierRules: FormRules = {
+ username: [{
+ required: true,
+ pattern: /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/,
+ message: '用户名须以字母开头,并由 4–64 位字母、数字或 _.@- 组成',
+ trigger: 'blur',
+ }],
+ phone: [{
+ required: true,
+ pattern: /^1[3-9]\d{9}$/,
+ message: '请输入有效的中国大陆手机号',
+ trigger: 'blur',
+ }],
+ currentPassword: [{ required: true, message: '请输入当前密码', trigger: 'blur' }],
+}
+
const passwordRules: FormRules = {
currentPassword: [{
required: true,
@@ -129,7 +154,6 @@ async function saveProfile() {
try {
await auth.savePersonalProfile({
realName: profileForm.realName.trim(),
- phone: profileForm.phone.trim(),
email: profileForm.email.trim(),
})
syncProfileForm()
@@ -142,6 +166,42 @@ async function saveProfile() {
}
}
+function resetIdentifiersForm() {
+ Object.assign(identifiersForm, {
+ username: auth.profile.username,
+ phone: auth.profile.phone,
+ currentPassword: '',
+ })
+ identifiersError.value = ''
+ identifiersFormRef.value?.clearValidate()
+}
+
+async function changeLoginIdentifiers() {
+ if (identifiersSaving.value) return
+ identifiersError.value = ''
+ const valid = await identifiersFormRef.value?.validate().catch(() => false)
+ if (!valid || validateLoginIdentifierChange(identifiersForm).length > 0) return
+ identifiersSaving.value = true
+ const username = identifiersForm.username.trim()
+ try {
+ await updateMerchantLoginIdentifiers({
+ username,
+ phone: identifiersForm.phone.trim(),
+ currentPassword: identifiersForm.currentPassword,
+ })
+ auth.clearSession()
+ await router.replace({ path: '/login', query: { account: username, reason: 'identifiers-updated' } })
+ } catch (error) {
+ identifiersForm.currentPassword = ''
+ if (error instanceof ApiError && error.code === 'A0409') identifiersError.value = '用户名或登录手机号已被使用,请更换后重试'
+ else if (error instanceof ApiError && error.code === 'A0401') identifiersError.value = '当前密码错误'
+ else if (error instanceof ApiError && error.code === 'A0403') identifiersError.value = '当前会话不允许修改登录标识,请退出代登录后重试'
+ else identifiersError.value = error instanceof Error ? error.message : '登录标识修改失败,请重试'
+ } finally {
+ identifiersSaving.value = false
+ }
+}
+
function resetPasswordForm() {
passwordForm.currentPassword = ''
passwordForm.newPassword = ''
@@ -190,12 +250,9 @@ async function changePassword() {
class="account-form-grid"
@submit.prevent="saveProfile"
>
-
-
- 账号标识暂不支持在客户端修改
-
+ 请在“登录标识”中修改
-
+ 请在“登录标识”中修改
@@ -210,12 +267,26 @@ async function changePassword() {
- 用户名
- {{ auth.profile.username }}
- 姓名
- {{ auth.profile.realName }}
- - 联系电话
- {{ auth.profile.phone || '未填写' }}
+ - 登录手机号
- {{ auth.profile.phone || '未填写' }}
- 邮箱
- {{ auth.profile.email || '未填写' }}
- 所属公司
- {{ auth.profile.tenantName }}
+
+ 登录标识
+
+
+
+
+
+
+
+ 重置保存并重新登录
+
+
+
+
修改密码
@@ -280,6 +351,10 @@ async function changePassword() {
.account-form-grid :deep(.el-form-item) { align-content: start; margin-bottom: 21px; }
.account-form-actions { display: flex; grid-column: 1 / -1; justify-content: flex-end; gap: 8px; padding-top: 2px; }
.field-help { margin-top: 6px; color: var(--muted); font-size: 10px; line-height: 1.5; }
+.identifier-layout { max-width: 620px; padding-top: 22px; }
+.identifier-form { display: grid; gap: 2px; }
+.identifier-form > :deep(.el-alert) { margin-bottom: 16px; }
+.identifier-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 10px; padding-top: 18px; border-top: 1px solid var(--border); }
.password-layout { display: grid; grid-template-columns: minmax(0, 440px) minmax(260px, 1fr); align-items: start; max-width: 980px; gap: clamp(32px, 6vw, 72px); padding-top: 22px; }
.password-form { max-width: 440px; }
.password-form :deep(.el-form-item) { margin-bottom: 18px; }
@@ -316,5 +391,6 @@ async function changePassword() {
.account-info-grid div { min-height: 82px; }
.password-actions { width: 100%; }
.password-actions .el-button { min-height: 44px; flex: 1; }
+ .identifier-actions .el-button { min-height: 44px; flex: 1; }
}
diff --git a/src/modules/auth/api/auth.ts b/src/modules/auth/api/auth.ts
index f0fc43e..cb3cdec 100644
--- a/src/modules/auth/api/auth.ts
+++ b/src/modules/auth/api/auth.ts
@@ -7,7 +7,7 @@ export interface RegisterMerchantRequest {
displayName: string
companyName: string
legalPerson: string
- legalPhone: string
+ phone: string
contactEmail?: string
countryCode?: string
address?: string
@@ -27,8 +27,8 @@ export interface ImpersonationExchangeResult {
expiresIn: number
}
-export function loginMerchant(username: string, password: string) {
- return apiRequest({ method: 'POST', url: '/auth/login', data: { username, password } })
+export function loginMerchant(loginId: string, password: string) {
+ return apiRequest({ method: 'POST', url: '/auth/login', data: { loginId, password } })
}
export function registerMerchant(payload: RegisterMerchantRequest) {
diff --git a/src/modules/auth/store/auth.ts b/src/modules/auth/store/auth.ts
index ef6bc45..574c1f4 100644
--- a/src/modules/auth/store/auth.ts
+++ b/src/modules/auth/store/auth.ts
@@ -149,8 +149,8 @@ export const useAuthStore = defineStore('client-auth', () => {
sessionRestored.value = true
}
- async function login(username: string, password: string, remember = false) {
- const tokens = await loginMerchant(username, password)
+ async function login(loginId: string, password: string, remember = false) {
+ const tokens = await loginMerchant(loginId, password)
const decoded = decodeMerchantToken(tokens.accessToken)
persistTokenPair(tokens, remember)
token.value = tokens.accessToken
@@ -210,10 +210,9 @@ export const useAuthStore = defineStore('client-auth', () => {
if (user.value) user.value = { ...user.value, name: next.realName }
}
- async function savePersonalProfile(next: Pick) {
+ async function savePersonalProfile(next: Pick) {
const updated = await updateMerchantPersonalProfile({
displayName: next.realName.trim(),
- phone: next.phone.trim(),
email: next.email.trim(),
})
updateProfile({
@@ -237,5 +236,5 @@ export const useAuthStore = defineStore('client-auth', () => {
if (event.key === 'cc-client-access-token' || event.key === 'cc-client-refresh-token') applyStoredSession()
})
- return { token, user, profile, permissionCodes, isAuthenticated, sessionRestored, hasPermission, login, logout, restoreSession, acceptImpersonationCode, updateProfile, savePersonalProfile }
+ return { token, user, profile, permissionCodes, isAuthenticated, sessionRestored, hasPermission, login, logout, clearSession, restoreSession, acceptImpersonationCode, updateProfile, savePersonalProfile }
})
diff --git a/src/modules/auth/views/AuthViews.test.mjs b/src/modules/auth/views/AuthViews.test.mjs
index f635cd4..6adc8fb 100644
--- a/src/modules/auth/views/AuthViews.test.mjs
+++ b/src/modules/auth/views/AuthViews.test.mjs
@@ -30,9 +30,12 @@ test('groups the full registration contract without adding out-of-scope sign-in
assert.doesNotMatch(loginSource + registerSource, /微信登录|企业微信登录|短信验证码|邮箱验证码|lang-switcher/)
})
-test('matches the backend username login contract', () => {
- assert.match(loginSource, /label="用户名"/)
- assert.match(loginSource, /\^\[A-Za-z0-9_\]/)
+test('matches the frozen username-or-mobile login contract', () => {
+ assert.match(loginSource, /label="用户名或手机号"/)
+ assert.match(loginSource, /const usernamePattern = \/\^\[A-Za-z\]/)
+ assert.match(loginSource, /const mainlandMobilePattern = \/\^1\[3-9\]\\d\{9\}\$\//)
+ assert.match(authApiSource, /data: \{ loginId, password \}/)
+ assert.doesNotMatch(authApiSource, /data: \{ username, password \}/)
assert.match(loginSource, /typeof route\.query\.account === 'string'/)
assert.doesNotMatch(loginSource, /developmentHint|demo-tip|merchant_admin|联调账号|本地后端/)
assert.match(loginSource, /form\.remember/)
@@ -47,9 +50,14 @@ test('persists both rotating tokens according to the remember choice', () => {
})
test('implements every backend registration field and real submission', () => {
- for (const field of ['username', 'companyName', 'displayName', 'legalPerson', 'legalPhone', 'contactEmail', 'countryCode', 'address', 'password', 'agreement']) {
+ for (const field of ['username', 'companyName', 'displayName', 'legalPerson', 'phone', 'contactEmail', 'countryCode', 'address', 'password', 'agreement']) {
assert.match(registerSource, new RegExp(`prop="${field}"`))
}
+ assert.match(registerSource, /label="登录手机号" prop="phone"/)
+ assert.doesNotMatch(registerSource, /legalPhone/)
+ assert.match(authApiSource, /phone: string/)
+ assert.doesNotMatch(authApiSource, /legalPhone/)
+ assert.match(registerSource, /注册时同时初始化公司联系电话;注册后两者可分别修改,互不联动/)
assert.match(registerSource, /registerMerchant\(/)
assert.match(registerSource, /4–64 位/)
assert.match(registerSource, /8–72 位/)
@@ -59,9 +67,9 @@ test('shows immediate login after registration and separates company review from
assert.match(registerSource, /企业账号注册成功/)
assert.match(registerSource, /无需等待注册审核/)
assert.match(registerSource, /提交订单前还需完成企业审核/)
- assert.match(registerSource, /营业执照和法人身份证/)
- assert.match(registerSource, /货代资质证书、银行开户许可证可选填/)
- assert.match(registerSource, /其他资质可选填/)
+ assert.match(registerSource, /首次登录需上传营业执照/)
+ assert.match(registerSource, /法人身份证、货代资质证书和银行开户许可证可选填/)
+ assert.match(registerSource, /法人身份证及其他资质可选填/)
assert.match(registerSource, /立即登录/)
})
@@ -113,6 +121,11 @@ test('shows the backend credential error on login', () => {
assert.doesNotMatch(loginSource, /登录状态已失效,请重新登录/)
})
+test('shows a forced re-login notice after login identifiers change', () => {
+ assert.match(loginSource, /route\.query\.reason === 'identifiers-updated'/)
+ assert.match(loginSource, /所有会话均已退出,请重新登录/)
+})
+
test('keeps a valid local session during temporary context and refresh outages', () => {
assert.match(authStoreSource, /function isExpiredSessionError/)
assert.match(authStoreSource, /if \(isExpiredSessionError\(error\)\)/)
diff --git a/src/modules/auth/views/LoginView.vue b/src/modules/auth/views/LoginView.vue
index ba3cda6..f10f820 100644
--- a/src/modules/auth/views/LoginView.vue
+++ b/src/modules/auth/views/LoginView.vue
@@ -18,6 +18,7 @@ const auth = useAuthStore()
const formRef = ref()
const loading = ref(false)
const impersonationFailed = route.query.impersonation === 'failed'
+const loginIdentifiersUpdated = route.query.reason === 'identifiers-updated'
const form = reactive({
account: typeof route.query.account === 'string'
? route.query.account
@@ -25,12 +26,23 @@ const form = reactive({
password: '',
remember: false,
})
-const accountPattern = /^[A-Za-z0-9_]{4,64}$/
+const usernamePattern = /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/
+const mainlandMobilePattern = /^1[3-9]\d{9}$/
+
+function validLoginId(value: string) {
+ return usernamePattern.test(value) || mainlandMobilePattern.test(value)
+}
const rules: FormRules = {
account: [
- { required: true, message: '请输入用户名', trigger: 'blur' },
- { pattern: accountPattern, message: '用户名为 4–64 位字母、数字或下划线', trigger: 'blur' },
+ { required: true, message: '请输入用户名或手机号', trigger: 'blur' },
+ {
+ validator: (_rule, value: string, callback) => {
+ if (!value || validLoginId(value.trim())) callback()
+ else callback(new Error('请输入有效用户名或中国大陆手机号'))
+ },
+ trigger: 'blur',
+ },
],
password: [
{ required: true, message: '请输入密码', trigger: 'blur' },
@@ -76,7 +88,7 @@ function showResetHint() {
+
企业注册成功后即可登录;提交订单前需完成企业资料审核。
-
-
+
+
diff --git a/src/modules/auth/views/RegisterView.vue b/src/modules/auth/views/RegisterView.vue
index 723ccc5..4068b5e 100644
--- a/src/modules/auth/views/RegisterView.vue
+++ b/src/modules/auth/views/RegisterView.vue
@@ -11,7 +11,7 @@ interface RegisterForm {
companyName: string
displayName: string
legalPerson: string
- legalPhone: string
+ phone: string
contactEmail: string
countryCode: string
address: string
@@ -24,12 +24,12 @@ const formRef = ref()
const submitting = ref(false)
const submitted = ref(false)
const submittedAt = ref('')
-const form = reactive({ username: '', companyName: '', displayName: '', legalPerson: '', legalPhone: '', contactEmail: '', countryCode: 'CN', address: '', password: '', agreement: false })
+const form = reactive({ username: '', companyName: '', displayName: '', legalPerson: '', phone: '', contactEmail: '', countryCode: 'CN', address: '', password: '', agreement: false })
const rules: FormRules = {
username: [
{ required: true, message: '请输入登录用户名', trigger: 'blur' },
- { pattern: /^[A-Za-z0-9_]{4,64}$/, message: '用户名为 4–64 位字母、数字或下划线', trigger: 'blur' },
+ { pattern: /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/, message: '用户名须以字母开头,并由 4–64 位字母、数字或 _.@- 组成', trigger: 'blur' },
],
companyName: [
{ required: true, message: '请输入企业名称', trigger: 'blur' },
@@ -46,9 +46,9 @@ const rules: FormRules = {
contactEmail: [
{ type: 'email', message: '请输入有效的邮箱地址', trigger: 'blur' },
],
- legalPhone: [
+ phone: [
{ required: true, message: '请输入手机号', trigger: 'blur' },
- { pattern: /^1\d{10}$/, message: '请输入有效的 11 位手机号', trigger: 'blur' },
+ { pattern: /^1[3-9]\d{9}$/, message: '请输入有效的中国大陆手机号', trigger: 'blur' },
],
password: [
{ required: true, message: '请设置登录密码', trigger: 'blur' },
@@ -74,7 +74,7 @@ async function submit() {
displayName: form.displayName.trim(),
companyName: form.companyName.trim(),
legalPerson: form.legalPerson.trim(),
- legalPhone: form.legalPhone.trim(),
+ phone: form.phone.trim(),
contactEmail: form.contactEmail.trim() || undefined,
countryCode: form.countryCode,
address: form.address.trim() || undefined,
@@ -113,7 +113,7 @@ async function submit() {
@@ -131,7 +131,7 @@ async function submit() {
-
+ 注册时同时初始化公司联系电话;注册后两者可分别修改,互不联动。
@@ -143,7 +143,7 @@ async function submit() {
《用户服务协议》 和 《隐私政策》
- 注册成功后账号可立即登录;首次登录需上传营业执照和法人身份证,货代资质证书、银行开户许可证可选填。提交平台审核通过后才能提交订单。
+ 注册成功后账号可立即登录;首次登录需上传营业执照,法人身份证、货代资质证书和银行开户许可证可选填。提交平台审核通过后才能提交订单。
注册企业账号
@@ -153,7 +153,7 @@ async function submit() {
企业账号注册成功
{{ form.username }} 现在可以直接登录客户工作台,无需等待注册审核。
- 企业名称
- {{ form.companyName }}
- 登录账号
- {{ form.username }}
- 注册时间
- {{ submittedAt }}
- 账号状态
- 可登录
- 提交订单前还需完成企业审核登录后请在“企业资料”上传营业执照和法人身份证并提交审核,其他资质可选填。审核期间仍可维护资料、成员和订单草稿。
+ 提交订单前还需完成企业审核登录后请在“企业资料”上传营业执照并提交审核;法人身份证及其他资质可选填。审核期间仍可维护资料、成员和订单草稿。
立即登录
diff --git a/src/modules/first-leg/api/firstLeg.ts b/src/modules/first-leg/api/firstLeg.ts
index b2e00c0..35639b4 100644
--- a/src/modules/first-leg/api/firstLeg.ts
+++ b/src/modules/first-leg/api/firstLeg.ts
@@ -8,6 +8,8 @@ export type ApiContainerMode = 'FULL_CONTAINER' | 'LCL'
export type ApiDestinationType = 'FBA' | 'OVERSEAS' | 'PRIVATE'
export type ApiPricingTaxMode = 'TAX_INCLUDED' | 'TAX_EXCLUDED'
export type ApiPriceModel = 'FBA_WAREHOUSE_RATE' | 'REGION_WEIGHT_TIER'
+export type CustomerOrderAddressType = 'PICKUP' | 'PRIVATE_DESTINATION'
+export type MerchantBatchImportMode = 'ATOMIC' | 'PARTIAL'
export interface PageResult {
records: T[]
@@ -91,6 +93,7 @@ export interface FirstLegOrderCargoPayload {
declaredValue?: number
currency?: string
dangerousGoods?: boolean
+ woodenProduct: boolean
cargoRemark?: string
skuNo?: string
upcNo?: string
@@ -130,8 +133,8 @@ export interface FirstLegOrderSaveRequest {
destinationWarehouseName: string
expectedShipDate?: string
customerRemark?: string
- sender?: FirstLegOrderPartyPayload
- receiver?: FirstLegOrderPartyPayload
+ senderAddressId?: number | string
+ privateDestinationAddressId?: number | string
cargoes: FirstLegOrderCargoPayload[]
pickupMethod: ApiPickupMethod
receivingWarehouseId: number | string
@@ -143,13 +146,33 @@ export interface FirstLegOrderSaveRequest {
pricingTaxMode: ApiPricingTaxMode
taxMode: ApiTaxMode
containerMode?: ApiContainerMode
- customsInspection: boolean
+ customsInspection: boolean | null
destinationType: ApiDestinationType
amazonWarehouseCode?: string
shipmentId?: string
referenceId?: string
}
+export interface CustomerOrderAddressSaveRequest {
+ addressType: CustomerOrderAddressType
+ addressName: string
+ companyName: string
+ contactName: string
+ phone: string
+ email?: string
+ countryCode: string
+ province?: string
+ city: string
+ district?: string
+ addressLine: string
+ postalCode?: string
+ defaultAddress: boolean
+}
+
+export interface CustomerOrderAddress extends CustomerOrderAddressSaveRequest {
+ id: number | string
+}
+
export interface InboundCodePolicy {
mode: 'AUTO' | 'LEGACY_MANUAL'
manualEntryRequired: boolean
@@ -178,6 +201,14 @@ export interface MerchantBatchDraftResponse {
items: MerchantBatchDraftResult[]
}
+export interface MerchantBatchImportResponse {
+ batchNo: string
+ mode: MerchantBatchImportMode
+ sourceFileId: number | string
+ errorReportFileId: number | string | null
+ result: MerchantBatchDraftResponse
+}
+
export interface MerchantFirstLegOrder {
id?: number | string
orderId?: number | string
@@ -205,7 +236,7 @@ export interface MerchantFirstLegOrder {
carrierName?: string | null
taxMode: ApiTaxMode
containerMode: ApiContainerMode | null
- customsInspection: boolean
+ customsInspection: boolean | null
destinationType: ApiDestinationType
amazonWarehouseCode: string | null
shipmentId: string | null
@@ -305,6 +336,13 @@ export function downloadMerchantProductImage(fileId: number | string) {
return apiBlobRequest({ method: 'GET', url: `/merchant/v1/files/product-images/${fileId}` })
}
+export function downloadMerchantFirstLegBatchImportErrorReport(batchNo: string, fileId: number | string) {
+ return apiBlobRequest({
+ method: 'GET',
+ url: `/merchant/v1/files/order-batch-imports/${encodeURIComponent(batchNo)}/error-reports/${encodeURIComponent(String(fileId))}`,
+ })
+}
+
export async function downloadMerchantInboundCartonLabels(inboundId: number | string) {
return downloadMerchantCartonLabelPdf(
`/merchant/v1/first-leg/inbounds/${encodeURIComponent(String(inboundId))}/carton-label-pdf`,
@@ -455,6 +493,38 @@ export function getMerchantInboundCodePolicy() {
return apiRequest({ method: 'GET', url: '/merchant/v1/orders/first-leg/inbound-code-policy' })
}
+export function getMerchantOrderAddresses(addressType?: CustomerOrderAddressType) {
+ return apiRequest({ method: 'GET', url: '/merchant/v1/orders/first-leg/addresses', params: { addressType } })
+}
+
+export function createMerchantOrderAddress(payload: CustomerOrderAddressSaveRequest) {
+ return apiRequest({ method: 'POST', url: '/merchant/v1/orders/first-leg/addresses', data: payload })
+}
+
+export function updateMerchantOrderAddress(id: number | string, payload: CustomerOrderAddressSaveRequest) {
+ return apiRequest({ method: 'PUT', url: `/merchant/v1/orders/first-leg/addresses/${id}`, data: payload })
+}
+
+export function disableMerchantOrderAddress(id: number | string) {
+ return apiRequest({ method: 'DELETE', url: `/merchant/v1/orders/first-leg/addresses/${id}` })
+}
+
+export function downloadMerchantFirstLegBatchImportTemplate() {
+ return apiBlobRequest({ method: 'GET', url: '/merchant/v1/orders/first-leg/batch-imports/template' })
+}
+
+export function importMerchantFirstLegOrders(file: File, mode: MerchantBatchImportMode, idempotencyKey: string) {
+ const data = new FormData()
+ data.append('file', file)
+ return apiRequest({
+ method: 'POST',
+ url: '/merchant/v1/orders/first-leg/batch-imports',
+ params: { mode },
+ headers: { 'Idempotency-Key': idempotencyKey },
+ data,
+ })
+}
+
export function createMerchantFirstLegOrder(payload: FirstLegOrderSaveRequest) {
return apiRequest({
method: 'POST',
diff --git a/src/modules/first-leg/components/InboundBillingPanel.vue b/src/modules/first-leg/components/InboundBillingPanel.vue
index ec715c1..29f3fa3 100644
--- a/src/modules/first-leg/components/InboundBillingPanel.vue
+++ b/src/modules/first-leg/components/InboundBillingPanel.vue
@@ -117,7 +117,7 @@ defineEmits<{
实际重量{{ formatInboundMeasure(props.data.latestFee.actualWeightKg, 'kg') }}
实际体积{{ formatInboundMeasure(props.data.latestFee.actualVolumeCbm, 'm³') }}
- 折算量{{ formatInboundMeasure(props.data.latestFee.convertedQuantity, props.data.latestFee.chargeUnit) }}
+ 体积重量{{ formatInboundMeasure(props.data.latestFee.convertedQuantity, props.data.latestFee.chargeUnit) }}
最终计费量{{ formatInboundMeasure(props.data.latestFee.chargeableQuantity, props.data.latestFee.chargeUnit) }}
单价{{ formatInboundMoney(props.data.latestFee.unitPrice, props.data.latestFee.currency) }} / {{ props.data.latestFee.chargeUnit }}
基础费用{{ formatInboundMoney(props.data.latestFee.subtotalAmount, props.data.latestFee.currency) }}
diff --git a/src/modules/first-leg/routes.ts b/src/modules/first-leg/routes.ts
index 96ff88f..f3d3ec5 100644
--- a/src/modules/first-leg/routes.ts
+++ b/src/modules/first-leg/routes.ts
@@ -1,4 +1,4 @@
-import { FileText, Package, Ship, Zap } from '@lucide/vue'
+import { FileText, Ship, Zap } from '@lucide/vue'
import type { ClientModuleManifest } from '../types.ts'
export const firstLegModule = {
@@ -30,14 +30,6 @@ export const firstLegModule = {
release: 'active',
meta: { title: '编辑订单草稿', description: '继续完善快速下单生成的草稿明细', permission: 'order:first-leg:edit' },
},
- {
- path: 'first-leg/import',
- name: 'first-leg-import',
- component: () => import('./views/BatchImportView.vue'),
- release: 'prototype',
- meta: { title: '一键导入', description: '批量导入头程订单', permission: 'merchant:first-leg:import' },
- navigation: { key: 'first-leg-import', icon: Package },
- },
{
path: 'first-leg/quick-order',
name: 'first-leg-quick-order',
diff --git a/src/modules/first-leg/store/firstLegOrders.test.mjs b/src/modules/first-leg/store/firstLegOrders.test.mjs
index db004c8..095cb15 100644
--- a/src/modules/first-leg/store/firstLegOrders.test.mjs
+++ b/src/modules/first-leg/store/firstLegOrders.test.mjs
@@ -29,6 +29,14 @@ test('declares the documented merchant warehouse and first-leg endpoints', () =>
assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg'/)
assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg\/inbound-code-policy'/)
assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg\/batch'/)
+ assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg\/addresses'/)
+ assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/addresses\/\$\{id\}`/)
+ assert.doesNotMatch(apiSource, /merchant\/v1\/order-addresses/)
+ assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg\/batch-imports\/template'/)
+ assert.match(apiSource, /url: '\/merchant\/v1\/orders\/first-leg\/batch-imports'/)
+ assert.match(apiSource, /`\/merchant\/v1\/files\/order-batch-imports\/\$\{encodeURIComponent\(batchNo\)\}\/error-reports\/\$\{encodeURIComponent\(String\(fileId\)\)\}`/)
+ assert.match(apiSource, /params: \{ mode \}/)
+ assert.match(apiSource, /data\.append\('file', file\)/)
assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}`/)
assert.match(apiSource, /`\/merchant\/v1\/orders\/first-leg\/\$\{id\}\/submit`/)
assert.match(apiSource, /`\/merchant\/v1\/warehouses\/\$\{id\}`/)
@@ -134,13 +142,14 @@ test('sends backend-required channel, pricing, customs, destination and cargo fi
assert.doesNotMatch(apiSource, /interface FirstLegOrderSaveRequest \{[^}]*customerId\??:/s)
assert.match(storeSource, /customerName: customerName\.trim\(\)/)
assert.match(storeSource, /destinationWarehouseId: draft\.destinationType === 'PRIVATE' \? undefined : draft\.destinationWarehouseId/)
- assert.match(storeSource, /draft\.pickupMethod === 'PICKUP'.*draft\.shipperPartySnapshot/s)
- assert.match(storeSource, /draft\.destinationType === 'PRIVATE'.*draft\.consigneePartySnapshot/s)
+ assert.match(storeSource, /senderAddressId: draft\.pickupMethod === 'PICKUP' \? draft\.senderAddressId : undefined/)
+ assert.match(storeSource, /privateDestinationAddressId: draft\.destinationType === 'PRIVATE' \? draft\.privateDestinationAddressId : undefined/)
+ assert.doesNotMatch(storeSource, /sender: partyPayload|receiver: partyPayload/)
for (const field of [
'customsHsCode', 'lengthCm', 'widthCm', 'heightCm', 'unitsPerPackage', 'totalQuantity',
'netWeightKg', 'unitDeclaredValue', 'material', 'usage', 'model', 'brand',
'productLengthCm', 'productWidthCm', 'productHeightCm', 'shipmentId', 'referenceId',
- 'productImageFileId',
+ 'productImageFileId', 'woodenProduct',
]) {
assert.match(storeSource, new RegExp(`${field}: item\\.`))
}
diff --git a/src/modules/first-leg/store/firstLegOrders.ts b/src/modules/first-leg/store/firstLegOrders.ts
index 12359bc..5cd450d 100644
--- a/src/modules/first-leg/store/firstLegOrders.ts
+++ b/src/modules/first-leg/store/firstLegOrders.ts
@@ -11,7 +11,6 @@ import {
submitMerchantFirstLegOrder,
updateMerchantFirstLegOrder,
type ApiFirstLegStatus,
- type FirstLegOrderPartyPayload,
type FirstLegOrderSaveRequest,
type MerchantFirstLegOrder,
type MerchantFirstLegOrderRow,
@@ -40,10 +39,6 @@ export type ClientWarehouse = PlatformReceivingWarehouse & {
countryCode: string
}
-const countryCodes: Readonly> = {
- 中国: 'CN', 美国: 'US', 德国: 'DE', 英国: 'GB', 加拿大: 'CA', 日本: 'JP', 澳大利亚: 'AU',
-}
-
function clientStatus(status: ApiFirstLegStatus): FirstLegOrder['status'] {
if (status === 'DRAFT') return 'DRAFT'
if (status === 'SUBMITTED') return 'PENDING_REVIEW'
@@ -73,35 +68,7 @@ function apiTransport(draft: FirstLegOrderDraft): FirstLegOrderSaveRequest['tran
return 'SEA'
}
-function partyPayload(snapshot: FirstLegPartySnapshot): FirstLegOrderPartyPayload {
- return {
- companyName: snapshot.name,
- contactName: snapshot.contactName,
- phone: snapshot.phone,
- email: snapshot.email || undefined,
- countryCode: countryCodes[snapshot.countryRegion] ?? snapshot.countryRegion.toUpperCase().slice(0, 2),
- province: snapshot.province || undefined,
- city: snapshot.city,
- addressLine: snapshot.address,
- postalCode: snapshot.postalCode || undefined,
- }
-}
-
function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, customerName: string): FirstLegOrderSaveRequest {
- const sender = draft.pickupMethod === 'PICKUP' && draft.shipperPartySnapshot
- ? partyPayload({
- ...draft.shipperPartySnapshot,
- name: draft.shipperPartySnapshot.name || customerName.trim() || '客户',
- countryRegion: draft.shipperPartySnapshot.countryRegion || 'CN',
- })
- : undefined
- const receiver = draft.destinationType === 'PRIVATE' && draft.consigneePartySnapshot
- ? partyPayload({
- ...draft.consigneePartySnapshot,
- name: draft.destinationWarehouseName,
- countryRegion: draft.destinationCountryCode.trim().toUpperCase(),
- })
- : undefined
return {
inboundCode: draft.inboundCode?.trim() || undefined,
customerName: customerName.trim(),
@@ -113,8 +80,8 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust
destinationWarehouseName: draft.destinationWarehouseName,
expectedShipDate: draft.expectedDepartureDate || undefined,
customerRemark: draft.customerRemark || undefined,
- sender,
- receiver,
+ senderAddressId: draft.pickupMethod === 'PICKUP' ? draft.senderAddressId : undefined,
+ privateDestinationAddressId: draft.destinationType === 'PRIVATE' ? draft.privateDestinationAddressId : undefined,
cargoes: draft.items.map((item) => ({
cargoName: item.cargoNameCn,
cargoNameEn: item.cargoNameEn || undefined,
@@ -130,6 +97,7 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: ClientWarehouse, cust
declaredValue: item.declaredValue || undefined,
currency: item.currency || undefined,
dangerousGoods: item.dangerousGoods,
+ woodenProduct: item.woodenProduct,
cargoRemark: item.remark || undefined,
skuNo: item.sku || undefined,
upcNo: item.upc || undefined,
@@ -330,6 +298,7 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar
volumeCbm: item.volumeCbm, remark: item.cargoRemark ?? '',
packageType: item.packageType, hsCode: item.hsCode ?? '', declaredValue: item.declaredValue ?? 0,
currency: item.currency ?? 'USD', dangerousGoods: item.dangerousGoods ?? false,
+ woodenProduct: item.woodenProduct ?? false,
netWeightKg: item.netWeightKg ?? 0, unitDeclaredValue: item.unitDeclaredValue ?? 0,
material: item.material ?? '', usage: item.usage ?? item.productUsage ?? '', model: item.model ?? '', brand: item.brand ?? '',
productLengthCm: item.productLengthCm ?? 0, productWidthCm: item.productWidthCm ?? 0,
diff --git a/src/modules/first-leg/types/firstLegOrder.ts b/src/modules/first-leg/types/firstLegOrder.ts
index cdf3196..c3685f1 100644
--- a/src/modules/first-leg/types/firstLegOrder.ts
+++ b/src/modules/first-leg/types/firstLegOrder.ts
@@ -45,6 +45,7 @@ export interface FirstLegCargoItem {
declaredValue: number
currency: string
dangerousGoods: boolean
+ woodenProduct: boolean
}
export interface FirstLegOrderTimelineItem {
@@ -143,7 +144,7 @@ export interface FirstLegOrder {
carrierId: string
carrierName: string
taxMode: 'BUY_DECLARATION' | 'TAX_REFUND'
- customsInspection: boolean
+ customsInspection: boolean | null
destinationType: 'FBA' | 'OVERSEAS' | 'PRIVATE'
amazonWarehouseCode: string
shipmentId: string
@@ -187,4 +188,6 @@ export type FirstLegOrderDraft = Omit, 'transportMode'> & {
transportMode: FirstLegTransportMode
inboundCode?: string
+ senderAddressId: string
+ privateDestinationAddressId: string
}
diff --git a/src/modules/first-leg/utils/firstLegOrder.test.mjs b/src/modules/first-leg/utils/firstLegOrder.test.mjs
index 8f37853..6ce01cf 100644
--- a/src/modules/first-leg/utils/firstLegOrder.test.mjs
+++ b/src/modules/first-leg/utils/firstLegOrder.test.mjs
@@ -140,6 +140,17 @@ test('requires public destination ids while keeping private addresses detached f
assert.ok(validateFirstLegOrderDraft(privateDraft).includes('私人地址不能引用平台公共仓库'))
})
+test('requires customs inspection when any cargo is a wooden product', () => {
+ const draft = validDraft()
+ draft.items[0].woodenProduct = true
+ draft.customsInspection = null
+ assert.ok(validateFirstLegOrderDraft(draft).includes('第 1 条货物为木制品,“是否商检”必须选择“是”'))
+ draft.customsInspection = false
+ assert.ok(validateFirstLegOrderDraft(draft).includes('第 1 条货物为木制品,“是否商检”必须选择“是”'))
+ draft.customsInspection = true
+ assert.ok(!validateFirstLegOrderDraft(draft).some((message) => message.includes('为木制品')))
+})
+
test('summarizes cargo values from line items', () => {
const first = validDraft().items[0]
const second = { ...first, id: 'cargo-2', pieces: 40, packageCount: 4, grossWeightKg: 300, volumeCbm: 1.25 }
diff --git a/src/modules/first-leg/utils/firstLegOrder.ts b/src/modules/first-leg/utils/firstLegOrder.ts
index 1109ba8..30709d4 100644
--- a/src/modules/first-leg/utils/firstLegOrder.ts
+++ b/src/modules/first-leg/utils/firstLegOrder.ts
@@ -84,6 +84,7 @@ export function createEmptyCargoItem(id = `cargo-${Date.now()}`): FirstLegCargoI
declaredValue: 0,
currency: 'USD',
dangerousGoods: false,
+ woodenProduct: false,
}
}
@@ -99,12 +100,14 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) {
if (!['TAX_INCLUDED', 'TAX_EXCLUDED'].includes(draft.pricingTaxMode)) errors.push('请选择报价包税方式')
if (!draft.receivingWarehouseId) errors.push('请选择平台收货仓')
if (draft.pickupMethod === 'PICKUP') {
+ if (!draft.senderAddressId) errors.push('请选择已保存的提货地址')
if (!draft.shipperPartySnapshot?.contactName.trim()) errors.push('请填写提货联系人')
if (!draft.shipperPartySnapshot?.phone.trim()) errors.push('请填写提货联系电话')
if (!draft.shipperPartySnapshot?.city.trim()) errors.push('请填写提货城市')
if (!draft.shipperPartySnapshot?.address.trim()) errors.push('请填写提货地址')
}
if (draft.destinationType === 'PRIVATE') {
+ if (!draft.privateDestinationAddressId) errors.push('请选择已保存的私人仓地址')
if (!draft.consigneePartySnapshot?.name.trim()) errors.push('请填写私人地址名称')
if (!draft.consigneePartySnapshot?.contactName.trim()) errors.push('请填写收件人姓名')
if (!draft.consigneePartySnapshot?.phone.trim()) errors.push('请填写收件人电话')
@@ -129,6 +132,7 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) {
if (!Number.isInteger(item.unitsPerPackage) || item.unitsPerPackage <= 0) errors.push(`${line}每箱数量必须为正整数`)
if (item.grossWeightKg <= 0) errors.push(`${line}毛重必须大于 0`)
if (item.volumeCbm <= 0) errors.push(`${line}体积必须大于 0`)
+ if (item.woodenProduct && draft.customsInspection !== true) errors.push(`${line}为木制品,“是否商检”必须选择“是”`)
})
return errors
diff --git a/src/modules/first-leg/views/BatchImportView.vue b/src/modules/first-leg/views/BatchImportView.vue
deleted file mode 100644
index f24454a..0000000
--- a/src/modules/first-leg/views/BatchImportView.vue
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
diff --git a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs
index 9040d98..f9aeea7 100644
--- a/src/modules/first-leg/views/FirstLegOrdersView.test.mjs
+++ b/src/modules/first-leg/views/FirstLegOrdersView.test.mjs
@@ -279,15 +279,20 @@ test('requires an enabled platform receiving warehouse in the order form', () =>
assert.match(source, /loadSelectedWarehouse\(detailedOrder\.receivingWarehouseId\)/)
})
-test('selects platform destinations by type and caches private addresses per user', () => {
+test('selects platform destinations and uses customer-scoped server address books', () => {
assert.match(source, /warehouse\.warehouseType === form\.destinationType/)
assert.match(source, /await store\.loadWarehouseType\(type\)/)
assert.match(source, /await loadDestinationWarehouses\(form\.destinationType\)/)
assert.match(source, /:loading="destinationWarehousesLoading"/)
assert.match(source, /v-model="form\.destinationWarehouseId"/)
assert.match(source, /form\.amazonWarehouseCode = form\.destinationType === 'FBA' \? warehouse\.code : ''/)
- assert.match(source, /cc:first-leg:private-addresses:\$\{auth\.user\?\.id/)
- assert.match(source, /localStorage\.setItem\(privateAddressStorageKey\(\)/)
+ assert.match(source, /getMerchantOrderAddresses\(\)/)
+ assert.match(source, /createMerchantOrderAddress\(payload\)/)
+ assert.match(source, /updateMerchantOrderAddress\(form\.(?:senderAddressId|privateDestinationAddressId), payload\)/)
+ assert.match(source, /disableMerchantOrderAddress\(id\)/)
+ assert.match(source, /v-model="form\.senderAddressId"/)
+ assert.match(source, /v-model="form\.privateDestinationAddressId"/)
+ assert.doesNotMatch(source, /localStorage|cc:first-leg:private-addresses/)
assert.match(source, /私人地址不能引用平台公共仓库|form\.destinationWarehouseId = ''/)
})
@@ -318,6 +323,9 @@ test('collects the backend-required first-leg fields', () => {
assert.match(source, new RegExp(`v-model="(?:form|item)\\.${model}"`))
}
assert.match(source, /form\.channelName = channel\?\.channelName/)
+ assert.match(source, /v-model="item\.woodenProduct"/)
+ assert.match(source, /第 \{\{ woodenCargoLines\.join\('、'\) \}\} 条货物已勾选木制品/)
+ assert.match(source, /第 \{\{ index \+ 1 \}\} 条须商检/)
})
test('distills order creation into the confirmed prototype sections with server pricing', () => {
@@ -330,10 +338,22 @@ test('distills order creation into the confirmed prototype sections with server
assert.match(source, /class="segmented-options"/)
assert.match(source, /class="prototype-form-row prototype-form-row--tabs"/)
assert.match(source, //)
- assert.match(source, /上传 Excel[\s\S]*下载模板[\s\S]*添加行[\s\S]*删除所选行/)
+ assert.match(source, /auth\.hasPermission\('order:first-leg:create'\)[\s\S]*批量导入订单/)
+ assert.doesNotMatch(source, /create-batch-import-entry|需要一次创建多条订单|页面上方“Excel 批量导入”/)
+ assert.match(source, /导入成功的数据只创建草稿,不会直接提交审核/)
+ assert.match(source, /下载正式模板[\s\S]*选择 Excel/)
+ assert.match(source, /仅支持 \.xlsx,文件不超过 5MB、最多 50 行/)
+ assert.match(source, /cargo\.woodenProduct(true\/false)[\s\S]*customsInspection 必须填写 true/)
+ assert.match(source, /下载 Excel 错误报告/)
+ assert.match(source, /batchImportError[\s\S]*requestId[\s\S]*traceId/)
+ assert.match(source, /添加行[\s\S]*删除所选行/)
+ assert.doesNotMatch(source, /货物明细导入模板\.xls/)
for (const heading of ['SKU NO.', '中文品名', '箱规长 cm', '总毛重', '报关 HS CODE', '货件编号', '跟踪编号', 'UPC NO.']) {
assert.ok(cargoTableSource.includes(heading))
}
+ assert.ok(cargoTableSource.indexOf('包装类型') < cargoTableSource.indexOf('危险品'))
+ assert.ok(cargoTableSource.indexOf('危险品') < cargoTableSource.indexOf('货件编号'))
+ assert.ok(cargoTableSource.indexOf('木制品') < cargoTableSource.indexOf('货件编号'))
assert.match(source, /参考运费/)
assert.match(source, /仅供参考,最终以实际结算为准/)
assert.doesNotMatch(source, /v-model="form\.(?:businessType|origin|destination|priority|tradeTerm)"/)
diff --git a/src/modules/first-leg/views/FirstLegOrdersView.vue b/src/modules/first-leg/views/FirstLegOrdersView.vue
index 23efff3..edcb152 100644
--- a/src/modules/first-leg/views/FirstLegOrdersView.vue
+++ b/src/modules/first-leg/views/FirstLegOrdersView.vue
@@ -16,11 +16,23 @@ import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBillin
import { getMerchantCustomsForOrder, isMerchantCustomsUnavailable, type MerchantCustomsContainer } from '@/modules/first-leg/api/customs'
import {
estimateMerchantFirstLegPrice,
+ createIdempotencyKey,
+ createMerchantOrderAddress,
+ disableMerchantOrderAddress,
+ downloadMerchantFirstLegBatchImportErrorReport,
+ downloadMerchantFirstLegBatchImportTemplate,
downloadMerchantOrderCartonLabelsBatch,
downloadMerchantProductImage,
getMerchantInboundCodePolicy,
+ getMerchantOrderAddresses,
getMerchantPricingChannels,
+ importMerchantFirstLegOrders,
+ updateMerchantOrderAddress,
uploadMerchantProductImage,
+ type CustomerOrderAddress,
+ type CustomerOrderAddressSaveRequest,
+ type MerchantBatchImportMode,
+ type MerchantBatchImportResponse,
type MerchantPriceEstimate,
type MerchantPriceEstimateRequest,
type MerchantPricingChannel,
@@ -83,18 +95,16 @@ const priceEstimate = ref(null)
const priceEstimateLoading = ref(false)
const priceEstimateError = ref('')
const selectedCargoIds = ref([])
-const cargoImportInput = ref()
-
-const cargoTemplateColumns = [
- ['SKU NO.', 'sku'], ['中文品名', 'cargoNameCn'], ['英文品名', 'cargoNameEn'], ['报关 HS CODE', 'hsCode'], ['清关 HS CODE', 'customsHsCode'],
- ['包装类型', 'packageType'], ['箱规长 cm', 'lengthCm'], ['箱规宽 cm', 'widthCm'], ['箱规高 cm', 'heightCm'], ['每箱数量', 'unitsPerPackage'],
- ['箱数', 'packageCount'], ['总毛重', 'grossWeightKg'], ['总体积', 'volumeCbm'], ['总数量', 'pieces'], ['总净重', 'netWeightKg'],
- ['清关申报单价 USD', 'unitDeclaredValue'], ['清关申报金额 USD', 'declaredValue'], ['材质', 'material'], ['用途', 'usage'], ['型号', 'model'],
- ['品牌', 'brand'], ['产品长 cm', 'productLengthCm'], ['产品宽 cm', 'productWidthCm'], ['产品高 cm', 'productHeightCm'], ['危险品', 'dangerousGoods'],
- ['货件编号', 'shipmentId'], ['跟踪编号', 'referenceId'], ['UPC NO.', 'upc'], ['备注', 'remark'],
-] as const
-
-const cargoNumberKeys = new Set(['lengthCm', 'widthCm', 'heightCm', 'unitsPerPackage', 'packageCount', 'grossWeightKg', 'volumeCbm', 'pieces', 'netWeightKg', 'unitDeclaredValue', 'declaredValue', 'productLengthCm', 'productWidthCm', 'productHeightCm'])
+const batchImportInput = ref()
+const batchImportOpen = ref(false)
+const batchImportMode = ref('PARTIAL')
+const batchImportFile = ref(null)
+const batchImportIdempotencyKey = ref('')
+const batchImportSubmitting = ref(false)
+const batchTemplateDownloading = ref(false)
+const batchErrorReportDownloading = ref(false)
+const batchImportResult = ref(null)
+const batchImportError = ref('')
const fixedOrderColumns: DataTableColumn[] = [
{ key: 'selection', type: 'selection', width: 48, fixed: 'left', reserveSelection: true },
{ key: 'actions', label: '操作', width: 216, fixed: 'left', align: 'center', headerAlign: 'center', slotName: 'actions' },
@@ -207,35 +217,13 @@ let channelRequestSequence = 0
let estimateRequestSequence = 0
let estimateTimer: ReturnType | undefined
-interface CachedPrivateAddress {
- id: string
- label: string
- consigneePartyId: string
- consigneePartySnapshot: NonNullable
- consignee: string
- destination: string
- destinationCountryCode: string
- destinationWarehouseName: string
-}
+const orderAddresses = ref([])
+const orderAddressesLoading = ref(false)
+const pickupAddresses = computed(() => orderAddresses.value.filter((item) => item.addressType === 'PICKUP'))
+const privateAddresses = computed(() => orderAddresses.value.filter((item) => item.addressType === 'PRIVATE_DESTINATION'))
-const privateAddresses = ref([])
-const selectedPrivateAddressId = ref('')
-
-function privateAddressStorageKey() {
- return `cc:first-leg:private-addresses:${auth.user?.id ?? 'anonymous'}`
-}
-
-function loadPrivateAddresses() {
- try {
- const value = JSON.parse(localStorage.getItem(privateAddressStorageKey()) ?? '[]') as unknown
- privateAddresses.value = Array.isArray(value) ? value.filter((item): item is CachedPrivateAddress => Boolean(item && typeof item === 'object' && 'id' in item && 'consigneePartySnapshot' in item)) : []
- } catch {
- privateAddresses.value = []
- }
-}
-
-function persistPrivateAddresses() {
- localStorage.setItem(privateAddressStorageKey(), JSON.stringify(privateAddresses.value))
+function addressLabel(address: CustomerOrderAddress) {
+ return `${address.addressName} · ${address.contactName} · ${address.countryCode} ${address.city} ${address.addressLine}`
}
const orderFormRules: FormRules = {
@@ -353,9 +341,11 @@ function emptyDraft(): FirstLegOrderDraft {
receivingWarehouseId: '',
expectedDepartureDate: '',
shipperPartyId: '',
+ senderAddressId: '',
shipperPartySnapshot: null,
shipper: '',
consigneePartyId: '',
+ privateDestinationAddressId: '',
consigneePartySnapshot: null,
consignee: '',
priority: 'NORMAL',
@@ -369,7 +359,7 @@ function emptyDraft(): FirstLegOrderDraft {
channelName: '',
pricingTaxMode: 'TAX_INCLUDED',
taxMode: 'BUY_DECLARATION',
- customsInspection: false,
+ customsInspection: null,
destinationType: 'FBA',
amazonWarehouseCode: '',
shipmentId: '',
@@ -430,6 +420,9 @@ function quickOrderDraft() {
const form = reactive(quickOrderDraft())
const serializedDraft = computed(() => JSON.stringify(form))
const editorDirty = computed(() => (editorOpen.value || isCreatePage.value) && serializedDraft.value !== editorBaseline.value)
+const woodenCargoLines = computed(() => form.items
+ .map((item, index) => item.woodenProduct ? index + 1 : null)
+ .filter((index): index is number => index != null))
const unsavedRouteKey = props.createPage ? 'first-leg-order-create' : 'first-leg-orders'
useUnsavedWorkRegistration({
@@ -567,11 +560,13 @@ async function loadPageData() {
const orderPageRequest = isCreatePage.value
? store.loadWarehouseType('DOMESTIC', true)
: store.load({ includeDrafts: true })
- const [ordersResult, companyResult, destinationResult, policyResult] = await Promise.allSettled([
+ const addressRequest = isCreatePage.value ? loadOrderAddresses() : Promise.resolve()
+ const [ordersResult, companyResult, destinationResult, policyResult, addressResult] = await Promise.allSettled([
orderPageRequest,
companyRequest,
destinationRequest,
getMerchantInboundCodePolicy(),
+ addressRequest,
])
if (policyResult.status === 'fulfilled') inboundCodePolicy.value = policyResult.value
else {
@@ -587,6 +582,7 @@ async function loadPageData() {
if (store.warehouseLoadError) supportingDataErrors.value.push(`公共仓库加载失败:${store.warehouseLoadError}`)
if (!isCreatePage.value && store.draftLoadError) supportingDataErrors.value.push(`订单草稿加载失败:${store.draftLoadError}`)
if (destinationResult.status === 'rejected') supportingDataErrors.value.push(`目的仓加载失败:${errorMessage(destinationResult.reason)}`)
+ if (addressResult.status === 'rejected') supportingDataErrors.value.push(`企业地址簿加载失败:${errorMessage(addressResult.reason)}`)
await autofillWarehouseSelections()
if (isCreatePage.value && routeDraftId.value) await loadRouteDraft(routeDraftId.value)
supportingDataLoading.value = false
@@ -619,6 +615,16 @@ function errorMessage(error: unknown) {
return error instanceof Error ? error.message : '服务暂时不可用'
}
+function batchImportErrorMessage(error: unknown) {
+ if (!(error instanceof ApiError)) return errorMessage(error)
+ return [
+ error.message,
+ error.code ? `错误码 ${error.code}` : '',
+ error.requestId ? `requestId ${error.requestId}` : '',
+ error.traceId ? `traceId ${error.traceId}` : '',
+ ].filter(Boolean).join(' · ')
+}
+
function getOrderCargoSummary(order: FirstLegOrder) {
if (order.items.length) return `${order.items[0]?.cargoNameCn || '货物'}${order.items.length > 1 ? `等 ${order.items.length} 项` : ''}`
if (order.businessSummary.totalPackageCount != null) return `${order.businessSummary.totalPackageCount} 箱`
@@ -626,7 +632,6 @@ function getOrderCargoSummary(order: FirstLegOrder) {
}
onMounted(() => {
- loadPrivateAddresses()
void loadPageData()
})
@@ -746,9 +751,11 @@ function draftFromOrder(order: FirstLegOrder): FirstLegOrderDraft {
receivingWarehouseId: order.receivingWarehouseId,
expectedDepartureDate: order.expectedDepartureDate,
shipperPartyId: order.shipperPartyId,
+ senderAddressId: '',
shipperPartySnapshot: order.shipperPartySnapshot ? { ...order.shipperPartySnapshot } : null,
shipper: order.shipper,
consigneePartyId: order.consigneePartyId,
+ privateDestinationAddressId: '',
consigneePartySnapshot: order.consigneePartySnapshot ? { ...order.consigneePartySnapshot } : null,
consignee: order.consignee,
priority: order.priority,
@@ -940,7 +947,7 @@ async function changeDestinationType() {
form.destinationWarehouseName = ''
form.destination = ''
form.amazonWarehouseCode = ''
- selectedPrivateAddressId.value = ''
+ form.privateDestinationAddressId = ''
form.consigneePartyId = ''
form.consigneePartySnapshot = form.destinationType === 'PRIVATE' ? createEmptyPartySnapshot('CONSIGNEE') : null
form.consignee = ''
@@ -959,6 +966,7 @@ function changePickupMethod() {
if (form.pickupMethod === 'PICKUP') {
form.shipperPartySnapshot ??= createPickupPartySnapshot()
} else {
+ form.senderAddressId = ''
form.shipperPartyId = ''
form.shipperPartySnapshot = null
form.shipper = ''
@@ -975,54 +983,129 @@ function syncPrivateDestinationName(value: string) {
}
function selectPrivateAddress(id: string) {
- const address = privateAddresses.value.find((item) => item.id === id)
+ const address = privateAddresses.value.find((item) => String(item.id) === id)
if (!address) return
- form.consigneePartyId = address.consigneePartyId
- form.consigneePartySnapshot = { ...address.consigneePartySnapshot }
- form.consignee = address.consignee
- form.destination = address.destination
- form.destinationCountryCode = address.destinationCountryCode
+ form.privateDestinationAddressId = String(address.id)
+ form.consigneePartyId = ''
+ form.consigneePartySnapshot = addressSnapshot(address, 'CONSIGNEE')
+ form.consignee = address.companyName || address.addressName
+ form.destination = address.addressName
+ form.destinationCountryCode = address.countryCode
form.destinationWarehouseId = ''
- form.destinationWarehouseName = address.destinationWarehouseName
+ form.destinationWarehouseName = address.addressName
}
-function savePrivateAddress() {
- if (!form.consigneePartySnapshot || !form.destinationWarehouseName.trim() || !form.destinationCountryCode.trim()
- || !form.consigneePartySnapshot.contactName.trim() || !form.consigneePartySnapshot.phone.trim()
- || !form.consigneePartySnapshot.city.trim() || !form.consigneePartySnapshot.address.trim()) {
- ElMessage.warning('请先完整填写私人地址和收件资料')
+function addressSnapshot(address: CustomerOrderAddress, role: FirstLegPartyRole): FirstLegPartySnapshot {
+ return {
+ id: '',
+ code: '',
+ name: address.companyName || address.addressName,
+ role,
+ contactName: address.contactName,
+ phone: address.phone,
+ email: address.email ?? '',
+ countryRegion: address.countryCode,
+ province: address.province ?? '',
+ city: address.city,
+ postalCode: address.postalCode ?? '',
+ address: address.addressLine,
+ }
+}
+
+async function loadOrderAddresses() {
+ orderAddressesLoading.value = true
+ try {
+ orderAddresses.value = await getMerchantOrderAddresses()
+ } finally {
+ orderAddressesLoading.value = false
+ }
+}
+
+function selectPickupAddress(id: string) {
+ const address = pickupAddresses.value.find((item) => String(item.id) === id)
+ if (!address) return
+ form.senderAddressId = String(address.id)
+ form.shipperPartyId = ''
+ form.shipperPartySnapshot = addressSnapshot(address, 'SHIPPER')
+ form.shipper = address.companyName || address.addressName
+ form.origin = address.city
+}
+
+function addressPayload(addressType: CustomerOrderAddress['addressType'], snapshot: FirstLegPartySnapshot, addressName: string): CustomerOrderAddressSaveRequest {
+ const payload: CustomerOrderAddressSaveRequest = {
+ addressType,
+ addressName: addressName.trim(),
+ companyName: snapshot.name.trim() || addressName.trim(),
+ contactName: snapshot.contactName.trim(),
+ phone: snapshot.phone.trim(),
+ countryCode: snapshot.countryRegion.trim().toUpperCase(),
+ city: snapshot.city.trim(),
+ addressLine: snapshot.address.trim(),
+ defaultAddress: false,
+ }
+ if (snapshot.email.trim()) payload.email = snapshot.email.trim()
+ if (snapshot.province.trim()) payload.province = snapshot.province.trim()
+ if (snapshot.postalCode.trim()) payload.postalCode = snapshot.postalCode.trim()
+ return payload
+}
+
+function addressComplete(snapshot: FirstLegPartySnapshot | null, addressName: string) {
+ return Boolean(snapshot && addressName.trim() && snapshot.contactName.trim() && snapshot.phone.trim()
+ && /^[A-Za-z]{2}$/.test(snapshot.countryRegion.trim()) && snapshot.city.trim() && snapshot.address.trim())
+}
+
+async function savePickupAddress() {
+ const snapshot = form.shipperPartySnapshot
+ const addressName = snapshot?.name.trim() || `${snapshot?.city.trim() || '国内'}提货地址`
+ if (!addressComplete(snapshot, addressName)) {
+ ElMessage.warning('请先完整填写提货地址和联系人资料')
return
}
- const snapshot = { ...form.consigneePartySnapshot }
- snapshot.name = form.destinationWarehouseName.trim()
- snapshot.countryRegion = form.destinationCountryCode.trim().toUpperCase()
- const existing = privateAddresses.value.find((item) => item.consigneePartySnapshot.address === snapshot.address)
- const partyId = existing?.consigneePartyId || snapshot.id || `private-party-${Date.now()}`
- snapshot.id = partyId
- const saved: CachedPrivateAddress = {
- id: existing?.id ?? globalThis.crypto?.randomUUID?.() ?? `private-${Date.now()}`,
- label: `${form.destinationWarehouseName.trim()} · ${snapshot.address}`,
- consigneePartyId: partyId,
- consigneePartySnapshot: snapshot,
- consignee: snapshot.name,
- destination: form.destination,
- destinationCountryCode: form.destinationCountryCode.trim().toUpperCase(),
- destinationWarehouseName: form.destinationWarehouseName.trim(),
+ try {
+ const payload = addressPayload('PICKUP', snapshot!, addressName)
+ if (form.senderAddressId) await updateMerchantOrderAddress(form.senderAddressId, payload)
+ else form.senderAddressId = String(await createMerchantOrderAddress(payload))
+ await loadOrderAddresses()
+ selectPickupAddress(form.senderAddressId)
+ ElMessage.success('企业提货地址已保存')
+ } catch (error) {
+ ElMessage.error(errorMessage(error))
}
- privateAddresses.value = existing
- ? privateAddresses.value.map((item) => item.id === existing.id ? saved : item)
- : [saved, ...privateAddresses.value]
- selectedPrivateAddressId.value = saved.id
- persistPrivateAddresses()
- ElMessage.success(existing ? '私人地址已更新' : '私人地址已保存到当前账号')
}
-function removePrivateAddress() {
- if (!selectedPrivateAddressId.value) return
- privateAddresses.value = privateAddresses.value.filter((item) => item.id !== selectedPrivateAddressId.value)
- selectedPrivateAddressId.value = ''
- persistPrivateAddresses()
- ElMessage.success('已从当前账号的私人地址中移除')
+async function savePrivateAddress() {
+ const snapshot = form.consigneePartySnapshot
+ const addressName = form.destinationWarehouseName.trim()
+ if (snapshot) snapshot.countryRegion = form.destinationCountryCode.trim().toUpperCase()
+ if (!addressComplete(snapshot, addressName)) {
+ ElMessage.warning('请先完整填写私人仓地址和收件资料')
+ return
+ }
+ snapshot!.name = addressName
+ try {
+ const payload = addressPayload('PRIVATE_DESTINATION', snapshot!, addressName)
+ if (form.privateDestinationAddressId) await updateMerchantOrderAddress(form.privateDestinationAddressId, payload)
+ else form.privateDestinationAddressId = String(await createMerchantOrderAddress(payload))
+ await loadOrderAddresses()
+ selectPrivateAddress(form.privateDestinationAddressId)
+ ElMessage.success('企业私人仓地址已保存')
+ } catch (error) {
+ ElMessage.error(errorMessage(error))
+ }
+}
+
+async function removeOrderAddress(type: CustomerOrderAddress['addressType']) {
+ const id = type === 'PICKUP' ? form.senderAddressId : form.privateDestinationAddressId
+ if (!id) return
+ try {
+ await disableMerchantOrderAddress(id)
+ if (type === 'PICKUP') form.senderAddressId = ''
+ else form.privateDestinationAddressId = ''
+ await loadOrderAddresses()
+ ElMessage.success('地址已从企业地址簿停用')
+ } catch (error) {
+ ElMessage.error(errorMessage(error))
+ }
}
async function loadSelectedWarehouse(id: string | number | boolean | undefined) {
@@ -1074,7 +1157,7 @@ async function openEdit(order: FirstLegOrder) {
if (openingEditor.value || !canEditOrder.value || !canClientEditFirstLegOrder(order.status)) return
openingEditor.value = true
try {
- const detailedOrder = await store.loadDetail(order.id)
+ const [detailedOrder] = await Promise.all([store.loadDetail(order.id), loadOrderAddresses()])
const draft = draftFromOrder(detailedOrder)
if (draft.destinationType !== 'PRIVATE') await loadDestinationWarehouses(draft.destinationType)
const warehouseAvailable = availableWarehouses.value.some((item) => item.id === detailedOrder.receivingWarehouseId)
@@ -1396,71 +1479,98 @@ function removeSelectedCargoItems() {
resetEditorValidation()
}
-function downloadCargoTemplate() {
- const headers = cargoTemplateColumns.map(([label]) => `| ${label} | `).join('')
- const example = cargoTemplateColumns.map(([label]) => {
- if (label === '包装类型') return 'CARTON | '
- if (label === '危险品') return '否 | '
- if (['每箱数量', '箱数', '总数量'].includes(label)) return '1 | '
- return ' | '
- }).join('')
- const html = ``
- const url = URL.createObjectURL(new Blob(['\ufeff', html], { type: 'application/vnd.ms-excel;charset=utf-8' }))
- const anchor = document.createElement('a')
- anchor.href = url
- anchor.download = '货物明细导入模板.xls'
- anchor.click()
- URL.revokeObjectURL(url)
+function openBatchImport() {
+ batchImportMode.value = 'PARTIAL'
+ batchImportFile.value = null
+ batchImportIdempotencyKey.value = ''
+ batchImportResult.value = null
+ batchImportError.value = ''
+ batchImportOpen.value = true
}
-function parseCargoTemplateRows(text: string) {
- const documentNode = new DOMParser().parseFromString(text, 'text/html')
- const tableRows = [...documentNode.querySelectorAll('table tr')]
- if (tableRows.length < 2) return []
- const headers = [...tableRows[0].querySelectorAll('th,td')].map((cell) => cell.textContent?.trim() || '')
- return tableRows.slice(1).map((row) => {
- const values = [...row.querySelectorAll('td')].map((cell) => cell.textContent?.trim() || '')
- return { headers, values }
- }).filter(({ values }) => values.some(Boolean))
-}
-
-async function importCargoTemplate(event: Event) {
+function selectBatchImportFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
- if (!file.name.toLowerCase().endsWith('.xls')) {
- ElMessage.error('请上传由“下载模板”生成的 .xls 文件')
+ if (!file.name.toLowerCase().endsWith('.xlsx')) {
+ batchImportError.value = '仅支持 .xlsx 格式的订单导入文件'
+ ElMessage.error(batchImportError.value)
+ return
+ }
+ if (file.size > 5 * 1024 * 1024) {
+ batchImportError.value = '订单导入文件不能超过 5MB'
+ ElMessage.error(batchImportError.value)
+ return
+ }
+ batchImportFile.value = file
+ batchImportIdempotencyKey.value = createIdempotencyKey('first-leg-batch-import')
+ batchImportResult.value = null
+ batchImportError.value = ''
+}
+
+async function downloadBatchTemplate() {
+ try {
+ batchTemplateDownloading.value = true
+ batchImportError.value = ''
+ saveBlob(await downloadMerchantFirstLegBatchImportTemplate(), '头程订单批量导入模板.xlsx')
+ } catch (error) {
+ batchImportError.value = batchImportErrorMessage(error)
+ ElMessage.error(batchImportError.value)
+ } finally {
+ batchTemplateDownloading.value = false
+ }
+}
+
+async function downloadBatchErrorReport() {
+ const result = batchImportResult.value
+ if (!result?.errorReportFileId) return
+ try {
+ batchErrorReportDownloading.value = true
+ batchImportError.value = ''
+ saveBlob(
+ await downloadMerchantFirstLegBatchImportErrorReport(result.batchNo, result.errorReportFileId),
+ `${result.batchNo}-错误报告.xlsx`,
+ )
+ } catch (error) {
+ batchImportError.value = batchImportErrorMessage(error)
+ ElMessage.error(batchImportError.value)
+ } finally {
+ batchErrorReportDownloading.value = false
+ }
+}
+
+async function submitBatchImport() {
+ if (!batchImportFile.value) {
+ batchImportError.value = '请先选择 .xlsx 订单导入文件'
+ ElMessage.warning(batchImportError.value)
return
}
try {
- const parsedRows = parseCargoTemplateRows(await file.text())
- if (!parsedRows.length) throw new Error('模板中没有可导入的货物数据')
- const imported = parsedRows.map(({ headers, values }) => {
- const item = createEmptyCargoItem(`imported-cargo-${++cargoSequence}`)
- for (const [columnLabel, itemKey] of cargoTemplateColumns) {
- const value = values[headers.indexOf(columnLabel)] ?? ''
- if (itemKey === 'dangerousGoods') item.dangerousGoods = ['是', 'true', '1', 'yes'].includes(value.toLowerCase())
- else if (cargoNumberKeys.has(itemKey)) (item as unknown as Record)[itemKey] = Number(value) || 0
- else (item as unknown as Record)[itemKey] = value
- }
- item.packageType = item.packageType || 'CARTON'
- item.unitsPerPackage = item.unitsPerPackage || 1
- item.packageCount = item.packageCount || 1
- item.pieces = item.pieces || 1
- return item
- })
- const shouldReplaceDefaultRow = form.items.length === 1 && !form.items[0].cargoNameCn && !form.items[0].sku
- if (shouldReplaceDefaultRow) form.items.splice(0, 1, ...imported)
- else form.items.push(...imported)
- selectedCargoIds.value = []
- resetEditorValidation()
- ElMessage.success(`已导入 ${imported.length} 行货物明细`)
+ batchImportSubmitting.value = true
+ batchImportError.value = ''
+ batchImportResult.value = await importMerchantFirstLegOrders(
+ batchImportFile.value,
+ batchImportMode.value,
+ batchImportIdempotencyKey.value,
+ )
+ const result = batchImportResult.value.result
+ if (result.succeeded && auth.hasPermission('order:first-leg:list')) await store.load({ includeDrafts: true })
+ if (result.failed) ElMessage.warning(`导入完成:成功 ${result.succeeded} 条,失败 ${result.failed} 条`)
+ else ElMessage.success(`已成功导入 ${result.succeeded} 条订单草稿`)
} catch (error) {
- ElMessage.error(error instanceof Error ? error.message : '货物明细导入失败')
+ batchImportError.value = batchImportErrorMessage(error)
+ ElMessage.error(batchImportError.value)
+ } finally {
+ batchImportSubmitting.value = false
}
}
+function viewImportedDrafts() {
+ batchImportOpen.value = false
+ void router.push({ name: 'first-leg-orders' })
+}
+
async function validateEditorForm() {
try {
const policy = await getMerchantInboundCodePolicy()
@@ -1635,6 +1745,7 @@ async function searchOrders(model: QueryModel) {
恢复默认
+ 批量导入订单
创建订单
@@ -1753,6 +1864,9 @@ async function searchOrders(model: QueryModel) {
普通头程一件代发测量完成后不办理国内库存入库,将直接推送 PDA 合托。
快递自送我司提货卡车自送
+
+ 保存当前地址停用
+
@@ -1777,7 +1891,7 @@ async function searchOrders(model: QueryModel) {
买单退税
整柜散货
- 是否
+ 第 {{ woodenCargoLines.join('、') }} 条货物已勾选木制品,提交前“是否商检”必须选择“是”。
@@ -1789,7 +1903,7 @@ async function searchOrders(model: QueryModel) {
FBA 仓海外仓私人仓
- 保存当前地址移除
+ 保存当前地址停用
平台暂未维护可用的{{ form.destinationType === 'FBA' ? ' FBA 仓' : '海外仓' }},请联系平台管理员。
@@ -1810,16 +1924,16 @@ async function searchOrders(model: QueryModel) {