feat: update client navigation and first-leg orders

This commit is contained in:
2026-08-03 15:50:24 +08:00
parent 5ab46fbff1
commit 64d051153d
10 changed files with 185 additions and 153 deletions
+4
View File
@@ -17,9 +17,12 @@ declare module 'vue' {
ClientSidebarMenuNode: typeof import('./components/ClientSidebarMenuNode.vue')['default']
CopyButton: typeof import('./components/common/CopyButton.vue')['default']
DataTable: typeof import('./components/common/DataTable.vue')['default']
ElAlert: typeof import('element-plus/es')['ElAlert']
ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
ElCollapse: typeof import('element-plus/es')['ElCollapse']
ElCollapseItem: typeof import('element-plus/es')['ElCollapseItem']
ElDatePicker: typeof import('element-plus/es')['ElDatePicker']
ElDialog: typeof import('element-plus/es')['ElDialog']
ElDrawer: typeof import('element-plus/es')['ElDrawer']
@@ -29,6 +32,7 @@ declare module 'vue' {
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElOption: typeof import('element-plus/es')['ElOption']
ElRadio: typeof import('element-plus/es')['ElRadio']
ElRadioButton: typeof import('element-plus/es')['ElRadioButton']
ElRadioGroup: typeof import('element-plus/es')['ElRadioGroup']
ElSelect: typeof import('element-plus/es')['ElSelect']
+1
View File
@@ -52,6 +52,7 @@ function handleSelect(path: string) {
:active-path="activePath"
:opened-keys="openedKeys"
:active-parent-keys="activeParentKeys"
:level="0"
@navigate="handleSelect"
@toggle="handleToggle"
/>
+4 -2
View File
@@ -8,6 +8,7 @@ const props = defineProps<{
activePath: string
openedKeys: ReadonlySet<string>
activeParentKeys: ReadonlySet<string>
level?: number
}>()
const emit = defineEmits<{
@@ -34,7 +35,7 @@ const groupId = computed(() => `client-nav-group-${props.node.key}`)
:aria-controls="groupId"
@click="emit('toggle', node.key)"
>
<component :is="node.icon" :size="18" />
<component v-if="!level" :is="node.icon" :size="20" />
<span>{{ node.title }}</span>
<ChevronDown class="client-sidebar-group-chevron" :size="16" aria-hidden="true" />
</button>
@@ -51,6 +52,7 @@ const groupId = computed(() => `client-nav-group-${props.node.key}`)
:active-path="activePath"
:opened-keys="openedKeys"
:active-parent-keys="activeParentKeys"
:level="(level ?? 0) + 1"
@navigate="emit('navigate', $event)"
@toggle="emit('toggle', $event)"
/>
@@ -64,7 +66,7 @@ const groupId = computed(() => `client-nav-group-${props.node.key}`)
:aria-current="node.path === activePath ? 'page' : undefined"
@click="emit('navigate', node.path)"
>
<component :is="node.icon" :size="18" />
<component v-if="!level" :is="node.icon" :size="20" />
<span>{{ node.title }}</span>
</RouterLink>
</li>
+4 -3
View File
@@ -55,10 +55,11 @@ test('source contract: keeps route context and user identity in the compact head
assert.doesNotMatch(routerSource, /showPageHeading|showTenantName/)
})
test('source contract: aligns the top bar beside the prototype sidebar and bounds the mobile header', () => {
test('source contract: aligns the admin-colored top bar beside the light sidebar and bounds the mobile header', () => {
assert.match(shellStyles, /\.top-header \{[^}]*inset: 0 0 auto 250px;[^}]*max-width: calc\(100vw - 250px\);[^}]*padding: 0 24px;/)
assert.match(shellStyles, /\.client-sidebar \{[^}]*inset: 0 auto 0 0;[^}]*width: 250px;[^}]*background: linear-gradient\(180deg, #0b1f3a 0%, #0c2a52 100%\)/)
assert.match(shellStyles, /\.client-sidebar-brand \{[^}]*border-bottom: 1px solid rgba\(255, 255, 255, \.1\)/)
assert.match(shellStyles, /\.top-header \{[^}]*background: var\(--topbar\)/)
assert.match(shellStyles, /\.client-sidebar \{[^}]*inset: 0 auto 0 0;[^}]*width: 250px;[^}]*background: rgba\(255, 255, 255, \.98\)/)
assert.match(shellStyles, /\.client-sidebar-brand \{[^}]*background: var\(--topbar\)/)
assert.match(shellStyles, /\.client-main \{[^}]*padding: 88px 20px 40px;/)
assert.match(globalStyles, /@media \(max-width: 720px\) \{[\s\S]*\.top-header \{ inset: 0;[^}]*padding: 0 14px; \}/)
assert.match(globalStyles, /\.client-header-menu-button \{ display: grid; \}/)
+2 -2
View File
@@ -1,6 +1,6 @@
:root { font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif; color: #172033; background: #f5f7f8; font-synthesis: none; --primary: #075e54; --primary-dark: #064b44; --navy: #17324d; --border: #e1e7ea; --muted: #687780; }
:root { font-family: Inter, "PingFang SC", "Microsoft YaHei", sans-serif; color: #172033; background: #f5f7fa; font-synthesis: none; --primary: #667eea; --primary-dark: #596fd3; --topbar: rgba(102, 126, 234, .95); --navy: #1e293b; --border: #e2e5ef; --muted: #71717a; --el-color-primary: #667eea; --el-color-primary-dark-2: #5265bb; --el-color-primary-light-3: #94a5f0; --el-color-primary-light-5: #b3bff5; --el-color-primary-light-7: #d1d8f9; --el-color-primary-light-8: #e0e5fb; --el-color-primary-light-9: #f0f2fd; }
* { box-sizing: border-box; }
html, body, #app { min-width: 320px; min-height: 100%; margin: 0; }
body { min-height: 100vh; background: #f5f7f8; }
body { min-height: 100vh; background: #f5f7fa; }
button, input { font: inherit; }
a { color: inherit; }
+1 -1
View File
@@ -95,7 +95,7 @@
}
@media (max-width: 720px) {
.top-header { inset: 0; width: 100%; max-width: 100vw; padding: 0 14px; }
.client-sidebar { display: flex; width: min(286px, 86vw); padding: 20px 14px 18px; visibility: hidden; pointer-events: none; transform: translateX(-100%); transition: transform .2s ease, visibility 0s linear .2s; z-index: 21; }
.client-sidebar { display: flex; width: min(286px, 86vw); padding: 0 14px 18px; visibility: hidden; pointer-events: none; transform: translateX(-100%); transition: transform .2s ease, visibility 0s linear .2s; z-index: 21; }
.mobile-menu-open .client-sidebar { visibility: visible; pointer-events: auto; transform: translateX(0); transition-delay: 0s; }
.client-sidebar-scrim { position: fixed; inset: 0; z-index: 20; display: block; padding: 0; border: 0; background: rgba(7, 18, 34, .56); }
.client-sidebar-brand button { display: grid; width: 44px; height: 44px; flex: 0 0 44px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: #fff; place-items: center; }
+11 -10
View File
@@ -10,13 +10,13 @@ const mobileStyles = compact.slice(compact.indexOf('@media (max-width: 720px)'))
test('style source contract: constrains the desktop sidebar and gives the menu its own scroll region', () => {
assert.match(compact, /\.client-shell\s*\{[^}]*overflow-x:\s*hidden/)
assert.match(compact, /\.client-sidebar\s*\{[^}]*inset:\s*0 auto 0 0[^}]*width:\s*250px[^}]*overflow:\s*hidden[^}]*background:\s*linear-gradient\(180deg, #0b1f3a 0%, #0c2a52 100%\)/)
assert.match(compact, /\.client-sidebar\s*\{[^}]*inset:\s*0 auto 0 0[^}]*width:\s*250px[^}]*overflow:\s*hidden[^}]*background:\s*rgba\(255, 255, 255, \.98\)/)
assert.match(compact, /\.top-header\s*\{[^}]*inset:\s*0 0 auto 250px[^}]*max-width:\s*calc\(100vw - 250px\)/)
assert.match(compact, /\.client-sidebar nav\s*\{[^}]*flex:\s*1[^}]*min-height:\s*0[^}]*overflow-y:\s*auto/)
assert.match(compact, /\.client-sidebar nav\s*\{[^}]*scrollbar-color:\s*rgba\(206, 222, 238, \.46\) transparent[^}]*scrollbar-width:\s*thin/)
assert.match(compact, /\.client-sidebar nav\s*\{[^}]*scrollbar-color:\s*rgba\(102, 126, 234, \.3\) transparent[^}]*scrollbar-width:\s*thin/)
assert.match(compact, /\.client-sidebar nav::\-webkit-scrollbar\s*\{[^}]*width:\s*10px[^}]*background:\s*transparent/)
assert.match(compact, /\.client-sidebar nav::\-webkit-scrollbar-track\s*\{[^}]*background:\s*transparent/)
assert.match(compact, /\.client-sidebar nav::\-webkit-scrollbar-thumb\s*\{[^}]*border:\s*3px solid transparent[^}]*background:\s*rgba\(206, 222, 238, \.46\)[^}]*background-clip:\s*padding-box/)
assert.match(compact, /\.client-sidebar nav::\-webkit-scrollbar-thumb\s*\{[^}]*border:\s*3px solid transparent[^}]*background:\s*rgba\(102, 126, 234, \.3\)[^}]*background-clip:\s*padding-box/)
assert.match(compact, /\.client-sidebar nav::\-webkit-scrollbar-button\s*\{[^}]*display:\s*none[^}]*width:\s*0[^}]*height:\s*0/)
assert.match(compact, /\.client-nav-menu,[^\{]*\.client-nav-submenu\s*\{[^}]*min-width:\s*0/)
assert.match(compact, /\.client-nav-menu\s*\{[^}]*flex:\s*1[^}]*background:\s*transparent/)
@@ -26,11 +26,12 @@ test('style source contract: constrains the desktop sidebar and gives the menu i
test('style source contract: presents native grouped rows matching the prototype active and divider states', () => {
assert.doesNotMatch(compact, /\.client-sidebar \.el-menu|\.el-menu-item|\.el-sub-menu/)
assert.match(compact, /\.client-nav-menu,[^\{]*\.client-nav-submenu\s*\{[^}]*margin:\s*0[^}]*padding:\s*0[^}]*list-style:\s*none/)
assert.match(compact, /\.client-sidebar-group-trigger,[^\{]*\.client-sidebar-link\s*\{[^}]*height:\s*44px[^}]*border-radius:\s*10px[^}]*color:\s*rgba\(255, 255, 255, \.72\)/)
assert.match(compact, /\.client-sidebar-link\[aria-current="page"\]\s*\{[^}]*background:\s*linear-gradient\(90deg, #1565c0, #1e88e5\)[^}]*color:\s*#fff/)
assert.match(compact, /\.client-nav-node\.is-active-parent > \.client-sidebar-group-trigger\s*\{[^}]*background:\s*rgba\(255, 255, 255, \.08\)[^}]*color:\s*#fff/)
assert.match(compact, /\.client-nav-node\.has-divider-before\s*\{[^}]*border-top:\s*1px solid rgba\(255, 255, 255, \.1\)/)
assert.match(compact, /\.client-nav-submenu\s*\{[^}]*padding-left:\s*34px/)
assert.match(compact, /\.client-sidebar-group-trigger,[^\{]*\.client-sidebar-link\s*\{[^}]*height:\s*44px[^}]*border-radius:\s*0 12px 12px 0[^}]*color:\s*#595959/)
assert.match(compact, /\.client-nav-submenu \.client-sidebar-link\[aria-current="page"\]\s*\{[^}]*background:\s*rgba\(102, 126, 234, \.12\)[^}]*box-shadow:\s*none[^}]*color:\s*var\(--primary\)/)
assert.match(compact, /\.client-nav-node\.is-active-parent > \.client-sidebar-group-trigger\s*\{[^}]*background:\s*transparent[^}]*color:\s*#52525b/)
assert.match(compact, /\.client-nav-node\.has-divider-before\s*\{[^}]*border-top:\s*1px solid rgba\(102, 126, 234, \.12\)/)
assert.match(compact, /\.client-nav-submenu\s*\{[^}]*padding:\s*4px 0/)
assert.match(compact, /\.client-nav-submenu \.client-sidebar-link\s*\{[^}]*height:\s*42px[^}]*margin:\s*0 12px[^}]*padding:\s*0 20px 0 54px[^}]*border-radius:\s*10px/)
assert.match(compact, /\.client-sidebar-group-trigger\s*\{[^}]*border:\s*0[^}]*background:\s*transparent/)
assert.match(compact, /\.client-sidebar-link\s*\{[^}]*text-decoration:\s*none/)
assert.match(compact, /\.client-sidebar (?:\.client-sidebar-group-trigger|\.client-sidebar-link) svg[^\{]*\{[^}]*flex:\s*0 0 18px/)
@@ -39,8 +40,8 @@ test('style source contract: presents native grouped rows matching the prototype
test('style source contract: makes navigation and drawer controls touch sized with visible keyboard focus', () => {
assert.match(compact, /\.client-sidebar-group-trigger,[^\{]*\.client-sidebar-link\s*\{[^}]*height:\s*44px/)
assert.match(compact, /\.client-sidebar-group-trigger:focus-visible,\s*\.client-sidebar-link:focus-visible,[\s\S]*?\.client-sidebar-logout:focus-visible\s*\{[^}]*outline:\s*2px solid #8ed8cf[^}]*outline-offset:\s*2px/)
assert.match(compact, /\.client-sidebar-logout\s*\{[^}]*height:\s*44px[^}]*background:\s*rgba\(239, 68, 68, \.12\)/)
assert.match(compact, /\.client-sidebar-group-trigger:focus-visible,\s*\.client-sidebar-link:focus-visible,[\s\S]*?\.client-sidebar-logout:focus-visible\s*\{[^}]*outline:\s*2px solid var\(--primary\)[^}]*outline-offset:\s*2px/)
assert.match(compact, /\.client-sidebar-logout\s*\{[^}]*height:\s*44px[^}]*background:\s*#fff1f0/)
})
test('style source contract: turns the sidebar into a layered drawer at 720px and below', () => {
+28 -25
View File
@@ -1,57 +1,60 @@
.client-shell { min-height: 100vh; overflow-x: hidden; }
.top-header { position: fixed; inset: 0 0 auto 250px; z-index: 20; display: flex; width: auto; max-width: calc(100vw - 250px); height: 68px; align-items: center; gap: 16px; padding: 0 24px; border-bottom: 1px solid var(--border); background: rgba(255, 255, 255, .94); backdrop-filter: blur(12px); }
.client-header-menu-button { display: none; width: 44px; height: 44px; flex: 0 0 44px; padding: 0; border: 0; border-radius: 8px; background: transparent; color: var(--navy); place-items: center; }
.top-header { position: fixed; inset: 0 0 auto 250px; z-index: 20; display: flex; width: auto; max-width: calc(100vw - 250px); height: 68px; align-items: center; gap: 16px; padding: 0 24px; background: var(--topbar); box-shadow: 0 8px 32px rgba(0, 0, 0, .15), 0 2px 8px rgba(0, 0, 0, .1); backdrop-filter: blur(20px); }
.client-header-menu-button { display: none; width: 44px; height: 44px; flex: 0 0 44px; padding: 0; border: 0; border-radius: 8px; background: rgba(255, 255, 255, .1); color: #fff; place-items: center; }
.client-header-heading { min-width: 0; flex: 1; }
.client-header-heading h1 { overflow: hidden; margin: 0; color: var(--navy); font-size: 18px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.client-header-heading p { overflow: hidden; margin: 2px 0 0; color: var(--muted); font-size: 11px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.client-header-heading h1 { overflow: hidden; margin: 0; color: #fff; font-size: 18px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.client-header-heading p { overflow: hidden; margin: 2px 0 0; color: rgba(255, 255, 255, .72); font-size: 11px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.client-user { display: flex; align-items: center; gap: 10px; }
.client-avatar { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 50%; background: #e7f3f1; color: var(--primary); font-weight: 750; }
.client-avatar { display: grid; width: 34px; height: 34px; place-items: center; border-radius: 50%; background: #fff; color: var(--primary); font-weight: 750; box-shadow: 0 4px 12px rgba(0, 0, 0, .15); }
.client-user-copy { display: flex; flex-direction: column; }
.client-user-copy strong { font-size: 12px; }
.client-user-copy small { color: var(--muted); font-size: 10px; }
.client-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 21; display: flex; width: 250px; flex-direction: column; overflow: hidden; padding: 20px 14px; background: linear-gradient(180deg, #0b1f3a 0%, #0c2a52 100%); color: #fff; }
.client-sidebar-brand { display: flex; flex: 0 0 auto; min-height: 68px; align-items: center; justify-content: space-between; gap: 10px; margin: -2px 0 14px; padding: 0 8px 14px; border-bottom: 1px solid rgba(255, 255, 255, .1); }
.client-user-copy strong { color: #fff; font-size: 12px; }
.client-user-copy small { color: rgba(255, 255, 255, .68); font-size: 10px; }
.client-sidebar { position: fixed; inset: 0 auto 0 0; z-index: 21; display: flex; width: 250px; flex-direction: column; overflow: hidden; padding: 0 14px 20px; background: rgba(255, 255, 255, .98); box-shadow: 4px 0 32px rgba(0, 0, 0, .08); color: var(--navy); }
.client-sidebar-brand { display: flex; flex: 0 0 68px; min-height: 68px; align-items: center; justify-content: space-between; gap: 10px; margin: 0 -14px 14px; padding: 0 18px; background: var(--topbar); }
.client-sidebar-brand-link { display: flex; min-width: 0; align-items: center; gap: 10px; color: #fff; text-decoration: none; }
.client-sidebar-brand-mark { display: grid; width: 36px; height: 36px; flex: 0 0 36px; place-items: center; border-radius: 9px; background: #fff; box-shadow: 0 5px 14px rgba(0, 0, 0, .18); }
.client-sidebar-brand-mark { display: grid; width: 36px; height: 36px; flex: 0 0 36px; place-items: center; border: 1px solid rgba(102, 126, 234, .14); border-radius: 9px; background: #fff; box-shadow: 0 3px 10px rgba(30, 41, 59, .08); }
.client-sidebar-brand-mark img { display: block; width: 28px; height: 28px; object-fit: contain; }
.client-sidebar-brand-copy { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
.client-sidebar-brand-copy strong { font-size: 15px; letter-spacing: .02em; line-height: 1.2; }
.client-sidebar-brand-copy small { color: rgba(255, 255, 255, .56); font-size: 10px; letter-spacing: .12em; }
.client-sidebar nav { display: flex; flex: 1; min-width: 0; min-height: 0; flex-direction: column; overflow-x: hidden; overflow-y: auto; scrollbar-color: rgba(206, 222, 238, .46) transparent; scrollbar-width: thin; }
.client-sidebar-brand-copy small { color: rgba(255, 255, 255, .76); font-size: 10px; letter-spacing: .12em; }
.client-sidebar nav { display: flex; flex: 1; min-width: 0; min-height: 0; flex-direction: column; overflow-x: hidden; overflow-y: auto; scrollbar-color: rgba(102, 126, 234, .3) transparent; scrollbar-width: thin; }
.client-sidebar nav::-webkit-scrollbar { width: 10px; background: transparent; }
.client-sidebar nav::-webkit-scrollbar-track { background: transparent; }
.client-sidebar nav::-webkit-scrollbar-thumb { min-height: 40px; border: 3px solid transparent; border-radius: 999px; background: rgba(206, 222, 238, .46); background-clip: padding-box; }
.client-sidebar nav::-webkit-scrollbar-thumb:hover { background-color: rgba(235, 244, 252, .72); }
.client-sidebar nav::-webkit-scrollbar-thumb { min-height: 40px; border: 3px solid transparent; border-radius: 999px; background: rgba(102, 126, 234, .3); background-clip: padding-box; }
.client-sidebar nav::-webkit-scrollbar-thumb:hover { background-color: rgba(102, 126, 234, .5); }
.client-sidebar nav::-webkit-scrollbar-button { display: none; width: 0; height: 0; }
.client-sidebar nav::-webkit-scrollbar-corner { background: transparent; }
.client-nav-menu,
.client-nav-submenu { min-width: 0; margin: 0; padding: 0; list-style: none; }
.client-nav-menu { flex: 1; background: transparent; }
.client-nav-submenu { padding-left: 34px; }
.client-nav-submenu { padding: 4px 0; background: linear-gradient(180deg, rgba(102, 126, 234, .03), transparent); }
.client-nav-node { min-width: 0; }
.client-nav-node.has-divider-before { margin-top: 14px; padding-top: 14px; border-top: 1px solid rgba(255, 255, 255, .1); }
.client-nav-node.has-divider-before { margin-top: 14px; padding-top: 14px; border-top: 1px solid rgba(102, 126, 234, .12); }
.client-sidebar-group-trigger,
.client-sidebar-link { display: flex; width: 100%; height: 44px; min-width: 0; align-items: center; gap: 11px; margin: 2px 0; padding: 0 10px; border-radius: 10px; color: rgba(255, 255, 255, .72); }
.client-sidebar-link { display: flex; width: 100%; height: 44px; min-width: 0; align-items: center; gap: 11px; margin: 2px 0; padding: 0 10px; border-radius: 0 12px 12px 0; color: #595959; transition: color .2s ease, background .2s ease, transform .2s ease; }
.client-sidebar-group-trigger { border: 0; background: transparent; cursor: pointer; text-align: left; }
.client-sidebar-link { text-decoration: none; }
.client-sidebar-group-trigger:hover,
.client-sidebar-link:hover { background: rgba(255, 255, 255, .08); color: #fff; }
.client-sidebar-link[aria-current="page"] { background: linear-gradient(90deg, #1565c0, #1e88e5); box-shadow: 0 6px 18px rgba(21, 101, 192, .4); color: #fff; font-weight: 650; }
.client-nav-node.is-active-parent > .client-sidebar-group-trigger { background: rgba(255, 255, 255, .08); color: #fff; font-weight: 600; }
.client-sidebar-link:hover { background: linear-gradient(135deg, rgba(102, 126, 234, .1), rgba(240, 147, 251, .08)); color: var(--primary); transform: translateX(3px); }
.client-nav-menu > .client-nav-node > .client-sidebar-link[aria-current="page"] { background: linear-gradient(135deg, rgba(102, 126, 234, .18), rgba(240, 147, 251, .12)); box-shadow: inset 3px 0 var(--primary), 0 2px 8px rgba(102, 126, 234, .1); color: var(--primary); font-weight: 700; }
.client-nav-submenu .client-sidebar-link { height: 42px; margin: 0 12px; padding: 0 20px 0 54px; border-radius: 10px; color: #71717a; }
.client-nav-submenu .client-sidebar-link:hover { background: rgba(102, 126, 234, .08); color: var(--primary); transform: translateX(4px); }
.client-nav-submenu .client-sidebar-link[aria-current="page"] { background: rgba(102, 126, 234, .12); box-shadow: none; color: var(--primary); font-weight: 700; }
.client-nav-node.is-active-parent > .client-sidebar-group-trigger { background: transparent; color: #52525b; font-weight: 400; }
.client-sidebar .client-sidebar-group-trigger svg,
.client-sidebar .client-sidebar-link svg { flex: 0 0 18px; }
.client-sidebar .client-sidebar-group-chevron { width: 16px; height: 16px; flex: 0 0 16px; margin-left: auto; transition: transform .2s ease; }
.client-sidebar-group-trigger[aria-expanded="true"] .client-sidebar-group-chevron { transform: rotate(180deg); }
.client-sidebar .client-sidebar-group-chevron { width: 16px; height: 16px; flex: 0 0 16px; margin-left: auto; transform: rotate(-90deg); transition: transform .2s ease; }
.client-sidebar-group-trigger[aria-expanded="true"] .client-sidebar-group-chevron { transform: rotate(0); }
.client-sidebar-group-trigger span,
.client-sidebar-link span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.client-sidebar-group-trigger:focus-visible,
.client-sidebar-link:focus-visible,
.client-header-menu-button:focus-visible,
.client-sidebar-brand button:focus-visible,
.client-sidebar-logout:focus-visible { outline: 2px solid #8ed8cf; outline-offset: 2px; }
.client-sidebar-logout:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.client-sidebar-brand button,
.client-sidebar-scrim { display: none; }
.client-sidebar-logout { display: flex; width: 100%; height: 44px; align-items: center; justify-content: flex-start; gap: 11px; margin: 2px 0 0; padding: 0 10px; border: 0; border-radius: 10px; background: rgba(239, 68, 68, .12); color: #fca5a5; cursor: pointer; font-weight: 600; text-align: left; }
.client-sidebar-logout:hover { background: rgba(239, 68, 68, .22); color: #fff; }
.client-sidebar-logout { display: flex; width: 100%; height: 44px; align-items: center; justify-content: flex-start; gap: 11px; margin: 2px 0 0; padding: 0 10px; border: 0; border-radius: 10px; background: #fff1f0; color: #d94b50; cursor: pointer; font-weight: 600; text-align: left; }
.client-sidebar-logout:hover { background: #ffe3e1; color: #b52f35; }
.client-sidebar-logout svg { flex: 0 0 18px; }
.client-main { width: calc(100% - 250px); max-width: 100%; min-height: 100vh; margin-left: 250px; padding: 88px 20px 40px; }
+14 -2
View File
@@ -79,9 +79,21 @@ test('requires an enabled platform receiving warehouse in the order form', () =>
})
test('collects the backend-required first-leg fields', () => {
for (const model of ['destinationCountryCode', 'destinationWarehouseName', 'channelCode', 'channelName', 'pickupMethod', 'taxMode', 'destinationType', 'packageType', 'upc']) {
for (const model of ['destinationCountryCode', 'destinationWarehouseName', 'channelCode', 'pickupMethod', 'taxMode', 'destinationType', 'packageType', 'upc']) {
assert.match(source, new RegExp(`v-model="(?:form|item)\\.${model}"`))
}
assert.match(source, /form\.channelName = channel\?\.name/)
})
test('distills order creation into the confirmed prototype sections without fake pricing', () => {
assert.match(source, /<h3>下单信息<\/h3>/)
assert.match(source, /<h3>发货与目的地<\/h3>/)
assert.match(source, /<h3>货物明细<\/h3>/)
assert.match(source, /class="segmented-options"/)
assert.match(source, /class="cargo-primary-grid"/)
assert.match(source, /title="更多申报信息"/)
assert.doesNotMatch(source, /费用预估|查看最新报价|cost-estimate/)
assert.doesNotMatch(source, /v-model="form\.(?:businessType|origin|destination|priority|tradeTerm)"/)
})
test('shows field-level validation and concise guidance for required order inputs', () => {
@@ -103,7 +115,7 @@ test('selects enabled counterparties and stores editable order snapshots', () =>
assert.match(source, /v-model="form\.consigneePartyId"/)
assert.match(source, /form\.shipperPartySnapshot\.contactName/)
assert.match(source, /form\.consigneePartySnapshot\.address/)
assert.match(source, /只影响本订单快照/)
assert.match(source, /保存为本订单快照/)
assert.match(source, /form\.shipperPartySnapshot\.name\} · 历史快照/)
assert.match(source, /form\.consigneePartySnapshot\.name\} · 历史快照/)
})
+116 -108
View File
@@ -2,7 +2,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox, type FormInstance, type FormItemRule, type FormRules } from 'element-plus'
import { AlertTriangle, Box, Building2, CheckCircle2, Eye, FilePlus2, MapPin, PackageCheck, Pencil, Plus, Send, Trash2 } from '@lucide/vue'
import { AlertTriangle, Box, CheckCircle2, Eye, FilePlus2, MapPin, PackageCheck, Pencil, Plus, Send, Trash2 } from '@lucide/vue'
import { FIRST_LEG_ORDER_STATUSES, FIRST_LEG_TRANSPORT_MODES, type FirstLegOrderStatus } from '@chongchong/shared'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
@@ -40,6 +40,12 @@ const queryFields: QueryField[] = [
{ key: 'transportMode', type: 'select', placeholder: '全部运输方式', options: FIRST_LEG_TRANSPORT_MODES.map((item) => ({ label: item.label, value: item.code })), props: { 'aria-label': '运输方式' } },
{ key: 'createdRange', type: 'daterange', placeholder: '订单创建日期', props: { 'aria-label': '订单创建日期' } },
]
const channelOptions = [
{ code: 'SEA-US-GENERAL', name: '美线普货专线' },
{ code: 'SEA-US-SENSITIVE', name: '美线敏感货专线' },
{ code: 'SEA-US-EXPRESS', name: '美线快线' },
{ code: 'SEA-US-FBA', name: '美线 FBA 直送' },
]
const orderColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单', minWidth: 190, slotName: 'order' },
@@ -133,7 +139,7 @@ function emptyDraft(): FirstLegOrderDraft {
destinationWarehouseName: '',
pickupMethod: 'SELF_EXPRESS',
channelCode: 'SEA-US-FBA',
channelName: '美国头程渠道',
channelName: '美线 FBA 直送',
taxMode: 'BUY_DECLARATION',
customsInspection: false,
destinationType: 'FBA',
@@ -297,10 +303,13 @@ function selectParty(role: FirstLegPartyRole, id: string) {
form.shipperPartyId = party.id
form.shipperPartySnapshot = snapshot
form.shipper = party.name
form.origin = [snapshot.province, snapshot.city].filter(Boolean).join(' ') || snapshot.countryRegion || snapshot.name
} else {
form.consigneePartyId = party.id
form.consigneePartySnapshot = snapshot
form.consignee = party.name
form.destination = [snapshot.province, snapshot.city].filter(Boolean).join(' ') || snapshot.countryRegion || snapshot.name
if (/^[A-Za-z]{2}$/.test(snapshot.countryRegion)) form.destinationCountryCode = snapshot.countryRegion.toUpperCase()
}
}
@@ -309,13 +318,24 @@ function clearParty(role: FirstLegPartyRole) {
form.shipperPartyId = ''
form.shipperPartySnapshot = null
form.shipper = ''
form.origin = ''
} else {
form.consigneePartyId = ''
form.consigneePartySnapshot = null
form.consignee = ''
form.destination = ''
}
}
function selectChannel(code: string) {
const channel = channelOptions.find((item) => item.code === code)
form.channelName = channel?.name ?? ''
}
function syncAmazonWarehouse(value: string) {
if (form.destinationType === 'FBA') form.destinationWarehouseName = value.trim()
}
function selectShipper(id: string) {
selectParty('SHIPPER', id)
}
@@ -332,10 +352,11 @@ function clearConsignee() {
clearParty('CONSIGNEE')
}
async function loadSelectedWarehouse(id: string) {
if (!id) return
async function loadSelectedWarehouse(id: string | number | boolean | undefined) {
const warehouseId = typeof id === 'string' ? id : typeof id === 'number' ? String(id) : ''
if (!warehouseId) return
try {
await store.loadWarehouseDetail(id)
await store.loadWarehouseDetail(warehouseId)
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '仓库地址与收货时间加载失败')
}
@@ -587,107 +608,85 @@ async function searchOrders(model: QueryModel) {
</template>
</div>
<el-drawer v-model="editorOpen" class="first-leg-editor" :title="editingId ? '编辑订单草稿' : '创建头程订单'" size="760px" destroy-on-close :before-close="requestCloseEditor">
<el-form ref="orderFormRef" :model="form" :rules="orderFormRules" label-position="top" class="order-form" scroll-to-error>
<section class="order-form-section">
<h3>运输信息</h3>
<el-drawer v-model="editorOpen" class="first-leg-editor" :title="editingId ? '编辑订单草稿' : '创建头程订单'" size="1080px" destroy-on-close :before-close="requestCloseEditor">
<el-form ref="orderFormRef" :model="form" :rules="orderFormRules" label-position="top" class="order-form prototype-order-form" scroll-to-error>
<section class="order-form-section prototype-section">
<div class="order-section-heading"><div><h3>下单信息</h3><p>选择送货方式、收货仓和运输渠道。</p></div></div>
<div class="order-form-grid">
<el-form-item label="运输方式" prop="transportMode" required><el-select v-model="form.transportMode"><el-option v-for="item in FIRST_LEG_TRANSPORT_MODES" :key="item.code" :label="item.label" :value="item.code" /></el-select></el-form-item>
<el-form-item label="业务类型" prop="businessType" required><el-radio-group v-model="form.businessType"><el-radio-button value="EXPORT">出口</el-radio-button><el-radio-button value="IMPORT">进口</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="起运地" prop="origin" required><el-input v-model="form.origin" placeholder="例如:宁波" /></el-form-item>
<el-form-item label="目的地" prop="destination" required><el-input v-model="form.destination" placeholder="例如:洛杉矶" /></el-form-item>
<el-form-item label="平台收货仓" prop="receivingWarehouseId" required><el-select v-model="form.receivingWarehouseId" filterable placeholder="请选择送货仓库" @change="loadSelectedWarehouse"><el-option v-for="warehouse in availableWarehouses" :key="warehouse.id" :label="warehouse.city ? `${warehouse.name} · ${warehouse.city}` : warehouse.name" :value="warehouse.id" /></el-select></el-form-item>
<el-form-item label="期望出发日期"><el-date-picker v-model="form.expectedDepartureDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择日期" /></el-form-item>
<el-form-item label="客户参考号" prop="referenceNo" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 订单请输入客户参考号') : undefined"><el-input v-model="form.referenceNo" maxlength="100" :placeholder="form.destinationType === 'FBA' ? '请输入 FBA Reference ID' : '贵司内部单号(选填)'" /></el-form-item>
<el-form-item label="优先级"><el-select v-model="form.priority"><el-option label="普通" value="NORMAL" /><el-option label="紧急" value="URGENT" /><el-option label="加急" value="EXPEDITED" /></el-select></el-form-item>
<el-form-item label="目的国代码" prop="destinationCountryCode" required><el-input v-model="form.destinationCountryCode" maxlength="2" placeholder="例如:US" /></el-form-item>
<el-form-item label="目的仓名称" prop="destinationWarehouseName" required><el-input v-model="form.destinationWarehouseName" maxlength="128" placeholder="例如:ONT8 或洛杉矶海外仓" /></el-form-item>
<el-form-item label="渠道编码" prop="channelCode" required><el-input v-model="form.channelCode" maxlength="64" placeholder="例如:SEA-US-FBA" /></el-form-item>
<el-form-item label="渠道名称" prop="channelName" required><el-input v-model="form.channelName" maxlength="128" placeholder="例如:美国海运 FBA 渠道" /></el-form-item>
<el-form-item label="提货方式" prop="pickupMethod" required><el-select v-model="form.pickupMethod"><el-option label="自送快递" value="SELF_EXPRESS" /><el-option label="上门提货" value="PICKUP" /><el-option label="自送卡车" value="SELF_TRUCK" /></el-select></el-form-item>
<el-form-item label="报关模式" prop="taxMode" required><el-select v-model="form.taxMode"><el-option label="买单报关" value="BUY_DECLARATION" /><el-option label="退税报关" value="TAX_REFUND" /></el-select></el-form-item>
<el-form-item label="目的仓类型" prop="destinationType" required><el-select v-model="form.destinationType"><el-option label="FBA" value="FBA" /><el-option label="海外仓" value="OVERSEAS" /><el-option label="私人地址" value="PRIVATE" /></el-select></el-form-item>
<el-form-item label="贸易术语"><el-input v-model="form.tradeTerm" maxlength="16" placeholder="例如:FOB" /></el-form-item>
<el-form-item v-if="form.destinationType === 'FBA'" label="Amazon 仓库编码" prop="amazonWarehouseCode" :rules="requiredTextRules('请输入 Amazon 仓库编码')" required><el-input v-model="form.amazonWarehouseCode" maxlength="32" placeholder="例如:ONT8" /></el-form-item>
<el-form-item v-if="form.destinationType === 'FBA'" label="Shipment ID" prop="shipmentId" :rules="requiredTextRules('请输入 Shipment ID')" required><el-input v-model="form.shipmentId" maxlength="64" placeholder="例如:FBA15ABC123" /></el-form-item>
<el-form-item label="取货方式" prop="pickupMethod" required class="form-span-full"><el-radio-group v-model="form.pickupMethod" class="segmented-options"><el-radio-button value="SELF_EXPRESS">自送货 · 快递</el-radio-button><el-radio-button value="PICKUP">我司提货</el-radio-button><el-radio-button value="SELF_TRUCK">自送货 · 卡车</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="平台收货仓" prop="receivingWarehouseId" required class="form-span-full"><el-radio-group v-model="form.receivingWarehouseId" class="warehouse-options" @change="loadSelectedWarehouse"><el-radio-button v-for="warehouse in availableWarehouses" :key="warehouse.id" :value="warehouse.id">{{ warehouse.city ? `${warehouse.name} · ${warehouse.city}` : warehouse.name }}</el-radio-button></el-radio-group></el-form-item>
<el-form-item label="渠道" prop="channelCode" required><el-select v-model="form.channelCode" placeholder="请选择运输渠道" @change="selectChannel"><el-option v-if="form.channelCode && !channelOptions.some((channel) => channel.code === form.channelCode)" :label="form.channelName || form.channelCode" :value="form.channelCode" /><el-option v-for="channel in channelOptions" :key="channel.code" :label="channel.name" :value="channel.code" /></el-select></el-form-item>
<el-form-item label="买单 / 退税" prop="taxMode" required><el-radio-group v-model="form.taxMode"><el-radio value="BUY_DECLARATION">买单报关</el-radio><el-radio value="TAX_REFUND">退税报关</el-radio></el-radio-group></el-form-item>
<el-form-item label="运输方式" prop="transportMode" required><el-radio-group v-model="form.transportMode"><el-radio v-for="item in FIRST_LEG_TRANSPORT_MODES" :key="item.code" :value="item.code">{{ item.label }}</el-radio></el-radio-group></el-form-item>
<el-form-item label="是否商检" prop="customsInspection" required><el-radio-group v-model="form.customsInspection"><el-radio :value="true">是</el-radio><el-radio :value="false">否</el-radio></el-radio-group></el-form-item>
<el-form-item label="期望出货日期"><el-date-picker v-model="form.expectedDepartureDate" type="date" value-format="YYYY-MM-DD" placeholder="请选择期望出货日期" /></el-form-item>
<el-form-item label="订单备注"><el-input v-model="form.customerRemark" maxlength="1000" placeholder="选填,例如:入仓前请电话联系" /></el-form-item>
</div>
<div v-if="selectedWarehouse" class="selected-warehouse"><MapPin :size="17" /><div><strong>{{ selectedWarehouse.name }}</strong><span>{{ selectedWarehouse.address ? `${selectedWarehouse.province}${selectedWarehouse.city} · ${selectedWarehouse.address}` : '仓库地址详情暂不可用' }}</span><small>{{ selectedWarehouse.businessHours ? `收货时间:${selectedWarehouse.businessHours}` : '收货时间暂不可用' }}</small></div></div>
</section>
<section class="order-form-section">
<div class="order-section-heading"><div><h3>收发货单位</h3><p>仅可选择平台已启用的往来单位;下方联系人和地址调整只影响本订单快照。</p></div></div>
<div class="party-editor-grid">
<article class="party-editor">
<header><span><Building2 :size="17" /><strong>发货单位</strong></span><small>必填</small></header>
<el-form-item label="选择发货单位" prop="shipperPartyId" required>
<el-select v-model="form.shipperPartyId" filterable clearable placeholder="请选择已启用的发货单位" @change="selectShipper" @clear="clearShipper">
<el-option v-if="form.shipperPartySnapshot && !availableShippers.some((party) => party.id === form.shipperPartyId)" :label="`${form.shipperPartySnapshot.name} · 历史快照`" :value="form.shipperPartyId" disabled />
<el-option v-for="party in availableShippers" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" />
</el-select>
</el-form-item>
<div v-if="form.shipperPartySnapshot" class="party-snapshot-fields">
<div class="party-name"><strong>{{ form.shipperPartySnapshot.name }}</strong><span>{{ form.shipperPartySnapshot.code }}</span></div>
<el-form-item label="联系人" prop="shipperPartySnapshot.contactName" required :rules="requiredTextRules('请输入发货联系人姓名')"><el-input v-model="form.shipperPartySnapshot.contactName" maxlength="50" placeholder="请输入发货联系人姓名" /></el-form-item>
<el-form-item label="联系电话" prop="shipperPartySnapshot.phone" required :rules="requiredTextRules('请输入发货联系电话')"><el-input v-model="form.shipperPartySnapshot.phone" maxlength="30" placeholder="请输入发货联系电话" /></el-form-item>
<el-form-item label="联系邮箱"><el-input v-model="form.shipperPartySnapshot.email" maxlength="100" placeholder="例如:name@example.com" /></el-form-item>
<el-form-item label="本单发货地址" prop="shipperPartySnapshot.address" required :rules="requiredTextRules('请输入本单实际发货地址')"><el-input v-model="form.shipperPartySnapshot.address" maxlength="300" placeholder="请输入本单实际发货地址" /></el-form-item>
<section class="order-form-section prototype-section destination-section">
<div class="order-section-heading"><div><h3>发货与目的地</h3><p>单位信息会保存为本订单快照,后续主数据变更不会影响本单。</p></div></div>
<el-form-item label="目的仓类型" prop="destinationType" required><el-radio-group v-model="form.destinationType" class="segmented-options"><el-radio-button value="FBA">FBA 仓</el-radio-button><el-radio-button value="OVERSEAS">海外仓</el-radio-button><el-radio-button value="PRIVATE">私人仓</el-radio-button></el-radio-group></el-form-item>
<div class="party-select-grid">
<div class="party-panel">
<h4>发货信息</h4>
<el-alert v-if="availableShippers.length === 0" title="暂无可用发货单位" description="请先在平台往来单位中启用“发货单位”类型。" type="warning" :closable="false" show-icon />
<el-form-item label="发货单位" prop="shipperPartyId" required><el-select v-model="form.shipperPartyId" filterable clearable placeholder="请选择发货单位" @change="selectShipper" @clear="clearShipper"><el-option v-if="form.shipperPartySnapshot && !availableShippers.some((party) => party.id === form.shipperPartyId)" :label="`${form.shipperPartySnapshot.name} · 历史快照`" :value="form.shipperPartyId" disabled /><el-option v-for="party in availableShippers" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" /></el-select></el-form-item>
<div v-if="form.shipperPartySnapshot" class="party-contact-grid">
<el-form-item label="联系人" prop="shipperPartySnapshot.contactName" required :rules="requiredTextRules('请输入发货联系人姓名')"><el-input v-model="form.shipperPartySnapshot.contactName" maxlength="50" placeholder="请输入联系人" /></el-form-item>
<el-form-item label="联系电话" prop="shipperPartySnapshot.phone" required :rules="requiredTextRules('请输入发货联系电话')"><el-input v-model="form.shipperPartySnapshot.phone" maxlength="30" placeholder="请输入联系电话" /></el-form-item>
<el-form-item label="发货地址" prop="shipperPartySnapshot.address" required :rules="requiredTextRules('请输入本单实际发货地址')" class="form-span-full"><el-input v-model="form.shipperPartySnapshot.address" maxlength="300" placeholder="请输入本单实际发货地址" /></el-form-item>
</div>
<div v-else class="party-empty">选择后可核对本单联系人和发货地址</div>
</article>
<article class="party-editor">
<header><span><Building2 :size="17" /><strong>收货单位</strong></span><small>必填</small></header>
<el-form-item label="选择收货单位" prop="consigneePartyId" required>
<el-select v-model="form.consigneePartyId" filterable clearable placeholder="请选择已启用的收货单位" @change="selectConsignee" @clear="clearConsignee">
<el-option v-if="form.consigneePartySnapshot && !availableConsignees.some((party) => party.id === form.consigneePartyId)" :label="`${form.consigneePartySnapshot.name} · 历史快照`" :value="form.consigneePartyId" disabled />
<el-option v-for="party in availableConsignees" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" />
</el-select>
</el-form-item>
<div v-if="form.consigneePartySnapshot" class="party-snapshot-fields">
<div class="party-name"><strong>{{ form.consigneePartySnapshot.name }}</strong><span>{{ form.consigneePartySnapshot.code }}</span></div>
<el-form-item label="联系人" prop="consigneePartySnapshot.contactName" required :rules="requiredTextRules('请输入收货联系人姓名')"><el-input v-model="form.consigneePartySnapshot.contactName" maxlength="50" placeholder="请输入收货联系人姓名" /></el-form-item>
<el-form-item label="联系电话" prop="consigneePartySnapshot.phone" required :rules="requiredTextRules('请输入收货联系电话')"><el-input v-model="form.consigneePartySnapshot.phone" maxlength="30" placeholder="请输入收货联系电话" /></el-form-item>
<el-form-item label="联系邮箱"><el-input v-model="form.consigneePartySnapshot.email" maxlength="100" placeholder="例如:name@example.com" /></el-form-item>
<el-form-item label="本单收货地址" prop="consigneePartySnapshot.address" required :rules="requiredTextRules('请输入本单实际收货地址')"><el-input v-model="form.consigneePartySnapshot.address" maxlength="300" placeholder="请输入本单实际收货地址" /></el-form-item>
</div>
<div v-else class="party-empty">选择后可核对本单联系人和收货地址</div>
</article>
<div class="party-panel">
<h4>目的地信息</h4>
<el-alert v-if="availableConsignees.length === 0" title="暂无可用收货单位" description="请先在平台往来单位中启用“收货单位”类型。" type="warning" :closable="false" show-icon />
<el-form-item label="收货单位" prop="consigneePartyId" required><el-select v-model="form.consigneePartyId" filterable clearable placeholder="请选择收货单位" @change="selectConsignee" @clear="clearConsignee"><el-option v-if="form.consigneePartySnapshot && !availableConsignees.some((party) => party.id === form.consigneePartyId)" :label="`${form.consigneePartySnapshot.name} · 历史快照`" :value="form.consigneePartyId" disabled /><el-option v-for="party in availableConsignees" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" /></el-select></el-form-item>
<div v-if="form.consigneePartySnapshot" class="party-contact-grid">
<el-form-item label="收件人" prop="consigneePartySnapshot.contactName" required :rules="requiredTextRules('请输入收货联系人姓名')"><el-input v-model="form.consigneePartySnapshot.contactName" maxlength="50" placeholder="请输入收件人姓名" /></el-form-item>
<el-form-item label="联系电话" prop="consigneePartySnapshot.phone" required :rules="requiredTextRules('请输入收货联系电话')"><el-input v-model="form.consigneePartySnapshot.phone" maxlength="30" placeholder="请输入收件人电话" /></el-form-item>
<el-form-item label="收件地址" prop="consigneePartySnapshot.address" required :rules="requiredTextRules('请输入本单实际收货地址')" class="form-span-full"><el-input v-model="form.consigneePartySnapshot.address" maxlength="300" placeholder="请输入收件详细地址" /></el-form-item>
</div>
</div>
</div>
<div class="destination-fields">
<el-form-item label="目的国代码" prop="destinationCountryCode" required><el-input v-model="form.destinationCountryCode" maxlength="2" placeholder="例如:US" /></el-form-item>
<el-form-item v-if="form.destinationType === 'FBA'" label="亚马逊仓代码" prop="amazonWarehouseCode" required :rules="requiredTextRules('请输入 Amazon 仓库编码')"><el-input v-model="form.amazonWarehouseCode" maxlength="32" placeholder="例如:ONT8" @input="syncAmazonWarehouse" /></el-form-item>
<el-form-item v-else label="目的仓名称" prop="destinationWarehouseName" required><el-input v-model="form.destinationWarehouseName" maxlength="128" placeholder="请输入海外仓或私人仓名称" /></el-form-item>
<el-form-item v-if="form.destinationType === 'FBA'" label="货件编号" prop="shipmentId" required :rules="requiredTextRules('请输入 Shipment ID')"><el-input v-model="form.shipmentId" maxlength="64" placeholder="Shipment ID" /></el-form-item>
<el-form-item label="跟踪编号" prop="referenceNo" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 订单请输入 Reference ID') : undefined"><el-input v-model="form.referenceNo" maxlength="100" :placeholder="form.destinationType === 'FBA' ? 'Reference ID' : '选填,贵司内部跟踪编号'" /></el-form-item>
</div>
</section>
<section class="order-form-section">
<div class="order-section-heading"><div><h3>货物明细</h3><p>总件数、重量和体积将在提交时由服务端汇总。</p></div><el-button @click="addCargoItem"><Plus :size="16" />添加货物</el-button></div>
<article v-for="(item, index) in form.items" :key="item.id" class="cargo-editor-card">
<section class="order-form-section prototype-section cargo-section">
<div class="order-section-heading"><div><h3>货物明细</h3><p>先填写常用字段,申报和整柜信息可按需展开。</p></div><el-button @click="addCargoItem"><Plus :size="16" />添加货物</el-button></div>
<article v-for="(item, index) in form.items" :key="item.id" class="cargo-entry">
<header><strong>货物 {{ index + 1 }}</strong><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="16" />删除</el-button></header>
<div class="cargo-form-grid">
<div class="cargo-primary-grid">
<el-form-item label="SKU" :prop="`items.${index}.sku`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入客户 SKU') : undefined"><el-input v-model="item.sku" placeholder="SKU NO." /></el-form-item>
<el-form-item label="中文品名" :prop="`items.${index}.cargoNameCn`" required :rules="requiredTextRules('请输入中文品名')"><el-input v-model="item.cargoNameCn" placeholder="例如:蓝牙音箱" /></el-form-item>
<el-form-item label="英文品名"><el-input v-model="item.cargoNameEn" placeholder="例如:Bluetooth speakers" /></el-form-item>
<el-form-item label="客户 SKU" :prop="`items.${index}.sku`" :required="form.destinationType === 'FBA'" :rules="form.destinationType === 'FBA' ? requiredTextRules('FBA 货物请输入客户 SKU') : undefined"><el-input v-model="item.sku" placeholder="例如:SKU-001" /></el-form-item>
<el-form-item v-if="form.destinationType === 'FBA'" label="UPC" :prop="`items.${index}.upc`" required :rules="requiredTextRules('FBA 货物请输入 UPC')"><el-input v-model="item.upc" maxlength="32" placeholder="例如:012345678905" /></el-form-item>
<el-form-item label="英文品名"><el-input v-model="item.cargoNameEn" placeholder="Bluetooth speakers" /></el-form-item>
<el-form-item v-if="form.destinationType === 'FBA'" label="UPC" :prop="`items.${index}.upc`" required :rules="requiredTextRules('FBA 货物请输入 UPC')"><el-input v-model="item.upc" maxlength="32" placeholder="UPC NO." /></el-form-item>
<el-form-item label="包装类型" :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>
<el-form-item label="HS Code"><el-input v-model="item.hsCode" maxlength="20" placeholder="例如:8518210000" /></el-form-item>
<el-form-item label="件数" :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('件数必须为大于 0 的整数', true)"><el-input-number v-model="item.pieces" :min="1" controls-position="right" /></el-form-item>
<el-form-item label="总数量" :prop="`items.${index}.pieces`" required :rules="positiveNumberRules('件数必须为大于 0 的整数', true)"><el-input-number v-model="item.pieces" :min="1" controls-position="right" /></el-form-item>
<el-form-item label="箱数"><el-input-number v-model="item.packageCount" :min="0" controls-position="right" /></el-form-item>
<el-form-item label="毛重 (kg)" :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="2" controls-position="right" /></el-form-item>
<el-form-item label="体积 (m³)" :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="3" controls-position="right" /></el-form-item>
<el-form-item label="总毛重 kg" :prop="`items.${index}.grossWeightKg`" required :rules="positiveNumberRules('毛重必须大于 0')"><el-input-number v-model="item.grossWeightKg" :min="0" :precision="2" controls-position="right" /></el-form-item>
<el-form-item label="总体积 m³" :prop="`items.${index}.volumeCbm`" required :rules="positiveNumberRules('体积必须大于 0')"><el-input-number v-model="item.volumeCbm" :min="0" :precision="3" controls-position="right" /></el-form-item>
</div>
<el-collapse class="cargo-more"><el-collapse-item title="更多申报信息"><div class="cargo-secondary-grid">
<el-form-item label="HS Code"><el-input v-model="item.hsCode" maxlength="20" placeholder="例如:8518210000" /></el-form-item>
<el-form-item label="申报货值"><el-input-number v-model="item.declaredValue" :min="0" :precision="2" controls-position="right" /></el-form-item>
<el-form-item label="币种"><el-input v-model="item.currency" maxlength="3" placeholder="例如:USD" /></el-form-item>
<el-form-item label="危险品"><el-switch v-model="item.dangerousGoods" /></el-form-item>
<template v-if="form.transportMode === 'FCL'">
<el-form-item label="箱型" :prop="`items.${index}.containerType`" required :rules="requiredSelectRules('请选择箱型')"><el-select v-model="item.containerType"><el-option v-for="type in ['20GP', '40GP', '40HQ']" :key="type" :label="type" :value="type" /></el-select></el-form-item>
<el-form-item label="柜量" :prop="`items.${index}.containerCount`" required :rules="positiveNumberRules('柜量必须为大于 0 的整数', true)"><el-input-number v-model="item.containerCount" :min="0" controls-position="right" /></el-form-item>
</template>
<el-form-item label="货物备注" class="cargo-remark"><el-input v-model="item.remark" maxlength="300" placeholder="选填,例如:注意防潮" /></el-form-item>
</div>
<template v-if="form.transportMode === 'FCL'"><el-form-item label="箱型" :prop="`items.${index}.containerType`" required :rules="requiredSelectRules('请选择箱型')"><el-select v-model="item.containerType"><el-option v-for="type in ['20GP', '40GP', '40HQ']" :key="type" :label="type" :value="type" /></el-select></el-form-item><el-form-item label="柜量" :prop="`items.${index}.containerCount`" required :rules="positiveNumberRules('柜量必须为大于 0 的整数', true)"><el-input-number v-model="item.containerCount" :min="0" controls-position="right" /></el-form-item></template>
<el-form-item label="货物备注" class="form-span-full"><el-input v-model="item.remark" maxlength="300" placeholder="选填,例如:注意防潮" /></el-form-item>
</div></el-collapse-item></el-collapse>
</article>
</section>
<section class="order-form-section">
<h3>订单备注</h3>
<el-form-item label="客户备注"><el-input v-model="form.customerRemark" type="textarea" :rows="3" maxlength="1000" show-word-limit placeholder="平台工作人员可以看到该备注" /></el-form-item>
<p class="cargo-tip">FBA 货物必须填写 SKU、UPC、货件编号和跟踪编号;提交前请核对重量与体积。</p>
</section>
</el-form>
<template #footer><div class="editor-actions"><el-button :disabled="saving" @click="closeEditor">返回</el-button><el-button v-if="editingId ? canEditOrder : canCreateOrder" :loading="saving" @click="saveDraft">保存草稿</el-button><el-button v-if="auth.hasPermission('order:first-leg:submit')" type="primary" :loading="saving" @click="submitOrder"><Send :size="16" />{{ companyApproved ? '提交审核' : '提交前需完成资料审核' }}</el-button></div></template>
<template #footer><div class="editor-actions"><el-button :disabled="saving" @click="closeEditor">返回</el-button><el-button v-if="editingId ? canEditOrder : canCreateOrder" :loading="saving" @click="saveDraft">保存草稿</el-button><el-button v-if="auth.hasPermission('order:first-leg:submit')" type="primary" :loading="saving" @click="submitOrder"><Send :size="16" />{{ companyApproved ? '提交订单' : '提交前需完成资料审核' }}</el-button></div></template>
</el-drawer>
<el-drawer v-model="detailOpen" class="first-leg-detail" title="订单详情" size="680px">
@@ -761,33 +760,38 @@ async function searchOrders(model: QueryModel) {
.order-form-section + .order-form-section { margin-top: 24px; padding-top: 22px; border-top: 1px solid var(--border); }
.order-form-section h3 { margin: 0 0 16px; color: var(--navy); font-size: 16px; }
.order-form-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 18px; }
.order-form-grid > *, .cargo-form-grid > * { min-width: 0; }
.order-form-grid :deep(.el-select), .order-form-grid :deep(.el-date-editor), .cargo-form-grid :deep(.el-select), .cargo-form-grid :deep(.el-input-number) { width: 100%; }
.order-form-grid > *, .destination-fields > *, .party-contact-grid > *, .cargo-primary-grid > *, .cargo-secondary-grid > * { min-width: 0; }
.order-form :deep(.el-select), .order-form :deep(.el-date-editor), .order-form :deep(.el-input-number) { width: 100%; }
.form-span-full { grid-column: 1 / -1; }
.prototype-section { padding: 2px 2px 0; }
.segmented-options { display: flex; width: min(100%, 620px); }
.segmented-options :deep(.el-radio-button) { flex: 1; }
.segmented-options :deep(.el-radio-button__inner) { width: 100%; min-height: 42px; padding: 11px 16px; }
.warehouse-options { display: flex; flex-wrap: wrap; gap: 8px; }
.warehouse-options :deep(.el-radio-button__inner) { min-height: 40px; border: 1px solid var(--border); border-radius: 6px !important; box-shadow: none !important; }
.warehouse-options :deep(.el-radio-button.is-active .el-radio-button__inner) { border-color: var(--primary); }
.selected-warehouse { display: flex; gap: 10px; margin-top: -2px; padding: 12px 14px; border: 1px solid #cfe1dd; background: #f3f8f7; color: var(--primary); }
.selected-warehouse svg { flex: 0 0 17px; margin-top: 2px; }
.selected-warehouse div { display: flex; min-width: 0; flex-direction: column; gap: 4px; }
.selected-warehouse span, .selected-warehouse small { color: var(--muted); font-size: 11px; line-height: 1.5; }
.party-editor-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.party-editor { min-width: 0; padding: 16px; border: 1px solid var(--border); background: #f9fbfb; }
.party-editor > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.party-editor > header span { display: flex; min-width: 0; align-items: center; gap: 8px; color: var(--navy); }
.party-editor > header svg { flex: 0 0 17px; color: var(--primary); }
.party-editor > header small { color: var(--muted); font-size: 11px; }
.party-editor :deep(.el-select) { width: 100%; }
.party-snapshot-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.party-name { display: flex; min-width: 0; grid-column: 1 / -1; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.party-name strong { min-width: 0; overflow-wrap: anywhere; color: var(--navy); font-size: 13px; }
.party-name span { flex: 0 0 auto; color: var(--muted); font-size: 10px; }
.party-snapshot-fields .el-form-item:last-child { grid-column: 1 / -1; }
.party-empty { display: grid; min-height: 150px; place-items: center; border-top: 1px solid var(--border); color: var(--muted); font-size: 12px; text-align: center; }
.order-section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.order-section-heading h3 { margin-bottom: 5px; }
.order-section-heading p { margin: 0; color: var(--muted); font-size: 11px; }
.cargo-editor-card { padding: 16px; border: 1px solid var(--border); background: #f9fbfb; }
.cargo-editor-card + .cargo-editor-card { margin-top: 12px; }
.cargo-editor-card header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
.cargo-form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); column-gap: 14px; }
.cargo-remark { grid-column: 1 / -1; }
.party-select-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }
.party-panel { min-width: 0; padding: 16px; border: 1px solid var(--border); background: #fbfcfc; }
.party-panel h4 { margin: 0 0 14px; color: var(--navy); font-size: 14px; }
.party-panel :deep(.el-alert) { margin-bottom: 14px; }
.party-contact-grid, .destination-fields { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 12px; }
.destination-fields { margin-top: 16px; }
.cargo-entry { padding: 16px; border: 1px solid var(--border); background: #fbfcfc; }
.cargo-entry + .cargo-entry { margin-top: 12px; }
.cargo-entry > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.cargo-primary-grid { display: grid; grid-template-columns: repeat(5, minmax(130px, 1fr)); column-gap: 12px; }
.cargo-secondary-grid { display: grid; grid-template-columns: repeat(4, minmax(130px, 1fr)); column-gap: 12px; padding-top: 6px; }
.cargo-more { border-top: 1px solid var(--border); border-bottom: 0; }
.cargo-more :deep(.el-collapse-item__header), .cargo-more :deep(.el-collapse-item__wrap) { background: transparent; }
.cargo-more :deep(.el-collapse-item__content) { padding-bottom: 0; }
.cargo-tip { margin: 12px 0 0; color: var(--muted); font-size: 11px; line-height: 1.55; }
.order-detail-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; padding-bottom: 20px; border-bottom: 1px solid var(--border); }
.order-detail-heading span { color: var(--muted); font-size: 11px; }
.order-detail-heading h2 { margin: 6px 0; font-size: 21px; }
@@ -841,7 +845,12 @@ async function searchOrders(model: QueryModel) {
.order-table-toolbar .el-button { width: 100%; min-height: 40px; }
.order-eligibility { flex-wrap: wrap; }
.order-eligibility .el-button { width: 100%; min-height: 44px; }
.order-form-grid, .cargo-form-grid, .order-detail-grid, .party-editor-grid, .party-snapshot-fields { grid-template-columns: 1fr; }
.order-form-grid, .order-detail-grid, .party-select-grid, .party-contact-grid, .destination-fields, .cargo-primary-grid, .cargo-secondary-grid { grid-template-columns: 1fr; }
.segmented-options { width: 100%; align-items: stretch; flex-direction: column; }
.segmented-options :deep(.el-radio-button__inner) { border: 1px solid var(--border); border-radius: 6px !important; box-shadow: none !important; }
.warehouse-options { display: grid; grid-template-columns: 1fr; }
.warehouse-options :deep(.el-radio-button), .warehouse-options :deep(.el-radio-button__inner) { width: 100%; }
.form-span-full { grid-column: auto; }
.editor-actions :deep(.el-button) { min-height: 44px; }
.desktop-order-table { display: none; }
.order-load-state { min-height: 240px; grid-template-columns: 1fr; justify-items: start; }
@@ -861,7 +870,6 @@ async function searchOrders(model: QueryModel) {
.mobile-order-card footer { align-items: flex-end; padding-top: 10px; border-top: 1px solid var(--border); }
.mobile-order-card footer > div { display: flex; }
.order-detail-grid div:nth-child(odd) { border-right: 0; }
.party-name, .party-snapshot-fields .el-form-item:last-child { grid-column: auto; }
.express-information { grid-template-columns: auto 1fr; }
.express-information > div:last-child { grid-column: 2; }
.cargo-summary { grid-template-columns: repeat(2, minmax(0, 1fr)); }