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
+3 -3
View File
@@ -60,8 +60,8 @@ const checkHealth = async (baseUrl: string) => {
}
};
const clearSessionForServerChange = () => {
auth.logout();
const clearSessionForServerChange = async () => {
await auth.logout();
studyStore.clearCurrentStudy();
};
@@ -83,7 +83,7 @@ const save = async () => {
return;
}
if (previous !== result.url) {
clearSessionForServerChange();
await clearSessionForServerChange();
}
ElMessage.success("服务器连接已确认");
router.replace("/login");
+128 -33
View File
@@ -10,19 +10,9 @@
<div class="avatar-name">{{ form.full_name || TEXT.modules.profile.title }}</div>
<div class="avatar-email">{{ form.email }}</div>
</div>
<el-upload
class="avatar-uploader"
:show-file-list="false"
accept="image/png,image/jpeg,image/gif,image/webp"
action="/api/v1/auth/me/avatar"
name="file"
:headers="uploadHeaders"
:before-upload="beforeAvatarUpload"
:on-success="onAvatarUploaded"
:on-error="onAvatarError"
>
<el-button :icon="Upload" class="upload-button">{{ TEXT.modules.profile.uploadAvatar }}</el-button>
</el-upload>
<el-button :icon="Upload" class="upload-button avatar-uploader" @click="selectAndUploadAvatar">
{{ TEXT.modules.profile.uploadAvatar }}
</el-button>
</div>
</aside>
@@ -74,6 +64,27 @@
</el-form-item>
</section>
<section class="form-section form-section--desktop">
<div class="section-heading">
<span class="section-kicker">Desktop</span>
<h4>客户端与通知</h4>
</div>
<el-form-item v-if="isDesktop" label="系统通知">
<el-switch
v-model="desktopNotificationsEnabled"
:loading="desktopNotificationLoading"
@change="onDesktopNotificationChange"
/>
<span class="desktop-setting-hint">仅推送不含项目详情的文件更新提示</span>
</el-form-item>
<el-form-item label="客户端信息">
<div class="client-metadata">
<code>{{ clientMetadataText }}</code>
<el-button size="small" @click="copyClientMetadata">复制</el-button>
</div>
</el-form-item>
</section>
<div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</div>
@@ -85,12 +96,22 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import type { FormInstance, FormRules, UploadRawFile } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { Close, Upload } from "@element-plus/icons-vue";
import { updateProfile, fetchMe } from "../api/auth";
import { updateProfile, fetchMe, uploadAvatar } from "../api/auth";
import {
getDesktopNotificationSubscription,
setDesktopNotificationSubscription,
} from "../api/desktopNotifications";
import { useAuthStore } from "../store/auth";
import { getToken } from "../utils/auth";
import {
getAppMetadata,
isTauriRuntime,
pickFiles,
requestNotificationPermission,
} from "../runtime";
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
import { TEXT, requiredMessage } from "../locales";
const emit = defineEmits<{
@@ -99,6 +120,16 @@ const emit = defineEmits<{
saved: [];
}>();
const auth = useAuthStore();
const isDesktop = isTauriRuntime();
const clientMetadata = getAppMetadata();
const clientMetadataText = [
`${clientMetadata.clientType} ${clientMetadata.version}`,
clientMetadata.platform,
clientMetadata.channel,
clientMetadata.commit,
].join(" · ");
const desktopNotificationsEnabled = ref(false);
const desktopNotificationLoading = ref(false);
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
@@ -114,9 +145,6 @@ const savedProfile = ref({
clinical_department: "",
});
const avatarPreview = ref<string | undefined>();
const uploadHeaders = computed<Record<string, string>>(() => ({
Authorization: `Bearer ${getToken() || ""}`,
}));
const profileInitial = computed(() => (form.full_name?.charAt(0) || form.email?.charAt(0) || "?").toUpperCase());
const avatarAcceptedTypes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
const hasUnsavedChanges = computed(
@@ -180,6 +208,41 @@ const loadProfile = async () => {
avatarPreview.value = data.avatar_url || undefined;
};
const loadDesktopNotificationSubscription = async () => {
if (!isDesktop) return;
const { data } = await getDesktopNotificationSubscription();
desktopNotificationsEnabled.value = data.enabled;
};
const onDesktopNotificationChange = async (value: string | number | boolean) => {
if (!isDesktop || desktopNotificationLoading.value) return;
desktopNotificationLoading.value = true;
try {
const enable = Boolean(value);
if (enable) {
const permission = await requestNotificationPermission();
if (permission !== "granted") {
desktopNotificationsEnabled.value = false;
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
return;
}
}
const { data } = await setDesktopNotificationSubscription(enable);
desktopNotificationsEnabled.value = data.enabled;
if (data.enabled) triggerDesktopNotificationPoll();
} catch (error: any) {
desktopNotificationsEnabled.value = !Boolean(value);
ElMessage.error(error?.response?.data?.detail || "通知设置保存失败");
} finally {
desktopNotificationLoading.value = false;
}
};
const copyClientMetadata = async () => {
await navigator.clipboard.writeText(clientMetadataText);
ElMessage.success("客户端信息已复制");
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
@@ -211,24 +274,31 @@ const onSubmit = async () => {
});
};
const onAvatarUploaded = async () => {
await auth.fetchMe();
avatarPreview.value = auth.user?.avatar_url || undefined;
ElMessage.success(TEXT.modules.profile.avatarUpdated);
};
const beforeAvatarUpload = (file: UploadRawFile) => {
if (avatarAcceptedTypes.has(file.type)) return true;
ElMessage.error(TEXT.modules.profile.avatarTypeInvalid);
return false;
};
const onAvatarError = (err: any) => {
ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed);
const selectAndUploadAvatar = async () => {
const [file] = await pickFiles({
multiple: false,
accept: ["png", "jpg", "jpeg", "gif", "webp"],
title: TEXT.modules.profile.uploadAvatar,
});
if (!file) return;
const extensionAllowed = /\.(png|jpe?g|gif|webp)$/i.test(file.name);
if (!avatarAcceptedTypes.has(file.type) && !extensionAllowed) {
ElMessage.error(TEXT.modules.profile.avatarTypeInvalid);
return;
}
try {
await uploadAvatar(file);
await auth.fetchMe();
avatarPreview.value = auth.user?.avatar_url || undefined;
ElMessage.success(TEXT.modules.profile.avatarUpdated);
} catch (err: any) {
ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed);
}
};
onMounted(() => {
loadProfile();
loadDesktopNotificationSubscription().catch(() => {});
});
watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true });
@@ -343,6 +413,31 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
border-top: 1px solid #e5ebf2;
}
.form-section--desktop {
margin-top: 26px;
padding-top: 28px;
border-top: 1px solid #e5ebf2;
}
.desktop-setting-hint {
margin-left: 12px;
color: #7f92ad;
font-size: 12px;
}
.client-metadata {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.client-metadata code {
overflow-wrap: anywhere;
color: #40566f;
font-size: 12px;
}
.section-heading {
margin-bottom: 18px;
padding-left: 112px;
+7 -4
View File
@@ -76,7 +76,7 @@
<el-skeleton v-if="loading.notifications" :rows="3" animated />
<StateEmpty v-else-if="!notifications.length" :description="TEXT.modules.projectOverview.notificationsEmpty" />
<div v-else class="notification-list">
<div class="notification-item" v-for="item in notifications" :key="item.id">
<div class="notification-item" :class="{ 'is-unread': !item.read_at }" v-for="item in notifications" :key="item.id">
<div class="notification-main">
<div class="notification-title">
<span class="notification-doc">{{ item.document_title }}</span>
@@ -87,7 +87,7 @@
<span v-if="item.change_summary" class="notification-summary">{{ item.change_summary }}</span>
</div>
</div>
<el-button link type="primary" @click="openDocument(item.document_id)">
<el-button link type="primary" @click="openDocument(item)">
{{ TEXT.common.actions.view }}
</el-button>
</div>
@@ -111,6 +111,7 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchFinanceSummary, fetchOverdueAesCount, fetchProgress } from "../api/dashboard";
import { listNotifications } from "../api/notifications";
import { markDesktopNotificationRead } from "../api/desktopNotifications";
import KpiCard from "../components/KpiCard.vue";
import QuickActions from "../components/QuickActions.vue";
import { List, Warning, Money } from "@element-plus/icons-vue";
@@ -202,8 +203,10 @@ const formatDate = (value?: string | null) => {
return value.replace("T", " ").replace("Z", "").split(".")[0];
};
const openDocument = (documentId: string) => {
router.push(`/documents/${documentId}`);
const openDocument = async (item: NotificationItem) => {
await markDesktopNotificationRead(item.id).catch(() => {});
item.read_at = new Date().toISOString();
router.push(`/documents/${item.document_id}`);
};
onMounted(() => {
+7 -9
View File
@@ -1954,6 +1954,7 @@ import {
type SetupWorkflowTagMeta,
} from "../../utils/setupPublishWorkflow";
import { useSetupConfig } from "../../composables/useSetupConfig";
import { saveFile } from "../../runtime";
type SetupStepKey =
| "project-info"
@@ -5174,7 +5175,7 @@ const buildExcelWorksheetXml = (
return `<Worksheet ss:Name="${excelEscapeXml(sheetName)}"><Table>${metaXml}<Row/>${headerXml}${dataXml}</Table></Worksheet>`;
};
const downloadVersionRaw = (versionItem: StudySetupConfigVersionItem) => {
const downloadVersionRaw = async (versionItem: StudySetupConfigVersionItem) => {
if (!project.value) return;
const displayVersion = getDisplayVersionLabel(versionItem.version);
const branchName = versionItem.branch_name || "main";
@@ -5342,16 +5343,13 @@ ${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Ro
</Workbook>`;
const blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const safeVersionLabel = displayVersion.replace(/[^\w.-]/g, "_");
const filename = `setup-config-${safeVersionLabel}-${project.value.code || project.value.id}.xls`;
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
await saveFile({
suggestedName: filename,
mimeType: "application/vnd.ms-excel;charset=utf-8",
data: blob,
});
};
const removeVersion = async (targetVersion: number) => {
+29 -13
View File
@@ -123,6 +123,9 @@
<el-button link type="primary" size="small" @click="downloadVersion(row)" v-if="canReadDocument">
{{ TEXT.modules.fileVersionManagement.actions.download }}
</el-button>
<el-button link type="primary" size="small" @click="openVersion(row)" v-if="canReadDocument">
打开
</el-button>
<el-button v-if="canDeleteDocument" link type="danger" size="small" @click="confirmDeleteVersion(row)">
{{ TEXT.common.actions.delete }}
</el-button>
@@ -262,7 +265,6 @@
上传文件
</div>
<div class="upload-zone" :class="{ 'has-file': uploadFile }" @click="triggerFileInput">
<input type="file" ref="fileInputRef" @change="onFileChange" class="file-input-hidden" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.png,.jpg" />
<template v-if="!uploadFile">
<div class="upload-zone-icon">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>
@@ -370,6 +372,7 @@ import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
import { usePermission } from "../../utils/permission";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { openFile, pickFiles, saveFile } from "../../runtime";
const route = useRoute();
const auth = useAuthStore();
@@ -494,7 +497,6 @@ const editorDirtyGuard = useDrawerDirtyGuard(() => editorForm);
const uploadVisible = ref(false);
const uploading = ref(false);
const uploadFormRef = ref<FormInstance>();
const fileInputRef = ref<HTMLInputElement | null>(null);
const uploadForm = reactive({
version_no: "",
version_date: "",
@@ -513,8 +515,15 @@ const uploadDirtyGuard = useDrawerDirtyGuard(() => ({
: null,
}));
const triggerFileInput = () => { fileInputRef.value?.click(); };
const removeFile = () => { uploadFile.value = null; if (fileInputRef.value) fileInputRef.value.value = ""; };
const triggerFileInput = async () => {
const [file] = await pickFiles({
multiple: false,
accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"],
title: "选择文档版本",
});
if (file) uploadFile.value = file;
};
const removeFile = () => { uploadFile.value = null; };
const distributeVisible = ref(false);
const distributing = ref(false);
@@ -706,11 +715,6 @@ const openUpload = () => {
uploadVisible.value = true;
};
const onFileChange = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) uploadFile.value = target.files[0];
};
const submitUpload = async () => {
if (!canUpdateDocument.value) { ElMessage.warning("权限不足"); return; }
if (!uploadFormRef.value) return;
@@ -797,13 +801,25 @@ const downloadVersion = async (version: DocumentVersion) => {
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
const blob = new Blob([response.data], { type: contentType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a"); link.href = url; link.download = filename;
document.body.appendChild(link); link.click(); link.remove();
window.URL.revokeObjectURL(url);
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed); }
};
const openVersion = async (version: DocumentVersion) => {
try {
const response = await downloadDocumentVersion(version.id);
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
await openFile({
suggestedName: filename,
mimeType: contentType,
data: new Blob([response.data], { type: contentType }),
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed);
}
};
const confirmDeleteVersion = async (version: DocumentVersion) => {
if (!version?.id) return;
if (!canDeleteDocument.value) { ElMessage.warning("权限不足"); return; }
@@ -132,20 +132,17 @@
<el-icon><Document /></el-icon>
<span>问题导入模板</span>
</el-button>
<el-upload
<el-button
v-if="canCreateIssue"
ref="importUploadRef"
:auto-upload="false"
:show-file-list="false"
accept=".xlsx,.csv"
:on-change="onImportChange"
:disabled="importing"
class="toolbar-button toolbar-button-warning"
type="warning"
plain
:loading="importing"
@click="selectImportFile"
>
<el-button class="toolbar-button toolbar-button-warning" type="warning" plain :loading="importing">
<el-icon><Upload /></el-icon>
<span>导入问题</span>
</el-button>
</el-upload>
<el-icon><Upload /></el-icon>
<span>导入问题</span>
</el-button>
</div>
<div class="toolbar-icons">
<el-button circle title="刷新" @click="loadIssues">
@@ -455,7 +452,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile, type UploadInstance } from "element-plus";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { CircleCheck, Delete, Document, Download, Location, Plus, Refresh, Search, Upload } from "@element-plus/icons-vue";
import {
createMonitoringVisitIssue,
@@ -469,6 +466,7 @@ import {
import { fetchSites } from "../../api/sites";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
import { pickFiles, saveFile } from "../../runtime";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import type { Site } from "../../types/api";
@@ -523,7 +521,6 @@ const viewDialogVisible = ref(false);
const formMode = ref<"create" | "edit">("create");
const editingIssueId = ref("");
const createFormRef = ref<FormInstance>();
const importUploadRef = ref<UploadInstance>();
const allItems = ref<MonitoringIssueRow[]>([]);
const viewIssue = ref<MonitoringIssueRow | null>(null);
const sitesLoading = ref(false);
@@ -981,14 +978,7 @@ const handleExportExcel = async () => {
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx";
const blob = new Blob([response.data], { type: contentType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
ElMessage.success("导出成功");
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
@@ -997,11 +987,9 @@ const handleExportExcel = async () => {
}
};
const onImportChange = async (uploadFile: UploadFile) => {
const importIssueFile = async (file: File) => {
const studyId = study.currentStudy?.id;
if (!studyId || !canCreateIssue.value) return;
const file = uploadFile.raw;
if (!file) return;
importing.value = true;
try {
@@ -1018,10 +1006,18 @@ const onImportChange = async (uploadFile: UploadFile) => {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.uploadFailed);
} finally {
importing.value = false;
importUploadRef.value?.clearFiles();
}
};
const selectImportFile = async () => {
const [file] = await pickFiles({
multiple: false,
accept: ["xlsx", "csv"],
title: "导入监查访视问题",
});
if (file) await importIssueFile(file);
};
watch(
() => study.currentStudy?.id,
(studyId) => {