优化任务附件字段展示
This commit is contained in:
@@ -66,11 +66,32 @@
|
|||||||
v-else-if="isFileField(field)"
|
v-else-if="isFileField(field)"
|
||||||
class="field-static field-static--file"
|
class="field-static field-static--file"
|
||||||
>
|
>
|
||||||
<i
|
<span
|
||||||
class="pi pi-paperclip"
|
v-if="fileItems(field).length"
|
||||||
aria-hidden="true"
|
class="field-file-list"
|
||||||
/>
|
>
|
||||||
{{ stringifyReservationValue(fieldValue(field), t('task.emptyValue')) }}
|
<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>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
@@ -149,6 +170,137 @@ function fieldValue(field: ReservationTaskFieldResult): string | number | readon
|
|||||||
return stringifyReservationValue(value, t('task.emptyValue'))
|
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 {
|
function isEditable(field: ReservationTaskFieldResult): boolean {
|
||||||
return isReservationFieldEditable(field, props.readOnly)
|
return isReservationFieldEditable(field, props.readOnly)
|
||||||
}
|
}
|
||||||
@@ -241,9 +393,51 @@ function updateField(field: ReservationTaskFieldResult, event: Event): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.field-static--file {
|
.field-static--file {
|
||||||
|
align-items: stretch;
|
||||||
color: var(--th-color-info);
|
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 {
|
.field-static--table {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
|
|||||||
@@ -138,4 +138,131 @@ describe('ReservationTaskFieldRenderer', () => {
|
|||||||
expect(wrapper.text()).toContain('客人姓名为必填项')
|
expect(wrapper.text()).toContain('客人姓名为必填项')
|
||||||
expect(wrapper.find('textarea').attributes('aria-invalid')).toBe('true')
|
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