Merge branch 'dev' of http://8.163.30.128:10082/html/client-web into dev
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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'/)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,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>登录手机号<\/dt><dd>\{\{ auth\.profile\.phone/)
|
||||
assert.doesNotMatch(viewSource, /<dt>联系电话<\/dt><dd>\{\{ 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/)
|
||||
|
||||
@@ -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 />
|
||||
@@ -210,12 +267,26 @@ async function changePassword() {
|
||||
<dl v-else class="account-info-grid">
|
||||
<div><dt>用户名</dt><dd>{{ auth.profile.username }}</dd></div>
|
||||
<div><dt>姓名</dt><dd>{{ auth.profile.realName }}</dd></div>
|
||||
<div><dt>联系电话</dt><dd>{{ auth.profile.phone || '未填写' }}</dd></div>
|
||||
<div><dt>登录手机号</dt><dd>{{ auth.profile.phone || '未填写' }}</dd></div>
|
||||
<div><dt>邮箱</dt><dd>{{ auth.profile.email || '未填写' }}</dd></div>
|
||||
<div><dt>所属公司</dt><dd>{{ auth.profile.tenantName }}</dd></div>
|
||||
</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>
|
||||
|
||||
@@ -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<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) {
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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\)\)/)
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const submitted = ref(false)
|
||||
const submittedAt = ref('')
|
||||
const form = reactive<RegisterForm>({ username: '', companyName: '', displayName: '', legalPerson: '', legalPhone: '', contactEmail: '', countryCode: 'CN', address: '', password: '', agreement: false })
|
||||
const form = reactive<RegisterForm>({ username: '', companyName: '', displayName: '', legalPerson: '', phone: '', contactEmail: '', countryCode: 'CN', address: '', password: '', agreement: false })
|
||||
|
||||
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' },
|
||||
@@ -46,9 +46,9 @@ const rules: FormRules<RegisterForm> = {
|
||||
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() {
|
||||
<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>
|
||||
@@ -131,7 +131,7 @@ async function submit() {
|
||||
<h2 id="contact-section-title">联系信息</h2>
|
||||
<div class="register-form-grid">
|
||||
<el-form-item label="联系人" prop="displayName" required><el-input v-model="form.displayName" maxlength="64" placeholder="请输入联系人姓名" /></el-form-item>
|
||||
<el-form-item label="法人手机号" prop="legalPhone" required><el-input v-model="form.legalPhone" type="tel" autocomplete="tel" inputmode="numeric" maxlength="11" placeholder="请输入 11 位手机号" :prefix-icon="Phone" /></el-form-item>
|
||||
<el-form-item label="登录手机号" prop="phone" required><el-input v-model="form.phone" type="tel" autocomplete="tel" inputmode="numeric" maxlength="11" placeholder="请输入 11 位手机号" :prefix-icon="Phone" /><span class="register-security-note">注册时同时初始化公司联系电话;注册后两者可分别修改,互不联动。</span></el-form-item>
|
||||
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="form.contactEmail" type="email" autocomplete="email" maxlength="128" placeholder="选填,用于接收业务通知" :prefix-icon="Mail" /></el-form-item>
|
||||
<el-form-item label="企业地址" prop="address"><el-input v-model="form.address" maxlength="255" placeholder="选填,最多 255 个字符" /></el-form-item>
|
||||
</div>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
@@ -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<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/orders/first-leg/addresses', params: { addressType } })
|
||||
}
|
||||
|
||||
export function createMerchantOrderAddress(payload: CustomerOrderAddressSaveRequest) {
|
||||
return apiRequest<number | string>({ method: 'POST', url: '/merchant/v1/orders/first-leg/addresses', data: payload })
|
||||
}
|
||||
|
||||
export function updateMerchantOrderAddress(id: number | string, payload: CustomerOrderAddressSaveRequest) {
|
||||
return apiRequest<void>({ method: 'PUT', url: `/merchant/v1/orders/first-leg/addresses/${id}`, data: payload })
|
||||
}
|
||||
|
||||
export function disableMerchantOrderAddress(id: number | string) {
|
||||
return apiRequest<void>({ 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<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',
|
||||
|
||||
@@ -117,7 +117,7 @@ defineEmits<{
|
||||
</div>
|
||||
<div><dt>实际重量</dt><dd>{{ formatInboundMeasure(props.data.latestFee.actualWeightKg, 'kg') }}</dd></div>
|
||||
<div><dt>实际体积</dt><dd>{{ formatInboundMeasure(props.data.latestFee.actualVolumeCbm, 'm³') }}</dd></div>
|
||||
<div><dt>折算量</dt><dd>{{ formatInboundMeasure(props.data.latestFee.convertedQuantity, props.data.latestFee.chargeUnit) }}</dd></div>
|
||||
<div><dt>体积重量</dt><dd>{{ formatInboundMeasure(props.data.latestFee.convertedQuantity, props.data.latestFee.chargeUnit) }}</dd></div>
|
||||
<div><dt>最终计费量</dt><dd>{{ formatInboundMeasure(props.data.latestFee.chargeableQuantity, props.data.latestFee.chargeUnit) }}</dd></div>
|
||||
<div><dt>单价</dt><dd>{{ formatInboundMoney(props.data.latestFee.unitPrice, props.data.latestFee.currency) }} / {{ props.data.latestFee.chargeUnit }}</dd></div>
|
||||
<div><dt>基础费用</dt><dd>{{ formatInboundMoney(props.data.latestFee.subtotalAmount, props.data.latestFee.currency) }}</dd></div>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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\\.`))
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
@@ -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,
|
||||
|
||||
@@ -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<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('第 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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="一键导入" description="批量导入头程订单" /></template>
|
||||
@@ -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, /<table class="cargo-edit-table">/)
|
||||
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)"/)
|
||||
|
||||
@@ -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<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 batchErrorReportDownloading = ref(false)
|
||||
const batchImportResult = ref<MerchantBatchImportResponse | null>(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<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 = {
|
||||
@@ -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<FirstLegOrderDraft>(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]) => `<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
|
||||
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<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
|
||||
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) {
|
||||
<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>
|
||||
@@ -1753,6 +1864,9 @@ async function searchOrders(model: QueryModel) {
|
||||
<el-form-item label="履约类型" prop="fulfillmentType" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.fulfillmentType" class="segmented-options"><el-radio-button value="STANDARD">普通头程</el-radio-button><el-radio-button value="ONE_CLICK_DROPSHIP">一件代发</el-radio-button></el-radio-group><small v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" class="field-guidance">测量完成后不办理国内库存入库,将直接推送 PDA 合托。</small></el-form-item>
|
||||
<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>
|
||||
@@ -1777,7 +1891,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="woodenCargoLines.length && form.customsInspection !== true" class="field-guidance field-guidance--error">第 {{ woodenCargoLines.join('、') }} 条货物已勾选木制品,提交前“是否商检”必须选择“是”。</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>
|
||||
@@ -1789,7 +1903,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>
|
||||
@@ -1810,16 +1924,16 @@ 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">
|
||||
<table class="cargo-edit-table">
|
||||
<thead><tr>
|
||||
<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>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>材质</th><th class="cargo-switch-column">危险品</th><th class="cargo-switch-column">木制品<small>勾选后须商检</small></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 class="cargo-dimensions-column">产品规格 长×宽×高</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>
|
||||
@@ -1827,11 +1941,12 @@ async function searchOrders(model: QueryModel) {
|
||||
<td><el-form-item :prop="`items.${index}.cargoNameCn`" required :rules="requiredTextRules('请输入中文品名')"><el-input v-model="item.cargoNameCn" maxlength="128" placeholder="中文品名" /></el-form-item></td>
|
||||
<td class="cargo-image-column"><div class="cargo-table-image"><img v-if="item.productImagePreviewUrl" :src="item.productImagePreviewUrl" :alt="`${item.cargoNameCn || `货物 ${index + 1}`}产品图片`" /><el-upload v-else :show-file-list="false" accept=".jpg,.jpeg,.png,image/jpeg,image/png" :disabled="item.productImageUploading" :http-request="(options: UploadRequestOptions) => uploadCargoProductImage(item, options)"><el-button text :loading="item.productImageUploading" aria-label="上传图片"><ImagePlus :size="17" /></el-button></el-upload><el-button v-if="item.productImagePreviewUrl" class="cargo-image-remove" text type="danger" aria-label="移除图片" @click="removeCargoProductImage(item)"><Trash2 :size="12" /></el-button></div></td>
|
||||
<td><el-input v-model="item.cargoNameEn" maxlength="128" placeholder="English Name" /></td><td><el-input v-model="item.hsCode" maxlength="20" placeholder="HS Code" /></td><td><el-input v-model="item.customsHsCode" maxlength="20" placeholder="HS Code" /></td>
|
||||
<td><el-form-item :prop="`items.${index}.packageType`" required :rules="requiredSelectRules('请选择包装类型')"><el-select v-model="item.packageType"><el-option label="纸箱" value="CARTON" /><el-option label="托盘" value="PALLET" /><el-option label="木箱" value="WOODEN_CASE" /></el-select></el-form-item></td>
|
||||
<td><el-form-item :prop="`items.${index}.packageType`" required :rules="requiredSelectRules('请选择包装类型')"><el-select v-model="item.packageType"><el-option label="纸箱" value="CARTON" /><el-option label="托盘" value="PALLET" /><el-option label="木箱" value="WOODEN_CASE" /></el-select></el-form-item></td><td><el-input v-model="item.material" maxlength="128" placeholder="材质" /></td>
|
||||
<td class="cargo-switch-column"><el-switch v-model="item.dangerousGoods" :aria-label="`第 ${index + 1} 条货物是否危险品`" /></td><td class="cargo-switch-column"><div class="cargo-switch-cell"><el-switch v-model="item.woodenProduct" :aria-label="`第 ${index + 1} 条货物是否木制品`" /><small v-if="item.woodenProduct && form.customsInspection !== true">第 {{ index + 1 }} 条须商检</small></div></td>
|
||||
<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><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.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><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>
|
||||
@@ -1877,6 +1992,36 @@ 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="Excel 批量创建订单草稿" width="680px" destroy-on-close append-to-body>
|
||||
<div class="batch-import-dialog">
|
||||
<el-alert title="使用服务端正式模板创建订单草稿" description="仅支持 .xlsx,文件不超过 5MB、最多 50 行;导入成功的数据只创建草稿,不会直接提交审核。模板包含 cargo.woodenProduct(true/false);任一货物填写 true 时,customsInspection 必须填写 true。" type="info" :closable="false" show-icon />
|
||||
<el-alert v-if="batchImportError" title="批量导入处理失败" :description="batchImportError" type="error" :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>
|
||||
<div v-if="batchImportResult.errorReportFileId" class="batch-import-result-actions">
|
||||
<el-button type="danger" plain :loading="batchErrorReportDownloading" @click="downloadBatchErrorReport"><Download :size="16" />下载 Excel 错误报告</el-button>
|
||||
</div>
|
||||
<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 v-if="batchImportResult?.result.succeeded && auth.hasPermission('order:first-leg:list')" :disabled="batchImportSubmitting" @click="viewImportedDrafts">查看订单草稿</el-button><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">
|
||||
@@ -1906,7 +2051,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>
|
||||
@@ -1975,6 +2120,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
|
||||
@@ -2045,6 +2191,14 @@ 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-actions { display: flex; margin-top: 4px; }
|
||||
.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; }
|
||||
@@ -2215,7 +2369,9 @@ async function searchOrders(model: QueryModel) {
|
||||
.cargo-number-column { width: 92px !important; }
|
||||
.cargo-dimensions-column { width: 240px !important; }
|
||||
.cargo-operation-column { width: 76px !important; text-align: center !important; }
|
||||
.cargo-danger-cell { text-align: center; vertical-align: middle !important; }
|
||||
.cargo-switch-column { width: 112px !important; text-align: center !important; vertical-align: middle !important; }
|
||||
.cargo-switch-cell { display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 4px; }
|
||||
.cargo-switch-cell small { color: #b54708; font-size: 9px; line-height: 1.25; white-space: normal; }
|
||||
.cargo-table-image { position: relative; display: flex; width: 34px; height: 34px; align-items: center; justify-content: center; margin: 0 auto; border: 1px dashed #d6dce5; border-radius: 4px; color: #788493; }
|
||||
.cargo-table-image img { width: 100%; height: 100%; border-radius: 3px; object-fit: contain; }
|
||||
.cargo-table-image :deep(.el-button) { width: 30px; height: 30px; padding: 0; }
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
@@ -1,17 +1,153 @@
|
||||
import { overseasApiRequest } from '@/api/overseasHttp'
|
||||
|
||||
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
||||
export interface DeliveryRow { id: string; deliveryNo: string; outboundNo: string; dispatchNo: string; overseasWarehouseName: string; status: string; waybillNo: string; deliveryMethod: string; carrierName: string; vehiclePlate: string; driverName: string; palletCount: number; packageCount: number; totalWeightKg: number; totalVolumeCbm: number; plannedDeliveryAt: string; expectedArrivalAt: string; signedAt: string; createdAt: string }
|
||||
export interface DeliveryQuery { page: number; size: number; documentNo?: string; warehouseId?: string; status?: string; dispatchNo?: string; waybillNo?: string; vehiclePlate?: string; driverName?: string; deliveryMethod?: string; plannedFrom?: string; plannedTo?: string }
|
||||
export interface DeliveryTransferPayload { dispatchNo: string; idempotencyKey: string; plannedDeliveryAt: string; deliveryMethod: string; pickupMethod: string; loadingType: string; waybillNo?: string; carrierName?: string; vehicleType?: string; vehicleSize?: string; driverName?: string; driverPhone?: string; vehiclePlate?: string; appointmentAccount?: string; sellerName?: string; remark?: string; fees: Array<{ feeType: string; currency: string; amount: number; remark?: string }> }
|
||||
export interface PageResult<T> {
|
||||
records: T[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface DeliveryRow {
|
||||
id: string
|
||||
deliveryNo: string
|
||||
outboundNo: string | null
|
||||
dispatchNo: string
|
||||
overseasWarehouseName: string | null
|
||||
status: string
|
||||
waybillNo: string | null
|
||||
deliveryMethod: string
|
||||
carrierName: string | null
|
||||
vehiclePlate: string | null
|
||||
driverName: string | null
|
||||
palletCount: number
|
||||
packageCount: number
|
||||
totalWeightKg: number | null
|
||||
totalVolumeCbm: number | null
|
||||
plannedDeliveryAt: string
|
||||
expectedArrivalAt: string | null
|
||||
signedAt: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export interface DeliveryOrderFee {
|
||||
id?: string
|
||||
feeType: string
|
||||
currency: string
|
||||
amount: number
|
||||
remark?: string | null
|
||||
}
|
||||
|
||||
export interface DeliveryOrderItem {
|
||||
id?: string
|
||||
palletNo?: string | null
|
||||
inboundCode?: string | null
|
||||
skuCode?: string | null
|
||||
productName?: string | null
|
||||
packageCount?: number | null
|
||||
weightKg?: number | null
|
||||
volumeCbm?: number | null
|
||||
}
|
||||
|
||||
export interface DeliveryOrderDetail extends DeliveryRow {
|
||||
pickupMethod: string
|
||||
loadingType: string
|
||||
vehicleType: string | null
|
||||
vehicleSize: string | null
|
||||
driverPhone: string | null
|
||||
appointmentAccount: string | null
|
||||
sellerName: string | null
|
||||
remark: string | null
|
||||
fees: DeliveryOrderFee[]
|
||||
items?: DeliveryOrderItem[] | PageResult<DeliveryOrderItem>
|
||||
updatedAt?: string | null
|
||||
}
|
||||
|
||||
interface DeliveryOrderDetailResponse {
|
||||
order: Omit<DeliveryOrderDetail, 'fees' | 'items' | 'updatedAt'>
|
||||
skus: DeliveryOrderItem[]
|
||||
fees: DeliveryOrderFee[]
|
||||
operations: Array<{
|
||||
id: string
|
||||
operatedAt: string
|
||||
operationNode: string
|
||||
operatorName: string
|
||||
result: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface DeliveryTransferResult {
|
||||
dispatchNo: string
|
||||
outboundNo: string
|
||||
outboundStatus: string
|
||||
deliveryNo: string
|
||||
deliveryStatus: string
|
||||
palletCount: number
|
||||
packageCount: number
|
||||
replayed: boolean
|
||||
}
|
||||
|
||||
export interface DeliveryQuery {
|
||||
page: number
|
||||
size: number
|
||||
documentNo?: string
|
||||
warehouseId?: string
|
||||
status?: string
|
||||
dispatchNo?: string
|
||||
waybillNo?: string
|
||||
vehiclePlate?: string
|
||||
driverName?: string
|
||||
deliveryMethod?: string
|
||||
plannedFrom?: string
|
||||
plannedTo?: string
|
||||
}
|
||||
|
||||
export interface DeliveryTransferPayload {
|
||||
dispatchNo: string
|
||||
idempotencyKey: string
|
||||
plannedDeliveryAt: string
|
||||
deliveryMethod: string
|
||||
pickupMethod: string
|
||||
loadingType: string
|
||||
waybillNo?: string
|
||||
carrierName?: string
|
||||
vehicleType?: string
|
||||
vehicleSize?: string
|
||||
driverName?: string
|
||||
driverPhone?: string
|
||||
vehiclePlate?: string
|
||||
appointmentAccount?: string
|
||||
sellerName?: string
|
||||
remark?: string
|
||||
fees: DeliveryOrderFee[]
|
||||
}
|
||||
|
||||
export function listDeliveryOrders(params: DeliveryQuery) {
|
||||
return overseasApiRequest<PageResult<DeliveryRow>>({ method: 'GET', url: '/merchant/v1/last-leg/delivery-orders', params })
|
||||
return overseasApiRequest<PageResult<DeliveryRow>>({
|
||||
method: 'GET',
|
||||
url: '/merchant/v1/last-leg/delivery-orders',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getDeliveryOrder(deliveryNo: string) {
|
||||
return overseasApiRequest<Record<string, unknown>>({ method: 'GET', url: `/merchant/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}`, params: { page: 1, size: 200 } })
|
||||
return overseasApiRequest<DeliveryOrderDetailResponse>({
|
||||
method: 'GET',
|
||||
url: `/merchant/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}`,
|
||||
params: { page: 1, size: 200 },
|
||||
}).then(({ order, skus, fees }) => ({
|
||||
...order,
|
||||
fees,
|
||||
items: skus,
|
||||
}))
|
||||
}
|
||||
|
||||
export function createDeliveryOrder(payload: DeliveryTransferPayload) {
|
||||
const { dispatchNo, ...request } = payload
|
||||
return overseasApiRequest<Record<string, unknown>>({ method: 'POST', url: '/merchant/v1/last-leg/delivery-orders', params: { dispatchNo }, data: request })
|
||||
return overseasApiRequest<DeliveryTransferResult>({
|
||||
method: 'POST',
|
||||
url: '/merchant/v1/last-leg/delivery-orders',
|
||||
params: { dispatchNo },
|
||||
data: request,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import ts from 'typescript'
|
||||
|
||||
const apiSource = readFileSync(new URL('./api/deliveryOrders.ts', import.meta.url), 'utf8')
|
||||
const routesSource = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
|
||||
const listSource = readFileSync(new URL('./views/DeliveryOrdersView.vue', import.meta.url), 'utf8')
|
||||
const detailSource = readFileSync(new URL('./views/DeliveryOrderDetailView.vue', import.meta.url), 'utf8')
|
||||
const createSource = readFileSync(new URL('./views/DeliveryOrderCreateView.vue', import.meta.url), 'utf8')
|
||||
const permissionSource = readFileSync(new URL('../tenant-management/contracts.ts', import.meta.url), 'utf8')
|
||||
|
||||
async function importTypeScriptModule(source) {
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText
|
||||
return import(`data:text/javascript;base64,${Buffer.from(compiled).toString('base64')}`)
|
||||
}
|
||||
|
||||
test('uses only authenticated merchant delivery endpoints without customer scope or mock fallback', () => {
|
||||
assert.match(apiSource, /method: 'GET'[\s\S]*url: '\/merchant\/v1\/last-leg\/delivery-orders'/)
|
||||
assert.match(apiSource, /delivery-orders\/\$\{encodeURIComponent\(deliveryNo\)\}/)
|
||||
assert.match(apiSource, /method: 'POST'[\s\S]*url: '\/merchant\/v1\/last-leg\/delivery-orders'/)
|
||||
assert.match(apiSource, /const \{ dispatchNo, \.\.\.request \} = payload/)
|
||||
assert.match(apiSource, /params: \{ dispatchNo \}/)
|
||||
assert.match(apiSource, /overseasApiRequest<DeliveryOrderDetailResponse>/)
|
||||
assert.match(apiSource, /\.then\(\(\{ order, skus, fees \}\) => \(\{[\s\S]*\.\.\.order,[\s\S]*fees,[\s\S]*items: skus/)
|
||||
assert.match(apiSource, /overseasApiRequest<DeliveryTransferResult>/)
|
||||
assert.doesNotMatch(apiSource, /customerId|tenantId|Record<string, unknown>|mock|fallback/i)
|
||||
})
|
||||
|
||||
test('keeps list, read-only detail and create permissions independent in active routes', () => {
|
||||
assert.match(routesSource, /path: 'orders\/delivery'[\s\S]*permission: 'merchant:warehouse:delivery:list'[\s\S]*navigation:/)
|
||||
assert.match(routesSource, /path: 'orders\/delivery\/create'[\s\S]*DeliveryOrderCreateView\.vue[\s\S]*permission: 'merchant:warehouse:delivery:create'/)
|
||||
assert.match(routesSource, /path: 'orders\/delivery\/:deliveryNo'[\s\S]*DeliveryOrderDetailView\.vue[\s\S]*props: true[\s\S]*permission: 'merchant:warehouse:delivery:list'/)
|
||||
assert.match(routesSource, /orders-delivery-create[\s\S]*keepAlive: false/)
|
||||
assert.match(routesSource, /orders-delivery-detail[\s\S]*keepAlive: false/)
|
||||
})
|
||||
|
||||
test('renders the operation column first and covers loading, empty, error and pagination states', () => {
|
||||
const operationIndex = listSource.indexOf('label="操作"')
|
||||
const deliveryNumberIndex = listSource.indexOf('label="派送单号"')
|
||||
assert.ok(operationIndex >= 0 && operationIndex < deliveryNumberIndex)
|
||||
assert.match(listSource, /v-loading="loading"/)
|
||||
assert.match(listSource, /empty-text="暂无派送单"/)
|
||||
assert.match(listSource, /delivery-state--error/)
|
||||
assert.match(listSource, /@click="loadOrders">重试/)
|
||||
assert.match(listSource, /listDeliveryOrders\(buildQuery\(\)\)/)
|
||||
assert.match(listSource, /<el-pagination/)
|
||||
assert.doesNotMatch(listSource, /row\.id[^a-zA-Z]|deliveryNo \|\| row\.id|customerId|tenantId/)
|
||||
})
|
||||
|
||||
test('shows the create button only with the independent create permission', () => {
|
||||
assert.match(listSource, /auth\.hasPermission\('merchant:warehouse:delivery:create'\)/)
|
||||
assert.match(listSource, /<el-button v-if="canCreate" type="primary" @click="openCreate"/)
|
||||
assert.doesNotMatch(listSource, /hasPermission\('merchant:warehouse:delivery:list'\)[\s\S]{0,120}创建派送单/)
|
||||
})
|
||||
|
||||
test('loads a typed detail route by delivery number and never exposes an internal id fallback', () => {
|
||||
assert.match(detailSource, /defineProps<\{ deliveryNo: string \}>/)
|
||||
assert.match(detailSource, /getDeliveryOrder\(props\.deliveryNo\)/)
|
||||
assert.match(detailSource, /派送单详情加载失败/)
|
||||
for (const heading of ['订单信息', '运输与预约', '货物明细', '费用明细']) assert.match(detailSource, new RegExp(heading))
|
||||
assert.doesNotMatch(detailSource, /detail\?\.id|deliveryNo \|\| detail\?\.id|customerId|tenantId/)
|
||||
})
|
||||
|
||||
test('submits the complete create contract with stable retry idempotency and recoverable failures', () => {
|
||||
for (const model of ['dispatchNo', 'plannedDeliveryAt', 'deliveryMethod', 'pickupMethod', 'loadingType', 'waybillNo', 'carrierName', 'vehicleType', 'vehicleSize', 'driverName', 'driverPhone', 'vehiclePlate', 'appointmentAccount', 'sellerName', 'remark']) {
|
||||
assert.match(createSource, new RegExp(`v-model="form\\.${model}"`))
|
||||
}
|
||||
assert.match(createSource, /if \(!pendingIdempotencyKey\) pendingIdempotencyKey = crypto\.randomUUID\(\)/)
|
||||
assert.match(createSource, /createDeliveryOrder\(requestPayload\(\)\)/)
|
||||
assert.match(createSource, /fees: form\.fees\.map/)
|
||||
assert.match(createSource, /if \(form\.fees\.length === 0\)/)
|
||||
assert.match(createSource, /请至少添加一条费用明细/)
|
||||
for (const value of ['FBA_PICKUP', 'CUSTOMER_PICKUP', 'TRUCK_PALLET', 'TRUCK_FLOOR', 'EXPRESS', 'OUTSOURCED', 'DEDICATED_TRUCK', 'LTL']) {
|
||||
assert.match(createSource, new RegExp(`value="${value}"`))
|
||||
}
|
||||
for (const value of ['ONE_PICKUP_ONE_DROP', 'ONE_PICKUP_MULTI_DROP', 'MULTI_PICKUP_ONE_DROP', 'MULTI_PICKUP_MULTI_DROP', 'WAREHOUSE_PICKUP', 'PALLET', 'FLOOR', 'MIXED']) {
|
||||
assert.match(createSource, new RegExp(`value="${value}"`))
|
||||
}
|
||||
assert.doesNotMatch(createSource, /value="(?:TRUCK|COURIER|SELF_PICKUP|DOOR_PICKUP|LOOSE_CARTON)"/)
|
||||
assert.match(createSource, /submitError\.value = error instanceof Error/)
|
||||
assert.match(createSource, /useUnsavedWorkRegistration/)
|
||||
assert.doesNotMatch(createSource, /customerId|tenantId|mock|fallback/i)
|
||||
})
|
||||
|
||||
test('maps both merchant delivery permissions independently', async () => {
|
||||
assert.match(permissionSource, /'merchant:warehouse:delivery:list': \['merchant:warehouse:delivery:list'\]/)
|
||||
assert.match(permissionSource, /'merchant:warehouse:delivery:create': \['merchant:warehouse:delivery:create'\]/)
|
||||
const contracts = await importTypeScriptModule(permissionSource)
|
||||
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:list', new Set(['merchant:warehouse:delivery:list'])), true)
|
||||
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:list', new Set(['merchant:warehouse:delivery:create'])), false)
|
||||
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:create', new Set(['merchant:warehouse:delivery:create'])), true)
|
||||
assert.equal(contracts.hasMappedMerchantPermission('merchant:warehouse:delivery:create', new Set(['merchant:warehouse:delivery:list'])), false)
|
||||
})
|
||||
@@ -29,5 +29,20 @@ export const orderManagementModule = {
|
||||
meta: { title: '派送订单列表', description: '查询海外仓派送订单', permission: 'merchant:warehouse:delivery:list' },
|
||||
navigation: { key: 'orders-delivery', icon: Truck },
|
||||
},
|
||||
{
|
||||
path: 'orders/delivery/create',
|
||||
name: 'orders-delivery-create',
|
||||
component: () => import('./views/DeliveryOrderCreateView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '创建派送单', description: '从排车单创建海外仓派送单', permission: 'merchant:warehouse:delivery:create', keepAlive: false },
|
||||
},
|
||||
{
|
||||
path: 'orders/delivery/:deliveryNo',
|
||||
name: 'orders-delivery-detail',
|
||||
component: () => import('./views/DeliveryOrderDetailView.vue'),
|
||||
props: true,
|
||||
release: 'active',
|
||||
meta: { title: '派送单详情', description: '查看本企业海外仓派送单', permission: 'merchant:warehouse:delivery:list', keepAlive: false },
|
||||
},
|
||||
],
|
||||
} satisfies ClientModuleManifest
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowLeft, Plus, Trash2 } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { useUnsavedWorkRegistration } from '@/navigation/workspace-tabs/unsavedWork'
|
||||
import { createDeliveryOrder, type DeliveryOrderFee, type DeliveryTransferPayload } from '../api/deliveryOrders'
|
||||
|
||||
type DeliveryCreateForm = Omit<DeliveryTransferPayload, 'idempotencyKey'>
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const submitError = ref('')
|
||||
const submitted = ref(false)
|
||||
let pendingIdempotencyKey = ''
|
||||
|
||||
const form = reactive<DeliveryCreateForm>({
|
||||
dispatchNo: '',
|
||||
plannedDeliveryAt: '',
|
||||
deliveryMethod: '',
|
||||
pickupMethod: '',
|
||||
loadingType: '',
|
||||
waybillNo: '',
|
||||
carrierName: '',
|
||||
vehicleType: '',
|
||||
vehicleSize: '',
|
||||
driverName: '',
|
||||
driverPhone: '',
|
||||
vehiclePlate: '',
|
||||
appointmentAccount: '',
|
||||
sellerName: '',
|
||||
remark: '',
|
||||
fees: [],
|
||||
})
|
||||
|
||||
const rules: FormRules<DeliveryCreateForm> = {
|
||||
dispatchNo: [{ required: true, message: '请输入排车单号', trigger: 'blur' }],
|
||||
plannedDeliveryAt: [{ required: true, message: '请选择计划派送时间', trigger: 'change' }],
|
||||
deliveryMethod: [{ required: true, message: '请选择派送方式', trigger: 'change' }],
|
||||
pickupMethod: [{ required: true, message: '请选择提货方式', trigger: 'change' }],
|
||||
loadingType: [{ required: true, message: '请选择装载类型', trigger: 'change' }],
|
||||
}
|
||||
|
||||
const dirty = computed(() => !submitted.value && Boolean(
|
||||
form.dispatchNo || form.plannedDeliveryAt || form.deliveryMethod || form.pickupMethod || form.loadingType
|
||||
|| form.waybillNo || form.carrierName || form.vehicleType || form.vehicleSize || form.driverName
|
||||
|| form.driverPhone || form.vehiclePlate || form.appointmentAccount || form.sellerName || form.remark || form.fees.length,
|
||||
))
|
||||
|
||||
useUnsavedWorkRegistration({
|
||||
key: 'delivery-order-create-form',
|
||||
routeKey: 'orders-delivery-create',
|
||||
label: '创建派送单',
|
||||
dirty,
|
||||
})
|
||||
|
||||
function optional(value: string | undefined) {
|
||||
const normalized = value?.trim()
|
||||
return normalized || undefined
|
||||
}
|
||||
|
||||
function addFee() {
|
||||
form.fees.push({ feeType: '', currency: 'USD', amount: 0, remark: '' })
|
||||
}
|
||||
|
||||
function removeFee(index: number) {
|
||||
form.fees.splice(index, 1)
|
||||
}
|
||||
|
||||
function validateFees() {
|
||||
if (form.fees.length === 0) {
|
||||
ElMessage.warning('请至少添加一条费用明细')
|
||||
return false
|
||||
}
|
||||
const invalidIndex = form.fees.findIndex((fee) => !fee.feeType.trim() || !fee.currency.trim() || fee.amount <= 0)
|
||||
if (invalidIndex < 0) return true
|
||||
ElMessage.warning(`请完整填写第 ${invalidIndex + 1} 条费用的类型、币种和大于 0 的金额`)
|
||||
return false
|
||||
}
|
||||
|
||||
function requestPayload(): DeliveryTransferPayload {
|
||||
return {
|
||||
dispatchNo: form.dispatchNo.trim(),
|
||||
idempotencyKey: pendingIdempotencyKey,
|
||||
plannedDeliveryAt: form.plannedDeliveryAt,
|
||||
deliveryMethod: form.deliveryMethod,
|
||||
pickupMethod: form.pickupMethod,
|
||||
loadingType: form.loadingType,
|
||||
waybillNo: optional(form.waybillNo),
|
||||
carrierName: optional(form.carrierName),
|
||||
vehicleType: optional(form.vehicleType),
|
||||
vehicleSize: optional(form.vehicleSize),
|
||||
driverName: optional(form.driverName),
|
||||
driverPhone: optional(form.driverPhone),
|
||||
vehiclePlate: optional(form.vehiclePlate),
|
||||
appointmentAccount: optional(form.appointmentAccount),
|
||||
sellerName: optional(form.sellerName),
|
||||
remark: optional(form.remark),
|
||||
fees: form.fees.map((fee): DeliveryOrderFee => ({
|
||||
feeType: fee.feeType.trim(),
|
||||
currency: fee.currency.trim().toUpperCase(),
|
||||
amount: fee.amount,
|
||||
remark: optional(fee.remark ?? ''),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
submitError.value = ''
|
||||
const valid = await formRef.value?.validate().catch(() => false)
|
||||
if (!valid || !validateFees()) return
|
||||
if (!pendingIdempotencyKey) pendingIdempotencyKey = crypto.randomUUID()
|
||||
submitting.value = true
|
||||
try {
|
||||
const created = await createDeliveryOrder(requestPayload())
|
||||
submitted.value = true
|
||||
pendingIdempotencyKey = ''
|
||||
ElMessage.success('派送单创建成功')
|
||||
if (auth.hasPermission('merchant:warehouse:delivery:list')) {
|
||||
await router.replace({ name: 'orders-delivery-detail', params: { deliveryNo: created.deliveryNo } })
|
||||
} else {
|
||||
await router.replace('/')
|
||||
}
|
||||
} catch (error) {
|
||||
submitError.value = error instanceof Error ? error.message : '派送单创建失败,请检查后重试'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
if (dirty.value) {
|
||||
try {
|
||||
await ElMessageBox.confirm('当前派送单尚未提交,离开后填写内容将丢失。', '放弃创建?', { type: 'warning', confirmButtonText: '放弃', cancelButtonText: '继续填写' })
|
||||
} catch (error) {
|
||||
if (error === 'cancel' || error === 'close') return
|
||||
throw error
|
||||
}
|
||||
}
|
||||
submitted.value = true
|
||||
await router.push({ name: 'orders-delivery' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="delivery-page" aria-labelledby="delivery-create-title">
|
||||
<button type="button" class="delivery-back" @click="cancel"><ArrowLeft :size="16" />返回派送单列表</button>
|
||||
<div class="delivery-heading"><div><h2 id="delivery-create-title">创建派送单</h2><p>从已确认的排车单创建本企业海外仓派送任务。</p></div></div>
|
||||
|
||||
<div v-if="submitError" class="delivery-card delivery-state delivery-state--error" role="alert"><p>{{ submitError }}</p></div>
|
||||
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" class="delivery-card delivery-form" @submit.prevent="submit">
|
||||
<section>
|
||||
<h3>基础信息</h3>
|
||||
<div class="delivery-form-grid">
|
||||
<el-form-item label="排车单号" prop="dispatchNo" required><el-input v-model="form.dispatchNo" maxlength="64" placeholder="请输入排车单号" /></el-form-item>
|
||||
<el-form-item label="计划派送时间" prop="plannedDeliveryAt" required><el-date-picker v-model="form.plannedDeliveryAt" type="datetime" value-format="YYYY-MM-DDTHH:mm:ss" placeholder="请选择计划派送时间" /></el-form-item>
|
||||
<el-form-item label="派送方式" prop="deliveryMethod" required><el-select v-model="form.deliveryMethod" placeholder="请选择派送方式"><el-option label="FBA自提" value="FBA_PICKUP" /><el-option label="客户自提" value="CUSTOMER_PICKUP" /><el-option label="卡派打托" value="TRUCK_PALLET" /><el-option label="卡派地板" value="TRUCK_FLOOR" /><el-option label="快递" value="EXPRESS" /><el-option label="外配" value="OUTSOURCED" /><el-option label="专车直送" value="DEDICATED_TRUCK" /><el-option label="零担派送" value="LTL" /></el-select></el-form-item>
|
||||
<el-form-item label="提货方式" prop="pickupMethod" required><el-select v-model="form.pickupMethod" placeholder="请选择提货方式"><el-option label="一提一卸" value="ONE_PICKUP_ONE_DROP" /><el-option label="一提多卸" value="ONE_PICKUP_MULTI_DROP" /><el-option label="多提一卸" value="MULTI_PICKUP_ONE_DROP" /><el-option label="多提多卸" value="MULTI_PICKUP_MULTI_DROP" /><el-option label="仓库自提" value="WAREHOUSE_PICKUP" /></el-select></el-form-item>
|
||||
<el-form-item label="装载类型" prop="loadingType" required><el-select v-model="form.loadingType" placeholder="请选择装载类型"><el-option label="托盘" value="PALLET" /><el-option label="地板" value="FLOOR" /><el-option label="混装" value="MIXED" /></el-select></el-form-item>
|
||||
<el-form-item label="运单号"><el-input v-model="form.waybillNo" maxlength="64" placeholder="选填" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h3>承运与车辆</h3>
|
||||
<div class="delivery-form-grid">
|
||||
<el-form-item label="承运商"><el-input v-model="form.carrierName" maxlength="128" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="车型"><el-input v-model="form.vehicleType" maxlength="64" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="车辆尺寸"><el-input v-model="form.vehicleSize" maxlength="64" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="车牌号"><el-input v-model="form.vehiclePlate" maxlength="32" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="司机姓名"><el-input v-model="form.driverName" maxlength="64" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="司机电话"><el-input v-model="form.driverPhone" maxlength="32" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="预约账号"><el-input v-model="form.appointmentAccount" maxlength="128" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="卖家名称"><el-input v-model="form.sellerName" maxlength="128" placeholder="选填" /></el-form-item>
|
||||
<el-form-item label="备注" class="delivery-span-3"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" /></el-form-item>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="delivery-fee-heading"><h3>费用明细</h3><el-button @click="addFee"><Plus :size="16" />添加费用</el-button></div>
|
||||
<el-table v-if="form.fees.length" :data="form.fees" table-layout="fixed">
|
||||
<el-table-column label="操作" width="88"><template #default="{ $index }"><el-button link type="danger" aria-label="删除费用" @click="removeFee($index)"><Trash2 :size="16" />删除</el-button></template></el-table-column>
|
||||
<el-table-column label="费用类型" min-width="180"><template #default="{ row }"><el-input v-model="row.feeType" maxlength="64" placeholder="请输入费用类型" /></template></el-table-column>
|
||||
<el-table-column label="币种" width="140"><template #default="{ row }"><el-input v-model="row.currency" maxlength="3" placeholder="USD" /></template></el-table-column>
|
||||
<el-table-column label="金额" width="180"><template #default="{ row }"><el-input-number v-model="row.amount" :min="0" :precision="2" :step="1" controls-position="right" /></template></el-table-column>
|
||||
<el-table-column label="备注" min-width="220"><template #default="{ row }"><el-input v-model="row.remark" maxlength="200" placeholder="选填" /></template></el-table-column>
|
||||
</el-table>
|
||||
<div v-else class="delivery-fee-empty">请至少添加一条费用明细后再提交。</div>
|
||||
</section>
|
||||
|
||||
<div class="delivery-form-actions"><el-button :disabled="submitting" @click="cancel">取消</el-button><el-button type="primary" native-type="submit" :loading="submitting">提交创建</el-button></div>
|
||||
</el-form>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="./deliveryOrders.css"></style>
|
||||
@@ -0,0 +1,126 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@lucide/vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { getDeliveryOrder, type DeliveryOrderDetail, type DeliveryOrderItem } from '../api/deliveryOrders'
|
||||
|
||||
const props = defineProps<{ deliveryNo: string }>()
|
||||
const router = useRouter()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const detail = ref<DeliveryOrderDetail | null>(null)
|
||||
|
||||
const items = computed<DeliveryOrderItem[]>(() => {
|
||||
const value = detail.value?.items
|
||||
if (!value) return []
|
||||
return Array.isArray(value) ? value : value.records
|
||||
})
|
||||
|
||||
function display(value: string | number | null | undefined) {
|
||||
return value === null || value === undefined || value === '' ? '—' : String(value)
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—'
|
||||
}
|
||||
|
||||
function formatQuantity(value: number | null | undefined, unit: string) {
|
||||
return value === null || value === undefined ? '—' : `${value} ${unit}`
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
detail.value = await getDeliveryOrder(props.deliveryNo)
|
||||
} catch (error) {
|
||||
detail.value = null
|
||||
errorMessage.value = error instanceof Error ? error.message : '派送单详情加载失败,请重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function backToList() {
|
||||
void router.push({ name: 'orders-delivery' })
|
||||
}
|
||||
|
||||
onMounted(loadDetail)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="delivery-page" aria-labelledby="delivery-detail-title">
|
||||
<button type="button" class="delivery-back" @click="backToList"><ArrowLeft :size="16" />返回派送单列表</button>
|
||||
<div class="delivery-heading"><div><h2 id="delivery-detail-title">派送单详情</h2><p>{{ display(detail?.deliveryNo || deliveryNo) }}</p></div></div>
|
||||
|
||||
<div v-if="errorMessage" class="delivery-card delivery-state delivery-state--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="loadDetail">重试</el-button></div>
|
||||
<div v-else v-loading="loading" class="delivery-detail-content">
|
||||
<template v-if="detail">
|
||||
<section class="delivery-card delivery-section">
|
||||
<h3>订单信息</h3>
|
||||
<div class="delivery-detail-grid">
|
||||
<div><span>派送单号</span><strong>{{ display(detail.deliveryNo) }}</strong></div>
|
||||
<div><span>排车单号</span><strong>{{ display(detail.dispatchNo) }}</strong></div>
|
||||
<div><span>出库单号</span><strong>{{ display(detail.outboundNo) }}</strong></div>
|
||||
<div><span>状态</span><strong>{{ display(detail.status) }}</strong></div>
|
||||
<div><span>海外仓</span><strong>{{ display(detail.overseasWarehouseName) }}</strong></div>
|
||||
<div><span>派送方式</span><strong>{{ display(detail.deliveryMethod) }}</strong></div>
|
||||
<div><span>提货方式</span><strong>{{ display(detail.pickupMethod) }}</strong></div>
|
||||
<div><span>装载类型</span><strong>{{ display(detail.loadingType) }}</strong></div>
|
||||
<div><span>托盘数</span><strong>{{ formatQuantity(detail.palletCount, '托') }}</strong></div>
|
||||
<div><span>箱数</span><strong>{{ formatQuantity(detail.packageCount, '箱') }}</strong></div>
|
||||
<div><span>总重量</span><strong>{{ formatQuantity(detail.totalWeightKg, 'kg') }}</strong></div>
|
||||
<div><span>总体积</span><strong>{{ formatQuantity(detail.totalVolumeCbm, 'm³') }}</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="delivery-card delivery-section">
|
||||
<h3>运输与预约</h3>
|
||||
<div class="delivery-detail-grid">
|
||||
<div><span>运单号</span><strong>{{ display(detail.waybillNo) }}</strong></div>
|
||||
<div><span>承运商</span><strong>{{ display(detail.carrierName) }}</strong></div>
|
||||
<div><span>车型</span><strong>{{ display(detail.vehicleType) }}</strong></div>
|
||||
<div><span>车辆尺寸</span><strong>{{ display(detail.vehicleSize) }}</strong></div>
|
||||
<div><span>车牌号</span><strong>{{ display(detail.vehiclePlate) }}</strong></div>
|
||||
<div><span>司机姓名</span><strong>{{ display(detail.driverName) }}</strong></div>
|
||||
<div><span>司机电话</span><strong>{{ display(detail.driverPhone) }}</strong></div>
|
||||
<div><span>预约账号</span><strong>{{ display(detail.appointmentAccount) }}</strong></div>
|
||||
<div><span>卖家名称</span><strong>{{ display(detail.sellerName) }}</strong></div>
|
||||
<div><span>计划派送</span><strong>{{ formatDateTime(detail.plannedDeliveryAt) }}</strong></div>
|
||||
<div><span>预计到达</span><strong>{{ formatDateTime(detail.expectedArrivalAt) }}</strong></div>
|
||||
<div><span>签收时间</span><strong>{{ formatDateTime(detail.signedAt) }}</strong></div>
|
||||
<div><span>创建时间</span><strong>{{ formatDateTime(detail.createdAt) }}</strong></div>
|
||||
<div><span>更新时间</span><strong>{{ formatDateTime(detail.updatedAt) }}</strong></div>
|
||||
<div class="delivery-span-all"><span>备注</span><strong>{{ display(detail.remark) }}</strong></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="delivery-card delivery-section">
|
||||
<h3>货物明细</h3>
|
||||
<el-table :data="items" empty-text="暂无货物明细">
|
||||
<el-table-column prop="palletNo" label="托盘码" min-width="150"><template #default="{ row }">{{ display(row.palletNo) }}</template></el-table-column>
|
||||
<el-table-column prop="inboundCode" label="入仓码" min-width="150"><template #default="{ row }">{{ display(row.inboundCode) }}</template></el-table-column>
|
||||
<el-table-column prop="skuCode" label="SKU" min-width="140"><template #default="{ row }">{{ display(row.skuCode) }}</template></el-table-column>
|
||||
<el-table-column prop="productName" label="品名" min-width="180"><template #default="{ row }">{{ display(row.productName) }}</template></el-table-column>
|
||||
<el-table-column prop="packageCount" label="箱数" width="100"><template #default="{ row }">{{ display(row.packageCount) }}</template></el-table-column>
|
||||
<el-table-column prop="weightKg" label="重量(kg)" width="120"><template #default="{ row }">{{ display(row.weightKg) }}</template></el-table-column>
|
||||
<el-table-column prop="volumeCbm" label="体积(m³)" width="120"><template #default="{ row }">{{ display(row.volumeCbm) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
|
||||
<section class="delivery-card delivery-section">
|
||||
<h3>费用明细</h3>
|
||||
<el-table :data="detail.fees || []" empty-text="暂无费用明细">
|
||||
<el-table-column prop="feeType" label="费用类型" min-width="180"><template #default="{ row }">{{ display(row.feeType) }}</template></el-table-column>
|
||||
<el-table-column prop="currency" label="币种" width="110"><template #default="{ row }">{{ display(row.currency) }}</template></el-table-column>
|
||||
<el-table-column prop="amount" label="金额" width="140"><template #default="{ row }">{{ display(row.amount) }}</template></el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="220"><template #default="{ row }">{{ display(row.remark) }}</template></el-table-column>
|
||||
</el-table>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="./deliveryOrders.css"></style>
|
||||
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Plus, RefreshCw, Search } from '@lucide/vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { listDeliveryOrders, type DeliveryQuery, type DeliveryRow } from '../api/deliveryOrders'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const records = ref<DeliveryRow[]>([])
|
||||
const total = ref(0)
|
||||
const page = ref(1)
|
||||
const pageSize = ref(20)
|
||||
const filters = reactive({ documentNo: '', dispatchNo: '', waybillNo: '', status: '', deliveryMethod: '', plannedRange: [] as string[] })
|
||||
const canCreate = computed(() => auth.hasPermission('merchant:warehouse:delivery:create'))
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'PENDING', label: '待派送' },
|
||||
{ value: 'IN_TRANSIT', label: '派送中' },
|
||||
{ value: 'DELIVERED', label: '已送达' },
|
||||
{ value: 'SIGNED', label: '已签收' },
|
||||
{ value: 'CANCELLED', label: '已取消' },
|
||||
]
|
||||
|
||||
const statusLabels: Record<string, string> = Object.fromEntries(statusOptions.map((item) => [item.value, item.label]))
|
||||
|
||||
function display(value: string | number | null | undefined) {
|
||||
return value === null || value === undefined || value === '' ? '—' : String(value)
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '—'
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
return statusLabels[status] ?? status
|
||||
}
|
||||
|
||||
function statusType(status: string) {
|
||||
if (status === 'DELIVERED' || status === 'SIGNED') return 'success'
|
||||
if (status === 'CANCELLED') return 'info'
|
||||
if (status === 'IN_TRANSIT') return 'warning'
|
||||
return 'primary'
|
||||
}
|
||||
|
||||
function buildQuery(): DeliveryQuery {
|
||||
return {
|
||||
page: page.value,
|
||||
size: pageSize.value,
|
||||
documentNo: filters.documentNo.trim() || undefined,
|
||||
dispatchNo: filters.dispatchNo.trim() || undefined,
|
||||
waybillNo: filters.waybillNo.trim() || undefined,
|
||||
status: filters.status || undefined,
|
||||
deliveryMethod: filters.deliveryMethod || undefined,
|
||||
plannedFrom: filters.plannedRange[0] || undefined,
|
||||
plannedTo: filters.plannedRange[1] || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const result = await listDeliveryOrders(buildQuery())
|
||||
records.value = result.records
|
||||
total.value = result.total
|
||||
} catch (error) {
|
||||
records.value = []
|
||||
total.value = 0
|
||||
errorMessage.value = error instanceof Error ? error.message : '派送单加载失败,请重试'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function search() {
|
||||
page.value = 1
|
||||
void loadOrders()
|
||||
}
|
||||
|
||||
function reset() {
|
||||
Object.assign(filters, { documentNo: '', dispatchNo: '', waybillNo: '', status: '', deliveryMethod: '', plannedRange: [] })
|
||||
search()
|
||||
}
|
||||
|
||||
function changePage(nextPage: number) {
|
||||
page.value = nextPage
|
||||
void loadOrders()
|
||||
}
|
||||
|
||||
function changePageSize(nextSize: number) {
|
||||
pageSize.value = nextSize
|
||||
page.value = 1
|
||||
void loadOrders()
|
||||
}
|
||||
|
||||
function openDetail(row: DeliveryRow) {
|
||||
if (!row.deliveryNo) return
|
||||
void router.push({ name: 'orders-delivery-detail', params: { deliveryNo: row.deliveryNo } })
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
void router.push({ name: 'orders-delivery-create' })
|
||||
}
|
||||
|
||||
onMounted(loadOrders)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="delivery-page" aria-labelledby="delivery-list-title">
|
||||
<div class="delivery-heading">
|
||||
<div><h2 id="delivery-list-title">派送订单</h2><p>查看本企业海外仓派送进度与签收结果。</p></div>
|
||||
<el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />创建派送单</el-button>
|
||||
</div>
|
||||
|
||||
<form class="delivery-card delivery-filter" @submit.prevent="search">
|
||||
<div class="delivery-filter-grid">
|
||||
<el-input v-model="filters.documentNo" clearable placeholder="派送单号 / 出库单号" aria-label="派送单号或出库单号" />
|
||||
<el-input v-model="filters.dispatchNo" clearable placeholder="排车单号" aria-label="排车单号" />
|
||||
<el-input v-model="filters.waybillNo" clearable placeholder="运单号" aria-label="运单号" />
|
||||
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="派送状态"><el-option v-for="option in statusOptions" :key="option.value" :label="option.label" :value="option.value" /></el-select>
|
||||
<el-select v-model="filters.deliveryMethod" clearable placeholder="全部派送方式" aria-label="派送方式"><el-option label="卡车派送" value="TRUCK" /><el-option label="快递派送" value="COURIER" /><el-option label="客户自提" value="SELF_PICKUP" /></el-select>
|
||||
<el-date-picker v-model="filters.plannedRange" type="datetimerange" value-format="YYYY-MM-DDTHH:mm:ss" start-placeholder="计划开始时间" end-placeholder="计划结束时间" range-separator="至" aria-label="计划派送时间" />
|
||||
</div>
|
||||
<div class="delivery-filter-actions"><el-button type="primary" native-type="submit"><Search :size="16" />查询</el-button><el-button @click="reset"><RefreshCw :size="16" />重置</el-button></div>
|
||||
</form>
|
||||
|
||||
<div class="delivery-card delivery-table-card">
|
||||
<div v-if="errorMessage" class="delivery-state delivery-state--error" role="alert"><p>{{ errorMessage }}</p><el-button @click="loadOrders">重试</el-button></div>
|
||||
<el-table v-else v-loading="loading" :data="records" empty-text="暂无派送单" table-layout="fixed">
|
||||
<el-table-column label="操作" width="96" fixed="left"><template #default="{ row }"><el-button link type="primary" :disabled="!row.deliveryNo" @click="openDetail(row as DeliveryRow)">详情</el-button></template></el-table-column>
|
||||
<el-table-column prop="deliveryNo" label="派送单号" min-width="180"><template #default="{ row }">{{ display(row.deliveryNo) }}</template></el-table-column>
|
||||
<el-table-column prop="dispatchNo" label="排车单号" min-width="170"><template #default="{ row }">{{ display(row.dispatchNo) }}</template></el-table-column>
|
||||
<el-table-column prop="outboundNo" label="出库单号" min-width="170"><template #default="{ row }">{{ display(row.outboundNo) }}</template></el-table-column>
|
||||
<el-table-column prop="status" label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status)" effect="light">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="overseasWarehouseName" label="海外仓" min-width="150"><template #default="{ row }">{{ display(row.overseasWarehouseName) }}</template></el-table-column>
|
||||
<el-table-column prop="deliveryMethod" label="派送方式" width="120"><template #default="{ row }">{{ display(row.deliveryMethod) }}</template></el-table-column>
|
||||
<el-table-column prop="waybillNo" label="运单号" min-width="150"><template #default="{ row }">{{ display(row.waybillNo) }}</template></el-table-column>
|
||||
<el-table-column prop="carrierName" label="承运商" min-width="140"><template #default="{ row }">{{ display(row.carrierName) }}</template></el-table-column>
|
||||
<el-table-column prop="plannedDeliveryAt" label="计划派送" min-width="160"><template #default="{ row }">{{ formatDateTime(row.plannedDeliveryAt) }}</template></el-table-column>
|
||||
<el-table-column prop="expectedArrivalAt" label="预计到达" min-width="160"><template #default="{ row }">{{ formatDateTime(row.expectedArrivalAt) }}</template></el-table-column>
|
||||
<el-table-column prop="createdAt" label="创建时间" min-width="160"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<div v-if="!errorMessage && (records.length > 0 || total > 0)" class="delivery-pagination"><span>共 {{ total }} 条</span><el-pagination :current-page="page" :page-size="pageSize" :page-sizes="[10, 20, 50, 100]" layout="sizes, prev, pager, next" :total="total" @update:current-page="changePage" @update:page-size="changePageSize" /></div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped src="./deliveryOrders.css"></style>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.delivery-page{color:#25324a}.delivery-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:20px;margin-bottom:16px}.delivery-heading h2{margin:0;color:#17233a;font-size:22px}.delivery-heading p{margin:6px 0 0;color:#7a8596;font-size:13px}.delivery-card{margin-bottom:14px;border:1px solid #e5eaf1;border-radius:6px;background:#fff;box-shadow:0 1px 3px rgba(25,42,70,.04)}.delivery-filter{padding:18px 20px}.delivery-filter-grid{display:grid;grid-template-columns:repeat(3,minmax(210px,1fr));gap:14px}.delivery-filter-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:16px}.delivery-table-card{overflow:hidden}.delivery-pagination{display:flex;align-items:center;justify-content:flex-end;gap:16px;padding:14px 18px;color:#778398;font-size:12px}.delivery-state{display:flex;min-height:220px;align-items:center;justify-content:center;gap:12px;flex-direction:column;color:#7a8596}.delivery-state p{margin:0}.delivery-state--error{margin:20px;border:1px solid #f5c2bd;border-radius:6px;background:#fff8f7;color:#b42318}.delivery-section{padding:20px}.delivery-section h3{margin:0 0 18px;padding-bottom:12px;border-bottom:1px solid #edf0f4;color:#26364f;font-size:15px}.delivery-detail-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:20px 28px}.delivery-detail-grid div{display:flex;min-width:0;flex-direction:column;gap:7px}.delivery-detail-grid span{color:#8792a2;font-size:12px}.delivery-detail-grid strong{overflow-wrap:anywhere;color:#334157;font-size:13px;font-weight:550}.delivery-span-all{grid-column:1/-1}.delivery-back{display:inline-flex;align-items:center;gap:6px;margin-bottom:14px;padding:0;border:0;background:none;color:#2176d2;cursor:pointer}.delivery-form{padding:22px}.delivery-form-grid{display:grid;grid-template-columns:repeat(3,minmax(210px,1fr));gap:4px 24px}.delivery-form-grid .delivery-span-2{grid-column:span 2}.delivery-form-grid .delivery-span-3{grid-column:1/-1}.delivery-form-actions{display:flex;justify-content:center;gap:10px;padding-top:8px}.delivery-fee-heading{display:flex;align-items:center;justify-content:space-between;margin:10px 0 12px}.delivery-fee-heading h3{margin:0;font-size:15px}.delivery-fee-empty{padding:20px;color:#8792a2;text-align:center}:deep(.el-button span){display:inline-flex;align-items:center;gap:6px}:deep(.el-date-editor){width:100%}@media(max-width:1200px){.delivery-filter-grid,.delivery-form-grid{grid-template-columns:repeat(2,minmax(210px,1fr))}.delivery-detail-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.delivery-form-grid .delivery-span-3{grid-column:1/-1}}
|
||||
@@ -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', () => {
|
||||
@@ -71,6 +78,8 @@ test('starts empty and only loads server role and member records', () => {
|
||||
assert.match(storeSource, /await resetMerchantMemberPassword/)
|
||||
assert.match(source, /username: string/)
|
||||
assert.match(source, /lastLoginAt: string \| null/)
|
||||
assert.match(source, /phone: string \| null/)
|
||||
assert.match(storeSource, /phone: payload\.phone\.trim\(\) \|\| null/)
|
||||
assert.match(source, /memberCount: number/)
|
||||
assert.match(storeSource, /account: member\.username/)
|
||||
assert.match(storeSource, /lastLoginAt: formatLastLoginAt\(member\.lastLoginAt\)/)
|
||||
|
||||
@@ -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,12 +119,18 @@ export interface MerchantPersonalProfile {
|
||||
email: string | null
|
||||
}
|
||||
|
||||
export interface ChangeMerchantLoginIdentifiersRequest {
|
||||
username: string
|
||||
phone: string
|
||||
currentPassword: string
|
||||
}
|
||||
|
||||
export interface CreateMerchantMemberRequest {
|
||||
username: string
|
||||
password: string
|
||||
displayName: string
|
||||
phone: string
|
||||
email: string
|
||||
phone: string | null
|
||||
email: string | null
|
||||
memberType: 'EMPLOYEE'
|
||||
memberNo: string
|
||||
roleIds: Array<number | string>
|
||||
@@ -132,8 +138,8 @@ export interface CreateMerchantMemberRequest {
|
||||
|
||||
export interface UpdateMerchantMemberRequest {
|
||||
displayName: string
|
||||
phone: string
|
||||
email: string
|
||||
phone: string | null
|
||||
email: string | null
|
||||
memberType: string
|
||||
memberNo: string
|
||||
roleIds: Array<number | 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
|
||||
'order:first-leg:edit': ['merchant:order:edit'],
|
||||
'order:first-leg:submit': ['merchant:order:submit'],
|
||||
'order:first-leg:cancel': ['merchant:order:cancel'],
|
||||
'merchant:warehouse:delivery:list': ['merchant:warehouse:delivery:list'],
|
||||
'merchant:warehouse:delivery:create': ['merchant:warehouse:delivery:create'],
|
||||
'company:profile:view': ['merchant:profile:view'],
|
||||
'company:profile:edit': ['merchant:profile:edit'],
|
||||
'company:qualification:upload': ['merchant:qualification:upload'],
|
||||
|
||||
@@ -3,7 +3,9 @@ export {
|
||||
getCurrentMerchantPermissionCodes,
|
||||
getMerchantPersonalProfile,
|
||||
getTenantProfile,
|
||||
updateMerchantLoginIdentifiers,
|
||||
updateMerchantPersonalProfile,
|
||||
type ChangeMerchantLoginIdentifiersRequest,
|
||||
type MerchantPermissionResource,
|
||||
} from './api/merchantIam'
|
||||
export {
|
||||
|
||||
@@ -234,8 +234,8 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
|
||||
username: payload.account.trim(),
|
||||
password: payload.password,
|
||||
displayName: payload.name.trim(),
|
||||
phone: payload.phone.trim(),
|
||||
email: payload.email.trim(),
|
||||
phone: payload.phone.trim() || null,
|
||||
email: payload.email.trim() || null,
|
||||
memberType: 'EMPLOYEE',
|
||||
memberNo: payload.memberNo.trim(),
|
||||
roleIds: payload.roleIds,
|
||||
@@ -248,8 +248,8 @@ export const useTenantAdministrationStore = defineStore('tenant-administration',
|
||||
if (!member) throw new Error('成员不存在')
|
||||
const updated = await updateMerchantMember(id, {
|
||||
displayName: payload.name.trim(),
|
||||
phone: payload.phone.trim(),
|
||||
email: payload.email.trim(),
|
||||
phone: payload.phone.trim() || null,
|
||||
email: payload.email.trim() || null,
|
||||
memberType: member.isAdministrator ? 'OWNER' : 'EMPLOYEE',
|
||||
memberNo: payload.memberNo.trim(),
|
||||
roleIds: payload.roleIds,
|
||||
|
||||
@@ -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 standaloneLegalPersonIdUnsupported = computed(() => legalPersonIdFileSelected.value && !businessLicenseFileSelected.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 (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
|
||||
&& !standaloneLegalPersonIdUnsupported.value
|
||||
&& requiredPackageComplete.value)
|
||||
const qualificationSaveDisabledReason = computed(() => {
|
||||
if (!canUploadQualification.value) return qualificationUploadDisabledReason.value
|
||||
if (standaloneLegalPersonIdUnsupported.value) return '法人身份证为选填项;当前文件服务尚未开放单独保存,请取消本次选择后保存其他资质。'
|
||||
if (!requiredPackageComplete.value) return `请完整保存必填业务项:${missingRequiredPackageItems.value.join('、')}`
|
||||
if (!selectedQualificationCount.value) return '当前没有需要保存的变更'
|
||||
return ''
|
||||
@@ -141,13 +144,16 @@ 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' }],
|
||||
legalRepresentative: [{ required: true, whitespace: true, message: '请输入法人姓名', trigger: 'blur' }],
|
||||
contactName: [{ required: true, whitespace: true, message: '请输入联系人姓名', trigger: 'blur' }],
|
||||
contactPhone: [{ required: true, whitespace: true, message: '请输入联系电话', trigger: 'blur' }],
|
||||
contactPhone: [
|
||||
{ required: true, whitespace: true, message: '请输入公司联系电话', trigger: 'blur' },
|
||||
{ pattern: /^[0-9-]{4,32}$/, message: '公司联系电话仅支持 4–32 位数字或连字符', trigger: 'blur' },
|
||||
],
|
||||
contactEmail: [{ type: 'email', message: '请输入正确的联系邮箱', trigger: 'blur' }],
|
||||
}
|
||||
const qualificationColumns: DataTableColumn[] = [
|
||||
@@ -334,13 +340,21 @@ async function submitQualification() {
|
||||
ElMessage.warning(qualificationUploadDisabledReason.value)
|
||||
return
|
||||
}
|
||||
if (standaloneLegalPersonIdUnsupported.value) {
|
||||
const legalPersonId = qualificationDraftItem('LEGAL_PERSON_ID')
|
||||
const message = '当前文件服务尚未开放法人身份证单独上传或替换,该选填文件未提交。'
|
||||
if (legalPersonId) { legalPersonId.error = message; legalPersonId.uploadState = 'error' }
|
||||
ElMessage.warning(message)
|
||||
return
|
||||
}
|
||||
if (!requiredPackageComplete.value) {
|
||||
const businessLicense = qualificationDraftItem('BUSINESS_LICENSE')
|
||||
if (businessLicense && !qualificationCreditCodeReady.value) businessLicense.error = qualificationCreditCodeError.value
|
||||
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 +362,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 +370,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 +386,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 +429,9 @@ async function submitQualification() {
|
||||
return
|
||||
}
|
||||
qualificationOpen.value = false
|
||||
if (refreshed) ElMessage.success('营业执照、统一社会信用代码和法人身份证已完整保存,尚未提交审核')
|
||||
if (refreshed) ElMessage.success(includedLegalPersonId
|
||||
? '营业执照、统一社会信用代码和法人身份证已保存,尚未提交审核'
|
||||
: '营业执照和统一社会信用代码已保存,尚未提交审核')
|
||||
else ElMessage.warning('资质文件已保存,但最新状态刷新失败,请点击“重新加载”确认状态')
|
||||
} finally {
|
||||
qualificationSubmitting.value = false
|
||||
@@ -509,7 +526,7 @@ function closeQualificationPreview() {
|
||||
<header class="info-panel-header"><h3>联系信息</h3></header>
|
||||
<dl class="profile-grid">
|
||||
<div><dt>联系人</dt><dd>{{ store.company.contactName || '-' }}</dd></div>
|
||||
<div><dt>联系电话</dt><dd>{{ store.company.contactPhone || '-' }}</dd></div>
|
||||
<div><dt>公司联系电话</dt><dd>{{ store.company.contactPhone || '-' }}</dd></div>
|
||||
<div><dt>联系邮箱</dt><dd>{{ store.company.contactEmail || '-' }}</dd></div>
|
||||
<div><dt>联系微信</dt><dd>{{ store.company.contactWechat || '-' }}</dd></div>
|
||||
</dl>
|
||||
@@ -564,7 +581,7 @@ function closeQualificationPreview() {
|
||||
<h3>联系信息</h3>
|
||||
<div class="form-grid-two">
|
||||
<el-form-item label="联系人" prop="contactName" required><el-input v-model="draft.contactName" placeholder="请输入联系人姓名" /></el-form-item>
|
||||
<el-form-item label="联系电话" prop="contactPhone" required><el-input v-model="draft.contactPhone" placeholder="请输入联系电话" /></el-form-item>
|
||||
<el-form-item label="公司联系电话" prop="contactPhone" required><el-input v-model="draft.contactPhone" maxlength="32" inputmode="tel" placeholder="请输入公司联系电话" /><small class="profile-field-hint">仅用于公司业务联系,不会修改任何成员的登录手机号。</small></el-form-item>
|
||||
<el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="draft.contactEmail" placeholder="请输入联系邮箱(选填)" /></el-form-item>
|
||||
</div>
|
||||
</el-form>
|
||||
@@ -582,7 +599,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">
|
||||
@@ -677,6 +694,7 @@ function closeQualificationPreview() {
|
||||
.mobile-qualification-list { display: none; }
|
||||
.profile-form h3 { margin: 4px 0 18px; padding-bottom: 10px; border-bottom: 1px solid var(--border); font-size: 15px; }
|
||||
.profile-form h3:not(:first-child) { margin-top: 22px; }
|
||||
.profile-field-hint { display: block; margin-top: 5px; color: var(--muted); font-size: 10px; line-height: 1.5; }
|
||||
.form-grid-two { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
|
||||
.form-grid-two .full-row { grid-column: 1 / -1; }
|
||||
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
@@ -30,19 +30,19 @@ const memberRules: FormRules = {
|
||||
{ required: true, message: '请输入初始密码', trigger: 'blur' },
|
||||
{ pattern: /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, message: '密码需为 8–64 位,并同时包含字母和数字', trigger: 'blur' },
|
||||
],
|
||||
phone: [{ max: 32, message: '联系电话不能超过 32 个字符', trigger: 'blur' }],
|
||||
phone: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入有效的中国大陆登录手机号', trigger: 'blur' }],
|
||||
email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
|
||||
roleIds: [{ type: 'array', required: true, min: 1, message: '请至少选择一个成员角色', trigger: 'change' }],
|
||||
}
|
||||
|
||||
const memberQueryFields: QueryField[] = [
|
||||
{ key: 'keyword', type: 'input', placeholder: '姓名、账号、手机或邮箱' },
|
||||
{ key: 'keyword', type: 'input', placeholder: '姓名、账号、登录手机号或邮箱' },
|
||||
{ key: 'status', type: 'select', placeholder: '全部状态', options: [{ label: '正常', value: 'ACTIVE' }, { label: '已停用', value: 'INACTIVE' }] },
|
||||
]
|
||||
const memberTableColumns: DataTableColumn[] = [
|
||||
{ key: 'actions', label: '操作', fixed: 'left', width: 230, slotName: 'actions' },
|
||||
{ key: 'member', label: '成员', minWidth: 170, slotName: 'member' },
|
||||
{ key: 'contact', label: '联系方式', minWidth: 200, slotName: 'contact' },
|
||||
{ key: 'contact', label: '登录手机号 / 邮箱', minWidth: 200, slotName: 'contact' },
|
||||
{ key: 'roles', label: '角色', minWidth: 130, slotName: 'roles' },
|
||||
{ key: 'lastLoginAt', prop: 'lastLoginAt', label: '登录记录', minWidth: 130 },
|
||||
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
|
||||
@@ -176,7 +176,7 @@ async function resetPassword(member: TenantMember) {
|
||||
<DataTable v-model:page="memberPage" v-model:page-size="memberPageSize" :data="pagedMembers" :columns="memberTableColumns" :total="filteredMembers.length" row-key="id">
|
||||
<template #actions="{ row }"><el-button v-if="canEditMember" link type="primary" @click="openEdit(row as TenantMember)"><Pencil :size="15" />编辑</el-button><el-button v-if="canResetMemberPassword" link type="primary" @click="resetPassword(row as TenantMember)"><KeyRound :size="15" />重置密码</el-button><el-button v-if="canChangeMemberStatus && !(row as TenantMember).isAdministrator" link :type="(row as TenantMember).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as TenantMember)">{{ (row as TenantMember).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!canEditMember && !canResetMemberPassword && !canChangeMemberStatus" class="readonly-action">仅查看</span></template>
|
||||
<template #member="{ row }"><div class="member-identity"><span>{{ (row as TenantMember).name.slice(0, 1) }}</span><div><strong>{{ (row as TenantMember).name }}</strong><small>{{ (row as TenantMember).account }}</small></div></div></template>
|
||||
<template #contact="{ row }"><div class="member-contact"><span>{{ (row as TenantMember).phone }}</span><small>{{ (row as TenantMember).email || '-' }}</small></div></template>
|
||||
<template #contact="{ row }"><div class="member-contact"><span>{{ (row as TenantMember).phone || '-' }}</span><small>{{ (row as TenantMember).email || '-' }}</small></div></template>
|
||||
<template #roles="{ row }"><div class="role-tags"><el-tag v-for="name in store.roleNames((row as TenantMember).roleIds)" :key="name" effect="plain">{{ name }}</el-tag><span v-if="store.roleNames((row as TenantMember).roleIds).length === 0" class="readonly-action">未分配</span></div></template>
|
||||
<template #status="{ row }"><el-tag :type="(row as TenantMember).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as TenantMember).status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></template>
|
||||
<template #empty><div class="client-empty">没有匹配的企业成员,请调整筛选条件。</div></template>
|
||||
@@ -186,7 +186,7 @@ async function resetPassword(member: TenantMember) {
|
||||
<div v-if="pagedMembers.length" class="mobile-member-list">
|
||||
<article v-for="member in pagedMembers" :key="member.id">
|
||||
<header><div class="member-identity"><span>{{ member.name.slice(0, 1) }}</span><div><strong>{{ member.name }}</strong><small>{{ member.account }}</small></div></div><el-tag :type="member.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ member.status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></header>
|
||||
<dl><div><dt>手机</dt><dd>{{ member.phone }}</dd></div><div><dt>最近登录</dt><dd>{{ member.lastLoginAt }}</dd></div><div class="full"><dt>角色</dt><dd>{{ store.roleNames(member.roleIds).join('、') }}</dd></div></dl>
|
||||
<dl><div><dt>登录手机号</dt><dd>{{ member.phone || '-' }}</dd></div><div><dt>最近登录</dt><dd>{{ member.lastLoginAt }}</dd></div><div class="full"><dt>角色</dt><dd>{{ store.roleNames(member.roleIds).join('、') }}</dd></div></dl>
|
||||
<footer><el-button v-if="canEditMember" link type="primary" @click="openEdit(member)">编辑</el-button><el-button v-if="canResetMemberPassword" link type="primary" @click="resetPassword(member)">重置密码</el-button><el-button v-if="canChangeMemberStatus && !member.isAdministrator" link :type="member.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(member)">{{ member.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!canEditMember && !canResetMemberPassword && !canChangeMemberStatus" class="readonly-action">仅查看</span></footer>
|
||||
</article>
|
||||
</div>
|
||||
@@ -199,7 +199,7 @@ async function resetPassword(member: TenantMember) {
|
||||
<el-form-item label="成员姓名" prop="name" required><el-input v-model="form.name" maxlength="64" placeholder="例如 张三" /></el-form-item>
|
||||
<el-form-item label="登录账号" prop="account" required><el-input v-model="form.account" :disabled="Boolean(editingId)" maxlength="64" placeholder="4–64 位字母、数字或 _ . @ -" /></el-form-item>
|
||||
<el-form-item v-if="!editingId" label="初始密码" prop="password" required><el-input v-model="form.password" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="8–64 位,同时包含字母和数字" /></el-form-item>
|
||||
<div class="member-form-grid"><el-form-item label="联系电话" prop="phone"><el-input v-model="form.phone" maxlength="32" placeholder="例如 13800138000(选填)" /></el-form-item><el-form-item label="邮箱" prop="email"><el-input v-model="form.email" maxlength="128" placeholder="例如 name@example.com(选填)" /></el-form-item></div>
|
||||
<div class="member-form-grid"><el-form-item label="登录手机号" prop="phone"><el-input v-model="form.phone" maxlength="11" inputmode="tel" autocomplete="tel" placeholder="例如 13800138000(选填)" /><span class="member-field-help">用于该成员手机号登录;修改后会同步认证账号并退出该成员现有会话。</span></el-form-item><el-form-item label="邮箱" prop="email"><el-input v-model="form.email" maxlength="128" placeholder="例如 name@example.com(选填)" /></el-form-item></div>
|
||||
<el-form-item label="成员角色" prop="roleIds" required><el-select v-model="form.roleIds" multiple :disabled="Boolean(editingId) && !canGrantMemberRole" placeholder="请选择角色"><el-option v-for="role in store.roles.filter((item) => item.status === 'ACTIVE' && (!item.builtin || form.roleIds.includes(item.id)))" :key="role.id" :label="role.name" :value="role.id" :disabled="role.builtin" /></el-select></el-form-item>
|
||||
<div v-if="!editingId" class="member-form-tip"><KeyRound :size="17" /><span>请通过安全渠道将初始密码交给成员;前端不会保存或再次展示该密码。</span></div>
|
||||
</el-form>
|
||||
@@ -225,6 +225,7 @@ async function resetPassword(member: TenantMember) {
|
||||
.mobile-member-list, .mobile-member-empty { display: none; }
|
||||
.member-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.member-form-tip { display: flex; gap: 10px; padding: 13px; border: 1px solid #cfe1dc; background: #f2f7f6; color: var(--muted); font-size: 11px; line-height: 1.6; }
|
||||
.member-field-help { margin-top: 5px; color: var(--muted); font-size: 10px; line-height: 1.5; }
|
||||
.member-form-tip svg { flex: 0 0 17px; color: var(--primary); }
|
||||
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
||||
.members-page :deep(.el-form-item__error) { line-height: 1.45; overflow-wrap: anywhere; }
|
||||
|
||||
@@ -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,17 @@ 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.doesNotMatch(profileSource, /法人身份证需与本次营业执照一起保存/)
|
||||
assert.match(profileSource, /const standaloneLegalPersonIdUnsupported = computed/)
|
||||
assert.match(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/)
|
||||
@@ -93,6 +97,11 @@ test('implements company details, contact information and qualification workflow
|
||||
assert.match(profileSource, /<dt>成立日期<\/dt><dd>\{\{ store\.company\.establishedDate/)
|
||||
assert.match(profileSource, /<dt>注册资本<\/dt><dd>\{\{ store\.company\.registeredCapital/)
|
||||
assert.match(profileSource, /<dt>联系微信<\/dt><dd>\{\{ store\.company\.contactWechat/)
|
||||
assert.match(profileSource, /<dt>公司联系电话<\/dt><dd>\{\{ store\.company\.contactPhone/)
|
||||
assert.match(profileSource, /label="公司联系电话" prop="contactPhone" required/)
|
||||
assert.match(profileSource, /不会修改任何成员的登录手机号/)
|
||||
assert.match(profileSource, /pattern: \/\^\[0-9-\]\{4,32\}\$\//)
|
||||
assert.doesNotMatch(profileSource, /<dt>联系电话<\/dt><dd>\{\{ store\.company\.contactPhone/)
|
||||
assert.match(storeSource, /expiresAt: item\.validTo \|\| '长期有效'/)
|
||||
})
|
||||
|
||||
@@ -134,6 +143,11 @@ test('implements member list, role assignment, password reset and status control
|
||||
assert.match(membersSource, /canChangeMemberStatus.*company:member:status/)
|
||||
assert.doesNotMatch(membersSource, /company:member:write/)
|
||||
assert.doesNotMatch(membersSource, /member-metrics/)
|
||||
assert.match(membersSource, /label="登录手机号" prop="phone"/)
|
||||
assert.match(membersSource, /登录手机号 \/ 邮箱/)
|
||||
assert.match(membersSource, /用于该成员手机号登录;修改后会同步认证账号并退出该成员现有会话/)
|
||||
assert.match(membersSource, /phone: \[\{ pattern: \/\^1\[3-9\]\\d\{9\}\$\//)
|
||||
assert.doesNotMatch(membersSource, /label="联系电话" prop="phone"/)
|
||||
})
|
||||
|
||||
test('protects built-in roles and exposes role permission assignment', () => {
|
||||
|
||||
@@ -57,6 +57,10 @@ test('keeps only implemented navigation groups visible', () => {
|
||||
title: '海外仓',
|
||||
children: ['贴标申请', '仓储转运', '货物废弃', '退货换标'],
|
||||
},
|
||||
{
|
||||
title: '订单管理',
|
||||
children: ['派送订单列表'],
|
||||
},
|
||||
{
|
||||
title: '财务结算',
|
||||
children: ['客户账单'],
|
||||
@@ -95,6 +99,7 @@ test('flattens visible leaves with unique paths', () => {
|
||||
'/overseas-warehouse/storage-transfer',
|
||||
'/overseas-warehouse/cargo-disposal',
|
||||
'/overseas-warehouse/return-relabel',
|
||||
'/orders/delivery',
|
||||
'/finance/bills',
|
||||
'/company/profile',
|
||||
'/organization/members',
|
||||
@@ -320,7 +325,6 @@ test('keeps prototype views cataloged while allowing implemented order and overs
|
||||
'order-management',
|
||||
].map((moduleName) => readFileSync(new URL(`../modules/${moduleName}/routes.ts`, import.meta.url), 'utf8')).join('\n')
|
||||
const routes = [
|
||||
['first-leg/import', 'BatchImportView.vue'],
|
||||
['first-leg/quick-order', 'QuickOrderView.vue'],
|
||||
['cross-border/procurement', 'ProcurementView.vue'],
|
||||
['cross-border/domestic-trucking', 'DomesticTruckingView.vue'],
|
||||
@@ -335,7 +339,7 @@ test('keeps prototype views cataloged while allowing implemented order and overs
|
||||
]
|
||||
|
||||
for (const [path, view] of routes) {
|
||||
if (path === 'first-leg/quick-order' || path.startsWith('overseas-warehouse/')) {
|
||||
if (path === 'first-leg/quick-order' || path.startsWith('overseas-warehouse/') || path === 'orders/delivery') {
|
||||
assert.match(routerSource, new RegExp(`path: '${path}'[\\s\\S]*?\\./views/${view.replace('.', '\\.')}[\\s\\S]*?release: 'active'[\\s\\S]*?permission:`))
|
||||
assert.equal(getNavigationLeaves(clientNavigation).some((item) => item.path === `/${path}`), true)
|
||||
continue
|
||||
@@ -343,4 +347,5 @@ test('keeps prototype views cataloged while allowing implemented order and overs
|
||||
assert.match(routerSource, new RegExp(`path: '${path}'[\\s\\S]*?\\./views/${view.replace('.', '\\.')}[\\s\\S]*?release: 'prototype'[\\s\\S]*?permission:`))
|
||||
assert.equal(getNavigationLeaves(clientNavigation).some((item) => item.path === `/${path}`), false)
|
||||
}
|
||||
assert.doesNotMatch(routerSource, /first-leg\/import|BatchImportView|merchant:first-leg:import/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user