Merge branch 'dev' of http://8.163.30.128:10082/html/client-web into dev
# Conflicts: # src/modules/overseas-warehouse/api/warehouseServices.ts # src/modules/overseas-warehouse/views/WarehouseServiceView.vue
This commit is contained in:
+31
-11
@@ -1,32 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import { onBeforeUnmount, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
|
||||
let readyInterval: number | null = null
|
||||
let expectedAdminOrigin = ''
|
||||
let impersonationExchangePending = false
|
||||
|
||||
function configuredImpersonationOrigin() {
|
||||
const queryOrigin = new URLSearchParams(window.location.search).get('impersonationOrigin')
|
||||
if (queryOrigin) {
|
||||
try { return new URL(queryOrigin).origin } catch { return '' }
|
||||
}
|
||||
return String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim() || window.location.origin
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const configuredAdminOrigin = String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim()
|
||||
const targetOrigin = configuredAdminOrigin || window.location.origin
|
||||
const announceReady = () => window.opener?.postMessage({ source: 'chongchong-client', type: 'impersonation-ready' }, targetOrigin)
|
||||
expectedAdminOrigin = configuredImpersonationOrigin()
|
||||
if (!window.opener || !expectedAdminOrigin) return
|
||||
window.addEventListener('message', acceptImpersonationTicket)
|
||||
const announceReady = () => window.opener?.postMessage({ source: 'chongchong-client', type: 'impersonation-ready' }, expectedAdminOrigin)
|
||||
announceReady()
|
||||
readyInterval = window.setInterval(announceReady, 300)
|
||||
window.setTimeout(() => {
|
||||
if (readyInterval !== null) window.clearInterval(readyInterval)
|
||||
readyInterval = null
|
||||
}, 10000)
|
||||
window.addEventListener('message', stopReadyAnnouncement)
|
||||
})
|
||||
|
||||
function stopReadyAnnouncement(event: MessageEvent<unknown>) {
|
||||
const configuredAdminOrigin = String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim()
|
||||
const expectedAdminOrigin = configuredAdminOrigin || window.location.origin
|
||||
const message = event.data as { source?: string; type?: string } | null
|
||||
if (event.source !== window.opener || event.origin !== expectedAdminOrigin || message?.source !== 'chongchong-admin' || message.type !== 'impersonation-ticket' || readyInterval === null) return
|
||||
window.clearInterval(readyInterval)
|
||||
async function acceptImpersonationTicket(event: MessageEvent<unknown>) {
|
||||
const message = event.data as { source?: string; type?: string; code?: string } | null
|
||||
if (event.source !== window.opener || event.origin !== expectedAdminOrigin || message?.source !== 'chongchong-admin' || message.type !== 'impersonation-ticket' || !message.code || impersonationExchangePending) return
|
||||
impersonationExchangePending = true
|
||||
if (readyInterval !== null) window.clearInterval(readyInterval)
|
||||
readyInterval = null
|
||||
const auth = useAuthStore()
|
||||
try {
|
||||
await auth.acceptImpersonationCode(message.code)
|
||||
window.opener?.postMessage({ source: 'chongchong-client', type: 'impersonation-complete' }, expectedAdminOrigin)
|
||||
window.location.replace('/')
|
||||
} catch {
|
||||
window.opener?.postMessage({ source: 'chongchong-client', type: 'impersonation-failed' }, expectedAdminOrigin)
|
||||
window.location.replace('/login?impersonation=failed')
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (readyInterval !== null) window.clearInterval(readyInterval)
|
||||
window.removeEventListener('message', stopReadyAnnouncement)
|
||||
window.removeEventListener('message', acceptImpersonationTicket)
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
+7
-4
@@ -46,6 +46,11 @@ function mapError(error: unknown) {
|
||||
|
||||
let refreshPromise: Promise<string> | null = null
|
||||
let redirectingToLogin = false
|
||||
const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange']
|
||||
|
||||
function isPublicAuthRequest(url?: string) {
|
||||
return publicAuthPaths.some((path) => url?.endsWith(path))
|
||||
}
|
||||
|
||||
function redirectToLoginAfterSessionExpiry() {
|
||||
if (redirectingToLogin || typeof window === 'undefined') return
|
||||
@@ -98,7 +103,7 @@ export async function refreshAuthenticatedSession() {
|
||||
|
||||
transport.interceptors.request.use((config) => {
|
||||
const token = getAccessToken()
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
if (token && !isPublicAuthRequest(config.url)) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
@@ -106,9 +111,7 @@ transport.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError) => {
|
||||
const request = error.config as RetryableRequest | undefined
|
||||
const publicAuthPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/auth/impersonations/exchange']
|
||||
const isPublicAuthRequest = publicAuthPaths.some((path) => request?.url?.endsWith(path))
|
||||
if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest) {
|
||||
if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest(request.url)) {
|
||||
throw mapError(error)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
ACCESS_TOKEN_KEY,
|
||||
REFRESH_TOKEN_KEY,
|
||||
REMEMBERED_LOGIN_ID_KEY,
|
||||
clearSessionTokens,
|
||||
getRememberedLoginId,
|
||||
persistRememberedLoginId,
|
||||
persistTokenPair,
|
||||
} from './session.ts'
|
||||
|
||||
function storage() {
|
||||
const values = new Map()
|
||||
return {
|
||||
getItem: (key) => values.get(key) ?? null,
|
||||
setItem: (key, value) => values.set(key, String(value)),
|
||||
removeItem: (key) => values.delete(key),
|
||||
}
|
||||
}
|
||||
|
||||
function installStorage() {
|
||||
globalThis.localStorage = storage()
|
||||
globalThis.sessionStorage = storage()
|
||||
}
|
||||
|
||||
test('persists the normal rotating session independently of remembered account preference', () => {
|
||||
installStorage()
|
||||
persistTokenPair({
|
||||
tokenType: 'Bearer',
|
||||
accessToken: 'access-token',
|
||||
expiresIn: 1440,
|
||||
refreshToken: 'refresh-token',
|
||||
refreshExpiresIn: 604800,
|
||||
})
|
||||
|
||||
assert.equal(localStorage.getItem(ACCESS_TOKEN_KEY), 'access-token')
|
||||
assert.equal(localStorage.getItem(REFRESH_TOKEN_KEY), 'refresh-token')
|
||||
assert.equal(sessionStorage.getItem(ACCESS_TOKEN_KEY), null)
|
||||
assert.equal(sessionStorage.getItem(REFRESH_TOKEN_KEY), null)
|
||||
})
|
||||
|
||||
test('remembers only the login id and keeps it when session tokens are cleared', () => {
|
||||
installStorage()
|
||||
persistRememberedLoginId('merchant-user', true)
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, 'access-token')
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, 'refresh-token')
|
||||
|
||||
clearSessionTokens()
|
||||
|
||||
assert.equal(getRememberedLoginId(), 'merchant-user')
|
||||
assert.equal(localStorage.getItem(REMEMBERED_LOGIN_ID_KEY), 'merchant-user')
|
||||
assert.equal(localStorage.getItem(ACCESS_TOKEN_KEY), null)
|
||||
assert.equal(localStorage.getItem(REFRESH_TOKEN_KEY), null)
|
||||
|
||||
persistRememberedLoginId('merchant-user', false)
|
||||
assert.equal(getRememberedLoginId(), '')
|
||||
})
|
||||
+15
-6
@@ -2,6 +2,7 @@ export const ACCESS_TOKEN_KEY = 'cc-client-access-token'
|
||||
export const REFRESH_TOKEN_KEY = 'cc-client-refresh-token'
|
||||
export const OVERSEAS_ACCESS_TOKEN_KEY = 'cc-client-overseas-access-token'
|
||||
export const OVERSEAS_EXPIRES_AT_KEY = 'cc-client-overseas-expires-at'
|
||||
export const REMEMBERED_LOGIN_ID_KEY = 'cc-client-remembered-login-id'
|
||||
|
||||
export interface TokenPair {
|
||||
tokenType: string
|
||||
@@ -33,17 +34,25 @@ export function getOverseasAccessToken() {
|
||||
return storage.getItem(OVERSEAS_ACCESS_TOKEN_KEY) || ''
|
||||
}
|
||||
|
||||
export function persistTokenPair(tokens: TokenPair, remember?: boolean) {
|
||||
const storage = remember === undefined ? activeStorage() : remember ? localStorage : sessionStorage
|
||||
export function persistTokenPair(tokens: TokenPair) {
|
||||
clearSessionTokens()
|
||||
storage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken)
|
||||
storage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, tokens.accessToken)
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refreshToken)
|
||||
if (tokens.overseasAccessToken && Number(tokens.overseasExpiresIn) > 0) {
|
||||
storage.setItem(OVERSEAS_ACCESS_TOKEN_KEY, tokens.overseasAccessToken)
|
||||
storage.setItem(OVERSEAS_EXPIRES_AT_KEY, String(Date.now() + Number(tokens.overseasExpiresIn) * 1000))
|
||||
localStorage.setItem(OVERSEAS_ACCESS_TOKEN_KEY, tokens.overseasAccessToken)
|
||||
localStorage.setItem(OVERSEAS_EXPIRES_AT_KEY, String(Date.now() + Number(tokens.overseasExpiresIn) * 1000))
|
||||
}
|
||||
}
|
||||
|
||||
export function getRememberedLoginId() {
|
||||
return localStorage.getItem(REMEMBERED_LOGIN_ID_KEY) || ''
|
||||
}
|
||||
|
||||
export function persistRememberedLoginId(loginId: string, remember: boolean) {
|
||||
if (remember) localStorage.setItem(REMEMBERED_LOGIN_ID_KEY, loginId)
|
||||
else localStorage.removeItem(REMEMBERED_LOGIN_ID_KEY)
|
||||
}
|
||||
|
||||
export function persistTemporaryAccessToken(accessToken: string) {
|
||||
clearSessionTokens()
|
||||
sessionStorage.setItem(ACCESS_TOKEN_KEY, accessToken)
|
||||
|
||||
Vendored
+1
@@ -19,6 +19,7 @@ declare module 'vue' {
|
||||
DataTable: typeof import('./components/common/DataTable.vue')['default']
|
||||
ElAlert: typeof import('element-plus/es')['ElAlert']
|
||||
ElButton: typeof import('element-plus/es')['ElButton']
|
||||
ElCard: typeof import('element-plus/es')['ElCard']
|
||||
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
|
||||
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
|
||||
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
|
||||
|
||||
@@ -14,6 +14,12 @@ test('allows 50 MiB uploads with multipart overhead through both API proxies', (
|
||||
assert.equal(config.match(/client_max_body_size 55m;/g)?.length, 2)
|
||||
})
|
||||
|
||||
test('does not cache the SPA entry while keeping hashed assets immutable', () => {
|
||||
assert.match(config, /location = \/index\.html[\s\S]*no-store, no-cache, must-revalidate/)
|
||||
assert.match(config, /location \/assets\/[\s\S]*max-age=31536000, immutable/)
|
||||
assert.match(config, /location \/ \{[\s\S]*no-store, no-cache, must-revalidate[\s\S]*try_files \$uri \$uri\/ \/index\.html/)
|
||||
})
|
||||
|
||||
test('routes domestic and overseas browser APIs to their test gateways without duplicate api prefixes', () => {
|
||||
assert.match(testCompose, /API_UPSTREAM: "http:\/\/test-api-cn\.buggoglobal\.com"/)
|
||||
assert.match(testCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/test-api-global\.buggoglobal\.com"/)
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const sourceRoot = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
function collectVueFiles(directory) {
|
||||
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
|
||||
const target = path.join(directory, entry.name)
|
||||
return entry.isDirectory() ? collectVueFiles(target) : entry.name.endsWith('.vue') ? [target] : []
|
||||
})
|
||||
}
|
||||
|
||||
function lineNumber(source, offset) {
|
||||
return source.slice(0, offset).split('\n').length
|
||||
}
|
||||
|
||||
function hasLiteralAttribute(tag, name) {
|
||||
return new RegExp(`\\s${name}(?:\\s|=|/?>)`).test(tag)
|
||||
}
|
||||
|
||||
function isEditableNativeTextInput(tag) {
|
||||
if (hasLiteralAttribute(tag, 'disabled') || hasLiteralAttribute(tag, 'readonly')) return false
|
||||
const type = tag.match(/\stype\s*=\s*["']([^"']+)["']/)?.[1]?.toLowerCase() ?? 'text'
|
||||
return ['text', 'search', 'email', 'tel', 'url', 'password'].includes(type)
|
||||
}
|
||||
|
||||
test('all editable text inputs provide a user-facing placeholder', () => {
|
||||
const violations = []
|
||||
|
||||
for (const file of collectVueFiles(sourceRoot)) {
|
||||
const source = readFileSync(file, 'utf8')
|
||||
const template = source.match(/<template(?:\s[^>]*)?>([\s\S]*)<\/template>/)?.[1] ?? ''
|
||||
const withoutComments = template.replace(/<!--[\s\S]*?-->/g, '')
|
||||
const attributeContent = String.raw`(?:[^>"']|"[^"]*"|'[^']*')*`
|
||||
const tagPattern = new RegExp(`<el-input(?:-number)?\\b${attributeContent}/?>|<(?:input|textarea)\\b${attributeContent}/?>`, 'g')
|
||||
|
||||
for (const match of withoutComments.matchAll(tagPattern)) {
|
||||
const tag = match[0]
|
||||
const isElementInput = tag.startsWith('<el-input')
|
||||
const editable = isElementInput
|
||||
? !hasLiteralAttribute(tag, 'disabled') && !hasLiteralAttribute(tag, 'readonly')
|
||||
: isEditableNativeTextInput(tag)
|
||||
|
||||
if (editable && !/\s:?(?:placeholder)\s*=/.test(tag)) {
|
||||
violations.push(`${path.relative(sourceRoot, file)}:${lineNumber(withoutComments, match.index ?? 0)} ${tag.replace(/\s+/g, ' ')}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.deepEqual(violations, [], `Editable text inputs without placeholders:\n${violations.join('\n')}`)
|
||||
})
|
||||
@@ -2,7 +2,7 @@ import { computed, ref } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import type { CurrentUser } from '@chongchong/shared'
|
||||
import { exchangeImpersonationCode, loginMerchant, logoutMerchant } from '@/modules/auth/api/auth'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { ApiError, refreshAuthenticatedSession } from '@/api/http'
|
||||
import {
|
||||
FRONTEND_TO_MERCHANT_PERMISSION_CODES,
|
||||
hasMappedMerchantPermission,
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
getTenantProfile,
|
||||
updateMerchantPersonalProfile,
|
||||
} from '@/modules/tenant-management/public'
|
||||
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTemporaryAccessToken, persistTokenPair } from '@/api/session'
|
||||
import { decodeMerchantToken, type MerchantJwtPayload } from '@/modules/auth/utils/jwt'
|
||||
import { clearSessionTokens, getAccessToken, getRefreshToken, persistRememberedLoginId, persistTemporaryAccessToken, persistTokenPair } from '@/api/session'
|
||||
import { decodeMerchantToken, MerchantTokenExpiredError, type MerchantJwtPayload } from '@/modules/auth/utils/jwt'
|
||||
|
||||
export interface ClientAccountProfile {
|
||||
username: string
|
||||
@@ -103,8 +103,8 @@ export const useAuthStore = defineStore('client-auth', () => {
|
||||
if (token.value) {
|
||||
try {
|
||||
applyToken(token.value)
|
||||
} catch {
|
||||
clearSession()
|
||||
} catch (error) {
|
||||
if (!(error instanceof MerchantTokenExpiredError && getRefreshToken())) clearSession()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,8 +133,20 @@ export const useAuthStore = defineStore('client-auth', () => {
|
||||
async function restoreSession() {
|
||||
if (sessionRestored.value) return
|
||||
if (!isAuthenticated.value) {
|
||||
sessionRestored.value = true
|
||||
return
|
||||
if (!getRefreshToken()) {
|
||||
sessionRestored.value = true
|
||||
return
|
||||
}
|
||||
try {
|
||||
applyToken(await refreshAuthenticatedSession())
|
||||
} catch (error) {
|
||||
if (isExpiredSessionError(error)) {
|
||||
clearSession()
|
||||
throw error
|
||||
}
|
||||
sessionRestored.value = true
|
||||
return
|
||||
}
|
||||
}
|
||||
try {
|
||||
await loadSessionContext()
|
||||
@@ -152,7 +164,8 @@ export const useAuthStore = defineStore('client-auth', () => {
|
||||
async function login(loginId: string, password: string, remember = false) {
|
||||
const tokens = await loginMerchant(loginId, password)
|
||||
const decoded = decodeMerchantToken(tokens.accessToken)
|
||||
persistTokenPair(tokens, remember)
|
||||
persistTokenPair(tokens)
|
||||
persistRememberedLoginId(loginId, remember)
|
||||
token.value = tokens.accessToken
|
||||
payload.value = decoded
|
||||
user.value = currentUser(decoded)
|
||||
@@ -223,15 +236,6 @@ export const useAuthStore = defineStore('client-auth', () => {
|
||||
}
|
||||
|
||||
window.addEventListener('cc-client-session-expired', clearSession)
|
||||
window.addEventListener('message', (event: MessageEvent<unknown>) => {
|
||||
const configuredAdminOrigin = String(import.meta.env.VITE_ADMIN_WEB_ORIGIN || '').trim()
|
||||
const expectedAdminOrigin = configuredAdminOrigin || window.location.origin
|
||||
const message = event.data as { source?: string; type?: string; code?: string } | null
|
||||
if (event.source !== window.opener || event.origin !== expectedAdminOrigin || message?.source !== 'chongchong-admin' || message.type !== 'impersonation-ticket' || !message.code) return
|
||||
void acceptImpersonationCode(message.code)
|
||||
.then(() => window.location.replace('/'))
|
||||
.catch(() => window.location.replace('/login?impersonation=failed'))
|
||||
})
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === 'cc-client-access-token' || event.key === 'cc-client-refresh-token') applyStoredSession()
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { decodeMerchantToken } from './jwt.ts'
|
||||
import { decodeMerchantToken, MerchantTokenExpiredError } from './jwt.ts'
|
||||
|
||||
function tokenFor(payload) {
|
||||
const encoded = Buffer.from(JSON.stringify(payload)).toString('base64url')
|
||||
@@ -29,5 +29,5 @@ test('rejects malformed tokens', () => {
|
||||
})
|
||||
|
||||
test('rejects expired tokens', () => {
|
||||
assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, exp: 1 })), /登录状态已过期/)
|
||||
assert.throws(() => decodeMerchantToken(tokenFor({ ...payload, exp: 1 })), MerchantTokenExpiredError)
|
||||
})
|
||||
|
||||
@@ -9,6 +9,13 @@ export interface MerchantJwtPayload {
|
||||
exp: number
|
||||
}
|
||||
|
||||
export class MerchantTokenExpiredError extends Error {
|
||||
constructor() {
|
||||
super('登录状态已过期')
|
||||
this.name = 'MerchantTokenExpiredError'
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string) {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')
|
||||
@@ -27,10 +34,11 @@ export function decodeMerchantToken(token: string): MerchantJwtPayload {
|
||||
const payload = value as Partial<MerchantJwtPayload>
|
||||
if (!payload.user_id || !payload.username || !payload.tenant_id || !payload.membership_id || !payload.exp) throw new Error()
|
||||
if (payload.app_code !== 'MERCHANT') throw new Error('账号不属于当前应用端')
|
||||
if (payload.exp * 1000 <= Date.now()) throw new Error('登录状态已过期')
|
||||
if (payload.exp * 1000 <= Date.now()) throw new MerchantTokenExpiredError()
|
||||
return payload as MerchantJwtPayload
|
||||
} catch (error) {
|
||||
if (error instanceof Error && ['账号不属于当前应用端', '登录状态已过期'].includes(error.message)) throw error
|
||||
if (error instanceof Error && error.message === '账号不属于当前应用端') throw error
|
||||
if (error instanceof MerchantTokenExpiredError) throw error
|
||||
throw new Error('登录凭证无法解析,请重新登录')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ const httpSource = readFileSync(new URL('../../../api/http.ts', import.meta.url)
|
||||
const sessionSource = readFileSync(new URL('../../../api/session.ts', import.meta.url), 'utf8')
|
||||
const authApiSource = readFileSync(new URL('../api/auth.ts', import.meta.url), 'utf8')
|
||||
const mainSource = readFileSync(new URL('../../../main.ts', import.meta.url), 'utf8')
|
||||
const appSource = readFileSync(new URL('../../../App.vue', import.meta.url), 'utf8')
|
||||
const routerSource = readFileSync(new URL('../../../router/index.ts', import.meta.url), 'utf8')
|
||||
const stylesSource = readFileSync(new URL('../../../styles/index.css', import.meta.url), 'utf8')
|
||||
|
||||
test('uses one prototype-inspired responsive shell for login and registration', () => {
|
||||
@@ -42,10 +44,14 @@ test('matches the frozen username-or-mobile login contract', () => {
|
||||
assert.match(loginSource, /忘记密码/)
|
||||
})
|
||||
|
||||
test('persists both rotating tokens according to the remember choice', () => {
|
||||
test('persists rotating tokens independently and only remembers the login id by choice', () => {
|
||||
assert.match(sessionSource, /ACCESS_TOKEN_KEY/)
|
||||
assert.match(sessionSource, /REFRESH_TOKEN_KEY/)
|
||||
assert.match(sessionSource, /remember \? localStorage : sessionStorage/)
|
||||
assert.match(sessionSource, /localStorage\.setItem\(ACCESS_TOKEN_KEY, tokens\.accessToken\)/)
|
||||
assert.match(sessionSource, /localStorage\.setItem\(REFRESH_TOKEN_KEY, tokens\.refreshToken\)/)
|
||||
assert.match(sessionSource, /persistRememberedLoginId/)
|
||||
assert.match(loginSource, /getRememberedLoginId/)
|
||||
assert.match(loginSource, /记住账号/)
|
||||
assert.match(sessionSource, /clearSessionTokens/)
|
||||
})
|
||||
|
||||
@@ -91,6 +97,7 @@ test('uses real auth endpoints and single-flight refresh handling', () => {
|
||||
assert.match(httpSource, /refreshPromise/)
|
||||
assert.match(httpSource, /\/auth\/refresh/)
|
||||
assert.match(httpSource, /persistTokenPair\(tokens\)/)
|
||||
assert.match(httpSource, /token && !isPublicAuthRequest\(config\.url\)/)
|
||||
assert.match(httpSource, /request\._retriedAfterRefresh = true/)
|
||||
assert.match(httpSource, /return await transport\.request\(request\)/)
|
||||
assert.match(httpSource, /envelope\.code !== '200'/)
|
||||
@@ -100,12 +107,19 @@ test('uses real auth endpoints and single-flight refresh handling', () => {
|
||||
assert.match(httpSource, /error instanceof CrossTabRefreshTimeoutError[\s\S]*window\.dispatchEvent\(new CustomEvent\('cc-client-session-expired'\)\)[\s\S]*redirectToLoginAfterSessionExpiry\(\)/)
|
||||
assert.match(httpSource, /window\.location\.replace\(`\/login\?redirect=\$\{encodeURIComponent\(redirect\)\}`\)/)
|
||||
assert.match(authStoreSource, /decodeMerchantToken/)
|
||||
assert.match(authStoreSource, /MerchantTokenExpiredError && getRefreshToken\(\)/)
|
||||
assert.match(authStoreSource, /applyToken\(await refreshAuthenticatedSession\(\)\)/)
|
||||
assert.doesNotMatch(authStoreSource, /mock-client:/)
|
||||
})
|
||||
|
||||
test('handles impersonation exchange as public authentication and exposes failures', () => {
|
||||
assert.match(httpSource, /publicAuthPaths = \[[^\]]*'\/auth\/impersonations\/exchange'/)
|
||||
assert.match(authStoreSource, /\/login\?impersonation=failed/)
|
||||
assert.match(appSource, /impersonationOrigin/)
|
||||
assert.match(appSource, /await auth\.acceptImpersonationCode\(message\.code\)/)
|
||||
assert.match(appSource, /type: 'impersonation-complete'/)
|
||||
assert.match(appSource, /type: 'impersonation-failed'/)
|
||||
assert.doesNotMatch(authStoreSource, /message\.type !== 'impersonation-ticket'/)
|
||||
assert.match(routerSource, /isImpersonationHandoff[\s\S]*!isImpersonationHandoff/)
|
||||
assert.match(loginSource, /route\.query\.impersonation === 'failed'/)
|
||||
assert.match(loginSource, /代登录失败或授权已失效,请返回管理后台重新发起/)
|
||||
})
|
||||
|
||||
@@ -5,6 +5,7 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { ArrowRight, LockKeyhole, UserRound } from '@lucide/vue'
|
||||
import { useAuthStore } from '@/modules/auth/store/auth'
|
||||
import AuthShell from '@/modules/auth/components/AuthShell.vue'
|
||||
import { getRememberedLoginId } from '@/api/session'
|
||||
|
||||
interface LoginForm {
|
||||
account: string
|
||||
@@ -19,12 +20,13 @@ const formRef = ref<FormInstance>()
|
||||
const loading = ref(false)
|
||||
const impersonationFailed = route.query.impersonation === 'failed'
|
||||
const loginIdentifiersUpdated = route.query.reason === 'identifiers-updated'
|
||||
const rememberedLoginId = getRememberedLoginId()
|
||||
const form = reactive<LoginForm>({
|
||||
account: typeof route.query.account === 'string'
|
||||
? route.query.account
|
||||
: '',
|
||||
: rememberedLoginId,
|
||||
password: '',
|
||||
remember: false,
|
||||
remember: Boolean(rememberedLoginId),
|
||||
})
|
||||
const usernamePattern = /^[A-Za-z][A-Za-z0-9_.@-]{3,63}$/
|
||||
const mainlandMobilePattern = /^1[3-9]\d{9}$/
|
||||
@@ -113,7 +115,7 @@ function showResetHint() {
|
||||
<el-input v-model="form.password" autocomplete="current-password" maxlength="72" type="password" show-password placeholder="请输入 8–72 位密码" :prefix-icon="LockKeyhole" @keyup.enter="submit" />
|
||||
</el-form-item>
|
||||
<div class="auth-form-options">
|
||||
<el-checkbox v-model="form.remember">记住我</el-checkbox>
|
||||
<el-checkbox v-model="form.remember">记住账号</el-checkbox>
|
||||
<button type="button" @click="showResetHint">忘记密码?</button>
|
||||
</div>
|
||||
<el-button native-type="submit" class="auth-submit" type="primary" :loading="loading">登录 <ArrowRight :size="17" /></el-button>
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { overseasApiRequest } from '@/api/overseasHttp'
|
||||
|
||||
export interface PageResult<T> {
|
||||
records: T[]
|
||||
page: number
|
||||
size: number
|
||||
total: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export type CrossBorderBusinessKind = 'procurement' | 'trucking' | 'looseboard'
|
||||
|
||||
export interface BusinessOrderQuery {
|
||||
page: number
|
||||
size: number
|
||||
orderNo?: string
|
||||
status?: string
|
||||
routeKeyword?: string
|
||||
productName?: string
|
||||
purchaseCategory?: string
|
||||
transportType?: string
|
||||
transportMode?: string
|
||||
requestedFrom?: string
|
||||
requestedTo?: string
|
||||
createdFrom?: string
|
||||
createdTo?: string
|
||||
}
|
||||
|
||||
export interface ProcurementSummary {
|
||||
orderNo: string
|
||||
purchaseCategory: string
|
||||
productName: string
|
||||
productSpecification: string | null
|
||||
purchaseQuantity: number
|
||||
budgetUnitPrice: number | null
|
||||
currency: string | null
|
||||
inboundQuantity: number
|
||||
outboundQuantity: number
|
||||
inventoryQuantity: number
|
||||
status: string
|
||||
version: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ProcurementDetail {
|
||||
summary: ProcurementSummary
|
||||
productUrl: string | null
|
||||
receivingAddress: string | null
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
domesticCarrier: string | null
|
||||
domesticTrackingNo: string | null
|
||||
overseasCarrier: string | null
|
||||
overseasTrackingNo: string | null
|
||||
estimatedArrivalAt: string | null
|
||||
remark: string | null
|
||||
reviewRemark: string | null
|
||||
}
|
||||
|
||||
export interface ProcurementCreate {
|
||||
customerName: string
|
||||
purchaseCategory: string
|
||||
productName: string
|
||||
productSpecification?: string
|
||||
productUrl?: string
|
||||
purchaseQuantity: number
|
||||
budgetUnitPrice?: number
|
||||
currency?: string
|
||||
receivingAddress?: string
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
remark?: string
|
||||
idempotencyKey: string
|
||||
}
|
||||
|
||||
export interface TransportSummary {
|
||||
orderNo: string
|
||||
transportType: string
|
||||
transportMode?: string
|
||||
palletRequired?: string
|
||||
origin: string
|
||||
destination: string
|
||||
containerType: string | null
|
||||
palletCount?: number
|
||||
packageCount: number
|
||||
totalWeightKg: number
|
||||
totalVolumeCbm: number
|
||||
budgetAmount: number | null
|
||||
currency: string | null
|
||||
contactName: string | null
|
||||
contactInfo: string | null
|
||||
requestedDate: string | null
|
||||
status: string
|
||||
version: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface TransportDetail {
|
||||
summary: TransportSummary
|
||||
originAddress: string | null
|
||||
destinationAddress: string | null
|
||||
remark: string | null
|
||||
}
|
||||
|
||||
export interface TransportCreate {
|
||||
transportType: string
|
||||
transportMode?: string
|
||||
palletRequired?: string
|
||||
origin: string
|
||||
originAddress?: string
|
||||
destination: string
|
||||
destinationAddress?: string
|
||||
containerType?: string
|
||||
palletCount?: number
|
||||
packageCount: number
|
||||
totalWeightKg: number
|
||||
totalVolumeCbm: number
|
||||
budgetAmount?: number
|
||||
currency?: string
|
||||
contactName?: string
|
||||
contactInfo?: string
|
||||
requestedDate?: string
|
||||
remark?: string
|
||||
idempotencyKey: string
|
||||
}
|
||||
|
||||
export interface TransportUpdate extends Omit<TransportCreate, 'idempotencyKey'> {
|
||||
version: number
|
||||
}
|
||||
|
||||
const endpointByKind: Record<CrossBorderBusinessKind, string> = {
|
||||
procurement: '/merchant/v1/last-leg/overseas-procurements',
|
||||
trucking: '/merchant/v1/last-leg/us-truck-orders',
|
||||
looseboard: '/merchant/v1/last-leg/local-looseboard-orders',
|
||||
}
|
||||
|
||||
function normalizePage<T>(data: PageResult<T>): PageResult<T> {
|
||||
return { ...data, page: Number(data.page), size: Number(data.size), total: Number(data.total), pages: Number(data.pages) }
|
||||
}
|
||||
|
||||
export function listBusinessOrders<T>(kind: CrossBorderBusinessKind, params: BusinessOrderQuery) {
|
||||
return overseasApiRequest<PageResult<T>>({ method: 'GET', url: endpointByKind[kind], params }).then(normalizePage)
|
||||
}
|
||||
|
||||
export function getBusinessOrder<T>(kind: CrossBorderBusinessKind, orderNo: string) {
|
||||
return overseasApiRequest<T>({ method: 'GET', url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}` })
|
||||
}
|
||||
|
||||
export function createProcurement(payload: ProcurementCreate) {
|
||||
return overseasApiRequest<ProcurementDetail>({ method: 'POST', url: endpointByKind.procurement, data: payload })
|
||||
}
|
||||
|
||||
export function createTransport(kind: 'trucking' | 'looseboard', payload: TransportCreate) {
|
||||
return overseasApiRequest<TransportDetail>({ method: 'POST', url: endpointByKind[kind], data: payload })
|
||||
}
|
||||
|
||||
export function updateTransport(kind: 'trucking' | 'looseboard', orderNo: string, payload: TransportUpdate) {
|
||||
return overseasApiRequest<TransportDetail>({
|
||||
method: kind === 'trucking' ? 'PATCH' : 'PUT',
|
||||
url: `${endpointByKind[kind]}/${encodeURIComponent(orderNo)}`,
|
||||
data: payload,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const api = readFileSync(new URL('./api/businessOrders.ts', import.meta.url), 'utf8')
|
||||
const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
|
||||
const view = readFileSync(new URL('./components/BusinessOrdersView.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('activates all three customer cross-border pages with frozen permissions', () => {
|
||||
for (const [path, permission] of [
|
||||
['cross-border/procurement', 'merchant:cross-border:procurement'],
|
||||
['cross-border/domestic-trucking', 'merchant:cross-border:domestic-trucking'],
|
||||
['cross-border/pallet-shipping', 'merchant:cross-border:pallet-shipping'],
|
||||
]) {
|
||||
assert.match(routes, new RegExp(`path: '${path}'[\\s\\S]*?release: 'active'[\\s\\S]*?permission: '${permission}'`))
|
||||
}
|
||||
assert.doesNotMatch(view, /PrototypeFeatureView|mock|customerId|tenantId/i)
|
||||
})
|
||||
|
||||
test('uses only merchant overseas endpoints and keeps retry idempotency stable', () => {
|
||||
assert.match(api, /overseas-procurements/)
|
||||
assert.match(api, /us-truck-orders/)
|
||||
assert.match(api, /local-looseboard-orders/)
|
||||
assert.doesNotMatch(api, /\/platform\/|\/internal\/|customerId|tenantId/)
|
||||
assert.match(view, /const stableSubmitKey = submitKey\.value \|\| idempotencyKey\(\)/)
|
||||
assert.match(view, /submitKey\.value = stableSubmitKey/)
|
||||
assert.match(view, /idempotencyKey: stableSubmitKey/g)
|
||||
})
|
||||
|
||||
test('covers list detail create loading error empty pagination and actions-first contracts', () => {
|
||||
assert.match(view, /mode\.value = 'detail'/)
|
||||
assert.match(view, /mode\.value = 'list'/)
|
||||
assert.match(view, /mode = 'create'/)
|
||||
assert.match(view, /v-loading="loading"/)
|
||||
assert.match(view, /type="error"/)
|
||||
assert.match(view, /empty-text="暂无订单"/)
|
||||
assert.match(view, /<el-pagination/)
|
||||
assert.match(view, /<el-table-column label="操作"[^>]*fixed="left"/)
|
||||
})
|
||||
@@ -0,0 +1,358 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import {
|
||||
createProcurement,
|
||||
createTransport,
|
||||
getBusinessOrder,
|
||||
listBusinessOrders,
|
||||
type CrossBorderBusinessKind,
|
||||
type ProcurementDetail,
|
||||
type ProcurementSummary,
|
||||
type TransportDetail,
|
||||
type TransportSummary,
|
||||
} from '../api/businessOrders'
|
||||
|
||||
const props = defineProps<{ kind: CrossBorderBusinessKind }>()
|
||||
const auth = useAuthStore()
|
||||
|
||||
type BusinessRow = ProcurementSummary | TransportSummary
|
||||
type BusinessDetail = ProcurementDetail | TransportDetail
|
||||
type PageMode = 'list' | 'create' | 'detail'
|
||||
|
||||
interface ColumnDefinition { key: string; label: string; width?: number }
|
||||
|
||||
interface BusinessForm {
|
||||
purchaseCategory: string
|
||||
productName: string
|
||||
productSpecification: string
|
||||
productUrl: string
|
||||
purchaseQuantity: number
|
||||
transportMode: string
|
||||
palletRequired: string
|
||||
transportType: string
|
||||
origin: string
|
||||
originAddress: string
|
||||
destination: string
|
||||
destinationAddress: string
|
||||
containerType: string
|
||||
palletCount: number
|
||||
packageCount: number
|
||||
totalWeightKg: number
|
||||
totalVolumeCbm: number
|
||||
budgetAmount?: number
|
||||
currency: string
|
||||
contactName: string
|
||||
contactInfo: string
|
||||
requestedDate: string
|
||||
receivingAddress: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
const configuration = computed(() => ({
|
||||
procurement: {
|
||||
title: '海外采购',
|
||||
description: '查询并管理海外采购订单',
|
||||
columns: [
|
||||
{ key: 'orderNo', label: '订单号', width: 180 },
|
||||
{ key: 'purchaseCategory', label: '采购品类', width: 130 },
|
||||
{ key: 'productName', label: '商品名称', width: 160 },
|
||||
{ key: 'productSpecification', label: '商品规格', width: 160 },
|
||||
{ key: 'purchaseQuantity', label: '采购数量', width: 100 },
|
||||
{ key: 'budgetUnitPrice', label: '预算单价', width: 110 },
|
||||
{ key: 'status', label: '状态', width: 120 },
|
||||
{ key: 'createdAt', label: '创建时间', width: 170 },
|
||||
] satisfies ColumnDefinition[],
|
||||
},
|
||||
trucking: {
|
||||
title: '美国本土卡',
|
||||
description: '查询并管理美国本土卡订单',
|
||||
columns: transportColumns(false),
|
||||
},
|
||||
looseboard: {
|
||||
title: '美本土散板',
|
||||
description: '查询并管理美本土散板订单',
|
||||
columns: transportColumns(true),
|
||||
},
|
||||
})[props.kind])
|
||||
|
||||
function transportColumns(looseboard: boolean): ColumnDefinition[] {
|
||||
return [
|
||||
{ key: 'orderNo', label: '订单编号', width: 180 },
|
||||
...(looseboard ? [{ key: 'transportMode', label: '运输方式', width: 120 }, { key: 'palletRequired', label: '是否打盘', width: 100 }] : []),
|
||||
{ key: 'transportType', label: '运输类型', width: 130 },
|
||||
{ key: 'origin', label: '起运地', width: 150 },
|
||||
{ key: 'destination', label: '目的地', width: 150 },
|
||||
{ key: 'containerType', label: '柜型', width: 100 },
|
||||
{ key: 'totalWeightKg', label: '总重量(kg)', width: 120 },
|
||||
{ key: 'budgetAmount', label: '预算总价', width: 110 },
|
||||
{ key: 'requestedDate', label: '要求时间', width: 120 },
|
||||
{ key: 'status', label: '状态', width: 130 },
|
||||
]
|
||||
}
|
||||
|
||||
const mode = ref<PageMode>('list')
|
||||
const loading = ref(false)
|
||||
const submitting = ref(false)
|
||||
const submitKey = ref('')
|
||||
const error = ref('')
|
||||
const rows = ref<BusinessRow[]>([])
|
||||
const detail = ref<BusinessDetail | null>(null)
|
||||
const formRef = ref<FormInstance>()
|
||||
const query = reactive({ page: 1, size: 20, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' })
|
||||
const total = ref(0)
|
||||
|
||||
const form = reactive<BusinessForm>(emptyForm())
|
||||
const rules: FormRules<BusinessForm> = {
|
||||
purchaseCategory: [{ required: true, message: '请输入采购品类', trigger: 'blur' }],
|
||||
productName: [{ required: true, message: '请输入商品名称', trigger: 'blur' }],
|
||||
purchaseQuantity: [{ required: true, type: 'number', min: 1, message: '采购数量至少为 1', trigger: 'change' }],
|
||||
origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }],
|
||||
destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
|
||||
palletCount: [{ required: true, type: 'number', min: 1, message: '托盘数至少为 1', trigger: 'change' }],
|
||||
contactName: [{ required: true, message: '请输入联系人', trigger: 'blur' }],
|
||||
contactInfo: [{ required: true, message: '请输入联系电话或联系方式', trigger: 'blur' }],
|
||||
requestedDate: [{ required: true, message: '请选择要求时间', trigger: 'change' }],
|
||||
}
|
||||
|
||||
const detailFields = computed(() => {
|
||||
if (!detail.value) return []
|
||||
const summary = detail.value.summary
|
||||
if (props.kind === 'procurement') {
|
||||
const current = detail.value as ProcurementDetail
|
||||
return [
|
||||
['订单号', summary.orderNo], ['采购品类', read(summary, 'purchaseCategory')],
|
||||
['商品名称', read(summary, 'productName')], ['商品规格', read(summary, 'productSpecification')],
|
||||
['采购数量', read(summary, 'purchaseQuantity')], ['预算单价', money(read(summary, 'budgetUnitPrice'), read(summary, 'currency'))],
|
||||
['联系人', current.contactName], ['联系电话', current.contactPhone],
|
||||
['收货地址', current.receivingAddress], ['商品链接', current.productUrl],
|
||||
['国内运单', joinText(current.domesticCarrier, current.domesticTrackingNo)],
|
||||
['海外运单', joinText(current.overseasCarrier, current.overseasTrackingNo)],
|
||||
['状态', statusLabel(summary.status)], ['备注', current.remark],
|
||||
]
|
||||
}
|
||||
const current = detail.value as TransportDetail
|
||||
return [
|
||||
['订单编号', summary.orderNo], ['运输方式', read(summary, 'transportMode')],
|
||||
['是否打盘', yesNo(read(summary, 'palletRequired'))], ['运输类型', transportTypeLabel(read(summary, 'transportType'))],
|
||||
['起运地', read(summary, 'origin')], ['起运地址', current.originAddress],
|
||||
['目的地', read(summary, 'destination')], ['目的地址', current.destinationAddress],
|
||||
['柜型', read(summary, 'containerType')], ['托盘数', read(summary, 'palletCount')],
|
||||
['件数', read(summary, 'packageCount')], ['总重量(kg)', read(summary, 'totalWeightKg')],
|
||||
['总体积(m³)', read(summary, 'totalVolumeCbm')], ['预算总价', money(read(summary, 'budgetAmount'), read(summary, 'currency'))],
|
||||
['联系人', read(summary, 'contactName')], ['联系方式', read(summary, 'contactInfo')],
|
||||
['要求时间', read(summary, 'requestedDate')], ['状态', statusLabel(summary.status)], ['备注', current.remark],
|
||||
]
|
||||
})
|
||||
|
||||
function emptyForm(): BusinessForm {
|
||||
return {
|
||||
purchaseCategory: '', productName: '', productSpecification: '', productUrl: '', purchaseQuantity: 1,
|
||||
transportMode: 'SHARED_TRUCK', palletRequired: 'NO', transportType: 'PORT_PICKUP', origin: '', originAddress: '',
|
||||
destination: '', destinationAddress: '', containerType: '', palletCount: 1, packageCount: 0,
|
||||
totalWeightKg: 0, totalVolumeCbm: 0, currency: 'USD', contactName: '', contactInfo: '', requestedDate: '',
|
||||
receivingAddress: '', remark: '',
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() { Object.assign(form, emptyForm()); submitKey.value = ''; formRef.value?.clearValidate() }
|
||||
function read(source: object, key: string) { return Reflect.get(source, key) as unknown }
|
||||
function text(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
function joinText(...values: unknown[]) { const result = values.filter(Boolean).join(' / '); return result || '—' }
|
||||
function money(amount: unknown, currency: unknown) { return amount === null || amount === undefined ? '—' : `${text(currency)} ${Number(amount).toFixed(2)}` }
|
||||
function dateTime(value: unknown) { return value ? new Date(String(value)).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
function yesNo(value: unknown) { return value === 'YES' ? '是' : value === 'NO' ? '否' : text(value) }
|
||||
function transportTypeLabel(value: unknown) { return ({ PORT_PICKUP: '港口提柜', WAREHOUSE_TO_WAREHOUSE: '仓到仓', WAREHOUSE_TO_DOOR: '仓到门' } as Record<string, string>)[String(value)] || text(value) }
|
||||
function transportModeLabel(value: unknown) { return ({ SHARED_TRUCK: '拼车', PRIORITY_CONSOLIDATION: '优先拼载' } as Record<string, string>)[String(value)] || text(value) }
|
||||
function statusLabel(value: unknown) {
|
||||
return ({ PENDING_REVIEW: '待审核', APPROVED: '已通过', REJECTED: '已拒绝', PENDING_ACCEPTANCE: '待接单', PROCESSING: '处理中', IN_TRANSIT: '运输中', COMPLETED: '已完成', CANCELLED: '已取消' } as Record<string, string>)[String(value)] || text(value)
|
||||
}
|
||||
function displayCell(candidate: unknown, key: string) {
|
||||
const row = candidate as BusinessRow
|
||||
const value = read(row, key)
|
||||
if (key === 'status') return statusLabel(value)
|
||||
if (key === 'transportType') return transportTypeLabel(value)
|
||||
if (key === 'transportMode') return transportModeLabel(value)
|
||||
if (key === 'palletRequired') return yesNo(value)
|
||||
if (key === 'createdAt') return dateTime(value)
|
||||
if (key === 'budgetUnitPrice' || key === 'budgetAmount') return money(value, read(row, 'currency'))
|
||||
return text(value)
|
||||
}
|
||||
function idempotencyKey() { return globalThis.crypto?.randomUUID?.() || `client-${Date.now()}-${Math.random().toString(16).slice(2)}` }
|
||||
function errorMessage(cause: unknown) { return cause instanceof Error ? cause.message : '请求失败,请稍后重试' }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const params = {
|
||||
page: query.page, size: query.size, orderNo: query.orderNo || undefined, status: query.status || undefined,
|
||||
routeKeyword: query.routeKeyword || undefined, productName: query.productName || undefined,
|
||||
purchaseCategory: query.purchaseCategory || undefined,
|
||||
}
|
||||
const result = props.kind === 'procurement'
|
||||
? await listBusinessOrders<ProcurementSummary>('procurement', params)
|
||||
: await listBusinessOrders<TransportSummary>(props.kind, params)
|
||||
rows.value = result.records
|
||||
total.value = result.total
|
||||
} catch (cause) {
|
||||
error.value = errorMessage(cause)
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function resetQuery() {
|
||||
Object.assign(query, { page: 1, orderNo: '', routeKeyword: '', status: '', productName: '', purchaseCategory: '' })
|
||||
void load()
|
||||
}
|
||||
|
||||
async function openDetail(candidate: unknown) {
|
||||
const row = candidate as BusinessRow
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
detail.value = props.kind === 'procurement'
|
||||
? await getBusinessOrder<ProcurementDetail>('procurement', row.orderNo)
|
||||
: await getBusinessOrder<TransportDetail>(props.kind, row.orderNo)
|
||||
mode.value = 'detail'
|
||||
} catch (cause) { ElMessage.error(errorMessage(cause)) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (!await formRef.value?.validate().catch(() => false)) return
|
||||
submitting.value = true
|
||||
const stableSubmitKey = submitKey.value || idempotencyKey()
|
||||
submitKey.value = stableSubmitKey
|
||||
try {
|
||||
if (props.kind === 'procurement') {
|
||||
await createProcurement({
|
||||
customerName: auth.user?.tenantName || '',
|
||||
purchaseCategory: form.purchaseCategory, productName: form.productName,
|
||||
productSpecification: form.productSpecification || undefined, productUrl: form.productUrl || undefined,
|
||||
purchaseQuantity: form.purchaseQuantity, budgetUnitPrice: form.budgetAmount,
|
||||
currency: form.currency || undefined, receivingAddress: form.receivingAddress || undefined,
|
||||
contactName: form.contactName, contactPhone: form.contactInfo, remark: form.remark || undefined,
|
||||
idempotencyKey: stableSubmitKey,
|
||||
})
|
||||
} else {
|
||||
const base = {
|
||||
transportType: form.transportType, origin: form.origin, originAddress: form.originAddress || undefined,
|
||||
destination: form.destination, destinationAddress: form.destinationAddress || undefined,
|
||||
containerType: form.containerType || undefined, packageCount: form.packageCount,
|
||||
totalWeightKg: form.totalWeightKg, totalVolumeCbm: form.totalVolumeCbm,
|
||||
budgetAmount: form.budgetAmount, currency: form.currency || undefined,
|
||||
contactName: form.contactName || undefined, contactInfo: form.contactInfo || undefined,
|
||||
requestedDate: form.requestedDate || undefined, remark: form.remark || undefined,
|
||||
idempotencyKey: stableSubmitKey,
|
||||
}
|
||||
await createTransport(props.kind, props.kind === 'trucking'
|
||||
? { ...base, palletCount: form.palletCount }
|
||||
: { ...base, transportMode: form.transportMode, palletRequired: form.palletRequired })
|
||||
}
|
||||
ElMessage.success('订单提交成功')
|
||||
mode.value = 'list'
|
||||
resetForm()
|
||||
await load()
|
||||
} catch (cause) { ElMessage.error(errorMessage(cause)) }
|
||||
finally { submitting.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => void load())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-loading="loading" class="business-orders" :aria-busy="loading">
|
||||
<header class="page-head">
|
||||
<div>
|
||||
<h2>{{ mode === 'detail' ? `${configuration.title}订单详情` : mode === 'create' ? `${configuration.title}下单` : configuration.title }}</h2>
|
||||
<p>{{ mode === 'list' ? configuration.description : mode === 'create' ? '请填写订单信息,带 * 项为必填项' : `订单号:${detail?.summary.orderNo || '—'}` }}</p>
|
||||
</div>
|
||||
<el-button v-if="mode !== 'list'" @click="mode = 'list'">返回列表</el-button>
|
||||
<el-button v-else type="primary" @click="resetForm(); mode = 'create'">新增下单</el-button>
|
||||
</header>
|
||||
|
||||
<template v-if="mode === 'list'">
|
||||
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false">
|
||||
<template #default><el-button size="small" @click="load">重新加载</el-button></template>
|
||||
</el-alert>
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form inline @submit.prevent="query.page = 1; load()">
|
||||
<el-form-item label="订单号"><el-input v-model.trim="query.orderNo" clearable placeholder="请输入订单号" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'procurement'" label="采购品类"><el-input v-model.trim="query.purchaseCategory" clearable placeholder="请输入采购品类" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'procurement'" label="商品名称"><el-input v-model.trim="query.productName" clearable placeholder="请输入商品名称" /></el-form-item>
|
||||
<el-form-item v-else label="运输路线"><el-input v-model.trim="query.routeKeyword" clearable placeholder="起运地或目的地" /></el-form-item>
|
||||
<el-form-item label="状态"><el-input v-model.trim="query.status" clearable placeholder="请输入状态" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" native-type="submit">查询</el-button><el-button @click="resetQuery">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card shadow="never">
|
||||
<el-table :data="rows" empty-text="暂无订单" border>
|
||||
<el-table-column label="操作" width="90" fixed="left"><template #default="scope"><el-button link type="primary" @click="openDetail(scope.row)">查看</el-button></template></el-table-column>
|
||||
<el-table-column v-for="column in configuration.columns" :key="column.key" :label="column.label" :width="column.width" show-overflow-tooltip>
|
||||
<template #default="scope">{{ displayCell(scope.row, column.key) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" class="pagination" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next" @change="load" />
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<el-card v-else-if="mode === 'detail'" shadow="never">
|
||||
<dl class="detail-grid">
|
||||
<div v-for="field in detailFields" :key="String(field[0])"><dt>{{ field[0] }}</dt><dd>{{ text(field[1]) }}</dd></div>
|
||||
</dl>
|
||||
</el-card>
|
||||
|
||||
<el-card v-else shadow="never" class="form-card">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
|
||||
<template v-if="kind === 'procurement'">
|
||||
<el-form-item label="采购品类" prop="purchaseCategory"><el-input v-model.trim="form.purchaseCategory" maxlength="128" placeholder="请输入采购品类" /></el-form-item>
|
||||
<el-form-item label="商品名称" prop="productName"><el-input v-model.trim="form.productName" maxlength="255" placeholder="请输入商品名称" /></el-form-item>
|
||||
<el-form-item label="商品规格"><el-input v-model.trim="form.productSpecification" maxlength="500" placeholder="请输入规格、型号等信息" /></el-form-item>
|
||||
<el-form-item label="商品链接"><el-input v-model.trim="form.productUrl" maxlength="2000" placeholder="请输入商品链接" /></el-form-item>
|
||||
<el-form-item label="采购数量" prop="purchaseQuantity"><el-input-number v-model="form.purchaseQuantity" :min="1" placeholder="请输入数量" /></el-form-item>
|
||||
<el-form-item label="收货地址"><el-input v-model.trim="form.receivingAddress" maxlength="1000" placeholder="请输入收货地址" /></el-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="运输方式"><el-radio-group v-model="form.transportMode"><el-radio-button value="SHARED_TRUCK">拼车</el-radio-button><el-radio-button value="PRIORITY_CONSOLIDATION">优先拼载</el-radio-button></el-radio-group></el-form-item>
|
||||
<el-form-item v-if="kind === 'looseboard'" label="是否打盘"><el-radio-group v-model="form.palletRequired"><el-radio-button value="NO">否</el-radio-button><el-radio-button value="YES">是</el-radio-button></el-radio-group></el-form-item>
|
||||
<el-form-item label="运输类型"><el-select v-model="form.transportType"><el-option label="港口提柜" value="PORT_PICKUP" /><el-option label="仓到仓" value="WAREHOUSE_TO_WAREHOUSE" /><el-option label="仓到门" value="WAREHOUSE_TO_DOOR" /></el-select></el-form-item>
|
||||
<el-form-item label="起运地" prop="origin"><el-input v-model.trim="form.origin" maxlength="255" placeholder="请输入起运地" /></el-form-item>
|
||||
<el-form-item label="起运地址"><el-input v-model.trim="form.originAddress" maxlength="500" placeholder="请输入详细起运地址" /></el-form-item>
|
||||
<el-form-item label="目的地" prop="destination"><el-input v-model.trim="form.destination" maxlength="255" placeholder="请输入目的地" /></el-form-item>
|
||||
<el-form-item label="目的地址"><el-input v-model.trim="form.destinationAddress" maxlength="500" placeholder="请输入详细目的地址" /></el-form-item>
|
||||
<el-form-item label="柜型"><el-input v-model.trim="form.containerType" maxlength="32" placeholder="请输入柜型" /></el-form-item>
|
||||
<el-form-item v-if="kind === 'trucking'" label="托盘数" prop="palletCount"><el-input-number v-model="form.palletCount" :min="1" placeholder="请输入托盘数" /></el-form-item>
|
||||
<el-form-item label="件数"><el-input-number v-model="form.packageCount" :min="0" placeholder="请输入件数" /></el-form-item>
|
||||
<el-form-item label="总重量(kg)"><el-input-number v-model="form.totalWeightKg" :min="0" :precision="2" placeholder="请输入总重量" /></el-form-item>
|
||||
<el-form-item label="总体积(m³)"><el-input-number v-model="form.totalVolumeCbm" :min="0" :precision="3" placeholder="请输入总体积" /></el-form-item>
|
||||
<el-form-item label="要求时间" :prop="kind === 'trucking' ? 'requestedDate' : undefined"><el-date-picker v-model="form.requestedDate" type="date" value-format="YYYY-MM-DD" /></el-form-item>
|
||||
</template>
|
||||
<el-form-item :label="kind === 'procurement' ? '预算单价' : '预算金额'"><el-input-number v-model="form.budgetAmount" :min="0" :precision="2" placeholder="请输入预算金额" /><el-select v-model="form.currency" class="currency"><el-option label="USD" value="USD" /><el-option label="CNY" value="CNY" /></el-select></el-form-item>
|
||||
<el-form-item label="联系人" prop="contactName"><el-input v-model.trim="form.contactName" maxlength="128" placeholder="请输入联系人" /></el-form-item>
|
||||
<el-form-item label="联系方式" prop="contactInfo"><el-input v-model.trim="form.contactInfo" maxlength="128" placeholder="请输入电话、微信或邮箱" /></el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="请输入其他要求" /></el-form-item>
|
||||
<el-form-item><el-button @click="mode = 'list'">取消</el-button><el-button type="primary" :loading="submitting" @click="submit">提交订单</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.business-orders { display: grid; gap: 18px; }
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
||||
.page-head h2 { margin: 0; color: #111827; font-size: 26px; }
|
||||
.page-head p { margin: 7px 0 0; color: #64748b; font-size: 14px; }
|
||||
.filter-card :deep(.el-form-item) { margin-bottom: 0; }
|
||||
.filter-card :deep(.el-input) { width: 190px; }
|
||||
.pagination { justify-content: flex-end; margin-top: 18px; }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 0; }
|
||||
.detail-grid div { min-width: 0; padding: 18px 20px; border-right: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5; }
|
||||
.detail-grid dt { margin-bottom: 7px; color: #8492a6; font-size: 13px; }
|
||||
.detail-grid dd { margin: 0; overflow-wrap: anywhere; color: #1f2937; line-height: 1.55; }
|
||||
.form-card :deep(.el-form) { max-width: 860px; }
|
||||
.form-card :deep(.el-input), .form-card :deep(.el-textarea), .form-card :deep(.el-select) { width: 100%; }
|
||||
.form-card .currency { width: 110px; margin-left: 12px; }
|
||||
</style>
|
||||
@@ -9,7 +9,7 @@ export const crossBorderModule = {
|
||||
path: 'cross-border/procurement',
|
||||
name: 'cross-border-procurement',
|
||||
component: () => import('./views/ProcurementView.vue'),
|
||||
release: 'prototype',
|
||||
release: 'active',
|
||||
meta: { title: '海外采购', description: '管理海外采购订单', permission: 'merchant:cross-border:procurement' },
|
||||
navigation: { key: 'cross-border-procurement', icon: Package },
|
||||
},
|
||||
@@ -17,7 +17,7 @@ export const crossBorderModule = {
|
||||
path: 'cross-border/domestic-trucking',
|
||||
name: 'cross-border-domestic-trucking',
|
||||
component: () => import('./views/DomesticTruckingView.vue'),
|
||||
release: 'prototype',
|
||||
release: 'active',
|
||||
meta: { title: '美国本土卡', description: '创建美国本土卡车运输订单', permission: 'merchant:cross-border:domestic-trucking' },
|
||||
navigation: { key: 'cross-border-domestic-trucking', icon: Truck },
|
||||
},
|
||||
@@ -25,7 +25,7 @@ export const crossBorderModule = {
|
||||
path: 'cross-border/pallet-shipping',
|
||||
name: 'cross-border-pallet-shipping',
|
||||
component: () => import('./views/PalletShippingView.vue'),
|
||||
release: 'prototype',
|
||||
release: 'active',
|
||||
meta: { title: '美本土散板', description: '创建美国本土散板运输订单', permission: 'merchant:cross-border:pallet-shipping' },
|
||||
navigation: { key: 'cross-border-pallet-shipping', icon: Ship },
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
import BusinessOrdersView from '../components/BusinessOrdersView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="美国本土卡" description="创建美国本土卡车运输订单" /></template>
|
||||
<template><BusinessOrdersView kind="trucking" /></template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
import BusinessOrdersView from '../components/BusinessOrdersView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="美本土散板" description="创建美国本土散板运输订单" /></template>
|
||||
<template><BusinessOrdersView kind="looseboard" /></template>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
import BusinessOrdersView from '../components/BusinessOrdersView.vue'
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="海外采购" description="管理海外采购订单" /></template>
|
||||
<template><BusinessOrdersView kind="procurement" /></template>
|
||||
|
||||
@@ -25,12 +25,15 @@ test('isolates recoverable dashboard failures without hiding successful sections
|
||||
assert.doesNotMatch(source, /<template v-else>\s*<section class="quick-order-band">/)
|
||||
})
|
||||
|
||||
test('matches the prototype business overview while keeping real order routing and data', () => {
|
||||
test('matches the prototype business overview and routes each card to its real business page', () => {
|
||||
assert.match(source, /选择业务,快速下单/)
|
||||
assert.match(source, /海外采购/)
|
||||
assert.match(source, /美国本土卡/)
|
||||
assert.match(source, /美本土散板/)
|
||||
assert.match(source, /void router\.push\('\/orders\/first-leg\/create'\)/)
|
||||
assert.match(source, /route: '\/cross-border\/procurement'/)
|
||||
assert.match(source, /route: '\/cross-border\/domestic-trucking'/)
|
||||
assert.match(source, /route: '\/cross-border\/pallet-shipping'/)
|
||||
assert.match(source, /auth\.hasPermission\(business\.permission\)/)
|
||||
assert.match(source, /order\.status === 'DELIVERED'/)
|
||||
assert.match(source, /currentMonthOrderCount/)
|
||||
assert.match(source, /提交订单后,专属客户经理将在 24 小时内与您联系/)
|
||||
|
||||
@@ -40,7 +40,6 @@ const loadErrorItems = computed(() =>
|
||||
})),
|
||||
)
|
||||
const canViewOrders = computed(() => auth.hasPermission('order:first-leg:list'))
|
||||
const canCreateOrders = computed(() => auth.hasPermission('order:first-leg:create'))
|
||||
const accountName = computed(() => {
|
||||
if (!companyUnavailable.value && tenant.company.companyName) return tenant.company.companyName
|
||||
return auth.user?.tenantName || auth.user?.name || '企业客户'
|
||||
@@ -67,18 +66,24 @@ const businessCards = [
|
||||
description: '采购代购 · 验货质检 · 集中发货',
|
||||
icon: ShoppingCart,
|
||||
tone: 'blue',
|
||||
route: '/cross-border/procurement',
|
||||
permission: 'merchant:cross-border:procurement',
|
||||
},
|
||||
{
|
||||
title: '美国本土卡',
|
||||
description: '港口提柜 · 仓到仓 · 仓到门',
|
||||
icon: Truck,
|
||||
tone: 'green',
|
||||
route: '/cross-border/domestic-trucking',
|
||||
permission: 'merchant:cross-border:domestic-trucking',
|
||||
},
|
||||
{
|
||||
title: '美本土散板',
|
||||
description: '拼车拼载 · 打盘服务 · 灵活派送',
|
||||
icon: PanelsTopLeft,
|
||||
tone: 'teal',
|
||||
route: '/cross-border/pallet-shipping',
|
||||
permission: 'merchant:cross-border:pallet-shipping',
|
||||
},
|
||||
] as const
|
||||
|
||||
@@ -111,16 +116,12 @@ async function loadDashboardData() {
|
||||
}
|
||||
}
|
||||
|
||||
function openBusinessOrder() {
|
||||
if (canCreateOrders.value) {
|
||||
void router.push('/orders/first-leg/create')
|
||||
function openBusinessOrder(business: typeof businessCards[number]) {
|
||||
if (auth.hasPermission(business.permission)) {
|
||||
void router.push(business.route)
|
||||
return
|
||||
}
|
||||
if (canViewOrders.value) {
|
||||
void router.push('/orders/first-leg')
|
||||
return
|
||||
}
|
||||
ElMessage.warning('当前账号暂无头程订单权限,请联系企业管理员。')
|
||||
ElMessage.warning(`当前账号暂无${business.title}权限,请联系企业管理员。`)
|
||||
}
|
||||
|
||||
function showSupportMessage(type: 'service' | 'faq' | 'quote') {
|
||||
@@ -164,7 +165,7 @@ onMounted(() => void loadDashboardData())
|
||||
type="button"
|
||||
class="business-card"
|
||||
:class="`business-card--${business.tone}`"
|
||||
@click="openBusinessOrder"
|
||||
@click="openBusinessOrder(business)"
|
||||
>
|
||||
<span class="business-card__icon" aria-hidden="true">
|
||||
<component :is="business.icon" :size="29" :stroke-width="2.4" />
|
||||
@@ -174,7 +175,7 @@ onMounted(() => void loadDashboardData())
|
||||
<small>{{ business.description }}</small>
|
||||
</span>
|
||||
<span class="business-card__action">
|
||||
{{ canCreateOrders ? '开始下单' : canViewOrders ? '查看订单' : '暂无权限' }}
|
||||
{{ auth.hasPermission(business.permission) ? '开始下单' : '暂无权限' }}
|
||||
<ArrowRight :size="18" aria-hidden="true" />
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { apiRequest } from '@/api/http'
|
||||
|
||||
export interface PageResult<T> {
|
||||
records: T[]
|
||||
total: number
|
||||
page: number
|
||||
size: number
|
||||
pages: number
|
||||
}
|
||||
|
||||
export interface FinanceAccount {
|
||||
currencyCode: 'CNY'
|
||||
actualBalance: string
|
||||
frozenBalance: string
|
||||
availableBalance: string
|
||||
creditLimit: string
|
||||
creditUsed: string
|
||||
creditFrozen: string
|
||||
availableCredit: string
|
||||
version: number
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type AccountChangeType = 'RECHARGE' | 'DEDUCTION' | 'FREEZE' | 'UNFREEZE' | 'CONSUMPTION' | 'CREDIT_LIMIT'
|
||||
|
||||
export interface AccountLedgerEntry {
|
||||
ledgerId: string
|
||||
businessNo: string
|
||||
changeType: AccountChangeType
|
||||
amount: string
|
||||
actualBalanceAfter: string
|
||||
frozenBalanceAfter: string
|
||||
availableBalanceAfter: string
|
||||
creditLimitAfter: string
|
||||
creditUsedAfter: string
|
||||
availableCreditAfter: string
|
||||
sourceType: 'CLIENT_RECHARGE' | 'MANUAL_RECHARGE' | 'MANUAL_DEDUCTION' | 'CREDIT_LIMIT_ADJUSTMENT' | string
|
||||
sourceNo: string
|
||||
operatorName: string | null
|
||||
remark: string | null
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
export type RechargePaymentMethod = 'WECHAT' | 'ALIPAY'
|
||||
export type RechargeStatus = 'PENDING' | 'APPROVED' | 'REJECTED'
|
||||
|
||||
export interface RechargeRequest {
|
||||
requestId: string
|
||||
requestNo: string
|
||||
amount: string
|
||||
paymentMethod: RechargePaymentMethod
|
||||
voucherFileId: string
|
||||
voucherFileName: string
|
||||
status: RechargeStatus
|
||||
version: number
|
||||
submittedByName: string | null
|
||||
reviewedByName: string | null
|
||||
reviewRemark: string | null
|
||||
createdAt: string
|
||||
reviewedAt: string | null
|
||||
}
|
||||
|
||||
export interface RechargeChannel {
|
||||
paymentMethod: RechargePaymentMethod
|
||||
displayName: string
|
||||
enabled: boolean
|
||||
qrCodeUrl: string | null
|
||||
}
|
||||
|
||||
export function getFinanceAccount() {
|
||||
return apiRequest<FinanceAccount>({ method: 'GET', url: '/client/finance/v1/account' })
|
||||
}
|
||||
|
||||
export function getAccountLedger(page: number, size: number) {
|
||||
return apiRequest<PageResult<AccountLedgerEntry>>({
|
||||
method: 'GET',
|
||||
url: '/client/finance/v1/account/ledger',
|
||||
params: { page, size },
|
||||
})
|
||||
}
|
||||
|
||||
export function getRechargeRequests(page: number, size: number) {
|
||||
return apiRequest<PageResult<RechargeRequest>>({
|
||||
method: 'GET',
|
||||
url: '/client/finance/v1/recharge-requests',
|
||||
params: { page, size },
|
||||
})
|
||||
}
|
||||
|
||||
export function getRechargeChannels() {
|
||||
return apiRequest<RechargeChannel[]>({ method: 'GET', url: '/client/finance/v1/recharge-channels' })
|
||||
}
|
||||
|
||||
export function createRechargeRequest(amount: string, paymentMethod: RechargePaymentMethod, file: File,
|
||||
idempotencyKey: string) {
|
||||
const formData = new FormData()
|
||||
formData.append('amount', amount)
|
||||
formData.append('paymentMethod', paymentMethod)
|
||||
formData.append('file', file, file.name)
|
||||
return apiRequest<RechargeRequest>({
|
||||
method: 'POST',
|
||||
url: '/client/finance/v1/recharge-requests',
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
data: formData,
|
||||
})
|
||||
}
|
||||
@@ -34,6 +34,7 @@ export interface FinanceBillLineWire {
|
||||
sourceVersion: number
|
||||
orderId: WireScalar | null
|
||||
inboundId: WireScalar | null
|
||||
inboundCode: string | null
|
||||
feeItemCode: string
|
||||
feeItemName: string
|
||||
amount: WireScalar
|
||||
@@ -89,6 +90,7 @@ function mapBillLine(line: FinanceBillLineWire): FinanceBillLine {
|
||||
return {
|
||||
id: text(line.id),
|
||||
orderId: line.orderId == null ? null : text(line.orderId),
|
||||
inboundCode: line.inboundCode?.trim() || null,
|
||||
feeNo: `${line.sourceType}:${text(line.sourceId)}/v${line.sourceVersion}`,
|
||||
feeName: line.feeItemName,
|
||||
amount: text(line.amount),
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface FinanceCustomerConfirmationResult {
|
||||
export interface FinanceBillLine {
|
||||
id: string
|
||||
orderId: string | null
|
||||
inboundCode: string | null
|
||||
feeNo: string | null
|
||||
feeName: string
|
||||
amount: string
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { ReceiptText } from '@lucide/vue'
|
||||
import { CircleDollarSign, ReceiptText } from '@lucide/vue'
|
||||
import type { ClientModuleManifest } from '../types.ts'
|
||||
|
||||
export const financeModule = {
|
||||
key: 'finance',
|
||||
navigationGroup: { title: '财务结算', icon: ReceiptText },
|
||||
routes: [
|
||||
{
|
||||
path: 'finance/account',
|
||||
name: 'finance-account',
|
||||
component: () => import('./views/FinanceAccountView.vue'),
|
||||
release: 'active',
|
||||
meta: { title: '资金账户', description: '查看本企业资金余额、流水与充值申请', permission: 'merchant:finance:account:view' },
|
||||
navigation: { key: 'finance-account', title: '资金账户', icon: CircleDollarSign },
|
||||
},
|
||||
{
|
||||
path: 'finance/bills',
|
||||
name: 'finance-bills',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const apiSource = readFileSync(new URL('../api/account.ts', import.meta.url), 'utf8')
|
||||
const viewSource = readFileSync(new URL('./FinanceAccountView.vue', import.meta.url), 'utf8')
|
||||
const routesSource = readFileSync(new URL('../routes.ts', import.meta.url), 'utf8')
|
||||
const permissionsSource = readFileSync(new URL('../../tenant-management/contracts.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('uses only authenticated client finance account endpoints without customer scope', () => {
|
||||
for (const path of ['/client/finance/v1/account', '/client/finance/v1/account/ledger', '/client/finance/v1/recharge-requests', '/client/finance/v1/recharge-channels']) {
|
||||
assert.match(apiSource, new RegExp(path.replaceAll('/', '\\/')))
|
||||
}
|
||||
assert.match(apiSource, /headers: \{ 'Idempotency-Key': idempotencyKey \}/)
|
||||
assert.match(apiSource, /formData\.append\('amount', amount\)/)
|
||||
assert.match(apiSource, /formData\.append\('paymentMethod', paymentMethod\)/)
|
||||
assert.match(apiSource, /formData\.append\('file', file, file\.name\)/)
|
||||
assert.doesNotMatch(apiSource, /customerId|tenantId|mock|fallback/i)
|
||||
})
|
||||
|
||||
test('activates the account route and maps read and recharge permissions independently', () => {
|
||||
assert.match(routesSource, /path: 'finance\/account'[\s\S]*FinanceAccountView\.vue[\s\S]*release: 'active'[\s\S]*permission: 'merchant:finance:account:view'/)
|
||||
assert.match(permissionsSource, /'merchant:finance:account:view': \['merchant:finance:account:view'\]/)
|
||||
assert.match(permissionsSource, /'merchant:finance:recharge:create': \['merchant:finance:recharge:create'\]/)
|
||||
assert.match(viewSource, /auth\.hasPermission\('merchant:finance:recharge:create'\)/)
|
||||
assert.match(viewSource, /<el-button v-if="canRecharge"/)
|
||||
})
|
||||
|
||||
test('renders server balances, ledger and recharge states without internal identifiers', () => {
|
||||
for (const field of ['actualBalance', 'frozenBalance', 'availableBalance', 'creditLimit', 'creditUsed', 'availableCredit']) assert.match(viewSource, new RegExp(`account\\?\\.${field}`))
|
||||
for (const field of ['creditUsedAfter', 'availableCreditAfter']) assert.match(viewSource, new RegExp(`row\\.${field}`))
|
||||
assert.match(viewSource, /实际可用资金与可用授信合计/)
|
||||
assert.match(viewSource, /CREDIT_LIMIT: '授信调整'/)
|
||||
assert.match(viewSource, /row-key="businessNo"/)
|
||||
assert.match(viewSource, /row-key="requestNo"/)
|
||||
assert.match(viewSource, /empty-text="暂无资金流水"/)
|
||||
assert.match(viewSource, /empty-text="暂无充值申请"/)
|
||||
assert.match(viewSource, /account-state--error/)
|
||||
assert.match(viewSource, /v-loading="pageLoading"/)
|
||||
assert.match(viewSource, /function formatAccountAmount[\s\S]*value == null \? '—' : formatAmount\(value\)/)
|
||||
assert.match(viewSource, /formatAccountAmount\(account\?\.actualBalance\)/)
|
||||
assert.doesNotMatch(viewSource, /requestId|ledgerId|customerId|tenantId/)
|
||||
})
|
||||
|
||||
test('matches the frozen recharge workflow and preserves an idempotency key for failed retries', () => {
|
||||
assert.match(viewSource, /title="账户充值" width="760px"/)
|
||||
for (const amount of ['50.00', '100.00', '200.00', '500.00']) assert.match(viewSource, new RegExp(amount.replace('.', '\\.')))
|
||||
assert.match(viewSource, /\['WECHAT', 'ALIPAY'\]/)
|
||||
assert.match(viewSource, /selectedChannel\.value\?\.enabled && selectedChannel\.value\.qrCodeUrl/)
|
||||
assert.match(viewSource, /accept="\.jpg,\.jpeg,\.png,image\/jpeg,image\/png"/)
|
||||
assert.match(viewSource, /if \(!pendingIdempotencyKey\)[\s\S]*globalThis\.crypto\?\.randomUUID\?\.\(\)/)
|
||||
assert.match(viewSource, /pendingIdempotencyKey = ''[\s\S]*rechargeVisible\.value = false/)
|
||||
assert.match(viewSource, /:disabled="!canSubmit"/)
|
||||
assert.match(viewSource, /await loadAccountData\(\)/)
|
||||
assert.doesNotMatch(viewSource, /payment SDK|支付成功|模拟成功/)
|
||||
})
|
||||
@@ -0,0 +1,317 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { CircleDollarSign, RefreshCcw, Upload } from '@lucide/vue'
|
||||
import { ElMessage, type UploadFile, type UploadFiles, type UploadInstance } from 'element-plus'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import {
|
||||
createRechargeRequest,
|
||||
getAccountLedger,
|
||||
getFinanceAccount,
|
||||
getRechargeChannels,
|
||||
getRechargeRequests,
|
||||
type AccountChangeType,
|
||||
type AccountLedgerEntry,
|
||||
type FinanceAccount,
|
||||
type RechargeChannel,
|
||||
type RechargePaymentMethod,
|
||||
type RechargeRequest,
|
||||
type RechargeStatus,
|
||||
} from '../api/account'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const account = ref<FinanceAccount | null>(null)
|
||||
const ledger = ref<AccountLedgerEntry[]>([])
|
||||
const rechargeRequests = ref<RechargeRequest[]>([])
|
||||
const channels = ref<RechargeChannel[]>([])
|
||||
const pageLoading = ref(false)
|
||||
const pageError = ref('')
|
||||
const channelsError = ref('')
|
||||
const ledgerPage = ref(1)
|
||||
const ledgerTotal = ref(0)
|
||||
const rechargePage = ref(1)
|
||||
const rechargeTotal = ref(0)
|
||||
const pageSize = 10
|
||||
const rechargeVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
const submitError = ref('')
|
||||
const voucherFile = ref<File | null>(null)
|
||||
const voucherUpload = ref<UploadInstance>()
|
||||
let pendingIdempotencyKey = ''
|
||||
|
||||
const form = reactive({ amount: '100.00', paymentMethod: 'WECHAT' as RechargePaymentMethod })
|
||||
const quickAmounts = ['50.00', '100.00', '200.00', '500.00']
|
||||
const canRecharge = computed(() => auth.hasPermission('merchant:finance:recharge:create'))
|
||||
const selectedChannel = computed(() => channels.value.find((item) => item.paymentMethod === form.paymentMethod))
|
||||
const channelReady = computed(() => Boolean(selectedChannel.value?.enabled && selectedChannel.value.qrCodeUrl))
|
||||
const amountValid = computed(() => {
|
||||
const value = Number(form.amount)
|
||||
return Number.isFinite(value) && value > 0 && /^\d+(?:\.\d{1,2})?$/.test(form.amount.trim())
|
||||
})
|
||||
const canSubmit = computed(() => amountValid.value && channelReady.value && Boolean(voucherFile.value) && !submitting.value)
|
||||
|
||||
const changeLabels: Record<AccountChangeType, string> = {
|
||||
RECHARGE: '充值', DEDUCTION: '扣款', FREEZE: '冻结', UNFREEZE: '解冻', CONSUMPTION: '消费', CREDIT_LIMIT: '授信调整',
|
||||
}
|
||||
const statusLabels: Record<RechargeStatus, string> = { PENDING: '待审核', APPROVED: '已通过', REJECTED: '已驳回' }
|
||||
const methodLabels: Record<RechargePaymentMethod, string> = { WECHAT: '微信支付', ALIPAY: '支付宝' }
|
||||
|
||||
function messageOf(error: unknown, fallback: string) {
|
||||
return error instanceof ApiError ? error.message : fallback
|
||||
}
|
||||
|
||||
function formatAmount(value: string | null | undefined) {
|
||||
const number = Number(value ?? '0')
|
||||
return Number.isFinite(number)
|
||||
? number.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
: '0.00'
|
||||
}
|
||||
|
||||
function formatAccountAmount(value: string | null | undefined) {
|
||||
return value == null ? '—' : formatAmount(value)
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '—'
|
||||
}
|
||||
|
||||
function statusType(status: RechargeStatus) {
|
||||
return ({ PENDING: 'warning', APPROVED: 'success', REJECTED: 'danger' })[status] as 'warning' | 'success' | 'danger'
|
||||
}
|
||||
|
||||
function amountClass(amount: string) {
|
||||
return Number(amount) >= 0 ? 'amount-positive' : 'amount-negative'
|
||||
}
|
||||
|
||||
async function loadAccountData() {
|
||||
pageLoading.value = true
|
||||
pageError.value = ''
|
||||
channelsError.value = ''
|
||||
const requests = [getFinanceAccount(), getAccountLedger(ledgerPage.value, pageSize), getRechargeRequests(rechargePage.value, pageSize)] as const
|
||||
try {
|
||||
const [accountResult, ledgerResult, rechargeResult] = await Promise.all(requests)
|
||||
account.value = accountResult
|
||||
ledger.value = ledgerResult.records
|
||||
ledgerTotal.value = ledgerResult.total
|
||||
rechargeRequests.value = rechargeResult.records
|
||||
rechargeTotal.value = rechargeResult.total
|
||||
} catch (error) {
|
||||
account.value = null
|
||||
ledger.value = []
|
||||
rechargeRequests.value = []
|
||||
ledgerTotal.value = 0
|
||||
rechargeTotal.value = 0
|
||||
pageError.value = messageOf(error, '资金账户加载失败,请稍后重试')
|
||||
} finally {
|
||||
pageLoading.value = false
|
||||
}
|
||||
if (canRecharge.value) {
|
||||
try {
|
||||
channels.value = await getRechargeChannels()
|
||||
} catch (error) {
|
||||
channels.value = []
|
||||
channelsError.value = messageOf(error, '充值渠道加载失败,请稍后重试')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function changeLedgerPage(nextPage: number) {
|
||||
ledgerPage.value = nextPage
|
||||
try {
|
||||
const result = await getAccountLedger(nextPage, pageSize)
|
||||
ledger.value = result.records
|
||||
ledgerTotal.value = result.total
|
||||
} catch (error) {
|
||||
ElMessage.error(messageOf(error, '资金流水加载失败,请重试'))
|
||||
}
|
||||
}
|
||||
|
||||
async function changeRechargePage(nextPage: number) {
|
||||
rechargePage.value = nextPage
|
||||
try {
|
||||
const result = await getRechargeRequests(nextPage, pageSize)
|
||||
rechargeRequests.value = result.records
|
||||
rechargeTotal.value = result.total
|
||||
} catch (error) {
|
||||
ElMessage.error(messageOf(error, '充值申请加载失败,请重试'))
|
||||
}
|
||||
}
|
||||
|
||||
function openRecharge() {
|
||||
submitError.value = ''
|
||||
rechargeVisible.value = true
|
||||
}
|
||||
|
||||
function chooseAmount(amount: string) {
|
||||
form.amount = amount
|
||||
}
|
||||
|
||||
function handleVoucherChange(uploadFile: UploadFile, _uploadFiles: UploadFiles) {
|
||||
const file = uploadFile.raw
|
||||
if (!file) return
|
||||
const extensionAllowed = /\.(?:jpe?g|png)$/i.test(file.name)
|
||||
const mimeAllowed = ['image/jpeg', 'image/png'].includes(file.type)
|
||||
if (!extensionAllowed || !mimeAllowed) {
|
||||
voucherFile.value = null
|
||||
ElMessage.error('付款截图仅支持 jpg、jpeg 或 png 图片')
|
||||
return
|
||||
}
|
||||
voucherFile.value = file
|
||||
}
|
||||
|
||||
function handleVoucherRemove() {
|
||||
voucherFile.value = null
|
||||
}
|
||||
|
||||
function normalizedAmount() {
|
||||
return Number(form.amount).toFixed(2)
|
||||
}
|
||||
|
||||
async function submitRecharge() {
|
||||
if (submitting.value) return
|
||||
submitError.value = ''
|
||||
if (!amountValid.value) {
|
||||
submitError.value = '请输入大于 0 且最多两位小数的充值金额'
|
||||
return
|
||||
}
|
||||
if (!channelReady.value) {
|
||||
submitError.value = `${methodLabels[form.paymentMethod]}收款二维码尚未配置,暂不能提交充值申请`
|
||||
return
|
||||
}
|
||||
if (!voucherFile.value) {
|
||||
submitError.value = '请上传付款截图'
|
||||
return
|
||||
}
|
||||
if (!pendingIdempotencyKey) {
|
||||
pendingIdempotencyKey = globalThis.crypto?.randomUUID?.() ?? `recharge-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
await createRechargeRequest(normalizedAmount(), form.paymentMethod, voucherFile.value, pendingIdempotencyKey)
|
||||
pendingIdempotencyKey = ''
|
||||
rechargeVisible.value = false
|
||||
voucherFile.value = null
|
||||
voucherUpload.value?.clearFiles()
|
||||
ElMessage.success('充值申请已提交,等待平台审核')
|
||||
await loadAccountData()
|
||||
} catch (error) {
|
||||
submitError.value = messageOf(error, '充值申请提交失败,请使用当前内容重试')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => form.amount, () => form.paymentMethod, voucherFile], () => {
|
||||
if (!submitting.value) pendingIdempotencyKey = ''
|
||||
submitError.value = ''
|
||||
})
|
||||
|
||||
onMounted(() => { void loadAccountData() })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-loading="pageLoading" class="finance-account-page" :aria-busy="pageLoading">
|
||||
<div class="account-heading">
|
||||
<div><h2>资金账户</h2><p>查看本企业 CNY 资金、授信额度、账户流水和充值申请状态。</p></div>
|
||||
<el-button v-if="canRecharge" type="primary" @click="openRecharge"><CircleDollarSign :size="17" />账户充值</el-button>
|
||||
</div>
|
||||
|
||||
<div v-if="pageError" class="account-state account-state--error" role="alert">
|
||||
<p>{{ pageError }}</p><el-button @click="loadAccountData"><RefreshCcw :size="15" />重新加载</el-button>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<section class="balance-grid" aria-label="账户余额">
|
||||
<article><span>实际余额</span><strong>¥ {{ formatAccountAmount(account?.actualBalance) }}</strong><small>当前账户实际资金</small></article>
|
||||
<article><span>冻结金额</span><strong>¥ {{ formatAccountAmount(account?.frozenBalance) }}</strong><small>暂不可使用的资金</small></article>
|
||||
<article class="balance-available"><span>总可用余额</span><strong>¥ {{ formatAccountAmount(account?.availableBalance) }}</strong><small>实际可用资金与可用授信合计</small></article>
|
||||
<article><span>授信额度</span><strong>¥ {{ formatAccountAmount(account?.creditLimit) }}</strong><small>平台配置的授信上限</small></article>
|
||||
<article><span>已用授信</span><strong>¥ {{ formatAccountAmount(account?.creditUsed) }}</strong><small>已结算使用的授信</small></article>
|
||||
<article><span>可用授信</span><strong>¥ {{ formatAccountAmount(account?.availableCredit) }}</strong><small>扣除已用和冻结后的授信</small></article>
|
||||
</section>
|
||||
|
||||
<section class="account-card">
|
||||
<div class="section-heading"><div><h3>账户流水</h3><p>每次资金变动均生成唯一业务单号。</p></div></div>
|
||||
<el-table :data="ledger" row-key="businessNo" empty-text="暂无资金流水">
|
||||
<el-table-column prop="businessNo" label="业务单号" min-width="190" />
|
||||
<el-table-column label="变动类型" width="110"><template #default="{ row }">{{ changeLabels[row.changeType as AccountChangeType] }}</template></el-table-column>
|
||||
<el-table-column label="变动金额" width="145" align="right"><template #default="{ row }"><strong :class="amountClass(row.amount)">{{ Number(row.amount) >= 0 ? '+' : '' }}¥ {{ formatAmount(row.amount) }}</strong></template></el-table-column>
|
||||
<el-table-column label="实际余额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.actualBalanceAfter) }}</template></el-table-column>
|
||||
<el-table-column label="冻结金额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.frozenBalanceAfter) }}</template></el-table-column>
|
||||
<el-table-column label="可用余额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.availableBalanceAfter) }}</template></el-table-column>
|
||||
<el-table-column label="已用授信" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.creditUsedAfter) }}</template></el-table-column>
|
||||
<el-table-column label="可用授信" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.availableCreditAfter) }}</template></el-table-column>
|
||||
<el-table-column prop="operatorName" label="操作人" min-width="130"><template #default="{ row }">{{ row.operatorName || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="remark" label="备注" min-width="180"><template #default="{ row }">{{ row.remark || '—' }}</template></el-table-column>
|
||||
<el-table-column label="发生时间" width="170"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<div v-if="ledgerTotal" class="pagination-row"><span>共 {{ ledgerTotal }} 条</span><el-pagination :current-page="ledgerPage" :page-size="pageSize" layout="prev, pager, next" :total="ledgerTotal" @current-change="changeLedgerPage" /></div>
|
||||
</section>
|
||||
|
||||
<section class="account-card">
|
||||
<div class="section-heading"><div><h3>充值申请</h3><p>付款后上传截图,平台审核通过后资金入账。</p></div></div>
|
||||
<el-table :data="rechargeRequests" row-key="requestNo" empty-text="暂无充值申请">
|
||||
<el-table-column prop="requestNo" label="充值申请单号" min-width="190" />
|
||||
<el-table-column label="金额" width="145" align="right"><template #default="{ row }">¥ {{ formatAmount(row.amount) }}</template></el-table-column>
|
||||
<el-table-column label="付款方式" width="120"><template #default="{ row }">{{ methodLabels[row.paymentMethod as RechargePaymentMethod] }}</template></el-table-column>
|
||||
<el-table-column prop="voucherFileName" label="付款截图" min-width="170" />
|
||||
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="statusType(row.status as RechargeStatus)">{{ statusLabels[row.status as RechargeStatus] }}</el-tag></template></el-table-column>
|
||||
<el-table-column prop="submittedByName" label="提交人" min-width="120"><template #default="{ row }">{{ row.submittedByName || '—' }}</template></el-table-column>
|
||||
<el-table-column prop="reviewRemark" label="审核备注" min-width="180"><template #default="{ row }">{{ row.reviewRemark || '—' }}</template></el-table-column>
|
||||
<el-table-column label="提交时间" width="170"><template #default="{ row }">{{ formatDateTime(row.createdAt) }}</template></el-table-column>
|
||||
<el-table-column label="审核时间" width="170"><template #default="{ row }">{{ formatDateTime(row.reviewedAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<div v-if="rechargeTotal" class="pagination-row"><span>共 {{ rechargeTotal }} 条</span><el-pagination :current-page="rechargePage" :page-size="pageSize" layout="prev, pager, next" :total="rechargeTotal" @current-change="changeRechargePage" /></div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="rechargeVisible" title="账户充值" width="760px" :close-on-click-modal="false" :close-on-press-escape="!submitting" :show-close="!submitting" class="recharge-dialog">
|
||||
<div class="recharge-form">
|
||||
<label class="field-label" for="recharge-amount">充值金额</label>
|
||||
<el-input id="recharge-amount" v-model="form.amount" size="large" inputmode="decimal" placeholder="请输入充值金额" aria-label="充值金额"><template #prefix>¥</template></el-input>
|
||||
<div class="quick-amounts" aria-label="快捷充值金额">
|
||||
<button v-for="amount in quickAmounts" :key="amount" type="button" :class="{ active: form.amount === amount }" @click="chooseAmount(amount)">¥{{ Number(amount) }}</button>
|
||||
</div>
|
||||
|
||||
<div class="payment-methods" aria-label="付款方式">
|
||||
<button v-for="method in (['WECHAT', 'ALIPAY'] as RechargePaymentMethod[])" :key="method" type="button" :class="[`payment-${method.toLowerCase()}`, { active: form.paymentMethod === method }]" @click="form.paymentMethod = method"><span>{{ method === 'WECHAT' ? '微' : '支' }}</span>{{ methodLabels[method] }}</button>
|
||||
</div>
|
||||
|
||||
<div v-if="channelsError" class="channel-warning" role="alert">{{ channelsError }}</div>
|
||||
<div v-else-if="channelReady" class="qr-panel">
|
||||
<img :src="selectedChannel?.qrCodeUrl || ''" :alt="`${methodLabels[form.paymentMethod]}收款二维码`">
|
||||
<p>请使用{{ methodLabels[form.paymentMethod] }}扫码完成付款</p>
|
||||
</div>
|
||||
<div v-else class="channel-warning" role="status">{{ methodLabels[form.paymentMethod] }}收款二维码尚未配置,暂不能提交充值申请。</div>
|
||||
|
||||
<div class="voucher-upload">
|
||||
<span class="field-label">付款截图</span>
|
||||
<el-upload ref="voucherUpload" :auto-upload="false" :limit="1" accept=".jpg,.jpeg,.png,image/jpeg,image/png" :on-change="handleVoucherChange" :on-remove="handleVoucherRemove">
|
||||
<el-button><Upload :size="16" />选择付款截图</el-button>
|
||||
<template #tip><div class="el-upload__tip">仅支持 jpg、jpeg、png 图片;提交后进入平台审核。</div></template>
|
||||
</el-upload>
|
||||
</div>
|
||||
<p v-if="submitError" class="submit-error" role="alert">{{ submitError }}</p>
|
||||
</div>
|
||||
<template #footer><div class="dialog-actions"><el-button :disabled="submitting" @click="rechargeVisible = false">取消</el-button><el-button type="primary" :loading="submitting" :disabled="!canSubmit" @click="submitRecharge">确认充值</el-button></div></template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.finance-account-page { display: flex; min-height: 420px; flex-direction: column; gap: 16px; }
|
||||
.account-heading, .section-heading, .pagination-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.account-heading h2, .section-heading h3 { margin: 0; color: var(--navy); }.account-heading h2 { font-size: 22px; }.section-heading h3 { font-size: 16px; }
|
||||
.account-heading p, .section-heading p { margin: 5px 0 0; color: var(--muted); font-size: 13px; }.account-heading .el-button { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.balance-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 16px; }
|
||||
.balance-grid article { display: flex; min-height: 132px; flex-direction: column; padding: 22px 24px; border: 1px solid var(--border); border-radius: 10px; background: #fff; }
|
||||
.balance-grid span { color: var(--muted); font-size: 13px; }.balance-grid strong { margin-top: 14px; color: var(--navy); font-size: 28px; line-height: 1; }.balance-grid small { margin-top: auto; color: #8a98a8; }.balance-grid .balance-available { border-color: #b9e1db; background: #f3faf8; }.balance-grid .balance-available strong { color: #058b78; }
|
||||
.account-card { min-width: 0; padding: 18px; border: 1px solid var(--border); border-radius: 8px; background: #fff; }.section-heading { margin-bottom: 14px; }
|
||||
.pagination-row { padding-top: 14px; color: var(--muted); font-size: 12px; }.amount-positive { color: #078a72; }.amount-negative { color: #b42318; }
|
||||
.account-state { display: flex; min-height: 240px; align-items: center; justify-content: center; flex-direction: column; gap: 12px; border-radius: 8px; }.account-state--error { border: 1px solid #f5c2bd; background: #fff8f7; color: #b42318; }.account-state p { margin: 0; }
|
||||
.recharge-form { display: flex; flex-direction: column; gap: 16px; }.field-label { color: #4e6075; font-size: 14px; font-weight: 650; }
|
||||
.quick-amounts, .payment-methods { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; }.quick-amounts button, .payment-methods button { min-height: 54px; border: 1px solid #dbe3ed; border-radius: 10px; background: #f8fafc; color: #506077; cursor: pointer; font: inherit; }.quick-amounts button.active, .payment-methods button.active { border: 2px solid #1671c9; background: #eef8f7; color: #1467b8; font-weight: 700; }
|
||||
.payment-methods { grid-template-columns: repeat(2, 1fr); }.payment-methods button { display: flex; align-items: center; justify-content: center; gap: 10px; font-size: 17px; font-weight: 650; }.payment-methods span { display: grid; width: 26px; height: 26px; place-items: center; border-radius: 7px; background: #1685ef; color: #fff; font-size: 14px; }.payment-wechat span { background: #09ae42; }
|
||||
.qr-panel { display: flex; align-items: center; flex-direction: column; gap: 14px; padding: 22px; border: 1px dashed #bed0e4; border-radius: 12px; background: #f8fafc; }.qr-panel img { width: 210px; height: 210px; border: 1px solid #dae3ed; border-radius: 10px; background: #fff; object-fit: contain; }.qr-panel p { margin: 0; color: #62758d; }
|
||||
.channel-warning { padding: 24px; border: 1px solid #f0d39b; border-radius: 10px; background: #fff9ed; color: #8b5b12; text-align: center; }.voucher-upload { display: grid; gap: 10px; }.voucher-upload :deep(.el-button) { display: inline-flex; align-items: center; gap: 6px; }.submit-error { margin: 0; color: #b42318; font-size: 13px; }.dialog-actions { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; }.dialog-actions .el-button { min-height: 46px; margin: 0; }
|
||||
</style>
|
||||
@@ -92,7 +92,7 @@ test('maps nested bill detail and customer-safe receipt allocations', () => {
|
||||
receivedAmount: '40.00', outstandingAmount: '60.00',
|
||||
version: 3, customerConfirmationStatus: 'PENDING_CONFIRMATION', customerConfirmedByName: null, customerConfirmedAt: null,
|
||||
},
|
||||
lines: [{ id: 21, sourceType: 'INBOUND_FEE', sourceId: 81, sourceVersion: 3, orderId: 31, inboundId: 41, feeItemCode: 'FREIGHT', feeItemName: '运费', amount: '100.00', currency: 'CNY' }],
|
||||
lines: [{ id: 21, sourceType: 'INBOUND_FEE', sourceId: 81, sourceVersion: 3, orderId: 31, inboundId: 41, inboundCode: 'NYEXX2608180003', feeItemCode: 'FREIGHT', feeItemName: '运费', amount: '100.00', currency: 'CNY' }],
|
||||
})
|
||||
const receipts = financeAdapters.mapFinanceReceipts([{
|
||||
id: 51, receiptNo: 'RCPT-51', receivedAmount: '40.00', receivedAt: '2026-08-12T10:00:00',
|
||||
@@ -101,7 +101,7 @@ test('maps nested bill detail and customer-safe receipt allocations', () => {
|
||||
}], '2')
|
||||
|
||||
assert.equal(detail.billNo, 'B-2')
|
||||
assert.deepEqual(detail.lines[0], { id: '21', orderId: '31', feeNo: 'INBOUND_FEE:81/v3', feeName: '运费', amount: '100.00', currencyCode: 'CNY' })
|
||||
assert.deepEqual(detail.lines[0], { id: '21', orderId: '31', inboundCode: 'NYEXX2608180003', feeNo: 'INBOUND_FEE:81/v3', feeName: '运费', amount: '100.00', currencyCode: 'CNY' })
|
||||
assert.deepEqual(receipts[0], { id: '51', receiptNo: 'RCPT-51', receivedAt: '2026-08-12T10:00:00', amount: '40.00', currencyCode: 'CNY', method: 'BANK_TRANSFER', allocatedAmount: '40.00' })
|
||||
})
|
||||
|
||||
@@ -120,6 +120,12 @@ test('keeps actions first and exposes list, balance, detail, confirmation, recei
|
||||
for (const expected of ['未收余额', '账单详情', '确认账单', '客户确认信息', '收款记录', '暂无账单', '暂无收款记录', 'state-panel--error']) {
|
||||
assert.match(viewSource, new RegExp(expected))
|
||||
}
|
||||
assert.match(viewSource, /BANK_TRANSFER: '银行转账'/)
|
||||
assert.match(viewSource, /paymentMethodLabel\(row\.method\)/)
|
||||
assert.match(viewSource, /label="入仓码"/)
|
||||
assert.match(viewSource, /openInboundAssociation\(row\.inboundCode\)/)
|
||||
assert.match(viewSource, /InboundCodeAssociationDialog/)
|
||||
assert.match(viewSource, /:inbound-code="inboundAssociationCode"/)
|
||||
assert.match(viewSource, /auth\.hasPermission\('finance:bill:confirm'\)/)
|
||||
assert.match(viewSource, /row\.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm/)
|
||||
assert.match(viewSource, /size="small" type="success" plain/)
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CircleDollarSign, Eye, RefreshCcw, Search } from '@lucide/vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { ApiError } from '@/api/http'
|
||||
import { useAuthStore } from '@/modules/auth/public'
|
||||
import { InboundCodeAssociationDialog } from '@/modules/first-leg/public'
|
||||
import { confirmMerchantFinanceStatement, getMerchantFinanceBill, getMerchantFinanceBills, getMerchantFinanceReceipts } from '../api/finance'
|
||||
import type { FinanceBillDetail, FinanceBillQuery, FinanceBillStatus, FinanceBillSummary, FinanceCustomerConfirmationStatus } from '../contracts'
|
||||
|
||||
@@ -16,6 +17,8 @@ const bills = ref<FinanceBillSummary[]>([])
|
||||
const detail = ref<FinanceBillDetail | null>(null)
|
||||
const selectedBillId = ref('')
|
||||
const detailVisible = ref(false)
|
||||
const inboundAssociationVisible = ref(false)
|
||||
const inboundAssociationCode = ref('')
|
||||
const confirmingBillId = ref('')
|
||||
const confirmationKeys = new Map<string, string>()
|
||||
const page = ref(1)
|
||||
@@ -49,6 +52,11 @@ function formatDateTime(value: string | null | undefined) {
|
||||
return value ? value.replace('T', ' ').replace(/Z$/, '').slice(0, 16) : '-'
|
||||
}
|
||||
|
||||
function paymentMethodLabel(method: string | null | undefined) {
|
||||
if (!method) return '—'
|
||||
return ({ BANK_TRANSFER: '银行转账', CASH: '现金', OTHER: '其他' })[method] ?? '其他'
|
||||
}
|
||||
|
||||
function statusLabel(status: FinanceBillStatus) {
|
||||
return ({ DRAFT: '草稿', ISSUED: '已出账', PARTIALLY_PAID: '部分收款', PAID: '已收清', VOIDED: '作废' })[status] ?? status
|
||||
}
|
||||
@@ -155,6 +163,13 @@ function handlePageChange(nextPage: number) {
|
||||
void loadBills(nextPage)
|
||||
}
|
||||
|
||||
function openInboundAssociation(inboundCode: string | null) {
|
||||
const code = inboundCode?.trim()
|
||||
if (!code) return
|
||||
inboundAssociationCode.value = code
|
||||
inboundAssociationVisible.value = true
|
||||
}
|
||||
|
||||
onMounted(() => { void loadBills(1) })
|
||||
</script>
|
||||
|
||||
@@ -206,12 +221,13 @@ onMounted(() => { void loadBills(1) })
|
||||
<template v-else-if="detail">
|
||||
<div class="detail-heading"><div><h2>{{ detail.billNo }}</h2><p>出账 {{ formatDate(detail.issueDate) }} · 到期 {{ formatDate(detail.dueDate) }} · 未收 <strong class="amount-warning">¥ {{ formatAmount(detail.outstandingAmount) }}</strong></p></div><div class="detail-statuses"><el-tag :type="statusType(detail.status)">{{ statusLabel(detail.status) }}</el-tag><el-tag :type="confirmationType(detail.customerConfirmationStatus)">{{ confirmationLabel(detail.customerConfirmationStatus) }}</el-tag></div></div>
|
||||
<section class="confirmation-panel" aria-label="客户确认信息"><div><strong>{{ confirmationLabel(detail.customerConfirmationStatus) }}</strong><p v-if="detail.customerConfirmationStatus === 'CONFIRMED'">{{ detail.customerConfirmedByName || '当前企业成员' }} · {{ formatDateTime(detail.customerConfirmedAt) }}</p><p v-else>请核对本期金额和费用明细,确认后将记录当前登录成员与确认时间。</p></div><el-button v-if="detail.customerConfirmationStatus === 'PENDING_CONFIRMATION' && canConfirm" type="primary" :loading="confirmingBillId === detail.id" :disabled="Boolean(confirmingBillId)" @click="confirmStatement(detail)">确认账单</el-button></section>
|
||||
<section class="detail-section"><h3>费用明细</h3><el-table :data="detail.lines" size="small"><el-table-column prop="feeName" label="费用项目" min-width="180" /><el-table-column label="金额" width="130" align="right"><template #default="{ row }">¥ {{ formatAmount(row.amount) }}</template></el-table-column></el-table></section>
|
||||
<section class="detail-section"><h3>收款记录</h3><el-table v-if="detail.receipts.length" :data="detail.receipts" size="small"><el-table-column prop="receivedAt" label="收款日期" width="110"><template #default="{ row }">{{ formatDate(row.receivedAt) }}</template></el-table-column><el-table-column prop="receiptNo" label="收款编号" min-width="130" /><el-table-column label="本次金额" width="130" align="right"><template #default="{ row }">¥ {{ formatAmount(row.allocatedAmount) }}</template></el-table-column><el-table-column prop="method" label="方式" min-width="100" /></el-table><div v-else class="sub-empty">暂无收款记录</div></section>
|
||||
<section class="detail-section"><h3>费用明细</h3><el-table :data="detail.lines" size="small"><el-table-column label="入仓码" min-width="170"><template #default="{ row }"><el-button v-if="row.inboundCode" link type="primary" :aria-label="`查看入仓码 ${row.inboundCode} 详情`" @click="openInboundAssociation(row.inboundCode)">{{ row.inboundCode }}</el-button><span v-else>—</span></template></el-table-column><el-table-column prop="feeName" label="费用项目" min-width="180" /><el-table-column label="金额" width="130" align="right"><template #default="{ row }">¥ {{ formatAmount(row.amount) }}</template></el-table-column></el-table></section>
|
||||
<section class="detail-section"><h3>收款记录</h3><el-table v-if="detail.receipts.length" :data="detail.receipts" size="small"><el-table-column prop="receivedAt" label="收款日期" width="110"><template #default="{ row }">{{ formatDate(row.receivedAt) }}</template></el-table-column><el-table-column prop="receiptNo" label="收款编号" min-width="130" /><el-table-column label="本次金额" width="130" align="right"><template #default="{ row }">¥ {{ formatAmount(row.allocatedAmount) }}</template></el-table-column><el-table-column label="方式" min-width="100"><template #default="{ row }">{{ paymentMethodLabel(row.method) }}</template></el-table-column></el-table><div v-else class="sub-empty">暂无收款记录</div></section>
|
||||
<p class="readonly-notice">如对账单内容有疑问,请先线下联系平台财务,核对无误后再确认。</p>
|
||||
</template>
|
||||
</div>
|
||||
</el-drawer>
|
||||
<InboundCodeAssociationDialog v-model="inboundAssociationVisible" :inbound-code="inboundAssociationCode" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -6,9 +6,17 @@ const source = readFileSync(new URL('./inboundBilling.ts', import.meta.url), 'ut
|
||||
const typesSource = readFileSync(new URL('../types/inboundBilling.ts', import.meta.url), 'utf8')
|
||||
|
||||
test('queries the merchant order inbound-billing endpoint without tenant parameters', () => {
|
||||
assert.match(source, /url: `\/merchant\/v1\/first-leg\/orders\/\$\{orderId\}\/inbound-billing`/)
|
||||
assert.match(source, /method: 'GET'/)
|
||||
assert.doesNotMatch(source, /tenantId|method: '(?:POST|PUT|PATCH|DELETE)'/)
|
||||
const readSource = source.slice(source.indexOf('export async function getMerchantInboundBilling'), source.indexOf('function createFeeConfirmationKey'))
|
||||
assert.match(readSource, /url: `\/merchant\/v1\/first-leg\/orders\/\$\{orderId\}\/inbound-billing`/)
|
||||
assert.match(readSource, /method: 'GET'/)
|
||||
assert.doesNotMatch(readSource, /tenantId|method: '(?:POST|PUT|PATCH|DELETE)'/)
|
||||
})
|
||||
|
||||
test('confirms the latest customer fee version with an idempotency key', () => {
|
||||
assert.match(source, /function confirmMerchantInboundFee/)
|
||||
assert.match(source, /\/inbound-billing\/confirm/)
|
||||
assert.match(source, /headers: \{ 'Idempotency-Key': idempotencyKey \}/)
|
||||
assert.match(source, /data: \{ expectedFeeVersion \}/)
|
||||
})
|
||||
|
||||
test('sanitizes the platform-shaped response into a merchant whitelist', () => {
|
||||
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
|
||||
interface MerchantInboundBillingWireResponse {
|
||||
inbound: MerchantInboundResult
|
||||
measurementSummary: MerchantInboundBilling['measurementSummary']
|
||||
measurementItems: MerchantInboundBilling['measurementItems']
|
||||
billingBasis: MerchantInboundBilling['billingBasis']
|
||||
latestFee: MerchantInboundFeeSnapshot
|
||||
}
|
||||
|
||||
@@ -62,6 +65,8 @@ function sanitizeFee(fee: MerchantInboundFeeSnapshot): MerchantInboundFeeSnapsho
|
||||
feeStatus: fee.feeStatus,
|
||||
inboundId: fee.inboundId,
|
||||
inboundCode: fee.inboundCode,
|
||||
measurementId: fee.measurementId == null ? null : String(fee.measurementId),
|
||||
measurementSource: fee.measurementSource,
|
||||
chargeRuleId: fee.chargeRuleId,
|
||||
ruleCode: fee.ruleCode,
|
||||
chargeRuleVersion: fee.chargeRuleVersion,
|
||||
@@ -98,6 +103,43 @@ function sanitizeFee(fee: MerchantInboundFeeSnapshot): MerchantInboundFeeSnapsho
|
||||
confirmedAt: fee.confirmedAt,
|
||||
sentAt: fee.sentAt,
|
||||
nextProcessStatus: fee.nextProcessStatus,
|
||||
customerConfirmationStatus: fee.customerConfirmationStatus,
|
||||
customerConfirmedBy: fee.customerConfirmedBy == null ? null : String(fee.customerConfirmedBy),
|
||||
customerConfirmedByName: fee.customerConfirmedByName,
|
||||
customerConfirmedAt: fee.customerConfirmedAt,
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeMeasurementSummary(item: MerchantInboundBilling['measurementSummary']): MerchantInboundBilling['measurementSummary'] {
|
||||
return {
|
||||
measurementId: item.measurementId == null ? null : String(item.measurementId),
|
||||
sourceType: item.sourceType,
|
||||
measurementStatus: item.measurementStatus,
|
||||
actualPackageCount: item.actualPackageCount,
|
||||
actualGrossWeightKg: item.actualGrossWeightKg,
|
||||
actualNetWeightKg: item.actualNetWeightKg,
|
||||
actualVolumeCbm: item.actualVolumeCbm,
|
||||
volumeWeightKg: item.volumeWeightKg,
|
||||
actualGoodsName: item.actualGoodsName,
|
||||
calculationSource: item.calculationSource,
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeMeasurementItem(item: MerchantInboundBilling['measurementItems'][number]): MerchantInboundBilling['measurementItems'][number] {
|
||||
return {
|
||||
id: String(item.id),
|
||||
itemNo: item.itemNo,
|
||||
boxSpecification: item.boxSpecification,
|
||||
packageCount: item.packageCount,
|
||||
lengthCm: item.lengthCm,
|
||||
widthCm: item.widthCm,
|
||||
heightCm: item.heightCm,
|
||||
unitVolumeCbm: item.unitVolumeCbm,
|
||||
totalVolumeCbm: item.totalVolumeCbm,
|
||||
volumeWeightKg: item.volumeWeightKg,
|
||||
chargeRuleId: item.chargeRuleId == null ? null : String(item.chargeRuleId),
|
||||
coefficientMode: item.coefficientMode,
|
||||
calculationRuleVersion: item.calculationRuleVersion,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +150,9 @@ function sanitizeInboundBilling(response: MerchantInboundBillingWireResponse): M
|
||||
publicRemark: response.inbound.publicRemark,
|
||||
palletLocations: response.inbound.palletLocations.map(sanitizePalletLocation),
|
||||
},
|
||||
measurementSummary: sanitizeMeasurementSummary(response.measurementSummary),
|
||||
measurementItems: (response.measurementItems || []).map(sanitizeMeasurementItem),
|
||||
billingBasis: response.billingBasis,
|
||||
latestFee: sanitizeFee(response.latestFee),
|
||||
}
|
||||
}
|
||||
@@ -119,3 +164,19 @@ export async function getMerchantInboundBilling(orderId: string): Promise<Mercha
|
||||
})
|
||||
return sanitizeInboundBilling(response)
|
||||
}
|
||||
|
||||
function createFeeConfirmationKey(orderId: string, feeVersion: number) {
|
||||
const randomPart = globalThis.crypto?.randomUUID?.() ?? `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
return `inbound-fee-confirm-${orderId}-${feeVersion}-${randomPart}`
|
||||
}
|
||||
|
||||
export async function confirmMerchantInboundFee(orderId: string, expectedFeeVersion: number,
|
||||
idempotencyKey = createFeeConfirmationKey(orderId, expectedFeeVersion)) {
|
||||
const response = await apiRequest<MerchantInboundFeeSnapshot>({
|
||||
method: 'POST',
|
||||
url: `/merchant/v1/first-leg/orders/${encodeURIComponent(orderId)}/inbound-billing/confirm`,
|
||||
headers: { 'Idempotency-Key': idempotencyKey },
|
||||
data: { expectedFeeVersion },
|
||||
})
|
||||
return sanitizeFee(response)
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ const emit = defineEmits<{
|
||||
.editor-page-nav span { color: var(--muted); font-size: 12px; }
|
||||
.editor-page-body { padding: 26px 32px 34px; }
|
||||
.editor-page-footer { position: sticky; z-index: 3; bottom: 0; display: flex; justify-content: center; padding: 12px 24px; border-top: 1px solid #edf0f5; background: rgba(255, 255, 255, 0.96); box-shadow: 0 -6px 18px rgba(15, 31, 58, .06); backdrop-filter: blur(10px); }
|
||||
.editor-page-footer :deep(.editor-actions) { width: min(100%, 900px); justify-content: flex-start; }
|
||||
.editor-page-footer :deep(.editor-actions) { width: min(100%, 900px); justify-content: center; }
|
||||
.editor-page-footer :deep(.editor-actions > .el-button) { min-width: 132px; min-height: 42px; }
|
||||
.editor-page-footer :deep(.editor-actions > .el-button:last-child) { min-width: 172px; }
|
||||
:global(.first-leg-editor-dialog.el-dialog) { display: flex; max-width: 1500px; height: 94vh; margin-bottom: 0; flex-direction: column; overflow: hidden; border-radius: 6px; }
|
||||
|
||||
@@ -10,17 +10,33 @@ test('renders loading, pending, isolated error, and published states', () => {
|
||||
assert.match(source, /role="alert"/)
|
||||
assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/)
|
||||
assert.match(source, /role="status"/)
|
||||
assert.match(source, /待分柜 · 费用已发布/)
|
||||
assert.match(source, /费用已发布 · 待确认/)
|
||||
assert.match(source, /\$emit\('retry'\)/)
|
||||
})
|
||||
|
||||
test('shows the confirmed merchant inbound and fee fields without write actions', () => {
|
||||
test('supports a standalone dialog and keeps the unpushed billing-weight state visible', () => {
|
||||
assert.match(source, /dialogOnly\?: boolean/)
|
||||
assert.match(source, /v-if="!props\.dialogOnly"/)
|
||||
assert.match(source, /watch\(\(\) => props\.autoOpen/)
|
||||
assert.match(source, /if \(autoOpen\) feeDialogOpen\.value = true/)
|
||||
assert.match(source, /v-else-if="props\.unavailable \|\| !props\.data"/)
|
||||
assert.match(source, /平台推送计费重后,可在这里核对并确认/)
|
||||
assert.match(source, /@closed="emit\('closed'\)"/)
|
||||
})
|
||||
|
||||
test('shows the published merchant inbound, measurement details and customer confirmation entry', () => {
|
||||
for (const label of ['入仓码', '平台收货仓', '实际毛重', '实测体积', '托盘与库位', '换算与精度', '最终计费量', '基础费用', '附加费用', '费用合计', '平台备注']) {
|
||||
assert.match(source, new RegExp(label))
|
||||
}
|
||||
assert.match(source, /formatInboundMoney\(props\.data\.latestFee\.totalAmount/)
|
||||
assert.match(source, /formatConversionPrecision\(/)
|
||||
assert.doesNotMatch(source, /确认费用|调整费用|发送费用|下载照片/)
|
||||
assert.match(source, /确认费用/)
|
||||
assert.match(source, /二次确认费用/)
|
||||
assert.match(source, /确认后该费用将锁定,平台不能再修改/)
|
||||
assert.match(source, /customerConfirmationStatus === 'CONFIRMED'/)
|
||||
assert.match(source, /confirmMerchantInboundFee/)
|
||||
assert.match(source, /measurementItems/)
|
||||
assert.doesNotMatch(source, /调整费用|发送费用|下载照片/)
|
||||
assert.doesNotMatch(source, /internalNotes|statusHistory|files/)
|
||||
assert.match(source, /latestFee\.ruleCode/)
|
||||
assert.match(source, /latestFee\.rateCode/)
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
import { Clock3, PackageCheck, RefreshCw, TriangleAlert } from '@lucide/vue'
|
||||
import { confirmMerchantInboundFee } from '@/modules/first-leg/api/inboundBilling'
|
||||
import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling'
|
||||
import {
|
||||
formatConversionPrecision,
|
||||
@@ -17,24 +20,74 @@ const props = withDefaults(defineProps<{
|
||||
unavailable?: boolean
|
||||
unavailableMessage?: string
|
||||
error?: string
|
||||
autoOpen?: boolean
|
||||
dialogOnly?: boolean
|
||||
canConfirm?: boolean
|
||||
}>(), {
|
||||
data: null,
|
||||
loading: false,
|
||||
unavailable: false,
|
||||
unavailableMessage: '入库及费用结果尚未发布',
|
||||
unavailableMessage: '还未测量推送',
|
||||
error: '',
|
||||
autoOpen: false,
|
||||
dialogOnly: false,
|
||||
canConfirm: false,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
const emit = defineEmits<{
|
||||
retry: []
|
||||
confirmed: []
|
||||
closed: []
|
||||
}>()
|
||||
|
||||
const feeDialogOpen = ref(false)
|
||||
const confirming = ref(false)
|
||||
watch(() => props.autoOpen, (autoOpen) => {
|
||||
if (autoOpen) feeDialogOpen.value = true
|
||||
}, { immediate: true })
|
||||
|
||||
async function confirmFee() {
|
||||
const fee = props.data?.latestFee
|
||||
const orderId = props.data?.inbound.summary.orderId
|
||||
if (!fee || !orderId || !props.canConfirm || confirming.value) return
|
||||
if (fee.customerConfirmationStatus === 'CONFIRMED') {
|
||||
ElMessage.info('当前费用已经确认,无需重复操作')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`请再次确认费用单 ${fee.feeNo},合计 ${formatInboundMoney(fee.totalAmount, fee.currency)}。确认后该费用将锁定,平台不能再修改。`,
|
||||
'二次确认费用',
|
||||
{ type: 'warning', confirmButtonText: '确认费用无误', cancelButtonText: '返回核对' },
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
confirming.value = true
|
||||
try {
|
||||
await confirmMerchantInboundFee(orderId, fee.feeVersion)
|
||||
ElMessage.success('费用确认成功,当前费用已锁定')
|
||||
feeDialogOpen.value = false
|
||||
emit('confirmed')
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '费用确认失败,请刷新后重试')
|
||||
} finally {
|
||||
confirming.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="inbound-billing-panel" aria-labelledby="inbound-billing-title">
|
||||
<section v-if="!props.dialogOnly" class="inbound-billing-panel" aria-labelledby="inbound-billing-title">
|
||||
<header class="inbound-billing-heading">
|
||||
<h3 id="inbound-billing-title">入库与费用</h3>
|
||||
<el-tag v-if="props.data" type="success" effect="light">待分柜 · 费用已发布</el-tag>
|
||||
<div v-if="props.data" class="inbound-billing-actions">
|
||||
<el-tag :type="props.data.latestFee.customerConfirmationStatus === 'CONFIRMED' ? 'success' : 'warning'" effect="light">
|
||||
{{ props.data.latestFee.customerConfirmationStatus === 'CONFIRMED' ? '客户已确认 · 费用已锁定' : '费用已发布 · 待确认' }}
|
||||
</el-tag>
|
||||
<el-button v-if="props.data.latestFee.customerConfirmationStatus !== 'CONFIRMED' && props.canConfirm" type="primary" @click="feeDialogOpen = true">确认费用</el-button>
|
||||
<el-button v-else plain @click="feeDialogOpen = true">查看费用</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="props.loading" class="inbound-billing-loading" aria-live="polite" aria-busy="true">
|
||||
@@ -55,7 +108,7 @@ defineEmits<{
|
||||
<Clock3 :size="22" />
|
||||
<div>
|
||||
<strong>{{ props.unavailableMessage }}</strong>
|
||||
<span>客服完成测量审核、入库并发布费用后,将在这里展示最终结果。</span>
|
||||
<span>平台完成测量审核并确认推送费用后,将在这里展示测量与计费结果。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +121,7 @@ defineEmits<{
|
||||
</dl>
|
||||
|
||||
<div class="inbound-result-section">
|
||||
<h4>实测结果</h4>
|
||||
<div class="inbound-section-title"><h4>测量数据</h4><el-tag size="small" type="warning" effect="light">后台推送</el-tag></div>
|
||||
<dl class="inbound-measurements">
|
||||
<div><dt>实际箱数</dt><dd>{{ formatInboundNumber(props.data.inbound.summary.totalPackageCount, 0) }}</dd></div>
|
||||
<div><dt>实际毛重</dt><dd>{{ formatInboundMeasure(props.data.inbound.summary.actualGrossWeightKg, 'kg') }}</dd></div>
|
||||
@@ -77,6 +130,17 @@ defineEmits<{
|
||||
<div><dt>材积重</dt><dd>{{ formatInboundMeasure(props.data.inbound.summary.volumeWeightKg, 'kg') }}</dd></div>
|
||||
<div><dt>测量完成</dt><dd>{{ formatInboundDateTime(props.data.inbound.summary.measureCompletedAt) }}</dd></div>
|
||||
</dl>
|
||||
<el-table v-if="props.data.measurementItems.length" class="measurement-detail-table" :data="props.data.measurementItems" border size="small">
|
||||
<el-table-column type="index" label="#" width="54" />
|
||||
<el-table-column prop="lengthCm" label="箱规长(cm)" min-width="105" />
|
||||
<el-table-column prop="widthCm" label="箱规宽(cm)" min-width="105" />
|
||||
<el-table-column prop="heightCm" label="箱规高(cm)" min-width="105" />
|
||||
<el-table-column prop="packageCount" label="箱数" min-width="76" />
|
||||
<el-table-column label="每箱体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.unitVolumeCbm, 'm³') }}</template></el-table-column>
|
||||
<el-table-column label="总体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.totalVolumeCbm, 'm³') }}</template></el-table-column>
|
||||
<el-table-column label="总材积重" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.volumeWeightKg, 'kg') }}</template></el-table-column>
|
||||
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
|
||||
</el-table>
|
||||
</div>
|
||||
|
||||
<div class="inbound-result-section">
|
||||
@@ -138,12 +202,65 @@ defineEmits<{
|
||||
<p>{{ props.data.inbound.publicRemark }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="feeDialogOpen" title="确认测量与计费数据" width="min(980px, 94vw)" append-to-body @closed="emit('closed')">
|
||||
<div v-if="props.loading" class="inbound-billing-loading" aria-live="polite" aria-busy="true">
|
||||
<el-skeleton :rows="4" animated />
|
||||
<span class="sr-only">正在加载计费重</span>
|
||||
</div>
|
||||
<div v-else-if="props.error" class="inbound-billing-state is-error" role="alert">
|
||||
<TriangleAlert :size="22" />
|
||||
<div><strong>计费重加载失败</strong><span>{{ props.error }}</span></div>
|
||||
<el-button @click="$emit('retry')"><RefreshCw :size="15" />重新加载</el-button>
|
||||
</div>
|
||||
<div v-else-if="props.unavailable || !props.data" class="inbound-billing-state is-pending" role="status">
|
||||
<Clock3 :size="22" />
|
||||
<div><strong>{{ props.unavailableMessage }}</strong><span>平台推送计费重后,可在这里核对并确认。</span></div>
|
||||
</div>
|
||||
<template v-else>
|
||||
<dl class="inbound-measurements fee-dialog-summary">
|
||||
<div><dt>计费总箱数</dt><dd>{{ formatInboundNumber(props.data.measurementSummary.actualPackageCount, 0) }}</dd></div>
|
||||
<div><dt>计费总毛重</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.actualGrossWeightKg, 'kg') }}</dd></div>
|
||||
<div><dt>外箱规总体积</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.actualVolumeCbm, 'm³') }}</dd></div>
|
||||
<div><dt>外箱规总材积重</dt><dd>{{ formatInboundMeasure(props.data.measurementSummary.volumeWeightKg, 'kg') }}</dd></div>
|
||||
<div><dt>计费体积</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableVolumeCbm, 'm³') }}</dd></div>
|
||||
<div><dt>计费重量</dt><dd>{{ formatInboundMeasure(props.data.billingBasis.chargeableWeightKg, 'kg') }}</dd></div>
|
||||
</dl>
|
||||
<el-table v-if="props.data?.measurementItems.length" class="measurement-detail-table" :data="props.data.measurementItems" border size="small">
|
||||
<el-table-column type="index" label="#" width="54" />
|
||||
<el-table-column prop="lengthCm" label="箱规长(cm)" min-width="105" />
|
||||
<el-table-column prop="widthCm" label="箱规宽(cm)" min-width="105" />
|
||||
<el-table-column prop="heightCm" label="箱规高(cm)" min-width="105" />
|
||||
<el-table-column prop="packageCount" label="箱数" min-width="76" />
|
||||
<el-table-column label="每箱体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.unitVolumeCbm, 'm³') }}</template></el-table-column>
|
||||
<el-table-column label="总体积" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.totalVolumeCbm, 'm³') }}</template></el-table-column>
|
||||
<el-table-column label="总材积重" min-width="105"><template #default="{ row }">{{ formatInboundMeasure(row.volumeWeightKg, 'kg') }}</template></el-table-column>
|
||||
<el-table-column prop="calculationRuleVersion" label="箱规规则" min-width="180" />
|
||||
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
|
||||
</el-table>
|
||||
<el-alert
|
||||
v-if="props.data.latestFee.customerConfirmationStatus === 'CONFIRMED'"
|
||||
class="fee-confirmed-alert"
|
||||
:title="`客户已于 ${formatInboundDateTime(props.data.latestFee.customerConfirmedAt)} 确认,费用已锁定,不允许修改。`"
|
||||
type="success"
|
||||
:closable="false"
|
||||
show-icon
|
||||
/>
|
||||
<el-alert v-else class="fee-confirmed-alert" title="请核对全部计费数据。点击确认后还会进行一次确认,完成后费用将锁定。" type="warning" :closable="false" show-icon />
|
||||
</template>
|
||||
<template #footer>
|
||||
<el-button @click="feeDialogOpen = false">关闭</el-button>
|
||||
<el-button v-if="props.data?.latestFee.customerConfirmationStatus !== 'CONFIRMED' && props.canConfirm" type="primary" :loading="confirming" @click="confirmFee">确认费用</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-billing-panel { margin-top: 22px; }
|
||||
.inbound-billing-heading, .inbound-section-title, .fee-total { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.inbound-billing-actions { display: flex; align-items: center; gap: 10px; }
|
||||
.inbound-billing-heading h3, .inbound-result-section h4 { margin: 0; color: var(--navy); }
|
||||
.inbound-billing-heading h3 { font-size: 15px; }
|
||||
.inbound-result-section h4 { font-size: 13px; }
|
||||
@@ -166,6 +283,9 @@ defineEmits<{
|
||||
.inbound-section-title > span { color: var(--muted); font-size: 10px; text-align: right; }
|
||||
.inbound-measurements { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); margin: 10px 0 0; background: #f6f8f8; }
|
||||
.inbound-measurements > div { min-width: 0; padding: 12px; }
|
||||
.measurement-detail-table { margin-top: 12px; }
|
||||
.fee-dialog-summary { margin-top: 0; }
|
||||
.fee-confirmed-alert { margin-top: 14px; }
|
||||
.pallet-location-list { margin-top: 9px; border-top: 1px solid var(--border); }
|
||||
.pallet-location-list article { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 11px; padding: 12px 0; border-bottom: 1px solid var(--border); }
|
||||
.pallet-location-list article > svg { color: var(--primary); }
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { useFirstLegOrdersStore } from './store/firstLegOrders'
|
||||
export { getFirstLegStatusLabel, getFirstLegStatusTone } from './utils/firstLegOrder'
|
||||
export { default as InboundCodeAssociationDialog } from './components/InboundCodeAssociationDialog.vue'
|
||||
|
||||
@@ -62,6 +62,8 @@ export interface MerchantInboundFeeSnapshot {
|
||||
feeStatus: MerchantInboundFeeStatus
|
||||
inboundId: string
|
||||
inboundCode: string
|
||||
measurementId?: string | null
|
||||
measurementSource?: string | null
|
||||
chargeRuleId: string
|
||||
ruleCode: string
|
||||
chargeRuleVersion: number
|
||||
@@ -98,9 +100,55 @@ export interface MerchantInboundFeeSnapshot {
|
||||
confirmedAt: string | null
|
||||
sentAt: string | null
|
||||
nextProcessStatus: MerchantInboundProcessStatus | null
|
||||
customerConfirmationStatus: 'PENDING_CONFIRMATION' | 'CONFIRMED'
|
||||
customerConfirmedBy: string | null
|
||||
customerConfirmedByName: string | null
|
||||
customerConfirmedAt: string | null
|
||||
}
|
||||
|
||||
export interface MerchantMeasurementItem {
|
||||
id: string
|
||||
itemNo: string | null
|
||||
boxSpecification: string | null
|
||||
packageCount: number | null
|
||||
lengthCm: number | null
|
||||
widthCm: number | null
|
||||
heightCm: number | null
|
||||
unitVolumeCbm: number | null
|
||||
totalVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
chargeRuleId: string | null
|
||||
coefficientMode: 'ADD' | 'MULTIPLY' | null
|
||||
calculationRuleVersion: string | null
|
||||
}
|
||||
|
||||
export interface MerchantBillingBasis {
|
||||
chargeMode: string
|
||||
chargeUnit: string
|
||||
chargeableBasis: string
|
||||
actualWeightKg: number
|
||||
actualVolumeCbm: number
|
||||
convertedQuantity: number
|
||||
chargeableQuantity: number
|
||||
chargeableWeightKg: number | null
|
||||
chargeableVolumeCbm: number | null
|
||||
}
|
||||
|
||||
export interface MerchantInboundBilling {
|
||||
inbound: MerchantInboundResult
|
||||
measurementSummary: {
|
||||
measurementId: string | null
|
||||
sourceType: string | null
|
||||
measurementStatus: string | null
|
||||
actualPackageCount: number | null
|
||||
actualGrossWeightKg: number | null
|
||||
actualNetWeightKg: number | null
|
||||
actualVolumeCbm: number | null
|
||||
volumeWeightKg: number | null
|
||||
actualGoodsName: string | null
|
||||
calculationSource: 'OUTER' | string | null
|
||||
}
|
||||
measurementItems: MerchantMeasurementItem[]
|
||||
billingBasis: MerchantBillingBasis
|
||||
latestFee: MerchantInboundFeeSnapshot
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ test('opens order creation as a dedicated protected page while keeping draft edi
|
||||
assert.match(editorShellSource, /width="94vw"/)
|
||||
assert.doesNotMatch(editorShellSource, /<el-drawer/)
|
||||
assert.match(editorShellSource, /返回订单列表/)
|
||||
assert.match(editorShellSource, /\.editor-page-footer :deep\(\.editor-actions\) \{[^}]*justify-content: center;/)
|
||||
})
|
||||
|
||||
test('exposes real list, draft, submit, and detail workflow entries without cancellation', () => {
|
||||
@@ -330,6 +331,7 @@ test('collects the backend-required first-leg fields', () => {
|
||||
|
||||
test('distills order creation into the confirmed prototype sections with server pricing', () => {
|
||||
const cargoTableSource = source.slice(source.indexOf('<table class="cargo-edit-table">'), source.indexOf('</table>', source.indexOf('<table class="cargo-edit-table">')))
|
||||
const cargoToolbarSource = source.slice(source.indexOf('<div class="cargo-table-toolbar">'), source.indexOf('<div class="cargo-table-wrap">', source.indexOf('<div class="cargo-table-toolbar">')))
|
||||
assert.match(source, /<h3>目的地信息<\/h3>/)
|
||||
assert.match(source, /<h3>客户下单<\/h3>/)
|
||||
assert.match(source, /<h3>货物明细<\/h3>/)
|
||||
@@ -339,12 +341,20 @@ test('distills order creation into the confirmed prototype sections with server
|
||||
assert.match(source, /class="prototype-form-row prototype-form-row--tabs"/)
|
||||
assert.match(source, /<table class="cargo-edit-table">/)
|
||||
assert.match(source, /auth\.hasPermission\('order:first-leg:create'\)[\s\S]*批量导入订单/)
|
||||
assert.doesNotMatch(source, /create-batch-import-entry|需要一次创建多条订单|页面上方“Excel 批量导入”/)
|
||||
assert.doesNotMatch(source, /create-batch-import-entry/)
|
||||
assert.match(cargoToolbarSource, /isCreatePage && !routeDraftId && canCreateOrder/)
|
||||
assert.match(cargoToolbarSource, /downloadBatchTemplate[\s\S]*下载 Excel 模板/)
|
||||
assert.match(cargoToolbarSource, /openBatchImport[\s\S]*Excel 批量导入/)
|
||||
assert.match(cargoToolbarSource, /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, /直接选择模板内的中文下拉项,不需要填写英文代码或内部编号/)
|
||||
assert.match(source, /选择木制品时,模板会要求同步选择需要商检/)
|
||||
assert.match(source, /下载 Excel 错误报告/)
|
||||
assert.match(source, /\^excel-row-\(\\d\+\)\$[\s\S]*item\.index \+ 3/)
|
||||
assert.match(source, /Excel 第 \{\{ batchImportExcelRow\(item\) \}\} 行/)
|
||||
assert.doesNotMatch(source, /未填写行标识/)
|
||||
assert.match(source, /batchImportError[\s\S]*requestId[\s\S]*traceId/)
|
||||
assert.match(source, /添加行[\s\S]*删除所选行/)
|
||||
assert.doesNotMatch(source, /货物明细导入模板\.xls/)
|
||||
@@ -481,8 +491,9 @@ test('loads merchant inbound billing as an isolated read-only detail section', (
|
||||
assert.match(source, /<InboundBillingPanel/)
|
||||
assert.match(source, /@retry="retryInboundBilling"/)
|
||||
assert.match(source, /error instanceof ApiError && error\.code === 'A0404'/)
|
||||
assert.match(source, /inboundBillingUnavailableMessage\.value = error\.message \|\| '入库及费用结果尚未发布'/)
|
||||
assert.match(source, /error\.message \|\| unavailableMessage/)
|
||||
assert.match(source, /order\.status === 'DRAFT'/)
|
||||
assert.match(source, /feeConfirmationOpen\.value[\s\S]*\? '未推送计费重'[\s\S]*: '订单提交后可查看入库结果'/)
|
||||
assert.match(source, /订单提交后可查看入库结果/)
|
||||
assert.doesNotMatch(source, /confirmMerchantInbound|adjustMerchantInbound|sendMerchantInbound/)
|
||||
})
|
||||
@@ -492,11 +503,28 @@ test('keeps platform enterprise scope and tenant terminology out of the customer
|
||||
assert.doesNotMatch(templateSource, /租户|tenantId|所属企业|企业筛选/)
|
||||
})
|
||||
|
||||
test('discards stale inbound billing responses when the drawer closes or the user changes orders', () => {
|
||||
test('opens billing-weight confirmation independently from order details', () => {
|
||||
const handler = source.slice(source.indexOf('function openFeeConfirmation'), source.indexOf('async function retryDetail'))
|
||||
assert.match(handler, /feeConfirmationOpen\.value = true/)
|
||||
assert.match(handler, /loadInboundBilling\(order, '未推送计费重'\)/)
|
||||
assert.doesNotMatch(handler, /openDetail\(/)
|
||||
assert.match(source, /v-if="feeConfirmationOpen"[\s\S]*dialog-only[\s\S]*auto-open/)
|
||||
assert.match(source, /unavailable-message="未推送计费重"/)
|
||||
})
|
||||
|
||||
test('shows the standalone billing-weight entry at every order node', () => {
|
||||
const templateSource = source.slice(source.indexOf('<template>'), source.lastIndexOf('</template>'))
|
||||
const entries = templateSource.match(/<el-button link type="primary" @click="openFeeConfirmation\([^)]*\)">确认计费重<\/el-button>/g) ?? []
|
||||
assert.equal(entries.length, 2)
|
||||
assert.doesNotMatch(templateSource, /v-if="[^"]*status === 'WAIT_SPLIT'[^"]*"[^>]*>确认计费重/)
|
||||
})
|
||||
|
||||
test('discards stale inbound billing responses when billing views close or the user changes orders', () => {
|
||||
assert.match(source, /let inboundBillingRequestSequence = 0/)
|
||||
assert.match(source, /const sequence = \+\+inboundBillingRequestSequence/)
|
||||
assert.match(source, /sequence === inboundBillingRequestSequence[\s\S]*detailOpen\.value[\s\S]*detailId\.value === orderId/)
|
||||
assert.match(source, /sequence === inboundBillingRequestSequence[\s\S]*inboundBillingOrderId\.value === orderId[\s\S]*detailOpen\.value \|\| feeConfirmationOpen\.value/)
|
||||
assert.match(source, /watch\(detailOpen,[\s\S]*resetInboundBillingState\(\)/)
|
||||
assert.match(source, /watch\(feeConfirmationOpen,[\s\S]*resetInboundBillingState\(\)/)
|
||||
})
|
||||
|
||||
test('protects unsaved drafts and exposes recoverable load failures', () => {
|
||||
|
||||
@@ -72,6 +72,7 @@ const companyAuditLoaded = ref(false)
|
||||
const companySubmissionBlocked = computed(() => companyAuditLoaded.value && tenantStore.company.auditStatus !== 'ACTIVE')
|
||||
const canCreateOrder = computed(() => auth.hasPermission('order:first-leg:create'))
|
||||
const canEditOrder = computed(() => auth.hasPermission('order:first-leg:edit'))
|
||||
const canConfirmInboundFee = computed(() => auth.hasPermission('order:first-leg:edit'))
|
||||
const availableWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === 'DOMESTIC'))
|
||||
const availableDestinationWarehouses = computed(() => store.warehouses.filter((warehouse) => warehouse.warehouseType === form.destinationType))
|
||||
const filters = ref<QueryModel>({ orderNo: '', receivingWarehouseId: '', channelName: '', cargoKeyword: '', createdRange: [], status: '', destinationType: '', trackingNumber: '' })
|
||||
@@ -198,8 +199,10 @@ const detailCargoPageSize = ref(5)
|
||||
const inboundBilling = ref<MerchantInboundBilling | null>(null)
|
||||
const inboundBillingLoading = ref(false)
|
||||
const inboundBillingUnavailable = ref(false)
|
||||
const inboundBillingUnavailableMessage = ref('入库及费用结果尚未发布')
|
||||
const inboundBillingUnavailableMessage = ref('还未测量推送')
|
||||
const inboundBillingError = ref('')
|
||||
const inboundBillingOrderId = ref('')
|
||||
const feeConfirmationOpen = ref(false)
|
||||
const customsByOrder = reactive<Record<string, MerchantCustomsContainer | null | undefined>>({})
|
||||
const customsEligibilityPending = new Set<string>()
|
||||
const customsDialogOpen = ref(false)
|
||||
@@ -649,12 +652,16 @@ watch(isCreatePage, (isCreate, wasCreate) => {
|
||||
}, { immediate: true })
|
||||
|
||||
watch(detailOpen, (open) => {
|
||||
if (!open) {
|
||||
if (!open && !feeConfirmationOpen.value) {
|
||||
resetInboundBillingState()
|
||||
if (detailOrder.value) releaseProductImagePreviews(detailOrder.value.items)
|
||||
}
|
||||
})
|
||||
|
||||
watch(feeConfirmationOpen, (open) => {
|
||||
if (!open && !detailOpen.value) resetInboundBillingState()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
resetInboundBillingState()
|
||||
channelRequestSequence += 1
|
||||
@@ -1197,6 +1204,7 @@ async function openEdit(order: FirstLegOrder) {
|
||||
}
|
||||
|
||||
async function openDetail(order: FirstLegOrder) {
|
||||
feeConfirmationOpen.value = false
|
||||
detailId.value = order.id
|
||||
detailCargoPage.value = 1
|
||||
detailOpen.value = true
|
||||
@@ -1215,6 +1223,14 @@ async function openDetail(order: FirstLegOrder) {
|
||||
}
|
||||
}
|
||||
|
||||
function openFeeConfirmation(order: FirstLegOrder) {
|
||||
detailOpen.value = false
|
||||
resetInboundBillingState()
|
||||
inboundBillingUnavailableMessage.value = '未推送计费重'
|
||||
feeConfirmationOpen.value = true
|
||||
void loadInboundBilling(order, '未推送计费重')
|
||||
}
|
||||
|
||||
async function retryDetail() {
|
||||
const order = detailId.value ? store.getById(detailId.value) : undefined
|
||||
if (order) await openDetail(order)
|
||||
@@ -1222,30 +1238,34 @@ async function retryDetail() {
|
||||
|
||||
function resetInboundBillingState() {
|
||||
inboundBillingRequestSequence += 1
|
||||
inboundBillingOrderId.value = ''
|
||||
inboundBilling.value = null
|
||||
inboundBillingLoading.value = false
|
||||
inboundBillingUnavailable.value = false
|
||||
inboundBillingUnavailableMessage.value = '入库及费用结果尚未发布'
|
||||
inboundBillingUnavailableMessage.value = '还未测量推送'
|
||||
inboundBillingError.value = ''
|
||||
}
|
||||
|
||||
function isCurrentInboundBillingRequest(sequence: number, orderId: string) {
|
||||
return sequence === inboundBillingRequestSequence
|
||||
&& detailOpen.value
|
||||
&& detailId.value === orderId
|
||||
&& inboundBillingOrderId.value === orderId
|
||||
&& (detailOpen.value || feeConfirmationOpen.value)
|
||||
}
|
||||
|
||||
async function loadInboundBilling(order: FirstLegOrder) {
|
||||
async function loadInboundBilling(order: FirstLegOrder, unavailableMessage = '还未测量推送') {
|
||||
const sequence = ++inboundBillingRequestSequence
|
||||
inboundBillingOrderId.value = order.id
|
||||
inboundBilling.value = null
|
||||
inboundBillingLoading.value = false
|
||||
inboundBillingUnavailable.value = false
|
||||
inboundBillingUnavailableMessage.value = '入库及费用结果尚未发布'
|
||||
inboundBillingUnavailableMessage.value = unavailableMessage
|
||||
inboundBillingError.value = ''
|
||||
|
||||
if (order.status === 'DRAFT') {
|
||||
inboundBillingUnavailable.value = true
|
||||
inboundBillingUnavailableMessage.value = '订单提交后可查看入库结果'
|
||||
inboundBillingUnavailableMessage.value = feeConfirmationOpen.value
|
||||
? '未推送计费重'
|
||||
: '订单提交后可查看入库结果'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1258,7 +1278,9 @@ async function loadInboundBilling(order: FirstLegOrder) {
|
||||
if (!isCurrentInboundBillingRequest(sequence, order.id)) return
|
||||
if (error instanceof ApiError && error.code === 'A0404') {
|
||||
inboundBillingUnavailable.value = true
|
||||
inboundBillingUnavailableMessage.value = error.message || '入库及费用结果尚未发布'
|
||||
inboundBillingUnavailableMessage.value = feeConfirmationOpen.value
|
||||
? '未推送计费重'
|
||||
: error.message || unavailableMessage
|
||||
} else {
|
||||
inboundBillingError.value = error instanceof Error ? error.message : '入库与费用加载失败'
|
||||
}
|
||||
@@ -1268,8 +1290,12 @@ async function loadInboundBilling(order: FirstLegOrder) {
|
||||
}
|
||||
|
||||
async function retryInboundBilling() {
|
||||
const order = detailId.value ? store.getById(detailId.value) : undefined
|
||||
if (order) await loadInboundBilling(order)
|
||||
const order = inboundBillingOrderId.value ? store.getById(inboundBillingOrderId.value) : undefined
|
||||
if (order) await loadInboundBilling(order, feeConfirmationOpen.value ? '未推送计费重' : '还未测量推送')
|
||||
}
|
||||
|
||||
function handleInboundFeeConfirmed() {
|
||||
if (!feeConfirmationOpen.value) void retryInboundBilling()
|
||||
}
|
||||
|
||||
async function confirmDiscardChanges() {
|
||||
@@ -1491,6 +1517,11 @@ function openBatchImport() {
|
||||
batchImportOpen.value = true
|
||||
}
|
||||
|
||||
function batchImportExcelRow(item: { index: number; clientRowId: string }) {
|
||||
const matched = /^excel-row-(\d+)$/.exec(item.clientRowId)
|
||||
return matched ? Number(matched[1]) : item.index + 3
|
||||
}
|
||||
|
||||
function selectBatchImportFile(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
@@ -1773,6 +1804,7 @@ async function searchOrders(model: QueryModel) {
|
||||
<template #actions="{ row }">
|
||||
<div class="order-row-actions">
|
||||
<el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button>
|
||||
<el-button link type="primary" @click="openFeeConfirmation(row as FirstLegOrder)">确认计费重</el-button>
|
||||
<el-button v-if="canEditOrder && canClientEditFirstLegOrder((row as FirstLegOrder).status)" link type="primary" :loading="openingEditor" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button v-if="eligibleCustomsDetail(row as FirstLegOrder)" link type="primary" @click="openCustomsDocuments(row as FirstLegOrder)"><FileText :size="15" />报关资料</el-button>
|
||||
</div>
|
||||
@@ -1838,6 +1870,7 @@ async function searchOrders(model: QueryModel) {
|
||||
<span>更新于 {{ formatDateTime(order.updatedAt) }}</span>
|
||||
<div>
|
||||
<el-button link type="primary" @click="openDetail(order)"><Eye :size="15" />详情</el-button>
|
||||
<el-button link type="primary" @click="openFeeConfirmation(order)">确认计费重</el-button>
|
||||
<el-button v-if="canEditOrder && canClientEditFirstLegOrder(order.status)" link type="primary" :loading="openingEditor" @click="openEdit(order)"><Pencil :size="15" />编辑</el-button>
|
||||
<el-button v-if="eligibleCustomsDetail(order)" link type="primary" @click="openCustomsDocuments(order)"><FileText :size="15" />报关资料</el-button>
|
||||
</div>
|
||||
@@ -1928,7 +1961,12 @@ 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">
|
||||
<span class="cargo-table-hint">可逐行维护货物明细;批量订单请返回订单列表使用正式 Excel 导入。</span>
|
||||
<div v-if="isCreatePage && !routeDraftId && canCreateOrder" class="cargo-import-actions">
|
||||
<el-button class="cargo-download-button" :loading="batchTemplateDownloading" @click="downloadBatchTemplate"><Download :size="16" />下载 Excel 模板</el-button>
|
||||
<el-button class="cargo-upload-button" type="primary" plain @click="openBatchImport"><Upload :size="16" />Excel 批量导入</el-button>
|
||||
<span class="cargo-table-hint">Excel 导入用于一次创建多条订单草稿。</span>
|
||||
</div>
|
||||
<span v-else class="cargo-table-hint">可逐行维护货物明细。</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">
|
||||
@@ -1947,10 +1985,10 @@ async function searchOrders(model: QueryModel) {
|
||||
<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-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.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-input-number v-model="item.lengthCm" :min="0" :precision="2" :controls="false" placeholder="长" /></td><td><el-input-number v-model="item.widthCm" :min="0" :precision="2" :controls="false" placeholder="宽" /></td><td><el-input-number v-model="item.heightCm" :min="0" :precision="2" :controls="false" placeholder="高" /></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" placeholder="每箱数量" /></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" placeholder="箱数" /></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" placeholder="总毛重" /></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" placeholder="总体积" /></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" placeholder="总数量" /></el-form-item></td><td><el-input-number v-model="item.netWeightKg" :min="0" :precision="3" :controls="false" placeholder="净重" /></td>
|
||||
<td><el-input-number v-model="item.unitDeclaredValue" :min="0" :precision="2" :controls="false" placeholder="申报单价" /></td><td><el-input-number v-model="item.declaredValue" :min="0" :precision="2" :controls="false" 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" placeholder="长" /><span>×</span><el-input-number v-model="item.productWidthCm" :min="0" :precision="2" :controls="false" placeholder="宽" /><span>×</span><el-input-number v-model="item.productHeightCm" :min="0" :precision="2" :controls="false" placeholder="高" /></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>
|
||||
@@ -1998,7 +2036,7 @@ async function searchOrders(model: QueryModel) {
|
||||
|
||||
<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 title="使用中文模板创建订单草稿" description="仅支持 .xlsx,文件不超过 5MB、最多 50 行;请直接选择模板内的中文下拉项,不需要填写英文代码或内部编号。导入成功的数据只创建草稿,不会直接提交审核;选择木制品时,模板会要求同步选择需要商检。" 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" />
|
||||
@@ -2017,8 +2055,8 @@ async function searchOrders(model: QueryModel) {
|
||||
<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 v-for="item in batchImportResult.result.items.filter((row) => !row.success)" :key="item.index">
|
||||
Excel 第 {{ batchImportExcelRow(item) }} 行:{{ item.errorMessage || item.errorCode || '导入失败' }}
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
@@ -2145,12 +2183,28 @@ async function searchOrders(model: QueryModel) {
|
||||
:unavailable="inboundBillingUnavailable"
|
||||
:unavailable-message="inboundBillingUnavailableMessage"
|
||||
:error="inboundBillingError"
|
||||
:can-confirm="canConfirmInboundFee"
|
||||
@retry="retryInboundBilling"
|
||||
@confirmed="handleInboundFeeConfirmed"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
<template #footer><div v-if="detailOrder" class="editor-actions"><el-button @click="detailOpen = false">关闭</el-button><el-button v-if="canEditOrder && canClientEditFirstLegOrder(detailOrder.status)" :loading="openingEditor" @click="openEdit(detailOrder)"><Pencil :size="16" />编辑草稿</el-button></div></template>
|
||||
</el-dialog>
|
||||
<InboundBillingPanel
|
||||
v-if="feeConfirmationOpen"
|
||||
dialog-only
|
||||
auto-open
|
||||
:data="inboundBilling"
|
||||
:loading="inboundBillingLoading"
|
||||
:unavailable="inboundBillingUnavailable"
|
||||
unavailable-message="未推送计费重"
|
||||
:error="inboundBillingError"
|
||||
:can-confirm="canConfirmInboundFee"
|
||||
@retry="retryInboundBilling"
|
||||
@confirmed="handleInboundFeeConfirmed"
|
||||
@closed="feeConfirmationOpen = false"
|
||||
/>
|
||||
<CustomsDocumentsDialog
|
||||
v-model="customsDialogOpen"
|
||||
:order-id="customsDialogOrderId"
|
||||
|
||||
@@ -102,7 +102,11 @@ test('binds exactly one destination warehouse through a destination dialog', ()
|
||||
assert.match(source, /availableDestinationWarehouses\.value\.find/)
|
||||
assert.match(source, /destinationWarehouseId: warehouse\.id/)
|
||||
assert.match(source, /destinationWarehouseId: ''/)
|
||||
assert.match(source, /const required = \[destinationForm\.privateName, destinationForm\.countryCode\]/)
|
||||
assert.match(source, /getMerchantOrderAddresses\('PRIVATE_DESTINATION'\)/)
|
||||
assert.match(source, /v-model="destinationForm\.privateAddressId"/)
|
||||
assert.match(source, /privateDestinationAddressId: String\(address\.id\)/)
|
||||
assert.match(source, /privateDestinationAddressId: row\.destinationType === 'PRIVATE' \? row\.privateDestinationAddressId : ''/)
|
||||
assert.match(source, /hasCompletePrivateContact\(row\)/)
|
||||
assert.doesNotMatch(source, /v-model="destinationForm\.privateName"/)
|
||||
assert.match(source, /确认绑定/)
|
||||
})
|
||||
|
||||
@@ -6,7 +6,9 @@ import { MapPin, Plus, Send, Trash2 } from '@lucide/vue'
|
||||
import { useAuthStore } from '@/modules/auth/store/auth'
|
||||
import {
|
||||
getMerchantInboundCodePolicy,
|
||||
getMerchantOrderAddresses,
|
||||
getMerchantPricingChannels,
|
||||
type CustomerOrderAddress,
|
||||
type InboundCodePolicy,
|
||||
type MerchantPricingChannel,
|
||||
} from '@/modules/first-leg/api/firstLeg'
|
||||
@@ -32,6 +34,7 @@ interface QuickOrderRow {
|
||||
destinationWarehouseName: string
|
||||
destinationCountryCode: string
|
||||
amazonWarehouseCode: string
|
||||
privateDestinationAddressId: string
|
||||
consigneeContactName: string
|
||||
consigneePhone: string
|
||||
consigneeCity: string
|
||||
@@ -64,6 +67,8 @@ const inboundCodePolicy = ref<InboundCodePolicy | null>(null)
|
||||
const channelOptions = ref<QuickChannelOption[]>([])
|
||||
const destinationDialogOpen = ref(false)
|
||||
const destinationLoading = ref(false)
|
||||
const privateAddresses = ref<CustomerOrderAddress[]>([])
|
||||
const privateAddressesError = ref('')
|
||||
const editingDestinationRow = ref<QuickOrderRow>()
|
||||
const draftResults = reactive<QuickDraftResult[]>([])
|
||||
const resultDialogOpen = ref(false)
|
||||
@@ -71,13 +76,7 @@ const activeBatchKey = ref('')
|
||||
const destinationForm = reactive({
|
||||
type: 'FBA' as DestinationType,
|
||||
warehouseId: '',
|
||||
privateName: '',
|
||||
countryCode: 'US',
|
||||
contactName: '',
|
||||
phone: '',
|
||||
city: '',
|
||||
address: '',
|
||||
postalCode: '',
|
||||
privateAddressId: '',
|
||||
})
|
||||
let rowSequence = 0
|
||||
let productSequence = 0
|
||||
@@ -101,6 +100,7 @@ function createRow(): QuickOrderRow {
|
||||
destinationWarehouseName: '',
|
||||
destinationCountryCode: '',
|
||||
amazonWarehouseCode: '',
|
||||
privateDestinationAddressId: '',
|
||||
consigneeContactName: '',
|
||||
consigneePhone: '',
|
||||
consigneeCity: '',
|
||||
@@ -149,6 +149,10 @@ function selectedChannel(row: QuickOrderRow) {
|
||||
return channelOptions.value.find((channel) => channelKey(channel.warehouseId, channel.channelCode) === row.channelKey)
|
||||
}
|
||||
|
||||
function addressLabel(address: CustomerOrderAddress) {
|
||||
return `${address.addressName} · ${address.contactName} · ${address.countryCode} ${address.city} ${address.addressLine}`
|
||||
}
|
||||
|
||||
function createConsigneeSnapshot(row: QuickOrderRow) {
|
||||
return {
|
||||
id: '',
|
||||
@@ -187,7 +191,7 @@ function buildOrderDraft(row: QuickOrderRow, channel: QuickChannelOption): First
|
||||
shipperPartySnapshot: null,
|
||||
shipper: '',
|
||||
consigneePartyId: '',
|
||||
privateDestinationAddressId: '',
|
||||
privateDestinationAddressId: row.destinationType === 'PRIVATE' ? row.privateDestinationAddressId : '',
|
||||
consigneePartySnapshot: row.destinationType === 'PRIVATE' && hasCompletePrivateContact(row)
|
||||
? createConsigneeSnapshot(row) : null,
|
||||
consignee: row.destinationType === 'PRIVATE' && hasCompletePrivateContact(row)
|
||||
@@ -231,21 +235,24 @@ async function openDestinationDialog(row: QuickOrderRow, typeValue: string | num
|
||||
Object.assign(destinationForm, {
|
||||
type,
|
||||
warehouseId: row.destinationType === type ? row.destinationWarehouseId : '',
|
||||
privateName: row.destinationType === type ? row.destinationWarehouseName : '',
|
||||
countryCode: row.destinationType === type ? row.destinationCountryCode || 'US' : 'US',
|
||||
contactName: row.destinationType === type ? row.consigneeContactName : '',
|
||||
phone: row.destinationType === type ? row.consigneePhone : '',
|
||||
city: row.destinationType === type ? row.consigneeCity : '',
|
||||
address: row.destinationType === type ? row.consigneeAddress : '',
|
||||
postalCode: row.destinationType === type ? row.consigneePostalCode : '',
|
||||
privateAddressId: row.destinationType === type ? row.privateDestinationAddressId : '',
|
||||
})
|
||||
destinationDialogOpen.value = true
|
||||
if (type === 'PRIVATE') return
|
||||
destinationLoading.value = true
|
||||
try {
|
||||
await store.loadWarehouseType(type)
|
||||
if (type === 'PRIVATE') {
|
||||
privateAddressesError.value = ''
|
||||
privateAddresses.value = await getMerchantOrderAddresses('PRIVATE_DESTINATION')
|
||||
} else {
|
||||
await store.loadWarehouseType(type)
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '目的仓加载失败,请稍后重试')
|
||||
const message = error instanceof Error ? error.message : '目的仓加载失败,请稍后重试'
|
||||
if (type === 'PRIVATE') {
|
||||
privateAddresses.value = []
|
||||
privateAddressesError.value = message
|
||||
}
|
||||
ElMessage.error(message)
|
||||
} finally {
|
||||
destinationLoading.value = false
|
||||
}
|
||||
@@ -266,6 +273,7 @@ function bindDestination() {
|
||||
destinationWarehouseName: warehouse.name,
|
||||
destinationCountryCode: warehouse.countryCode,
|
||||
amazonWarehouseCode: destinationForm.type === 'FBA' ? warehouse.code : '',
|
||||
privateDestinationAddressId: '',
|
||||
consigneeContactName: '',
|
||||
consigneePhone: '',
|
||||
consigneeCity: '',
|
||||
@@ -273,26 +281,23 @@ function bindDestination() {
|
||||
consigneePostalCode: '',
|
||||
})
|
||||
} else {
|
||||
const required = [destinationForm.privateName, destinationForm.countryCode]
|
||||
if (required.some((value) => !value.trim())) {
|
||||
ElMessage.warning('请填写私人仓名称和目的国家')
|
||||
return
|
||||
}
|
||||
if (!/^[A-Za-z]{2}$/.test(destinationForm.countryCode.trim())) {
|
||||
ElMessage.warning('目的国家请输入两位英文代码,例如 US')
|
||||
const address = privateAddresses.value.find((item) => String(item.id) === destinationForm.privateAddressId)
|
||||
if (!address) {
|
||||
ElMessage.warning('请选择企业已保存的私人仓地址')
|
||||
return
|
||||
}
|
||||
Object.assign(row, {
|
||||
destinationType: 'PRIVATE',
|
||||
destinationWarehouseId: '',
|
||||
destinationWarehouseName: destinationForm.privateName.trim(),
|
||||
destinationCountryCode: destinationForm.countryCode.trim().toUpperCase(),
|
||||
destinationWarehouseName: address.addressName,
|
||||
destinationCountryCode: address.countryCode,
|
||||
amazonWarehouseCode: '',
|
||||
consigneeContactName: destinationForm.contactName.trim(),
|
||||
consigneePhone: destinationForm.phone.trim(),
|
||||
consigneeCity: destinationForm.city.trim(),
|
||||
consigneeAddress: destinationForm.address.trim(),
|
||||
consigneePostalCode: destinationForm.postalCode.trim(),
|
||||
privateDestinationAddressId: String(address.id),
|
||||
consigneeContactName: address.contactName,
|
||||
consigneePhone: address.phone,
|
||||
consigneeCity: address.city,
|
||||
consigneeAddress: address.addressLine,
|
||||
consigneePostalCode: address.postalCode ?? '',
|
||||
})
|
||||
}
|
||||
destinationDialogOpen.value = false
|
||||
@@ -347,6 +352,7 @@ async function submitQuickOrder() {
|
||||
return
|
||||
}
|
||||
const incompleteIndex = rows.findIndex((row) => !row.channelKey || !row.destinationWarehouseName
|
||||
|| (row.destinationType === 'PRIVATE' && !row.privateDestinationAddressId)
|
||||
|| row.products.some((product) => !product.sku.trim() || product.packageCount < 1))
|
||||
if (incompleteIndex >= 0) {
|
||||
ElMessage.warning(`请完善第 ${incompleteIndex + 1} 行的渠道、目的仓、产品 SKU 和箱数`)
|
||||
@@ -481,7 +487,7 @@ onMounted(loadChannels)
|
||||
<td class="products-cell">
|
||||
<div v-for="(product, productIndex) in row.products" :key="product.id" class="product-fields">
|
||||
<el-input v-model="product.sku" maxlength="64" placeholder="产品 SKU" />
|
||||
<el-input-number v-model="product.packageCount" :min="1" :max="99999" :precision="0" controls-position="right" aria-label="箱数" />
|
||||
<el-input-number v-model="product.packageCount" :min="1" :max="99999" :precision="0" controls-position="right" aria-label="箱数" placeholder="请输入箱数" />
|
||||
<el-button v-if="row.products.length > 1" text type="danger" aria-label="删除产品" @click="removeProduct(row, productIndex)"><Trash2 :size="15" /></el-button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -513,15 +519,13 @@ onMounted(loadChannels)
|
||||
<small v-if="!destinationLoading && availableDestinationWarehouses.length === 0">平台暂未维护可用目的仓,请联系平台管理员。</small>
|
||||
</el-form-item>
|
||||
<template v-else>
|
||||
<el-form-item label="私人地址名称" required><el-input v-model="destinationForm.privateName" maxlength="128" placeholder="例如:洛杉矶客户自有仓" /></el-form-item>
|
||||
<div class="destination-private-grid">
|
||||
<el-form-item label="目的国家" required><el-input v-model="destinationForm.countryCode" maxlength="2" placeholder="US" /></el-form-item>
|
||||
<el-form-item label="联系人" required><el-input v-model="destinationForm.contactName" maxlength="64" /></el-form-item>
|
||||
<el-form-item label="联系电话" required><el-input v-model="destinationForm.phone" maxlength="32" /></el-form-item>
|
||||
<el-form-item label="城市" required><el-input v-model="destinationForm.city" maxlength="64" /></el-form-item>
|
||||
</div>
|
||||
<el-form-item label="详细地址" required><el-input v-model="destinationForm.address" maxlength="256" /></el-form-item>
|
||||
<el-form-item label="邮编"><el-input v-model="destinationForm.postalCode" maxlength="20" /></el-form-item>
|
||||
<el-form-item label="私人仓地址" required>
|
||||
<el-select v-model="destinationForm.privateAddressId" filterable :loading="destinationLoading" placeholder="请选择企业已保存的私人仓地址">
|
||||
<el-option v-for="address in privateAddresses" :key="String(address.id)" :label="addressLabel(address)" :value="String(address.id)" />
|
||||
</el-select>
|
||||
<small v-if="privateAddressesError">私人仓地址加载失败,请关闭后重试。</small>
|
||||
<small v-else-if="!destinationLoading && privateAddresses.length === 0">暂无已保存的私人仓地址,请先在普通下单页面维护。</small>
|
||||
</el-form-item>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
|
||||
@@ -25,3 +25,14 @@ test('keeps internal IDs only as non-rendered routing, selection, and request va
|
||||
assert.match(overseas, /:value="candidate\.orderId"/)
|
||||
assert.match(firstLeg, /detailId\.value = order\.id/)
|
||||
})
|
||||
|
||||
test('keeps overseas warehouse operations first and renders readable list values', () => {
|
||||
assert.match(overseas, /columns: \['操作', '贴标申请单号'/)
|
||||
assert.match(overseas, /columns: \['操作', '转运单号'/)
|
||||
assert.match(overseas, /columns: \['操作', '废弃申请单号'/)
|
||||
assert.match(overseas, /columns: \['操作', '退货换标单号'/)
|
||||
assert.match(overseas, /function statusLabel\(value: string\)/)
|
||||
assert.match(overseas, /function deliveryMethodLabel\(value: string\)/)
|
||||
assert.match(overseas, /function formatDateTime\(value: string\)/)
|
||||
assert.match(overseas, /<td><button class="warehouse-link"[^>]*>.*?查看<\/button><\/td><td v-for="\(_, index\) in config\.columns\.slice\(1\)"/s)
|
||||
})
|
||||
|
||||
@@ -102,6 +102,16 @@ export interface DeliveryQuery {
|
||||
plannedTo?: string
|
||||
}
|
||||
|
||||
export function normalizeDeliveryPage(data: PageResult<DeliveryRow>): PageResult<DeliveryRow> {
|
||||
return {
|
||||
...data,
|
||||
page: Number(data.page),
|
||||
size: Number(data.size),
|
||||
total: Number(data.total),
|
||||
pages: Number(data.pages),
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeliveryTransferPayload {
|
||||
dispatchNo: string
|
||||
idempotencyKey: string
|
||||
@@ -127,7 +137,7 @@ export function listDeliveryOrders(params: DeliveryQuery) {
|
||||
method: 'GET',
|
||||
url: '/merchant/v1/last-leg/delivery-orders',
|
||||
params,
|
||||
})
|
||||
}).then(normalizeDeliveryPage)
|
||||
}
|
||||
|
||||
export function getDeliveryOrder(deliveryNo: string) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { overseasApiRequest } from '@/api/overseasHttp'
|
||||
|
||||
export interface PageResult<T> { records: T[]; page: number; size: number; total: number; pages: number }
|
||||
export interface InboundPlanQuery {
|
||||
page: number
|
||||
size: number
|
||||
planNo?: string
|
||||
inboundCode?: string
|
||||
status?: string
|
||||
destinationWarehouseCode?: string
|
||||
appointmentStatus?: string
|
||||
dispatchStatus?: string
|
||||
createdFrom?: string
|
||||
createdTo?: string
|
||||
}
|
||||
export interface InboundPlanSummary {
|
||||
planNo: string
|
||||
inboundCode: string
|
||||
orderType: string | null
|
||||
warehouseCode: string | null
|
||||
warehouseName: string | null
|
||||
fbaWarehouseCode: string | null
|
||||
packageCount: number
|
||||
pickedPackageCount: number
|
||||
volumeCbm: number | null
|
||||
palletCount: number
|
||||
status: string
|
||||
appointmentNo: string | null
|
||||
appointmentStatus: string | null
|
||||
appointmentAt: string | null
|
||||
expectedLoadingAt: string | null
|
||||
dispatchStatus: string | null
|
||||
createdAt: string
|
||||
version: number
|
||||
}
|
||||
export interface InboundPlanLine {
|
||||
inboundCode: string
|
||||
fbaWarehouseCode: string | null
|
||||
destinationWarehouseCode: string | null
|
||||
destinationWarehouseName: string | null
|
||||
volumeCbm: number
|
||||
requiredPackageCount: number
|
||||
plannedPalletCount: number
|
||||
}
|
||||
export interface InboundPlanPallet {
|
||||
palletNo: string
|
||||
destinationWarehouseCode: string | null
|
||||
destinationWarehouseName: string | null
|
||||
capacityCbm: number
|
||||
allocatedVolumeCbm: number
|
||||
}
|
||||
export interface InboundPlanDetail {
|
||||
planNo: string
|
||||
containerNo: string | null
|
||||
status: string
|
||||
lineCount: number
|
||||
palletCount: number
|
||||
totalVolumeCbm: number
|
||||
expectedPackageCount: number
|
||||
pickedPackageCount: number
|
||||
lines: InboundPlanLine[]
|
||||
pallets: InboundPlanPallet[]
|
||||
version: number
|
||||
}
|
||||
|
||||
export async function listInboundPlans(params: InboundPlanQuery) {
|
||||
const result = await overseasApiRequest<PageResult<InboundPlanSummary>>({ method: 'GET', url: '/merchant/v1/last-leg/inbound-plans', params })
|
||||
return { ...result, page: Number(result.page), size: Number(result.size), total: Number(result.total), pages: Number(result.pages) }
|
||||
}
|
||||
|
||||
export function getInboundPlan(planNo: string) {
|
||||
return overseasApiRequest<InboundPlanDetail>({ method: 'GET', url: `/merchant/v1/last-leg/inbound-plans/${encodeURIComponent(planNo)}` })
|
||||
}
|
||||
@@ -47,6 +47,12 @@ test('renders the operation column first and covers loading, empty, error and pa
|
||||
assert.match(listSource, /@click="loadOrders">重试/)
|
||||
assert.match(listSource, /listDeliveryOrders\(buildQuery\(\)\)/)
|
||||
assert.match(listSource, /<el-pagination/)
|
||||
assert.match(listSource, /v-model:current-page="page"/)
|
||||
assert.match(listSource, /v-model:page-size="pageSize"/)
|
||||
assert.match(listSource, /@current-change="changePage"/)
|
||||
assert.match(listSource, /@size-change="changePageSize"/)
|
||||
assert.match(apiSource, /\.then\(normalizeDeliveryPage\)/)
|
||||
assert.match(apiSource, /total: Number\(data\.total\)/)
|
||||
assert.doesNotMatch(listSource, /row\.id[^a-zA-Z]|deliveryNo \|\| row\.id|customerId|tenantId/)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const api = readFileSync(new URL('./api/inboundPlans.ts', import.meta.url), 'utf8')
|
||||
const routes = readFileSync(new URL('./routes.ts', import.meta.url), 'utf8')
|
||||
const view = readFileSync(new URL('./views/InboundOrdersView.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('activates the customer-scoped overseas inbound list and detail', () => {
|
||||
assert.match(routes, /path: 'orders\/inbound'[\s\S]*?release: 'active'[\s\S]*?permission: 'merchant:warehouse:inbound:list'/)
|
||||
assert.match(api, /\/merchant\/v1\/last-leg\/inbound-plans/)
|
||||
assert.match(api, /encodeURIComponent\(planNo\)/)
|
||||
assert.doesNotMatch(api, /\/platform\/|\/internal\/|customerId|tenantId/)
|
||||
})
|
||||
|
||||
test('keeps actions first and exposes filters loading errors empty state pagination and business identifiers', () => {
|
||||
assert.match(view, /<el-table-column label="操作"[^>]*fixed="left"/)
|
||||
for (const token of ['planNo', 'inboundCode', 'destinationWarehouseCode', 'appointmentStatus', 'dispatchStatus', 'createdDates']) assert.match(view, new RegExp(token))
|
||||
assert.match(view, /v-loading="loading"/)
|
||||
assert.match(view, /type="error"/)
|
||||
assert.match(view, /empty-text="暂无入库订单"/)
|
||||
assert.match(view, /<el-pagination/)
|
||||
assert.match(view, /detail\.planNo/)
|
||||
assert.doesNotMatch(view, /planId|orderId|warehouseId|customerId/)
|
||||
})
|
||||
@@ -17,7 +17,7 @@ export const orderManagementModule = {
|
||||
path: 'orders/inbound',
|
||||
name: 'orders-inbound',
|
||||
component: () => import('./views/InboundOrdersView.vue'),
|
||||
release: 'prototype',
|
||||
release: 'active',
|
||||
meta: { title: '入库订单列表', description: '查询海外仓入库订单', permission: 'merchant:warehouse:inbound:list' },
|
||||
navigation: { key: 'orders-inbound', icon: Package },
|
||||
},
|
||||
|
||||
@@ -186,7 +186,7 @@ async function cancel() {
|
||||
<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="金额" width="180"><template #default="{ row }"><el-input-number v-model="row.amount" :min="0" :precision="2" :step="1" controls-position="right" placeholder="请输入金额" /></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>
|
||||
|
||||
@@ -144,7 +144,7 @@ onMounted(loadOrders)
|
||||
<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 v-if="!errorMessage && (records.length > 0 || total > 0)" class="delivery-pagination"><span>共 {{ total }} 条</span><el-pagination v-model:current-page="page" v-model:page-size="pageSize" :page-sizes="[10, 20, 50, 100]" layout="sizes, prev, pager, next" :total="total" @current-change="changePage" @size-change="changePageSize" /></div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import PrototypeFeatureView from '@/components/common/PrototypeFeatureView.vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getInboundPlan, listInboundPlans, type InboundPlanDetail, type InboundPlanSummary } from '../api/inboundPlans'
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const rows = ref<InboundPlanSummary[]>([])
|
||||
const detail = ref<InboundPlanDetail | null>(null)
|
||||
const total = ref(0)
|
||||
const query = reactive({
|
||||
page: 1, size: 20, planNo: '', inboundCode: '', destinationWarehouseCode: '', status: '',
|
||||
appointmentStatus: '', dispatchStatus: '', createdDates: [] as string[],
|
||||
})
|
||||
|
||||
function text(value: unknown) { return value === null || value === undefined || value === '' ? '—' : String(value) }
|
||||
function dateTime(value: unknown) { return value ? new Date(String(value)).toLocaleString('zh-CN', { hour12: false }) : '—' }
|
||||
function statusLabel(value: unknown) {
|
||||
return ({ PENDING: '待处理', PROCESSING: '处理中', COMPLETED: '已完成', CANCELLED: '已取消', UNAPPOINTED: '未预约', SUCCESS: '预约成功', FAILED: '预约失败', SCHEDULED: '已排车', UNSCHEDULED: '未排车' } as Record<string, string>)[String(value)] || text(value)
|
||||
}
|
||||
function errorMessage(cause: unknown) { return cause instanceof Error ? cause.message : '请求失败,请稍后重试' }
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const result = await listInboundPlans({
|
||||
page: query.page, size: query.size, planNo: query.planNo || undefined, inboundCode: query.inboundCode || undefined,
|
||||
destinationWarehouseCode: query.destinationWarehouseCode || undefined, status: query.status || undefined,
|
||||
appointmentStatus: query.appointmentStatus || undefined, dispatchStatus: query.dispatchStatus || undefined,
|
||||
createdFrom: query.createdDates[0] ? `${query.createdDates[0]}T00:00:00` : undefined,
|
||||
createdTo: query.createdDates[1] ? `${query.createdDates[1]}T23:59:59` : undefined,
|
||||
})
|
||||
rows.value = result.records
|
||||
total.value = result.total
|
||||
} catch (cause) {
|
||||
error.value = errorMessage(cause)
|
||||
rows.value = []
|
||||
total.value = 0
|
||||
} finally { loading.value = false }
|
||||
}
|
||||
|
||||
function resetQuery() {
|
||||
Object.assign(query, { page: 1, planNo: '', inboundCode: '', destinationWarehouseCode: '', status: '', appointmentStatus: '', dispatchStatus: '', createdDates: [] })
|
||||
void load()
|
||||
}
|
||||
|
||||
async function openDetail(candidate: unknown) {
|
||||
const row = candidate as InboundPlanSummary
|
||||
loading.value = true
|
||||
try { detail.value = await getInboundPlan(row.planNo) }
|
||||
catch (cause) { ElMessage.error(errorMessage(cause)) }
|
||||
finally { loading.value = false }
|
||||
}
|
||||
|
||||
onMounted(() => void load())
|
||||
</script>
|
||||
|
||||
<template><PrototypeFeatureView title="入库订单列表" description="查询海外仓入库订单" /></template>
|
||||
<template>
|
||||
<section v-loading="loading" class="inbound-orders" :aria-busy="loading">
|
||||
<header class="page-head">
|
||||
<div><h2>{{ detail ? '入库订单详情' : '入库订单列表' }}</h2><p>{{ detail ? `计划号:${detail.planNo}` : '查询当前企业在海外仓的入库计划与作业进度' }}</p></div>
|
||||
<el-button v-if="detail" @click="detail = null">返回列表</el-button>
|
||||
</header>
|
||||
|
||||
<template v-if="!detail">
|
||||
<el-alert v-if="error" :title="error" type="error" show-icon :closable="false"><template #default><el-button size="small" @click="load">重新加载</el-button></template></el-alert>
|
||||
<el-card shadow="never" class="filter-card">
|
||||
<el-form inline @submit.prevent="query.page = 1; load()">
|
||||
<el-form-item label="计划号"><el-input v-model.trim="query.planNo" clearable placeholder="请输入计划号" /></el-form-item>
|
||||
<el-form-item label="入仓码"><el-input v-model.trim="query.inboundCode" clearable placeholder="请输入入仓码" /></el-form-item>
|
||||
<el-form-item label="目的仓"><el-input v-model.trim="query.destinationWarehouseCode" clearable placeholder="请输入仓库代码" /></el-form-item>
|
||||
<el-form-item label="作业状态"><el-input v-model.trim="query.status" clearable placeholder="请输入状态" /></el-form-item>
|
||||
<el-form-item label="预约状态"><el-select v-model="query.appointmentStatus" clearable placeholder="全部"><el-option label="未预约" value="UNAPPOINTED" /><el-option label="待处理" value="PENDING" /><el-option label="成功" value="SUCCESS" /><el-option label="失败" value="FAILED" /><el-option label="已取消" value="CANCELLED" /></el-select></el-form-item>
|
||||
<el-form-item label="排车状态"><el-select v-model="query.dispatchStatus" clearable placeholder="全部"><el-option label="未排车" value="UNSCHEDULED" /><el-option label="已排车" value="SCHEDULED" /></el-select></el-form-item>
|
||||
<el-form-item label="创建时间"><el-date-picker v-model="query.createdDates" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="开始日期" end-placeholder="结束日期" /></el-form-item>
|
||||
<el-form-item><el-button type="primary" native-type="submit">查询</el-button><el-button @click="resetQuery">重置</el-button></el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
<el-card shadow="never">
|
||||
<el-table :data="rows" border empty-text="暂无入库订单">
|
||||
<el-table-column label="操作" width="90" fixed="left"><template #default="scope"><el-button link type="primary" @click="openDetail(scope.row)">查看</el-button></template></el-table-column>
|
||||
<el-table-column prop="planNo" label="计划号" min-width="180" show-overflow-tooltip />
|
||||
<el-table-column prop="inboundCode" label="入仓码" min-width="170" show-overflow-tooltip />
|
||||
<el-table-column label="海外仓" min-width="160" show-overflow-tooltip><template #default="scope">{{ text(scope.row.warehouseName || scope.row.warehouseCode) }}</template></el-table-column>
|
||||
<el-table-column label="目的仓" min-width="150" show-overflow-tooltip><template #default="scope">{{ text(scope.row.fbaWarehouseCode) }}</template></el-table-column>
|
||||
<el-table-column prop="packageCount" label="计划箱数" width="100" />
|
||||
<el-table-column prop="pickedPackageCount" label="已拣箱数" width="100" />
|
||||
<el-table-column prop="palletCount" label="托盘数" width="90" />
|
||||
<el-table-column label="状态" width="120"><template #default="scope">{{ statusLabel(scope.row.status) }}</template></el-table-column>
|
||||
<el-table-column label="预约状态" width="120"><template #default="scope">{{ statusLabel(scope.row.appointmentStatus) }}</template></el-table-column>
|
||||
<el-table-column label="排车状态" width="110"><template #default="scope">{{ statusLabel(scope.row.dispatchStatus) }}</template></el-table-column>
|
||||
<el-table-column label="创建时间" width="180"><template #default="scope">{{ dateTime(scope.row.createdAt) }}</template></el-table-column>
|
||||
</el-table>
|
||||
<el-pagination v-model:current-page="query.page" v-model:page-size="query.size" class="pagination" :total="total" :page-sizes="[10, 20, 50, 100]" layout="total, sizes, prev, pager, next" @change="load" />
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<el-card shadow="never">
|
||||
<dl class="detail-grid">
|
||||
<div><dt>计划号</dt><dd>{{ detail.planNo }}</dd></div><div><dt>集装箱号</dt><dd>{{ text(detail.containerNo) }}</dd></div>
|
||||
<div><dt>状态</dt><dd>{{ statusLabel(detail.status) }}</dd></div><div><dt>入仓码数</dt><dd>{{ detail.lineCount }}</dd></div>
|
||||
<div><dt>托盘数</dt><dd>{{ detail.palletCount }}</dd></div><div><dt>总体积(m³)</dt><dd>{{ detail.totalVolumeCbm }}</dd></div>
|
||||
<div><dt>计划箱数</dt><dd>{{ detail.expectedPackageCount }}</dd></div><div><dt>已拣箱数</dt><dd>{{ detail.pickedPackageCount }}</dd></div>
|
||||
</dl>
|
||||
</el-card>
|
||||
<el-card shadow="never" header="入仓明细">
|
||||
<el-table :data="detail.lines" border empty-text="暂无入仓明细">
|
||||
<el-table-column prop="inboundCode" label="入仓码" min-width="180" /><el-table-column prop="destinationWarehouseCode" label="目的仓代码" min-width="140" />
|
||||
<el-table-column prop="destinationWarehouseName" label="目的仓名称" min-width="160" /><el-table-column prop="requiredPackageCount" label="计划箱数" width="100" />
|
||||
<el-table-column prop="plannedPalletCount" label="计划托数" width="100" /><el-table-column prop="volumeCbm" label="体积(m³)" width="110" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
<el-card shadow="never" header="托盘明细">
|
||||
<el-table :data="detail.pallets" border empty-text="暂无托盘">
|
||||
<el-table-column prop="palletNo" label="托盘码" min-width="180" /><el-table-column prop="destinationWarehouseCode" label="目的仓" min-width="150" />
|
||||
<el-table-column prop="capacityCbm" label="容量(m³)" width="120" /><el-table-column prop="allocatedVolumeCbm" label="已分配体积(m³)" width="140" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inbound-orders { display: grid; gap: 18px; }
|
||||
.page-head { display: flex; align-items: center; justify-content: space-between; gap: 24px; }
|
||||
.page-head h2 { margin: 0; color: #111827; font-size: 26px; }
|
||||
.page-head p { margin: 7px 0 0; color: #64748b; font-size: 14px; }
|
||||
.filter-card :deep(.el-form-item) { margin-bottom: 12px; }
|
||||
.filter-card :deep(.el-input), .filter-card :deep(.el-select) { width: 190px; }
|
||||
.pagination { justify-content: flex-end; margin-top: 18px; }
|
||||
.detail-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); margin: 0; }
|
||||
.detail-grid div { padding: 18px 20px; border-right: 1px solid #ebeef5; border-bottom: 1px solid #ebeef5; }
|
||||
.detail-grid dt { margin-bottom: 7px; color: #8492a6; font-size: 13px; }
|
||||
.detail-grid dd { margin: 0; overflow-wrap: anywhere; color: #1f2937; }
|
||||
</style>
|
||||
|
||||
@@ -91,6 +91,14 @@ test('shares the merchant permission map and preserves resources not rendered by
|
||||
assert.match(contractsSource, /export const FRONTEND_TO_MERCHANT_PERMISSION_CODES/)
|
||||
assert.match(contractsSource, /'order:first-leg:cancel': \['merchant:order:cancel'\]/)
|
||||
assert.match(contractsSource, /'company:member:create': \['merchant:member:create'\]/)
|
||||
for (const permission of [
|
||||
'merchant:warehouse:inbound:list',
|
||||
'merchant:cross-border:procurement',
|
||||
'merchant:cross-border:domestic-trucking',
|
||||
'merchant:cross-border:pallet-shipping',
|
||||
]) {
|
||||
assert.match(contractsSource, new RegExp(`'${permission}': \\['${permission}'\\]`))
|
||||
}
|
||||
assert.match(contractsSource, /'company:member:edit': \['merchant:member:edit'\]/)
|
||||
assert.match(contractsSource, /'company:member:grant-role': \['merchant:member:grant-role'\]/)
|
||||
assert.match(contractsSource, /'company:member:status': \['merchant:member:status'\]/)
|
||||
|
||||
@@ -38,6 +38,8 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
|
||||
key: 'finance',
|
||||
label: '财务账单',
|
||||
items: [
|
||||
{ code: 'merchant:finance:account:view', label: '查看资金账户', description: '查看本企业实际、冻结和可用余额及资金流水' },
|
||||
{ code: 'merchant:finance:recharge:create', label: '提交充值申请', description: '上传付款截图并提交本企业充值申请' },
|
||||
{ code: 'finance:bill:view', label: '查看客户账单', description: '查看本企业账单、收款记录和未收余额' },
|
||||
{ code: 'finance:bill:confirm', label: '确认客户账单', description: '确认本企业已出账且待客户确认的对账单' },
|
||||
],
|
||||
@@ -93,6 +95,8 @@ export const TENANT_PERMISSION_GROUPS: readonly TenantPermissionGroup[] = [
|
||||
]
|
||||
|
||||
export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, readonly string[]>> = {
|
||||
'merchant:finance:account:view': ['merchant:finance:account:view'],
|
||||
'merchant:finance:recharge:create': ['merchant:finance:recharge:create'],
|
||||
'order:first-leg:list': ['merchant:order:list', 'merchant:order:view'],
|
||||
'order:first-leg:create': ['merchant:order:create'],
|
||||
'order:first-leg:quick-create': ['merchant:order:quick-create', 'merchant:order:create'],
|
||||
@@ -101,6 +105,10 @@ export const FRONTEND_TO_MERCHANT_PERMISSION_CODES: Readonly<Record<string, read
|
||||
'order:first-leg:cancel': ['merchant:order:cancel'],
|
||||
'merchant:warehouse:delivery:list': ['merchant:warehouse:delivery:list'],
|
||||
'merchant:warehouse:delivery:create': ['merchant:warehouse:delivery:create'],
|
||||
'merchant:warehouse:inbound:list': ['merchant:warehouse:inbound:list'],
|
||||
'merchant:cross-border:procurement': ['merchant:cross-border:procurement'],
|
||||
'merchant:cross-border:domestic-trucking': ['merchant:cross-border:domestic-trucking'],
|
||||
'merchant:cross-border:pallet-shipping': ['merchant:cross-border:pallet-shipping'],
|
||||
'company:profile:view': ['merchant:profile:view'],
|
||||
'company:profile:edit': ['merchant:profile:edit'],
|
||||
'company:qualification:upload': ['merchant:qualification:upload'],
|
||||
|
||||
@@ -83,7 +83,6 @@ 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 standaloneLegalPersonIdUnsupported = computed(() => legalPersonIdFileSelected.value && !businessLicenseFileSelected.value)
|
||||
const requiredPackageChangeSelected = computed(() => businessLicenseFileSelected.value)
|
||||
const requiredPackageFilesSelected = computed(() => businessLicenseFileSelected.value)
|
||||
const qualificationCreditCodeReady = computed(() => requiredPackageChangeSelected.value
|
||||
@@ -122,11 +121,9 @@ 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 ''
|
||||
@@ -340,20 +337,15 @@ 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.value !== 'LEGAL_PERSON_ID' && item.file)
|
||||
const optionalItems = qualificationDrafts.filter((item) => !item.required
|
||||
&& item.file
|
||||
&& (item.value !== 'LEGAL_PERSON_ID' || !requiredPackageChangeSelected.value))
|
||||
const includedLegalPersonId = legalPersonIdFileSelected.value
|
||||
qualificationSubmitting.value = true
|
||||
let uploadedCount = 0
|
||||
@@ -430,8 +422,10 @@ async function submitQualification() {
|
||||
}
|
||||
qualificationOpen.value = false
|
||||
if (refreshed) ElMessage.success(includedLegalPersonId
|
||||
? '营业执照、统一社会信用代码和法人身份证已保存,尚未提交审核'
|
||||
: '营业执照和统一社会信用代码已保存,尚未提交审核')
|
||||
? (requiredPackageChangeSelected.value
|
||||
? '营业执照、统一社会信用代码和法人身份证已保存,尚未提交审核'
|
||||
: '法人身份证已保存,尚未提交审核')
|
||||
: '资质文件已保存,尚未提交审核')
|
||||
else ElMessage.warning('资质文件已保存,但最新状态刷新失败,请点击“重新加载”确认状态')
|
||||
} finally {
|
||||
qualificationSubmitting.value = false
|
||||
|
||||
@@ -73,12 +73,14 @@ test('implements company details, contact information and qualification workflow
|
||||
assert.match(profileSource, /requiredQualificationTypes\.every/)
|
||||
assert.doesNotMatch(profileSource, /四类必填资质/)
|
||||
assert.doesNotMatch(profileSource, /法人身份证需与本次营业执照一起保存/)
|
||||
assert.match(profileSource, /const standaloneLegalPersonIdUnsupported = computed/)
|
||||
assert.match(profileSource, /当前文件服务尚未开放法人身份证单独上传或替换,该选填文件未提交/)
|
||||
assert.doesNotMatch(profileSource, /standaloneLegalPersonIdUnsupported/)
|
||||
assert.doesNotMatch(profileSource, /当前文件服务尚未开放法人身份证单独上传或替换/)
|
||||
assert.match(profileSource, /item\.value !== 'LEGAL_PERSON_ID' \|\| !requiredPackageChangeSelected\.value/)
|
||||
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/)
|
||||
|
||||
@@ -53,17 +53,21 @@ test('keeps only implemented navigation groups visible', () => {
|
||||
title: '头程物流',
|
||||
children: ['订单列表', '快速下单'],
|
||||
},
|
||||
{
|
||||
title: '出海下单',
|
||||
children: ['海外采购', '美国本土卡', '美本土散板'],
|
||||
},
|
||||
{
|
||||
title: '海外仓',
|
||||
children: ['贴标申请', '仓储转运', '货物废弃', '退货换标'],
|
||||
},
|
||||
{
|
||||
title: '订单管理',
|
||||
children: ['派送订单列表'],
|
||||
children: ['入库订单列表', '派送订单列表'],
|
||||
},
|
||||
{
|
||||
title: '财务结算',
|
||||
children: ['客户账单'],
|
||||
children: ['资金账户', '客户账单'],
|
||||
},
|
||||
{
|
||||
title: '企业管理',
|
||||
@@ -95,11 +99,16 @@ test('flattens visible leaves with unique paths', () => {
|
||||
'/',
|
||||
'/orders/first-leg',
|
||||
'/first-leg/quick-order',
|
||||
'/cross-border/procurement',
|
||||
'/cross-border/domestic-trucking',
|
||||
'/cross-border/pallet-shipping',
|
||||
'/overseas-warehouse/labeling',
|
||||
'/overseas-warehouse/storage-transfer',
|
||||
'/overseas-warehouse/cargo-disposal',
|
||||
'/overseas-warehouse/return-relabel',
|
||||
'/orders/inbound',
|
||||
'/orders/delivery',
|
||||
'/finance/account',
|
||||
'/finance/bills',
|
||||
'/company/profile',
|
||||
'/organization/members',
|
||||
@@ -317,7 +326,7 @@ test('keeps the dashboard route permission synchronized with navigation', () =>
|
||||
assert.match(dashboardRoute, /permission: 'dashboard:view'/)
|
||||
})
|
||||
|
||||
test('keeps prototype views cataloged while allowing implemented order and overseas warehouse pages', () => {
|
||||
test('keeps only the unified my-orders placeholder cataloged while activating implemented business pages', () => {
|
||||
const routerSource = [
|
||||
'first-leg',
|
||||
'cross-border',
|
||||
@@ -339,7 +348,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/') || path === 'orders/delivery') {
|
||||
if (path === 'first-leg/quick-order' || path.startsWith('cross-border/') || path.startsWith('overseas-warehouse/') || path === 'orders/inbound' || 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
|
||||
|
||||
+2
-1
@@ -32,7 +32,8 @@ router.beforeEach(async (to) => {
|
||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||
return { name: 'login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
if ((to.name === 'login' || to.name === 'register') && auth.isAuthenticated) {
|
||||
const isImpersonationHandoff = to.name === 'login' && typeof to.query.impersonationOrigin === 'string'
|
||||
if ((to.name === 'login' || to.name === 'register') && auth.isAuthenticated && !isImpersonationHandoff) {
|
||||
return getFirstAccessiblePath(auth.permissionCodes)
|
||||
}
|
||||
const permission = typeof to.meta.permission === 'string' ? to.meta.permission : undefined
|
||||
|
||||
Reference in New Issue
Block a user