This commit is contained in:
2026-08-26 11:10:48 +08:00
parent 53c92d4d37
commit c38e538a95
14 changed files with 301 additions and 235 deletions
@@ -1,8 +1,11 @@
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import ElementPlus from 'element-plus'
import ContainerOverviewPanel from './ContainerOverviewPanel.vue'
import type { ContainerOverview } from '../types'
import { downloadContainerPdaPhoto } from '../api/platformContainerLoading'
vi.mock('../api/platformContainerLoading', () => ({ downloadContainerPdaPhoto: vi.fn() }))
const overview: ContainerOverview = {
container: { id: '7', bookingNo: 'BK-1', cabinetNo: 'CB-1', containerNo: 'MSCU-1', status: 'LOADING', warehouseId: '8', warehouseName: '义乌仓', voyageNo: 'V1', vesselName: '船一', version: 2 },
@@ -15,8 +18,16 @@ const overview: ContainerOverview = {
}
describe('ContainerOverviewPanel', () => {
it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } })
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(downloadContainerPdaPhoto).mockResolvedValue(new Blob(['photo']))
Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: vi.fn(() => 'blob:container-photo') })
Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: vi.fn() })
})
it('renders documents, PDA progress, exception history and timeline from the aggregate DTO', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.text()).toContain('DO 资料')
expect(wrapper.text()).toContain('do.pdf')
expect(wrapper.text()).toContain('PDA 作业进度')
@@ -29,25 +40,72 @@ describe('ContainerOverviewPanel', () => {
})
it('emits stable document types and does not expose file ids in visible text', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } })
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
const updateButton = wrapper.findAll('button').find((button) => button.text().includes('更新'))!
await updateButton.trigger('click')
expect(wrapper.emitted('uploadDocument')?.[0]).toEqual(['DELIVERY_ORDER'])
expect(wrapper.text()).not.toContain('9007199254740999')
})
it('emits the backend-provided photo object from an accessible view control', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await wrapper.find('button[aria-label="查看装柜照片 empty.jpg"]').trigger('click')
expect(wrapper.emitted('previewPhoto')?.[0]?.[0]).toMatchObject({ evidenceId: '20', downloadPath: '/api/platform/v1/containers/7/pda/photos/23/download' })
it('loads protected photos into direct thumbnails, opens them and revokes object URLs', async () => {
const open = vi.spyOn(window, 'open').mockImplementation(() => null)
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(downloadContainerPdaPhoto).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/23/download')
expect(wrapper.find('img[src="blob:container-photo"]').exists()).toBe(true)
await wrapper.find('button[aria-label="预览装柜照片 empty.jpg"]').trigger('click')
expect(open).toHaveBeenCalledWith('blob:container-photo', '_blank', 'noopener,noreferrer')
wrapper.unmount()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:container-photo')
})
it('renders empty and forbidden/load failure states without exposing credentials', () => {
it('revokes old thumbnail URLs when the overview photos change', async () => {
const wrapper = mount(ContainerOverviewPanel, { props: { overview, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await flushPromises()
const changed = { ...overview.pda!.photos[0], evidenceId: '22', fileId: '25', fileName: 'next.jpg', downloadPath: '/api/platform/v1/containers/7/pda/photos/25/download' }
await wrapper.setProps({ overview: { ...overview, pda: { ...overview.pda!, photos: [changed] } } })
await flushPromises()
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:container-photo')
expect(downloadContainerPdaPhoto).toHaveBeenCalledWith('/api/platform/v1/containers/7/pda/photos/25/download')
wrapper.unmount()
})
it('isolates one thumbnail failure without blocking other photos', async () => {
const second = { ...overview.pda!.photos[0], evidenceId: '21', fileId: '24', fileName: 'seal.jpg', evidenceType: 'SEAL', downloadPath: '/api/platform/v1/containers/7/pda/photos/24/download' }
vi.mocked(downloadContainerPdaPhoto).mockResolvedValueOnce(new Blob(['photo'])).mockRejectedValueOnce(new Error('photo unavailable'))
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: { ...overview.pda!, photos: [...overview.pda!.photos, second] } }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
await flushPromises()
expect(wrapper.find('img[src="blob:container-photo"]').exists()).toBe(true)
expect(wrapper.text()).toContain('照片加载失败')
wrapper.unmount()
})
it('maps PDA events to Chinese and hides long technical keys from the main timeline', () => {
const longKey = 'pda-submit-1000001-2092438843630391297-2092437496092790785--0c642e80891d76a4-m9428-76'
const timeline = [
{ eventType: 'PDA_CLAIM', detail: `claim-${longKey}`, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:28:00' },
{ eventType: 'PDA_BATCH', detail: longKey, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:28:00' },
{ eventType: 'PDA_COMPLETE_LOADING', detail: `complete-${longKey}`, operatorId: '1', operatorName: 'platform_admin', occurredAt: '2026-08-26T10:29:00' },
]
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: null, timeline }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
expect(wrapper.text()).toContain('领取 PDA 任务')
expect(wrapper.text()).toContain('提交拣货结果')
expect(wrapper.text()).toContain('完成装柜')
expect(wrapper.text()).not.toContain(longKey)
})
it('renders a compact empty photo state without exposing credentials', () => {
const withoutPhotos = { ...overview, pda: overview.pda ? { ...overview.pda, photos: [] } : null }
const wrapper = mount(ContainerOverviewPanel, { props: { overview: withoutPhotos, canManageDocuments: false, canManageException: false, photoError: { message: '当前账号无权访问该数据,请联系管理员确认权限', code: 'HTTP_403', requestId: 'req-photo' } }, global: { plugins: [ElementPlus] } })
const wrapper = mount(ContainerOverviewPanel, { props: { overview: withoutPhotos, canManageDocuments: false, canManageCustoms: false, canManageException: false }, global: { plugins: [ElementPlus] } })
expect(wrapper.text()).toContain('暂无可查看的有效照片')
expect(wrapper.text()).toContain('当前账号无权访问该数据')
expect(wrapper.text()).toContain('req-photo')
expect(wrapper.text()).not.toContain('Authorization')
})
it('keeps a completed customs document labelled as an attachment, not formal customs release', () => {
const customs = { ...overview.documents[0], documentType: 'CUSTOMS_DECLARATION', completed: true, fileName: 'customs.pdf' }
const wrapper = mount(ContainerOverviewPanel, { props: { overview: { ...overview, pda: null, documents: [customs] }, canManageDocuments: true, canManageCustoms: true, canManageException: true }, global: { plugins: [ElementPlus] } })
expect(wrapper.text()).toContain('国内出口报关附件')
expect(wrapper.text()).not.toContain('已完成报关')
expect(wrapper.text()).not.toContain('已放行')
})
})
@@ -1,43 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Download, Eye, FileUp, TriangleAlert } from '@lucide/vue'
import ApiErrorMeta from '@/shared/components/ApiErrorMeta.vue'
import LoadErrorState from '@/shared/components/LoadErrorState.vue'
import type { ApiErrorDetails } from '@/shared/api/http'
import { computed, onBeforeUnmount, reactive, watch } from 'vue'
import { Download, FileUp, TriangleAlert } from '@lucide/vue'
import { getApiErrorDetails } from '@/shared/api/http'
import StatusBadge from '@/shared/components/StatusBadge.vue'
import type { ContainerOverview, ContainerOverviewDocument, ContainerPdaPhoto } from '../types'
import { downloadContainerPdaPhoto } from '../api/platformContainerLoading'
const props = defineProps<{
overview: ContainerOverview
canManageDocuments: boolean
canManageCustoms: boolean
canManageException: boolean
documentBusy?: boolean
photoBusyId?: string | null
photoError?: ApiErrorDetails | null
}>()
const emit = defineEmits<{
uploadDocument: [documentType: string]
downloadDocument: [document: ContainerOverviewDocument]
manageException: []
previewPhoto: [photo: ContainerPdaPhoto]
}>()
const documentTypes = [
{ type: 'DESTINATION_CLEARANCE', label: '目的港清关资料' },
{ type: 'CUSTOMS_DECLARATION', label: '报关资料' },
{ type: 'CUSTOMS_DECLARATION', label: '国内出口报关附件' },
{ type: 'DELIVERY_ORDER', label: 'DO 资料' },
{ type: 'PICKUP_ORDER', label: 'PU 资料' },
] as const
const documentsByType = computed(() => new Map(props.overview.documents.map((item) => [item.documentType, item])))
const downloadableTypes = new Set(['DELIVERY_ORDER', 'PICKUP_ORDER'])
const customsTypes = new Set(['DESTINATION_CLEARANCE', 'CUSTOMS_DECLARATION'])
function canUploadDocument(documentType: string) {
return downloadableTypes.has(documentType) ? props.canManageDocuments : customsTypes.has(documentType) && props.canManageCustoms
}
const pdaProgress = computed(() => {
const pda = props.overview.pda
if (!pda || pda.itemCount <= 0) return 0
return Math.min(100, Math.round((pda.completedItemCount / pda.itemCount) * 100))
})
const activePhotos = computed(() => (props.overview.pda?.photos || []).filter((item) => item.status === 'ACTIVE' && item.downloadPath))
const photoStates = reactive<Record<string, { loading: boolean; url: string | null; error: string | null }>>({})
let photoGeneration = 0
const workflowStatusLabels: Record<string, string> = {
LOADING: '作业中', AWAITING_REVIEW: '待人工复核', COMPLETED: '已完成',
}
@@ -55,8 +58,41 @@ function formatTime(value: string | null | undefined) {
function eventLabel(value: string) {
return ({
MARK: '标记异常', CANCEL: '取消异常', TRANSPORT_UPDATE: '更新运输信息',
PDA_CLAIM: '领取 PDA 任务', PDA_BATCH: '提交拣货结果', PDA_COMPLETE_LOADING: '完成装柜',
} as Record<string, string>)[value] || value || '状态更新'
}
function eventSummary(entry: ContainerOverview['timeline'][number]) {
if (entry.eventType === 'PDA_CLAIM') return 'PDA 作业任务已领取'
if (entry.eventType === 'PDA_BATCH') return '拣货结果已提交'
if (entry.eventType === 'PDA_COMPLETE_LOADING') return '装柜作业已完成'
const detail = String(entry.detail || '').trim()
return !detail || detail.length > 120 || /(?:operation|idempot|claim-|pda-submit|complete-)/i.test(detail) ? '操作已记录' : detail
}
function revokePhotoUrls() {
Object.values(photoStates).forEach((state) => { if (state.url) URL.revokeObjectURL(state.url) })
Object.keys(photoStates).forEach((key) => delete photoStates[key])
}
async function loadPhotoThumbnails() {
const generation = ++photoGeneration
revokePhotoUrls()
await Promise.all(activePhotos.value.map(async (photo) => {
photoStates[photo.evidenceId] = { loading: true, url: null, error: null }
try {
const blob = await downloadContainerPdaPhoto(photo.downloadPath)
if (generation !== photoGeneration) return
photoStates[photo.evidenceId] = { loading: false, url: URL.createObjectURL(blob), error: null }
} catch (cause) {
if (generation !== photoGeneration) return
photoStates[photo.evidenceId] = { loading: false, url: null, error: getApiErrorDetails(cause, '照片加载失败').message }
}
}))
}
function openPhoto(photo: ContainerPdaPhoto) {
const url = photoStates[photo.evidenceId]?.url
if (url) window.open(url, '_blank', 'noopener,noreferrer')
}
watch(() => activePhotos.value.map((photo) => `${photo.evidenceId}:${photo.downloadPath}`).join('|'), () => void loadPhotoThumbnails(), { immediate: true })
onBeforeUnmount(() => { photoGeneration += 1; revokePhotoUrls() })
</script>
<template>
@@ -64,15 +100,15 @@ function eventLabel(value: string) {
<section class="overview-section" aria-label="货柜异常状态">
<header><h3>异常状态</h3><el-button v-if="canManageException" :type="overview.exception.active ? 'primary' : 'danger'" plain @click="emit('manageException')"><TriangleAlert :size="16" />{{ overview.exception.active ? '取消异常' : '标记异常' }}</el-button></header>
<el-alert v-if="overview.exception.active" type="error" :closable="false" show-icon><template #title>货柜已标记异常</template><p>{{ overview.exception.reason || '未填写异常原因' }}</p><small>{{ overview.exception.markedByName || '操作人信息缺失' }} · {{ formatTime(overview.exception.markedAt) }}</small></el-alert>
<el-empty v-else description="当前无货柜异常" :image-size="64" />
<p v-else class="compact-empty">当前无货柜异常</p>
</section>
<section class="overview-section" aria-label="货柜业务资料">
<header><h3>业务资料</h3><span>{{ overview.documents.length }} 份</span></header>
<div class="document-grid">
<div class="document-grid compact-document-list">
<article v-for="item in documentTypes" :key="item.type" class="document-item">
<div><strong>{{ item.label }}</strong><span>{{ documentsByType.get(item.type)?.fileName || '未上传' }}</span><small v-if="documentsByType.get(item.type)">{{ documentsByType.get(item.type)?.uploadedByName || '上传人信息缺失' }} · {{ formatTime(documentsByType.get(item.type)?.uploadedAt) }}</small></div>
<div class="document-actions"><el-button v-if="documentsByType.get(item.type) && downloadableTypes.has(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canManageDocuments && downloadableTypes.has(item.type)" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新' : '上传' }}</el-button></div>
<div><strong>{{ item.label }}</strong><span>{{ documentsByType.get(item.type)?.fileName || '未上传' }}</span><small>{{ documentsByType.get(item.type) ? formatTime(documentsByType.get(item.type)?.uploadedAt) : '等待上传' }}</small></div>
<div class="document-actions"><el-button v-if="documentsByType.get(item.type) && downloadableTypes.has(item.type)" size="small" :loading="documentBusy" @click="emit('downloadDocument', documentsByType.get(item.type)!)"><Download :size="15" />下载</el-button><el-button v-if="canUploadDocument(item.type)" size="small" @click="emit('uploadDocument', item.type)"><FileUp :size="15" />{{ documentsByType.get(item.type) ? '更新' : '上传' }}</el-button></div>
</article>
</div>
</section>
@@ -80,13 +116,13 @@ function eventLabel(value: string) {
<section class="overview-section" aria-label="PDA作业摘要">
<header><h3>PDA 作业进度</h3><StatusBadge v-if="overview.pda" :status="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus)" /></header>
<el-empty v-if="!overview.pda" description="尚未生成 PDA 作业任务" :image-size="64" />
<template v-else><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div class="metric-grid"><div><span>任务明细</span><strong>{{ overview.pda.itemCount }}</strong></div><div><span>已处理</span><strong>{{ overview.pda.completedItemCount }}</strong></div><div><span>未解决</span><strong>{{ overview.pda.unresolvedItemCount }}</strong></div><div><span>有效照片</span><strong>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</strong></div></div><div v-if="overview.pda.photoCounts.length" class="photo-summary"><span v-for="item in overview.pda.photoCounts" :key="item.evidenceType">{{ evidenceTypeLabel(item.evidenceType) }}:{{ item.count }}</span></div><div v-if="activePhotos.length" class="photo-list"><article v-for="photo in activePhotos" :key="photo.evidenceId"><div><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><span>{{ photo.fileName }}</span><small>{{ formatTime(photo.uploadedAt) }}</small></div><el-button size="small" :loading="photoBusyId === photo.evidenceId" :aria-label="`查看装柜照片 ${photo.fileName}`" @click="emit('previewPhoto', photo)"><Eye :size="15" />查看</el-button></article></div><el-empty v-else description="暂无可查看的有效照片" :image-size="48" /><LoadErrorState v-if="photoError" compact title="照片加载失败" :message="photoError.message" :show-retry="false"><template #details><ApiErrorMeta :details="photoError" /></template></LoadErrorState><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
<template v-else><div class="pda-summary"><el-progress :percentage="pdaProgress" :status="overview.pda.unresolvedItemCount ? 'exception' : undefined" /><div class="metric-grid"><div><span>任务明细</span><strong>{{ overview.pda.itemCount }}</strong></div><div><span>已处理</span><strong>{{ overview.pda.completedItemCount }}</strong></div><div><span>未解决</span><strong>{{ overview.pda.unresolvedItemCount }}</strong></div><div><span>有效照片</span><strong>{{ overview.pda.photoCounts.reduce((sum, item) => sum + item.count, 0) }}</strong></div></div></div><el-alert v-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '待人工复核'" type="warning" :closable="false" title="PDA 作业已完成,等待人工复核后才能完成装柜。" /><el-alert v-else-if="workflowStatusLabel(overview.pda.workflowStatus, overview.pda.taskStatus) === '作业中'" type="info" :closable="false" title="请继续在 PDA 完成拣货、拍照和封条登记。" /><div v-if="activePhotos.length" class="photo-gallery"><button v-for="photo in activePhotos" :key="photo.evidenceId" type="button" class="photo-thumb" :aria-label="`预览装柜照片 ${photo.fileName}`" :disabled="!photoStates[photo.evidenceId]?.url" @click="openPhoto(photo)"><span v-if="photoStates[photo.evidenceId]?.loading" class="photo-placeholder">加载中</span><span v-else-if="photoStates[photo.evidenceId]?.error" class="photo-placeholder photo-placeholder--error">{{ photoStates[photo.evidenceId]?.error }}</span><img v-else-if="photoStates[photo.evidenceId]?.url" :src="photoStates[photo.evidenceId]?.url || ''" :alt="`${evidenceTypeLabel(photo.evidenceType)} ${photo.fileName}`" /><span class="photo-caption"><strong>{{ evidenceTypeLabel(photo.evidenceType) }}</strong><small>{{ formatTime(photo.uploadedAt) }}</small></span></button></div><p v-else class="compact-empty">暂无可查看的有效照片</p><el-alert v-if="overview.pda.exceptionReasons.length" type="warning" :closable="false" :title="overview.pda.exceptionReasons.join(';')" /></template>
</section>
<section class="overview-section" aria-label="运输轨迹和操作日志">
<header><h3>运输轨迹与操作日志</h3><span>{{ overview.timeline.length }} 条</span></header>
<el-empty v-if="overview.timeline.length === 0" description="暂无轨迹或操作记录" :image-size="64" />
<el-timeline v-else><el-timeline-item v-for="(entry, index) in overview.timeline" :key="`${entry.eventType}-${entry.occurredAt}-${index}`" :timestamp="formatTime(entry.occurredAt)" placement="top"><strong>{{ eventLabel(entry.eventType) }}</strong><p>{{ entry.detail || '无补充说明' }}</p><small>{{ entry.operatorName || '系统' }}</small></el-timeline-item></el-timeline>
<ol v-else class="compact-timeline"><li v-for="(entry, index) in overview.timeline" :key="`${entry.eventType}-${entry.occurredAt}-${index}`"><time>{{ formatTime(entry.occurredAt) }}</time><strong>{{ eventLabel(entry.eventType) }}</strong><span>{{ entry.operatorName || '系统' }}</span><small>{{ eventSummary(entry) }}</small></li></ol>
</section>
<section class="overview-section" aria-label="异常处理历史">
@@ -98,23 +134,24 @@ function eventLabel(value: string) {
</template>
<style scoped>
.overview-panel { display: grid; gap: 16px; margin-top: 16px; }
.overview-section { min-width: 0; padding: 16px; border: 1px solid var(--cc-border); background: #fff; }
.overview-panel { display: grid; gap: 12px; }.overview-section:nth-child(3){order:1}.overview-section:nth-child(2){order:2}.overview-section:nth-child(1){order:3}.overview-section:nth-child(4){order:4}.overview-section:nth-child(5){order:5}
.overview-section { min-width: 0; padding: 12px 16px; border: 1px solid var(--cc-border); background: #fff; }
.overview-section > header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
.overview-section h3 { margin: 0; font-size: 15px; }
.overview-section header > span, .overview-section small { color: var(--cc-muted); font-size: 12px; }
.overview-section p { margin: 5px 0; color: var(--cc-muted); font-size: 13px; }
.document-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; }
.document-item { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 12px; padding: 12px; border: 1px solid var(--cc-border); }
.document-item { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 10px; border-top: 1px solid var(--cc-border); }
.document-item:nth-child(-n+2){border-top:0}.compact-document-list{column-gap:18px;row-gap:0}
.document-item > div:first-child { display: grid; min-width: 0; gap: 4px; }
.document-item span { overflow: hidden; color: var(--cc-muted); font-size: 13px; text-overflow: ellipsis; white-space: nowrap; }
.document-actions { display: flex; flex-shrink: 0; gap: 6px; }
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; margin: 12px 0; }
.metric-grid > div { display: grid; gap: 4px; padding: 10px; background: var(--cc-bg-subtle, #f7f8fa); }
.pda-summary{display:grid;grid-template-columns:minmax(220px,1fr) minmax(0,2fr);align-items:center;gap:16px;margin-bottom:10px}.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 6px; margin:0; }
.metric-grid > div { display: grid; gap: 2px; padding: 6px 9px; background: var(--cc-bg-subtle, #f7f8fa); }
.metric-grid span { color: var(--cc-muted); font-size: 12px; }
.metric-grid strong { font-size: 18px; }
.photo-summary { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }.photo-summary span { padding: 4px 8px; border: 1px solid var(--cc-border); color: var(--cc-muted); font-size: 12px; }
.photo-list { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; margin-bottom: 12px; }.photo-list article { display:flex; min-width:0; align-items:center; justify-content:space-between; gap:8px; padding:10px; border:1px solid var(--cc-border); }.photo-list article > div { display:grid; min-width:0; gap:3px; }.photo-list span { overflow:hidden; color:var(--cc-muted); font-size:12px; text-overflow:ellipsis; white-space:nowrap; }
@media (max-width: 720px) { .document-grid,.photo-list { grid-template-columns: 1fr; } .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.photo-gallery{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:8px;margin:10px 0}.photo-thumb{display:grid;min-width:0;overflow:hidden;padding:0;border:1px solid var(--cc-border);background:#fff;color:inherit;text-align:left;cursor:pointer}.photo-thumb:disabled{cursor:default}.photo-thumb img,.photo-placeholder{width:100%;aspect-ratio:4/3;object-fit:cover;background:var(--cc-bg-subtle,#f7f8fa)}.photo-placeholder{display:grid;place-items:center;padding:8px;color:var(--cc-muted);font-size:12px}.photo-placeholder--error{color:var(--el-color-danger)}.photo-caption{display:flex;align-items:center;justify-content:space-between;gap:6px;padding:6px 8px}.photo-caption small{white-space:nowrap}.compact-empty{margin:6px 0;color:var(--cc-muted);font-size:12px}.compact-timeline{display:grid;margin:0;padding:0;list-style:none}.compact-timeline li{display:grid;grid-template-columns:130px 130px 110px minmax(0,1fr);gap:8px;padding:6px 0;border-top:1px solid var(--cc-border);font-size:12px}.compact-timeline li:first-child{border-top:0}.compact-timeline time,.compact-timeline span,.compact-timeline small{overflow:hidden;color:var(--cc-muted);text-overflow:ellipsis;white-space:nowrap}
@media (max-width: 1200px){.photo-gallery{grid-template-columns:repeat(4,minmax(0,1fr))}.pda-summary{grid-template-columns:1fr}.compact-timeline li{grid-template-columns:120px 120px minmax(0,1fr)}.compact-timeline small{grid-column:2/-1}}
@media (max-width: 720px) { .document-grid { grid-template-columns: 1fr; }.document-item:nth-child(2){border-top:1px solid var(--cc-border)} .metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }.photo-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.compact-timeline li{grid-template-columns:1fr 1fr}.compact-timeline span,.compact-timeline small{grid-column:auto;white-space:normal} }
@media (max-width: 420px) { .document-item { align-items: stretch; flex-direction: column; } .document-actions :deep(.el-button) { flex: 1; } }
</style>