feat(desktop): implement phase 2 native capabilities
This commit is contained in:
@@ -817,8 +817,8 @@ const toggleCollapse = () => {
|
||||
localStorage.setItem("ctms_sidebar_collapsed", isCollapsed.value ? "1" : "0");
|
||||
};
|
||||
|
||||
const logoutImmediately = () => {
|
||||
auth.logout();
|
||||
const logoutImmediately = async () => {
|
||||
await auth.logout();
|
||||
study.clearCurrentStudy();
|
||||
router.replace("/login");
|
||||
};
|
||||
@@ -838,7 +838,7 @@ const onLogout = async () => {
|
||||
if (!confirmed) return;
|
||||
loggingOut.value = true;
|
||||
try {
|
||||
forceLogout(LOGOUT_REASON_MANUAL);
|
||||
await forceLogout(LOGOUT_REASON_MANUAL);
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
loggingOut.value = false;
|
||||
@@ -856,7 +856,7 @@ onMounted(async () => {
|
||||
try {
|
||||
await auth.fetchMe();
|
||||
} catch {
|
||||
logoutImmediately();
|
||||
await logoutImmediately();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,6 +154,7 @@ import type {
|
||||
} from "@/types/api";
|
||||
import { Search as SearchIcon } from "@element-plus/icons-vue";
|
||||
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
|
||||
import { saveFile } from "@/runtime";
|
||||
|
||||
const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), {
|
||||
showSecurityLog: false,
|
||||
@@ -311,7 +312,8 @@ const buildSecurityTerminalLine = (row: SecurityAccessLogItem) => {
|
||||
const account = row.account_label || "未知账号";
|
||||
const auth = SECURITY_AUTH_LABELS[row.auth_status] || row.auth_status;
|
||||
const userAgent = row.user_agent || "-";
|
||||
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
|
||||
const client = [row.client_type, row.client_version, row.client_platform].filter(Boolean).join("/") || "unknown";
|
||||
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client=${client} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
|
||||
};
|
||||
|
||||
const terminalLogRows = computed(() =>
|
||||
@@ -515,14 +517,9 @@ const openSecurityLogDialog = () => {
|
||||
scrollSecurityTerminalToBottom();
|
||||
};
|
||||
|
||||
const downloadLogFile = (fileName: string, lines: string[]) => {
|
||||
const downloadLogFile = async (fileName: string, lines: string[]) => {
|
||||
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
URL.revokeObjectURL(url);
|
||||
await saveFile({ suggestedName: fileName, mimeType: "text/plain;charset=utf-8", data: blob });
|
||||
};
|
||||
|
||||
const downloadInterfaceLog = () => {
|
||||
|
||||
@@ -70,7 +70,7 @@ const continueSession = () => {
|
||||
};
|
||||
|
||||
const logoutNow = () => {
|
||||
forceLogout(LOGOUT_REASON_MANUAL);
|
||||
void forceLogout(LOGOUT_REASON_MANUAL);
|
||||
};
|
||||
|
||||
watch(
|
||||
|
||||
@@ -11,9 +11,12 @@
|
||||
<el-input v-model="contentProxy" type="textarea" rows="3" :placeholder="TEXT.common.placeholders.input" />
|
||||
<div v-if="allowAttachments" class="upload-row">
|
||||
<div class="upload-label">{{ TEXT.common.labels.attachments }}</div>
|
||||
<el-upload v-model:file-list="fileListProxy" :auto-upload="false" multiple list-type="picture" class="thread-upload">
|
||||
<el-upload v-if="!nativeFiles" v-model:file-list="fileListProxy" :auto-upload="false" multiple list-type="picture" class="thread-upload">
|
||||
<el-button size="small" class="upload-button">{{ TEXT.common.actions.upload }}</el-button>
|
||||
</el-upload>
|
||||
<el-button v-else size="small" class="upload-button" @click="pickNativeAttachments">
|
||||
{{ TEXT.common.actions.upload }}
|
||||
</el-button>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<el-button v-if="showCancel" size="small" @click="$emit('cancel')">{{ TEXT.common.actions.cancel }}</el-button>
|
||||
@@ -26,6 +29,7 @@
|
||||
import { computed } from "vue";
|
||||
import type { UploadUserFile } from "element-plus";
|
||||
import { displayDateTime, displayUser } from "../utils/display";
|
||||
import { clientRuntime, pickFiles } from "../runtime";
|
||||
import { TEXT } from "../locales";
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -46,6 +50,7 @@ const emit = defineEmits<{
|
||||
(e: "cancel"): void;
|
||||
(e: "clear-quote"): void;
|
||||
}>();
|
||||
const nativeFiles = clientRuntime.capabilities().nativeFiles;
|
||||
|
||||
const contentProxy = computed({
|
||||
get: () => props.modelValue,
|
||||
@@ -58,6 +63,21 @@ const fileListProxy = computed({
|
||||
});
|
||||
|
||||
const quoteContent = (item: any) => (item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback);
|
||||
|
||||
const pickNativeAttachments = async () => {
|
||||
const files = await pickFiles({ multiple: true, title: TEXT.common.labels.attachments });
|
||||
const existing = new Set(props.fileList.map((item) => `${item.name}:${item.size}`));
|
||||
const additions: UploadUserFile[] = files
|
||||
.filter((file) => !existing.has(`${file.name}:${file.size}`))
|
||||
.map((file) => ({
|
||||
name: file.name,
|
||||
size: file.size,
|
||||
raw: file as any,
|
||||
status: "ready",
|
||||
uid: Date.now() + Math.floor(Math.random() * 100000),
|
||||
}));
|
||||
emit("update:fileList", [...props.fileList, ...additions]);
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -45,6 +45,9 @@
|
||||
</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";
|
||||
|
||||
@@ -68,13 +71,37 @@ const quoteContent = (item: any) =>
|
||||
const contentText = (item: any) =>
|
||||
item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback;
|
||||
|
||||
const attachmentUrl = (id: string) => {
|
||||
const token = localStorage.getItem("ctms_token");
|
||||
return token ? `/api/v1/attachments/${id}/download?token=${token}` : `/api/v1/attachments/${id}/download`;
|
||||
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 download = (id: string) => {
|
||||
window.open(attachmentUrl(id), "_blank");
|
||||
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) => {
|
||||
@@ -88,6 +115,9 @@ 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>
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<span>{{ headerTitle }}</span>
|
||||
<div v-if="canShowUploader && tableUploadGroup" class="immediate-upload">
|
||||
<el-upload
|
||||
v-if="!nativeFiles"
|
||||
:http-request="uploadImmediate"
|
||||
:show-file-list="false"
|
||||
:limit="1"
|
||||
@@ -13,6 +14,9 @@
|
||||
>
|
||||
<el-button type="primary" :loading="isImmediateUploading">{{ TEXT.common.actions.upload }}</el-button>
|
||||
</el-upload>
|
||||
<el-button v-else type="primary" :loading="isImmediateUploading" @click="pickImmediateNative">
|
||||
{{ TEXT.common.actions.upload }}
|
||||
</el-button>
|
||||
<el-progress v-if="tableProgress > 0 && tableProgress < 100" :percentage="tableProgress" :stroke-width="6" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,6 +45,7 @@
|
||||
{{ TEXT.common.labels.preview }}
|
||||
</el-button>
|
||||
<el-button link type="primary" size="small" @click="download(scope.row)">{{ TEXT.common.actions.download }}</el-button>
|
||||
<el-button link type="primary" size="small" @click="openExternally(scope.row)">打开</el-button>
|
||||
<el-button
|
||||
v-if="canDelete(scope.row)"
|
||||
link
|
||||
@@ -58,6 +63,7 @@
|
||||
<div v-else class="upload-grid" :class="{ 'upload-grid--three': uploadCardColumns === 3 }">
|
||||
<div v-for="group in uploadGroups" :key="group.key" class="attachment-upload-item">
|
||||
<el-upload
|
||||
v-if="!nativeFiles"
|
||||
class="attachment-card-upload"
|
||||
:show-file-list="false"
|
||||
multiple
|
||||
@@ -79,6 +85,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</el-upload>
|
||||
<div
|
||||
v-else
|
||||
class="attachment-upload-card"
|
||||
:class="{
|
||||
'is-disabled': !canUploadGroup(group),
|
||||
'attachment-upload-card--centered': centerUploadCardContent,
|
||||
}"
|
||||
@click="pickPendingNative(group)"
|
||||
>
|
||||
<div v-if="showUploadCardLabels" class="upload-card-label">{{ group.label }}</div>
|
||||
<div class="upload-trigger">
|
||||
<el-icon class="upload-icon"><UploadFilled /></el-icon>
|
||||
<span class="upload-text">{{ group.uploadText || "点击上传文件" }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pendingMap[group.key]?.length" class="pending-list">
|
||||
<div
|
||||
v-for="item in pendingMap[group.key]"
|
||||
@@ -122,7 +143,7 @@
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import { ElMessage, ElMessageBox } from "element-plus";
|
||||
import { UploadFilled } from "@element-plus/icons-vue";
|
||||
import { fetchAttachments, deleteAttachment, uploadAttachment } from "../../api/attachments";
|
||||
import { fetchAttachments, deleteAttachment, downloadAttachment, uploadAttachment } from "../../api/attachments";
|
||||
import { formatFileSize } from "./attachmentUtils";
|
||||
import { useAuthStore } from "../../store/auth";
|
||||
import { useStudyStore } from "../../store/study";
|
||||
@@ -131,6 +152,7 @@ import { displayDateTime, getUserDisplayName } from "../../utils/display";
|
||||
import { getAttachmentPermissionKey } from "../../utils/attachmentPermissions";
|
||||
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
|
||||
import { TEXT } from "../../locales";
|
||||
import { clientRuntime, openFile, pickFiles, saveFile } from "../../runtime";
|
||||
|
||||
type AttachmentEntityGroup = {
|
||||
entityType: string;
|
||||
@@ -182,6 +204,7 @@ const previewType = ref<"image" | "pdf" | "other">("other");
|
||||
const previewTitle = ref(TEXT.common.labels.preview);
|
||||
const previewError = ref("");
|
||||
const previewLoading = ref(false);
|
||||
const nativeFiles = clientRuntime.capabilities().nativeFiles;
|
||||
const headerTitle = computed(() => props.title || TEXT.common.labels.attachments);
|
||||
const displayMode = computed(() => props.mode || "table");
|
||||
const maxSize = computed(() => props.maxSizeMb ?? 50);
|
||||
@@ -240,10 +263,9 @@ const validateFile = (file: File) => {
|
||||
|
||||
const pendingFileKey = (file: File) => `${file.name}-${file.size}-${file.lastModified}`;
|
||||
|
||||
const queuePendingUpload = (fileType: string, uploadFile: any) => {
|
||||
const queuePendingFile = (fileType: string, file: File) => {
|
||||
const group = uploadGroups.value.find((item) => item.key === fileType);
|
||||
if (!group || !canUploadGroup(group)) return;
|
||||
const file = uploadFile?.raw as File | undefined;
|
||||
if (!file || !validateFile(file)) return;
|
||||
const next = pendingMap[fileType] || [];
|
||||
if (!next.some((item) => pendingFileKey(item.file) === pendingFileKey(file))) {
|
||||
@@ -251,6 +273,17 @@ const queuePendingUpload = (fileType: string, uploadFile: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
const queuePendingUpload = (fileType: string, uploadFile: any) => {
|
||||
const file = uploadFile?.raw as File | undefined;
|
||||
if (file) queuePendingFile(fileType, file);
|
||||
};
|
||||
|
||||
const pickPendingNative = async (group: UploadGroup) => {
|
||||
if (!canUploadGroup(group)) return;
|
||||
const selected = await pickFiles({ multiple: true, title: group.label });
|
||||
selected.forEach((file) => queuePendingFile(group.key, file));
|
||||
};
|
||||
|
||||
const removePendingUpload = (fileType: string, item: PendingUploadItem) => {
|
||||
pendingMap[fileType] = (pendingMap[fileType] || []).filter((current) => pendingFileKey(current.file) !== pendingFileKey(item.file));
|
||||
};
|
||||
@@ -317,6 +350,11 @@ const uploadImmediate = async (options: any) => {
|
||||
}
|
||||
};
|
||||
|
||||
const pickImmediateNative = async () => {
|
||||
const [file] = await pickFiles({ multiple: false, title: headerTitle.value });
|
||||
if (file) await uploadImmediate({ file });
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
if (!props.studyId || !props.entityId) return;
|
||||
loading.value = true;
|
||||
@@ -352,25 +390,35 @@ const uploaderLabel = (row: any) => {
|
||||
return row.uploaded_by_id || row.uploaded_by || TEXT.common.fallback;
|
||||
};
|
||||
|
||||
const getDownloadUrl = (row: any) => {
|
||||
const token = localStorage.getItem("ctms_token");
|
||||
return row?.url || (token ? `/api/v1/attachments/${row.id}/download?token=${token}` : `/api/v1/attachments/${row.id}/download`);
|
||||
const fetchAttachmentBlob = async (row: any): Promise<Blob> => {
|
||||
const response = await downloadAttachment(row.id);
|
||||
return new Blob([response.data], {
|
||||
type: response.headers?.["content-type"] || row?.content_type || "application/octet-stream",
|
||||
});
|
||||
};
|
||||
|
||||
const getPreviewUrl = (row: any) => {
|
||||
const token = localStorage.getItem("ctms_token");
|
||||
return token ? `/api/v1/attachments/${row.id}/preview?token=${token}` : `/api/v1/attachments/${row.id}/preview`;
|
||||
const download = async (row: any) => {
|
||||
try {
|
||||
await saveFile({
|
||||
suggestedName: row?.filename || "download",
|
||||
mimeType: row?.content_type,
|
||||
data: await fetchAttachmentBlob(row),
|
||||
});
|
||||
} catch {
|
||||
ElMessage.error(TEXT.common.messages.downloadFailed);
|
||||
}
|
||||
};
|
||||
|
||||
const download = (row: any) => {
|
||||
const url = getDownloadUrl(row);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = row?.filename || "download";
|
||||
link.rel = "noopener";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
const openExternally = async (row: any) => {
|
||||
try {
|
||||
await openFile({
|
||||
suggestedName: row?.filename || "attachment",
|
||||
mimeType: row?.content_type,
|
||||
data: await fetchAttachmentBlob(row),
|
||||
});
|
||||
} catch {
|
||||
ElMessage.error(TEXT.common.messages.previewNotSupported);
|
||||
}
|
||||
};
|
||||
|
||||
const getFileName = (row: any) => (row?.filename || "").toLowerCase();
|
||||
@@ -386,37 +434,29 @@ const detectPreviewType = (row: any) => {
|
||||
return "other";
|
||||
};
|
||||
|
||||
const previewImage = async (downloadUrl: string) => {
|
||||
const previewImage = async (row: any) => {
|
||||
previewLoading.value = true;
|
||||
try {
|
||||
const token = localStorage.getItem("ctms_token");
|
||||
const headers: Record<string, string> = {};
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
const response = await fetch(downloadUrl, { headers });
|
||||
if (!response.ok) throw new Error("preview failed");
|
||||
const blob = await response.blob();
|
||||
const blob = await fetchAttachmentBlob(row);
|
||||
if (previewObjectUrl.value) URL.revokeObjectURL(previewObjectUrl.value);
|
||||
previewObjectUrl.value = URL.createObjectURL(blob);
|
||||
previewUrl.value = previewObjectUrl.value;
|
||||
} catch {
|
||||
previewUrl.value = downloadUrl;
|
||||
previewError.value = TEXT.common.messages.previewNotSupported;
|
||||
} finally {
|
||||
previewLoading.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const preview = (row: any) => {
|
||||
const preview = async (row: any) => {
|
||||
previewError.value = "";
|
||||
previewType.value = detectPreviewType(row);
|
||||
previewTitle.value = row?.filename || TEXT.common.labels.preview;
|
||||
const downloadUrl = getDownloadUrl(row);
|
||||
if (previewType.value === "image") {
|
||||
if (previewType.value === "image" || previewType.value === "pdf") {
|
||||
previewUrl.value = "";
|
||||
previewImage(downloadUrl);
|
||||
} else if (previewType.value === "pdf") {
|
||||
previewUrl.value = getPreviewUrl(row);
|
||||
await previewImage(row);
|
||||
} else {
|
||||
previewUrl.value = downloadUrl;
|
||||
previewUrl.value = "";
|
||||
}
|
||||
if (previewType.value === "other") {
|
||||
previewError.value = TEXT.common.messages.previewNotSupported;
|
||||
|
||||
Reference in New Issue
Block a user