优化任务附件字段展示
This commit is contained in:
@@ -66,11 +66,32 @@
|
||||
v-else-if="isFileField(field)"
|
||||
class="field-static field-static--file"
|
||||
>
|
||||
<i
|
||||
class="pi pi-paperclip"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ stringifyReservationValue(fieldValue(field), t('task.emptyValue')) }}
|
||||
<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"
|
||||
/>
|
||||
<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;
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user