feat(client): align account and order foundations

This commit is contained in:
2026-09-01 19:34:37 +08:00
parent c1e7ba411e
commit 1305ae4669
23 changed files with 602 additions and 258 deletions
@@ -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', () => {
+18 -3
View File
@@ -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[] = []
@@ -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,25 @@ 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, /企业归属由平台审核关系确定/)
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.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/)
@@ -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<AccountTab>('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<FormInstance>()
const identifiersFormRef = ref<FormInstance>()
const passwordFormRef = ref<FormInstance>()
const profileForm = reactive<ProfileForm>({ ...auth.profile })
@@ -46,20 +53,38 @@ const passwordForm = reactive<PasswordChangePayload>({
newPassword: '',
confirmPassword: '',
})
const identifiersForm = reactive<LoginIdentifierChangePayload>({
username: auth.profile.username,
phone: auth.profile.phone,
currentPassword: '',
})
const profileRules: FormRules<ProfileForm> = {
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<LoginIdentifierChangePayload> = {
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<PasswordChangePayload> = {
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"
>
<el-form-item label="用户名">
<el-input v-model="profileForm.username" disabled />
<span class="field-help">账号标识暂不支持在客户端修改</span>
</el-form-item>
<el-form-item label="用户名"><el-input v-model="profileForm.username" disabled /><span class="field-help">请在“登录标识”中修改</span></el-form-item>
<el-form-item label="姓名" prop="realName"><el-input v-model="profileForm.realName" maxlength="64" show-word-limit placeholder="请输入真实姓名" /></el-form-item>
<el-form-item label="联系电话" prop="phone"><el-input v-model="profileForm.phone" maxlength="32" inputmode="tel" placeholder="选填" /></el-form-item>
<el-form-item label="登录手机号"><el-input v-model="profileForm.phone" disabled /><span class="field-help">请在“登录标识”中修改</span></el-form-item>
<el-form-item label="邮箱" prop="email"><el-input v-model="profileForm.email" maxlength="128" inputmode="email" placeholder="选填" /></el-form-item>
<el-form-item label="所属公司">
<el-input v-model="profileForm.tenantName" disabled />
@@ -216,6 +273,20 @@ async function changePassword() {
</dl>
</el-tab-pane>
<el-tab-pane name="identifiers">
<template #label><span class="account-tab-label"><ShieldCheck :size="16" />登录标识</span></template>
<div class="identifier-layout">
<el-form ref="identifiersFormRef" :model="identifiersForm" :rules="identifierRules" label-position="top" class="identifier-form" @submit.prevent="changeLoginIdentifiers">
<el-alert title="修改成功后,国内与海外的全部登录会话都会立即退出,需要使用新用户名或手机号重新登录。" type="warning" :closable="false" show-icon />
<el-form-item label="用户名" prop="username"><el-input v-model="identifiersForm.username" maxlength="64" autocomplete="username" placeholder="请输入新用户名" /></el-form-item>
<el-form-item label="中国大陆手机号" prop="phone"><el-input v-model="identifiersForm.phone" maxlength="11" inputmode="tel" autocomplete="tel" placeholder="请输入11位手机号" /></el-form-item>
<el-form-item label="当前密码" prop="currentPassword"><el-input v-model="identifiersForm.currentPassword" type="password" show-password maxlength="72" autocomplete="current-password" placeholder="用于确认是本人操作" /></el-form-item>
<el-alert v-if="identifiersError" :title="identifiersError" type="error" :closable="false" show-icon />
<div class="identifier-actions"><el-button :disabled="identifiersSaving" @click="resetIdentifiersForm">重置</el-button><el-button type="primary" native-type="submit" :loading="identifiersSaving"><Save :size="16" />保存并重新登录</el-button></div>
</el-form>
</div>
</el-tab-pane>
<el-tab-pane name="password">
<template #label><span class="account-tab-label"><KeyRound :size="16" />修改密码</span></template>
@@ -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; }
}
</style>
+2 -2
View File
@@ -27,8 +27,8 @@ export interface ImpersonationExchangeResult {
expiresIn: number
}
export function loginMerchant(username: string, password: string) {
return apiRequest<TokenPair>({ method: 'POST', url: '/auth/login', data: { username, password } })
export function loginMerchant(loginId: string, password: string) {
return apiRequest<TokenPair>({ method: 'POST', url: '/auth/login', data: { loginId, password } })
}
export function registerMerchant(payload: RegisterMerchantRequest) {
+4 -5
View File
@@ -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<ClientAccountProfile, 'realName' | 'phone' | 'email'>) {
async function savePersonalProfile(next: Pick<ClientAccountProfile, 'realName' | 'email'>) {
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 }
})
+14 -6
View File
@@ -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/)
@@ -59,9 +62,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 +116,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\)\)/)
+25 -6
View File
@@ -18,6 +18,7 @@ const auth = useAuthStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const impersonationFailed = route.query.impersonation === 'failed'
const loginIdentifiersUpdated = route.query.reason === 'identifiers-updated'
const form = reactive<LoginForm>({
account: typeof route.query.account === 'string'
? route.query.account
@@ -25,12 +26,23 @@ const form = reactive<LoginForm>({
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<LoginForm> = {
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() {
<div class="auth-form-content auth-form-content--login">
<div class="auth-form-heading">
<h2 id="login-title">登录客户工作台</h2>
<p>使用企业账号登录,继续管理您的跨境物流业务。</p>
<p>使用用户名或中国大陆手机号登录,继续管理您的跨境物流业务。</p>
</div>
<el-alert
v-if="impersonationFailed"
@@ -85,10 +97,17 @@ function showResetHint() {
:closable="false"
show-icon
/>
<el-alert
v-if="loginIdentifiersUpdated"
title="用户名或手机号已更新,所有会话均已退出,请重新登录"
type="success"
:closable="false"
show-icon
/>
<div class="auth-account-note">企业注册成功后即可登录;提交订单前需完成企业资料审核。</div>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" size="large" @submit.prevent="submit">
<el-form-item label="用户名" prop="account">
<el-input v-model="form.account" autocomplete="username" maxlength="64" placeholder="请输入用户名" :prefix-icon="UserRound" />
<el-form-item label="用户名或手机号" prop="account">
<el-input v-model="form.account" autocomplete="username" maxlength="64" placeholder="请输入用户名或中国大陆手机号" :prefix-icon="UserRound" />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input v-model="form.password" autocomplete="current-password" maxlength="72" type="password" show-password placeholder="请输入 8–72 位密码" :prefix-icon="LockKeyhole" @keyup.enter="submit" />
+5 -5
View File
@@ -29,7 +29,7 @@ const form = reactive<RegisterForm>({ username: '', companyName: '', displayName
const rules: FormRules<RegisterForm> = {
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' },
@@ -48,7 +48,7 @@ const rules: FormRules<RegisterForm> = {
],
legalPhone: [
{ 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' },
@@ -113,7 +113,7 @@ async function submit() {
<section class="register-section" aria-labelledby="account-section-title">
<h2 id="account-section-title">账号信息</h2>
<div class="register-form-grid">
<el-form-item label="登录用户名" prop="username" required><el-input v-model="form.username" autocomplete="username" maxlength="64" placeholder="4–64 位字母、数字或下划线" /></el-form-item>
<el-form-item label="登录用户名" prop="username" required><el-input v-model="form.username" autocomplete="username" maxlength="64" placeholder="以字母开头,支持数字和 _.@-" /></el-form-item>
<el-form-item label="登录密码" prop="password" required><el-input v-model="form.password" type="password" autocomplete="new-password" maxlength="72" show-password placeholder="请设置 8–72 位密码" /></el-form-item>
</div>
</section>
@@ -143,7 +143,7 @@ async function submit() {
<span><RouterLink to="/legal/terms" target="_blank" rel="noopener">《用户服务协议》</RouterLink> 和 <RouterLink to="/legal/privacy" target="_blank" rel="noopener">《隐私政策》</RouterLink></span>
</div>
</el-form-item>
<p class="register-security-note">注册成功后账号可立即登录;首次登录需上传营业执照和法人身份证,货代资质证书、银行开户许可证可选填。提交平台审核通过后才能提交订单。</p>
<p class="register-security-note">注册成功后账号可立即登录;首次登录需上传营业执照,法人身份证、货代资质证书和银行开户许可证可选填。提交平台审核通过后才能提交订单。</p>
<el-button native-type="submit" class="register-submit" type="success" :loading="submitting">注册企业账号</el-button>
</el-form>
</template>
@@ -153,7 +153,7 @@ async function submit() {
<h1>企业账号注册成功</h1>
<p><strong>{{ form.username }}</strong> 现在可以直接登录客户工作台,无需等待注册审核。</p>
<dl><div><dt>企业名称</dt><dd>{{ form.companyName }}</dd></div><div><dt>登录账号</dt><dd>{{ form.username }}</dd></div><div><dt>注册时间</dt><dd>{{ submittedAt }}</dd></div><div><dt>账号状态</dt><dd><el-tag type="success" effect="light">可登录</el-tag></dd></div></dl>
<div class="registration-next-step" role="note"><strong>提交订单前还需完成企业审核</strong><span>登录后请在“企业资料”上传营业执照和法人身份证并提交审核,其他资质可选填。审核期间仍可维护资料、成员和订单草稿。</span></div>
<div class="registration-next-step" role="note"><strong>提交订单前还需完成企业审核</strong><span>登录后请在“企业资料”上传营业执照并提交审核;法人身份证及其他资质可选填。审核期间仍可维护资料、成员和订单草稿。</span></div>
<div class="registration-result-actions"><el-button type="primary" @click="router.push({ path: '/login', query: { account: form.username } })">立即登录</el-button></div>
</section>
</div>
+67 -4
View File
@@ -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<T> {
records: T[]
@@ -91,6 +93,7 @@ export interface FirstLegOrderCargoPayload {
declaredValue?: number
currency?: string
dangerousGoods?: boolean
woodenProduct: boolean
cargoRemark?: string
skuNo?: string
upcNo?: string
@@ -129,8 +132,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
@@ -142,13 +145,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
@@ -177,6 +200,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
@@ -203,7 +234,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
@@ -453,6 +484,38 @@ export function getMerchantInboundCodePolicy() {
return apiRequest<InboundCodePolicy>({ method: 'GET', url: '/merchant/v1/orders/first-leg/inbound-code-policy' })
}
export function getMerchantOrderAddresses(addressType?: CustomerOrderAddressType) {
return apiRequest<CustomerOrderAddress[]>({ method: 'GET', url: '/merchant/v1/order-addresses', params: { addressType } })
}
export function createMerchantOrderAddress(payload: CustomerOrderAddressSaveRequest) {
return apiRequest<number | string>({ method: 'POST', url: '/merchant/v1/order-addresses', data: payload })
}
export function updateMerchantOrderAddress(id: number | string, payload: CustomerOrderAddressSaveRequest) {
return apiRequest<void>({ method: 'PUT', url: `/merchant/v1/order-addresses/${id}`, data: payload })
}
export function disableMerchantOrderAddress(id: number | string) {
return apiRequest<void>({ method: 'DELETE', url: `/merchant/v1/order-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<MerchantBatchImportResponse>({
method: 'POST',
url: '/merchant/v1/orders/first-leg/batch-imports',
params: { mode },
headers: { 'Idempotency-Key': idempotencyKey },
data,
})
}
export function createMerchantFirstLegOrder(payload: FirstLegOrderSaveRequest) {
return apiRequest<number | string>({
method: 'POST',
@@ -29,6 +29,12 @@ 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\/order-addresses'/)
assert.match(apiSource, /`\/merchant\/v1\/order-addresses\/\$\{id\}`/)
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, /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 +140,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\\.`))
}
+4 -35
View File
@@ -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<Record<string, string>> = {
中国: '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(),
@@ -112,8 +79,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,
@@ -129,6 +96,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,
@@ -329,6 +297,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,
+4 -1
View File
@@ -45,6 +45,7 @@ export interface FirstLegCargoItem {
declaredValue: number
currency: string
dangerousGoods: boolean
woodenProduct: boolean
}
export interface FirstLegOrderTimelineItem {
@@ -142,7 +143,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
@@ -185,4 +186,6 @@ export type FirstLegOrderDraft = Omit<Pick<
>, 'transportMode'> & {
transportMode: FirstLegTransportMode
inboundCode?: string
senderAddressId: string
privateDestinationAddressId: string
}
@@ -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('木制品必须选择商检'))
draft.customsInspection = false
assert.ok(validateFirstLegOrderDraft(draft).includes('木制品必须选择商检'))
draft.customsInspection = true
assert.ok(!validateFirstLegOrderDraft(draft).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 }
@@ -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('请填写收件人电话')
@@ -130,6 +133,9 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) {
if (item.grossWeightKg <= 0) errors.push(`${line}毛重必须大于 0`)
if (item.volumeCbm <= 0) errors.push(`${line}体积必须大于 0`)
})
if (draft.items.some((item) => item.woodenProduct) && draft.customsInspection !== true) {
errors.push('木制品必须选择商检')
}
return errors
}
@@ -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,8 @@ 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, /货物包含木制品,提交前必须选择“是”/)
})
test('distills order creation into the confirmed prototype sections with server pricing', () => {
@@ -330,7 +337,11 @@ 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, /<table class="cargo-edit-table">/)
assert.match(source, /上传 Excel[\s\S]*下载模板[\s\S]*添加行[\s\S]*删除所选行/)
assert.match(source, /批量导入订单/)
assert.match(source, /下载正式模板[\s\S]*选择 Excel/)
assert.match(source, /仅支持 \.xlsx,文件不超过 5MB、最多 50 行/)
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))
}
+242 -142
View File
@@ -16,11 +16,22 @@ import { getMerchantInboundBilling } from '@/modules/first-leg/api/inboundBillin
import { getMerchantCustomsForOrder, isMerchantCustomsUnavailable, type MerchantCustomsContainer } from '@/modules/first-leg/api/customs'
import {
estimateMerchantFirstLegPrice,
createIdempotencyKey,
createMerchantOrderAddress,
disableMerchantOrderAddress,
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 +94,14 @@ const priceEstimate = ref<MerchantPriceEstimate | null>(null)
const priceEstimateLoading = ref(false)
const priceEstimateError = ref('')
const selectedCargoIds = ref<string[]>([])
const cargoImportInput = ref<HTMLInputElement>()
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<string>(['lengthCm', 'widthCm', 'heightCm', 'unitsPerPackage', 'packageCount', 'grossWeightKg', 'volumeCbm', 'pieces', 'netWeightKg', 'unitDeclaredValue', 'declaredValue', 'productLengthCm', 'productWidthCm', 'productHeightCm'])
const batchImportInput = ref<HTMLInputElement>()
const batchImportOpen = ref(false)
const batchImportMode = ref<MerchantBatchImportMode>('PARTIAL')
const batchImportFile = ref<File | null>(null)
const batchImportIdempotencyKey = ref('')
const batchImportSubmitting = ref(false)
const batchTemplateDownloading = ref(false)
const batchImportResult = ref<MerchantBatchImportResponse | null>(null)
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 +214,13 @@ let channelRequestSequence = 0
let estimateRequestSequence = 0
let estimateTimer: ReturnType<typeof setTimeout> | undefined
interface CachedPrivateAddress {
id: string
label: string
consigneePartyId: string
consigneePartySnapshot: NonNullable<FirstLegOrderDraft['consigneePartySnapshot']>
consignee: string
destination: string
destinationCountryCode: string
destinationWarehouseName: string
}
const orderAddresses = ref<CustomerOrderAddress[]>([])
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<CachedPrivateAddress[]>([])
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 = {
@@ -352,9 +337,11 @@ function emptyDraft(): FirstLegOrderDraft {
receivingWarehouseId: '',
expectedDepartureDate: '',
shipperPartyId: '',
senderAddressId: '',
shipperPartySnapshot: null,
shipper: '',
consigneePartyId: '',
privateDestinationAddressId: '',
consigneePartySnapshot: null,
consignee: '',
priority: 'NORMAL',
@@ -368,7 +355,7 @@ function emptyDraft(): FirstLegOrderDraft {
channelName: '',
pricingTaxMode: 'TAX_INCLUDED',
taxMode: 'BUY_DECLARATION',
customsInspection: false,
customsInspection: null,
destinationType: 'FBA',
amazonWarehouseCode: '',
shipmentId: '',
@@ -566,11 +553,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 {
@@ -586,6 +575,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
@@ -625,7 +615,6 @@ function getOrderCargoSummary(order: FirstLegOrder) {
}
onMounted(() => {
loadPrivateAddresses()
void loadPageData()
})
@@ -745,9 +734,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,
@@ -939,7 +930,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 = ''
@@ -958,6 +949,7 @@ function changePickupMethod() {
if (form.pickupMethod === 'PICKUP') {
form.shipperPartySnapshot ??= createPickupPartySnapshot()
} else {
form.senderAddressId = ''
form.shipperPartyId = ''
form.shipperPartySnapshot = null
form.shipper = ''
@@ -974,54 +966,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) {
@@ -1073,7 +1140,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)
@@ -1395,68 +1462,63 @@ function removeSelectedCargoItems() {
resetEditorValidation()
}
function downloadCargoTemplate() {
const headers = cargoTemplateColumns.map(([label]) => `<th>${label}</th>`).join('')
const example = cargoTemplateColumns.map(([label]) => {
if (label === '包装类型') return '<td>CARTON</td>'
if (label === '危险品') return '<td>否</td>'
if (['每箱数量', '箱数', '总数量'].includes(label)) return '<td>1</td>'
return '<td></td>'
}).join('')
const html = `<!doctype html><html><head><meta charset="UTF-8"></head><body><table><thead><tr>${headers}</tr></thead><tbody><tr>${example}</tr></tbody></table></body></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
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')) {
ElMessage.error('仅支持 .xlsx 格式的订单导入文件')
return
}
if (file.size > 5 * 1024 * 1024) {
ElMessage.error('订单导入文件不能超过 5MB')
return
}
batchImportFile.value = file
batchImportIdempotencyKey.value = createIdempotencyKey('first-leg-batch-import')
batchImportResult.value = null
}
async function downloadBatchTemplate() {
try {
batchTemplateDownloading.value = true
saveBlob(await downloadMerchantFirstLegBatchImportTemplate(), '头程订单批量导入模板.xlsx')
} catch (error) {
ElMessage.error(errorMessage(error))
} finally {
batchTemplateDownloading.value = false
}
}
async function submitBatchImport() {
if (!batchImportFile.value) {
ElMessage.warning('请先选择 .xlsx 订单导入文件')
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<string, number>)[itemKey] = Number(value) || 0
else (item as unknown as Record<string, string>)[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
batchImportResult.value = await importMerchantFirstLegOrders(
batchImportFile.value,
batchImportMode.value,
batchImportIdempotencyKey.value,
)
const result = batchImportResult.value.result
if (result.succeeded) 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 : '货物明细导入失败')
ElMessage.error(errorMessage(error))
} finally {
batchImportSubmitting.value = false
}
}
@@ -1634,6 +1696,7 @@ async function searchOrders(model: QueryModel) {
<el-button link class="column-settings-reset" @click="resetOrderColumns"><RotateCcw :size="17" />恢复默认</el-button>
</div>
</el-popover>
<el-button v-if="auth.hasPermission('order:first-leg:create')" @click="openBatchImport"><Upload :size="17" />批量导入订单</el-button>
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :loading="pageDataLoading" :disabled="pageDataLoading || availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button>
</div>
</div>
@@ -1751,6 +1814,9 @@ async function searchOrders(model: QueryModel) {
<div class="prototype-form-flow shipping-form-flow">
<el-form-item label="取货方式" prop="pickupMethod" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.pickupMethod" class="segmented-options" @change="changePickupMethod"><el-radio-button value="SELF_EXPRESS">快递自送</el-radio-button><el-radio-button value="PICKUP">我司提货</el-radio-button><el-radio-button value="SELF_TRUCK">卡车自送</el-radio-button></el-radio-group></el-form-item>
<template v-if="form.pickupMethod === 'PICKUP' && form.shipperPartySnapshot">
<el-form-item label="企业提货地址簿" prop="senderAddressId" required :rules="requiredSelectRules('请选择企业提货地址')" class="prototype-form-row prototype-form-row--wide address-book-row">
<div class="address-book-control"><el-select v-model="form.senderAddressId" clearable filterable :loading="orderAddressesLoading" placeholder="选择企业保存的提货地址" @change="selectPickupAddress"><el-option v-for="address in pickupAddresses" :key="String(address.id)" :label="addressLabel(address)" :value="String(address.id)" /></el-select><el-button @click="savePickupAddress">保存当前地址</el-button><el-button v-if="form.senderAddressId" link type="danger" @click="removeOrderAddress('PICKUP')">停用</el-button></div>
</el-form-item>
<el-form-item label="提货联系人" prop="shipperPartySnapshot.contactName" required :rules="requiredTextRules('请输入提货联系人')" class="prototype-form-row prototype-form-row--compact"><el-input v-model="form.shipperPartySnapshot.contactName" maxlength="50" placeholder="请输入联系人姓名" /></el-form-item>
<el-form-item label="联系电话" prop="shipperPartySnapshot.phone" required :rules="requiredTextRules('请输入提货联系电话')" class="prototype-form-row prototype-form-row--compact"><el-input v-model="form.shipperPartySnapshot.phone" maxlength="30" placeholder="请输入联系电话" /></el-form-item>
<el-form-item label="州 / 省" class="prototype-form-row prototype-form-row--compact"><el-input v-model="form.shipperPartySnapshot.province" maxlength="100" placeholder="请输入州或省" /></el-form-item>
@@ -1775,7 +1841,7 @@ async function searchOrders(model: QueryModel) {
</el-form-item>
<el-form-item label="买单 / 退税" prop="taxMode" required><el-radio-group v-model="form.taxMode"><el-radio value="BUY_DECLARATION">买单</el-radio><el-radio value="TAX_REFUND">退税</el-radio></el-radio-group></el-form-item>
<el-form-item label="是否整柜" prop="transportMode" required><el-radio-group v-model="form.transportMode"><el-radio value="FCL">整柜</el-radio><el-radio value="LCL">散货</el-radio></el-radio-group></el-form-item>
<el-form-item label="是否商检" prop="customsInspection" required><el-radio-group v-model="form.customsInspection"><el-radio :value="true">是</el-radio><el-radio :value="false">否</el-radio></el-radio-group></el-form-item>
<el-form-item label="是否商检" prop="customsInspection"><el-select v-model="form.customsInspection" clearable placeholder="暂不选择"><el-option label="是" :value="true" /><el-option label="否" :value="false" /></el-select><small v-if="form.items.some((item) => item.woodenProduct) && form.customsInspection !== true" class="field-guidance field-guidance--error">货物包含木制品,提交前必须选择“是”。</small></el-form-item>
<el-form-item label="期望出货日期"><el-date-picker v-model="form.expectedDepartureDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择期望出货日期" /></el-form-item>
<el-form-item label="订单备注"><el-input v-model="form.customerRemark" type="textarea" :rows="3" resize="vertical" maxlength="300" show-word-limit placeholder="选填,例如:入仓前请电话联系" /></el-form-item>
</div>
@@ -1787,7 +1853,7 @@ async function searchOrders(model: QueryModel) {
<el-form-item label="目的仓类型" prop="destinationType" required class="prototype-form-row prototype-form-row--tabs destination-type-row"><el-radio-group v-model="form.destinationType" class="segmented-options destination-type-options" @change="changeDestinationType"><el-radio-button value="FBA">FBA 仓</el-radio-button><el-radio-button value="OVERSEAS">海外仓</el-radio-button><el-radio-button value="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item>
<el-form-item v-if="form.destinationType === 'PRIVATE'" label="私人地址簿" class="prototype-form-row prototype-form-row--wide address-book-row">
<div class="address-book-control"><el-select v-model="selectedPrivateAddressId" clearable filterable placeholder="选择当前账号保存的私人地址" @change="selectPrivateAddress"><el-option v-for="address in privateAddresses" :key="address.id" :label="address.label" :value="address.id" /></el-select><el-button @click="savePrivateAddress">保存当前地址</el-button><el-button v-if="selectedPrivateAddressId" link type="danger" @click="removePrivateAddress">移除</el-button></div>
<div class="address-book-control"><el-select v-model="form.privateDestinationAddressId" clearable filterable :loading="orderAddressesLoading" placeholder="选择企业保存的私人仓地址" @change="selectPrivateAddress"><el-option v-for="address in privateAddresses" :key="String(address.id)" :label="addressLabel(address)" :value="String(address.id)" /></el-select><el-button @click="savePrivateAddress">保存当前地址</el-button><el-button v-if="form.privateDestinationAddressId" link type="danger" @click="removeOrderAddress('PRIVATE_DESTINATION')">停用</el-button></div>
</el-form-item>
<el-form-item v-if="form.destinationType !== 'PRIVATE'" :label="form.destinationType === 'FBA' ? 'FBA 仓' : '海外仓'" prop="destinationWarehouseId" required :rules="requiredSelectRules('请选择平台维护的目的仓')" class="prototype-form-row"><el-select v-model="form.destinationWarehouseId" filterable :loading="destinationWarehousesLoading" placeholder="请选择当前可送达的目的仓" @change="selectDestinationWarehouse"><el-option v-for="warehouse in availableDestinationWarehouses" :key="warehouse.id" :label="`${warehouse.name} · ${warehouse.code}`" :value="warehouse.id" /></el-select><small v-if="!destinationWarehousesLoading && availableDestinationWarehouses.length === 0" class="field-guidance">平台暂未维护可用的{{ form.destinationType === 'FBA' ? ' FBA 仓' : '海外仓' }},请联系平台管理员。</small></el-form-item>
@@ -1808,7 +1874,7 @@ async function searchOrders(model: QueryModel) {
<section class="order-form-section prototype-section cargo-section">
<div class="order-section-heading cargo-table-heading"><div><h3>货物明细</h3></div></div>
<div class="cargo-table-toolbar">
<div class="cargo-import-actions"><input ref="cargoImportInput" class="cargo-import-input" type="file" accept=".xls,application/vnd.ms-excel" @change="importCargoTemplate" /><el-button class="cargo-upload-button" @click="cargoImportInput?.click()"><Upload :size="16" />上传 Excel</el-button><el-button class="cargo-download-button" @click="downloadCargoTemplate"><Download :size="16" />下载模板</el-button></div>
<span class="cargo-table-hint">可逐行维护货物明细;批量订单请返回订单列表使用正式 Excel 导入。</span>
<div class="cargo-table-actions"><el-button @click="addCargoItem"><Plus :size="16" />添加行</el-button><el-button type="danger" plain :disabled="!selectedCargoIds.length" @click="removeSelectedCargoItems"><Trash2 :size="15" />删除所选行</el-button></div>
</div>
<div class="cargo-table-wrap">
@@ -1817,7 +1883,7 @@ async function searchOrders(model: QueryModel) {
<th class="cargo-check-cell"><el-checkbox :model-value="allCargoSelected" :indeterminate="someCargoSelected" aria-label="全选货物" @change="toggleAllCargoItems" /></th><th class="cargo-index-cell">#</th>
<th>SKU NO.<small>SKU必填</small></th><th>中文品名</th><th class="cargo-image-column">图片</th><th>英文品名</th><th>报关 HS CODE</th><th>清关 HS CODE</th><th>包装类型</th>
<th class="cargo-number-column">箱规长 cm</th><th class="cargo-number-column">箱规宽 cm</th><th class="cargo-number-column">箱规高 cm</th><th class="cargo-number-column">每箱数量</th><th class="cargo-number-column">箱数</th><th class="cargo-number-column">总毛重</th><th class="cargo-number-column">总体积</th><th class="cargo-number-column">总数量</th><th class="cargo-number-column">总净重</th>
<th>清关申报单价 USD</th><th>清关申报金额 USD</th><th>材质</th><th>用途</th><th>型号</th><th>品牌</th><th class="cargo-dimensions-column">产品规格 长×宽×高</th><th>危险品</th><th>货件编号<small>货件编号必填</small></th><th>跟踪编号<small>跟踪编号必填</small></th><th>UPC NO.</th><th>备注</th><th class="cargo-operation-column">操作</th>
<th>清关申报单价 USD</th><th>清关申报金额 USD</th><th>材质</th><th>用途</th><th>型号</th><th>品牌</th><th class="cargo-dimensions-column">产品规格 长×宽×高</th><th>危险品</th><th>木制品</th><th>货件编号<small>货件编号必填</small></th><th>跟踪编号<small>跟踪编号必填</small></th><th>UPC NO.</th><th>备注</th><th class="cargo-operation-column">操作</th>
</tr></thead>
<tbody><tr v-for="(item, index) in form.items" :key="item.id">
<td class="cargo-check-cell"><el-checkbox :model-value="selectedCargoIds.includes(item.id)" :aria-label="`选择货物 ${index + 1}`" @change="toggleCargoItem(item.id, $event)" /></td><td class="cargo-index-cell">{{ index + 1 }}</td>
@@ -1829,7 +1895,7 @@ async function searchOrders(model: QueryModel) {
<td><el-input-number v-model="item.lengthCm" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.widthCm" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.heightCm" :min="0" :precision="2" :controls="false" /></td>
<td><el-form-item :prop="`items.${index}.unitsPerPackage`" required :rules="positiveNumberRules('每箱数量必须大于 0', true)"><el-input-number v-model="item.unitsPerPackage" :min="1" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.packageCount`" required :rules="positiveNumberRules('箱数必须大于 0', true)"><el-input-number v-model="item.packageCount" :min="1" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('总毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="3" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('总体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="4" :controls="false" /></el-form-item></td><td><el-form-item :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('总数量必须大于 0', true)"><el-input-number v-model="item.pieces" :min="1" :controls="false" /></el-form-item></td><td><el-input-number v-model="item.netWeightKg" :min="0" :precision="3" :controls="false" /></td>
<td><el-input-number v-model="item.unitDeclaredValue" :min="0" :precision="2" :controls="false" /></td><td><el-input-number v-model="item.declaredValue" :min="0" :precision="2" :controls="false" /></td><td><el-input v-model="item.material" maxlength="128" placeholder="材质" /></td><td><el-input v-model="item.usage" maxlength="128" placeholder="用途" /></td><td><el-input v-model="item.model" maxlength="64" placeholder="型号" /></td><td><el-input v-model="item.brand" maxlength="64" placeholder="品牌" /></td>
<td class="cargo-dimensions-column"><div class="cargo-table-dimensions"><el-input-number v-model="item.productLengthCm" :min="0" :precision="2" :controls="false" /><span>×</span><el-input-number v-model="item.productWidthCm" :min="0" :precision="2" :controls="false" /><span>×</span><el-input-number v-model="item.productHeightCm" :min="0" :precision="2" :controls="false" /></div></td><td class="cargo-danger-cell"><el-switch v-model="item.dangerousGoods" /></td>
<td class="cargo-dimensions-column"><div class="cargo-table-dimensions"><el-input-number v-model="item.productLengthCm" :min="0" :precision="2" :controls="false" /><span>×</span><el-input-number v-model="item.productWidthCm" :min="0" :precision="2" :controls="false" /><span>×</span><el-input-number v-model="item.productHeightCm" :min="0" :precision="2" :controls="false" /></div></td><td class="cargo-danger-cell"><el-switch v-model="item.dangerousGoods" /></td><td class="cargo-danger-cell"><el-switch v-model="item.woodenProduct" /></td>
<td><el-form-item :prop="`items.${index}.shipmentId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入货件编号') : undefined"><el-input v-model="item.shipmentId" maxlength="64" placeholder="货件编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.referenceId`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入跟踪编号') : undefined"><el-input v-model="item.referenceId" maxlength="64" placeholder="跟踪编号" /></el-form-item></td><td><el-form-item :prop="`items.${index}.upc`"><el-input v-model="item.upc" maxlength="64" placeholder="UPC NO.(选填)" /></el-form-item></td><td><el-input v-model="item.remark" maxlength="500" placeholder="备注" /></td><td class="cargo-operation-column"><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="15" />删除</el-button></td>
</tr></tbody>
</table>
@@ -1875,6 +1941,32 @@ async function searchOrders(model: QueryModel) {
<template #footer><div class="editor-actions"><el-button :disabled="saving" @click="closeEditor">返回</el-button><el-button v-if="editingId ? canEditOrder : canCreateOrder" :loading="saving" @click="saveDraft">保存草稿</el-button><el-button v-if="auth.hasPermission('order:first-leg:submit')" type="primary" :loading="saving" @click="submitOrder"><Send :size="16" />{{ companySubmissionBlocked ? '提交前需完成资料审核' : '提交订单' }}</el-button></div></template>
</FirstLegOrderEditorShell>
<el-dialog v-model="batchImportOpen" title="批量导入头程订单" width="680px" destroy-on-close append-to-body>
<div class="batch-import-dialog">
<el-alert title="使用服务端正式模板导入" description="仅支持 .xlsx,文件不超过 5MB、最多 50 行。导入结果按本企业权限和数据范围返回。" type="info" :closable="false" show-icon />
<div class="batch-import-actions">
<input ref="batchImportInput" class="cargo-import-input" type="file" accept=".xlsx,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" @change="selectBatchImportFile" />
<el-button :loading="batchTemplateDownloading" @click="downloadBatchTemplate"><Download :size="16" />下载正式模板</el-button>
<el-button type="primary" plain @click="batchImportInput?.click()"><Upload :size="16" />选择 Excel</el-button>
<span>{{ batchImportFile?.name || '尚未选择文件' }}</span>
</div>
<el-radio-group v-model="batchImportMode" aria-label="导入模式">
<el-radio value="PARTIAL">部分成功:有效行创建草稿,失败行返回原因</el-radio>
<el-radio value="ATOMIC">原子导入:任一行失败则整批不创建</el-radio>
</el-radio-group>
<section v-if="batchImportResult" class="batch-import-result" aria-live="polite">
<strong>批次 {{ batchImportResult.batchNo }}</strong>
<span>共 {{ batchImportResult.result.total }} 条,成功 {{ batchImportResult.result.succeeded }} 条,失败 {{ batchImportResult.result.failed }} 条</span>
<ul v-if="batchImportResult.result.failed">
<li v-for="item in batchImportResult.result.items.filter((row) => !row.success)" :key="`${item.index}-${item.clientRowId}`">
第 {{ item.index + 1 }} 行({{ item.clientRowId || '未填写行标识' }}):{{ item.errorMessage || item.errorCode || '导入失败' }}
</li>
</ul>
</section>
</div>
<template #footer><div class="editor-actions"><el-button :disabled="batchImportSubmitting" @click="batchImportOpen = false">关闭</el-button><el-button type="primary" :loading="batchImportSubmitting" :disabled="!batchImportFile" @click="submitBatchImport">开始导入</el-button></div></template>
</el-dialog>
<el-dialog v-model="detailOpen" class="first-leg-detail-dialog" title="订单详情" width="94vw" top="3vh" destroy-on-close append-to-body>
<div v-loading="detailLoading" class="order-detail-content">
<div v-if="detailError" class="order-load-state is-compact" role="alert">
@@ -1904,7 +1996,7 @@ async function searchOrders(model: QueryModel) {
<div><dt>目的国家 / 仓库</dt><dd>{{ detailOrder.destinationCountryCode || '-' }} · {{ detailOrder.destinationWarehouseName || '-' }}</dd></div>
<div v-if="detailOrder.destinationType === 'FBA'"><dt>Amazon 仓库代码</dt><dd>{{ displayValue(detailOrder.amazonWarehouseCode) }}</dd></div>
<div><dt>期望出运</dt><dd>{{ displayValue(detailOrder.expectedDepartureDate) }}</dd></div>
<div><dt>海关查验</dt><dd>{{ detailOrder.customsInspection ? '需要' : '不需要' }}</dd></div>
<div><dt>海关查验</dt><dd>{{ detailOrder.customsInspection == null ? '未选择' : (detailOrder.customsInspection ? '需要' : '不需要') }}</dd></div>
<div><dt>提交时间</dt><dd>{{ detailOrder.submittedAt ? formatDateTime(detailOrder.submittedAt) : '-' }}</dd></div>
<div><dt>完成时间</dt><dd>{{ detailOrder.completedAt ? formatDateTime(detailOrder.completedAt) : '-' }}</dd></div>
</dl>
@@ -1973,6 +2065,7 @@ async function searchOrders(model: QueryModel) {
<el-table-column label="品牌" width="110" show-overflow-tooltip><template #default="{ row }">{{ row.brand || '-' }}</template></el-table-column>
<el-table-column label="产品规格 长×宽×高(cm)" width="180" align="center"><template #default="{ row }">{{ row.productLengthCm || '-' }} × {{ row.productWidthCm || '-' }} × {{ row.productHeightCm || '-' }}</template></el-table-column>
<el-table-column label="危险品" width="82" align="center"><template #default="{ row }">{{ row.dangerousGoods ? '是' : '否' }}</template></el-table-column>
<el-table-column label="木制品" width="82" align="center"><template #default="{ row }">{{ row.woodenProduct ? '是' : '否' }}</template></el-table-column>
<el-table-column label="备注" min-width="160" show-overflow-tooltip><template #default="{ row }">{{ row.remark || '-' }}</template></el-table-column>
</el-table>
<el-pagination
@@ -2043,6 +2136,13 @@ async function searchOrders(model: QueryModel) {
.order-table-toolbar span { color: var(--muted); font-size: 11px; }
.order-table-toolbar > .order-table-toolbar-actions { align-items: center; gap: 8px; }
.order-table-toolbar-actions :deep(.el-button + .el-button) { margin-left: 0; }
.batch-import-dialog { display: flex; flex-direction: column; gap: 18px; }
.batch-import-actions { display: flex; min-width: 0; align-items: center; flex-wrap: wrap; gap: 10px; }
.batch-import-actions > span { min-width: 0; overflow: hidden; color: var(--muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
.batch-import-dialog :deep(.el-radio-group) { display: flex; align-items: flex-start; flex-direction: column; gap: 8px; }
.batch-import-result { display: flex; flex-direction: column; gap: 6px; padding: 14px; border: 1px solid var(--border); background: #f8fafc; }
.batch-import-result > span, .batch-import-result li { color: var(--muted); font-size: 12px; line-height: 1.55; }
.batch-import-result ul { max-height: 180px; margin: 4px 0 0; padding-left: 20px; overflow-y: auto; }
.column-settings-trigger { width: 40px; min-width: 40px; height: 36px; padding: 0; color: #53627b; }
:global(.order-column-popper.el-popover) { padding: 0; border-color: #e3e8f0; border-radius: 4px; box-shadow: 0 8px 24px rgba(29, 45, 74, .12); }
:global(.order-column-popper .column-settings-panel) { padding: 14px 10px 10px; }
@@ -183,9 +183,11 @@ function buildOrderDraft(row: QuickOrderRow, channel: QuickChannelOption): First
receivingWarehouseId: channel.warehouseId,
expectedDepartureDate: '',
shipperPartyId: '',
senderAddressId: '',
shipperPartySnapshot: null,
shipper: '',
consigneePartyId: '',
privateDestinationAddressId: '',
consigneePartySnapshot: row.destinationType === 'PRIVATE' && hasCompletePrivateContact(row)
? createConsigneeSnapshot(row) : null,
consignee: row.destinationType === 'PRIVATE' && hasCompletePrivateContact(row)
@@ -201,7 +203,7 @@ function buildOrderDraft(row: QuickOrderRow, channel: QuickChannelOption): First
channelName: channel.channelName,
pricingTaxMode,
taxMode: 'BUY_DECLARATION',
customsInspection: false,
customsInspection: null,
destinationType: row.destinationType,
amazonWarehouseCode: row.amazonWarehouseCode,
shipmentId: '',
@@ -42,11 +42,18 @@ test('supports tenant profile updates and authenticated qualification downloads'
assert.match(storeSource, /enterpriseType: profile\.enterpriseType \?\? ''/)
})
test('uploads the three required qualification fields through one multipart package request', () => {
test('uploads the required license and omits the optional legal-person file when absent', () => {
assert.match(source, /url: '\/merchant\/v1\/files\/qualifications\/required-package'/)
assert.match(source, /data\.append\('businessLicense', businessLicense\)/)
assert.match(source, /data\.append\('legalPersonId', legalPersonId\)/)
assert.match(source, /params: \{ unifiedSocialCreditCode, replaceBusinessLicense, replaceLegalPersonId \}/)
assert.match(source, /if \(legalPersonId\) data\.append\('legalPersonId', legalPersonId\)/)
assert.match(source, /\.\.\.\(legalPersonId \? \{ replaceLegalPersonId: Boolean\(replaceLegalPersonId\) \} : \{\}\)/)
assert.match(source, /legalPersonIdQualificationId: number \| string \| null/)
})
test('uses the dedicated login-identifiers endpoint and keeps phone out of ordinary profile updates', () => {
assert.match(source, /url: '\/merchant\/v1\/iam\/profile\/login-identifiers'/)
assert.match(source, /ChangeMerchantLoginIdentifiersRequest/)
assert.match(source, /Pick<MerchantPersonalProfile, 'displayName' \| 'email'>/)
})
test('uses membershipId and full replacement arrays in authorization writes', () => {
@@ -65,10 +65,10 @@ export interface QualificationUploadResult {
export interface RequiredQualificationPackageUploadResult {
businessLicenseQualificationId: number | string
legalPersonIdQualificationId: number | string
legalPersonIdQualificationId: number | string | null
tenantStatus: MerchantTenantStatus
businessLicenseDocumentUrl: string
legalPersonIdDocumentUrl: string
legalPersonIdDocumentUrl: string | null
}
export interface MerchantRole {
@@ -119,6 +119,12 @@ export interface MerchantPersonalProfile {
email: string | null
}
export interface ChangeMerchantLoginIdentifiersRequest {
username: string
phone: string
currentPassword: string
}
export interface CreateMerchantMemberRequest {
username: string
password: string
@@ -163,10 +169,18 @@ export function getMerchantPersonalProfile() {
return apiRequest<MerchantPersonalProfile>({ method: 'GET', url: '/merchant/v1/iam/profile' })
}
export function updateMerchantPersonalProfile(payload: Pick<MerchantPersonalProfile, 'displayName' | 'phone' | 'email'>) {
export function updateMerchantPersonalProfile(payload: Pick<MerchantPersonalProfile, 'displayName' | 'email'>) {
return apiRequest<MerchantPersonalProfile>({ method: 'PUT', url: '/merchant/v1/iam/profile', data: payload })
}
export function updateMerchantLoginIdentifiers(payload: ChangeMerchantLoginIdentifiersRequest) {
return apiRequest<MerchantPersonalProfile>({
method: 'PUT',
url: '/merchant/v1/iam/profile/login-identifiers',
data: payload,
})
}
export function uploadMerchantQualification(
file: File,
qualificationType: MerchantQualificationType,
@@ -185,18 +199,22 @@ export function uploadMerchantQualification(
export function uploadMerchantRequiredQualificationPackage(
businessLicense: File,
legalPersonId: File,
legalPersonId: File | undefined,
unifiedSocialCreditCode: string,
replaceBusinessLicense = false,
replaceLegalPersonId = false,
replaceLegalPersonId?: boolean,
) {
const data = new FormData()
data.append('businessLicense', businessLicense)
data.append('legalPersonId', legalPersonId)
if (legalPersonId) data.append('legalPersonId', legalPersonId)
return apiRequest<RequiredQualificationPackageUploadResult>({
method: 'POST',
url: '/merchant/v1/files/qualifications/required-package',
params: { unifiedSocialCreditCode, replaceBusinessLicense, replaceLegalPersonId },
params: {
unifiedSocialCreditCode,
replaceBusinessLicense,
...(legalPersonId ? { replaceLegalPersonId: Boolean(replaceLegalPersonId) } : {}),
},
data,
})
}
+2
View File
@@ -3,7 +3,9 @@ export {
getCurrentMerchantPermissionCodes,
getMerchantPersonalProfile,
getTenantProfile,
updateMerchantLoginIdentifiers,
updateMerchantPersonalProfile,
type ChangeMerchantLoginIdentifiersRequest,
type MerchantPermissionResource,
} from './api/merchantIam'
export {
@@ -20,7 +20,7 @@ const store = useTenantAdministrationStore()
const auth = useAuthStore()
const companyApproved = computed(() => store.company.auditStatus === 'ACTIVE')
const auditStatusCopy = computed(() => ({
PROFILE_REQUIRED: { title: '请完善企业资质', detail: '上传营业执照和法人身份证并提交审核后,才能提交头程订单。' },
PROFILE_REQUIRED: { title: '请完善企业资质', detail: '上传营业执照并提交审核后,才能提交头程订单;法人身份证可按需补充。' },
PENDING: { title: '企业资料正在等待平台审核', detail: '审核期间仍可维护资料和保存订单草稿;审核通过前不能提交订单。' },
ACTIVE: { title: '企业资料已审核,下单资格已开通', detail: '您可以创建、保存并提交头程订单。' },
REJECTED: { title: '企业资料审核未通过', detail: '请根据平台反馈替换对应资质,必填文件齐全后可重新提交审核。' },
@@ -60,7 +60,7 @@ type QualificationDraftItem = QualificationTypeDefinition & {
const qualificationTypes: QualificationTypeDefinition[] = [
{ value: 'BUSINESS_LICENSE', label: '营业执照', description: '上传清晰完整的营业执照文件。', required: true },
{ value: 'LEGAL_PERSON_ID', label: '法人身份证', description: '正反面请合并为一个 PDF、JPG 或 PNG 文件。', required: true },
{ value: 'LEGAL_PERSON_ID', label: '法人身份证', description: '选填;如上传,正反面请合并为一个 PDF、JPG 或 PNG 文件。', required: false },
{ value: 'FREIGHT_FORWARDER_CERTIFICATE', label: '货代资质证书', description: '如有,可上传当前有效的货代资质证明。', required: false },
{ value: 'BANK_ACCOUNT_LICENSE', label: '银行开户许可证', description: '如有,可上传企业对公账户开户证明。', required: false },
]
@@ -83,14 +83,15 @@ const normalizedQualificationCertificateNo = computed(() => qualificationCertifi
const draftCreditCodeValid = computed(() => unifiedSocialCreditCodePattern.test(normalizedQualificationCertificateNo.value))
const businessLicenseFileSelected = computed(() => Boolean(qualificationDraftItem('BUSINESS_LICENSE')?.file))
const legalPersonIdFileSelected = computed(() => Boolean(qualificationDraftItem('LEGAL_PERSON_ID')?.file))
const requiredPackageChangeSelected = computed(() => businessLicenseFileSelected.value || legalPersonIdFileSelected.value)
const requiredPackageFilesSelected = computed(() => businessLicenseFileSelected.value && legalPersonIdFileSelected.value)
const requiredPackageChangeSelected = computed(() => businessLicenseFileSelected.value)
const requiredPackageFilesSelected = computed(() => businessLicenseFileSelected.value)
const qualificationCreditCodeReady = computed(() => requiredPackageChangeSelected.value
? requiredPackageFilesSelected.value && (companyCreditCodeValid.value || draftCreditCodeValid.value)
: companyCreditCodeValid.value)
const qualificationCreditCodeError = computed(() => {
if (qualificationCreditCodeReady.value) return ''
if (draftCreditCodeValid.value && !requiredPackageFilesSelected.value) return '统一社会信用代码必须随营业执照和法人身份证共同保存'
if (legalPersonIdFileSelected.value && !businessLicenseFileSelected.value) return '法人身份证需与本次营业执照一起保存'
if (draftCreditCodeValid.value && !requiredPackageFilesSelected.value) return '统一社会信用代码必须随营业执照共同保存'
return '请输入18位字母或数字组成的统一社会信用代码'
})
const canUploadQualification = computed(() => ['PROFILE_REQUIRED', 'REJECTED'].includes(store.company.auditStatus))
@@ -121,9 +122,11 @@ const qualificationUploadDisabledReason = computed(() => {
const selectedQualificationCount = computed(() => qualificationDrafts.filter((item) => Boolean(item.file)).length)
const canSaveQualificationBatch = computed(() => canUploadQualification.value
&& selectedQualificationCount.value > 0
&& (!legalPersonIdFileSelected.value || businessLicenseFileSelected.value)
&& requiredPackageComplete.value)
const qualificationSaveDisabledReason = computed(() => {
if (!canUploadQualification.value) return qualificationUploadDisabledReason.value
if (legalPersonIdFileSelected.value && !businessLicenseFileSelected.value) return '法人身份证需与本次营业执照一起保存'
if (!requiredPackageComplete.value) return `请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`
if (!selectedQualificationCount.value) return '当前没有需要保存的变更'
return ''
@@ -141,7 +144,7 @@ const qualificationReviewDisabledReason = computed(() => {
return missing.length ? `请先上传:${missing.join('、')}` : ''
})
const qualificationPanelHint = computed(() => canUploadQualification.value
? '营业执照、法人身份证为必填;货代资质证书、银行开户许可证可选填。文件保存后需另行提交审核。'
? '营业执照为必填;法人身份证、货代资质证书、银行开户许可证可选填。文件保存后需另行提交审核。'
: qualificationUploadDisabledReason.value)
const profileFormRules: FormRules<TenantCompanyProfile> = {
companyName: [{ required: true, whitespace: true, message: '请输入公司名称', trigger: 'blur' }],
@@ -340,7 +343,8 @@ async function submitQualification() {
ElMessage.warning(`请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`)
return
}
const optionalItems = qualificationDrafts.filter((item) => !item.required && item.file)
const optionalItems = qualificationDrafts.filter((item) => !item.required && item.value !== 'LEGAL_PERSON_ID' && item.file)
const includedLegalPersonId = legalPersonIdFileSelected.value
qualificationSubmitting.value = true
let uploadedCount = 0
const failures: string[] = []
@@ -348,7 +352,7 @@ async function submitQualification() {
if (requiredPackageChangeSelected.value) {
const businessLicense = qualificationDraftItem('BUSINESS_LICENSE')
const legalPersonId = qualificationDraftItem('LEGAL_PERSON_ID')
for (const item of [businessLicense, legalPersonId]) {
for (const item of [businessLicense, ...(legalPersonId?.file ? [legalPersonId] : [])]) {
if (!item) continue
item.error = ''
item.uploadState = 'uploading'
@@ -356,12 +360,13 @@ async function submitQualification() {
try {
await uploadMerchantRequiredQualificationPackage(
businessLicense?.file as File,
legalPersonId?.file as File,
legalPersonId?.file || undefined,
normalizedQualificationCertificateNo.value,
Boolean(replaceableQualification('BUSINESS_LICENSE')),
Boolean(replaceableQualification('LEGAL_PERSON_ID')),
legalPersonId?.file ? Boolean(replaceableQualification('LEGAL_PERSON_ID')) : undefined,
)
for (const item of [businessLicense, legalPersonId]) {
const uploadedRequiredItems = [businessLicense, ...(legalPersonId?.file ? [legalPersonId] : [])]
for (const item of uploadedRequiredItems) {
if (!item) continue
releaseQualificationPreview(item)
item.file = null
@@ -371,11 +376,11 @@ async function submitQualification() {
item.inputVersion += 1
item.uploadState = 'uploaded'
}
uploadedCount += 2
uploadedCount += uploadedRequiredItems.filter(Boolean).length
} catch (error) {
const message = qualificationUploadErrorMessage(error)
if (businessLicense) { businessLicense.error = message; businessLicense.uploadState = 'error' }
if (legalPersonId) { legalPersonId.error = message; legalPersonId.uploadState = 'error' }
if (legalPersonId?.file) { legalPersonId.error = message; legalPersonId.uploadState = 'error' }
failures.push(`必填资质包:${message}`)
}
}
@@ -414,7 +419,9 @@ async function submitQualification() {
return
}
qualificationOpen.value = false
if (refreshed) ElMessage.success('营业执照、统一社会信用代码和法人身份证已完整保存,尚未提交审核')
if (refreshed) ElMessage.success(includedLegalPersonId
? '营业执照、统一社会信用代码和法人身份证已保存,尚未提交审核'
: '营业执照和统一社会信用代码已保存,尚未提交审核')
else ElMessage.warning('资质文件已保存,但最新状态刷新失败,请点击“重新加载”确认状态')
} finally {
qualificationSubmitting.value = false
@@ -582,7 +589,7 @@ function closeQualificationPreview() {
>
<div class="qualification-dialog-intro">
<strong>企业认证必填业务项</strong>
<span>营业执照、18位统一社会信用代码、法人身份证需共同完整保存;其余两项可按需上传。</span>
<span>营业执照和18位统一社会信用代码需共同保存;法人身份证及其他资质可按需上传。</span>
</div>
<div class="qualification-upload-grid">
<article v-for="item in qualificationDrafts" :key="item.value" class="qualification-upload-item">
@@ -51,7 +51,7 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /:close-on-press-escape="!qualificationSubmitting"/)
assert.match(profileSource, /:show-close="!qualificationSubmitting"/)
assert.match(profileSource, /const requiredPackageComplete = computed/)
assert.match(profileSource, /营业执照、18位统一社会信用代码、法人身份证需共同完整保存/)
assert.match(profileSource, /营业执照和18位统一社会信用代码需共同保存;法人身份证及其他资质可按需上传/)
assert.match(profileSource, /已登记的统一社会信用代码不可直接修改/)
assert.match(profileSource, /companyCreditCodeValid\.value/)
assert.doesNotMatch(profileSource, /<el-select v-model="qualificationDraft\.type"/)
@@ -68,13 +68,14 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /const valid = await profileFormRef\.value\?\.validate\(\)/)
assert.match(profileSource, /const requiredQualificationTypes = qualificationTypes\.filter\(\(type\) => type\.required\)/)
assert.match(profileSource, /货代资质证书'.*required: false/)
assert.match(profileSource, /法人身份证'.*required: false/)
assert.match(profileSource, /银行开户许可证'.*required: false/)
assert.match(profileSource, /requiredQualificationTypes\.every/)
assert.doesNotMatch(profileSource, /四类必填资质/)
assert.match(profileSource, /qualificationUploadErrorMessage\(error\)/)
assert.match(profileSource, /submitTenantQualificationReview/)
assert.match(profileSource, /提交企业审核/)
assert.match(profileSource, /营业执照、统一社会信用代码和法人身份证已完整保存,尚未提交审核/)
assert.match(profileSource, /营业执照和统一社会信用代码已保存,尚未提交审核/)
assert.match(profileSource, /资质文件已保存,但最新状态刷新失败/)
assert.match(profileSource, /企业资料已提交审核,但最新状态刷新失败/)
assert.match(profileSource, /qualificationStatusMeta/)