231 lines
6.6 KiB
Vue
231 lines
6.6 KiB
Vue
<template>
|
|
<el-timeline>
|
|
<el-timeline-item v-for="item in items" :key="item.id" :timestamp="displayDateTime(item.created_at)">
|
|
<div class="thread-item" :class="{ 'is-highlighted': isHighlighted(item) }">
|
|
<div class="thread-meta">
|
|
<strong>{{ displayUser(item.created_by, { users: userMap, members: memberMap }) }}</strong>
|
|
<div class="thread-actions">
|
|
<el-button v-if="canQuote" type="text" size="small" @click="$emit('quote', item)">{{ TEXT.common.actions.quote }}</el-button>
|
|
<el-button v-if="canDelete?.(item)" type="text" size="small" class="danger" @click="$emit('delete', item)">
|
|
{{ TEXT.common.actions.delete }}
|
|
</el-button>
|
|
<slot name="actions" :item="item" />
|
|
</div>
|
|
</div>
|
|
<div v-if="item.quote" class="quote-block">
|
|
<div class="quote-meta">
|
|
{{ displayUser(item.quote.created_by, { users: userMap, members: memberMap }) }}
|
|
· {{ displayDateTime(item.quote.created_at) }}
|
|
</div>
|
|
<div class="quote-content">{{ quoteContent(item.quote) }}</div>
|
|
</div>
|
|
<div v-if="attachmentsMap[item.id]?.length" class="thread-attachments">
|
|
<div v-for="file in attachmentsMap[item.id]" :key="file.id" class="attachment-item">
|
|
<el-image
|
|
v-if="isImage(file)"
|
|
:src="attachmentUrl(file.id)"
|
|
:preview-src-list="[attachmentUrl(file.id)]"
|
|
fit="cover"
|
|
preview-teleported
|
|
class="attachment-image"
|
|
/>
|
|
<div v-if="isImage(file)" class="attachment-name" :title="file.filename">
|
|
{{ file.filename }}
|
|
</div>
|
|
<el-link v-else :underline="false" class="file-link" @click="download(file.id)">
|
|
<span class="file-badge">{{ TEXT.common.labels.file }}</span>
|
|
<span class="file-name">{{ file.filename }}</span>
|
|
</el-link>
|
|
</div>
|
|
</div>
|
|
<p>{{ contentText(item) }}</p>
|
|
</div>
|
|
</el-timeline-item>
|
|
</el-timeline>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { onBeforeUnmount, reactive, watch } from "vue";
|
|
import { downloadAttachment } from "../api/attachments";
|
|
import { saveFile } from "../runtime";
|
|
import { displayDateTime, displayUser } from "../utils/display";
|
|
import { TEXT } from "../locales";
|
|
|
|
const props = defineProps<{
|
|
items: any[];
|
|
attachmentsMap: Record<string, any[]>;
|
|
memberMap?: Record<string, string>;
|
|
userMap?: Record<string, string>;
|
|
canQuote?: boolean;
|
|
canDelete?: (item: any) => boolean;
|
|
highlightIds?: Array<string | number>;
|
|
}>();
|
|
|
|
defineEmits<{
|
|
(e: "quote", item: any): void;
|
|
(e: "delete", item: any): void;
|
|
}>();
|
|
|
|
const quoteContent = (item: any) =>
|
|
item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback;
|
|
const contentText = (item: any) =>
|
|
item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback;
|
|
|
|
const attachmentObjectUrls = reactive<Record<string, string>>({});
|
|
const attachmentUrl = (id: string) => attachmentObjectUrls[id] || "";
|
|
|
|
const revokeAttachmentUrls = () => {
|
|
Object.values(attachmentObjectUrls).forEach((url) => URL.revokeObjectURL(url));
|
|
Object.keys(attachmentObjectUrls).forEach((key) => delete attachmentObjectUrls[key]);
|
|
};
|
|
|
|
const loadImageUrls = async () => {
|
|
revokeAttachmentUrls();
|
|
const files = Object.values(props.attachmentsMap).flat().filter(isImage);
|
|
await Promise.all(files.map(async (file) => {
|
|
try {
|
|
const response = await downloadAttachment(file.id);
|
|
attachmentObjectUrls[file.id] = URL.createObjectURL(
|
|
new Blob([response.data], { type: response.headers?.["content-type"] || file.content_type }),
|
|
);
|
|
} catch {
|
|
attachmentObjectUrls[file.id] = "";
|
|
}
|
|
}));
|
|
};
|
|
|
|
const download = async (id: string) => {
|
|
const file = Object.values(props.attachmentsMap).flat().find((item) => item.id === id);
|
|
const response = await downloadAttachment(id);
|
|
await saveFile({
|
|
suggestedName: file?.filename || "attachment",
|
|
mimeType: response.headers?.["content-type"] || file?.content_type,
|
|
data: response.data,
|
|
});
|
|
};
|
|
|
|
const isImage = (file: any) => {
|
|
const type = String(file?.content_type || "").toLowerCase();
|
|
if (type.startsWith("image/")) return true;
|
|
const name = String(file?.filename || "").toLowerCase();
|
|
return [".png", ".jpg", ".jpeg", ".gif", ".bmp", ".webp"].some((ext) => name.endsWith(ext));
|
|
};
|
|
|
|
const isHighlighted = (item: any) => {
|
|
if (!props.highlightIds?.length) return false;
|
|
return props.highlightIds.includes(item?.id);
|
|
};
|
|
|
|
watch(() => props.attachmentsMap, () => void loadImageUrls(), { deep: true, immediate: true });
|
|
onBeforeUnmount(revokeAttachmentUrls);
|
|
</script>
|
|
|
|
<style scoped>
|
|
.thread-item.is-highlighted {
|
|
background: #fff7ed;
|
|
border: 1px solid #fed7aa;
|
|
border-radius: 10px;
|
|
padding: 10px 12px;
|
|
}
|
|
.thread-item {
|
|
padding: 10px 12px;
|
|
border-radius: 10px;
|
|
border: 1px solid #edf1f7;
|
|
background: #ffffff;
|
|
margin: 6px 0;
|
|
}
|
|
.thread-item.is-highlighted {
|
|
border-color: #fed7aa;
|
|
}
|
|
.thread-item p {
|
|
margin: 4px 0 0;
|
|
}
|
|
.thread-meta {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.thread-actions {
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
}
|
|
.quote-block {
|
|
background: #f5f7fa;
|
|
border-left: 3px solid #dcdfe6;
|
|
padding: 8px 10px;
|
|
border-radius: 4px;
|
|
margin-top: 6px;
|
|
}
|
|
.quote-meta {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
color: #909399;
|
|
font-size: 12px;
|
|
margin-bottom: 6px;
|
|
}
|
|
.quote-content {
|
|
white-space: pre-wrap;
|
|
}
|
|
.thread-attachments {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 8px;
|
|
margin-top: 6px;
|
|
}
|
|
.attachment-item {
|
|
display: inline-flex;
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
gap: 6px;
|
|
max-width: 140px;
|
|
}
|
|
.attachment-image {
|
|
width: 128px;
|
|
height: 96px;
|
|
border-radius: 10px;
|
|
border: 1px solid #ebeef5;
|
|
background: #fafafa;
|
|
box-shadow: 0 6px 14px rgba(15, 23, 42, 0.08);
|
|
}
|
|
.attachment-name {
|
|
font-size: 12px;
|
|
color: #606266;
|
|
max-width: 128px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.file-link {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 6px 10px;
|
|
border-radius: 8px;
|
|
background: #f8fafc;
|
|
border: 1px solid #e2e8f0;
|
|
color: #1f2937;
|
|
font-weight: 600;
|
|
}
|
|
.file-link:hover {
|
|
border-color: #cbd5f5;
|
|
background: #eef2ff;
|
|
}
|
|
.file-badge {
|
|
font-size: 11px;
|
|
color: #334155;
|
|
background: #e2e8f0;
|
|
padding: 2px 6px;
|
|
border-radius: 999px;
|
|
}
|
|
.file-name {
|
|
max-width: 180px;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
.danger {
|
|
color: #f56c6c;
|
|
}
|
|
</style>
|