feat(desktop): implement phase 2 native capabilities
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-06-30 21:21:55 +08:00
parent 7c721d4e5c
commit 628ff8828b
64 changed files with 3516 additions and 222 deletions
+4 -4
View File
@@ -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(
+21 -1
View File
@@ -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>
+35 -5
View File
@@ -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;