优化任务附件字段展示

This commit is contained in:
andy
2026-07-12 20:42:04 +08:00
parent eda3e70873
commit c3dad8eae9
2 changed files with 326 additions and 5 deletions

View File

@@ -65,12 +65,33 @@
<span
v-else-if="isFileField(field)"
class="field-static field-static--file"
>
<span
v-if="fileItems(field).length"
class="field-file-list"
>
<span
v-for="item in fileItems(field)"
:key="item.key"
class="field-file-item"
>
<i
class="pi pi-paperclip"
aria-hidden="true"
/>
{{ stringifyReservationValue(fieldValue(field), t('task.emptyValue')) }}
<span class="field-file-item__content">
<a
v-if="item.url"
:href="item.url"
target="_blank"
rel="noreferrer noopener"
>{{ item.name }}</a>
<span v-else>{{ item.name }}</span>
<small v-if="item.meta">{{ item.meta }}</small>
</span>
</span>
</span>
<span v-else>{{ t('task.emptyValue') }}</span>
</span>
<span
@@ -149,6 +170,137 @@ function fieldValue(field: ReservationTaskFieldResult): string | number | readon
return stringifyReservationValue(value, t('task.emptyValue'))
}
interface FileDisplayItem {
key: string
name: string
meta: string
url: string | undefined
}
function fileItems(field: ReservationTaskFieldResult): FileDisplayItem[] {
return normalizeFileFieldValue(readReservationFieldValue(field, props.modelValue)).map((item, index) =>
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;

View File

@@ -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')
})
})