Merge remote-tracking branch 'origin/release/test' into dev
This commit is contained in:
@@ -11,6 +11,19 @@ server {
|
||||
return 200 "ok\n";
|
||||
}
|
||||
|
||||
# SPA 入口每次重新校验,避免发布后浏览器继续运行旧资源清单。
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
add_header Pragma "no-cache" always;
|
||||
add_header Expires "0" always;
|
||||
}
|
||||
|
||||
# Vite 产物文件名带内容哈希,可安全长期缓存。
|
||||
location /assets/ {
|
||||
try_files $uri =404;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
}
|
||||
|
||||
# 前端统一请求 /api,保持原始路径转发给 Gateway。
|
||||
location /api/ {
|
||||
client_max_body_size 55m;
|
||||
@@ -36,6 +49,7 @@ server {
|
||||
|
||||
# 支持 Vue Router history 路由直接刷新。
|
||||
location / {
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
|
||||
+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>
|
||||
|
||||
|
||||
@@ -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"/)
|
||||
|
||||
@@ -223,15 +223,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()
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
@@ -105,7 +107,12 @@ test('uses real auth endpoints and single-flight refresh handling', () => {
|
||||
|
||||
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, /代登录失败或授权已失效,请返回管理后台重新发起/)
|
||||
})
|
||||
|
||||
@@ -9,6 +9,9 @@ import type {
|
||||
|
||||
interface MerchantInboundBillingWireResponse {
|
||||
inbound: MerchantInboundResult
|
||||
measurementSummary: MerchantInboundBilling['measurementSummary']
|
||||
measurementItems: MerchantInboundBilling['measurementItems']
|
||||
billingBasis: MerchantInboundBilling['billingBasis']
|
||||
latestFee: MerchantInboundFeeSnapshot
|
||||
}
|
||||
|
||||
@@ -101,6 +104,35 @@ function sanitizeFee(fee: MerchantInboundFeeSnapshot): MerchantInboundFeeSnapsho
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeInboundBilling(response: MerchantInboundBillingWireResponse): MerchantInboundBilling {
|
||||
return {
|
||||
inbound: {
|
||||
@@ -108,6 +140,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,13 +14,15 @@ test('renders loading, pending, isolated error, and published states', () => {
|
||||
assert.match(source, /\$emit\('retry'\)/)
|
||||
})
|
||||
|
||||
test('shows the confirmed merchant inbound and fee fields without write actions', () => {
|
||||
test('shows the confirmed 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, /measurementItems/)
|
||||
assert.doesNotMatch(source, /调整费用|发送费用|下载照片/)
|
||||
assert.doesNotMatch(source, /internalNotes|statusHistory|files/)
|
||||
assert.match(source, /latestFee\.ruleCode/)
|
||||
assert.match(source, /latestFee\.rateCode/)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { Clock3, PackageCheck, RefreshCw, TriangleAlert } from '@lucide/vue'
|
||||
import type { MerchantInboundBilling } from '@/modules/first-leg/types/inboundBilling'
|
||||
import {
|
||||
@@ -17,24 +18,34 @@ const props = withDefaults(defineProps<{
|
||||
unavailable?: boolean
|
||||
unavailableMessage?: string
|
||||
error?: string
|
||||
autoOpen?: boolean
|
||||
}>(), {
|
||||
data: null,
|
||||
loading: false,
|
||||
unavailable: false,
|
||||
unavailableMessage: '入库及费用结果尚未发布',
|
||||
unavailableMessage: '还未测量推送',
|
||||
error: '',
|
||||
autoOpen: false,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
retry: []
|
||||
}>()
|
||||
|
||||
const feeDialogOpen = ref(false)
|
||||
watch(() => [props.autoOpen, props.data] as const, ([autoOpen, data]) => {
|
||||
if (autoOpen && data) feeDialogOpen.value = true
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section 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="success" effect="light">待分柜 · 费用已发布</el-tag>
|
||||
<el-button type="primary" @click="feeDialogOpen = true">确认费用</el-button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="props.loading" class="inbound-billing-loading" aria-live="polite" aria-busy="true">
|
||||
@@ -55,7 +66,7 @@ defineEmits<{
|
||||
<Clock3 :size="22" />
|
||||
<div>
|
||||
<strong>{{ props.unavailableMessage }}</strong>
|
||||
<span>客服完成测量审核、入库并发布费用后,将在这里展示最终结果。</span>
|
||||
<span>平台完成测量审核并确认推送费用后,将在这里展示测量与计费结果。</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +79,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 +88,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 +160,36 @@ defineEmits<{
|
||||
<p>{{ props.data.inbound.publicRemark }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-dialog v-model="feeDialogOpen" title="确认测量与计费数据" width="min(980px, 94vw)" append-to-body>
|
||||
<dl v-if="props.data" 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="boxSpecification" label="备注" min-width="130" />
|
||||
</el-table>
|
||||
<template #footer><el-button type="primary" @click="feeDialogOpen = false">确认</el-button></template>
|
||||
</el-dialog>
|
||||
</section>
|
||||
</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 +212,8 @@ 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; }
|
||||
.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); }
|
||||
|
||||
@@ -100,7 +100,45 @@ export interface MerchantInboundFeeSnapshot {
|
||||
nextProcessStatus: MerchantInboundProcessStatus | 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
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
measurementItems: MerchantMeasurementItem[]
|
||||
billingBasis: MerchantBillingBasis
|
||||
latestFee: MerchantInboundFeeSnapshot
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ test('exposes real list, draft, submit, and detail workflow entries without canc
|
||||
assert.match(source, /function openCreate\(\)/)
|
||||
assert.match(source, /async function saveDraft\(\)/)
|
||||
assert.match(source, /function submitOrder\(\)/)
|
||||
assert.match(source, /async function openDetail\(order: FirstLegOrder\)/)
|
||||
assert.match(source, /async function openDetail\(order: FirstLegOrder, showFeeDialog = false\)/)
|
||||
assert.match(source, /Promise\.allSettled\(\[/)
|
||||
assert.match(source, /const orderPageRequest = isCreatePage\.value\s*\? store\.loadWarehouseType\('DOMESTIC', true\)\s*:\s*store\.load\(\{ includeDrafts: true \}\)/)
|
||||
assert.match(source, /Promise\.allSettled\(\[\s*orderPageRequest,\s*companyRequest/)
|
||||
@@ -481,7 +481,7 @@ 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, /inboundBillingUnavailableMessage\.value = error\.message \|\| '还未测量推送'/)
|
||||
assert.match(source, /order\.status === 'DRAFT'/)
|
||||
assert.match(source, /订单提交后可查看入库结果/)
|
||||
assert.doesNotMatch(source, /confirmMerchantInbound|adjustMerchantInbound|sendMerchantInbound/)
|
||||
|
||||
@@ -198,7 +198,8 @@ 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 openFeeDialogWithDetail = ref(false)
|
||||
const inboundBillingError = ref('')
|
||||
const customsByOrder = reactive<Record<string, MerchantCustomsContainer | null | undefined>>({})
|
||||
const customsEligibilityPending = new Set<string>()
|
||||
@@ -1193,7 +1194,8 @@ async function openEdit(order: FirstLegOrder) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openDetail(order: FirstLegOrder) {
|
||||
async function openDetail(order: FirstLegOrder, showFeeDialog = false) {
|
||||
openFeeDialogWithDetail.value = showFeeDialog
|
||||
detailId.value = order.id
|
||||
detailCargoPage.value = 1
|
||||
detailOpen.value = true
|
||||
@@ -1212,6 +1214,10 @@ async function openDetail(order: FirstLegOrder) {
|
||||
}
|
||||
}
|
||||
|
||||
function openFeeConfirmation(order: FirstLegOrder) {
|
||||
void openDetail(order, true)
|
||||
}
|
||||
|
||||
async function retryDetail() {
|
||||
const order = detailId.value ? store.getById(detailId.value) : undefined
|
||||
if (order) await openDetail(order)
|
||||
@@ -1222,7 +1228,7 @@ function resetInboundBillingState() {
|
||||
inboundBilling.value = null
|
||||
inboundBillingLoading.value = false
|
||||
inboundBillingUnavailable.value = false
|
||||
inboundBillingUnavailableMessage.value = '入库及费用结果尚未发布'
|
||||
inboundBillingUnavailableMessage.value = '还未测量推送'
|
||||
inboundBillingError.value = ''
|
||||
}
|
||||
|
||||
@@ -1237,7 +1243,7 @@ async function loadInboundBilling(order: FirstLegOrder) {
|
||||
inboundBilling.value = null
|
||||
inboundBillingLoading.value = false
|
||||
inboundBillingUnavailable.value = false
|
||||
inboundBillingUnavailableMessage.value = '入库及费用结果尚未发布'
|
||||
inboundBillingUnavailableMessage.value = '还未测量推送'
|
||||
inboundBillingError.value = ''
|
||||
|
||||
if (order.status === 'DRAFT') {
|
||||
@@ -1255,7 +1261,7 @@ 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 = error.message || '还未测量推送'
|
||||
} else {
|
||||
inboundBillingError.value = error instanceof Error ? error.message : '入库与费用加载失败'
|
||||
}
|
||||
@@ -1770,6 +1776,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 v-if="(row as FirstLegOrder).status === 'WAIT_SPLIT'" 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>
|
||||
@@ -1835,6 +1842,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 v-if="order.status === 'WAIT_SPLIT'" 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>
|
||||
@@ -2137,6 +2145,7 @@ async function searchOrders(model: QueryModel) {
|
||||
<OrderProgressPanel :order="detailOrder" />
|
||||
<InboundBillingPanel
|
||||
:data="inboundBilling"
|
||||
:auto-open="openFeeDialogWithDetail"
|
||||
:loading="inboundBillingLoading"
|
||||
:unavailable="inboundBillingUnavailable"
|
||||
:unavailable-message="inboundBillingUnavailableMessage"
|
||||
|
||||
+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