Files
ctms/frontend/src/components/ThreadList.vue
T
Cheng Zhou 628ff8828b
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
feat(desktop): implement phase 2 native capabilities
2026-06-30 21:21:55 +08:00

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>