This commit is contained in:
2026-08-12 17:45:49 +08:00
parent 5b441626e9
commit 2a889a7d91
25 changed files with 154 additions and 29 deletions
+3 -2
View File
@@ -121,9 +121,10 @@ describe('adminNavigation', () => {
it('shows the five formal finance pages with their real backend permissions', () => {
const filtered = filterAdminNavigation(adminNavigation, new Set([
'platform:finance:account:view',
'platform:finance:bill:view',
'platform:finance:receipt:view',
'platform:finance:cost:view',
'platform:finance:settlement-ar:view',
'platform:finance:settlement-ap:view',
]))
expect(getNavigationLeaves(filtered).map((item) => item.path).filter((path) => path.startsWith('/finance/'))).toEqual([
@@ -33,4 +33,8 @@ describe('CustomsCasesView', () => {
expect(source).toContain('max-height:90vh')
expect(source).toContain('overflow-y:auto')
})
it('uses a bordered view action instead of a text link', () => {
expect(source).toContain('size="small" type="primary" plain')
expect(source).not.toContain("customs:view')\" link")
})
})
@@ -27,6 +27,6 @@ async function openDetail(id: string) { detailOpen.value = true; detailLoading.v
async function submit() { if (!detail.value || !submitForm.customsAgentName.trim()) return; actionError.value = null; try { await submitCustomsCase(detail.value.customsCase.id, { ...submitForm, expectedVersion: detail.value.customsCase.version }); ElMessage.success('已登记线下提交批次'); detail.value = await getCustomsCase(detail.value.customsCase.id) } catch (cause) { actionError.value = getApiErrorDetails(cause); if (actionError.value.code === 'A0409') detail.value = await getCustomsCase(detail.value.customsCase.id) } }
watch([page, pageSize], () => void load()); onMounted(load)
</script>
<template><AdminPage as="div"><ListPageLayout aria-label="报关协同列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="报关协同筛选" :fields="fields" @search="search" /></template><template v-if="error" #state><LoadErrorState title="报关协同加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" preference-key="customs-cases" empty-text="暂无报关协同记录"><template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:customs:view')" link type="primary" @click="openDetail(row.id)"><Eye :size="15" />查看</el-button></template><template #status="{ row }"><StatusBadge :status="customsStatusLabel(row.status)" /></template></DataTable></section></ListPageLayout>
<template><AdminPage as="div"><ListPageLayout aria-label="报关协同列表"><template #toolbar><QueryToolbar v-model="filters" class="surface-panel" ariaLabel="报关协同筛选" :fields="fields" @search="search" /></template><template v-if="error" #state><LoadErrorState title="报关协同加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template><section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="id" preference-key="customs-cases" empty-text="暂无报关协同记录"><template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:customs:view')" size="small" type="primary" plain @click="openDetail(row.id)"><Eye :size="15" />查看</el-button></template><template #status="{ row }"><StatusBadge :status="customsStatusLabel(row.status)" /></template></DataTable></section></ListPageLayout>
<AppDialog v-model="detailOpen" title="报关协同详情" width="900px" :show-confirm="false"><LoadErrorState v-if="actionError && !detail" title="详情加载失败" :message="actionError.message" :loading="detailLoading" :show-retry="false"><template #details><ApiErrorMeta :details="actionError" /></template></LoadErrorState><template v-if="detail"><el-alert v-if="actionError" type="error" :closable="false"><ApiErrorMeta :details="actionError" /></el-alert><el-descriptions :column="2" border><el-descriptions-item label="报关编号">{{ detail.customsCase.customsNo }}</el-descriptions-item><el-descriptions-item label="状态"><StatusBadge :status="customsStatusLabel(detail.customsCase.status)" /></el-descriptions-item><el-descriptions-item label="资料清单">{{ detail.customsCase.checklistCode }} / {{ detail.customsCase.checklistVersionNo }}</el-descriptions-item><el-descriptions-item label="资料/回执">{{ detail.documents.length }} / {{ detail.receipts.length }}</el-descriptions-item></el-descriptions><el-form v-if="canSubmit" label-position="top" class="submit-form"><el-row :gutter="12"><el-col :xs="24" :sm="12"><el-form-item label="报关代理"><el-input v-model="submitForm.customsAgentName" placeholder="请输入线下报关代理名称" /></el-form-item></el-col><el-col :xs="24" :sm="12"><el-form-item label="外部参考号"><el-input v-model="submitForm.externalReference" placeholder="请输入代理回传的参考号(可选)" /></el-form-item></el-col><el-col :xs="24"><el-form-item label="提交备注"><el-input v-model="submitForm.note" placeholder="请输入本次提交说明(可选)" /></el-form-item></el-col></el-row><el-button type="primary" @click="submit"><Send :size="16" />登记线下提交</el-button></el-form><h3>状态历史</h3><el-timeline><el-timeline-item v-for="entry in detail.history" :key="entry.id" :timestamp="entry.createdAt">{{ customsHistoryActionLabel(entry.action) }}:{{ customsStatusLabel(entry.fromStatus) }} → {{ customsStatusLabel(entry.toStatus) }}<small>{{ entry.note || '' }}</small></el-timeline-item></el-timeline></template></AppDialog></AdminPage></template>
<style scoped>.submit-form{margin-top:18px}.submit-form+.el-button{margin-bottom:12px}h3{margin:20px 0 12px}small{display:block;color:var(--cc-muted)}:global(.el-dialog:has(.submit-form,.el-descriptions)){width:min(900px,calc(100vw - 28px))!important;max-height:90vh;overflow:hidden}:global(.el-dialog:has(.submit-form,.el-descriptions) .el-dialog__body){max-height:calc(90vh - 120px);overflow-y:auto}</style>
@@ -100,9 +100,14 @@ describe('FeeItemsView public master data and access', () => {
})
it('keeps all three row actions visible without a premature more menu', () => {
expect(source).toContain('size="small" plain type="primary"')
expect(source).toContain('size="small"\n plain\n :type=')
expect(source).toContain('size="small" plain type="danger"')
expect(source).toContain('<Pencil :size="15" />编辑')
expect(source).toContain('<Power :size="15" />')
expect(source).toContain('<Trash2 :size="15" />删除')
expect(source).not.toContain('MoreHorizontal')
expect(source).not.toContain("access.can('firstleg:fee-item:edit')\" link")
expect(source).not.toContain("access.can('firstleg:fee-item:delete')\" link")
})
})
+4 -3
View File
@@ -372,16 +372,17 @@ onMounted(() => {
</template>
<template #actions="{ row }">
<div v-if="access.can('firstleg:fee-item:edit') || access.can('firstleg:fee-item:delete')" class="table-actions">
<el-button v-if="access.can('firstleg:fee-item:edit')" link type="primary" :disabled="loading || editorLoading" @click="openEdit(row as PlatformFeeItemResource)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="access.can('firstleg:fee-item:edit')" size="small" plain type="primary" :disabled="loading || editorLoading" @click="openEdit(row as PlatformFeeItemResource)"><Pencil :size="15" />编辑</el-button>
<el-button
v-if="access.can('firstleg:fee-item:edit')"
link
size="small"
plain
:type="(row as PlatformFeeItemResource).status === 'ACTIVE' ? 'warning' : 'success'"
:loading="statusLoadingIds.has(String((row as PlatformFeeItemResource).id))"
:disabled="loading"
@click="requestStatusChange(row as PlatformFeeItemResource)"
><Power :size="15" />{{ (row as PlatformFeeItemResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
<el-button v-if="access.can('firstleg:fee-item:delete')" link type="danger" :disabled="loading" @click="requestDelete(row as PlatformFeeItemResource)"><Trash2 :size="15" />删除</el-button>
<el-button v-if="access.can('firstleg:fee-item:delete')" size="small" plain type="danger" :disabled="loading" @click="requestDelete(row as PlatformFeeItemResource)"><Trash2 :size="15" />删除</el-button>
</div>
<span v-else class="muted-value">仅查看</span>
</template>
@@ -74,6 +74,14 @@ enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('InternalUsersView platform internal user management', () => {
it('renders row business actions as colored outlined buttons with prototype icons', () => {
expect(internalUsersSource).toContain('size="small" plain type="primary" @click="openEdit')
expect(internalUsersSource).toContain('size="small" plain :type="(row as PlatformManagedUser).status')
expect(internalUsersSource).toContain('<Pencil :size="15" />编辑')
expect(internalUsersSource).toContain('<Power :size="15" />')
expect(internalUsersSource).not.toContain('v-if="canChangeStatus" link')
})
it('uses the prototype-sized centered editor with a scrollable body and mobile single column', () => {
expect(internalUsersSource).toContain('width="min(650px, calc(100vw - 28px))"')
expect(internalUsersSource).toContain(':global(.internal-user-editor-dialog .el-dialog__body) { min-height: 0; overflow-y: auto; }')
+2 -2
View File
@@ -324,10 +324,10 @@ onMounted(loadInitialData)
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增用户</el-button></template>
<template #actions="{ row }">
<div class="table-actions">
<el-button v-if="canEdit || canChangeStatus || canResetPassword" link type="primary" @click="openEdit(row as PlatformManagedUser)">
<el-button v-if="canEdit || canChangeStatus || canResetPassword" size="small" plain type="primary" @click="openEdit(row as PlatformManagedUser)">
<Pencil :size="15" />编辑
</el-button>
<el-button v-if="canChangeStatus" link :type="(row as PlatformManagedUser).status === 'ACTIVE' ? 'danger' : 'success'"
<el-button v-if="canChangeStatus" size="small" plain :type="(row as PlatformManagedUser).status === 'ACTIVE' ? 'danger' : 'success'"
:loading="rowActionId === String((row as PlatformManagedUser).membershipId)"
:disabled="cannotDisable(row as PlatformManagedUser)"
:title="(row as PlatformManagedUser).protectedAccount ? '系统管理员不能被禁用' : isCurrentUser(row as PlatformManagedUser) ? '不能禁用当前登录用户' : undefined"
@@ -81,6 +81,23 @@ function mountServerView(permissionCodes = ['platform:permission:list', 'platfor
}
describe('PermissionsView IAM mode', () => {
it('uses outlined business action buttons while keeping tree view helpers as links', () => {
expect(permissionsSource).toContain('size="small" plain type="primary" @click="openCreate((row as Permission).id)"')
expect(permissionsSource).toContain('size="small" plain type="primary" :disabled="rowActionId')
expect(permissionsSource).toContain('size="small" plain :type="(row as Permission).status')
expect(permissionsSource).toContain('size="small" plain type="danger"')
expect(permissionsSource).toContain('size="small" plain :aria-label="moreActionsLabel(row as Permission)"')
expect(permissionsSource).toContain('<Plus :size="15" />添加下级')
expect(permissionsSource).toContain('<Pencil :size="15" />编辑')
expect(permissionsSource).toContain('<Power :size="15" />')
expect(permissionsSource).toContain('<Trash2 :size="15" />删除')
expect(permissionsSource).toContain('<MoreHorizontal :size="16" />')
expect(permissionsSource).toContain('<el-button link type="primary" @click="expandAllRows">展开全部</el-button>')
expect(permissionsSource).not.toContain('canAddChild(row as Permission)" link')
expect(permissionsSource).not.toContain('canEditRow(row as Permission)" link')
expect(permissionsSource).not.toContain('canChangeStatusRow(row as Permission)" link')
})
beforeEach(() => {
vi.mocked(listPlatformPermissions).mockReset()
})
+5 -5
View File
@@ -281,12 +281,12 @@ onMounted(loadPermissions)
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate(null)"><Plus :size="16" />新增权限</el-button></template>
<template #actions="{ row }">
<div v-if="permissionActionCount(row as Permission) > 0 || (row as Permission).builtIn" class="table-actions">
<el-button v-if="canAddChild(row as Permission)" link type="primary" @click="openCreate((row as Permission).id)"><Plus :size="15" />添加下级</el-button>
<el-button v-if="canEditRow(row as Permission)" link type="primary" :disabled="rowActionId === (row as Permission).id" @click="openEdit(row as Permission)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="canChangeStatusRow(row as Permission)" link :type="(row as Permission).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Permission).id" @click="toggleStatus(row as Permission)"><Power :size="15" />{{ (row as Permission).status === '启用' ? '停用' : '启用' }}</el-button>
<el-button v-if="showDeleteDirect(row as Permission)" link type="danger" :disabled="rowActionId === (row as Permission).id" @click="removePermission(row as Permission)"><Trash2 :size="15" />删除</el-button>
<el-button v-if="canAddChild(row as Permission)" size="small" plain type="primary" @click="openCreate((row as Permission).id)"><Plus :size="15" />添加下级</el-button>
<el-button v-if="canEditRow(row as Permission)" size="small" plain type="primary" :disabled="rowActionId === (row as Permission).id" @click="openEdit(row as Permission)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="canChangeStatusRow(row as Permission)" size="small" plain :type="(row as Permission).status === '启用' ? 'danger' : 'success'" :loading="rowActionId === (row as Permission).id" @click="toggleStatus(row as Permission)"><Power :size="15" />{{ (row as Permission).status === '启用' ? '停用' : '启用' }}</el-button>
<el-button v-if="showDeleteDirect(row as Permission)" size="small" plain type="danger" :disabled="rowActionId === (row as Permission).id" @click="removePermission(row as Permission)"><Trash2 :size="15" />删除</el-button>
<el-dropdown v-if="showDeleteMore(row as Permission)" trigger="click" @command="(command: string) => command === 'delete' && removePermission(row as Permission)">
<el-button link :aria-label="moreActionsLabel(row as Permission)" :title="moreActionsLabel(row as Permission)"><MoreHorizontal :size="16" /></el-button>
<el-button size="small" plain :aria-label="moreActionsLabel(row as Permission)" :title="moreActionsLabel(row as Permission)"><MoreHorizontal :size="16" /></el-button>
<template #dropdown><el-dropdown-menu><el-dropdown-item command="delete" :disabled="rowActionId === (row as Permission).id" class="danger-menu-item"><Trash2 :size="15" />删除</el-dropdown-item></el-dropdown-menu></template>
</el-dropdown>
</div>
@@ -247,9 +247,13 @@ describe('PartnersView real API integration', () => {
})
it('keeps all three row actions visible without a premature more menu', () => {
expect(source).toContain('<el-button size="small" plain type="primary" @click="openDetail')
expect(source).toContain("access.can('platform:counterparty:edit')\" size=\"small\" plain type=\"primary\"")
expect(source).toContain("access.can('platform:counterparty:status')\" size=\"small\" plain")
expect(source).toContain('<Eye :size="15" />详情')
expect(source).toContain('<Pencil :size="15" />编辑')
expect(source).toContain('<Power :size="15" />')
expect(source).not.toContain('MoreHorizontal')
expect(source).not.toContain('<el-button link type="primary" @click="openDetail')
})
})
@@ -395,9 +395,9 @@ onMounted(() => {
<template #toolbar-actions><div class="partners-primary-actions"><el-button v-if="access.can('platform:counterparty:import')" :disabled="loading" @click="openImport"><FileUp :size="16" />批量导入</el-button><el-button v-if="access.can('platform:counterparty:create')" type="primary" :disabled="loading" @click="openCreate"><Plus :size="16" />新增往来单位</el-button></div></template>
<template #actions="{ row }">
<div class="table-actions">
<el-button link type="primary" @click="openDetail(row as PlatformPartnerResource)"><Eye :size="15" />详情</el-button>
<el-button v-if="access.can('platform:counterparty:edit')" link type="primary" :disabled="loading" @click="openEdit(row as PlatformPartnerResource)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="access.can('platform:counterparty:status')" link :disabled="loading" :type="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusLoadingIds.has(String((row as PlatformPartnerResource).id))" @click="toggleStatus(row as PlatformPartnerResource)"><Power :size="15" />{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
<el-button size="small" plain type="primary" @click="openDetail(row as PlatformPartnerResource)"><Eye :size="15" />详情</el-button>
<el-button v-if="access.can('platform:counterparty:edit')" size="small" plain type="primary" :disabled="loading" @click="openEdit(row as PlatformPartnerResource)"><Pencil :size="15" />编辑</el-button>
<el-button v-if="access.can('platform:counterparty:status')" size="small" plain :disabled="loading" :type="(row as PlatformPartnerResource).status === 'ACTIVE' ? 'danger' : 'success'" :loading="statusLoadingIds.has(String((row as PlatformPartnerResource).id))" @click="toggleStatus(row as PlatformPartnerResource)"><Power :size="15" />{{ (row as PlatformPartnerResource).status === 'ACTIVE' ? '停用' : '启用' }}</el-button>
</div>
</template>
<template #partner="{ row }"><div class="partner-cell"><span><Building2 :size="18" /></span><div><strong>{{ (row as PlatformPartnerResource).partnerName }}</strong></div></div></template>
@@ -73,6 +73,15 @@ enableAutoUnmount(afterEach)
afterAll(() => vi.unstubAllGlobals())
describe('ExpressCompaniesView query and table migration', () => {
it('renders edit and status as colored outlined buttons with prototype icons', () => {
expect(expressCompaniesSource).toContain('size="small"\n plain\n type="primary"')
expect(expressCompaniesSource).toContain('size="small"\n plain\n :disabled="loading"\n :type=')
expect(expressCompaniesSource).toContain('<Pencil :size="15" />编辑')
expect(expressCompaniesSource).toContain('<Power :size="15" />')
expect(expressCompaniesSource).not.toContain("access.can('platform:express:edit')\"\n link")
expect(expressCompaniesSource).not.toContain("access.can('platform:express:status')\"\n link")
})
it('keeps operation first and exposes the prototype business columns separately', () => {
const actionsIndex = expressCompaniesSource.indexOf("{ key: 'actions', label: '操作'")
const nameIndex = expressCompaniesSource.indexOf("{ key: 'company', label: '公司名称'")
@@ -314,7 +314,8 @@ onMounted(() => {
<div v-if="access.can('platform:express:edit') || access.can('platform:express:status')" class="table-actions">
<el-button
v-if="access.can('platform:express:edit')"
link
size="small"
plain
type="primary"
:disabled="loading"
:loading="editLoadingIds.has(String((row as PlatformExpressCompanyResource).id))"
@@ -322,7 +323,8 @@ onMounted(() => {
><Pencil :size="15" />编辑</el-button>
<el-button
v-if="access.can('platform:express:status')"
link
size="small"
plain
:disabled="loading"
:type="(row as PlatformExpressCompanyResource).status === 'ACTIVE' ? 'danger' : 'success'"
:loading="statusLoadingIds.has(String((row as PlatformExpressCompanyResource).id))"
@@ -152,4 +152,11 @@ describe('OutboundDispatchesView', () => {
expect(source).toContain('max-height: 90vh')
expect(source).toContain('overflow-y: auto')
})
it('uses semantic bordered and filled business buttons instead of text links', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/outbound-dispatch/pages/OutboundDispatchesView.vue'), 'utf8')
expect(source).toContain('size="small" type="primary" plain')
expect(source).toContain('size="small" type="primary" @click="action')
expect(source).toContain('size="small" type="danger" plain')
expect(source).not.toContain("outbound-dispatch:view')\" link")
})
})
@@ -189,7 +189,7 @@ onBeforeUnmount(() => { disposed = true; listRequestId += 1; optionRequestId +=
<section v-if="!error" class="surface-panel table-panel">
<el-empty v-if="!queried" description="请选择订单 / 入库单或柜次后查询" />
<DataTable v-else :data="rows" :columns="columns" :loading="loading" row-key="inboundId" preference-key="outbound-dispatches" empty-text="暂无符合条件的出仓记录">
<template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:outbound-dispatch:view')" link type="primary" @click="open(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:confirm') && row.dispatchStatus === 'READY_FOR_DISPATCH'" link type="primary" @click="action(row, 'confirm-dispatch')"><Send :size="15" />确认</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:reverse') && row.dispatchStatus === 'WAITING_FOR_LOADING'" link type="danger" @click="action(row, 'reverse-dispatch')"><RotateCcw :size="15" />撤销</el-button></template>
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:firstleg:outbound-dispatch:view')" size="small" type="primary" plain @click="open(row)"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:confirm') && row.dispatchStatus === 'READY_FOR_DISPATCH'" size="small" type="primary" @click="action(row, 'confirm-dispatch')"><Send :size="15" />确认</el-button><el-button v-if="access.can('platform:firstleg:outbound-dispatch:reverse') && row.dispatchStatus === 'WAITING_FOR_LOADING'" size="small" type="danger" plain @click="action(row, 'reverse-dispatch')"><RotateCcw :size="15" />撤销</el-button></div></template>
<template #booking="{ row }"><StatusBadge :status="oceanBookingStatusLabel(row.oceanBookingStatus)" /></template><template #truck="{ row }"><StatusBadge :status="truckStatusLabel(row.truckAppointmentStatus)" /></template><template #pick="{ row }"><StatusBadge :status="pickTaskStatusLabel(row.pickTaskStatus)" /></template><template #status="{ row }"><StatusBadge :status="outboundDispatchStatusLabel(row.dispatchStatus)" /></template><template #gate="{ row }"><el-tag :type="row.gate?.passed ? 'success' : 'danger'">{{ row.gate?.passed ? '通过' : '未通过' }}</el-tag></template>
</DataTable>
</section>
@@ -206,6 +206,7 @@ onBeforeUnmount(() => { disposed = true; listRequestId += 1; optionRequestId +=
h3 { margin: 20px 0 12px; }
small { display: block; color: var(--cc-muted); }
.detail-loading { padding: 12px 0; }
.table-actions { display: flex; align-items: center; gap: 6px; }.table-actions :deep(.el-button) { margin-left: 0; }
:global(.outbound-detail-dialog.el-dialog) { max-height: 90vh; overflow: hidden; }
:global(.outbound-detail-dialog .el-dialog__body) { max-height: calc(90vh - 120px); overflow-y: auto; }
</style>
@@ -73,4 +73,10 @@ describe('PortSchedulesView', () => {
expect(source).toContain('width:min(820px,calc(100vw - 28px))!important')
expect(source).toContain('max-height:90vh')
})
it('uses bordered business action buttons while keeping retry actions as links', () => {
expect(source).toContain('size="small" type="primary" plain')
expect(source).toContain('size="small" @click="openSchedule')
expect(source).not.toContain("port-schedule:view')\" link")
expect(source).toContain('<el-button link type="primary" @click="loadCustomers"')
})
})
@@ -59,7 +59,7 @@ watch([page, pageSize], () => void load()); onMounted(load)
<template v-if="error" #state><LoadErrorState title="船期列表加载失败" :message="error.message" :loading="loading" @retry="load"><template #details><ApiErrorMeta :details="error" /></template></LoadErrorState></template>
<section v-if="!error" class="surface-panel table-panel"><DataTable v-model:page="page" v-model:page-size="pageSize" :data="rows" :columns="columns" :loading="loading" :total="total" row-key="schedule.id" preference-key="port-schedules" empty-text="暂无符合条件的船期">
<template #toolbar-actions><el-button v-if="canCreate" type="primary" @click="openCreate"><Plus :size="16" />新增船期</el-button></template>
<template #actions="{ row }"><el-button v-if="access.can('platform:firstleg:port-schedule:view')" link type="primary" @click="openSchedule(row.schedule.id, 'view')"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:port-schedule:edit')" link @click="openSchedule(row.schedule.id, 'edit')"><Pencil :size="15" />编辑</el-button></template>
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:firstleg:port-schedule:view')" size="small" type="primary" plain @click="openSchedule(row.schedule.id, 'view')"><Eye :size="15" />查看</el-button><el-button v-if="access.can('platform:firstleg:port-schedule:edit')" size="small" @click="openSchedule(row.schedule.id, 'edit')"><Pencil :size="15" />编辑</el-button></div></template>
<template #vessel="{ row }">{{ row.schedule.vesselName }} / {{ row.schedule.voyageNo }}</template><template #observedAt="{ row }">{{ formatDateTime(row.schedule.observedAt) }}</template><template #updatedAt="{ row }">{{ formatDateTime(row.schedule.updatedAt) }}</template><template #expired="{ row }"><el-tag :type="row.expired ? 'danger' : 'success'">{{ row.expired ? '已过期' : '有效' }}</el-tag></template>
</DataTable></section>
</ListPageLayout>
@@ -119,4 +119,9 @@ describe('TruckAppointmentsView', () => {
expect(source).not.toContain("key: 'payableAmount'")
expect(source).not.toContain("key: 'receivableAmount'")
})
it('uses bordered list actions instead of text links', () => {
expect(source).toContain('size="small" type="primary" plain')
expect(source).toContain('size="small" :aria-label="appointmentActionLabel(\'编辑\'')
expect(source).not.toContain("truck-appointment:view')\" link")
})
})
@@ -65,7 +65,7 @@ function openDetail(row: TruckAppointmentResource, edit = false) { router.push({
<template #driver="{ row }">{{ row.driverName || '—' }}<small>{{ row.driverPhone || '—' }}</small></template>
<template #store="{ row }">{{ row.storeName || '—' }}</template>
<template #status="{ row }"><StatusBadge :status="statusLabel(row.status)" /></template>
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:firstleg:truck-appointment:view')" link type="primary" :aria-label="appointmentActionLabel('查看', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)" link type="primary" :aria-label="appointmentActionLabel('编辑', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource, true)"><Pencil :size="15" />编辑</el-button></div></template>
<template #actions="{ row }"><div class="table-actions"><el-button v-if="access.can('platform:firstleg:truck-appointment:view')" size="small" type="primary" plain :aria-label="appointmentActionLabel('查看', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource)"><Eye :size="15" />详情</el-button><el-button v-if="access.can('platform:firstleg:truck-appointment:edit') && ['DRAFT', 'PENDING_DISPATCH', 'RESCHEDULED'].includes(row.status)" size="small" :aria-label="appointmentActionLabel('编辑', row as TruckAppointmentResource)" @click="openDetail(row as TruckAppointmentResource, true)"><Pencil :size="15" />编辑</el-button></div></template>
</DataTable>
</section>
</ListPageLayout>
@@ -75,4 +75,5 @@ function openDetail(row: TruckAppointmentResource, edit = false) { router.push({
.trucking-page { min-width: 0; }
.table-panel { overflow:hidden; }
small { display:block; color:var(--el-text-color-secondary); margin-top:3px; }
.table-actions { display:flex; align-items:center; gap:6px; }.table-actions :deep(.el-button) { margin-left:0; }
</style>
@@ -275,4 +275,12 @@ describe('UnifiedOrdersView contract', () => {
expect(source).toContain('<dt>真实集装箱号</dt>')
expect(source).toContain('<dt>内部柜次号</dt>')
})
it('uses outlined semantic buttons for row business actions while keeping detail navigation explicit', () => {
const rowActions = source.slice(source.indexOf('<template #actions'), source.indexOf('<template #inboundCode'))
expect(rowActions).toContain('size="small" plain :type="unifiedOrderActionType(action)"')
expect(rowActions).not.toContain('v-for="action in directUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" link')
expect(rowActions).toContain('<Eye :size="15" />详情')
expect(source).toContain('<el-button link class="back-button"')
})
})
@@ -992,7 +992,7 @@ onMounted(() => {
<template #actions="{ row }">
<div class="row-actions">
<el-button link type="primary" @click="openDetail(row as UnifiedOrderResource)"><Eye :size="15" />详情</el-button>
<el-button v-for="action in directUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" link :type="unifiedOrderActionType(action)" :loading="action === 'approve' && approvalLoadingId === String((row as UnifiedOrderResource).orderId)" @click="runMoreAction(action, row as UnifiedOrderResource)"><component :is="unifiedOrderRowActionIcons[action]" :size="15" />{{ unifiedOrderActionLabel(action) }}</el-button>
<el-button v-for="action in directUnifiedOrderActions(row as UnifiedOrderResource)" :key="action" size="small" plain :type="unifiedOrderActionType(action)" :loading="action === 'approve' && approvalLoadingId === String((row as UnifiedOrderResource).orderId)" @click="runMoreAction(action, row as UnifiedOrderResource)"><component :is="unifiedOrderRowActionIcons[action]" :size="15" />{{ unifiedOrderActionLabel(action) }}</el-button>
<el-dropdown v-if="moreUnifiedOrderActions(row as UnifiedOrderResource).length" trigger="click" @command="runMoreAction($event, row as UnifiedOrderResource)">
<el-button link type="primary" :aria-label="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)" :title="moreUnifiedOrderActionsLabel(row as UnifiedOrderResource)"><MoreHorizontal :size="17" /></el-button>
<template #dropdown>
@@ -1,4 +1,6 @@
import ElementPlus, { ElMessageBox } from 'element-plus'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { createPinia, setActivePinia } from 'pinia'
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -59,6 +61,17 @@ describe('UnifiedOrderWorkflowPanel preload editor', () => {
vi.mocked(voidEvidence).mockReset()
})
it('keeps workflow business actions as outlined semantic buttons with icons', () => {
const source = readFileSync(resolve(process.cwd(), 'src/modules/unified-orders/workflows/components/UnifiedOrderWorkflowPanel.vue'), 'utf8')
expect(source).toContain('size="small" plain type="primary"')
expect(source).toContain('size="small" plain type="danger"')
expect(source).toContain('<Pencil :size="14" />编辑')
expect(source).toContain('<Eye :size="14" />查看')
expect(source).toContain('<Trash2 :size="14" />作废')
expect(source).not.toContain('<el-button link type="primary" :disabled="!canSplit')
expect(source).not.toContain('<el-button link type="danger" :disabled="!canSplit')
})
it('renders the formal preload table and keeps the operation column first', async () => {
const wrapper = mountPanel(['firstleg:inbound:preload-view', 'firstleg:inbound:split'])
await flushPromises()
@@ -455,8 +455,8 @@ onMounted(() => { void refresh() })
<template v-else>
<DataTable :data="preloadItems" :columns="preloadColumns" :loading="loading" empty-text="暂无预装明细,请先新增">
<template #actions="{ $index }">
<el-button link type="primary" :disabled="!canSplit || Boolean(busy)" :aria-label="`编辑第 ${Number($index) + 1} 条预装明细`" @click="editItem(Number($index))"><Pencil :size="14" />编辑</el-button>
<el-button link type="danger" :disabled="!canSplit || Boolean(busy)" :aria-label="`删除第 ${Number($index) + 1} 条预装明细`" @click="removeItem(Number($index))"><Trash2 :size="14" />删除</el-button>
<el-button size="small" plain type="primary" :disabled="!canSplit || Boolean(busy)" :aria-label="`编辑第 ${Number($index) + 1} 条预装明细`" @click="editItem(Number($index))"><Pencil :size="14" />编辑</el-button>
<el-button size="small" plain type="danger" :disabled="!canSplit || Boolean(busy)" :aria-label="`删除第 ${Number($index) + 1} 条预装明细`" @click="removeItem(Number($index))"><Trash2 :size="14" />删除</el-button>
</template>
<template #volume="{ row }">{{ formatVolume((row as PreloadItem).volumeCbm) }}</template>
<template #toolbar-actions><el-button :disabled="!canSplit || Boolean(busy)" @click="addItem"><Plus :size="15" />新增明细</el-button></template>
@@ -516,8 +516,8 @@ onMounted(() => { void refresh() })
<div v-for="item in activeEvidences(entry.type)" :key="String(item.id)" class="evidence-file">
<span>{{ item.fileName || `证据 #${item.id}` }}<template v-if="item.sealNo"> · 锁码 {{ item.sealNo }}</template></span>
<div class="evidence-file__actions">
<el-button link type="primary" :disabled="Boolean(busy)" @click="previewEvidence(item)"><Eye :size="14" />查看</el-button>
<el-button v-if="canEvidence" link type="danger" :disabled="Boolean(busy)" @click="markEvidenceVoided(item)">作废</el-button>
<el-button size="small" plain type="primary" :disabled="Boolean(busy)" @click="previewEvidence(item)"><Eye :size="14" />查看</el-button>
<el-button v-if="canEvidence" size="small" plain type="danger" :disabled="Boolean(busy)" @click="markEvidenceVoided(item)"><Trash2 :size="14" />作废</el-button>
</div>
</div>
<span v-if="activeEvidences(entry.type).length === 0" class="workflow-hint">尚未上传有效照片</span>
+23 -2
View File
@@ -8,8 +8,8 @@ import { UI_PREFERENCE_SCOPE, preferenceStorageKey } from '@/shared/utils/uiLayo
const TableStub = defineComponent({ template: '<div><slot /></div>', methods: { doLayout() {} } })
const ColumnStub = defineComponent({
inheritAttrs: false,
props: { label: String },
template: '<div class="column-stub" :data-label="label"><slot /></div>',
props: { label: String, prop: String },
template: '<div class="column-stub" :data-label="label" :data-prop="prop"><slot /></div>',
})
const ButtonStub = defineComponent({ template: '<button type="button"><slot /></button>' })
const PopoverStub = defineComponent({ template: '<div><slot name="reference" /><slot /></div>' })
@@ -69,6 +69,27 @@ describe('DataTable pagination helpers', () => {
describe('DataTable column preferences', () => {
beforeEach(() => localStorage.clear())
it('uses key as the data property for ordinary columns only', () => {
const wrapper = shallowMount(DataTable, {
props: {
data: [{ customerName: '客户A', explicitName: '显式字段' }],
columns: [
{ key: 'actions', label: '操作', slotName: 'actions' },
{ key: 'customerName', label: '客户' },
{ key: 'name', prop: 'explicitName', label: '显式字段' },
{ key: 'selection', type: 'selection', label: '选择' },
],
},
global: {
stubs: { ElTable: TableStub, ElTableColumn: ColumnStub, ElButton: ButtonStub, ElPopover: PopoverStub },
directives: { loading: {} },
},
})
const columns = wrapper.findAll('.column-stub')
expect(columns.map((column) => column.attributes('data-prop'))).toEqual([undefined, 'customerName', 'explicitName', undefined])
})
it('restores column order and visibility while locking the actions column', async () => {
localStorage.setItem(preferenceStorageKey('table', 'user-1', 'orders-table'), JSON.stringify({
version: 1,
+13 -1
View File
@@ -232,7 +232,19 @@ watch([page, pageCount], ([value, count]) => {
}, { immediate: true })
function columnAttrs({ key, slotName, headerSlotName, hideable, movable, defaultVisible, ...attrs }: DataTableColumn) {
return attrs
const hasDataProperty = attrs.prop !== undefined || attrs.property !== undefined
const isSpecialColumn = slotName || ['selection', 'expand', 'index'].includes(String(attrs.type || ''))
if (hasDataProperty || isSpecialColumn || key === undefined || key === null || key === '') return attrs
const label = String(attrs.label || '')
return {
...attrs,
prop: String(key),
formatter: attrs.formatter || ((_row: unknown, _column: unknown, value: unknown) => {
if (value === null || value === undefined || value === '') return '—'
if (typeof value === 'number' && /CNY|金额|应收|应付|已收|已付|未收|待收|待付|开账/.test(label)) return value.toFixed(2)
return String(value)
}),
}
}
function columnRenderKey(column: DataTableColumn, index: number) {