diff --git a/client/src/components/reservation/ReservationTaskFieldRenderer.vue b/client/src/components/reservation/ReservationTaskFieldRenderer.vue
index 0d0a9f4..85c5920 100644
--- a/client/src/components/reservation/ReservationTaskFieldRenderer.vue
+++ b/client/src/components/reservation/ReservationTaskFieldRenderer.vue
@@ -66,11 +66,32 @@
v-else-if="isFileField(field)"
class="field-static field-static--file"
>
-
- {{ stringifyReservationValue(fieldValue(field), t('task.emptyValue')) }}
+
+
+
+
+ {{ item.name }}
+ {{ item.name }}
+ {{ item.meta }}
+
+
+
+ {{ t('task.emptyValue') }}
+ toFileDisplayItem(item, index),
+ )
+}
+
+function normalizeFileFieldValue(value: unknown): unknown[] {
+ if (value === null || value === undefined || value === '') {
+ return []
+ }
+ if (Array.isArray(value)) {
+ return value.flatMap((item) => normalizeFileFieldValue(item))
+ }
+ if (typeof value === 'string') {
+ const trimmedValue = value.trim()
+ if (!trimmedValue) {
+ return []
+ }
+ const parsedValue = parseMaybeJson(trimmedValue)
+ return parsedValue === trimmedValue ? [trimmedValue] : normalizeFileFieldValue(parsedValue)
+ }
+ return [value]
+}
+
+function toFileDisplayItem(value: unknown, index: number): FileDisplayItem {
+ const record = readRecord(value)
+ const name = record ? readFirstString(record, ['name', 'file_name', 'filename', 'original_file_name', 'display_name', 'title']) : String(value)
+ const rawType = record ? readFirstString(record, ['content_type', 'mime_type', 'media_type', 'file_type', 'type']) : null
+ const size = record ? readFirstNumber(record, ['size_bytes', 'file_size', 'size']) : null
+ const typeLabel = formatFileTypeLabel(name, rawType)
+ const sizeLabel = formatFileSize(size)
+ const id = record ? readFirstString(record, ['id', 'media_id', 'file_id', 'attachment_id']) : null
+ return {
+ key: id ? `${id}-${index}` : `${name}-${index}`,
+ name: name || t('conversation.unnamedAttachment'),
+ meta: [typeLabel, sizeLabel].filter(Boolean).join(' · '),
+ url: record ? safeExternalUrl(readFirstString(record, ['url', 'external_url', 'download_url', 'oss_url', 'media_url'])) : undefined,
+ }
+}
+
+function parseMaybeJson(value: string): unknown {
+ if (!value.startsWith('{') && !value.startsWith('[')) {
+ return value
+ }
+ try {
+ return JSON.parse(value)
+ } catch {
+ return value
+ }
+}
+
+function readRecord(value: unknown): ReservationRecord | null {
+ return value && typeof value === 'object' && !Array.isArray(value) ? value as ReservationRecord : null
+}
+
+function readFirstString(record: ReservationRecord, keys: string[]): string | null {
+ for (const key of keys) {
+ const value = record[key]
+ if (typeof value === 'string' && value.trim()) {
+ return value.trim()
+ }
+ }
+ return null
+}
+
+function readFirstNumber(record: ReservationRecord, keys: string[]): number | null {
+ for (const key of keys) {
+ const value = record[key]
+ if (typeof value === 'number' && Number.isFinite(value)) {
+ return value
+ }
+ if (typeof value === 'string' && Number.isFinite(Number(value))) {
+ return Number(value)
+ }
+ }
+ return null
+}
+
+function formatFileTypeLabel(fileName: string | null, rawType: string | null): string {
+ const source = `${rawType ?? ''} ${fileName ?? ''}`.toLowerCase()
+ if (source.includes('pdf') || source.endsWith('.pdf')) {
+ return 'PDF'
+ }
+ if (source.includes('spreadsheet') || source.includes('excel') || /\.(xlsx|xls|csv)$/.test(source)) {
+ return 'Excel'
+ }
+ if (source.includes('word') || /\.(docx|doc)$/.test(source)) {
+ return 'Word'
+ }
+ if (source.includes('image') || /\.(png|jpe?g|gif|webp)$/.test(source)) {
+ return 'Image'
+ }
+ if (source.includes('message/rfc822') || source.endsWith('.eml')) {
+ return 'EML'
+ }
+ const extension = fileName?.match(/\.([a-z0-9]{2,8})$/i)?.[1]
+ return extension ? extension.toUpperCase() : ''
+}
+
+function formatFileSize(size: number | null): string {
+ if (!size || size <= 0) {
+ return ''
+ }
+ if (size < 1024) {
+ return `${size} B`
+ }
+ if (size < 1024 * 1024) {
+ return `${(size / 1024).toFixed(1)} KB`
+ }
+ return `${(size / 1024 / 1024).toFixed(1)} MB`
+}
+
+function safeExternalUrl(value: string | null): string | undefined {
+ if (!value) {
+ return undefined
+ }
+ try {
+ const url = new URL(value)
+ return url.protocol === 'http:' || url.protocol === 'https:' ? value : undefined
+ } catch {
+ return undefined
+ }
+}
+
function isEditable(field: ReservationTaskFieldResult): boolean {
return isReservationFieldEditable(field, props.readOnly)
}
@@ -241,9 +393,51 @@ function updateField(field: ReservationTaskFieldResult, event: Event): void {
}
.field-static--file {
+ align-items: stretch;
color: var(--th-color-info);
}
+.field-file-list {
+ display: grid;
+ width: 100%;
+ gap: 8px;
+ margin: 0;
+ padding: 0;
+}
+
+.field-file-item {
+ display: flex;
+ min-width: 0;
+ align-items: flex-start;
+ gap: 8px;
+}
+
+.field-file-item i {
+ margin-top: 2px;
+ flex: 0 0 auto;
+}
+
+.field-file-item__content {
+ display: grid;
+ min-width: 0;
+ gap: 2px;
+ color: var(--th-color-slate-900);
+}
+
+.field-file-item__content a {
+ color: var(--th-color-info);
+ text-decoration: none;
+}
+
+.field-file-item__content a:hover {
+ text-decoration: underline;
+}
+
+.field-file-item__content small {
+ color: var(--th-color-slate-500);
+ font-size: 12px;
+}
+
.field-static--table {
align-items: flex-start;
white-space: normal;
diff --git a/client/src/tests/ReservationTaskFieldRenderer.spec.ts b/client/src/tests/ReservationTaskFieldRenderer.spec.ts
index 3ccdd45..b40a318 100644
--- a/client/src/tests/ReservationTaskFieldRenderer.spec.ts
+++ b/client/src/tests/ReservationTaskFieldRenderer.spec.ts
@@ -138,4 +138,131 @@ describe('ReservationTaskFieldRenderer', () => {
expect(wrapper.text()).toContain('客人姓名为必填项')
expect(wrapper.find('textarea').attributes('aria-invalid')).toBe('true')
})
+
+ it('renders file fields as attachment items instead of raw JSON', () => {
+ const i18n = createI18n({
+ legacy: false,
+ locale: 'zh-CN',
+ messages: {
+ 'zh-CN': zhCN,
+ },
+ })
+ const fileField: ReservationTaskFieldResult = {
+ ...fields[0]!,
+ row_number: 3,
+ display_area: '证据',
+ field_path: 'attachments',
+ display_name: '相关附件',
+ editable: 'N',
+ input_editable: 'N',
+ file_display: 'Y',
+ required_rule: 'N',
+ notes: '附件、图片、PDF、表格证据展示。',
+ value: null,
+ }
+
+ const wrapper = mount(ReservationTaskFieldRenderer, {
+ props: {
+ fields: [fileField],
+ modelValue: {
+ attachments: [
+ {
+ id: 'att-1',
+ name: 'WYNDHAM LIANTAI 2026 UPDATE BOOKING 12-05-2026 NO.1.xlsx',
+ content_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
+ },
+ {
+ id: 'att-2',
+ file_name: 'Rooming List.pdf',
+ mime_type: 'application/pdf',
+ },
+ ],
+ },
+ readOnly: true,
+ validationErrors: {},
+ },
+ global: {
+ plugins: [i18n],
+ },
+ })
+
+ expect(wrapper.findAll('.field-file-item')).toHaveLength(2)
+ expect(wrapper.text()).toContain('WYNDHAM LIANTAI 2026 UPDATE BOOKING 12-05-2026 NO.1.xlsx')
+ expect(wrapper.text()).toContain('Rooming List.pdf')
+ expect(wrapper.text()).toContain('Excel')
+ expect(wrapper.text()).toContain('PDF')
+ expect(wrapper.text()).not.toContain('{"id"')
+ expect(wrapper.text()).not.toContain('"name"')
+ expect(wrapper.text()).not.toContain('content_type')
+ })
+
+ it('only renders http and https attachment urls as links', () => {
+ const i18n = createI18n({
+ legacy: false,
+ locale: 'zh-CN',
+ messages: {
+ 'zh-CN': zhCN,
+ },
+ })
+ const fileField: ReservationTaskFieldResult = {
+ ...fields[0]!,
+ row_number: 3,
+ display_area: '证据',
+ field_path: 'attachments',
+ display_name: '相关附件',
+ editable: 'N',
+ input_editable: 'N',
+ file_display: 'Y',
+ required_rule: 'N',
+ notes: null,
+ value: null,
+ }
+
+ const wrapper = mount(ReservationTaskFieldRenderer, {
+ props: {
+ fields: [fileField],
+ modelValue: {
+ attachments: [
+ {
+ id: 'safe-1',
+ name: '安全附件.pdf',
+ external_url: 'https://oss.example/safe.pdf',
+ },
+ {
+ id: 'unsafe-js',
+ name: '脚本附件.pdf',
+ external_url: 'javascript:alert(1)',
+ },
+ {
+ id: 'unsafe-data',
+ name: '内联数据附件.pdf',
+ download_url: 'data:text/html;base64,PHNjcmlwdD5iYWQoKTwvc2NyaXB0Pg==',
+ },
+ {
+ id: 'unsafe-file',
+ name: '本地文件附件.pdf',
+ oss_url: 'file:///tmp/private.pdf',
+ },
+ ],
+ },
+ readOnly: true,
+ validationErrors: {},
+ },
+ global: {
+ plugins: [i18n],
+ },
+ })
+
+ const links = wrapper.findAll('.field-file-item a')
+
+ expect(links).toHaveLength(1)
+ expect(links[0]?.text()).toBe('安全附件.pdf')
+ expect(links[0]?.attributes('href')).toBe('https://oss.example/safe.pdf')
+ expect(wrapper.text()).toContain('脚本附件.pdf')
+ expect(wrapper.text()).toContain('内联数据附件.pdf')
+ expect(wrapper.text()).toContain('本地文件附件.pdf')
+ expect(wrapper.html()).not.toContain('javascript:alert')
+ expect(wrapper.html()).not.toContain('data:text/html')
+ expect(wrapper.html()).not.toContain('file:///tmp/private.pdf')
+ })
})