feat: 完成客户端联调与通用列表组件

This commit is contained in:
2026-08-01 12:17:33 +08:00
parent be7675954a
commit beb86d53e7
31 changed files with 1326 additions and 378 deletions
+1
View File
@@ -79,6 +79,7 @@ export interface FirstLegOrderCargoPayload {
dangerousGoods?: boolean dangerousGoods?: boolean
cargoRemark?: string cargoRemark?: string
skuNo?: string skuNo?: string
upcNo?: string
cargoNameEn?: string cargoNameEn?: string
totalQuantity?: number totalQuantity?: number
netWeightKg?: number netWeightKg?: number
+14 -1
View File
@@ -14,7 +14,7 @@ interface RetryableRequest extends InternalAxiosRequestConfig {
} }
function unwrap<T>(envelope: ApiEnvelope<T>) { function unwrap<T>(envelope: ApiEnvelope<T>) {
if (envelope.code !== '0') { if (envelope.code !== '200') {
throw new ApiError(envelope.message || '请求失败', envelope.code, undefined, asFieldErrors(envelope.data), envelope.traceId) throw new ApiError(envelope.message || '请求失败', envelope.code, undefined, asFieldErrors(envelope.data), envelope.traceId)
} }
return envelope.data return envelope.data
@@ -40,6 +40,18 @@ function mapError(error: unknown) {
} }
let refreshPromise: Promise<TokenPair> | null = null let refreshPromise: Promise<TokenPair> | null = null
let redirectingToLogin = false
function redirectToLoginAfterSessionExpiry() {
if (redirectingToLogin || typeof window === 'undefined') return
const { pathname, search, hash } = window.location
if (pathname === '/login' || pathname === '/register') return
redirectingToLogin = true
const redirect = `${pathname}${search}${hash}`
window.location.replace(`/login?redirect=${encodeURIComponent(redirect)}`)
}
async function refreshSession() { async function refreshSession() {
const refreshToken = getRefreshToken() const refreshToken = getRefreshToken()
@@ -85,6 +97,7 @@ transport.interceptors.response.use(
if (mappedError.status === 401 || mappedError.code === 'A0401') { if (mappedError.status === 401 || mappedError.code === 'A0401') {
clearSessionTokens() clearSessionTokens()
window.dispatchEvent(new CustomEvent('cc-client-session-expired')) window.dispatchEvent(new CustomEvent('cc-client-session-expired'))
redirectToLoginAfterSessionExpiry()
} }
throw mappedError throw mappedError
} }
+6
View File
@@ -11,8 +11,12 @@ export {}
/* prettier-ignore */ /* prettier-ignore */
declare module 'vue' { declare module 'vue' {
export interface GlobalComponents { export interface GlobalComponents {
AppDialog: typeof import('./components/common/AppDialog.vue')['default']
AppDrawer: typeof import('./components/common/AppDrawer.vue')['default']
ClientSidebarMenu: typeof import('./components/ClientSidebarMenu.vue')['default'] ClientSidebarMenu: typeof import('./components/ClientSidebarMenu.vue')['default']
ClientSidebarMenuNode: typeof import('./components/ClientSidebarMenuNode.vue')['default'] ClientSidebarMenuNode: typeof import('./components/ClientSidebarMenuNode.vue')['default']
CopyButton: typeof import('./components/common/CopyButton.vue')['default']
DataTable: typeof import('./components/common/DataTable.vue')['default']
ElButton: typeof import('element-plus/es')['ElButton'] ElButton: typeof import('element-plus/es')['ElButton']
ElCheckbox: typeof import('element-plus/es')['ElCheckbox'] ElCheckbox: typeof import('element-plus/es')['ElCheckbox']
ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup'] ElCheckboxGroup: typeof import('element-plus/es')['ElCheckboxGroup']
@@ -37,6 +41,8 @@ declare module 'vue' {
ElTimeline: typeof import('element-plus/es')['ElTimeline'] ElTimeline: typeof import('element-plus/es')['ElTimeline']
ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem'] ElTimelineItem: typeof import('element-plus/es')['ElTimelineItem']
ElUpload: typeof import('element-plus/es')['ElUpload'] ElUpload: typeof import('element-plus/es')['ElUpload']
LoadErrorState: typeof import('./components/common/LoadErrorState.vue')['default']
QueryToolbar: typeof import('./components/common/QueryToolbar.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView'] RouterView: typeof import('vue-router')['RouterView']
} }
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed } from 'vue'
export type DialogConfirmType = 'primary' | 'success' | 'warning' | 'danger' | 'info'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
title: string
width?: string | number
loading?: boolean
confirmLoading?: boolean
confirmDisabled?: boolean
confirmText?: string
cancelText?: string
confirmType?: DialogConfirmType
showFooter?: boolean
showCancel?: boolean
showConfirm?: boolean
showClose?: boolean
closeOnClickModal?: boolean
closeOnPressEscape?: boolean
destroyOnClose?: boolean
lockWhileSubmitting?: boolean
}>(), {
width: 'min(560px, calc(100vw - 28px))',
loading: false,
confirmLoading: false,
confirmDisabled: false,
confirmText: '确定',
cancelText: '取消',
confirmType: 'primary',
showFooter: true,
showCancel: true,
showConfirm: true,
showClose: true,
closeOnClickModal: false,
closeOnPressEscape: true,
destroyOnClose: false,
lockWhileSubmitting: true,
})
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const submittingLocked = computed(() => props.lockWhileSubmitting && props.confirmLoading)
function closeDialog() {
if (submittingLocked.value) return
open.value = false
emit('cancel')
}
</script>
<template>
<el-dialog
v-bind="$attrs"
v-model="open"
class="app-dialog"
:title="title"
:width="width"
:show-close="showClose && !submittingLocked"
:close-on-click-modal="closeOnClickModal && !submittingLocked"
:close-on-press-escape="closeOnPressEscape && !submittingLocked"
:destroy-on-close="destroyOnClose"
>
<template v-if="$slots.header" #header><slot name="header" /></template>
<div v-loading="loading" class="app-dialog__body" :aria-busy="loading || confirmLoading">
<slot />
</div>
<template v-if="showFooter" #footer>
<slot name="footer" :close="closeDialog" :submitting="confirmLoading">
<div class="app-dialog__footer">
<el-button v-if="showCancel" :disabled="submittingLocked" @click="closeDialog">{{ cancelText }}</el-button>
<el-button
v-if="showConfirm"
:type="confirmType"
:loading="confirmLoading"
:disabled="loading || confirmDisabled"
@click="emit('confirm')"
>{{ confirmText }}</el-button>
</div>
</slot>
</template>
</el-dialog>
</template>
<style scoped>
.app-dialog__body { min-height: 0; }
.app-dialog__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.app-dialog__footer :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) { .app-dialog__footer :deep(.el-button) { min-height: 44px; } }
</style>
+95
View File
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed } from 'vue'
import type { DialogConfirmType } from './AppDialog.vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
title: string
size?: string | number
loading?: boolean
confirmLoading?: boolean
confirmDisabled?: boolean
confirmText?: string
cancelText?: string
confirmType?: DialogConfirmType
showFooter?: boolean
showCancel?: boolean
showConfirm?: boolean
showClose?: boolean
closeOnClickModal?: boolean
closeOnPressEscape?: boolean
destroyOnClose?: boolean
lockWhileSubmitting?: boolean
}>(), {
size: 'min(680px, 94vw)',
loading: false,
confirmLoading: false,
confirmDisabled: false,
confirmText: '确定',
cancelText: '取消',
confirmType: 'primary',
showFooter: true,
showCancel: true,
showConfirm: true,
showClose: true,
closeOnClickModal: false,
closeOnPressEscape: true,
destroyOnClose: false,
lockWhileSubmitting: true,
})
const open = defineModel<boolean>({ default: false })
const emit = defineEmits<{
confirm: []
cancel: []
}>()
const submittingLocked = computed(() => props.lockWhileSubmitting && props.confirmLoading)
function closeDrawer() {
if (submittingLocked.value) return
open.value = false
emit('cancel')
}
</script>
<template>
<el-drawer
v-bind="$attrs"
v-model="open"
class="app-drawer"
:title="title"
:size="size"
:show-close="showClose && !submittingLocked"
:close-on-click-modal="closeOnClickModal && !submittingLocked"
:close-on-press-escape="closeOnPressEscape && !submittingLocked"
:destroy-on-close="destroyOnClose"
>
<template v-if="$slots.header" #header><slot name="header" /></template>
<div v-loading="loading" class="app-drawer__body" :aria-busy="loading || confirmLoading">
<slot />
</div>
<template v-if="showFooter" #footer>
<slot name="footer" :close="closeDrawer" :submitting="confirmLoading">
<div class="app-drawer__footer">
<el-button v-if="showCancel" :disabled="submittingLocked" @click="closeDrawer">{{ cancelText }}</el-button>
<el-button
v-if="showConfirm"
:type="confirmType"
:loading="confirmLoading"
:disabled="loading || confirmDisabled"
@click="emit('confirm')"
>{{ confirmText }}</el-button>
</div>
</slot>
</template>
</el-drawer>
</template>
<style scoped>
:global(.app-drawer) { max-width: calc(100vw - 20px); }
.app-drawer__body { min-height: 0; }
.app-drawer__footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 10px; }
.app-drawer__footer :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) { :global(.app-drawer) { max-width: 94vw; } .app-drawer__footer :deep(.el-button) { min-height: 44px; } }
</style>
+62
View File
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Copy } from '@lucide/vue'
import { ElMessage } from 'element-plus'
import { copyText } from '@/utils/copyText'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
value: string | number | null | undefined
label?: string
buttonText?: string
successText?: string
errorText?: string
disabled?: boolean
notify?: boolean
}>(), {
label: '内容',
buttonText: '复制',
successText: undefined,
errorText: undefined,
disabled: false,
notify: true,
})
const emit = defineEmits<{
copied: [value: string]
'copy-error': [error: unknown]
}>()
const copying = ref(false)
const copyValue = computed(() => props.value == null ? '' : String(props.value))
const isDisabled = computed(() => props.disabled || copying.value || copyValue.value.length === 0)
async function handleCopy() {
if (isDisabled.value) return
copying.value = true
try {
await copyText(copyValue.value)
if (props.notify) ElMessage.success(props.successText || `${props.label}已复制`)
emit('copied', copyValue.value)
} catch (error) {
if (props.notify) ElMessage.error(props.errorText || `${props.label}复制失败,请手动选择复制`)
emit('copy-error', error)
} finally {
copying.value = false
}
}
</script>
<template>
<el-button
v-bind="$attrs"
:disabled="isDisabled"
:aria-label="`复制${label}`"
:title="`复制${label}`"
@click="handleCopy"
>
<Copy :size="14" aria-hidden="true" />
<slot :copying="copying">{{ buttonText }}</slot>
</el-button>
</template>
+180
View File
@@ -0,0 +1,180 @@
<script lang="ts">
export interface DataTableColumn {
key?: string | number
label?: string
prop?: string
property?: string
columnKey?: string
slotName?: string
headerSlotName?: string
[attribute: string]: unknown
}
export type PaginationItem = number | 'ellipsis-start' | 'ellipsis-end'
export function normalizePageSizes(values: readonly number[]) {
const sizes = values
.filter((value) => Number.isFinite(value))
.map((value) => Math.trunc(value))
.filter((value) => value > 0)
const uniqueSizes = [...new Set(sizes)]
return uniqueSizes.length > 0 ? uniqueSizes : [10]
}
export function normalizePage(value: number, pageCount: number) {
const maximum = Number.isFinite(pageCount) && pageCount > 0 ? Math.trunc(pageCount) : 1
if (!Number.isFinite(value)) return 1
return Math.min(Math.max(Math.trunc(value), 1), maximum)
}
export function createPaginationItems(page: number, pageCount: number): PaginationItem[] {
if (pageCount <= 0) return []
if (pageCount <= 7) return Array.from({ length: pageCount }, (_, index) => index + 1)
if (page <= 4) return [1, 2, 3, 4, 5, 'ellipsis-end', pageCount]
if (page >= pageCount - 3) {
return [1, 'ellipsis-start', pageCount - 4, pageCount - 3, pageCount - 2, pageCount - 1, pageCount]
}
return [1, 'ellipsis-start', page - 1, page, page + 1, 'ellipsis-end', pageCount]
}
</script>
<script setup lang="ts">
import { computed, useSlots, watch } from 'vue'
import { ChevronLeft, ChevronRight } from '@lucide/vue'
defineOptions({ inheritAttrs: false })
const props = withDefaults(defineProps<{
data: object[]
columns: DataTableColumn[]
loading?: boolean
emptyText?: string
total?: number
pageSizes?: number[]
}>(), {
loading: false,
emptyText: '暂无数据',
total: undefined,
pageSizes: () => [10, 20, 50, 100],
})
const page = defineModel<number>('page', { default: 1 })
const pageSize = defineModel<number>('pageSize', { default: 10 })
const slots = useSlots()
const normalizedTotal = computed(() => {
const value = props.total ?? 0
return Number.isFinite(value) && value > 0 ? Math.trunc(value) : 0
})
const normalizedPageSizes = computed(() => normalizePageSizes(props.pageSizes))
const effectivePageSize = computed(() => (
normalizedPageSizes.value.includes(pageSize.value) ? pageSize.value : normalizedPageSizes.value[0]
))
const pageCount = computed(() => (
normalizedTotal.value === 0 ? 0 : Math.ceil(normalizedTotal.value / effectivePageSize.value)
))
const currentPage = computed(() => normalizePage(page.value, pageCount.value))
const paginationItems = computed(() => createPaginationItems(currentPage.value, pageCount.value))
watch([pageSize, normalizedPageSizes], ([value, sizes]) => {
const integerValue = Number.isFinite(value) ? Math.trunc(value) : sizes[0]
const nextValue = sizes.includes(integerValue) ? integerValue : sizes[0]
if (value === nextValue) return
pageSize.value = nextValue
page.value = 1
}, { immediate: true })
watch([page, pageCount], ([value, count]) => {
const nextValue = normalizePage(value, count)
if (value !== nextValue) page.value = nextValue
}, { immediate: true })
function columnAttrs({ key, slotName, headerSlotName, ...attrs }: DataTableColumn) {
return attrs
}
function columnRenderKey(column: DataTableColumn, index: number) {
return String(column.key ?? column.columnKey ?? column.prop ?? column.property ?? index)
}
function hasSlot(name?: string) {
return Boolean(name && slots[name])
}
function updatePageSize(value: number) {
const nextValue = normalizedPageSizes.value.includes(value) ? value : normalizedPageSizes.value[0]
if (nextValue === pageSize.value) return
pageSize.value = nextValue
page.value = 1
}
function setPage(value: number) {
page.value = normalizePage(value, pageCount.value)
}
</script>
<template>
<div class="data-table">
<el-table v-bind="$attrs" v-loading="loading" :data="data" :empty-text="emptyText" :aria-busy="loading">
<el-table-column
v-for="(column, index) in columns"
:key="columnRenderKey(column, index)"
v-bind="columnAttrs(column)"
>
<template v-if="hasSlot(column.headerSlotName)" #header="scope">
<slot :name="column.headerSlotName" v-bind="scope" />
</template>
<template v-if="hasSlot(column.slotName)" #default="scope">
<slot :name="column.slotName" v-bind="scope" />
</template>
</el-table-column>
<template v-if="$slots.empty" #empty><slot name="empty" /></template>
</el-table>
<footer v-if="total !== undefined" class="data-table__pagination" aria-label="表格分页">
<div class="data-table__pagination-left">
<slot name="pagination-left" :total="normalizedTotal" :page="currentPage" :page-size="effectivePageSize" :page-count="pageCount">
<span v-if="pageCount > 0">共 {{ normalizedTotal }} 条 · 第 {{ currentPage }} / {{ pageCount }} 页</span>
<span v-else>共 0 条</span>
</slot>
</div>
<div class="data-table__pagination-controls">
<label class="data-table__page-size">
<span>每页</span>
<el-select :model-value="effectivePageSize" :disabled="loading" aria-label="每页条数" @update:model-value="updatePageSize">
<el-option v-for="size in normalizedPageSizes" :key="size" :label="String(size)" :value="size" />
</el-select>
<span>条</span>
</label>
<nav v-if="pageCount > 0" class="data-table__page-buttons" aria-label="页码">
<el-button class="data-table__page-button" :disabled="loading || currentPage <= 1" aria-label="上一页" title="上一页" @click="setPage(currentPage - 1)"><ChevronLeft :size="16" /></el-button>
<template v-for="item in paginationItems" :key="item">
<span v-if="typeof item !== 'number'" class="data-table__page-ellipsis" aria-hidden="true">...</span>
<el-button
v-else
class="data-table__page-button"
:type="item === currentPage ? 'primary' : undefined"
:disabled="loading"
:aria-current="item === currentPage ? 'page' : undefined"
:aria-label="`第 ${item} 页`"
:title="`第 ${item} 页`"
@click="setPage(item)"
>{{ item }}</el-button>
</template>
<el-button class="data-table__page-button" :disabled="loading || currentPage >= pageCount" aria-label="下一页" title="下一页" @click="setPage(currentPage + 1)"><ChevronRight :size="16" /></el-button>
</nav>
</div>
</footer>
</div>
</template>
<style scoped>
.data-table { min-width: 0; }
.data-table__pagination { display: flex; min-height: 58px; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; border-top: 1px solid var(--border); color: var(--muted); font-size: 12px; }
.data-table__pagination-left { display: flex; min-width: 0; flex: 1; align-items: center; gap: 8px; }
.data-table__pagination-controls, .data-table__page-size, .data-table__page-buttons { display: flex; align-items: center; gap: 8px; }
.data-table__pagination-controls { min-width: 0; flex-wrap: wrap; justify-content: flex-end; }
.data-table__page-size :deep(.el-select) { width: 82px; }
.data-table__page-buttons { gap: 4px; }
.data-table__page-button { width: 32px; min-width: 32px; height: 32px; padding: 0; margin-left: 0; }
.data-table__page-ellipsis { display: inline-flex; width: 24px; height: 32px; align-items: center; justify-content: center; }
@media (max-width: 720px) { .data-table__pagination { align-items: flex-start; flex-direction: column; } .data-table__pagination-left, .data-table__pagination-controls, .data-table__page-buttons { width: 100%; } .data-table__pagination-controls { justify-content: flex-start; } .data-table__page-buttons { flex-wrap: wrap; } .data-table__page-button { width: 44px; min-width: 44px; height: 44px; } .data-table__page-ellipsis { height: 44px; } }
</style>
+48
View File
@@ -0,0 +1,48 @@
<script setup lang="ts">
import { CircleAlert } from '@lucide/vue'
withDefaults(defineProps<{
title: string
message?: string
loading?: boolean
retryText?: string
showRetry?: boolean
compact?: boolean
}>(), {
message: '',
loading: false,
retryText: '重新加载',
showRetry: true,
compact: false,
})
defineEmits<{ retry: [] }>()
</script>
<template>
<section class="load-error-state" :class="{ 'is-compact': compact }" role="alert">
<CircleAlert class="load-error-state__icon" :size="20" aria-hidden="true" />
<div class="load-error-state__content">
<strong>{{ title }}</strong>
<p v-if="message">{{ message }}</p>
<slot name="details" />
</div>
<div v-if="showRetry || $slots.actions" class="load-error-state__actions">
<slot name="actions">
<el-button type="primary" plain :loading="loading" @click="$emit('retry')">{{ retryText }}</el-button>
</slot>
</div>
</section>
</template>
<style scoped>
.load-error-state { display: flex; min-width: 0; align-items: flex-start; gap: 12px; padding: 16px; border: 1px solid var(--el-color-danger-light-7); border-radius: 8px; background: var(--el-color-danger-light-9); color: var(--el-text-color-primary); }
.load-error-state.is-compact { padding: 12px; }
.load-error-state__icon { flex: 0 0 auto; margin-top: 1px; color: var(--el-color-danger); }
.load-error-state__content { min-width: 0; flex: 1; }
.load-error-state__content strong { display: block; font-size: 14px; line-height: 1.5; }
.load-error-state__content p { margin: 4px 0 0; color: var(--el-text-color-secondary); font-size: 13px; line-height: 1.6; overflow-wrap: anywhere; }
.load-error-state__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; gap: 8px; }
.load-error-state__actions :deep(.el-button) { margin-left: 0; }
@media (max-width: 720px) { .load-error-state { flex-wrap: wrap; } .load-error-state__content { flex-basis: calc(100% - 36px); } .load-error-state__actions { width: 100%; padding-left: 32px; } .load-error-state__actions :deep(.el-button) { min-height: 44px; } }
</style>
+133
View File
@@ -0,0 +1,133 @@
<script setup lang="ts">
import { Search, RotateCcw } from '@lucide/vue'
defineOptions({ inheritAttrs: false })
export type QueryFieldType = 'input' | 'select' | 'date' | 'daterange'
export type QueryValue = string | number | boolean | null | undefined | string[] | number[] | Date | Date[]
export type QueryModel = Record<string, QueryValue>
export interface QueryOption {
label: string
value: string | number | boolean
disabled?: boolean
}
export interface QueryField {
key: string
type: QueryFieldType
placeholder?: string
options?: QueryOption[]
props?: Record<string, unknown>
}
const props = withDefaults(defineProps<{
ariaLabel: string
fields: QueryField[]
searchText?: string
resetText?: string
searchOnReset?: boolean
}>(), {
searchText: '查询',
resetText: '重置',
searchOnReset: true,
})
const model = defineModel<QueryModel>({ required: true })
const emit = defineEmits<{
search: [model: QueryModel]
reset: [model: QueryModel]
}>()
function updateField(key: string, value: QueryValue) {
model.value = { ...model.value, [key]: value }
}
function defaultPlaceholder(field: QueryField) {
if (field.type === 'input') return '请输入关键词'
if (field.type === 'select') return '全部'
return field.type === 'daterange' ? '请选择日期范围' : '请选择日期'
}
function datePickerType(field: QueryField): 'date' | 'daterange' {
return field.type === 'daterange' ? 'daterange' : 'date'
}
function resetValue(field: QueryField): QueryValue {
return field.type === 'daterange' ? [] : ''
}
function search() {
emit('search', model.value)
}
function reset() {
const next = { ...model.value }
for (const field of props.fields) next[field.key] = resetValue(field)
model.value = next
emit('reset', next)
if (props.searchOnReset) emit('search', next)
}
defineExpose({ search, reset })
</script>
<template>
<section
v-bind="$attrs"
class="query-toolbar"
:aria-label="ariaLabel"
>
<div class="query-toolbar__filters">
<template v-for="field in fields" :key="field.key">
<el-input
v-if="field.type === 'input'"
:model-value="model[field.key] as string"
:placeholder="field.placeholder || defaultPlaceholder(field)"
clearable
v-bind="field.props"
@update:model-value="updateField(field.key, $event)"
>
<template #prefix><Search :size="16" /></template>
</el-input>
<el-select
v-else-if="field.type === 'select'"
:model-value="model[field.key] as string | number | boolean | undefined"
:placeholder="field.placeholder || defaultPlaceholder(field)"
clearable
v-bind="field.props"
@update:model-value="updateField(field.key, $event)"
>
<el-option v-for="option in field.options || []" :key="String(option.value)" :label="option.label" :value="option.value" :disabled="option.disabled" />
</el-select>
<el-date-picker
v-else
:model-value="model[field.key] as string | string[] | undefined"
:type="datePickerType(field)"
:placeholder="field.placeholder || defaultPlaceholder(field)"
clearable
value-format="YYYY-MM-DD"
range-separator="至"
start-placeholder="开始日期"
end-placeholder="结束日期"
v-bind="field.props"
@update:model-value="updateField(field.key, $event)"
/>
</template>
</div>
<div class="query-toolbar__actions">
<el-button type="primary" @click="search"><Search :size="16" />{{ searchText }}</el-button>
<el-button @click="reset"><RotateCcw :size="16" />{{ resetText }}</el-button>
<slot name="actions" />
</div>
</section>
</template>
<style scoped>
.query-toolbar { display: flex; align-items: flex-end; justify-content: flex-start; flex-wrap: wrap; gap: 10px; padding: 16px; border: 1px solid var(--border); background: #fff; }
.query-toolbar__filters { display: flex; flex: 0 1 auto; flex-wrap: wrap; gap: 10px; }
.query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 260px; max-width: 100%; min-width: 0; }
.query-toolbar__actions { display: flex; flex: 0 0 auto; flex-wrap: wrap; justify-content: flex-start; gap: 8px; }
.query-toolbar__actions :deep(.el-button + .el-button) { margin-left: 0; }
@media (max-width: 720px) { .query-toolbar { align-items: stretch; flex-direction: column; } .query-toolbar__filters { width: 100%; flex-direction: column; } .query-toolbar__filters :deep(.el-input), .query-toolbar__filters :deep(.el-select), .query-toolbar__filters :deep(.el-date-editor) { width: 100%; } .query-toolbar__actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); width: 100%; } .query-toolbar__actions :deep(.el-button) { min-height: 40px; margin-left: 0; } }
</style>
+2 -1
View File
@@ -88,6 +88,7 @@ function savePayload(draft: FirstLegOrderDraft, warehouse: PlatformReceivingWare
dangerousGoods: item.dangerousGoods, dangerousGoods: item.dangerousGoods,
cargoRemark: item.remark || undefined, cargoRemark: item.remark || undefined,
skuNo: item.sku || undefined, skuNo: item.sku || undefined,
upcNo: item.upc || undefined,
totalQuantity: item.pieces, totalQuantity: item.pieces,
})), })),
pickupMethod: draft.pickupMethod, pickupMethod: draft.pickupMethod,
@@ -158,7 +159,7 @@ function mapOrder(order: MerchantFirstLegOrder, warehouses: PlatformReceivingWar
expressCompanyName: order.expressCompany ?? '', trackingNumber: order.expressTrackingNo ?? '', priority: 'NORMAL', customerRemark: order.customerRemark ?? '', operatorName: '', expressCompanyName: order.expressCompany ?? '', trackingNumber: order.expressTrackingNo ?? '', priority: 'NORMAL', customerRemark: order.customerRemark ?? '', operatorName: '',
status: clientStatus(order.status), version: order.version ?? 0, status: clientStatus(order.status), version: order.version ?? 0,
items: (detail?.cargoes ?? []).map((item) => ({ items: (detail?.cargoes ?? []).map((item) => ({
id: String(item.id), sku: item.skuNo ?? '', cargoNameCn: item.cargoName, cargoNameEn: item.cargoNameEn ?? '', id: String(item.id), sku: item.skuNo ?? '', upc: item.upcNo ?? '', cargoNameCn: item.cargoName, cargoNameEn: item.cargoNameEn ?? '',
pieces: item.totalQuantity ?? item.packageCount, packageCount: item.packageCount, grossWeightKg: item.grossWeightKg, pieces: item.totalQuantity ?? item.packageCount, packageCount: item.packageCount, grossWeightKg: item.grossWeightKg,
volumeCbm: item.volumeCbm, containerType: '', containerCount: 0, remark: item.cargoRemark ?? '', volumeCbm: item.volumeCbm, containerType: '', containerCount: 0, remark: item.cargoRemark ?? '',
packageType: item.packageType, hsCode: item.hsCode ?? '', declaredValue: item.declaredValue ?? 0, packageType: item.packageType, hsCode: item.hsCode ?? '', declaredValue: item.declaredValue ?? 0,
+6
View File
@@ -0,0 +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; }
* { box-sizing: border-box; }
html, body, #app { min-width: 320px; min-height: 100%; margin: 0; }
body { min-height: 100vh; background: #f5f7f8; }
button, input { font: inherit; }
a { color: inherit; }
+3 -53
View File
@@ -1,56 +1,5 @@
: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; } @import './foundation.css';
* { box-sizing: border-box; } @import './shell.css';
html, body, #app { min-width: 320px; min-height: 100%; margin: 0; }
body { min-height: 100vh; background: #f5f7f8; }
button, input { font: inherit; }
a { color: inherit; }
.client-shell { min-height: 100vh; overflow-x: hidden; }
.top-header { position: fixed; inset: 0 0 auto 0; z-index: 20; display: flex; width: 100%; max-width: 100vw; height: 68px; align-items: center; padding: 0 28px; border-bottom: 1px solid var(--border); background: #fff; }
.client-brand { display: flex; width: 232px; min-width: 0; align-items: center; color: var(--navy); text-decoration: none; }
.client-brand-logo { display: block; width: 150px; height: 54px; object-fit: contain; object-position: left center; }
.tenant-name { display: flex; flex: 1; align-items: center; gap: 8px; color: #475467; font-size: 13px; }
.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-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: 68px auto 0 0; display: flex; width: 232px; flex-direction: column; overflow: hidden; padding: 20px 14px; border-right: 1px solid var(--border); background: #fff; }
.client-sidebar nav { display: flex; flex: 1; min-width: 0; min-height: 0; flex-direction: column; overflow-x: hidden; overflow-y: auto; }
.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: 37px; }
.client-nav-node { min-width: 0; }
.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: 8px; color: #52616b; }
.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: #f2f7f6; color: var(--primary); }
.client-sidebar-link[aria-current="page"] { background: #e9f4f2; color: var(--primary); font-weight: 650; }
.client-nav-node.is-active-parent > .client-sidebar-group-trigger { background: #f2f7f6; color: var(--primary); font-weight: 600; }
.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-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-page-heading > button:focus-visible,
.client-sidebar-header button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.client-sidebar-header,
.client-sidebar-scrim { display: none; }
.sidebar-help { display: flex; flex: 0 0 auto; flex-direction: column; gap: 4px; margin-top: 16px; padding: 14px; border-left: 3px solid var(--primary); background: #f2f7f6; }
.sidebar-help strong { font-size: 12px; }
.sidebar-help span { color: var(--muted); font-size: 11px; }
.client-main { width: calc(100% - 232px); max-width: 100%; min-height: 100vh; margin-left: 232px; padding: 92px 28px 40px; }
.client-page-heading { display: flex; min-width: 0; align-items: center; gap: 12px; margin-bottom: 22px; }
.client-page-heading > div { min-width: 0; }
.client-page-heading button { display: none; width: 44px; height: 44px; flex: 0 0 44px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--navy); }
.client-page-heading h1 { margin: 0; font-size: 22px; }
.client-page-heading p { margin: 5px 0 0; color: var(--muted); font-size: 12px; }
.auth-page { min-height: 100vh; background: #eef3f3; } .auth-page { min-height: 100vh; background: #eef3f3; }
.auth-header { display: flex; height: 72px; align-items: center; justify-content: space-between; padding: 0 clamp(24px, 6vw, 90px); border-bottom: 1px solid var(--border); background: #fff; color: var(--muted); font-size: 13px; } .auth-header { display: flex; height: 72px; align-items: center; justify-content: space-between; padding: 0 clamp(24px, 6vw, 90px); border-bottom: 1px solid var(--border); background: #fff; color: var(--muted); font-size: 13px; }
@@ -86,6 +35,7 @@ a { color: inherit; }
.agreement-item { margin-bottom: 8px; } .agreement-item { margin-bottom: 8px; }
.agreement-control { display: flex; min-height: 44px; align-items: center; gap: 4px; flex-wrap: wrap; color: var(--muted); } .agreement-control { display: flex; min-height: 44px; align-items: center; gap: 4px; flex-wrap: wrap; color: var(--muted); }
.agreement-item .el-checkbox { min-height: 44px; white-space: normal; } .agreement-item .el-checkbox { min-height: 44px; white-space: normal; }
.agreement-required { margin-right: 2px; color: var(--el-color-danger); }
.agreement-item button { padding: 0; border: 0; background: transparent; color: var(--primary); cursor: pointer; } .agreement-item button { padding: 0; border: 0; background: transparent; color: var(--primary); cursor: pointer; }
.agreement-item button:hover { text-decoration: underline; } .agreement-item button:hover { text-decoration: underline; }
.agreement-item button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; } .agreement-item button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
+3 -1
View File
@@ -2,7 +2,9 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs' import { readFileSync } from 'node:fs'
import test from 'node:test' import test from 'node:test'
const styles = readFileSync(new URL('./index.css', import.meta.url), 'utf8') const styles = ['foundation.css', 'shell.css', 'index.css']
.map((file) => readFileSync(new URL(file, import.meta.url), 'utf8'))
.join('\n')
const compact = styles.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ') const compact = styles.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, ' ')
const mobileStyles = compact.slice(compact.indexOf('@media (max-width: 720px)')) const mobileStyles = compact.slice(compact.indexOf('@media (max-width: 720px)'))
+46
View File
@@ -0,0 +1,46 @@
.client-shell { min-height: 100vh; overflow-x: hidden; }
.top-header { position: fixed; inset: 0 0 auto 0; z-index: 20; display: flex; width: 100%; max-width: 100vw; height: 68px; align-items: center; padding: 0 28px; border-bottom: 1px solid var(--border); background: #fff; }
.client-brand { display: flex; width: 232px; min-width: 0; align-items: center; color: var(--navy); text-decoration: none; }
.client-brand-logo { display: block; width: 150px; height: 54px; object-fit: contain; object-position: left center; }
.tenant-name { display: flex; flex: 1; align-items: center; gap: 8px; color: #475467; font-size: 13px; }
.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-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: 68px auto 0 0; display: flex; width: 232px; flex-direction: column; overflow: hidden; padding: 20px 14px; border-right: 1px solid var(--border); background: #fff; }
.client-sidebar nav { display: flex; flex: 1; min-width: 0; min-height: 0; flex-direction: column; overflow-x: hidden; overflow-y: auto; }
.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: 37px; }
.client-nav-node { min-width: 0; }
.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: 8px; color: #52616b; }
.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: #f2f7f6; color: var(--primary); }
.client-sidebar-link[aria-current="page"] { background: #e9f4f2; color: var(--primary); font-weight: 650; }
.client-nav-node.is-active-parent > .client-sidebar-group-trigger { background: #f2f7f6; color: var(--primary); font-weight: 600; }
.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-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-page-heading > button:focus-visible,
.client-sidebar-header button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.client-sidebar-header,
.client-sidebar-scrim { display: none; }
.sidebar-help { display: flex; flex: 0 0 auto; flex-direction: column; gap: 4px; margin-top: 16px; padding: 14px; border-left: 3px solid var(--primary); background: #f2f7f6; }
.sidebar-help strong { font-size: 12px; }
.sidebar-help span { color: var(--muted); font-size: 11px; }
.client-main { width: calc(100% - 232px); max-width: 100%; min-height: 100vh; margin-left: 232px; padding: 92px 28px 40px; }
.client-page-heading { display: flex; min-width: 0; align-items: center; gap: 12px; margin-bottom: 22px; }
.client-page-heading > div { min-width: 0; }
.client-page-heading button { display: none; width: 44px; height: 44px; flex: 0 0 44px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--navy); }
.client-page-heading h1 { margin: 0; font-size: 22px; }
.client-page-heading p { margin: 5px 0 0; color: var(--muted); font-size: 12px; }
+1
View File
@@ -11,6 +11,7 @@ export type FirstLegPriority = 'NORMAL' | 'URGENT' | 'EXPEDITED'
export interface FirstLegCargoItem { export interface FirstLegCargoItem {
id: string id: string
sku: string sku: string
upc: string
cargoNameCn: string cargoNameCn: string
cargoNameEn: string cargoNameEn: string
pieces: number pieces: number
+35
View File
@@ -0,0 +1,35 @@
export async function copyText(value: string) {
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
try {
await navigator.clipboard.writeText(value)
return
} catch {
// In insecure development contexts the Clipboard API may exist but reject.
}
}
if (typeof document === 'undefined' || !document.body) {
throw new Error('Clipboard is unavailable')
}
const textarea = document.createElement('textarea')
textarea.value = value
textarea.readOnly = true
textarea.setAttribute('aria-hidden', 'true')
Object.assign(textarea.style, {
position: 'fixed',
inset: '0 auto auto -9999px',
opacity: '0',
pointerEvents: 'none',
})
document.body.appendChild(textarea)
textarea.select()
try {
if (typeof document.execCommand !== 'function' || !document.execCommand('copy')) {
throw new Error('Clipboard copy failed')
}
} finally {
textarea.remove()
}
}
+17 -1
View File
@@ -50,7 +50,7 @@ function validDraft() {
destinationType: 'FBA', destinationType: 'FBA',
amazonWarehouseCode: 'ONT8', amazonWarehouseCode: 'ONT8',
shipmentId: 'FBA-001', shipmentId: 'FBA-001',
items: [{ ...createEmptyCargoItem('cargo-1'), cargoNameCn: '家居用品', pieces: 100, grossWeightKg: 1200, volumeCbm: 3.5, containerType: '40HQ', containerCount: 1 }], items: [{ ...createEmptyCargoItem('cargo-1'), sku: 'SKU-001', upc: '012345678905', cargoNameCn: '家居用品', pieces: 100, grossWeightKg: 1200, volumeCbm: 3.5, containerType: '40HQ', containerCount: 1 }],
} }
} }
@@ -97,6 +97,22 @@ test('requires enabled-party snapshots and order addresses before submission', (
assert.ok(errors.includes('请填写本单收货地址')) assert.ok(errors.includes('请填写本单收货地址'))
}) })
test('requires FBA reference, warehouse, shipment, SKU and UPC fields', () => {
const draft = validDraft()
draft.referenceNo = ''
draft.amazonWarehouseCode = ''
draft.shipmentId = ''
draft.items[0].sku = ''
draft.items[0].upc = ''
const errors = validateFirstLegOrderDraft(draft)
assert.ok(errors.includes('FBA 订单请填写客户参考号'))
assert.ok(errors.includes('FBA 订单请填写 Amazon 仓库编码'))
assert.ok(errors.includes('FBA 订单请填写 Shipment ID'))
assert.ok(errors.includes('第 1 条货物缺少客户 SKU'))
assert.ok(errors.includes('第 1 条货物缺少 UPC'))
})
test('does not require container details for non-FCL cargo', () => { test('does not require container details for non-FCL cargo', () => {
const draft = validDraft() const draft = validDraft()
draft.transportMode = 'AIR' draft.transportMode = 'AIR'
+14 -1
View File
@@ -41,6 +41,7 @@ export function createEmptyCargoItem(id = `cargo-${Date.now()}`): FirstLegCargoI
return { return {
id, id,
sku: '', sku: '',
upc: '',
cargoNameCn: '', cargoNameCn: '',
cargoNameEn: '', cargoNameEn: '',
pieces: 1, pieces: 1,
@@ -63,20 +64,32 @@ export function validateFirstLegOrderDraft(draft: FirstLegOrderDraft) {
if (!draft.origin.trim()) errors.push('请填写起运地') if (!draft.origin.trim()) errors.push('请填写起运地')
if (!draft.destination.trim()) errors.push('请填写目的地') if (!draft.destination.trim()) errors.push('请填写目的地')
if (!draft.destinationCountryCode.trim()) errors.push('请填写目的国两位代码') if (!/^[A-Za-z]{2}$/.test(draft.destinationCountryCode.trim())) errors.push('请输入两位英文字母的目的国代码')
if (!draft.destinationWarehouseName.trim()) errors.push('请填写目的仓名称') if (!draft.destinationWarehouseName.trim()) errors.push('请填写目的仓名称')
if (!draft.channelCode.trim() || !draft.channelName.trim()) errors.push('请填写渠道编码和渠道名称') if (!draft.channelCode.trim() || !draft.channelName.trim()) errors.push('请填写渠道编码和渠道名称')
if (!draft.receivingWarehouseId) errors.push('请选择平台收货仓') if (!draft.receivingWarehouseId) errors.push('请选择平台收货仓')
if (!draft.shipperPartyId || !draft.shipperPartySnapshot || !draft.shipper.trim()) errors.push('请选择发货单位') if (!draft.shipperPartyId || !draft.shipperPartySnapshot || !draft.shipper.trim()) errors.push('请选择发货单位')
if (draft.shipperPartySnapshot && !draft.shipperPartySnapshot.contactName.trim()) errors.push('请填写发货联系人')
if (draft.shipperPartySnapshot && !draft.shipperPartySnapshot.phone.trim()) errors.push('请填写发货联系电话')
if (draft.shipperPartySnapshot && !draft.shipperPartySnapshot.address.trim()) errors.push('请填写本单发货地址') if (draft.shipperPartySnapshot && !draft.shipperPartySnapshot.address.trim()) errors.push('请填写本单发货地址')
if (!draft.consigneePartyId || !draft.consigneePartySnapshot || !draft.consignee.trim()) errors.push('请选择收货单位') if (!draft.consigneePartyId || !draft.consigneePartySnapshot || !draft.consignee.trim()) errors.push('请选择收货单位')
if (draft.consigneePartySnapshot && !draft.consigneePartySnapshot.contactName.trim()) errors.push('请填写收货联系人')
if (draft.consigneePartySnapshot && !draft.consigneePartySnapshot.phone.trim()) errors.push('请填写收货联系电话')
if (draft.consigneePartySnapshot && !draft.consigneePartySnapshot.address.trim()) errors.push('请填写本单收货地址') if (draft.consigneePartySnapshot && !draft.consigneePartySnapshot.address.trim()) errors.push('请填写本单收货地址')
if (draft.items.length === 0) errors.push('请至少添加一条货物明细') if (draft.items.length === 0) errors.push('请至少添加一条货物明细')
if (draft.destinationType === 'FBA') {
if (!draft.referenceNo.trim()) errors.push('FBA 订单请填写客户参考号')
if (!draft.amazonWarehouseCode.trim()) errors.push('FBA 订单请填写 Amazon 仓库编码')
if (!draft.shipmentId.trim()) errors.push('FBA 订单请填写 Shipment ID')
}
draft.items.forEach((item, index) => { draft.items.forEach((item, index) => {
const line = `第 ${index + 1} 条货物` const line = `第 ${index + 1} 条货物`
if (!item.cargoNameCn.trim()) errors.push(`${line}缺少中文品名`) if (!item.cargoNameCn.trim()) errors.push(`${line}缺少中文品名`)
if (!item.packageType.trim()) errors.push(`${line}缺少包装类型`) if (!item.packageType.trim()) errors.push(`${line}缺少包装类型`)
if (draft.destinationType === 'FBA' && !item.sku.trim()) errors.push(`${line}缺少客户 SKU`)
if (draft.destinationType === 'FBA' && !item.upc.trim()) errors.push(`${line}缺少 UPC`)
if (!Number.isInteger(item.pieces) || item.pieces <= 0) errors.push(`${line}件数必须为正整数`) if (!Number.isInteger(item.pieces) || item.pieces <= 0) errors.push(`${line}件数必须为正整数`)
if (item.grossWeightKg <= 0) errors.push(`${line}毛重必须大于 0`) if (item.grossWeightKg <= 0) errors.push(`${line}毛重必须大于 0`)
if (item.volumeCbm <= 0) errors.push(`${line}体积必须大于 0`) if (item.volumeCbm <= 0) errors.push(`${line}体积必须大于 0`)
+1
View File
@@ -16,6 +16,7 @@ test('registers the account profile route and navigation entry', () => {
test('implements profile view, edit, save and cancel states', () => { test('implements profile view, edit, save and cancel states', () => {
assert.match(viewSource, /基本信息/) assert.match(viewSource, /基本信息/)
assert.doesNotMatch(viewSource, /account-identity/)
assert.match(viewSource, /function startEditing\(\)/) assert.match(viewSource, /function startEditing\(\)/)
assert.match(viewSource, /function cancelEditing\(\)/) assert.match(viewSource, /function cancelEditing\(\)/)
assert.match(viewSource, /async function saveProfile\(\)/) assert.match(viewSource, /async function saveProfile\(\)/)
+1 -28
View File
@@ -4,13 +4,10 @@ import { useRouter } from 'vue-router'
import type { FormInstance, FormRules } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { import {
Building2,
CheckCircle2,
KeyRound, KeyRound,
Pencil, Pencil,
Save, Save,
ShieldCheck, ShieldCheck,
UserRound,
X, X,
} from '@lucide/vue' } from '@lucide/vue'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -170,19 +167,6 @@ async function changePassword() {
<template> <template>
<section class="account-page"> <section class="account-page">
<div class="client-card account-identity">
<div class="account-avatar" aria-hidden="true"><UserRound :size="28" /></div>
<div class="account-identity-copy">
<h2>{{ auth.profile.realName }}</h2>
<p>{{ auth.profile.username }}</p>
</div>
<div class="account-context">
<span><Building2 :size="16" />{{ auth.profile.tenantName }}</span>
<span><ShieldCheck :size="16" />{{ auth.user?.roles.join('、') }}</span>
</div>
<el-tag type="success" effect="plain"><CheckCircle2 :size="14" />账号正常</el-tag>
</div>
<div class="client-card account-workspace"> <div class="client-card account-workspace">
<el-tabs v-model="activeTab" class="account-tabs"> <el-tabs v-model="activeTab" class="account-tabs">
<el-tab-pane name="profile"> <el-tab-pane name="profile">
@@ -206,7 +190,7 @@ async function changePassword() {
<el-input v-model="profileForm.username" disabled /> <el-input v-model="profileForm.username" disabled />
<span class="field-help">账号标识暂不支持在客户端修改</span> <span class="field-help">账号标识暂不支持在客户端修改</span>
</el-form-item> </el-form-item>
<el-form-item label="姓名" prop="realName"><el-input v-model="profileForm.realName" maxlength="64" show-word-limit /></el-form-item> <el-form-item label="姓名" prop="realName"><el-input v-model="profileForm.realName" maxlength="64" show-word-limit placeholder="请输入真实姓名" /></el-form-item>
<el-form-item label="联系电话" prop="phone"><el-input v-model="profileForm.phone" maxlength="32" inputmode="tel" placeholder="选填" /></el-form-item> <el-form-item label="联系电话" prop="phone"><el-input v-model="profileForm.phone" maxlength="32" inputmode="tel" placeholder="选填" /></el-form-item>
<el-form-item label="邮箱" prop="email"><el-input v-model="profileForm.email" maxlength="128" inputmode="email" placeholder="选填" /></el-form-item> <el-form-item label="邮箱" prop="email"><el-input v-model="profileForm.email" maxlength="128" inputmode="email" placeholder="选填" /></el-form-item>
<el-form-item label="所属公司"> <el-form-item label="所属公司">
@@ -282,14 +266,6 @@ async function changePassword() {
<style scoped> <style scoped>
.account-page { display: flex; flex-direction: column; gap: 14px; } .account-page { display: flex; flex-direction: column; gap: 14px; }
.account-identity { display: flex; min-width: 0; align-items: center; gap: 14px; padding: 18px 22px; }
.account-avatar { display: grid; width: 52px; height: 52px; flex: 0 0 52px; place-items: center; border-radius: 14px; background: #e7f3f1; color: var(--primary); }
.account-identity-copy { min-width: 140px; }
.account-identity h2 { margin: 0 0 4px; color: var(--navy); font-size: 18px; }
.account-identity p { margin: 0; color: var(--muted); font-size: 12px; }
.account-context { display: flex; min-width: 0; flex: 1; gap: 22px; color: #52616b; font-size: 12px; }
.account-context span { display: flex; min-width: 0; align-items: center; gap: 7px; overflow-wrap: anywhere; }
.account-identity :deep(.el-tag) { display: flex; align-items: center; gap: 5px; }
.account-workspace { min-height: 520px; padding: 0 24px 28px; } .account-workspace { min-height: 520px; padding: 0 24px 28px; }
.account-tabs :deep(.el-tabs__header) { margin-bottom: 0; } .account-tabs :deep(.el-tabs__header) { margin-bottom: 0; }
.account-tabs :deep(.el-tabs__item) { min-height: 52px; } .account-tabs :deep(.el-tabs__item) { min-height: 52px; }
@@ -334,9 +310,6 @@ async function changePassword() {
} }
@media (max-width: 620px) { @media (max-width: 620px) {
.account-identity { align-items: flex-start; flex-wrap: wrap; }
.account-identity-copy { flex: 1; }
.account-context { order: 3; width: 100%; flex-basis: 100%; padding-top: 12px; border-top: 1px solid var(--border); }
.account-workspace { min-height: 0; padding: 0 16px 22px; } .account-workspace { min-height: 0; padding: 0 16px 22px; }
.account-section-heading { align-items: stretch; flex-direction: column; gap: 14px; padding-top: 20px; } .account-section-heading { align-items: stretch; flex-direction: column; gap: 14px; padding-top: 20px; }
.account-section-heading > .el-button { width: 100%; } .account-section-heading > .el-button { width: 100%; }
+8 -1
View File
@@ -48,11 +48,18 @@ test('uses real auth endpoints and single-flight refresh handling', () => {
assert.match(authApiSource, /\/auth\/logout/) assert.match(authApiSource, /\/auth\/logout/)
assert.match(httpSource, /refreshPromise/) assert.match(httpSource, /refreshPromise/)
assert.match(httpSource, /\/auth\/refresh/) assert.match(httpSource, /\/auth\/refresh/)
assert.match(httpSource, /envelope\.code !== '0'/) assert.match(httpSource, /envelope\.code !== '200'/)
assert.match(httpSource, /redirectToLoginAfterSessionExpiry/)
assert.match(httpSource, /window\.location\.replace\(`\/login\?redirect=\$\{encodeURIComponent\(redirect\)\}`\)/)
assert.match(authStoreSource, /decodeMerchantToken/) assert.match(authStoreSource, /decodeMerchantToken/)
assert.doesNotMatch(authStoreSource, /mock-client:/) assert.doesNotMatch(authStoreSource, /mock-client:/)
}) })
test('treats only body code 200 as success', () => {
assert.match(httpSource, /if \(envelope\.code !== '200'\)/)
assert.doesNotMatch(httpSource, /if \(envelope\.code !== '0'\)/)
})
test('keeps a valid local session during temporary context and refresh outages', () => { test('keeps a valid local session during temporary context and refresh outages', () => {
assert.match(authStoreSource, /function isExpiredSessionError/) assert.match(authStoreSource, /function isExpiredSessionError/)
assert.match(authStoreSource, /if \(isExpiredSessionError\(error\)\)/) assert.match(authStoreSource, /if \(isExpiredSessionError\(error\)\)/)
+58 -23
View File
@@ -1,8 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import type { UploadFile, UploadInstance } from 'element-plus' import type { FormInstance, FormRules, UploadFile, UploadInstance } from 'element-plus'
import { Building2, CheckCircle2, Clock3, FileCheck2, FileText, Pencil, Trash2, Upload, UserRound } from '@lucide/vue' import { Building2, CheckCircle2, Clock3, FileCheck2, FileText, Pencil, Trash2, Upload, UserRound } from '@lucide/vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import { useTenantAdministrationStore, type TenantCompanyProfile, type TenantQualification } from '@/stores/tenantAdministration' import { useTenantAdministrationStore, type TenantCompanyProfile, type TenantQualification } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { formatQualificationFileSize, validateQualificationFile } from '@/utils/qualificationFile' import { formatQualificationFileSize, validateQualificationFile } from '@/utils/qualificationFile'
@@ -25,7 +26,11 @@ const previewOpen = ref(false)
const previewRecord = ref<TenantQualification | null>(null) const previewRecord = ref<TenantQualification | null>(null)
const previewUrl = ref('') const previewUrl = ref('')
const previewLoading = ref(false) const previewLoading = ref(false)
const profileFormRef = ref<FormInstance>()
const qualificationFormRef = ref<FormInstance>()
const uploadRef = ref<UploadInstance>() const uploadRef = ref<UploadInstance>()
const qualificationPage = ref(1)
const qualificationPageSize = ref(10)
const selectedFileSize = ref(0) const selectedFileSize = ref(0)
const selectedFileType = ref('') const selectedFileType = ref('')
const qualificationFileError = ref('') const qualificationFileError = ref('')
@@ -36,6 +41,28 @@ const loadError = ref('')
const selectedFile = ref<File | null>(null) const selectedFile = ref<File | null>(null)
const draft = reactive<TenantCompanyProfile>({ ...store.company }) const draft = reactive<TenantCompanyProfile>({ ...store.company })
const qualificationDraft = reactive({ type: '营业执照', fileName: '', certificateNo: '' }) const qualificationDraft = reactive({ type: '营业执照', fileName: '', certificateNo: '' })
const profileFormRules: FormRules<TenantCompanyProfile> = {
companyName: [{ required: true, whitespace: true, message: '请输入公司名称', trigger: 'blur' }],
legalRepresentative: [{ required: true, whitespace: true, message: '请输入法人姓名', trigger: 'blur' }],
contactName: [{ required: true, whitespace: true, message: '请输入联系人姓名', trigger: 'blur' }],
contactPhone: [{ required: true, whitespace: true, message: '请输入联系电话', trigger: 'blur' }],
contactEmail: [{ type: 'email', message: '请输入正确的联系邮箱', trigger: 'blur' }],
}
const qualificationFormRules: FormRules = {
fileName: [{ required: true, message: '请选择营业执照图片', trigger: 'change' }],
}
const qualificationColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 90, fixed: 'left', slotName: 'actions' },
{ key: 'type', prop: 'type', label: '文件类型', minWidth: 150 },
{ key: 'fileName', prop: 'fileName', label: '文件名称', minWidth: 220, showOverflowTooltip: true },
{ key: 'status', label: '状态', width: 110, slotName: 'status' },
{ key: 'expiresAt', prop: 'expiresAt', label: '有效期', width: 130 },
{ key: 'updatedAt', prop: 'updatedAt', label: '更新时间', minWidth: 160 },
]
const pagedQualifications = computed(() => {
const start = (qualificationPage.value - 1) * qualificationPageSize.value
return store.qualifications.slice(start, start + qualificationPageSize.value)
})
async function loadCompanyContext() { async function loadCompanyContext() {
loading.value = true loading.value = true
@@ -54,20 +81,20 @@ onMounted(() => void loadCompanyContext())
function startEdit() { function startEdit() {
Object.assign(draft, store.company) Object.assign(draft, store.company)
profileFormRef.value?.clearValidate()
editing.value = true editing.value = true
} }
function cancelEdit() { function cancelEdit() {
Object.assign(draft, store.company) Object.assign(draft, store.company)
editing.value = false editing.value = false
profileFormRef.value?.clearValidate()
} }
async function saveProfile() { async function saveProfile() {
if (profileSubmitting.value) return if (profileSubmitting.value) return
if (!draft.companyName || !draft.legalRepresentative || !draft.contactName || !draft.contactPhone) { const valid = await profileFormRef.value?.validate().then(() => true).catch(() => false)
ElMessage.warning('请完整填写企业名称、法人及联系人信息') if (!valid) return
return
}
profileSubmitting.value = true profileSubmitting.value = true
try { try {
await store.updateCompany({ ...draft }) await store.updateCompany({ ...draft })
@@ -127,12 +154,14 @@ function resetQualificationDraft() {
selectedFileType.value = '' selectedFileType.value = ''
qualificationFileError.value = '' qualificationFileError.value = ''
selectedFile.value = null selectedFile.value = null
qualificationFormRef.value?.clearValidate()
} }
async function submitQualification() { async function submitQualification() {
if (qualificationSubmitting.value) return if (qualificationSubmitting.value) return
if (!qualificationDraft.fileName || !selectedFile.value) { const valid = await qualificationFormRef.value?.validate().then(() => true).catch(() => false)
ElMessage.warning('请选择营业执照图片') if (!valid || !selectedFile.value) {
qualificationFileError.value = '请选择营业执照图片'
return return
} }
if (qualificationFileError.value) { if (qualificationFileError.value) {
@@ -226,14 +255,20 @@ function qualificationStatus(status: string) {
<template #label><span class="tab-label"><FileCheck2 :size="16" />资质文件</span></template> <template #label><span class="tab-label"><FileCheck2 :size="16" />资质文件</span></template>
<div class="client-card qualification-panel"> <div class="client-card qualification-panel">
<header><div><h3>企业资质</h3><p>文件更新后需等待平台重新审核,历史业务不受影响。</p></div><el-button v-if="auth.hasPermission('company:qualification:upload')" type="primary" @click="openQualificationDialog"><Upload :size="16" />更新资质</el-button></header> <header><div><h3>企业资质</h3><p>文件更新后需等待平台重新审核,历史业务不受影响。</p></div><el-button v-if="auth.hasPermission('company:qualification:upload')" type="primary" @click="openQualificationDialog"><Upload :size="16" />更新资质</el-button></header>
<el-table class="desktop-qualification-table" :data="store.qualifications" row-key="id" empty-text="暂无企业资质记录"> <DataTable
<el-table-column label="操作" width="90" fixed="left"><template #default="{ row }"><el-button link type="primary" @click="previewQualification(row as TenantQualification)">查看</el-button></template></el-table-column> v-model:page="qualificationPage"
<el-table-column prop="type" label="文件类型" min-width="150" /> v-model:page-size="qualificationPageSize"
<el-table-column prop="fileName" label="文件名称" min-width="220" show-overflow-tooltip /> class="desktop-qualification-table"
<el-table-column label="状态" width="110"><template #default="{ row }"><el-tag :type="row.status === 'VERIFIED' ? 'success' : row.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(row.status) }}</el-tag></template></el-table-column> :columns="qualificationColumns"
<el-table-column prop="expiresAt" label="有效期" width="130" /> :data="pagedQualifications"
<el-table-column prop="updatedAt" label="更新时间" min-width="160" /> :empty-text="'暂无企业资质记录'"
</el-table> :page-sizes="[10, 20, 50]"
:total="store.qualifications.length"
row-key="id"
>
<template #actions="{ row }"><el-button link type="primary" @click="previewQualification(row as TenantQualification)">查看</el-button></template>
<template #status="{ row }"><el-tag :type="row.status === 'VERIFIED' ? 'success' : row.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(row.status) }}</el-tag></template>
</DataTable>
<div class="mobile-qualification-list"> <div class="mobile-qualification-list">
<article v-for="record in store.qualifications" :key="record.id"> <article v-for="record in store.qualifications" :key="record.id">
<header><strong>{{ record.type }}</strong><el-tag :type="record.status === 'VERIFIED' ? 'success' : record.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(record.status) }}</el-tag></header> <header><strong>{{ record.type }}</strong><el-tag :type="record.status === 'VERIFIED' ? 'success' : record.status === 'PENDING' ? 'warning' : 'danger'" effect="light">{{ qualificationStatus(record.status) }}</el-tag></header>
@@ -248,27 +283,27 @@ function qualificationStatus(status: string) {
</el-tabs> </el-tabs>
<el-drawer v-model="editing" title="编辑企业资料" size="min(620px, 94vw)" @closed="cancelEdit"> <el-drawer v-model="editing" title="编辑企业资料" size="min(620px, 94vw)" @closed="cancelEdit">
<el-form label-position="top" class="profile-form"> <el-form ref="profileFormRef" :model="draft" :rules="profileFormRules" label-position="top" class="profile-form">
<h3>企业基本信息</h3> <h3>企业基本信息</h3>
<div class="form-grid-two"> <div class="form-grid-two">
<el-form-item label="公司名称" required><el-input v-model="draft.companyName" /></el-form-item> <el-form-item label="公司名称" prop="companyName" required><el-input v-model="draft.companyName" placeholder="请输入公司名称" /></el-form-item>
<el-form-item label="统一社会信用代码"><el-input v-model="draft.creditCode" disabled /></el-form-item> <el-form-item label="统一社会信用代码"><el-input v-model="draft.creditCode" disabled /></el-form-item>
<el-form-item label="法人姓名" required><el-input v-model="draft.legalRepresentative" /></el-form-item> <el-form-item label="法人姓名" prop="legalRepresentative" required><el-input v-model="draft.legalRepresentative" placeholder="请输入法定代表人姓名" /></el-form-item>
</div> </div>
<h3>联系信息</h3> <h3>联系信息</h3>
<div class="form-grid-two"> <div class="form-grid-two">
<el-form-item label="联系人" required><el-input v-model="draft.contactName" /></el-form-item> <el-form-item label="联系人" prop="contactName" required><el-input v-model="draft.contactName" placeholder="请输入联系人姓名" /></el-form-item>
<el-form-item label="联系电话" required><el-input v-model="draft.contactPhone" /></el-form-item> <el-form-item label="联系电话" prop="contactPhone" required><el-input v-model="draft.contactPhone" placeholder="请输入联系电话" /></el-form-item>
<el-form-item label="联系邮箱"><el-input v-model="draft.contactEmail" /></el-form-item> <el-form-item label="联系邮箱" prop="contactEmail"><el-input v-model="draft.contactEmail" placeholder="请输入联系邮箱(选填)" /></el-form-item>
</div> </div>
</el-form> </el-form>
<template #footer><div class="drawer-actions"><el-button :disabled="profileSubmitting" @click="cancelEdit">取消</el-button><el-button type="primary" :loading="profileSubmitting" @click="saveProfile">保存修改</el-button></div></template> <template #footer><div class="drawer-actions"><el-button :disabled="profileSubmitting" @click="cancelEdit">取消</el-button><el-button type="primary" :loading="profileSubmitting" @click="saveProfile">保存修改</el-button></div></template>
</el-drawer> </el-drawer>
<el-dialog v-model="qualificationOpen" title="更新资质文件" width="min(520px, 92vw)" @closed="resetQualificationDraft"> <el-dialog v-model="qualificationOpen" title="更新资质文件" width="min(520px, 92vw)" @closed="resetQualificationDraft">
<el-form label-position="top"> <el-form ref="qualificationFormRef" :model="qualificationDraft" :rules="qualificationFormRules" label-position="top">
<el-form-item label="文件类型"><el-input v-model="qualificationDraft.type" disabled /></el-form-item> <el-form-item label="文件类型"><el-input v-model="qualificationDraft.type" disabled /></el-form-item>
<el-form-item label="选择文件" required :error="qualificationFileError"> <el-form-item label="选择文件" prop="fileName" required :error="qualificationFileError">
<el-upload ref="uploadRef" :auto-upload="false" :limit="1" :show-file-list="false" accept=".jpg,.jpeg,.png" @change="handleFileChange"> <el-upload ref="uploadRef" :auto-upload="false" :limit="1" :show-file-list="false" accept=".jpg,.jpeg,.png" @change="handleFileChange">
<el-button><Upload :size="16" />选择文件</el-button> <el-button><Upload :size="16" />选择文件</el-button>
<template #tip><div class="upload-tip">仅支持 JPG、PNG,单张图片不超过 5MB。</div></template> <template #tip><div class="upload-tip">仅支持 JPG、PNG,单张图片不超过 5MB。</div></template>
+10 -2
View File
@@ -11,8 +11,16 @@ test('loads real dashboard data allowed by the current permissions', () => {
assert.match(source, /onMounted\(\(\) => void loadDashboardData\(\)\)/) assert.match(source, /onMounted\(\(\) => void loadDashboardData\(\)\)/)
}) })
test('shows a recoverable dashboard load failure', () => { test('isolates recoverable dashboard failures without hiding successful sections', () => {
assert.match(source, /Promise\.allSettled/)
assert.match(source, /Partial<Record<DashboardDataSource, string>>/)
assert.match(source, /:aria-busy="loading"/)
assert.match(source, /role="alert"/) assert.match(source, /role="alert"/)
assert.match(source, /业务总览加载失败/) assert.match(source, /部分业务数据加载失败/)
assert.match(source, /ordersUnavailable/)
assert.match(source, /companyUnavailable/)
assert.match(source, /订单数据暂不可用,请重新加载后查看。/)
assert.match(source, /企业资料暂不可用,请重新加载后查看。/)
assert.match(source, /@click="loadDashboardData"/) assert.match(source, /@click="loadDashboardData"/)
assert.doesNotMatch(source, /<template v-else>\s*<section class="quick-order-band">/)
}) })
+73 -20
View File
@@ -10,32 +10,56 @@ const orders = useFirstLegOrdersStore()
const tenant = useTenantAdministrationStore() const tenant = useTenantAdministrationStore()
const auth = useAuthStore() const auth = useAuthStore()
const loading = ref(false) const loading = ref(false)
const loadError = ref('') type DashboardDataSource = 'company' | 'members' | 'orders'
const loadErrors = ref<Partial<Record<DashboardDataSource, string>>>({})
const dataSourceLabels: Record<DashboardDataSource, string> = {
company: '企业资料',
members: '企业成员',
orders: '头程订单',
}
const companyApproved = computed(() => tenant.company.auditStatus === 'ACTIVE') const companyApproved = computed(() => tenant.company.auditStatus === 'ACTIVE')
const companyUnavailable = computed(() => Boolean(loadErrors.value.company))
const membersUnavailable = computed(() => Boolean(loadErrors.value.members))
const ordersUnavailable = computed(() => Boolean(loadErrors.value.orders))
const loadErrorItems = computed(() =>
(Object.keys(loadErrors.value) as DashboardDataSource[]).map((key) => ({
key,
label: dataSourceLabels[key],
message: loadErrors.value[key] ?? '请求失败',
})),
)
const stats = computed(() => [ const stats = computed(() => [
...(auth.hasPermission('order:first-leg:list') ...(auth.hasPermission('order:first-leg:list')
? [ ? [
{ label: '进行中订单', value: String(orders.activeCount), icon: Clock3 }, { label: '进行中订单', value: ordersUnavailable.value ? '—' : String(orders.activeCount), icon: Clock3 },
{ label: '待平台审核', value: String(orders.pendingCount), icon: ClipboardCheck }, { label: '待平台审核', value: ordersUnavailable.value ? '—' : String(orders.pendingCount), icon: ClipboardCheck },
] ]
: []), : []),
...(auth.hasPermission('company:member:list') ...(auth.hasPermission('company:member:list')
? [{ label: '企业成员', value: String(tenant.activeMemberCount), icon: Users }] ? [{ label: '企业成员', value: membersUnavailable.value ? '—' : String(tenant.activeMemberCount), icon: Users }]
: []), : []),
]) ])
const recentOrders = computed(() => orders.orders.slice(0, 3)) const recentOrders = computed(() => orders.orders.slice(0, 3))
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试'
}
async function loadDashboardData() { async function loadDashboardData() {
loading.value = true loading.value = true
loadError.value = '' loadErrors.value = {}
const requests: Promise<unknown>[] = [] const requests: Array<{ source: DashboardDataSource; request: Promise<unknown> }> = []
if (auth.hasPermission('company:profile:view')) requests.push(tenant.loadCompanyContext()) if (auth.hasPermission('company:profile:view')) requests.push({ source: 'company', request: tenant.loadCompanyContext() })
if (auth.hasPermission('company:member:list')) requests.push(tenant.loadMembers()) if (auth.hasPermission('company:member:list')) requests.push({ source: 'members', request: tenant.loadMembers() })
if (auth.hasPermission('order:first-leg:list')) requests.push(orders.load()) if (auth.hasPermission('order:first-leg:list')) requests.push({ source: 'orders', request: orders.load() })
try { try {
await Promise.all(requests) const results = await Promise.allSettled(requests.map(({ request }) => request))
} catch (error) { const nextErrors: Partial<Record<DashboardDataSource, string>> = {}
loadError.value = error instanceof Error ? error.message : '业务总览加载失败' results.forEach((result, index) => {
const request = requests[index]
if (request && result.status === 'rejected') nextErrors[request.source] = errorMessage(result.reason)
})
loadErrors.value = nextErrors
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -45,24 +69,53 @@ onMounted(() => void loadDashboardData())
</script> </script>
<template> <template>
<div v-loading="loading" class="client-dashboard"> <div v-loading="loading" class="client-dashboard" :aria-busy="loading">
<div v-if="loadError" class="dashboard-load-error" role="alert"> <div v-if="loadErrorItems.length" class="dashboard-load-error" role="alert">
<AlertTriangle :size="20" /> <AlertTriangle :size="20" />
<div><strong>业务总览加载失败</strong><span>{{ loadError }}</span></div> <div>
<strong>部分业务数据加载失败</strong>
<ul>
<li v-for="item in loadErrorItems" :key="item.key">
<span>{{ item.label }}:</span>{{ item.message }}
</li>
</ul>
</div>
<el-button :loading="loading" @click="loadDashboardData">重新加载</el-button> <el-button :loading="loading" @click="loadDashboardData">重新加载</el-button>
</div> </div>
<template v-else>
<section class="quick-order-band"> <section class="quick-order-band">
<div><span>头程物流</span><h2>{{ auth.hasPermission('order:first-leg:create') ? '创建新的头程订单' : '跟进企业头程订单' }}</h2><p>{{ companyApproved ? '企业资料已审核,可正常提交并跟进订单。' : '企业资料待平台审核,当前可以维护并保存订单草稿。' }}</p></div> <div>
<span>头程物流</span>
<h2>{{ auth.hasPermission('order:first-leg:create') ? '创建新的头程订单' : '跟进企业头程订单' }}</h2>
<p v-if="companyUnavailable">企业资料暂不可用,恢复后可查看下单资格。</p>
<p v-else>{{ companyApproved ? '企业资料已审核,可正常提交并跟进订单。' : '企业资料待平台审核,当前可以维护并保存订单草稿。' }}</p>
</div>
<el-button v-if="auth.hasPermission('order:first-leg:list')" type="primary" size="large" @click="$router.push('/orders/first-leg')"><FilePlus2 :size="18" /> {{ auth.hasPermission('order:first-leg:create') ? '开始下单' : '查看订单' }}</el-button> <el-button v-if="auth.hasPermission('order:first-leg:list')" type="primary" size="large" @click="$router.push('/orders/first-leg')"><FilePlus2 :size="18" /> {{ auth.hasPermission('order:first-leg:create') ? '开始下单' : '查看订单' }}</el-button>
</section> </section>
<section v-if="stats.length" class="client-stats"> <section v-if="stats.length" class="client-stats">
<article v-for="item in stats" :key="item.label"><component :is="item.icon" :size="20" /><div><span>{{ item.label }}</span><strong>{{ item.value }}</strong></div></article> <article v-for="item in stats" :key="item.label"><component :is="item.icon" :size="20" /><div><span>{{ item.label }}</span><strong>{{ item.value }}</strong></div></article>
</section> </section>
<section class="client-content-grid"> <section class="client-content-grid">
<article v-if="auth.hasPermission('order:first-leg:list')" class="client-card"><header><div><span class="section-label">ORDERS</span><h3>最近订单</h3></div><el-button text @click="$router.push('/orders/first-leg')">查看全部 <ArrowRight :size="16" /></el-button></header><div v-if="recentOrders.length" class="recent-orders"><button v-for="order in recentOrders" :key="order.id" type="button" @click="$router.push('/orders/first-leg')"><span><strong>{{ order.orderNo || '未提交草稿' }}</strong><small>{{ order.origin }} → {{ order.destination }}</small></span><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></button></div><div v-else class="client-empty">暂无订单,提交第一笔头程订单后将在这里展示。</div></article> <article v-if="auth.hasPermission('order:first-leg:list')" class="client-card">
<article v-if="auth.hasPermission('company:profile:view')" class="client-card"><header><div><span class="section-label">ACCOUNT</span><h3>企业资料</h3></div></header><dl class="profile-summary"><div><dt>企业名称</dt><dd>{{ tenant.company.companyName || '-' }}</dd></div><div><dt>审核状态</dt><dd><el-tag :type="companyApproved ? 'success' : 'warning'" effect="light">{{ companyApproved ? '已通过' : '待审核' }}</el-tag></dd></div><div><dt>当前成员</dt><dd>{{ auth.user?.name }}</dd></div></dl><el-button plain @click="$router.push('/company/profile')">{{ companyApproved ? '查看企业资料' : '完善企业资料' }}</el-button></article> <header><div><span class="section-label">ORDERS</span><h3>最近订单</h3></div><el-button text @click="$router.push('/orders/first-leg')">查看全部 <ArrowRight :size="16" /></el-button></header>
<div v-if="ordersUnavailable" class="dashboard-section-error" role="status">订单数据暂不可用,请重新加载后查看。</div>
<div v-else-if="recentOrders.length" class="recent-orders"><button v-for="order in recentOrders" :key="order.id" type="button" @click="$router.push('/orders/first-leg')"><span><strong>{{ order.orderNo || '未提交草稿' }}</strong><small>{{ order.origin }} → {{ order.destination }}</small></span><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></button></div>
<div v-else class="client-empty">暂无订单,提交第一笔头程订单后将在这里展示。</div>
</article>
<article v-if="auth.hasPermission('company:profile:view')" class="client-card">
<header><div><span class="section-label">ACCOUNT</span><h3>企业资料</h3></div></header>
<div v-if="companyUnavailable" class="dashboard-section-error" role="status">企业资料暂不可用,请重新加载后查看。</div>
<template v-else>
<dl class="profile-summary"><div><dt>企业名称</dt><dd>{{ tenant.company.companyName || '-' }}</dd></div><div><dt>审核状态</dt><dd><el-tag :type="companyApproved ? 'success' : 'warning'" effect="light">{{ companyApproved ? '已通过' : '待审核' }}</el-tag></dd></div><div><dt>当前成员</dt><dd>{{ auth.user?.name }}</dd></div></dl>
<el-button plain @click="$router.push('/company/profile')">{{ companyApproved ? '查看企业资料' : '完善企业资料' }}</el-button>
</template>
</article>
</section> </section>
</template>
</div> </div>
</template> </template>
<style scoped>
.dashboard-load-error ul { display: grid; gap: 3px; margin: 0; padding: 0; list-style: none; }
.dashboard-load-error li { overflow-wrap: anywhere; color: var(--muted); font-size: 12px; line-height: 1.5; }
.dashboard-load-error li span { color: #9a5a09; font-weight: 650; }
.dashboard-section-error { display: grid; min-height: 160px; place-items: center; padding: 24px; border-top: 1px solid var(--border); color: #8a5a22; font-size: 13px; line-height: 1.6; text-align: center; }
</style>
+25 -3
View File
@@ -39,10 +39,21 @@ test('renders dynamic cargo lines and responsive drawers', () => {
}) })
test('keeps primary mobile order actions touch sized', () => { test('keeps primary mobile order actions touch sized', () => {
assert.match(source, /\.order-toolbar-actions :deep\(\.el-button\) \{ min-height: 44px;/)
assert.match(source, /\.editor-actions :deep\(\.el-button\) \{ min-height: 44px;/) assert.match(source, /\.editor-actions :deep\(\.el-button\) \{ min-height: 44px;/)
}) })
test('uses the shared query toolbar and data table without metric cards', () => {
assert.match(source, /import QueryToolbar, \{ type QueryField, type QueryModel \}/)
assert.match(source, /import DataTable, \{ type DataTableColumn \}/)
assert.match(source, /const filters = ref<QueryModel>/)
assert.match(source, /<QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="头程订单查询" @search="searchOrders">/)
assert.match(source, /async function searchOrders\(model: QueryModel\)/)
assert.match(source, /await store\.load\(\)/)
assert.match(source, /<DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="orderColumns"/)
assert.match(source, /\{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' \}/)
assert.doesNotMatch(source, /order-metrics|订单概览/)
})
test('does not expose arbitrary status mutation controls to customers', () => { test('does not expose arbitrary status mutation controls to customers', () => {
assert.doesNotMatch(source, /setStatusForPreview/) assert.doesNotMatch(source, /setStatusForPreview/)
assert.doesNotMatch(source, /状态更新|更新状态/) assert.doesNotMatch(source, /状态更新|更新状态/)
@@ -51,7 +62,7 @@ test('does not expose arbitrary status mutation controls to customers', () => {
test('requires an enabled platform receiving warehouse in the order form', () => { test('requires an enabled platform receiving warehouse in the order form', () => {
assert.match(source, /computed\(\(\) => store\.warehouses\)/) assert.match(source, /computed\(\(\) => store\.warehouses\)/)
assert.match(source, /availableWarehouses\.value\.length === 0/) assert.match(source, /availableWarehouses\.value\.length === 0/)
assert.match(source, /label="平台收货仓" required/) assert.match(source, /label="平台收货仓"[^>]*required/)
assert.match(source, /v-model="form\.receivingWarehouseId"/) assert.match(source, /v-model="form\.receivingWarehouseId"/)
assert.match(source, /selectedWarehouse\.businessHours/) assert.match(source, /selectedWarehouse\.businessHours/)
assert.match(source, /detailOrder\.receivingWarehouseSnapshot\?\.name/) assert.match(source, /detailOrder\.receivingWarehouseSnapshot\?\.name/)
@@ -60,11 +71,22 @@ test('requires an enabled platform receiving warehouse in the order form', () =>
}) })
test('collects the backend-required first-leg fields', () => { test('collects the backend-required first-leg fields', () => {
for (const model of ['destinationCountryCode', 'destinationWarehouseName', 'channelCode', 'channelName', 'pickupMethod', 'taxMode', 'destinationType', 'packageType']) { for (const model of ['destinationCountryCode', 'destinationWarehouseName', 'channelCode', 'channelName', 'pickupMethod', 'taxMode', 'destinationType', 'packageType', 'upc']) {
assert.match(source, new RegExp(`v-model="(?:form|item)\\.${model}"`)) assert.match(source, new RegExp(`v-model="(?:form|item)\\.${model}"`))
} }
}) })
test('shows field-level validation and concise guidance for required order inputs', () => {
assert.match(source, /ref="orderFormRef" :model="form" :rules="orderFormRules"/)
assert.match(source, /prop="shipperPartySnapshot\.contactName" required/)
assert.match(source, /prop="consigneePartySnapshot\.address" required/)
assert.match(source, /:prop="`items\.\$\{index\}\.cargoNameCn`" required/)
assert.match(source, /:prop="`items\.\$\{index\}\.grossWeightKg`" required/)
assert.match(source, /label="UPC".*required.*v-model="item\.upc"/)
assert.match(source, /placeholder="例如:蓝牙音箱"/)
assert.match(source, /\.order-form :deep\(\.el-form-item__error\).*position: static/)
})
test('selects enabled counterparties and stores editable order snapshots', () => { test('selects enabled counterparties and stores editable order snapshots', () => {
assert.match(source, /firstLegPartySource\.listAvailable\('SHIPPER'\)/) assert.match(source, /firstLegPartySource\.listAvailable\('SHIPPER'\)/)
assert.match(source, /firstLegPartySource\.findAvailable\(id, role\)/) assert.match(source, /firstLegPartySource\.findAvailable\(id, role\)/)
+197 -120
View File
@@ -1,9 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { onBeforeRouteLeave, useRouter } from 'vue-router' import { onBeforeRouteLeave, useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox, type FormInstance, type FormItemRule, type FormRules } from 'element-plus'
import { AlertTriangle, Box, Building2, CheckCircle2, Eye, FilePlus2, MapPin, PackageCheck, Pencil, Plus, RotateCcw, Search, Send, Trash2 } from '@lucide/vue' import { AlertTriangle, Box, Building2, 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 { 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'
import { useFirstLegOrdersStore } from '@/stores/firstLegOrders' import { useFirstLegOrdersStore } from '@/stores/firstLegOrders'
import { useTenantAdministrationStore } from '@/stores/tenantAdministration' import { useTenantAdministrationStore } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
@@ -30,9 +32,28 @@ const canEditOrder = computed(() => auth.hasPermission('order:first-leg:edit'))
const availableWarehouses = computed(() => store.warehouses) const availableWarehouses = computed(() => store.warehouses)
const availableShippers = ref(firstLegPartySource.listAvailable('SHIPPER')) const availableShippers = ref(firstLegPartySource.listAvailable('SHIPPER'))
const availableConsignees = ref(firstLegPartySource.listAvailable('CONSIGNEE')) const availableConsignees = ref(firstLegPartySource.listAvailable('CONSIGNEE'))
const filters = reactive({ keyword: '', status: '' as FirstLegOrderStatus | '', transportMode: '' }) const filters = ref<QueryModel>({ keyword: '', status: '', transportMode: '', createdRange: [] })
const dateRange = ref<[string, string] | []>([]) const appliedFilters = ref<QueryModel>({ ...filters.value })
const queryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '订单号、参考号、路线或品名', props: { 'aria-label': '订单关键词' } },
{ key: 'status', type: 'select', placeholder: '全部状态', options: FIRST_LEG_ORDER_STATUSES.map((item) => ({ label: item.label, value: item.code })), props: { 'aria-label': '订单状态' } },
{ 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 orderColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', width: 180, fixed: 'left', slotName: 'actions' },
{ key: 'order', label: '订单', minWidth: 190, slotName: 'order' },
{ key: 'route', label: '运输路线', minWidth: 190, slotName: 'route' },
{ key: 'warehouse', label: '收货仓', prop: 'receivingWarehouseName', minWidth: 120 },
{ key: 'cargo', label: '货物', minWidth: 150, slotName: 'cargo' },
{ key: 'status', label: '状态', width: 120, slotName: 'status' },
{ key: 'operator', label: '负责人', width: 110, slotName: 'operator' },
{ key: 'updatedAt', label: '更新时间', width: 155, slotName: 'updatedAt' },
]
const tablePage = ref(1)
const tablePageSize = ref(10)
const editorOpen = ref(false) const editorOpen = ref(false)
const orderFormRef = ref<FormInstance>()
const detailOpen = ref(false) const detailOpen = ref(false)
const editingId = ref<string>() const editingId = ref<string>()
const openingEditor = ref(false) const openingEditor = ref(false)
@@ -45,6 +66,38 @@ const supportingDataErrors = ref<string[]>([])
const editorBaseline = ref('') const editorBaseline = ref('')
let cargoSequence = 0 let cargoSequence = 0
const orderFormRules: FormRules = {
transportMode: [{ required: true, message: '请选择运输方式', trigger: 'change' }],
businessType: [{ required: true, message: '请选择业务类型', trigger: 'change' }],
origin: [{ required: true, message: '请输入起运地', trigger: 'blur' }],
destination: [{ required: true, message: '请输入目的地', trigger: 'blur' }],
receivingWarehouseId: [{ required: true, message: '请选择平台收货仓', trigger: 'change' }],
destinationCountryCode: [
{ required: true, message: '请输入目的国代码', trigger: 'blur' },
{ pattern: /^[A-Za-z]{2}$/, message: '请输入两位英文字母,例如 US', trigger: 'blur' },
],
destinationWarehouseName: [{ required: true, message: '请输入目的仓名称', trigger: 'blur' }],
channelCode: [{ required: true, message: '请输入渠道编码', trigger: 'blur' }],
channelName: [{ required: true, message: '请输入渠道名称', trigger: 'blur' }],
pickupMethod: [{ required: true, message: '请选择提货方式', trigger: 'change' }],
taxMode: [{ required: true, message: '请选择报关模式', trigger: 'change' }],
destinationType: [{ required: true, message: '请选择目的仓类型', trigger: 'change' }],
shipperPartyId: [{ required: true, message: '请选择发货单位', trigger: 'change' }],
consigneePartyId: [{ required: true, message: '请选择收货单位', trigger: 'change' }],
}
function requiredTextRules(message: string): FormItemRule[] {
return [{ required: true, message, trigger: 'blur' }]
}
function requiredSelectRules(message: string): FormItemRule[] {
return [{ required: true, message, trigger: 'change' }]
}
function positiveNumberRules(message: string, integer = false): FormItemRule[] {
return [{ type: integer ? 'integer' : 'number', min: integer ? 1 : 0.001, message, trigger: 'change' }]
}
function formatDateTime(value: string) { function formatDateTime(value: string) {
const date = new Date(value) const date = new Date(value)
if (Number.isNaN(date.getTime())) return value || '-' if (Number.isNaN(date.getTime())) return value || '-'
@@ -95,20 +148,39 @@ const serializedDraft = computed(() => JSON.stringify(form))
const editorDirty = computed(() => editorOpen.value && serializedDraft.value !== editorBaseline.value) const editorDirty = computed(() => editorOpen.value && serializedDraft.value !== editorBaseline.value)
const filteredOrders = computed(() => store.orders.filter((order) => { const filteredOrders = computed(() => store.orders.filter((order) => {
const keyword = filters.keyword.trim().toLowerCase() const keywordValue = appliedFilters.value.keyword
const statusValue = appliedFilters.value.status
const transportModeValue = appliedFilters.value.transportMode
const createdRangeValue = appliedFilters.value.createdRange
const keyword = typeof keywordValue === 'string' ? keywordValue.trim().toLowerCase() : ''
const status = typeof statusValue === 'string' ? statusValue as FirstLegOrderStatus | '' : ''
const transportMode = typeof transportModeValue === 'string' ? transportModeValue : ''
const createdRange = Array.isArray(createdRangeValue) ? createdRangeValue.filter((value): value is string => typeof value === 'string') : []
const matchesKeyword = !keyword || [order.orderNo, order.referenceNo, order.origin, order.destination, order.items.map((item) => item.cargoNameCn).join(' ')] const matchesKeyword = !keyword || [order.orderNo, order.referenceNo, order.origin, order.destination, order.items.map((item) => item.cargoNameCn).join(' ')]
.filter(Boolean) .filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword)) .some((value) => String(value).toLowerCase().includes(keyword))
const matchesStatus = !filters.status || order.status === filters.status const matchesStatus = !status || order.status === status
const matchesMode = !filters.transportMode || order.transportMode === filters.transportMode const matchesMode = !transportMode || order.transportMode === transportMode
const matchesDate = dateRange.value.length !== 2 || (order.createdAt.slice(0, 10) >= dateRange.value[0] && order.createdAt.slice(0, 10) <= dateRange.value[1]) const matchesDate = createdRange.length !== 2 || (order.createdAt.slice(0, 10) >= createdRange[0] && order.createdAt.slice(0, 10) <= createdRange[1])
return matchesKeyword && matchesStatus && matchesMode && matchesDate return matchesKeyword && matchesStatus && matchesMode && matchesDate
})) }))
const pagedOrders = computed(() => {
const start = (tablePage.value - 1) * tablePageSize.value
return filteredOrders.value.slice(start, start + tablePageSize.value)
})
const detailOrder = computed(() => detailId.value ? store.getById(detailId.value) : undefined) const detailOrder = computed(() => detailId.value ? store.getById(detailId.value) : undefined)
const detailSummary = computed(() => detailOrder.value ? summarizeFirstLegCargo(detailOrder.value.items) : null) const detailSummary = computed(() => detailOrder.value ? summarizeFirstLegCargo(detailOrder.value.items) : null)
const selectedWarehouse = computed(() => availableWarehouses.value.find((item) => item.id === form.receivingWarehouseId)) const selectedWarehouse = computed(() => availableWarehouses.value.find((item) => item.id === form.receivingWarehouseId))
const hasActiveFilters = computed(() => Boolean(filters.keyword.trim() || filters.status || filters.transportMode || dateRange.value.length)) const hasActiveFilters = computed(() => {
const values = appliedFilters.value
return Boolean(
(typeof values.keyword === 'string' && values.keyword.trim())
|| values.status
|| values.transportMode
|| (Array.isArray(values.createdRange) && values.createdRange.length),
)
})
const emptyDescription = computed(() => hasActiveFilters.value ? '没有符合筛选条件的头程订单' : '还没有头程订单') const emptyDescription = computed(() => hasActiveFilters.value ? '没有符合筛选条件的头程订单' : '还没有头程订单')
async function loadPageData() { async function loadPageData() {
@@ -167,6 +239,10 @@ function markEditorClean() {
editorBaseline.value = serializedDraft.value editorBaseline.value = serializedDraft.value
} }
function resetEditorValidation() {
void nextTick(() => orderFormRef.value?.clearValidate())
}
function cloneDraft(draft: FirstLegOrderDraft): FirstLegOrderDraft { function cloneDraft(draft: FirstLegOrderDraft): FirstLegOrderDraft {
return { return {
...draft, ...draft,
@@ -273,6 +349,7 @@ function openCreate() {
editingId.value = undefined editingId.value = undefined
replaceForm(emptyDraft()) replaceForm(emptyDraft())
editorOpen.value = true editorOpen.value = true
resetEditorValidation()
} }
async function openEdit(order: FirstLegOrder) { async function openEdit(order: FirstLegOrder) {
@@ -291,6 +368,7 @@ async function openEdit(order: FirstLegOrder) {
if (warehouseAvailable) void loadSelectedWarehouse(detailedOrder.receivingWarehouseId) if (warehouseAvailable) void loadSelectedWarehouse(detailedOrder.receivingWarehouseId)
detailOpen.value = false detailOpen.value = false
editorOpen.value = true editorOpen.value = true
resetEditorValidation()
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '订单详情加载失败,暂时无法编辑') ElMessage.error(error instanceof Error ? error.message : '订单详情加载失败,暂时无法编辑')
} finally { } finally {
@@ -345,6 +423,7 @@ function closeEditor() {
function addCargoItem() { function addCargoItem() {
form.items.push(createEmptyCargoItem(`new-cargo-${++cargoSequence}`)) form.items.push(createEmptyCargoItem(`new-cargo-${++cargoSequence}`))
resetEditorValidation()
} }
function removeCargoItem(index: number) { function removeCargoItem(index: number) {
@@ -353,15 +432,32 @@ function removeCargoItem(index: number) {
return return
} }
form.items.splice(index, 1) form.items.splice(index, 1)
resetEditorValidation()
}
async function validateEditorForm() {
let fieldsValid = false
try {
fieldsValid = await orderFormRef.value?.validate() ?? false
} catch {
fieldsValid = false
}
if (!fieldsValid) {
ElMessage.warning('请检查并完善表单中标红的必填信息')
return false
}
const errors = validateFirstLegOrderDraft(form)
if (errors.length > 0) {
ElMessage.warning(errors[0])
return false
}
return true
} }
async function saveDraft() { async function saveDraft() {
if (saving.value) return if (saving.value) return
const errors = validateFirstLegOrderDraft(form) if (!await validateEditorForm()) return
if (errors.length > 0) {
ElMessage.warning(errors[0])
return
}
saving.value = true saving.value = true
try { try {
const order = await store.saveDraft(cloneDraft(form), tenantStore.company.companyName, editingId.value) const order = await store.saveDraft(cloneDraft(form), tenantStore.company.companyName, editingId.value)
@@ -394,11 +490,7 @@ async function submitOrder() {
} }
return return
} }
const errors = validateFirstLegOrderDraft(form) if (!await validateEditorForm()) return
if (errors.length > 0) {
ElMessage.warning(errors[0])
return
}
try { try {
saving.value = true saving.value = true
@@ -415,11 +507,18 @@ async function submitOrder() {
} }
} }
function resetFilters() { async function searchOrders(model: QueryModel) {
filters.keyword = '' const createdRange = model.createdRange
filters.status = '' appliedFilters.value = {
filters.transportMode = '' ...model,
dateRange.value = [] createdRange: Array.isArray(createdRange) ? createdRange.filter((value): value is string => typeof value === 'string') : [],
}
tablePage.value = 1
try {
await store.load()
} catch {
// Store keeps the recoverable error state rendered below the query toolbar.
}
} }
</script> </script>
@@ -435,66 +534,44 @@ function resetFilters() {
<div><strong>部分订单配套数据加载失败</strong><span v-for="message in supportingDataErrors" :key="message">{{ message }}</span></div> <div><strong>部分订单配套数据加载失败</strong><span v-for="message in supportingDataErrors" :key="message">{{ message }}</span></div>
<el-button :loading="supportingDataLoading || store.loading" @click="loadPageData">重新加载</el-button> <el-button :loading="supportingDataLoading || store.loading" @click="loadPageData">重新加载</el-button>
</div> </div>
<div class="order-metrics" aria-label="订单概览"> <QueryToolbar v-model="filters" :fields="queryFields" ariaLabel="头程订单查询" @search="searchOrders">
<article><span>全部订单</span><strong>{{ store.orders.length }}</strong></article> <template #actions>
<article><span>进行中</span><strong>{{ store.activeCount }}</strong></article>
<article><span>待平台审核</span><strong>{{ store.pendingCount }}</strong></article>
</div>
<div class="order-toolbar client-card">
<div class="order-filters">
<el-input v-model="filters.keyword" clearable placeholder="订单号、参考号、路线或品名" aria-label="订单关键词">
<template #prefix><Search :size="16" /></template>
</el-input>
<el-select v-model="filters.status" clearable placeholder="全部状态" aria-label="订单状态">
<el-option v-for="item in FIRST_LEG_ORDER_STATUSES" :key="item.code" :label="item.label" :value="item.code" />
</el-select>
<el-select v-model="filters.transportMode" clearable placeholder="全部运输方式" aria-label="运输方式">
<el-option v-for="item in FIRST_LEG_TRANSPORT_MODES" :key="item.code" :label="item.label" :value="item.code" />
</el-select>
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" range-separator="至" start-placeholder="创建开始" end-placeholder="创建结束" aria-label="订单创建日期" />
</div>
<div class="order-toolbar-actions">
<el-button @click="resetFilters"><RotateCcw :size="16" />重置</el-button>
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button> <el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><FilePlus2 :size="17" />创建订单</el-button>
</div> </template>
</div> </QueryToolbar>
<div v-loading="store.loading" class="client-card order-table-card"> <div class="client-card order-table-card">
<div v-if="store.loadError" class="order-load-state" role="alert"> <div v-if="store.loadError" class="order-load-state" role="alert">
<AlertTriangle :size="24" /> <AlertTriangle :size="24" />
<div><strong>订单数据加载失败</strong><span>{{ store.loadError }}</span></div> <div><strong>订单数据加载失败</strong><span>{{ store.loadError }}</span></div>
<el-button :loading="store.loading" @click="loadPageData">重新加载</el-button> <el-button :loading="store.loading" @click="loadPageData">重新加载</el-button>
</div> </div>
<template v-else> <template v-else>
<el-table v-if="filteredOrders.length" class="desktop-order-table" :data="filteredOrders" row-key="id"> <div class="desktop-order-table">
<el-table-column label="操作" width="180" fixed="left"> <DataTable v-model:page="tablePage" v-model:page-size="tablePageSize" :data="pagedOrders" :columns="orderColumns" :loading="store.loading" :total="filteredOrders.length" :page-sizes="[10, 20, 50]" row-key="id" empty-text="暂无头程订单">
<template #default="{ row }"> <template #actions="{ row }">
<div class="order-row-actions"> <div class="order-row-actions">
<el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button> <el-button link type="primary" @click="openDetail(row as FirstLegOrder)"><Eye :size="15" />详情</el-button>
<el-button v-if="canEditOrder && canClientEditFirstLegOrder((row as FirstLegOrder).status)" link type="primary" :loading="openingEditor" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button> <el-button v-if="canEditOrder && canClientEditFirstLegOrder((row as FirstLegOrder).status)" link type="primary" :loading="openingEditor" @click="openEdit(row as FirstLegOrder)"><Pencil :size="15" />编辑</el-button>
</div> </div>
</template> </template>
</el-table-column> <template #order="{ row }">
<el-table-column label="订单" min-width="190">
<template #default="{ row }">
<button class="order-number-link" type="button" @click="openDetail(row as FirstLegOrder)">{{ (row as FirstLegOrder).orderNo || '未提交草稿' }}</button> <button class="order-number-link" type="button" @click="openDetail(row as FirstLegOrder)">{{ (row as FirstLegOrder).orderNo || '未提交草稿' }}</button>
<span class="order-reference">{{ (row as FirstLegOrder).referenceNo || '无客户参考号' }}</span> <span class="order-reference">{{ (row as FirstLegOrder).referenceNo || '无客户参考号' }}</span>
</template> </template>
</el-table-column> <template #route="{ row }"><strong>{{ (row as FirstLegOrder).origin }} → {{ (row as FirstLegOrder).destination }}</strong><span class="order-reference">{{ getFirstLegTransportModeLabel((row as FirstLegOrder).transportMode) }}</span></template>
<el-table-column label="运输路线" min-width="190"> <template #cargo="{ row }">{{ (row as FirstLegOrder).items[0]?.cargoNameCn || '-' }}<span v-if="(row as FirstLegOrder).items.length > 1" class="order-reference">等 {{ (row as FirstLegOrder).items.length }} 项</span></template>
<template #default="{ row }"><strong>{{ (row as FirstLegOrder).origin }} → {{ (row as FirstLegOrder).destination }}</strong><span class="order-reference">{{ getFirstLegTransportModeLabel((row as FirstLegOrder).transportMode) }}</span></template> <template #status="{ row }"><el-tag :type="getFirstLegStatusTone((row as FirstLegOrder).status)" effect="light">{{ getFirstLegStatusLabel((row as FirstLegOrder).status) }}</el-tag></template>
</el-table-column> <template #operator="{ row }">{{ (row as FirstLegOrder).operatorName || '-' }}</template>
<el-table-column label="收货仓" min-width="120" prop="receivingWarehouseName" /> <template #updatedAt="{ row }">{{ formatDateTime((row as FirstLegOrder).updatedAt) }}</template>
<el-table-column label="货物" min-width="150"> <template #empty>
<template #default="{ row }">{{ (row as FirstLegOrder).items[0]?.cargoNameCn || '-' }}<span v-if="(row as FirstLegOrder).items.length > 1" class="order-reference">等 {{ (row as FirstLegOrder).items.length }} 项</span></template> <el-empty :description="emptyDescription">
</el-table-column> <el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" />{{ availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓' }}</el-button>
<el-table-column label="状态" width="120"> </el-empty>
<template #default="{ row }"><el-tag :type="getFirstLegStatusTone((row as FirstLegOrder).status)" effect="light">{{ getFirstLegStatusLabel((row as FirstLegOrder).status) }}</el-tag></template> </template>
</el-table-column> </DataTable>
<el-table-column label="负责人" width="110"><template #default="{ row }">{{ (row as FirstLegOrder).operatorName || '-' }}</template></el-table-column> </div>
<el-table-column label="更新时间" width="155"><template #default="{ row }">{{ formatDateTime((row as FirstLegOrder).updatedAt) }}</template></el-table-column> <div v-loading="store.loading" class="mobile-order-surface">
</el-table>
<div v-if="filteredOrders.length" class="mobile-order-list"> <div v-if="filteredOrders.length" class="mobile-order-list">
<article v-for="order in filteredOrders" :key="order.id" class="mobile-order-card"> <article v-for="order in filteredOrders" :key="order.id" class="mobile-order-card">
<header><button type="button" @click="openDetail(order)">{{ order.orderNo || '未提交草稿' }}</button><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></header> <header><button type="button" @click="openDetail(order)">{{ order.orderNo || '未提交草稿' }}</button><el-tag :type="getFirstLegStatusTone(order.status)" effect="light">{{ getFirstLegStatusLabel(order.status) }}</el-tag></header>
@@ -506,32 +583,33 @@ function resetFilters() {
<el-empty v-else :description="emptyDescription"> <el-empty v-else :description="emptyDescription">
<el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" />{{ availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓' }}</el-button> <el-button v-if="auth.hasPermission('order:first-leg:create')" type="primary" :disabled="availableWarehouses.length === 0" @click="openCreate"><Plus :size="16" />{{ availableWarehouses.length ? '创建第一笔订单' : '暂无可用收货仓' }}</el-button>
</el-empty> </el-empty>
</div>
</template> </template>
</div> </div>
<el-drawer v-model="editorOpen" class="first-leg-editor" :title="editingId ? '编辑订单草稿' : '创建头程订单'" size="760px" destroy-on-close :before-close="requestCloseEditor"> <el-drawer v-model="editorOpen" class="first-leg-editor" :title="editingId ? '编辑订单草稿' : '创建头程订单'" size="760px" destroy-on-close :before-close="requestCloseEditor">
<el-form label-position="top" class="order-form"> <el-form ref="orderFormRef" :model="form" :rules="orderFormRules" label-position="top" class="order-form" scroll-to-error>
<section class="order-form-section"> <section class="order-form-section">
<h3>运输信息</h3> <h3>运输信息</h3>
<div class="order-form-grid"> <div class="order-form-grid">
<el-form-item label="运输方式" 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="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="业务类型" 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="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="起运地" required><el-input v-model="form.origin" placeholder="例如:宁波" /></el-form-item> <el-form-item label="起运地" prop="origin" required><el-input v-model="form.origin" placeholder="例如:宁波" /></el-form-item>
<el-form-item label="目的地" required><el-input v-model="form.destination" 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="平台收货仓" 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="平台收货仓" 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="期望出发日期"><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.referenceNo" maxlength="100" 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="优先级"><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="目的国代码" required><el-input v-model="form.destinationCountryCode" maxlength="2" placeholder="例如:US" /></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="目的仓名称" required><el-input v-model="form.destinationWarehouseName" maxlength="128" placeholder="例如:ONT8" /></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="渠道编码" required><el-input v-model="form.channelCode" maxlength="64" /></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="渠道名称" required><el-input v-model="form.channelName" maxlength="128" /></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="提货方式" 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="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="报关模式" 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="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="目的仓类型" 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="目的仓类型" 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 label="贸易术语"><el-input v-model="form.tradeTerm" maxlength="16" placeholder="例如:FOB" /></el-form-item>
<el-form-item v-if="form.destinationType === 'FBA'" label="Amazon 仓库编码"><el-input v-model="form.amazonWarehouseCode" maxlength="32" placeholder="例如:ONT8" /></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"><el-input v-model="form.shipmentId" maxlength="64" /></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>
</div> </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> <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>
@@ -541,7 +619,7 @@ function resetFilters() {
<div class="party-editor-grid"> <div class="party-editor-grid">
<article class="party-editor"> <article class="party-editor">
<header><span><Building2 :size="17" /><strong>发货单位</strong></span><small>必填</small></header> <header><span><Building2 :size="17" /><strong>发货单位</strong></span><small>必填</small></header>
<el-form-item label="选择发货单位" required> <el-form-item label="选择发货单位" prop="shipperPartyId" required>
<el-select v-model="form.shipperPartyId" filterable clearable placeholder="请选择已启用的发货单位" @change="selectShipper" @clear="clearShipper"> <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-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-option v-for="party in availableShippers" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" />
@@ -549,17 +627,17 @@ function resetFilters() {
</el-form-item> </el-form-item>
<div v-if="form.shipperPartySnapshot" class="party-snapshot-fields"> <div v-if="form.shipperPartySnapshot" class="party-snapshot-fields">
<div class="party-name"><strong>{{ form.shipperPartySnapshot.name }}</strong><span>{{ form.shipperPartySnapshot.code }}</span></div> <div class="party-name"><strong>{{ form.shipperPartySnapshot.name }}</strong><span>{{ form.shipperPartySnapshot.code }}</span></div>
<el-form-item label="联系人"><el-input v-model="form.shipperPartySnapshot.contactName" maxlength="50" /></el-form-item> <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="联系电话"><el-input v-model="form.shipperPartySnapshot.phone" maxlength="30" /></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" /></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="本单发货地址"><el-input v-model="form.shipperPartySnapshot.address" maxlength="300" /></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>
</div> </div>
<div v-else class="party-empty">选择后可核对本单联系人和发货地址</div> <div v-else class="party-empty">选择后可核对本单联系人和发货地址</div>
</article> </article>
<article class="party-editor"> <article class="party-editor">
<header><span><Building2 :size="17" /><strong>收货单位</strong></span><small>必填</small></header> <header><span><Building2 :size="17" /><strong>收货单位</strong></span><small>必填</small></header>
<el-form-item label="选择收货单位" required> <el-form-item label="选择收货单位" prop="consigneePartyId" required>
<el-select v-model="form.consigneePartyId" filterable clearable placeholder="请选择已启用的收货单位" @change="selectConsignee" @clear="clearConsignee"> <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-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-option v-for="party in availableConsignees" :key="party.id" :label="`${party.name} · ${party.code}`" :value="party.id" />
@@ -567,10 +645,10 @@ function resetFilters() {
</el-form-item> </el-form-item>
<div v-if="form.consigneePartySnapshot" class="party-snapshot-fields"> <div v-if="form.consigneePartySnapshot" class="party-snapshot-fields">
<div class="party-name"><strong>{{ form.consigneePartySnapshot.name }}</strong><span>{{ form.consigneePartySnapshot.code }}</span></div> <div class="party-name"><strong>{{ form.consigneePartySnapshot.name }}</strong><span>{{ form.consigneePartySnapshot.code }}</span></div>
<el-form-item label="联系人"><el-input v-model="form.consigneePartySnapshot.contactName" maxlength="50" /></el-form-item> <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="联系电话"><el-input v-model="form.consigneePartySnapshot.phone" maxlength="30" /></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" /></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="本单收货地址"><el-input v-model="form.consigneePartySnapshot.address" maxlength="300" /></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>
<div v-else class="party-empty">选择后可核对本单联系人和收货地址</div> <div v-else class="party-empty">选择后可核对本单联系人和收货地址</div>
</article> </article>
@@ -582,23 +660,24 @@ function resetFilters() {
<article v-for="(item, index) in form.items" :key="item.id" class="cargo-editor-card"> <article v-for="(item, index) in form.items" :key="item.id" class="cargo-editor-card">
<header><strong>货物 {{ index + 1 }}</strong><el-button link type="danger" :aria-label="`删除货物 ${index + 1}`" @click="removeCargoItem(index)"><Trash2 :size="16" />删除</el-button></header> <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-form-grid">
<el-form-item label="中文品名" required><el-input v-model="item.cargoNameCn" /></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" /></el-form-item> <el-form-item label="英文品名"><el-input v-model="item.cargoNameEn" placeholder="例如:Bluetooth speakers" /></el-form-item>
<el-form-item label="客户 SKU"><el-input v-model="item.sku" /></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 label="包装类型" required><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 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="HS Code"><el-input v-model="item.hsCode" maxlength="20" /></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="件数" required><el-input-number v-model="item.pieces" :min="1" controls-position="right" /></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="箱数"><el-input-number v-model="item.packageCount" :min="0" 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)" required><el-input-number v-model="item.grossWeightKg" :min="0" :precision="2" 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³)" required><el-input-number v-model="item.volumeCbm" :min="0" :precision="3" 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="申报货值"><el-input-number v-model="item.declaredValue" :min="0" :precision="2" controls-position="right" /></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" /></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> <el-form-item label="危险品"><el-switch v-model="item.dangerousGoods" /></el-form-item>
<template v-if="form.transportMode === 'FCL'"> <template v-if="form.transportMode === 'FCL'">
<el-form-item label="箱型" required><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}.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="柜量" required><el-input-number v-model="item.containerCount" :min="0" controls-position="right" /></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> </template>
<el-form-item label="货物备注" class="cargo-remark"><el-input v-model="item.remark" maxlength="300" /></el-form-item> <el-form-item label="货物备注" class="cargo-remark"><el-input v-model="item.remark" maxlength="300" placeholder="选填,例如:注意防潮" /></el-form-item>
</div> </div>
</article> </article>
</section> </section>
@@ -657,13 +736,7 @@ function resetFilters() {
.supporting-data-error > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; } .supporting-data-error > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.supporting-data-error strong { color: var(--navy); font-size: 13px; } .supporting-data-error strong { color: var(--navy); font-size: 13px; }
.supporting-data-error span { overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.55; } .supporting-data-error span { overflow-wrap: anywhere; color: var(--muted); font-size: 11px; line-height: 1.55; }
.order-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } .order-row-actions, .editor-actions { display: flex; justify-content: flex-end; gap: 8px; }
.order-metrics article { display: flex; align-items: center; justify-content: space-between; min-height: 76px; padding: 16px 20px; border: 1px solid var(--border); background: #fff; }
.order-metrics span { color: var(--muted); font-size: 12px; }
.order-metrics strong { color: var(--navy); font-size: 25px; }
.order-toolbar { display: flex; align-items: stretch; flex-direction: column; gap: 12px; padding: 16px; }
.order-filters { display: grid; width: 100%; grid-template-columns: minmax(190px, 1.35fr) minmax(130px, .65fr) minmax(150px, .75fr) minmax(230px, 1fr); gap: 10px; }
.order-toolbar-actions, .order-row-actions, .editor-actions { display: flex; justify-content: flex-end; gap: 8px; }
.order-table-card { padding: 0; overflow: hidden; } .order-table-card { padding: 0; overflow: hidden; }
.order-load-state { display: grid; min-height: 220px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 28px; color: #9a5a09; } .order-load-state { display: grid; min-height: 220px; grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; gap: 14px; padding: 28px; color: #9a5a09; }
.order-load-state > div { display: flex; min-width: 0; flex-direction: column; gap: 5px; } .order-load-state > div { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
@@ -671,14 +744,17 @@ function resetFilters() {
.order-load-state span { overflow-wrap: anywhere; color: var(--muted); font-size: 12px; line-height: 1.55; } .order-load-state span { overflow-wrap: anywhere; color: var(--muted); font-size: 12px; line-height: 1.55; }
.order-load-state.is-compact { min-height: 180px; } .order-load-state.is-compact { min-height: 180px; }
.order-detail-content { min-height: 180px; } .order-detail-content { min-height: 180px; }
.mobile-order-surface { display: none; min-height: 220px; }
.mobile-order-list { display: none; } .mobile-order-list { display: none; }
.order-number-link { display: block; padding: 0; border: 0; background: none; color: var(--primary); cursor: pointer; font-weight: 700; text-align: left; } .order-number-link { display: block; padding: 0; border: 0; background: none; color: var(--primary); cursor: pointer; font-weight: 700; text-align: left; }
.order-number-link:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; } .order-number-link:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
.order-reference { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; font-weight: 400; } .order-reference { display: block; margin-top: 4px; color: var(--muted); font-size: 11px; font-weight: 400; }
.order-form { padding: 0 2px 28px; } .order-form { padding: 0 2px 28px; }
.order-form :deep(.el-form-item__error) { position: static; padding-top: 6px; overflow-wrap: anywhere; line-height: 1.45; }
.order-form-section + .order-form-section { margin-top: 24px; padding-top: 22px; border-top: 1px solid var(--border); } .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-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 { 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 :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%; }
.selected-warehouse { display: flex; gap: 10px; margin-top: -2px; padding: 12px 14px; border: 1px solid #cfe1dd; background: #f3f8f7; 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 svg { flex: 0 0 17px; margin-top: 2px; }
@@ -735,9 +811,9 @@ function resetFilters() {
.cargo-detail-list span, :deep(.el-timeline-item__content p), :deep(.el-timeline-item__content > span) { color: var(--muted); } .cargo-detail-list span, :deep(.el-timeline-item__content p), :deep(.el-timeline-item__content > span) { color: var(--muted); }
:deep(.el-timeline-item__content p) { margin: 5px 0; font-size: 12px; } :deep(.el-timeline-item__content p) { margin: 5px 0; font-size: 12px; }
:deep(.el-timeline-item__content > span) { font-size: 11px; } :deep(.el-timeline-item__content > span) { font-size: 11px; }
@media (max-width: 1100px) { .order-toolbar-actions { align-self: flex-end; } .order-filters { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 1280px) { @media (max-width: 1280px) {
.desktop-order-table { display: none; } .desktop-order-table { display: none; }
.mobile-order-surface { display: block; }
.mobile-order-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 10px; } .mobile-order-list { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; padding: 10px; }
.mobile-order-card { min-width: 0; padding: 14px; border: 1px solid var(--border); background: #fff; } .mobile-order-card { min-width: 0; padding: 14px; border: 1px solid var(--border); background: #fff; }
.mobile-order-card header, .mobile-order-card footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; } .mobile-order-card header, .mobile-order-card footer { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
@@ -754,11 +830,7 @@ function resetFilters() {
@media (max-width: 720px) { @media (max-width: 720px) {
.order-eligibility { flex-wrap: wrap; } .order-eligibility { flex-wrap: wrap; }
.order-eligibility .el-button { width: 100%; min-height: 44px; } .order-eligibility .el-button { width: 100%; min-height: 44px; }
.order-metrics { grid-template-columns: 1fr; } .order-form-grid, .cargo-form-grid, .order-detail-grid, .party-editor-grid, .party-snapshot-fields { grid-template-columns: 1fr; }
.order-metrics article { min-height: 60px; }
.order-filters, .order-form-grid, .cargo-form-grid, .order-detail-grid, .party-editor-grid, .party-snapshot-fields { grid-template-columns: 1fr; }
.order-toolbar-actions { width: 100%; align-self: stretch; }
.order-toolbar-actions :deep(.el-button) { min-height: 44px; flex: 1; }
.editor-actions :deep(.el-button) { min-height: 44px; } .editor-actions :deep(.el-button) { min-height: 44px; }
.desktop-order-table { display: none; } .desktop-order-table { display: none; }
.order-load-state { min-height: 240px; grid-template-columns: 1fr; justify-items: start; } .order-load-state { min-height: 240px; grid-template-columns: 1fr; justify-items: start; }
@@ -787,4 +859,9 @@ function resetFilters() {
.cargo-detail-list li { flex-direction: column; } .cargo-detail-list li { flex-direction: column; }
:global(.first-leg-editor), :global(.first-leg-detail) { width: 100% !important; } :global(.first-leg-editor), :global(.first-leg-detail) { width: 100% !important; }
} }
@media (max-width: 480px) {
.editor-actions { display: grid; width: 100%; grid-template-columns: repeat(2, minmax(0, 1fr)); }
.editor-actions :deep(.el-button) { width: 100%; min-width: 0; margin-left: 0; }
.editor-actions :deep(.el-button:nth-child(3)) { grid-column: 1 / -1; }
}
</style> </style>
+86 -66
View File
@@ -1,26 +1,59 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { KeyRound, Pencil, Plus, RotateCcw, Search, ShieldCheck, UserCheck, UserRoundX } from '@lucide/vue' import { KeyRound, Pencil, Plus } from '@lucide/vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import { useTenantAdministrationStore, type TenantMember, type TenantRecordStatus } from '@/stores/tenantAdministration' import { useTenantAdministrationStore, type TenantMember, type TenantRecordStatus } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const store = useTenantAdministrationStore() const store = useTenantAdministrationStore()
const auth = useAuthStore() const auth = useAuthStore()
const keyword = ref('') const memberFilters = ref({ keyword: '', status: '' as TenantRecordStatus | '' })
const statusFilter = ref<TenantRecordStatus | ''>('') const appliedMemberFilters = ref({ ...memberFilters.value })
const memberPage = ref(1)
const memberPageSize = ref(10)
const editorOpen = ref(false) const editorOpen = ref(false)
const editingId = ref('') const editingId = ref('')
const loading = ref(false) const loading = ref(false)
const loadError = ref('') const loadError = ref('')
const saving = ref(false) const saving = ref(false)
const formRef = ref<FormInstance>()
const form = reactive({ name: '', account: '', password: '', phone: '', email: '', roleIds: [] as string[] }) const form = reactive({ name: '', account: '', password: '', phone: '', email: '', roleIds: [] as string[] })
const memberRules: FormRules = {
name: [{ required: true, message: '请输入成员姓名', trigger: 'blur' }],
account: [
{ required: true, message: '请输入登录账号', trigger: 'blur' },
{ pattern: /^[A-Za-z0-9_.@-]{4,64}$/, message: '账号需为 4–64 位字母、数字或 _ . @ -', trigger: 'blur' },
],
password: [
{ required: true, message: '请输入初始密码', trigger: 'blur' },
{ pattern: /^(?=.*[A-Za-z])(?=.*\d).{8,64}$/, message: '密码需为 8–64 位,并同时包含字母和数字', trigger: 'blur' },
],
phone: [{ max: 32, message: '联系电话不能超过 32 个字符', trigger: 'blur' }],
email: [{ type: 'email', message: '请输入正确的邮箱地址', trigger: 'blur' }],
roleIds: [{ type: 'array', required: true, min: 1, message: '请至少选择一个成员角色', trigger: 'change' }],
}
const memberQueryFields: QueryField[] = [
{ key: 'keyword', type: 'input', placeholder: '姓名、账号、手机或邮箱' },
{ key: 'status', type: 'select', placeholder: '全部状态', options: [{ label: '正常', value: 'ACTIVE' }, { label: '已停用', value: 'INACTIVE' }] },
]
const memberTableColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', fixed: 'left', width: 230, slotName: 'actions' },
{ key: 'member', label: '成员', minWidth: 170, slotName: 'member' },
{ key: 'contact', label: '联系方式', minWidth: 200, slotName: 'contact' },
{ key: 'roles', label: '角色', minWidth: 130, slotName: 'roles' },
{ key: 'lastLoginAt', prop: 'lastLoginAt', label: '登录记录', minWidth: 130 },
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
]
const filteredMembers = computed(() => store.members.filter((member) => { const filteredMembers = computed(() => store.members.filter((member) => {
const key = keyword.value.trim().toLowerCase() const key = appliedMemberFilters.value.keyword.trim().toLowerCase()
const matchesKeyword = !key || [member.name, member.account, member.phone, member.email].some((value) => value.toLowerCase().includes(key)) const matchesKeyword = !key || [member.name, member.account, member.phone, member.email].some((value) => value.toLowerCase().includes(key))
return matchesKeyword && (!statusFilter.value || member.status === statusFilter.value) return matchesKeyword && (!appliedMemberFilters.value.status || member.status === appliedMemberFilters.value.status)
})) }))
const pagedMembers = computed(() => filteredMembers.value.slice((memberPage.value - 1) * memberPageSize.value, memberPage.value * memberPageSize.value))
async function loadMembers() { async function loadMembers() {
loading.value = true loading.value = true
@@ -36,9 +69,19 @@ async function loadMembers() {
onMounted(() => void loadMembers()) onMounted(() => void loadMembers())
function searchMembers(filters: QueryModel) {
appliedMemberFilters.value = {
keyword: typeof filters.keyword === 'string' ? filters.keyword : '',
status: filters.status === 'ACTIVE' || filters.status === 'INACTIVE' ? filters.status : '',
}
memberPage.value = 1
void loadMembers()
}
function resetForm() { function resetForm() {
editingId.value = '' editingId.value = ''
Object.assign(form, { name: '', account: '', password: '', phone: '', email: '', roleIds: [] }) Object.assign(form, { name: '', account: '', password: '', phone: '', email: '', roleIds: [] })
void nextTick(() => formRef.value?.clearValidate())
} }
function openCreate() { function openCreate() {
@@ -54,17 +97,16 @@ function openEdit(member: TenantMember) {
async function saveMember() { async function saveMember() {
if (saving.value) return if (saving.value) return
if (!form.name.trim() || !/^[A-Za-z0-9_.@-]{4,64}$/.test(form.account) || form.roleIds.length === 0) { form.name = form.name.trim()
ElMessage.warning('请填写姓名、4–64 位合法账号并至少选择一个角色') form.account = form.account.trim()
return form.phone = form.phone.trim()
} form.email = form.email.trim()
if (form.phone.length > 32 || (form.email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email))) { if (!editingId.value || form.password) {
ElMessage.warning('请检查联系电话长度和邮箱格式') const valid = await formRef.value?.validate().catch(() => false)
return if (!valid) return
} } else {
if (!editingId.value && (!/^(?=.*[A-Za-z])(?=.*\d).{8,64}$/.test(form.password))) { const valid = await formRef.value?.validateField(['name', 'account', 'phone', 'email', 'roleIds']).then(() => true).catch(() => false)
ElMessage.warning('初始密码需为 8–64 位,并同时包含字母和数字') if (!valid) return
return
} }
saving.value = true saving.value = true
try { try {
@@ -112,10 +154,6 @@ async function resetPassword(member: TenantMember) {
} }
} }
function clearFilters() {
keyword.value = ''
statusFilter.value = ''
}
</script> </script>
<template> <template>
@@ -125,49 +163,38 @@ function clearFilters() {
<el-button :loading="loading" @click="loadMembers">重新加载</el-button> <el-button :loading="loading" @click="loadMembers">重新加载</el-button>
</div> </div>
<template v-else> <template v-else>
<div class="member-metrics"> <QueryToolbar v-model="memberFilters" ariaLabel="企业成员筛选" :fields="memberQueryFields" search-text="查询成员" @search="searchMembers">
<article><UserCheck :size="20" /><div><span>成员总数</span><strong>{{ store.members.length }}</strong></div></article> <template #actions><el-button v-if="auth.hasPermission('company:member:write')" type="primary" @click="openCreate"><Plus :size="17" />新增成员</el-button></template>
<article><ShieldCheck :size="20" /><div><span>正常成员</span><strong>{{ store.activeMemberCount }}</strong></div></article> </QueryToolbar>
<article><UserRoundX :size="20" /><div><span>已停用</span><strong>{{ store.members.length - store.activeMemberCount }}</strong></div></article>
</div>
<div class="member-toolbar client-card">
<div class="member-filters">
<el-input v-model="keyword" clearable placeholder="姓名、账号、手机或邮箱"><template #prefix><Search :size="16" /></template></el-input>
<el-select v-model="statusFilter" clearable placeholder="全部状态"><el-option label="正常" value="ACTIVE" /><el-option label="已停用" value="INACTIVE" /></el-select>
<el-button @click="clearFilters"><RotateCcw :size="16" />重置</el-button>
</div>
<el-button v-if="auth.hasPermission('company:member:write')" type="primary" @click="openCreate"><Plus :size="17" />新增成员</el-button>
</div>
<div class="client-card member-table-card"> <div class="client-card member-table-card">
<el-table v-if="filteredMembers.length" class="desktop-member-table" :data="filteredMembers" row-key="id"> <DataTable v-model:page="memberPage" v-model:page-size="memberPageSize" class="desktop-member-table" :data="pagedMembers" :columns="memberTableColumns" :loading="loading" :total="filteredMembers.length" row-key="id">
<el-table-column label="操作" width="230" fixed="left"><template #default="{ row }"><el-button v-if="auth.hasPermission('company:member:write')" link type="primary" @click="openEdit(row as TenantMember)"><Pencil :size="15" />编辑</el-button><el-button v-if="auth.hasPermission('company:member:reset-password')" link type="primary" @click="resetPassword(row as TenantMember)"><KeyRound :size="15" />重置密码</el-button><el-button v-if="auth.hasPermission('company:member:write') && !row.isAdministrator" link :type="row.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as TenantMember)">{{ row.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!auth.hasPermission('company:member:write') && !auth.hasPermission('company:member:reset-password')" class="readonly-action">仅查看</span></template></el-table-column> <template #actions="{ row }"><el-button v-if="auth.hasPermission('company:member:write')" link type="primary" @click="openEdit(row as TenantMember)"><Pencil :size="15" />编辑</el-button><el-button v-if="auth.hasPermission('company:member:reset-password')" link type="primary" @click="resetPassword(row as TenantMember)"><KeyRound :size="15" />重置密码</el-button><el-button v-if="auth.hasPermission('company:member:write') && !(row as TenantMember).isAdministrator" link :type="(row as TenantMember).status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(row as TenantMember)">{{ (row as TenantMember).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!auth.hasPermission('company:member:write') && !auth.hasPermission('company:member:reset-password')" class="readonly-action">仅查看</span></template>
<el-table-column label="成员" min-width="170"><template #default="{ row }"><div class="member-identity"><span>{{ row.name.slice(0, 1) }}</span><div><strong>{{ row.name }}</strong><small>{{ row.account }}</small></div></div></template></el-table-column> <template #member="{ row }"><div class="member-identity"><span>{{ (row as TenantMember).name.slice(0, 1) }}</span><div><strong>{{ (row as TenantMember).name }}</strong><small>{{ (row as TenantMember).account }}</small></div></div></template>
<el-table-column label="联系方式" min-width="200"><template #default="{ row }"><div class="member-contact"><span>{{ row.phone }}</span><small>{{ row.email || '-' }}</small></div></template></el-table-column> <template #contact="{ row }"><div class="member-contact"><span>{{ (row as TenantMember).phone }}</span><small>{{ (row as TenantMember).email || '-' }}</small></div></template>
<el-table-column label="角色" min-width="130"><template #default="{ row }"><div class="role-tags"><el-tag v-for="name in store.roleNames(row.roleIds)" :key="name" effect="plain">{{ name }}</el-tag><span v-if="store.roleNames(row.roleIds).length === 0" class="readonly-action">未分配</span></div></template></el-table-column> <template #roles="{ row }"><div class="role-tags"><el-tag v-for="name in store.roleNames((row as TenantMember).roleIds)" :key="name" effect="plain">{{ name }}</el-tag><span v-if="store.roleNames((row as TenantMember).roleIds).length === 0" class="readonly-action">未分配</span></div></template>
<el-table-column prop="lastLoginAt" label="登录记录" min-width="130" /> <template #status="{ row }"><el-tag :type="(row as TenantMember).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as TenantMember).status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></template>
<el-table-column label="状态" width="90"><template #default="{ row }"><el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ row.status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></template></el-table-column> <template #empty><div class="client-empty">没有匹配的企业成员,请调整筛选条件。</div></template>
</el-table> </DataTable>
<div v-if="filteredMembers.length" class="mobile-member-list"> <div v-if="pagedMembers.length" class="mobile-member-list">
<article v-for="member in filteredMembers" :key="member.id"> <article v-for="member in pagedMembers" :key="member.id">
<header><div class="member-identity"><span>{{ member.name.slice(0, 1) }}</span><div><strong>{{ member.name }}</strong><small>{{ member.account }}</small></div></div><el-tag :type="member.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ member.status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></header> <header><div class="member-identity"><span>{{ member.name.slice(0, 1) }}</span><div><strong>{{ member.name }}</strong><small>{{ member.account }}</small></div></div><el-tag :type="member.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ member.status === 'ACTIVE' ? '正常' : '已停用' }}</el-tag></header>
<dl><div><dt>手机</dt><dd>{{ member.phone }}</dd></div><div><dt>最近登录</dt><dd>{{ member.lastLoginAt }}</dd></div><div class="full"><dt>角色</dt><dd>{{ store.roleNames(member.roleIds).join('、') }}</dd></div></dl> <dl><div><dt>手机</dt><dd>{{ member.phone }}</dd></div><div><dt>最近登录</dt><dd>{{ member.lastLoginAt }}</dd></div><div class="full"><dt>角色</dt><dd>{{ store.roleNames(member.roleIds).join('、') }}</dd></div></dl>
<footer><el-button v-if="auth.hasPermission('company:member:write')" link type="primary" @click="openEdit(member)">编辑</el-button><el-button v-if="auth.hasPermission('company:member:reset-password')" link type="primary" @click="resetPassword(member)">重置密码</el-button><el-button v-if="auth.hasPermission('company:member:write') && !member.isAdministrator" link :type="member.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(member)">{{ member.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!auth.hasPermission('company:member:write') && !auth.hasPermission('company:member:reset-password')" class="readonly-action">仅查看</span></footer> <footer><el-button v-if="auth.hasPermission('company:member:write')" link type="primary" @click="openEdit(member)">编辑</el-button><el-button v-if="auth.hasPermission('company:member:reset-password')" link type="primary" @click="resetPassword(member)">重置密码</el-button><el-button v-if="auth.hasPermission('company:member:write') && !member.isAdministrator" link :type="member.status === 'ACTIVE' ? 'danger' : 'success'" @click="toggleStatus(member)">{{ member.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><span v-if="!auth.hasPermission('company:member:write') && !auth.hasPermission('company:member:reset-password')" class="readonly-action">仅查看</span></footer>
</article> </article>
</div> </div>
<div v-else class="client-empty">没有匹配的企业成员,请调整筛选条件。</div> <div v-if="!pagedMembers.length" class="mobile-member-empty client-empty">没有匹配的企业成员,请调整筛选条件。</div>
</div> </div>
<el-drawer v-model="editorOpen" :title="editingId ? '编辑成员' : '新增成员'" size="min(520px, 94vw)" @closed="resetForm"> <el-drawer v-model="editorOpen" :title="editingId ? '编辑成员' : '新增成员'" size="min(520px, 94vw)" @closed="resetForm">
<el-form label-position="top"> <el-form ref="formRef" :model="form" :rules="memberRules" label-position="top">
<el-form-item label="成员姓名" required><el-input v-model="form.name" maxlength="64" /></el-form-item> <el-form-item label="成员姓名" prop="name" required><el-input v-model="form.name" maxlength="64" placeholder="例如 张三" /></el-form-item>
<el-form-item label="登录账号" required><el-input v-model="form.account" :disabled="Boolean(editingId)" maxlength="64" placeholder="4–64 位字母、数字或 _ . @ -" /></el-form-item> <el-form-item label="登录账号" prop="account" required><el-input v-model="form.account" :disabled="Boolean(editingId)" maxlength="64" placeholder="4–64 位字母、数字或 _ . @ -" /></el-form-item>
<el-form-item v-if="!editingId" label="初始密码" required><el-input v-model="form.password" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="8–64 位,同时包含字母和数字" /></el-form-item> <el-form-item v-if="!editingId" label="初始密码" prop="password" required><el-input v-model="form.password" type="password" show-password autocomplete="new-password" maxlength="64" placeholder="8–64 位,同时包含字母和数字" /></el-form-item>
<div class="member-form-grid"><el-form-item label="联系电话"><el-input v-model="form.phone" maxlength="32" placeholder="选填" /></el-form-item><el-form-item label="邮箱"><el-input v-model="form.email" maxlength="128" placeholder="选填" /></el-form-item></div> <div class="member-form-grid"><el-form-item label="联系电话" prop="phone"><el-input v-model="form.phone" maxlength="32" placeholder="例如 13800138000(选填)" /></el-form-item><el-form-item label="邮箱" prop="email"><el-input v-model="form.email" maxlength="128" placeholder="例如 name@example.com(选填)" /></el-form-item></div>
<el-form-item label="成员角色" required><el-select v-model="form.roleIds" multiple placeholder="请选择角色"><el-option v-for="role in store.roles.filter((item) => item.status === 'ACTIVE')" :key="role.id" :label="role.name" :value="role.id" /></el-select></el-form-item> <el-form-item label="成员角色" prop="roleIds" required><el-select v-model="form.roleIds" multiple placeholder="请选择角色"><el-option v-for="role in store.roles.filter((item) => item.status === 'ACTIVE')" :key="role.id" :label="role.name" :value="role.id" /></el-select></el-form-item>
<div v-if="!editingId" class="member-form-tip"><KeyRound :size="17" /><span>请通过安全渠道将初始密码交给成员;前端不会保存或再次展示该密码。</span></div> <div v-if="!editingId" class="member-form-tip"><KeyRound :size="17" /><span>请通过安全渠道将初始密码交给成员;前端不会保存或再次展示该密码。</span></div>
</el-form> </el-form>
<template #footer><div class="drawer-actions"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveMember">保存</el-button></div></template> <template #footer><div class="drawer-actions"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveMember">保存</el-button></div></template>
@@ -181,13 +208,6 @@ function clearFilters() {
.collection-load-error { display: flex; min-height: 150px; align-items: center; justify-content: center; gap: 20px; } .collection-load-error { display: flex; min-height: 150px; align-items: center; justify-content: center; gap: 20px; }
.collection-load-error > div { display: flex; max-width: 520px; flex-direction: column; gap: 6px; } .collection-load-error > div { display: flex; max-width: 520px; flex-direction: column; gap: 6px; }
.collection-load-error span { color: var(--muted); font-size: 12px; line-height: 1.6; } .collection-load-error span { color: var(--muted); font-size: 12px; line-height: 1.6; }
.member-metrics { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 14px; }
.member-metrics article { display: flex; align-items: center; gap: 14px; padding: 18px 20px; border: 1px solid var(--border); background: #fff; color: var(--primary); }
.member-metrics article div { display: flex; flex-direction: column; gap: 2px; }
.member-metrics span { color: var(--muted); font-size: 11px; }
.member-metrics strong { color: var(--navy); font-size: 23px; }
.member-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 16px; }
.member-filters { display: grid; flex: 1; grid-template-columns: minmax(210px, 1fr) 150px auto; gap: 10px; }
.member-table-card { padding: 0; overflow: hidden; } .member-table-card { padding: 0; overflow: hidden; }
.member-identity { display: flex; align-items: center; gap: 10px; } .member-identity { display: flex; align-items: center; gap: 10px; }
.member-identity > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 50%; background: #e7f3f1; color: var(--primary); font-weight: 750; } .member-identity > span { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; border-radius: 50%; background: #e7f3f1; color: var(--primary); font-weight: 750; }
@@ -197,16 +217,15 @@ function clearFilters() {
.role-tags { display: flex; gap: 5px; flex-wrap: wrap; } .role-tags { display: flex; gap: 5px; flex-wrap: wrap; }
.mobile-member-list { display: none; } .mobile-member-list { display: none; }
.member-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; } .member-form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.member-form-tip { display: flex; gap: 10px; padding: 13px; border-left: 3px solid var(--primary); background: #f2f7f6; color: var(--muted); font-size: 11px; line-height: 1.6; } .member-form-tip { display: flex; gap: 10px; padding: 13px; border: 1px solid #cfe1dc; background: #f2f7f6; color: var(--muted); font-size: 11px; line-height: 1.6; }
.member-form-tip svg { flex: 0 0 17px; color: var(--primary); } .member-form-tip svg { flex: 0 0 17px; color: var(--primary); }
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; } .drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
@media (max-width: 900px) { .member-toolbar { align-items: stretch; flex-direction: column; } .member-toolbar > .el-button { align-self: flex-end; } } .members-page :deep(.el-form-item__error) { line-height: 1.45; overflow-wrap: anywhere; }
@media (max-width: 720px) { @media (max-width: 720px) {
.member-metrics { grid-template-columns: 1fr; } .member-form-grid { grid-template-columns: 1fr; }
.member-filters, .member-form-grid { grid-template-columns: 1fr; } .desktop-member-table :deep(.el-table) { display: none; }
.member-toolbar > .el-button { width: 100%; }
.desktop-member-table { display: none; }
.mobile-member-list { display: grid; gap: 10px; padding: 10px; } .mobile-member-list { display: grid; gap: 10px; padding: 10px; }
.mobile-member-empty { display: grid; min-height: 150px; place-items: center; }
.mobile-member-list article { padding: 14px; border: 1px solid var(--border); background: #fff; } .mobile-member-list article { padding: 14px; border: 1px solid var(--border); background: #fff; }
.mobile-member-list header { display: flex; align-items: center; justify-content: space-between; } .mobile-member-list header { display: flex; align-items: center; justify-content: space-between; }
.mobile-member-list dl { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0; } .mobile-member-list dl { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin: 16px 0; }
@@ -214,5 +233,6 @@ function clearFilters() {
.mobile-member-list dt { color: var(--muted); font-size: 10px; } .mobile-member-list dt { color: var(--muted); font-size: 10px; }
.mobile-member-list dd { margin: 4px 0 0; font-size: 12px; } .mobile-member-list dd { margin: 4px 0 0; font-size: 12px; }
.mobile-member-list footer { display: flex; justify-content: flex-end; padding-top: 10px; border-top: 1px solid var(--border); } .mobile-member-list footer { display: flex; justify-content: flex-end; padding-top: 10px; border-top: 1px solid var(--border); }
.drawer-actions { flex-wrap: wrap; }
} }
</style> </style>
+9 -4
View File
@@ -15,6 +15,7 @@ const selectedCodes = ref<string[]>([])
const savedCodes = ref<string[]>([]) const savedCodes = ref<string[]>([])
const loading = ref(false) const loading = ref(false)
const loadError = ref('') const loadError = ref('')
const saving = ref(false)
const selectedRole = computed(() => store.roles.find((role) => role.id === selectedRoleId.value)) const selectedRole = computed(() => store.roles.find((role) => role.id === selectedRoleId.value))
const editable = computed(() => Boolean(auth.hasPermission('company:permission:assign') && selectedRole.value && !selectedRole.value.builtin && selectedRole.value.status === 'ACTIVE')) const editable = computed(() => Boolean(auth.hasPermission('company:permission:assign') && selectedRole.value && !selectedRole.value.builtin && selectedRole.value.status === 'ACTIVE'))
@@ -70,15 +71,18 @@ function resetPermissions() {
} }
async function savePermissions() { async function savePermissions() {
if (!selectedRole.value || !editable.value) return if (!selectedRole.value || !editable.value || saving.value) return
if (!selectedCodes.value.includes('account:profile:view')) selectedCodes.value.unshift('account:profile:view') if (!selectedCodes.value.includes('account:profile:view')) selectedCodes.value.unshift('account:profile:view')
if (!selectedCodes.value.includes('dashboard:view')) selectedCodes.value.unshift('dashboard:view') if (!selectedCodes.value.includes('dashboard:view')) selectedCodes.value.unshift('dashboard:view')
saving.value = true
try { try {
await store.setRolePermissions(selectedRole.value.id, selectedCodes.value) await store.setRolePermissions(selectedRole.value.id, selectedCodes.value)
savedCodes.value = [...selectedCodes.value] savedCodes.value = [...selectedCodes.value]
ElMessage.success('角色菜单及操作权限已保存') ElMessage.success('角色菜单及操作权限已保存')
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存失败') ElMessage.error(error instanceof Error ? error.message : '保存失败')
} finally {
saving.value = false
} }
} }
</script> </script>
@@ -102,7 +106,7 @@ async function savePermissions() {
<div class="permission-content"> <div class="permission-content">
<div class="permission-heading client-card"> <div class="permission-heading client-card">
<div><span class="section-label">PERMISSIONS</span><h2>{{ selectedRole?.name }}</h2><p>{{ selectedRole?.description }}</p></div> <div><span class="section-label">PERMISSIONS</span><h2>{{ selectedRole?.name }}</h2><p>{{ selectedRole?.description }}</p></div>
<div class="permission-actions"><el-button :disabled="!editable || !hasChanges" @click="resetPermissions"><RotateCcw :size="16" />撤销修改</el-button><el-button type="primary" :disabled="!editable || !hasChanges" @click="savePermissions"><Save :size="16" />保存权限</el-button></div> <div class="permission-actions"><el-button :disabled="!editable || !hasChanges || saving" @click="resetPermissions"><RotateCcw :size="16" />撤销修改</el-button><el-button type="primary" :loading="saving" :disabled="!editable || !hasChanges || saving" @click="savePermissions"><Save :size="16" />保存权限</el-button></div>
</div> </div>
<div v-if="selectedRole?.builtin" class="permission-notice"><LockKeyhole :size="18" /><span>当前角色为系统内置角色,权限由平台统一维护,不允许修改。</span></div> <div v-if="selectedRole?.builtin" class="permission-notice"><LockKeyhole :size="18" /><span>当前角色为系统内置角色,权限由平台统一维护,不允许修改。</span></div>
@@ -141,6 +145,7 @@ async function savePermissions() {
.role-selector h3 { margin-bottom: 0; } .role-selector h3 { margin-bottom: 0; }
.role-selector > button { display: flex; width: 100%; min-height: 58px; align-items: center; gap: 9px; margin: 3px 0; padding: 9px; border: 0; border-radius: 8px; background: transparent; color: var(--navy); cursor: pointer; text-align: left; } .role-selector > button { display: flex; width: 100%; min-height: 58px; align-items: center; gap: 9px; margin: 3px 0; padding: 9px; border: 0; border-radius: 8px; background: transparent; color: var(--navy); cursor: pointer; text-align: left; }
.role-selector > button:hover, .role-selector > button.active { background: #edf6f4; } .role-selector > button:hover, .role-selector > button.active { background: #edf6f4; }
.role-selector > button:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; }
.role-selector > button.active { color: var(--primary); } .role-selector > button.active { color: var(--primary); }
.role-select-icon { display: grid; width: 32px; height: 32px; flex: 0 0 32px; place-items: center; background: #f0f5f4; } .role-select-icon { display: grid; width: 32px; height: 32px; flex: 0 0 32px; place-items: center; background: #f0f5f4; }
.role-selector > button > span:nth-child(2) { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; } .role-selector > button > span:nth-child(2) { display: flex; min-width: 0; flex: 1; flex-direction: column; gap: 3px; }
@@ -151,8 +156,8 @@ async function savePermissions() {
.permission-heading h2 { margin: 5px 0; font-size: 19px; } .permission-heading h2 { margin: 5px 0; font-size: 19px; }
.permission-heading p { margin: 0; color: var(--muted); font-size: 11px; } .permission-heading p { margin: 0; color: var(--muted); font-size: 11px; }
.permission-actions { display: flex; gap: 8px; } .permission-actions { display: flex; gap: 8px; }
.permission-notice { display: flex; align-items: center; gap: 9px; padding: 12px 15px; border-left: 3px solid var(--primary); background: #edf6f4; color: #45615d; font-size: 11px; } .permission-notice { display: flex; align-items: center; gap: 9px; padding: 12px 15px; border: 1px solid #cfe1dc; background: #edf6f4; color: #45615d; font-size: 11px; }
.permission-notice.warning { border-left-color: #d97706; background: #fff7ed; color: #9a5a09; } .permission-notice.warning { border-color: #f1d4a8; background: #fff7ed; color: #9a5a09; }
.permission-groups { display: grid; gap: 12px; } .permission-groups { display: grid; gap: 12px; }
.permission-group { padding: 0; overflow: hidden; } .permission-group { padding: 0; overflow: hidden; }
.permission-group > header { padding: 14px 18px; border-bottom: 1px solid var(--border); background: #fafcfc; } .permission-group > header { padding: 14px 18px; border-bottom: 1px solid var(--border); background: #fafcfc; }
+1 -1
View File
@@ -115,7 +115,7 @@ function showDocument(name: string) {
</div> </div>
<el-form-item prop="agreement" class="agreement-item"> <el-form-item prop="agreement" class="agreement-item">
<div class="agreement-control"> <div class="agreement-control">
<el-checkbox v-model="form.agreement">我已阅读并同意</el-checkbox> <el-checkbox v-model="form.agreement"><span class="agreement-required" aria-hidden="true">*</span>我已阅读并同意</el-checkbox>
<span><button type="button" @click="showDocument('《用户服务协议》')">《用户服务协议》</button> 和 <button type="button" @click="showDocument('《隐私政策》')">《隐私政策》</button></span> <span><button type="button" @click="showDocument('《用户服务协议》')">《用户服务协议》</button> 和 <button type="button" @click="showDocument('《隐私政策》')">《隐私政策》</button></span>
</div> </div>
</el-form-item> </el-form-item>
+63 -51
View File
@@ -1,31 +1,53 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue' import { computed, nextTick, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { LockKeyhole, Pencil, Plus, Search, ShieldCheck, Trash2, Users } from '@lucide/vue' import { LockKeyhole, Pencil, Plus, ShieldCheck, Trash2 } from '@lucide/vue'
import QueryToolbar, { type QueryField, type QueryModel } from '@/components/common/QueryToolbar.vue'
import DataTable, { type DataTableColumn } from '@/components/common/DataTable.vue'
import { useTenantAdministrationStore, type TenantRole } from '@/stores/tenantAdministration' import { useTenantAdministrationStore, type TenantRole } from '@/stores/tenantAdministration'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
const store = useTenantAdministrationStore() const store = useTenantAdministrationStore()
const auth = useAuthStore() const auth = useAuthStore()
const router = useRouter() const router = useRouter()
const keyword = ref('') const roleFilters = ref({ keyword: '' })
const appliedRoleFilters = ref({ ...roleFilters.value })
const rolePage = ref(1)
const rolePageSize = ref(10)
const editorOpen = ref(false) const editorOpen = ref(false)
const editingId = ref('') const editingId = ref('')
const loading = ref(false) const loading = ref(false)
const loadError = ref('') const loadError = ref('')
const saving = ref(false) const saving = ref(false)
const actionRoleId = ref('') const actionRoleId = ref('')
const formRef = ref<FormInstance>()
const form = reactive({ name: '', code: '', description: '' }) const form = reactive({ name: '', code: '', description: '' })
const roleRules: FormRules = {
name: [{ required: true, message: '请输入角色名称', trigger: 'blur' }],
code: [{ required: true, message: '请输入角色编码', trigger: 'blur' }],
description: [{ required: true, message: '请填写角色说明', trigger: 'blur' }],
}
const roleQueryFields: QueryField[] = [{ key: 'keyword', type: 'input', placeholder: '搜索角色名称、编码或说明' }]
const roleTableColumns: DataTableColumn[] = [
{ key: 'actions', label: '操作', fixed: 'left', width: 250, slotName: 'actions' },
{ key: 'role', label: '角色', minWidth: 180, slotName: 'role' },
{ key: 'description', prop: 'description', label: '角色说明', minWidth: 220 },
{ key: 'memberCount', label: '成员数', width: 100, slotName: 'memberCount' },
{ key: 'permissionCount', label: '权限数', width: 100, slotName: 'permissionCount' },
{ key: 'status', label: '状态', width: 90, slotName: 'status' },
]
const filteredRoles = computed(() => { const filteredRoles = computed(() => {
const key = keyword.value.trim().toLowerCase() const key = appliedRoleFilters.value.keyword.trim().toLowerCase()
return store.roles.filter((role) => !key || [role.name, role.code, role.description].some((value) => value.toLowerCase().includes(key))) return store.roles.filter((role) => !key || [role.name, role.code, role.description].some((value) => value.toLowerCase().includes(key)))
}) })
const pagedRoles = computed(() => filteredRoles.value.slice((rolePage.value - 1) * rolePageSize.value, rolePage.value * rolePageSize.value))
function resetForm() { function resetForm() {
editingId.value = '' editingId.value = ''
Object.assign(form, { name: '', code: '', description: '' }) Object.assign(form, { name: '', code: '', description: '' })
void nextTick(() => formRef.value?.clearValidate())
} }
function openCreate() { function openCreate() {
@@ -55,11 +77,19 @@ async function loadRoles() {
onMounted(() => void loadRoles()) onMounted(() => void loadRoles())
function searchRoles(filters: QueryModel) {
appliedRoleFilters.value = { keyword: typeof filters.keyword === 'string' ? filters.keyword : '' }
rolePage.value = 1
void loadRoles()
}
async function saveRole() { async function saveRole() {
if (!form.name || !form.code || !form.description) { if (saving.value) return
ElMessage.warning('请完整填写角色名称、编码和说明') form.name = form.name.trim()
return form.code = form.code.trim().toUpperCase()
} form.description = form.description.trim()
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) return
saving.value = true saving.value = true
try { try {
if (editingId.value) await store.updateRole(editingId.value, { name: form.name, description: form.description }) if (editingId.value) await store.updateRole(editingId.value, { name: form.name, description: form.description })
@@ -114,28 +144,26 @@ function assignPermissions(role: TenantRole) {
<el-button :loading="loading" @click="loadRoles">重新加载</el-button> <el-button :loading="loading" @click="loadRoles">重新加载</el-button>
</div> </div>
<template v-else> <template v-else>
<div class="role-guidance client-card"><ShieldCheck :size="22" /><div><strong>角色用于约束企业内部成员可见菜单和可执行操作</strong><span>企业管理员为系统内置角色,不能停用或删除;平台后台权限不会出现在客户工作台。</span></div></div> <QueryToolbar v-model="roleFilters" ariaLabel="企业角色筛选" :fields="roleQueryFields" search-text="查询角色" @search="searchRoles">
<template #actions><el-button v-if="auth.hasPermission('company:role:write')" type="primary" @click="openCreate"><Plus :size="17" />新增角色</el-button></template>
</QueryToolbar>
<div class="role-toolbar client-card"> <div class="client-card role-table-card">
<el-input v-model="keyword" clearable placeholder="搜索角色名称、编码或说明"><template #prefix><Search :size="16" /></template></el-input> <DataTable v-model:page="rolePage" v-model:page-size="rolePageSize" :data="pagedRoles" :columns="roleTableColumns" :loading="loading" :total="filteredRoles.length" row-key="id">
<el-button v-if="auth.hasPermission('company:role:write')" type="primary" @click="openCreate"><Plus :size="17" />新增角色</el-button> <template #actions="{ row }"><el-button v-if="auth.hasPermission('company:permission:list')" link type="primary" :disabled="actionRoleId === (row as TenantRole).id" @click="assignPermissions(row as TenantRole)">{{ auth.hasPermission('company:permission:assign') && !(row as TenantRole).builtin ? '分配权限' : '查看权限' }}</el-button><el-button v-if="auth.hasPermission('company:role:write') && !(row as TenantRole).builtin" link type="primary" :disabled="actionRoleId === (row as TenantRole).id" @click="openEdit(row as TenantRole)"><Pencil :size="15" />编辑</el-button><el-button v-if="auth.hasPermission('company:role:write') && !(row as TenantRole).builtin" link :type="(row as TenantRole).status === 'ACTIVE' ? 'warning' : 'success'" :loading="actionRoleId === (row as TenantRole).id" @click="toggleStatus(row as TenantRole)">{{ (row as TenantRole).status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="auth.hasPermission('company:role:write') && !(row as TenantRole).builtin" link type="danger" :disabled="actionRoleId === (row as TenantRole).id" @click="removeRole(row as TenantRole)"><Trash2 :size="15" />删除</el-button></template>
<template #role="{ row }"><div class="role-identity"><span class="role-icon"><LockKeyhole v-if="(row as TenantRole).builtin" :size="20" /><ShieldCheck v-else :size="20" /></span><div><strong>{{ (row as TenantRole).name }}</strong><code>{{ (row as TenantRole).code }}</code></div></div></template>
<template #memberCount="{ row }"><span>{{ store.roleMemberCount((row as TenantRole).id) }}</span></template>
<template #permissionCount="{ row }"><span>{{ (row as TenantRole).permissionCodes.length }}</span></template>
<template #status="{ row }"><el-tag v-if="(row as TenantRole).builtin" effect="plain">内置</el-tag><el-tag :type="(row as TenantRole).status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ (row as TenantRole).status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></template>
<template #empty><div class="client-empty">没有匹配的企业角色。</div></template>
</DataTable>
</div> </div>
<div v-if="filteredRoles.length" class="role-grid">
<article v-for="role in filteredRoles" :key="role.id" class="client-card role-card" :class="{ inactive: role.status === 'INACTIVE' }">
<header><div class="role-icon"><LockKeyhole v-if="role.builtin" :size="20" /><ShieldCheck v-else :size="20" /></div><div class="role-heading"><div><h3>{{ role.name }}</h3><el-tag v-if="role.builtin" effect="plain">内置</el-tag><el-tag :type="role.status === 'ACTIVE' ? 'success' : 'info'" effect="light">{{ role.status === 'ACTIVE' ? '启用' : '停用' }}</el-tag></div><code>{{ role.code }}</code></div></header>
<p>{{ role.description }}</p>
<dl><div><dt><Users :size="15" />成员数</dt><dd>{{ store.roleMemberCount(role.id) }}</dd></div><div><dt><ShieldCheck :size="15" />权限数</dt><dd>{{ role.permissionCodes.length }}</dd></div></dl>
<footer><el-button v-if="auth.hasPermission('company:permission:list')" link type="primary" :disabled="actionRoleId === role.id" @click="assignPermissions(role)">{{ auth.hasPermission('company:permission:assign') && !role.builtin ? '分配权限' : '查看权限' }}</el-button><el-button v-if="auth.hasPermission('company:role:write') && !role.builtin" link type="primary" :disabled="actionRoleId === role.id" @click="openEdit(role)"><Pencil :size="15" />编辑</el-button><el-button v-if="auth.hasPermission('company:role:write') && !role.builtin" link :type="role.status === 'ACTIVE' ? 'warning' : 'success'" :loading="actionRoleId === role.id" @click="toggleStatus(role)">{{ role.status === 'ACTIVE' ? '停用' : '启用' }}</el-button><el-button v-if="auth.hasPermission('company:role:write') && !role.builtin" link type="danger" :disabled="actionRoleId === role.id" @click="removeRole(role)"><Trash2 :size="15" />删除</el-button></footer>
</article>
</div>
<div v-else class="client-card client-empty">没有匹配的企业角色。</div>
<el-drawer v-model="editorOpen" :title="editingId ? '编辑角色' : '新增角色'" size="min(500px, 94vw)" @closed="resetForm"> <el-drawer v-model="editorOpen" :title="editingId ? '编辑角色' : '新增角色'" size="min(500px, 94vw)" @closed="resetForm">
<el-form label-position="top"> <el-form ref="formRef" :model="form" :rules="roleRules" label-position="top">
<el-form-item label="角色名称" required><el-input v-model="form.name" maxlength="30" /></el-form-item> <el-form-item label="角色名称" prop="name" required><el-input v-model="form.name" maxlength="30" placeholder="例如 订单操作员" /></el-form-item>
<el-form-item label="角色编码" required><el-input v-model="form.code" :disabled="Boolean(editingId)" placeholder="例如 ORDER_OPERATOR" maxlength="40" /></el-form-item> <el-form-item label="角色编码" prop="code" required><el-input v-model="form.code" :disabled="Boolean(editingId)" placeholder="例如 ORDER_OPERATOR" maxlength="40" /></el-form-item>
<el-form-item label="角色说明" required><el-input v-model="form.description" type="textarea" :rows="4" maxlength="200" show-word-limit /></el-form-item> <el-form-item label="角色说明" prop="description" required><el-input v-model="form.description" type="textarea" :rows="4" maxlength="200" show-word-limit placeholder="简要说明该角色的职责和适用范围" /></el-form-item>
<div class="role-form-tip">保存后请前往“菜单权限”为角色分配可见菜单和操作权限。</div> <div class="role-form-tip">保存后请前往“菜单权限”为角色分配可见菜单和操作权限。</div>
</el-form> </el-form>
<template #footer><div class="drawer-actions"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveRole">保存</el-button></div></template> <template #footer><div class="drawer-actions"><el-button :disabled="saving" @click="editorOpen = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveRole">保存</el-button></div></template>
@@ -146,33 +174,17 @@ function assignPermissions(role: TenantRole) {
<style scoped> <style scoped>
.roles-page { display: flex; flex-direction: column; gap: 14px; } .roles-page { display: flex; flex-direction: column; gap: 14px; }
.role-guidance { display: flex; align-items: center; gap: 13px; padding: 17px 20px; border-left: 4px solid var(--primary); color: var(--primary); }
.role-guidance > div { display: flex; flex-direction: column; gap: 4px; }
.role-guidance strong { color: var(--navy); font-size: 13px; }
.role-guidance span { color: var(--muted); font-size: 11px; }
.collection-load-error { display: flex; min-height: 150px; align-items: center; justify-content: center; gap: 20px; } .collection-load-error { display: flex; min-height: 150px; align-items: center; justify-content: center; gap: 20px; }
.collection-load-error > div { display: flex; max-width: 520px; flex-direction: column; gap: 6px; } .collection-load-error > div { display: flex; max-width: 520px; flex-direction: column; gap: 6px; }
.collection-load-error span { color: var(--muted); font-size: 12px; line-height: 1.6; } .collection-load-error span { color: var(--muted); font-size: 12px; line-height: 1.6; }
.role-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 16px; } .role-table-card { padding: 0; overflow: hidden; }
.role-toolbar .el-input { max-width: 360px; } .role-identity { display: flex; min-width: 0; align-items: center; gap: 10px; }
.role-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } .role-icon { display: grid; width: 34px; height: 34px; flex: 0 0 34px; place-items: center; background: #e7f3f1; color: var(--primary); }
.role-card { display: flex; min-width: 0; flex-direction: column; padding: 20px; } .role-identity > div { display: flex; min-width: 0; flex-direction: column; gap: 3px; }
.role-card.inactive { opacity: .72; } .role-identity strong { overflow-wrap: anywhere; }
.role-card header { justify-content: flex-start; gap: 12px; } .role-identity code { color: var(--muted); font-size: 10px; }
.role-icon { display: grid; width: 42px; height: 42px; flex: 0 0 42px; place-items: center; background: #e7f3f1; color: var(--primary); }
.role-heading { min-width: 0; }
.role-heading > div { display: flex; align-items: center; gap: 7px; }
.role-heading h3 { margin: 0; font-size: 16px; }
.role-heading code { display: block; margin-top: 5px; color: var(--muted); font-size: 10px; }
.role-card > p { flex: 1; min-height: 42px; margin: 18px 0; color: var(--muted); font-size: 12px; line-height: 1.7; }
.role-card dl { display: grid; grid-template-columns: 1fr 1fr; margin: 0; border: 1px solid var(--border); }
.role-card dl div { display: flex; align-items: center; justify-content: space-between; padding: 11px 13px; }
.role-card dl div:first-child { border-right: 1px solid var(--border); }
.role-card dt { display: flex; align-items: center; gap: 6px; color: var(--muted); font-size: 11px; }
.role-card dd { margin: 0; color: var(--navy); font-weight: 750; }
.role-card footer { display: flex; justify-content: flex-end; gap: 4px; margin-top: 16px; padding-top: 12px; border-top: 1px solid var(--border); flex-wrap: wrap; }
.role-form-tip { padding: 12px; background: #f2f7f6; color: var(--muted); font-size: 11px; } .role-form-tip { padding: 12px; background: #f2f7f6; color: var(--muted); font-size: 11px; }
.drawer-actions { display: flex; justify-content: flex-end; gap: 8px; } .drawer-actions { display: flex; justify-content: flex-end; gap: 8px; }
@media (max-width: 800px) { .role-grid { grid-template-columns: 1fr; } } .roles-page :deep(.el-form-item__error) { line-height: 1.45; overflow-wrap: anywhere; }
@media (max-width: 600px) { .role-toolbar { align-items: stretch; flex-direction: column; } .role-toolbar .el-input { max-width: none; } .role-toolbar .el-button { width: 100%; } } @media (max-width: 600px) { .drawer-actions { flex-wrap: wrap; } }
</style> </style>
+33 -1
View File
@@ -27,6 +27,16 @@ test('implements company details, contact information and qualification workflow
assert.match(profileSource, /function removeSelectedFile\(\)/) assert.match(profileSource, /function removeSelectedFile\(\)/)
assert.match(profileSource, /qualificationSubmitting/) assert.match(profileSource, /qualificationSubmitting/)
assert.match(profileSource, /class="mobile-qualification-list"/) assert.match(profileSource, /class="mobile-qualification-list"/)
assert.match(profileSource, /import DataTable, \{ type DataTableColumn \} from '@\/components\/common\/DataTable\.vue'/)
assert.match(profileSource, /const qualificationColumns: DataTableColumn\[\]/)
assert.match(profileSource, /slotName: 'actions'/)
assert.match(profileSource, /<DataTable[\s\S]*v-model:page="qualificationPage"[\s\S]*v-model:page-size="qualificationPageSize"[\s\S]*:columns="qualificationColumns"/)
assert.doesNotMatch(profileSource, /<el-table class="desktop-qualification-table"/)
assert.match(profileSource, /ref="profileFormRef" :model="draft" :rules="profileFormRules"/)
assert.match(profileSource, /prop="companyName" required><el-input v-model="draft\.companyName" placeholder="请输入公司名称"/)
assert.match(profileSource, /prop="fileName" required :error="qualificationFileError"/)
assert.match(profileSource, /const valid = await profileFormRef\.value\?\.validate\(\)/)
assert.match(profileSource, /qualificationFileError\.value = '请选择营业执照图片'/)
assert.match(profileSource, /downloadQualificationImage/) assert.match(profileSource, /downloadQualificationImage/)
assert.match(profileSource, /URL\.createObjectURL/) assert.match(profileSource, /URL\.createObjectURL/)
assert.match(profileSource, /URL\.revokeObjectURL/) assert.match(profileSource, /URL\.revokeObjectURL/)
@@ -47,10 +57,20 @@ test('implements member list, role assignment, password reset and status control
assert.match(membersSource, /v-model="form\.password"/) assert.match(membersSource, /v-model="form\.password"/)
assert.match(membersSource, /企业成员加载失败/) assert.match(membersSource, /企业成员加载失败/)
assert.match(membersSource, /@click="loadMembers"/) assert.match(membersSource, /@click="loadMembers"/)
assert.match(membersSource, /const memberRules: FormRules/)
assert.match(membersSource, /ref="formRef" :model="form" :rules="memberRules"/)
assert.match(membersSource, /prop="roleIds" required/)
assert.match(membersSource, /await formRef\.value\?\.validate\(\)/)
assert.match(membersSource, /import QueryToolbar.*QueryToolbar\.vue/)
assert.match(membersSource, /import DataTable.*DataTable\.vue/)
assert.match(membersSource, /<QueryToolbar v-model="memberFilters"/)
assert.match(membersSource, /<DataTable v-model:page="memberPage" v-model:page-size="memberPageSize"/)
assert.match(membersSource, /fixed: 'left'/)
assert.doesNotMatch(membersSource, /member-metrics/)
}) })
test('protects built-in roles and exposes role permission assignment', () => { test('protects built-in roles and exposes role permission assignment', () => {
assert.match(rolesSource, /auth\.hasPermission\('company:role:write'\) && !role\.builtin/) assert.match(rolesSource, /auth\.hasPermission\('company:role:write'\).*\.builtin/)
assert.match(rolesSource, /function assignPermissions\(role: TenantRole\)/) assert.match(rolesSource, /function assignPermissions\(role: TenantRole\)/)
assert.match(storeSource, /if \(role\.builtin\) throw new Error\('内置角色不能删除'\)/) assert.match(storeSource, /if \(role\.builtin\) throw new Error\('内置角色不能删除'\)/)
assert.match(storeSource, /if \(members\.value\.some\(\(member\) => member\.roleIds\.includes\(id\)\)\)/) assert.match(storeSource, /if \(members\.value\.some\(\(member\) => member\.roleIds\.includes\(id\)\)\)/)
@@ -63,6 +83,15 @@ test('protects built-in roles and exposes role permission assignment', () => {
assert.match(rolesSource, /await store\.deleteRole/) assert.match(rolesSource, /await store\.deleteRole/)
assert.match(rolesSource, /企业角色加载失败/) assert.match(rolesSource, /企业角色加载失败/)
assert.match(rolesSource, /@click="loadRoles"/) assert.match(rolesSource, /@click="loadRoles"/)
assert.match(rolesSource, /const roleRules: FormRules/)
assert.match(rolesSource, /ref="formRef" :model="form" :rules="roleRules"/)
assert.match(rolesSource, /placeholder="例如 订单操作员"/)
assert.match(rolesSource, /import QueryToolbar.*QueryToolbar\.vue/)
assert.match(rolesSource, /import DataTable.*DataTable\.vue/)
assert.match(rolesSource, /<QueryToolbar v-model="roleFilters"/)
assert.match(rolesSource, /<DataTable v-model:page="rolePage" v-model:page-size="rolePageSize"/)
assert.match(rolesSource, /fixed: 'left'/)
assert.doesNotMatch(rolesSource, /role-guidance/)
}) })
test('shows company audit status and applies button permissions', () => { test('shows company audit status and applies button permissions', () => {
@@ -84,6 +113,9 @@ test('implements grouped menu and action permission editing', () => {
assert.match(permissionsSource, /await store\.setRolePermissions/) assert.match(permissionsSource, /await store\.setRolePermissions/)
assert.match(permissionsSource, /角色权限加载失败/) assert.match(permissionsSource, /角色权限加载失败/)
assert.match(permissionsSource, /当前企业暂无可用角色/) assert.match(permissionsSource, /当前企业暂无可用角色/)
assert.match(permissionsSource, /const saving = ref\(false\)/)
assert.match(permissionsSource, /:loading="saving"/)
assert.match(permissionsSource, /button:focus-visible/)
assert.doesNotMatch(permissionsSource, /当前只读展示权限资源/) assert.doesNotMatch(permissionsSource, /当前只读展示权限资源/)
}) })