优化任务附件字段展示
This commit is contained in:
1 parent
eda3e70873
commit
c3dad8eae9
2 files changed
+326
-5
No files matched your search
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user