style(admin): unify dialog layouts and dense readonly presentation

This commit is contained in:
2026-10-20 10:11:31 +08:00
parent 7ff7c9914f
commit 3267679758
214 changed files with 10931 additions and 2654 deletions
@@ -1,6 +1,11 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus, { ElMessageBox } from 'element-plus'
import TruckAttachmentDialog from './TruckAttachmentDialog.vue'
import { deleteTruckAppointmentAttachment, uploadTruckAppointmentAttachment } from '../api/platformTrucking'
import type { TruckAttachmentReference } from '../types'
const source = readFileSync(resolve(process.cwd(), 'src/modules/trucking/components/TruckAttachmentDialog.vue'), 'utf8')
@@ -27,7 +32,7 @@ describe('TruckAttachmentDialog interaction governance', () => {
})
it('keeps list-only access read-only and preserves traced mutation failures', () => {
expect(source).toContain('v-if="props.canManage" class="upload-panel"')
expect(source).toMatch(/<section v-if="props.canManage"[\s\S]*?class="upload-panel"/)
expect(source).toContain('props.appointmentId && props.canManage && item.id')
expect(source).toContain('if (busy.value || !props.appointmentId || !selectedFile.value || !props.canManage) return')
expect(source).toContain('if (busy.value || !props.appointmentId || !item.id || !props.canManage) return')
@@ -41,3 +46,56 @@ describe('TruckAttachmentDialog interaction governance', () => {
expect(source).toContain('.attachment-row>div{min-width:0}')
})
})
vi.mock('../api/platformTrucking', () => ({ deleteTruckAppointmentAttachment: vi.fn(), uploadTruckAppointmentAttachment: vi.fn() }))
const attachment: TruckAttachmentReference = { id: 'attachment-1', fileId: 'file-internal', fileName: '派车资料-中文.pdf', attachmentType: 'DISPATCH_ORDER', uploadedAt: '2026-10-19T10:00:00' }
beforeEach(() => { vi.clearAllMocks() })
afterEach(() => { vi.restoreAllMocks(); document.body.innerHTML = '' })
describe('TruckAttachmentDialog workflows', () => {
it('keeps attachment names readable through fullscreen and protects read-only access', async () => {
const wrapper = mount(TruckAttachmentDialog, { props: { modelValue: true, appointmentId: 'appointment-internal', appointmentNo: 'TA-123', attachments: [attachment], canManage: false }, global: { plugins: [ElementPlus] }, attachTo: document.body })
await flushPromises()
expect(document.querySelector('.el-dialog__title')?.textContent).toBe('预约附件 · TA-123')
expect(wrapper.find('.upload-panel').exists()).toBe(false)
expect(document.body.textContent).toContain(attachment.fileName)
expect(document.body.textContent).not.toContain('appointment-internal')
expect(Array.from(document.querySelectorAll('button')).some(button => button.textContent?.trim() === '删除')).toBe(false)
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click()
await flushPromises()
expect(document.querySelector('.el-dialog.is-fullscreen')).not.toBeNull()
expect(document.body.textContent).toContain(attachment.fileName)
expect(uploadTruckAppointmentAttachment).not.toHaveBeenCalled()
expect(deleteTruckAppointmentAttachment).not.toHaveBeenCalled()
wrapper.unmount()
})
it('uploads the selected type once and confirms the named attachment before deleting it', async () => {
let resolveUpload!: (value: TruckAttachmentReference) => void
vi.mocked(uploadTruckAppointmentAttachment).mockImplementation(() => new Promise<TruckAttachmentReference>(resolve => { resolveUpload = resolve }))
const confirm = vi.spyOn(ElMessageBox, 'confirm').mockResolvedValue(undefined as never)
const wrapper = mount(TruckAttachmentDialog, { props: { modelValue: true, appointmentId: 'appointment-internal', appointmentNo: 'TA-123', attachments: [attachment], canManage: true }, global: { plugins: [ElementPlus] }, attachTo: document.body })
await flushPromises()
const file = new File(['data'], '司机证件.pdf', { type: 'application/pdf' })
wrapper.findComponent({ name: 'ElSelect' }).vm.$emit('update:modelValue', 'DRIVER_DOCUMENT')
const selectFile = wrapper.findComponent({ name: 'ElUpload' }).props('onChange') as (upload: { raw: File }) => void
selectFile({ raw: file })
await flushPromises()
document.querySelector<HTMLButtonElement>('[aria-label="展开全屏"]')!.click()
await flushPromises()
expect(wrapper.findComponent({ name: 'ElSelect' }).props('modelValue')).toBe('DRIVER_DOCUMENT')
const upload = Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => button.textContent?.trim() === '上传并关联')!
upload.click(); upload.click()
await flushPromises()
expect(uploadTruckAppointmentAttachment).toHaveBeenCalledExactlyOnceWith('appointment-internal', file, 'DRIVER_DOCUMENT')
resolveUpload(attachment)
await flushPromises()
expect(wrapper.emitted('changed')).toHaveLength(1)
Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find(button => button.textContent?.trim() === '删除')!.click()
await flushPromises()
expect(confirm).toHaveBeenCalledWith(expect.stringContaining(attachment.fileName), '删除预约附件 · TA-123', expect.objectContaining({ customClass: 'admin-density-confirm', confirmButtonText: '确认' }))
expect(deleteTruckAppointmentAttachment).toHaveBeenCalledExactlyOnceWith('appointment-internal', 'attachment-1')
expect(wrapper.emitted('changed')).toHaveLength(2)
wrapper.unmount()
})
})
@@ -3,36 +3,43 @@ import '@/styles/adminDensity.css'
import { ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { FileUp } from '@lucide/vue'
import AppDialog from '@/shared/components/AppDialog.vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import { getApiErrorDetails, type ApiErrorDetails } from '@/shared/api/http'
import { deleteTruckAppointmentAttachment, uploadTruckAppointmentAttachment } from '../api/platformTrucking'
import type { TruckAttachmentReference, TruckAttachmentType } from '../types'
const props = defineProps<{ modelValue: boolean; appointmentId?: string; attachments: TruckAttachmentReference[]; canManage?: boolean }>()
const props = defineProps<{ modelValue: boolean; appointmentId?: string; appointmentNo?: string | null; attachments: TruckAttachmentReference[]; canManage?: boolean }>()
const emit = defineEmits<{ 'update:modelValue': [boolean]; changed: [] }>()
const busy = ref(false)
const error = ref<ApiErrorDetails | null>(null)
const selectedFile = ref<File | null>(null)
const selectedType = ref<TruckAttachmentType>('OTHER')
const attachmentTypeLabels: Record<TruckAttachmentType, string> = { DISPATCH_ORDER: '派车单', DRIVER_DOCUMENT: '司机证件', VEHICLE_DOCUMENT: '车辆证件', OTHER: '其他附件' }
function attachmentDialogTitle(action: string) { const code = props.appointmentNo?.trim(); return code ? `${action} · ${code}` : action }
function attachmentTypeLabel(value: string | null | undefined) { return value && value in attachmentTypeLabels ? attachmentTypeLabels[value as TruckAttachmentType] : '业务附件' }
function selectFile(upload: { raw?: File }) { selectedFile.value = upload.raw || null }
async function upload() { if (busy.value || !props.appointmentId || !selectedFile.value || !props.canManage) return; busy.value = true; error.value = null; try { await uploadTruckAppointmentAttachment(props.appointmentId, selectedFile.value, selectedType.value); selectedFile.value = null; emit('changed'); ElMessage.success('附件上传成功') } catch (cause) { error.value = getApiErrorDetails(cause, '附件上传失败,请稍后重试') } finally { busy.value = false } }
async function remove(item: TruckAttachmentReference) { if (busy.value || !props.appointmentId || !item.id || !props.canManage) return; busy.value = true; error.value = null; try { await ElMessageBox.confirm('确认删除该附件引用?', '删除确认', { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); await deleteTruckAppointmentAttachment(props.appointmentId, item.id); emit('changed'); ElMessage.success('附件引用已删除') } catch (cause) { if (cause === 'cancel' || cause === 'close') return; error.value = getApiErrorDetails(cause, '附件删除失败,请稍后重试') } finally { busy.value = false } }
async function remove(item: TruckAttachmentReference) { if (busy.value || !props.appointmentId || !item.id || !props.canManage) return; busy.value = true; error.value = null; try { await ElMessageBox.confirm(`确认删除“${item.fileName || '未命名文件'}”的附件引用?`, attachmentDialogTitle('删除预约附件'), { customClass: 'admin-density-confirm', confirmButtonText: '确认', cancelButtonText: '取消', type: 'warning' }); await deleteTruckAppointmentAttachment(props.appointmentId, item.id); emit('changed'); ElMessage.success('附件引用已删除') } catch (cause) { if (cause === 'cancel' || cause === 'close') return; error.value = getApiErrorDetails(cause, '附件删除失败,请稍后重试') } finally { busy.value = false } }
</script>
<template>
<el-dialog class="admin-density-dialog" :model-value="props.modelValue" title="预约附件" width="min(640px, 94vw)" @update:model-value="emit('update:modelValue', $event)">
<AppDialog class="admin-density-dialog" :model-value="props.modelValue" :title="attachmentDialogTitle('预约附件')" width="min(640px, 94vw)" fullscreenable :show-confirm="false" :close-on-click-modal="true" @update:model-value="emit('update:modelValue', $event)">
<el-alert v-if="error" type="error" :closable="true" show-icon @close="error = null"><template #title>{{ error.message }}</template><ApiErrorMeta :details="error" /></el-alert>
<div v-if="props.canManage" class="upload-panel"><el-select v-model="selectedType" aria-label="附件类型" style="width:160px"><el-option v-for="(label, type) in attachmentTypeLabels" :key="type" :label="label" :value="type" /></el-select><el-upload :auto-upload="false" :limit="1" :show-file-list="true" :on-change="selectFile" :on-remove="() => { selectedFile = null }"><el-button><FileUp :size="15" />选择附件</el-button></el-upload><el-button type="primary" :loading="busy" :disabled="!selectedFile" @click="upload">上传并关联</el-button></div>
<div class="density-dialog-sections">
<section v-if="props.canManage" class="density-dialog-section" data-tone="work"><h3 class="density-dialog-section__title">上传附件</h3><div class="upload-panel"><el-select v-model="selectedType" aria-label="附件类型" class="attachment-type"><el-option v-for="(label, type) in attachmentTypeLabels" :key="type" :label="label" :value="type" /></el-select><el-upload :auto-upload="false" :limit="1" :show-file-list="true" :on-change="selectFile" :on-remove="() => { selectedFile = null }"><el-button><FileUp :size="15" />选择附件</el-button></el-upload><el-button type="primary" :loading="busy" :disabled="!selectedFile" @click="upload">上传并关联</el-button></div></section>
<section class="density-dialog-section" data-tone="source"><h3 class="density-dialog-section__title">现有附件 <small>共 {{ attachments.length }} 份</small></h3>
<el-empty v-if="attachments.length === 0" description="暂无预约附件" :image-size="72" />
<div v-for="item in attachments" :key="item.fileId" class="attachment-row">
<div><strong>{{ item.fileName || '未命名文件' }}</strong><small>{{ attachmentTypeLabel(item.attachmentType) }}{{ item.uploadedAt ? ` · ${item.uploadedAt}` : '' }}</small></div>
<el-button data-tone="danger" class="density-action" v-if="props.appointmentId && props.canManage && item.id" size="small" type="danger" plain :loading="busy" @click="remove(item)">删除</el-button>
</div>
</section>
</div>
<template #footer><el-button type="primary" @click="emit('update:modelValue', false)">关闭</el-button></template>
</el-dialog>
</AppDialog>
</template>
<style scoped>
.upload-panel { display:flex; align-items:center; flex-wrap:wrap; gap:var(--admin-space-3); margin-bottom:var(--admin-space-5); }
.upload-panel { display:flex; align-items:center; flex-wrap:wrap; gap:var(--admin-space-3); }
.attachment-type { width:160px; }
.attachment-row { display:flex; justify-content:space-between; gap:var(--admin-space-5); overflow-wrap:anywhere; margin:0; padding:var(--admin-space-3) 0; border:0; border-bottom:1px solid var(--cc-border); border-radius:0; }.attachment-row small { display:block; margin-top:var(--admin-space-1); color:var(--el-text-color-secondary); }
.attachment-row>div{min-width:0}.attachment-row strong{overflow-wrap:anywhere}
@media (max-width: 560px) { .attachment-row { flex-direction:column; margin:0; padding:var(--admin-space-3) 0; border:0; border-bottom:1px solid var(--cc-border); border-radius:0; } }