合并 release/test 前端冲突

This commit is contained in:
2026-09-22 08:51:26 +08:00
22 changed files with 229 additions and 73 deletions
@@ -13,7 +13,11 @@ test('client production deployment switches healthy blue-green slots atomically'
const nginx = script.indexOf('nginx -t || ! nginx -s reload')
const stop = script.indexOf('docker stop --time 30 "$active_container"')
assert.ok(start >= 0 && start < health && health < nginx && nginx < stop)
assert.match(script, /target_port=\$\(\(base_port \+ 20000\)\)/)
assert.match(script, /target_port=\$\(\(base_port \+ 10000\)\)/)
assert.match(script, /00-blue-green-admin-web\.conf/)
assert.match(script, /00-blue-green-client-web\.conf/)
assert.doesNotMatch(script, /nginx_config=\/www\/server\/panel\/vhost\/nginx\/(?:admin|app)\.buggoglobal\.com\.conf/)
assert.match(script, /cp -f "\$backup" "\$nginx_config"/)
})
+21 -3
View File
@@ -131,8 +131,26 @@ test('keeps absent business numbers null and internal identifiers out of display
}
})
test('keeps all six quick actions visually neutral until hover or keyboard focus', () => {
assert.doesNotMatch(dashboardView, /featured|quick-action--featured/)
assert.match(dashboardView, /\.quick-action:hover \{[^}]*border-color:[^}]*transform: translateY\(-5px\);/)
test('matches the updated six-action icon and motion treatment', () => {
assert.match(dashboardView, /icon: Forklift/)
assert.doesNotMatch(dashboardView, /PanelsTopLeft/)
assert.match(dashboardView, /featured: true/)
assert.match(dashboardView, /quick-action--featured/)
assert.match(dashboardView, /featured-aura 5s ease-in-out infinite/)
assert.match(dashboardView, /featured-shine 3\.6s ease-in-out infinite/)
assert.match(dashboardView, /\.quick-action:hover \{[^}]*border-color:[^}]*transform: translateY\(-9px\);/)
assert.match(dashboardView, /prefers-reduced-motion: reduce/)
assert.match(dashboardView, /\.client-dashboard a:focus-visible/)
})
test('opens all six dashboard businesses through their dedicated create pages', () => {
for (const route of [
'/orders/first-leg/create',
'/overseas-warehouse/return-relabel/create',
'/cross-border/domestic-trucking/create',
'/cross-border/pallet-shipping/create',
'/cross-border/procurement/create',
'/overseas-warehouse/labeling/create',
]) assert.match(dashboardView, new RegExp(`route: '${route.replaceAll('/', '\\/')}'`))
assert.match(dashboardView, /title: '退货换标'[\s\S]*?permission: 'merchant:overseas-warehouse:return-order:create'/)
})
+31 -18
View File
@@ -1,18 +1,18 @@
<script setup lang="ts">
import { computed } from 'vue'
import { ArrowRight, PanelsTopLeft, RefreshCcw, Ship, ShoppingCart, Tags, Truck } from '@lucide/vue'
import { ArrowRight, Forklift, RefreshCcw, Ship, ShoppingCart, Tags, Truck } from '@lucide/vue'
import { useAuthStore } from '@/modules/auth/public'
import { FirstLegOrdersView } from '@/modules/first-leg/public'
const auth = useAuthStore()
const primaryActions = computed(() => [
{ title: '头程下单', description: '国内提货 · 海运订舱 · 清关派送', icon: Ship, route: '/orders/first-leg/create', permission: 'order:first-leg:create', tone: 'blue' },
{ title: '退货换标', description: '退件接收 · 标签更换 · 重新入仓', icon: RefreshCcw, route: '/overseas-warehouse/return-relabel/create', permission: 'merchant:overseas-warehouse:return-order:create', tone: 'purple' },
{ title: '美国本土卡', description: '港口提柜 · 仓到仓 · 仓到门', icon: Truck, route: '/cross-border/domestic-trucking/create', permission: 'merchant:cross-border:domestic-trucking:create', tone: 'green' },
{ title: '美本土散板', description: '拼车拼载 · 打盘服务 · 灵活派送', icon: PanelsTopLeft, route: '/cross-border/pallet-shipping/create', permission: 'merchant:cross-border:pallet-shipping:create', tone: 'cyan' },
{ title: '海外采购', description: '采购代购 · 验货质检 · 集中发货', icon: ShoppingCart, route: '/cross-border/procurement/create', permission: 'merchant:cross-border:procurement:create', tone: 'orange' },
{ title: '贴标申请', description: '产品建档 · 标签上传 · 批量贴标', icon: Tags, route: '/overseas-warehouse/labeling/create', permission: 'merchant:overseas-warehouse:relabel:create', tone: 'rose' },
{ title: '头程下单', description: '国内提货 · 海运订舱 · 清关派送', icon: Ship, route: '/orders/first-leg/create', permission: 'order:first-leg:create', tone: 'blue', featured: true },
{ title: '退货换标', description: '退件接收 · 标签更换 · 重新入仓', icon: RefreshCcw, route: '/overseas-warehouse/return-relabel/create', permission: 'merchant:overseas-warehouse:return-order:create', tone: 'purple', featured: false },
{ title: '美国本土卡', description: '港口提柜 · 仓到仓 · 仓到门', icon: Truck, route: '/cross-border/domestic-trucking/create', permission: 'merchant:cross-border:domestic-trucking:create', tone: 'green', featured: false },
{ title: '美本土散板', description: '拼车拼载 · 打盘服务 · 灵活派送', icon: Forklift, route: '/cross-border/pallet-shipping/create', permission: 'merchant:cross-border:pallet-shipping:create', tone: 'cyan', featured: false },
{ title: '海外采购', description: '采购代购 · 验货质检 · 集中发货', icon: ShoppingCart, route: '/cross-border/procurement/create', permission: 'merchant:cross-border:procurement:create', tone: 'orange', featured: false },
{ title: '贴标申请', description: '产品建档 · 标签上传 · 批量贴标', icon: Tags, route: '/overseas-warehouse/labeling/create', permission: 'merchant:overseas-warehouse:relabel:create', tone: 'rose', featured: false },
].filter((action) => auth.hasPermission(action.permission)))
</script>
@@ -25,10 +25,10 @@ const primaryActions = computed(() => [
</header>
<section class="quick-actions" aria-label="业务选择">
<RouterLink v-for="(action, index) in primaryActions" :key="action.route" :to="action.route" class="quick-action" :class="`quick-action--${action.tone}`" :style="{ '--entry-index': index }">
<span class="action-icon"><component :is="action.icon" :size="23" :stroke-width="2.1" aria-hidden="true" /></span>
<RouterLink v-for="(action, index) in primaryActions" :key="action.route" :to="action.route" class="quick-action" :class="[`quick-action--${action.tone}`, { 'quick-action--featured': action.featured }]" :style="{ '--entry-index': index }">
<span class="action-icon"><component :is="action.icon" :size="25" :stroke-width="2.2" aria-hidden="true" /></span>
<span class="action-copy"><strong>{{ action.title }}</strong><small>{{ action.description }}</small></span>
<span class="action-enter">开始下单<ArrowRight :size="15" aria-hidden="true" /></span>
<span class="action-enter"><span>开始下单</span><ArrowRight :size="15" aria-hidden="true" /></span>
</RouterLink>
<p v-if="!primaryActions.length" class="no-actions">当前账号暂无常用业务权限,请通过已授权菜单办理业务,或联系企业管理员。</p>
</section>
@@ -51,7 +51,7 @@ const primaryActions = computed(() => [
.quick-actions::-webkit-scrollbar-thumb { border-radius: 999px; background: #bdc9dc; }
.quick-actions::-webkit-scrollbar-track { background: transparent; }
.quick-action { scroll-snap-align: start; }
.quick-action { --action-a: #2563eb; --action-b: #3730a3; --action-soft: rgba(37, 99, 235, .18); position: relative; display: flex; min-width: 0; min-height: 154px; overflow: hidden; align-items: center; flex-direction: column; padding: 15px 9px 13px; border: 1px solid rgba(148, 163, 184, .22); border-radius: 14px; background: linear-gradient(155deg, #fff 8%, rgba(248, 250, 252, .96) 100%); color: inherit; text-align: center; text-decoration: none; box-shadow: 0 8px 22px rgba(15, 23, 42, .055); animation: action-enter .4s cubic-bezier(.22, 1, .36, 1) both; animation-delay: calc(var(--entry-index) * 55ms); transition: border-color .35s ease, box-shadow .35s ease, transform .35s cubic-bezier(.22, 1, .36, 1); }
.quick-action { --action-a: #2563eb; --action-b: #3730a3; --action-soft: rgba(37, 99, 235, .18); position: relative; isolation: isolate; display: flex; min-width: 0; min-height: 190px; overflow: hidden; align-items: center; flex-direction: column; padding: 21px 10px 18px; border: 1px solid rgba(148, 163, 184, .2); border-radius: 17px; background: linear-gradient(155deg, #fff 8%, rgba(248, 250, 252, .96) 100%); color: inherit; text-align: center; text-decoration: none; box-shadow: 0 12px 34px rgba(15, 23, 42, .07); backdrop-filter: blur(10px); animation: action-enter .4s cubic-bezier(.22, 1, .36, 1) both; animation-delay: calc(var(--entry-index) * 55ms); transition: border-color .35s ease, box-shadow .35s ease, transform .35s cubic-bezier(.22, 1, .36, 1); }
.quick-action::before { position: absolute; width: 120px; height: 120px; border-radius: 50%; background: radial-gradient(circle, var(--action-soft), transparent 70%); content: ''; right: -62px; top: -66px; transition: transform .4s ease; }
.quick-action::after { position: absolute; right: 20%; bottom: 0; left: 20%; height: 3px; border-radius: 99px 99px 0 0; background: linear-gradient(90deg, var(--action-a), var(--action-b)); content: ''; opacity: .55; transform: scaleX(.35); transition: all .35s ease; }
.quick-action--blue { --action-a: #2563eb; --action-b: #3730a3; --action-soft: rgba(37, 99, 235, .18); }
@@ -60,18 +60,29 @@ const primaryActions = computed(() => [
.quick-action--cyan { --action-a: #0891b2; --action-b: #0e7490; --action-soft: rgba(8, 145, 178, .18); }
.quick-action--orange { --action-a: #f59e0b; --action-b: #dc6b12; --action-soft: rgba(245, 158, 11, .2); }
.quick-action--rose { --action-a: #e11d48; --action-b: #be185d; --action-soft: rgba(225, 29, 72, .17); }
.quick-action:hover { border-color: color-mix(in srgb, var(--action-a) 35%, transparent); box-shadow: 0 16px 34px rgba(15, 23, 42, .12); transform: translateY(-5px); }
.quick-action:hover { border-color: color-mix(in srgb, var(--action-a) 35%, transparent); box-shadow: 0 24px 54px rgba(15, 23, 42, .14), 0 0 0 1px color-mix(in srgb, var(--action-a) 16%, transparent); transform: translateY(-9px); }
.quick-action:hover::before { transform: scale(1.25); }
.quick-action:hover::after { right: 12%; left: 12%; opacity: 1; transform: scaleX(1); }
.action-icon { position: relative; display: grid; width: 48px; height: 48px; flex: 0 0 auto; place-items: center; margin-bottom: 11px; border-radius: 14px; background: linear-gradient(135deg, var(--action-a), var(--action-b)); color: #fff; box-shadow: 0 8px 17px color-mix(in srgb, var(--action-a) 24%, transparent); transition: transform .35s ease; }
.action-icon::after { position: absolute; border: 1px solid color-mix(in srgb, var(--action-a) 20%, transparent); border-radius: 18px; content: ''; inset: -4px; }
.action-icon { position: relative; display: grid; width: 58px; height: 58px; flex: 0 0 auto; place-items: center; margin-bottom: 16px; border-radius: 18px; background: linear-gradient(135deg, var(--action-a), var(--action-b)); color: #fff; box-shadow: 0 11px 22px color-mix(in srgb, var(--action-a) 25%, transparent); transition: transform .35s ease; }
.action-icon::before { position: absolute; inset: 7px; border-radius: 12px; background: linear-gradient(135deg, rgba(255, 255, 255, .32), transparent 42%); content: ''; pointer-events: none; }
.action-icon::after { position: absolute; border: 1px solid color-mix(in srgb, var(--action-a) 20%, transparent); border-radius: 22px; content: ''; inset: -5px; }
.quick-action:hover .action-icon { transform: translateY(-3px) scale(1.06) rotate(-3deg); }
.action-copy { position: relative; display: flex; min-width: 0; width: 100%; align-items: center; flex-direction: column; }
.action-copy strong { color: #172033; font-size: 15px; font-weight: 750; line-height: 1.35; white-space: nowrap; }
.action-copy small { overflow: hidden; width: 100%; margin-top: 4px; color: #718096; font-size: 10px; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
.action-enter { position: relative; display: inline-flex; align-items: center; gap: 5px; margin-top: auto; padding-top: 7px; color: var(--action-a); font-size: 10px; font-weight: 700; }
.action-copy strong { color: #172033; font-size: 16px; font-weight: 800; line-height: 1.35; white-space: nowrap; }
.action-copy small { overflow: hidden; width: 100%; margin-top: 6px; color: #718096; font-size: 11px; line-height: 1.5; text-overflow: ellipsis; white-space: nowrap; }
.action-enter { position: relative; display: inline-flex; align-items: center; gap: 6px; margin-top: auto; color: var(--action-a); font-size: 11px; font-weight: 750; }
.action-enter svg { transition: transform .25s ease; }
.quick-action:hover .action-enter svg { transform: translateX(4px); }
.quick-action--featured { border-color: rgba(49, 126, 255, .62); background: radial-gradient(circle at 8% 2%, rgba(34, 211, 238, .23), transparent 32%), radial-gradient(circle at 100% 100%, rgba(79, 70, 229, .22), transparent 44%), linear-gradient(145deg, #f2fbff 0%, #f8faff 45%, #eef2ff 100%); box-shadow: 0 20px 48px rgba(37, 99, 235, .2), 0 0 0 1px rgba(255, 255, 255, .88) inset, 0 0 34px rgba(34, 211, 238, .1); }
.quick-action--featured::before { width: 220px; height: 220px; right: -88px; top: -112px; background: radial-gradient(circle, rgba(34, 211, 238, .35) 0%, rgba(59, 130, 246, .2) 34%, transparent 69%); filter: blur(1px); animation: featured-aura 5s ease-in-out infinite; }
.quick-action--featured::after { right: 8%; left: 8%; height: 4px; background: linear-gradient(90deg, #22d3ee 0%, #2563eb 48%, #4f46e5 100%); box-shadow: 0 -4px 16px rgba(37, 99, 235, .44), 0 -1px 6px rgba(34, 211, 238, .55); opacity: 1; transform: scaleX(1); }
.quick-action--featured:hover { border-color: rgba(34, 211, 238, .8); box-shadow: 0 30px 70px rgba(37, 99, 235, .27), 0 0 0 1px rgba(255, 255, 255, .95) inset, 0 0 40px rgba(34, 211, 238, .15); transform: translateY(-11px) scale(1.015); }
.quick-action--featured .action-icon { width: 64px; height: 64px; margin-bottom: 17px; border-radius: 20px; background: linear-gradient(145deg, #22c7df 0%, #2875ef 48%, #4338ca 100%); box-shadow: 0 16px 32px rgba(37, 99, 235, .37), 0 0 0 7px rgba(59, 130, 246, .09), 0 0 26px rgba(34, 211, 238, .24); }
.quick-action--featured .action-icon::after { inset: -6px; border-color: rgba(59, 130, 246, .25); border-radius: 25px; box-shadow: 0 0 0 1px rgba(34, 211, 238, .09); }
.quick-action--featured .action-copy strong { color: #15233e; font-size: 17px; }
.quick-action--featured .action-enter { overflow: hidden; padding: 8px 15px; border-radius: 999px; background: linear-gradient(100deg, #12b8d0 0%, #2563eb 52%, #4338ca 100%); box-shadow: 0 10px 24px rgba(37, 99, 235, .3), 0 0 18px rgba(34, 211, 238, .12); color: #fff; }
.quick-action--featured .action-enter::before { position: absolute; top: -60%; bottom: -60%; left: -55%; width: 38%; transform: rotate(18deg); background: linear-gradient(90deg, transparent, rgba(255, 255, 255, .55), transparent); content: ''; animation: featured-shine 3.6s ease-in-out infinite; }
.quick-action--featured .action-enter > * { position: relative; z-index: 1; }
.no-actions { margin: 0; padding: 20px; border: 1px solid var(--border); border-radius: 10px; background: #fff; color: #667085; font-size: 14px; }
.dashboard-order-list { min-width: 0; }
.orders-permission-empty { display: flex; min-height: 180px; align-items: center; justify-content: center; flex-direction: column; gap: 8px; border: 1px solid var(--border); border-radius: 10px; background: #fff; color: #7b8494; }
@@ -80,8 +91,10 @@ const primaryActions = computed(() => [
.client-dashboard a:focus-visible { border-radius: 6px; outline: 2px solid var(--primary); outline-offset: 4px; }
@keyframes dashboard-enter { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
@keyframes action-enter { from { opacity: 0; transform: translateY(7px); } to { opacity: 1; transform: translateY(0); } }
@keyframes featured-aura { 0%, 100% { opacity: .86; transform: translate3d(0, 0, 0) scale(1); } 50% { opacity: 1; transform: translate3d(-10px, 9px, 0) scale(1.08); } }
@keyframes featured-shine { 0%, 55% { left: -55%; opacity: 0; } 65% { opacity: 1; } 88%, 100% { left: 125%; opacity: 0; } }
@container dashboard-content (max-width: 1180px) { .quick-actions { grid-template-columns: repeat(6, minmax(178px, 1fr)); } }
@media (max-width: 720px) { .client-dashboard { padding: 0; gap: 16px; } .business-intro h1 { font-size: 25px; } .quick-actions { grid-template-columns: repeat(2, minmax(150px, 1fr)); } }
@media (max-width: 420px) { .quick-actions { grid-template-columns: repeat(6, minmax(168px, 1fr)); } }
@media (prefers-reduced-motion: reduce) { .client-dashboard, .quick-action { animation: none; } .quick-action, .quick-action::before, .quick-action::after, .action-icon, .action-enter svg { transition: none; } }
@media (prefers-reduced-motion: reduce) { .client-dashboard, .quick-action, .quick-action--featured::before, .quick-action--featured .action-enter::before { animation: none; } .quick-action, .quick-action::before, .quick-action::after, .action-icon, .action-enter svg { transition: none; } }
</style>
+2
View File
@@ -296,6 +296,8 @@ export interface MerchantFirstLegOrderRow {
inboundedAt: string | null
businessDate: string | null
shipmentId: string | null
fulfillmentType?: 'STANDARD' | 'ONE_CLICK_DROPSHIP' | null
lxCode?: string | null
createdAt: string
cargoes?: Array<{ skuNo?: string | null }>
}
@@ -43,9 +43,9 @@ const emit = defineEmits<{
<style scoped>
.first-leg-editor-page { min-width: 0; padding-bottom: 72px; border: 1px solid #edf0f5; background: #fff; box-shadow: 0 5px 18px rgba(15, 31, 58, .04); }
.editor-page-nav { display: flex; min-height: 52px; align-items: center; justify-content: space-between; gap: 16px; padding: 8px 24px; border-bottom: 1px solid #edf0f5; }
.editor-page-nav { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 5px 16px; border-bottom: 1px solid #edf0f5; }
.editor-page-nav span { color: var(--muted); font-size: 12px; }
.editor-page-body { padding: 26px 32px 34px; }
.editor-page-body { padding: 16px 20px 24px; }
.editor-page-footer { position: fixed; z-index: 18; right: 0; bottom: 0; left: 224px; display: flex; min-height: 68px; align-items: center; justify-content: center; padding: 10px 24px; border-top: 1px solid #dfe6ee; background: rgba(255, 255, 255, 0.97); box-shadow: 0 -6px 18px rgba(15, 31, 58, .08); backdrop-filter: blur(10px); }
.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; }
@@ -42,7 +42,8 @@ test('shows the published merchant inbound, measurement details and customer con
assert.match(source, /latestFee\.rateCode/)
assert.match(source, /latestFee\.chargeRuleVersion/)
assert.match(source, /latestFee\.carrierRateVersion/)
assert.doesNotMatch(source, /箱规规则|calculationRuleVersion/)
assert.match(source, /箱规规则/)
assert.match(source, /getCalculationRuleLabel\(row\.calculationRuleVersion\)/)
})
test('stacks core information for 720 and 360 pixel layouts', () => {
@@ -10,6 +10,7 @@ import {
formatInboundMeasure,
formatInboundMoney,
formatInboundNumber,
getCalculationRuleLabel,
getChargeModeLabel,
getTaxModeLabel,
} from '@/modules/first-leg/utils/inboundBilling'
@@ -139,6 +140,7 @@ async function confirmFee() {
<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 label="箱规规则" min-width="170"><template #default="{ row }">{{ getCalculationRuleLabel(row.calculationRuleVersion) }}</template></el-table-column>
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
</el-table>
</div>
@@ -241,6 +243,7 @@ async function confirmFee() {
<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 label="箱规规则" min-width="170"><template #default="{ row }">{{ getCalculationRuleLabel(row.calculationRuleVersion) }}</template></el-table-column>
<el-table-column prop="boxSpecification" label="备注" min-width="130" />
</el-table>
<el-alert
@@ -12,7 +12,7 @@ test('maps the first-leg aggregate list fields without loading detail rows', ()
assert.match(apiSource, /cargoes\?: Array<\{ skuNo\?: string \| null \}>/)
assert.match(apiSource, /apiRequest<PageResult<MerchantFirstLegOrderRow>>/)
assert.match(storeSource, /function mapFirstLegOrderRow/)
for (const field of ['inboundId', 'inboundCode', 'channelName', 'destinationWarehouseName', 'pickupMethod', 'packageCount', 'expressCompany', 'trackingNo', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'remark', 'shipmentId', 'referenceId', 'createdAt']) {
for (const field of ['inboundId', 'inboundCode', 'channelName', 'destinationWarehouseName', 'pickupMethod', 'packageCount', 'expressCompany', 'trackingNo', 'totalGrossWeightKg', 'totalNetWeightKg', 'totalVolumeCbm', 'totalQuantity', 'remark', 'shipmentId', 'fulfillmentType', 'lxCode', 'referenceId', 'createdAt']) {
assert.match(storeSource, new RegExp(`row\\.${field}`))
}
assert.match(storeSource, /row\.skuNos/)
@@ -102,7 +102,9 @@ test('keeps unified order pagination valid when the store omits page and size',
test('keeps drafts on the legacy read path without assigning a unified main status', () => {
assert.match(storeSource, /getMerchantFirstLegOrders\(legacyQuery\(query, 'DRAFT'\)\)/)
assert.match(storeSource, /getById\(id\)\?\.status === 'DRAFT' \? loadLegacyDetail\(id\) : loadUnifiedDetail\(id\)/)
assert.match(storeSource, /const listOrder = getById\(id\)/)
assert.match(storeSource, /listOrder\?\.status === 'DRAFT' \? loadLegacyDetail\(id, listOrder\) : loadUnifiedDetail\(id\)/)
assert.match(storeSource, /detail\.order\.lxCode == null && listOrder\?\.lxCode/)
assert.match(storeSource, /return loadLegacyDetail\(String\(createdId\)\)/)
assert.match(storeSource, /draftLoadError/)
})
+16 -5
View File
@@ -359,7 +359,7 @@ function mapFirstLegOrderRow(row: MerchantFirstLegOrderRow): FirstLegOrder {
const uniqueSkuNos = [...new Set(resolvedSkuNos)]
return {
id: String(row.orderId), orderNo: row.orderNo, referenceNo: row.referenceId ?? '', transportMode: null,
businessType: 'EXPORT', origin: '', destination: row.destinationWarehouseName ?? '', receivingWarehouseId: '',
businessType: 'EXPORT', fulfillmentType: row.fulfillmentType ?? 'STANDARD', lxCode: row.lxCode ?? '', origin: '', destination: row.destinationWarehouseName ?? '', receivingWarehouseId: '',
receivingWarehouseName: '', receivingWarehouseSnapshot: null, expectedDepartureDate: '', etd: '', eta: '',
shipperPartyId: '', shipperPartySnapshot: null, shipper: '', consigneePartyId: '', consigneePartySnapshot: null,
consignee: row.receivingCompanyName ?? '', expressCompanyName: row.expressCompany ?? '', trackingNumber: row.trackingNo ?? '',
@@ -542,9 +542,19 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
return mapped
}
async function loadLegacyDetail(id: string) {
async function loadLegacyDetail(id: string, listOrder?: FirstLegOrder) {
const detail = await getMerchantFirstLegOrder(id)
return upsertOrder(mapOrder(detail.order, warehouses.value, detail))
const mapped = mapOrder(detail.order, warehouses.value, detail)
// Older detail responses may omit newly added dropship fields. Keep the
// values already returned by the list row so editing a draft does not
// clear the persisted LX code in the form.
if (detail.order.fulfillmentType == null && listOrder?.fulfillmentType) {
mapped.fulfillmentType = listOrder.fulfillmentType
}
if (detail.order.lxCode == null && listOrder?.lxCode) {
mapped.lxCode = listOrder.lxCode
}
return upsertOrder(mapped)
}
async function loadUnifiedDetail(id: string) {
@@ -558,7 +568,8 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
}
async function loadDetail(id: string) {
return getById(id)?.status === 'DRAFT' ? loadLegacyDetail(id) : loadUnifiedDetail(id)
const listOrder = getById(id)
return listOrder?.status === 'DRAFT' ? loadLegacyDetail(id, listOrder) : loadUnifiedDetail(id)
}
async function loadWarehouseDetail(id: string) {
@@ -616,7 +627,7 @@ export const useFirstLegOrdersStore = defineStore('client-first-leg-orders', ()
}
async function loadDraftDetail(id: string) {
return loadLegacyDetail(id)
return loadLegacyDetail(id, getById(id))
}
return { orders, warehouses, loading, loadedFromApi, loadError, warehouseLoadError, draftLoadError, activeCount, pendingCount, getById, load, loadDetail, loadDraftDetail, loadWarehouseDetail, loadWarehouseType, saveDraft, saveDraftBatch, submitSaved, submit }
@@ -68,9 +68,9 @@ export interface MerchantInboundFeeSnapshot {
chargeRuleId: string
ruleCode: string
chargeRuleVersion: number
carrierRateId: string
carrierRateId: string | null
rateCode: string
carrierRateVersion: number
carrierRateVersion: number | null
customerId: string
customerName: string
originWarehouseId: string
@@ -19,6 +19,13 @@ test('translates backend rounding modes into customer-facing calculation labels'
assert.match(source, /return value \? labels\[value\] \|\| value : '-'/)
})
test('translates carton calculation rules into customer-facing labels', () => {
assert.match(source, /export function getCalculationRuleLabel/)
assert.match(source, /ACTUAL_DIMENSION_V1/)
assert.match(source, /按实际箱规计算/)
assert.match(source, /CUSTOMER_DIMENSION_\(SMALL\|MEDIUM\|LARGE\|EXTRA_LARGE\)_V/)
})
test('renders conversion metadata as one complete label or a single empty marker', () => {
assert.match(source, /export function formatConversionPrecision/)
assert.match(source, /roundingScale === null \|\| roundingScale === undefined/)
@@ -46,6 +46,20 @@ export function getTaxModeLabel(value: string) {
return value || '-'
}
export function getCalculationRuleLabel(value: string | null | undefined) {
if (!value) return '-'
if (value === 'ACTUAL_DIMENSION_V1' || value === 'DIMENSION_ACTUAL_V1') return '按实际箱规计算'
const customerRule = /^CUSTOMER_DIMENSION_(SMALL|MEDIUM|LARGE|EXTRA_LARGE)_V(\d+)$/.exec(value)
if (customerRule) {
const sizeLabel: Record<string, string> = {
SMALL: '小号', MEDIUM: '中号', LARGE: '大号', EXTRA_LARGE: '超大号',
}
return `客户箱规规则(${sizeLabel[customerRule[1]]},V${customerRule[2]})`
}
const warehouseRule = /^WAREHOUSE_OUTER_OVERRIDE_(.+)$/.exec(value)
return warehouseRule ? `仓库外箱规覆盖(${warehouseRule[1]})` : value
}
export function getRoundingModeLabel(value: string | null | undefined) {
const labels: Record<string, string> = {
UP: '向上舍入',
@@ -24,15 +24,18 @@ function selectMode(mode: 'standard' | 'quick') {
<template>
<section class="first-leg-create-hub">
<h1 class="create-hub-title">头程下单</h1>
<nav v-if="canQuickOrder" class="order-mode-switch" aria-label="头程下单方式">
<button type="button" :class="{ active: activeMode === 'standard' }" :aria-current="activeMode === 'standard' ? 'page' : undefined" @click="selectMode('standard')"><FilePenLine :size="18" />普通下单</button>
<button type="button" :class="{ active: activeMode === 'quick' }" :aria-current="activeMode === 'quick' ? 'page' : undefined" @click="selectMode('quick')"><Zap :size="18" />快速下单</button>
</nav>
<div class="create-mode-toolbar">
<nav v-if="canQuickOrder" class="order-mode-switch" aria-label="头程下单方式">
<button type="button" :class="{ active: activeMode === 'standard' }" :aria-current="activeMode === 'standard' ? 'page' : undefined" @click="selectMode('standard')"><FilePenLine :size="16" />普通下单</button>
<button type="button" :class="{ active: activeMode === 'quick' }" :aria-current="activeMode === 'quick' ? 'page' : undefined" @click="selectMode('quick')"><Zap :size="16" />快速下单</button>
</nav>
<span v-if="canQuickOrder" class="create-mode-caption">选择下单方式</span>
</div>
<FirstLegOrdersView v-if="activeMode === 'standard'" create-page />
<QuickOrderView v-else />
</section>
</template>
<style scoped>
.first-leg-create-hub{display:grid;min-width:0;gap:12px}.create-hub-title{margin:0;color:var(--navy);font-size:24px;line-height:1.25}.order-mode-switch{display:inline-flex;width:max-content;padding:4px;border:1px solid #d9e2ec;border-radius:8px;background:#f4f7fa}.order-mode-switch button{display:inline-flex;height:38px;align-items:center;gap:8px;padding:0 20px;border:0;border-radius:6px;background:transparent;color:#607087;font:inherit;font-size:13px;font-weight:650;cursor:pointer}.order-mode-switch button.active{background:var(--primary);color:#fff;box-shadow:0 2px 6px rgba(23,107,135,.2)}.order-mode-switch button:focus-visible{outline:2px solid var(--primary);outline-offset:2px}
.first-leg-create-hub{display:grid;min-width:0;gap:10px}.create-hub-title{margin:0;color:var(--navy);font-size:22px;line-height:1.25}.create-mode-toolbar{display:flex;min-width:0;align-items:center;gap:12px}.create-mode-caption{color:#94a0af;font-size:12px;font-weight:500}.order-mode-switch{display:inline-flex;width:max-content;padding:4px;border:1px solid #d5e0e7;border-radius:8px;background:#edf3f6;box-shadow:inset 0 1px 2px rgba(15,23,42,.035)}.order-mode-switch button{display:inline-flex;height:36px;min-width:112px;align-items:center;justify-content:center;gap:7px;padding:0 16px;border:0;border-radius:6px;background:transparent;color:#607087;font:inherit;font-size:12px;font-weight:650;cursor:pointer}.order-mode-switch button.active{background:linear-gradient(105deg,#118fa1,#176b87);color:#fff;box-shadow:0 5px 13px rgba(23,107,135,.24)}.order-mode-switch button:focus-visible{outline:2px solid var(--primary);outline-offset:2px}
</style>
@@ -427,10 +427,12 @@ test('distills order creation into the confirmed prototype sections with server
test('matches the prototype two-column create layout and reset action', () => {
assert.match(source, /class="order-form-section prototype-section order-basic-section"/)
assert.match(source, /<h3>客户下单<\/h3><p>创建新订单<\/p>/)
assert.match(source, /<span class="order-step-badge">01<\/span><h3>客户下单<\/h3><span class="order-section-context">国内交货与运输信息<\/span>/)
assert.match(source, /<span class="order-step-badge">02<\/span><h3>目的地信息<\/h3><span class="order-section-context">美国目的仓与收件信息<\/span>/)
assert.match(source, /\.is-create-route \.prototype-order-form \{[^}]*grid-template-columns: repeat\(2, minmax\(0, 1fr\)\)/)
assert.match(source, /\.is-create-route \.cargo-section, \.is-create-route \.cost-estimate-section \{ grid-column: 1 \/ -1; \}/)
assert.match(source, /\.is-create-route \.prototype-form-row :deep\(\.el-form-item__label\) \{[^}]*height: 40px[^}]*align-items: center[^}]*justify-content: flex-end/)
assert.match(source, /\.is-create-route \.prototype-form-row[^\n]*grid-template-columns: 108px minmax\(0, 1fr\)/)
assert.match(source, /\.is-create-route \.prototype-form-row :deep\(\.el-form-item__label\) \{[^}]*height: 36px[^}]*justify-content: flex-end[^}]*padding: 0 10px 0 0/)
assert.match(source, /\.is-create-route \.compact-transport-grid > :deep\(\.el-form-item__label\) \{[^}]*align-items: center[^}]*text-align: right/)
assert.match(source, /async function resetCreateForm\(\)/)
assert.match(source, /确认清空当前填写的订单内容并恢复默认值/)
@@ -442,11 +444,15 @@ test('matches the prototype two-column create layout and reset action', () => {
assert.match(editorShellSource, /submit-shine 3\.2s ease-in-out infinite/)
assert.match(editorShellSource, /@media \(prefers-reduced-motion: reduce\)/)
assert.match(createHubSource, /<h1 class="create-hub-title">头程下单<\/h1>/)
assert.match(createHubSource, /<div class="create-mode-toolbar">[\s\S]*order-mode-switch[\s\S]*选择下单方式/)
assert.doesNotMatch(createHubSource, /填写货物信息,创建新的头程运输订单/)
assert.match(source, /\.is-create-route \.prototype-order-form \{[^}]*align-items: start/)
assert.doesNotMatch(source, /\.is-create-route \.order-basic-section, \.is-create-route \.destination-section \{ height: 100%; \}/)
assert.match(source, /\.is-create-route \.order-basic-section > \.order-section-heading, \.is-create-route \.destination-section > \.order-section-heading \{[^}]*height: 74px[^}]*min-height: 74px/)
assert.doesNotMatch(source, /\.is-create-route \.order-basic-section > \.order-section-heading, \.is-create-route \.destination-section > \.order-section-heading \{[^}]*(?:height|min-height):/)
assert.match(source, /\.is-create-route \.order-basic-section > \.order-section-heading h3, \.is-create-route \.destination-section > \.order-section-heading h3 \{[^}]*font-size: 17px[^}]*line-height: 24px/)
assert.match(source, /\.is-create-route \.order-step-badge \{[^}]*width: 30px[^}]*height: 30px/)
assert.match(source, /\.is-create-route \.order-section-context \{[^}]*margin-left: auto/)
assert.match(source, /\.is-create-route \.warehouse-options :deep\(\.el-radio\.is-checked\) \{[^}]*linear-gradient/)
})
test('shows an optional LX inbound order number for one-click dropship orders', () => {
@@ -2158,7 +2158,7 @@ async function searchOrders(model: QueryModel) {
<FirstLegOrderEditorShell v-model="editorOpen" :page="isCreatePage" :title="editingId ? '编辑订单草稿' : (routeCopySourceId ? '复制订单' : '创建订单')" :before-close="requestCloseEditor" @back="closeEditor">
<el-form ref="orderFormRef" v-loading="isCreatePage && supportingDataLoading" :model="form" :rules="orderFormRules" label-position="top" class="order-form prototype-order-form" :class="{ 'is-dialog-order-form': !isCreatePage }" scroll-to-error>
<section class="order-form-section prototype-section order-basic-section">
<div class="order-section-heading"><div><h3>客户下单</h3><p>创建新订单</p></div></div>
<div class="order-section-heading"><span class="order-step-badge">01</span><h3>客户下单</h3><span class="order-section-context">国内交货与运输信息</span></div>
<div class="prototype-form-flow shipping-form-flow">
<el-form-item label="履约类型" prop="fulfillmentType" required class="prototype-form-row prototype-form-row--tabs"><el-radio-group v-model="form.fulfillmentType" class="segmented-options" @change="changeFulfillmentType"><el-radio-button value="STANDARD">普通头程</el-radio-button><el-radio-button value="ONE_CLICK_DROPSHIP">一件代发</el-radio-button></el-radio-group><small v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" class="field-guidance">测量完成后不办理国内库存入库,将直接推送 PDA 合托。</small></el-form-item>
<el-form-item v-if="form.fulfillmentType === 'ONE_CLICK_DROPSHIP'" label="LX入库单号" prop="lxCode" class="prototype-form-row prototype-form-row--wide"><el-input v-model="form.lxCode" maxlength="128" placeholder="请输入LX入库单号" /><small class="field-guidance">用于绑定领星入库单,提交后可在尾程订单、入库单和出库单中查询。</small></el-form-item>
@@ -2197,7 +2197,7 @@ async function searchOrders(model: QueryModel) {
</section>
<section class="order-form-section prototype-section destination-section">
<div class="order-section-heading destination-section-heading"><div><h3>目的地信息</h3></div></div>
<div class="order-section-heading destination-section-heading"><span class="order-step-badge">02</span><h3>目的地信息</h3><span class="order-section-context">美国目的仓与收件信息</span></div>
<div class="prototype-form-flow destination-form-flow">
<el-form-item label="目的仓类型" prop="destinationType" required class="prototype-form-row prototype-form-row--tabs destination-type-row"><el-radio-group v-model="form.destinationType" class="segmented-options destination-type-options" @change="changeDestinationType"><el-radio-button v-if="form.fulfillmentType !== 'ONE_CLICK_DROPSHIP'" value="FBA">FBA 仓</el-radio-button><el-radio-button value="OVERSEAS">海外仓</el-radio-button><el-radio-button v-if="form.fulfillmentType !== 'ONE_CLICK_DROPSHIP'" value="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item>
@@ -2550,24 +2550,42 @@ async function searchOrders(model: QueryModel) {
.is-create-route { gap: 0; }
.is-create-route .first-leg-editor-page { width: 100%; }
.is-create-route .supporting-data-error, .is-create-route > .el-alert { margin-bottom: 14px; }
.is-create-route .prototype-order-form { display: grid; width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 14px; margin: 0; padding: 0 0 14px; }
.is-create-route .prototype-section { min-width: 0; margin: 0; padding: 0 20px 18px; border: 1px solid #dfe6ee; border-radius: 8px; background: #fff; }
.is-create-route .prototype-order-form { display: grid; width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; gap: 10px; margin: 0; padding: 0 0 10px; }
.is-create-route .prototype-section { position: relative; min-width: 0; margin: 0; padding: 0 16px 14px; overflow: hidden; border: 1px solid #d9e4ea; border-radius: 8px; background: #fff; box-shadow: 0 8px 26px rgba(24, 55, 75, .065); transition: border-color .22s ease, box-shadow .22s ease; }
.is-create-route .order-basic-section::before, .is-create-route .destination-section::before { position: absolute; z-index: 2; top: 0; right: 0; left: 0; height: 3px; background: linear-gradient(90deg, #16a394, #176b87 55%, transparent 92%); content: ''; }
.is-create-route .destination-section::before { background: linear-gradient(90deg, #2563eb, #4f46e5 55%, transparent 92%); }
.is-create-route .order-basic-section:focus-within { border-color: rgba(22, 163, 148, .45); box-shadow: 0 12px 34px rgba(22, 113, 113, .1); }
.is-create-route .destination-section:focus-within { border-color: rgba(37, 99, 235, .4); box-shadow: 0 12px 34px rgba(37, 99, 235, .1); }
.is-create-route .prototype-section + .prototype-section { margin-top: 0; }
.is-create-route .cargo-section, .is-create-route .cost-estimate-section { grid-column: 1 / -1; }
.is-create-route .order-basic-section > .order-section-heading, .is-create-route .destination-section > .order-section-heading { box-sizing: border-box; height: 74px; min-height: 74px; align-items: flex-start; margin: 0 -20px 16px; padding: 12px 20px; border-bottom: 1px solid #e7ecf2; }
.is-create-route .order-basic-section > .order-section-heading, .is-create-route .destination-section > .order-section-heading { box-sizing: border-box; align-items: center; margin: 0 -16px 12px; padding: 10px 16px; border-bottom: 1px solid #e7ecf2; background: linear-gradient(90deg, #f8fbfc, #fff 58%); }
.is-create-route .destination-section > .order-section-heading { background: linear-gradient(90deg, #f7f9ff, #fff 58%); }
.is-create-route .order-basic-section > .order-section-heading h3, .is-create-route .destination-section > .order-section-heading h3 { margin: 0; color: var(--navy); font-size: 17px; font-weight: 700; line-height: 24px; }
.is-create-route .cargo-section > .order-section-heading, .is-create-route .cost-estimate-section > .order-section-heading { min-height: 58px; align-items: flex-start; margin: 0 -20px 16px; padding: 12px 20px; border-bottom: 1px solid #e7ecf2; }
.is-create-route .order-section-heading p { margin-top: 2px; color: #8a98a8; }
.is-create-route .order-step-badge { display: grid; width: 30px; height: 30px; flex: 0 0 30px; color: #fff; border-radius: 8px; background: linear-gradient(135deg, #16a394, #176b87); box-shadow: 0 5px 12px rgba(23, 107, 135, .2); font-size: 11px; font-weight: 800; place-items: center; }
.is-create-route .destination-section .order-step-badge { background: linear-gradient(135deg, #367ae8, #4f46e5); box-shadow: 0 5px 12px rgba(37, 99, 235, .2); }
.is-create-route .order-section-context { margin-left: auto; color: #8a98a8; font-size: 11px; font-weight: 500; }
.is-create-route .cargo-section > .order-section-heading, .is-create-route .cost-estimate-section > .order-section-heading { align-items: center; margin: 0 -16px 10px; padding: 9px 16px; border-bottom: 1px solid #e7ecf2; }
.is-create-route .order-section-heading p { margin: 0; color: #8a98a8; }
.is-create-route .prototype-form-flow, .is-create-route .destination-form-flow { width: 100%; max-width: none; }
.is-create-route .prototype-form-row, .is-create-route .prototype-form-row--wide, .is-create-route .prototype-form-row--compact, .is-create-route .prototype-form-row--short, .is-create-route .prototype-form-row--tabs { grid-template-columns: 120px minmax(0, 1fr); }
.is-create-route .prototype-form-row :deep(.el-form-item__label) { display: flex; height: 40px; min-height: 40px; align-items: center; justify-content: flex-end; padding: 0 18px 0 0; font-size: 13px; line-height: 1.35; text-align: right; }
.is-create-route .prototype-form-row, .is-create-route .prototype-form-row--wide, .is-create-route .prototype-form-row--compact, .is-create-route .prototype-form-row--short, .is-create-route .prototype-form-row--tabs { grid-template-columns: 108px minmax(0, 1fr); }
.is-create-route .prototype-form-row :deep(.el-form-item__label) { display: flex; height: 36px; min-height: 36px; align-items: center; justify-content: flex-end; padding: 0 10px 0 0; font-size: 13px; line-height: 1.35; text-align: right; }
.is-create-route .segmented-options, .is-create-route .destination-type-options { width: 100%; max-width: 560px; background: #f3f6f9; }
.is-create-route .order-basic-section .segmented-options :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) { background: linear-gradient(105deg, #1596a6, #176b87); }
.is-create-route .destination-section .segmented-options :deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) { background: linear-gradient(105deg, #367ae8, #4f46e5); box-shadow: 0 4px 10px rgba(37, 99, 235, .2); }
.is-create-route .compact-transport-grid { width: 100%; margin-top: 4px; }
.is-create-route .compact-transport-grid > :deep(.el-form-item), .is-create-route .compact-transport-grid > :deep(.el-form-item:nth-child(1)), .is-create-route .compact-transport-grid > :deep(.el-form-item:nth-child(6)), .is-create-route .compact-transport-grid > :deep(.el-form-item:nth-child(7)) { grid-template-columns: 120px minmax(0, 1fr); }
.is-create-route .compact-transport-grid > :deep(.el-form-item__label) { display: flex; height: 40px; min-height: 40px; align-items: center; justify-content: flex-end; padding: 0 18px 0 0; line-height: 1.35; text-align: right; }
.is-create-route .selected-destination { width: auto; margin-left: 138px; }
.is-create-route .shipping-form-flow .prototype-form-row, .is-create-route .destination-form-flow .prototype-form-row, .is-create-route .compact-transport-grid > :deep(.el-form-item) { margin-bottom: 8px; }
.is-create-route .cargo-table-heading, .is-create-route .cost-estimate-section .order-section-heading { margin-bottom: 16px; }
.is-create-route .compact-transport-grid > :deep(.el-form-item), .is-create-route .compact-transport-grid > :deep(.el-form-item:nth-child(1)), .is-create-route .compact-transport-grid > :deep(.el-form-item:nth-child(6)), .is-create-route .compact-transport-grid > :deep(.el-form-item:nth-child(7)) { grid-template-columns: 108px minmax(0, 1fr); }
.is-create-route .compact-transport-grid > :deep(.el-form-item__label) { display: flex; height: 36px; min-height: 36px; align-items: center; justify-content: flex-end; padding: 0 10px 0 0; line-height: 1.35; text-align: right; }
.is-create-route .selected-destination { width: auto; margin-left: 108px; }
.is-create-route .shipping-form-flow .prototype-form-row, .is-create-route .destination-form-flow .prototype-form-row, .is-create-route .compact-transport-grid > :deep(.el-form-item) { margin-bottom: 5px; }
.is-create-route .prototype-form-row :deep(.el-input__wrapper), .is-create-route .prototype-form-row :deep(.el-select__wrapper), .is-create-route .compact-transport-grid :deep(.el-input__wrapper), .is-create-route .compact-transport-grid :deep(.el-select__wrapper) { min-height: 36px; }
.is-create-route .segmented-options :deep(.el-radio-button__inner) { min-height: 36px; padding: 8px 12px; }
.is-create-route .warehouse-options { gap: 8px; padding-top: 1px; }
.is-create-route .warehouse-options :deep(.el-radio) { min-height: 32px; margin: 0; padding: 5px 11px; border: 1px solid #d7e0e7; border-radius: 7px; background: #f8fafb; color: #596779; transition: color .18s ease, border-color .18s ease, background .18s ease, box-shadow .18s ease, transform .18s ease; }
.is-create-route .warehouse-options :deep(.el-radio:hover) { transform: translateY(-1px); border-color: #91b8c3; background: #f3fafb; color: var(--primary); }
.is-create-route .warehouse-options :deep(.el-radio__input) { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; pointer-events: none; }
.is-create-route .warehouse-options :deep(.el-radio__label) { padding-left: 0; color: inherit; }
.is-create-route .warehouse-options :deep(.el-radio.is-checked) { border-color: var(--primary); background: linear-gradient(105deg, #1596a6, #176b87); box-shadow: 0 4px 10px rgba(23, 107, 135, .2); color: #fff; }
.is-create-route .cargo-table-heading, .is-create-route .cost-estimate-section .order-section-heading { margin-bottom: 10px; }
.order-form { width: min(100%, 1000px); margin: 0; padding: 0 0 12px; }
.order-form.is-dialog-order-form { width: 100%; max-width: none; }
.is-dialog-order-form > .cargo-section, .is-dialog-order-form > .cost-estimate-section { width: 100%; max-width: none; }
@@ -19,7 +19,7 @@ test('keeps quick order active but moves it from the menu into the head-leg crea
assert.match(routesSource, /path: 'orders\/first-leg\/create'[\s\S]*?FirstLegOrderCreateHubView\.vue/)
assert.match(createHubSource, />普通下单<\/button>/)
assert.match(createHubSource, />快速下单<\/button>/)
assert.match(createHubSource, /<h1>头程下单<\/h1>/)
assert.match(createHubSource, /<h1[^>]*>头程下单<\/h1>/)
assert.match(createHubSource, /auth\.hasPermission\('order:first-leg:quick-create'\)/)
assert.match(createHubSource, /<FirstLegOrdersView v-if="activeMode === 'standard'" create-page \/>/)
assert.match(createHubSource, /<QuickOrderView v-else \/>/)
@@ -20,9 +20,11 @@ test('protects every overseas warehouse page with its independent list permissio
test('opens labeling and return relabeling on dedicated protected create pages', () => {
assert.match(routes, /path: 'overseas-warehouse\/labeling\/create'[\s\S]*?props: \{ service: 'labeling', createPage: true \}[\s\S]*?permission: 'merchant:overseas-warehouse:relabel:create'/)
assert.match(routes, /path: 'overseas-warehouse\/return-relabel\/create'[\s\S]*?props: \{ service: 'relabel', createPage: true \}[\s\S]*?permission: 'merchant:overseas-warehouse:return-order:create'/)
assert.match(routes, /path: 'overseas-warehouse\/return-relabel\/create'[\s\S]*?ReturnOrdersView\.vue'[\s\S]*?props: \{ createPage: true \}[\s\S]*?title: '新建退货预报'[\s\S]*?permission: 'merchant:overseas-warehouse:return-order:create'/)
assert.match(services, /props\.createPage \? 'create' : 'list'/)
assert.match(services, /function returnToList\(\)/)
assert.match(returns, /props\.createPage/)
assert.match(returns, /class="return-create-surface"/)
})
test('opens warehouse service and return receipt details as dedicated protected workspace pages', () => {
+2 -2
View File
@@ -24,8 +24,8 @@ export const overseasWarehouseModule = {
},
{
path: 'overseas-warehouse/return-relabel/create', name: 'overseas-return-relabel-create',
component: () => import('./views/WarehouseServiceView.vue'), props: { service: 'relabel', createPage: true }, release: 'active',
meta: { title: '新建退货换标单', workspaceTitle: '退货换标 · 新建', description: '填写并提交退货换标订单', permission: 'merchant:overseas-warehouse:return-order:create' },
component: () => import('./views/ReturnOrdersView.vue'), props: { createPage: true }, release: 'active',
meta: { title: '新建退货预报', workspaceTitle: '退货预报 · 新建', description: '填写并提交海外仓退货预报', permission: 'merchant:overseas-warehouse:return-order:create', keepAlive: false },
},
{
path: 'overseas-warehouse/return-relabel/:returnNo/detail', name: 'overseas-return-relabel-detail',
@@ -27,9 +27,13 @@ test('新建退货预报选择海外仓后带出联系人和电话', () => {
assert.match(view, /warehouseContactPhone: form\.warehouseContactPhone\.trim\(\)/)
})
test('新建退货预报使用加宽的双列表单弹窗', () => {
test('新建退货预报使用独立路由页面,编辑仍保留加宽弹框', () => {
assert.match(view, /defineProps<\{ createPage\?: boolean; detailPage\?: boolean/)
assert.match(view, /name: 'overseas-return-relabel-create'/)
assert.match(view, /v-if="props\.createPage"/)
assert.match(view, /class="return-create-surface"/)
assert.match(view, /<h2>新建退货预报<\/h2>/)
assert.match(view, /:title="editingReturnNo \? '编辑退货预报' : '新建退货预报'" width="1120px"/)
assert.match(view, /<el-form label-width="190px" class="return-create-form">/)
assert.match(view, /return-create-form \{ display:grid; grid-template-columns:repeat\(2,minmax\(0,1fr\)\)/)
})
@@ -10,7 +10,7 @@ import { useAuthStore } from '@/modules/auth/public'
import { getMerchantWarehouses, type MerchantWarehouse } from '@/modules/first-leg/public'
import { createReturnOrder, downloadReturnPhoto, getReturnOrder, listReturnOrders, updateReturnOrder, type ReturnOrderDetail, type ReturnOrderSummary, type ReturnReceipt } from '../api/returnOrders'
const props = withDefaults(defineProps<{ detailPage?: boolean; returnNo?: string }>(), { detailPage: false, returnNo: '' })
const props = withDefaults(defineProps<{ createPage?: boolean; detailPage?: boolean; returnNo?: string }>(), { createPage: false, detailPage: false, returnNo: '' })
const router = useRouter()
const route = useRoute()
const detailReturnNo = computed(() => props.returnNo || String(route.params.returnNo || ''))
@@ -110,10 +110,7 @@ async function loadOperationWarehouses() {
} finally { operationWarehousesLoading.value = false }
}
function openCreate() {
editingReturnNo.value = ''
resetForm()
showCreate.value = true
if (!operationWarehouses.value.length && !operationWarehousesLoading.value) void loadOperationWarehouses()
void router.push({ name: 'overseas-return-relabel-create' })
}
async function openEdit(returnNo: string) {
const detail = await getReturnOrder(returnNo)
@@ -176,7 +173,8 @@ function previewPhoto(fileId: string | number) {
}
function openDetail(returnNo: string) { void router.push({ name: 'overseas-return-relabel-detail', params: { returnNo } }) }
function returnToList() { void router.push({ name: 'overseas-return-relabel' }) }
function canApplyRelabel(order: { status?: unknown; needRelabel?: unknown; relabelNo?: unknown }) { return order.status === 'COMPLETED' && order.needRelabel === true }
function cancelCreate() { resetForm(); void router.push({ name: 'overseas-return-relabel' }) }
function canApplyRelabel(order: { status?: unknown; needRelabel?: unknown; relabelNo?: unknown }) { return order.status === 'COMPLETED' && order.needRelabel === true && !order.relabelNo }
function openRelabel(order: Pick<ReturnOrderSummary, 'returnNo' | 'overseasWarehouseId'>) {
void router.push({ name: 'overseas-labeling-create', query: { returnNo: order.returnNo, warehouseId: String(order.overseasWarehouseId) } })
}
@@ -218,19 +216,48 @@ function formatDateTime(value?: string | null) {
return new Intl.DateTimeFormat('zh-CN', { year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }).format(date)
}
onMounted(() => {
if (props.detailPage) void Promise.all([loadDetail(), loadOperationWarehouses()])
if (props.createPage) { resetForm(); void loadOperationWarehouses() }
else if (props.detailPage) void Promise.all([loadDetail(), loadOperationWarehouses()])
else void Promise.all([load(), loadOperationWarehouses()])
})
watch(() => [query.page, query.size] as const, ([nextPage, nextSize], [previousPage, previousSize]) => {
if (nextSize !== previousSize && nextPage !== 1) { query.page = 1; return }
if (!props.detailPage) void load()
if (!props.createPage && !props.detailPage) void load()
})
onBeforeUnmount(clearPhotoUrls)
</script>
<template>
<section class="return-orders-page">
<template v-if="!props.detailPage">
<template v-if="props.createPage">
<article class="return-create-surface">
<header class="return-create-header">
<div><h2>新建退货预报</h2><p>填写预计退回的 SKU 和数量,仓库签收后将自动更新实收结果。</p></div>
<el-button @click="returnToList"><ArrowLeft :size="16" />返回列表</el-button>
</header>
<div class="return-create-body">
<el-form label-width="150px" class="return-create-form">
<el-form-item label="海外仓" required class="return-form-wide"><el-select v-model="form.overseasWarehouseId" filterable clearable :loading="operationWarehousesLoading" :disabled="Boolean(operationWarehousesError)" placeholder="请选择海外仓" @change="selectOperationWarehouse"><el-option v-for="warehouse in operationWarehouses" :key="warehouse.id" :label="warehouseLabel(warehouse)" :value="String(warehouse.id)" /></el-select><div v-if="operationWarehousesError" class="warehouse-error">{{ operationWarehousesError }}</div></el-form-item>
<el-form-item label="海外仓联系人" required><el-input v-model="form.warehouseContactName" :readonly="Boolean(selectedOperationWarehouse?.contactName)" :placeholder="selectedOperationWarehouse?.contactName ? '已从海外仓资料带出' : '该海外仓未维护,请手动填写'" /></el-form-item>
<el-form-item label="海外仓联系电话" required><el-input v-model="form.warehouseContactPhone" :readonly="Boolean(selectedOperationWarehouse?.contactPhone)" :placeholder="selectedOperationWarehouse?.contactPhone ? '已从海外仓资料带出' : '该海外仓未维护,请手动填写'" /></el-form-item>
<el-form-item label="客户参考号" class="return-form-wide"><el-input v-model="form.customerReferenceNo" placeholder="可填写客户侧参考号" /></el-form-item>
<el-form-item label="备注" class="return-form-wide"><el-input v-model="form.remark" type="textarea" :rows="3" placeholder="填写退货说明(选填)" /></el-form-item>
</el-form>
<div class="items-title"><span>SKU 预报明细</span><el-button text type="primary" @click="addItem"><Plus :size="15" />添加 SKU</el-button></div>
<div class="return-relabel-option"><span>换标</span><el-checkbox v-model="form.needRelabel">需要换标</el-checkbox></div>
<div class="items-hint">客户只需填写预计退回的 SKU 和数量;快递单号由仓库签收时按实际包裹扫描绑定。</div>
<el-table :data="form.items" row-key="id" size="small" border>
<el-table-column label="操作" width="80" fixed="left"><template #default="{ $index }"><el-button link type="danger" :disabled="form.items.length === 1" @click="removeItem($index)">删除</el-button></template></el-table-column>
<el-table-column label="SKU" min-width="240"><template #default="{ row }"><div><el-input v-model="row.skuCode" :class="{ 'duplicate-sku-input': duplicateSkuIds.has(row.id) }" placeholder="填写 SKU" @blur="row.skuCode = row.skuCode.trim()" /><div v-if="duplicateSkuIds.has(row.id)" class="duplicate-sku-error">该 SKU 已在明细中填写</div></div></template></el-table-column>
<el-table-column label="中文品名" min-width="320"><template #default="{ row }"><el-input v-model="row.productNameZh" placeholder="填写中文品名(选填)" /></template></el-table-column>
<el-table-column label="预报数量" width="180"><template #default="{ row }"><el-input-number v-model="row.expectedQuantity" :min="1" controls-position="right" placeholder="数量" /></template></el-table-column>
</el-table>
</div>
<footer class="return-create-actions"><el-button @click="cancelCreate">取消</el-button><el-button v-if="canCreate" type="primary" :loading="creating" @click="submit">提交预报</el-button></footer>
</article>
</template>
<template v-else-if="!props.detailPage">
<QueryToolbar v-model="queryModel" layout="grid" ariaLabel="退货签收筛选" :fields="queryFields" @search="search" />
<div class="return-table-card">
<DataTable v-model:page="query.page" v-model:page-size="query.size" :data="rows" :columns="tableColumns" :loading="loading" :total="total" :page-sizes="[15, 30, 50, 100]" :row-actions="rowActions" row-key="returnNo" empty-text="暂无退货预报">
@@ -337,6 +364,15 @@ onBeforeUnmount(clearPhotoUrls)
<style scoped>
.relabel-suborder { display: flex; align-items: center; gap: 8px; min-height: 30px; white-space: nowrap; }
.return-orders-page { display:grid; gap:12px; } .return-create-form { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); column-gap:20px; } .return-form-wide { grid-column:1 / -1; } .items-title { display:flex; justify-content:space-between; align-items:center; margin:16px 0 4px; font-weight:600; } .items-hint { margin-bottom:10px; color:var(--el-text-color-secondary); font-size:12px; } h3 { margin:24px 0 10px; font-size:16px; } h4 { margin:16px 0 8px; font-size:14px; } .remark { white-space:pre-wrap; } .warehouse-error, .duplicate-sku-error { margin-top:6px; color:var(--el-color-danger); font-size:12px; } :deep(.duplicate-sku-input .el-input__wrapper) { box-shadow:0 0 0 1px var(--el-color-danger) inset; } :deep(.el-dialog .el-select) { width:100%; }
.return-create-surface { overflow:hidden; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; }
.return-create-header { display:flex; min-height:68px; align-items:center; justify-content:space-between; gap:20px; padding:12px 20px; border-bottom:1px solid var(--client-border); }
.return-create-header h2 { margin:0; color:#1f2d45; font-size:20px; }
.return-create-header p { margin:4px 0 0; color:#7c899c; font-size:12px; }
.return-create-body { padding:18px 20px 14px; }
.return-create-body :deep(.el-select), .return-create-body :deep(.el-input-number) { width:100%; }
.return-create-body .items-title { margin-top:8px; }
.return-relabel-option { display:flex; align-items:center; gap:16px; margin:8px 0; color:#526176; }
.return-create-actions { display:flex; align-items:center; justify-content:flex-end; gap:10px; padding:12px 20px; border-top:1px solid var(--client-border); background:#fbfcfe; }
.return-table-card { overflow:hidden; border:1px solid var(--client-border); border-radius:var(--client-radius); background:#fff; box-shadow:none; }
.return-table-card :deep(.el-table) { width:100%; }
.return-status--pending-review { --el-tag-bg-color:#fff7e6; --el-tag-border-color:#f4c26b; --el-tag-text-color:#a86100; }
@@ -171,6 +171,18 @@ test('客户仓储转运展示每个实际流转状态的中文名称', () => {
assert.match(view, /OUTBOUNDED: '已出库'/)
})
test('仓储转运详情按接口具名字段展示订单概览', () => {
const overview = view.split('<h3>订单概览</h3>')[1]?.split('warehouse-transfer-cargo-card')[0] || ''
assert.match(overview, /v-if="props\.service === 'transfer'"/)
assert.match(overview, /selectedDetail\?\.transferNo/)
assert.match(overview, /selectedDetail\?\.packageCount \?\? '—'/)
assert.match(overview, /warehouseDisplay\(selectedDetail\?\.operatingWarehouseId\)/)
assert.match(overview, /selectedDetail\?\.destinationWarehouseName \|\| warehouseDisplay\(selectedDetail\?\.destinationWarehouseCode \|\| selectedDetail\?\.fbaWarehouseCode\)/)
assert.match(overview, /deliveryMethodLabel\(selectedDetail\?\.deliveryMethod \|\| ''\)/)
assert.match(overview, /statusLabel\(selectedDetail\?\.status \|\| ''\)/)
assert.match(overview, /formatDateTime\(selectedDetail\?\.createdAt\)/)
})
test('PDA 打托完成后客户可从列表或详情确认并查看实测数据', () => {
assert.match(view, /item\.__status === 'WAIT_CUSTOMER_CONFIRMATION'/)
assert.match(view, /confirmTransferFromList\(item\['0'\]\)/)