feat(desktop): 稳定桌面端界面与文件操作反馈

- 重构 DesktopPreferences 为分栏式设置面板,整合连接、外观、通知、更新与诊断信息分区,并补充过渡动效与暗色主题样式
- DesktopLayout 侧边栏导航分组支持展开折叠,调整管理/项目区块顺序并统一图标与标题
- 新增 fileTaskFeedback 工具,统一 pickFiles/saveFile/openFile 的成功/取消提示,替换审计导出、权限日志、附件、文档、线程、项目配置等处的直接调用
- desktopUpdateManager 暴露更新状态快照与状态变更监听,区分检查中、安装中、已推迟、失败等状态
- DesktopServerSettings 增加连接诊断信息(检查时间、健康地址、耗时、HTTP 状态)
- unified-page.css 与 ProjectMilestones 引入 CSS 变量以适配暗色主题
- WebLayout 将服务器设置入口改为打开系统偏好面板,管理菜单中邮件服务归入系统设置分组
- ProfileSettings 移除已迁入偏好面板的桌面端专属区块
- 补充 Layout.desktop 布局与偏好面板契约测试
This commit is contained in:
Cheng Zhou
2026-07-01 17:04:23 +08:00
parent d60a2fa5b2
commit 46f488c8ae
19 changed files with 2098 additions and 612 deletions
File diff suppressed because it is too large Load Diff
+116 -14
View File
@@ -22,6 +22,19 @@
:closable="false"
/>
<div v-if="connectionDiagnostic" class="connection-diagnostic">
<div class="diagnostic-grid">
<span>检查时间</span>
<strong>{{ connectionDiagnostic.checkedAt }}</strong>
<span>健康检查</span>
<code>{{ connectionDiagnostic.healthUrl }}</code>
<span>耗时</span>
<strong>{{ connectionDiagnostic.durationMs }}ms</strong>
<span>HTTP</span>
<strong>{{ connectionDiagnostic.httpStatus || "-" }}</strong>
</div>
</div>
<el-form label-position="top" @submit.prevent>
<el-form-item label="服务器地址" :error="urlError">
<el-input
@@ -33,10 +46,6 @@
/>
</el-form-item>
<div class="hint">
允许 HTTPS 服务地址本地开发可使用 http://localhost 或 http://127.0.0.1。
</div>
<div class="actions">
<el-button v-if="canCancel" size="large" @click="goBack">取消</el-button>
<el-button type="primary" size="large" :loading="saving" :disabled="!serverUrl.trim()" @click="save">
@@ -51,7 +60,7 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { ElMessageBox } from "element-plus";
import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime";
@@ -65,13 +74,46 @@ const serverUrl = ref(currentServerUrl || "");
const urlError = ref("");
const saving = ref(false);
const connectionStatus = ref<{ type: "success" | "warning" | "error"; title: string; message: string } | null>(null);
const connectionDiagnostic = ref<{
healthUrl: string;
checkedAt: string;
durationMs: number;
httpStatus?: number;
} | null>(null);
const canCancel = computed(() => Boolean(currentServerUrl));
const HEALTH_TIMEOUT_MS = 10_000;
const formatDiagnosticTime = (date = new Date()) =>
date.toLocaleString("zh-CN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
const toConnectionError = (
message: string,
details: {
serverUrl: string;
healthUrl: string;
durationMs: number;
httpStatus?: number;
},
) => Object.assign(new Error(message), details);
const checkHealth = async (baseUrl: string) => {
const healthUrl = new URL("health", baseUrl).toString();
const controller = new AbortController();
const startedAt = performance.now();
const timeout = window.setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
const details = () => ({
serverUrl: baseUrl,
healthUrl,
durationMs: Math.max(0, Math.round(performance.now() - startedAt)),
});
try {
const response = await fetch(healthUrl, {
method: "GET",
@@ -79,14 +121,21 @@ const checkHealth = async (baseUrl: string) => {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`服务器健康检查返回 HTTP ${response.status}`);
throw toConnectionError(`服务器健康检查返回 HTTP ${response.status}`, {
...details(),
httpStatus: response.status,
});
}
return {
...details(),
httpStatus: response.status,
};
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error("连接超时,请确认服务端地址和网络状态");
throw toConnectionError("连接超时,请确认服务端地址和网络状态", details());
}
if (error instanceof TypeError) {
throw new Error("网络请求失败,请确认地址、证书或 CORS 配置");
throw toConnectionError("网络请求失败,请确认地址、证书或 CORS 配置", details());
}
throw error;
} finally {
@@ -99,6 +148,21 @@ const clearSessionForServerChange = async () => {
studyStore.clearCurrentStudy();
};
const confirmServerChange = async (previous: string | null, next: string) => {
if (!previous || previous === next) return true;
const confirmed = await ElMessageBox.confirm(
"切换服务器会退出当前会话并清除当前项目上下文,确认后需要重新登录。",
"确认切换服务器",
{
type: "warning",
confirmButtonText: "切换并退出登录",
cancelButtonText: "继续编辑",
distinguishCancelAndClose: true,
},
).catch(() => null);
return Boolean(confirmed);
};
const save = async () => {
urlError.value = "";
connectionStatus.value = null;
@@ -110,8 +174,9 @@ const save = async () => {
saving.value = true;
try {
await checkHealth(normalized.url);
const health = await checkHealth(normalized.url);
const previous = getDesktopServerUrl();
if (!(await confirmServerChange(previous, normalized.url))) return;
const result = setDesktopServerUrl(normalized.url);
if (!result.ok) {
urlError.value = result.reason;
@@ -125,15 +190,32 @@ const save = async () => {
title: "连接已确认",
message: result.url,
};
ElMessage.success("服务器连接已确认");
connectionDiagnostic.value = {
healthUrl: health.healthUrl,
checkedAt: formatDiagnosticTime(),
durationMs: health.durationMs,
httpStatus: health.httpStatus,
};
router.replace("/login");
} catch (error) {
const message = error instanceof Error ? error.message : "无法连接服务器的 /health";
const details = error as Error & {
serverUrl?: string;
healthUrl?: string;
durationMs?: number;
httpStatus?: number;
};
connectionStatus.value = {
type: "error",
title: "连接检查失败",
message,
};
connectionDiagnostic.value = {
healthUrl: details.healthUrl || new URL("health", normalized.url).toString(),
checkedAt: formatDiagnosticTime(),
durationMs: details.durationMs ?? 0,
httpStatus: details.httpStatus,
};
urlError.value = message;
} finally {
saving.value = false;
@@ -215,11 +297,31 @@ h1 {
margin-bottom: 18px;
}
.hint {
margin-top: -8px;
.connection-diagnostic {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 18px;
padding: 12px;
border: 1px solid #dbe6f2;
border-radius: 8px;
background: #fbfdff;
}
.diagnostic-grid {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 6px 10px;
min-width: 0;
color: #64748b;
font-size: 13px;
line-height: 1.6;
font-size: 12px;
}
.diagnostic-grid strong {
min-width: 0;
color: #1e293b;
font-weight: 700;
}
.actions {
+2 -202
View File
@@ -64,45 +64,6 @@
</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="系统通知">
<div class="desktop-setting-stack">
<div class="desktop-setting-row">
<el-switch
v-model="desktopNotificationsEnabled"
:loading="desktopNotificationLoading"
@change="onDesktopNotificationChange"
/>
<el-tag size="small" :type="notificationPermissionTagType">{{ notificationPermissionText }}</el-tag>
</div>
<span class="desktop-setting-hint">仅推送不含项目详情的文件更新提示</span>
</div>
</el-form-item>
<el-form-item v-if="isDesktop && desktopUpdaterAvailable" label="桌面更新">
<div class="desktop-setting-row">
<el-button size="small" :loading="desktopUpdateChecking" @click="checkDesktopUpdateNow">
检查更新
</el-button>
<span class="desktop-setting-hint">正式版本会按发布源检查签名更新</span>
</div>
</el-form-item>
<el-form-item label="客户端信息">
<div class="client-metadata-panel">
<dl class="client-metadata-list">
<template v-for="row in clientMetadataRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</template>
</dl>
<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>
@@ -118,25 +79,9 @@ import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { Close, Upload } from "@element-plus/icons-vue";
import { updateProfile, fetchMe, uploadAvatar } from "../api/auth";
import {
getDesktopNotificationSubscription,
setDesktopNotificationSubscription,
} from "../api/desktopNotifications";
import { useAuthStore } from "../store/auth";
import {
clientRuntime,
getAppMetadata,
getDesktopServerUrl,
getNotificationPermission,
isDesktopUpdaterAvailable,
isTauriRuntime,
pickFiles,
requestNotificationPermission,
type NotificationPermissionState,
} from "../runtime";
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
import { TEXT, requiredMessage } from "../locales";
import { pickFilesWithFeedback } from "../utils/fileTaskFeedback";
const emit = defineEmits<{
"close-request": [];
@@ -144,31 +89,6 @@ const emit = defineEmits<{
saved: [];
}>();
const auth = useAuthStore();
const isDesktop = isTauriRuntime();
const clientMetadata = getAppMetadata();
const desktopCapabilities = clientRuntime.capabilities();
const desktopUpdaterAvailable = isDesktopUpdaterAvailable();
const clientMetadataRows = [
{ label: "客户端", value: `${clientMetadata.clientType} ${clientMetadata.version}` },
{ label: "平台", value: clientMetadata.platform },
{ label: "构建通道", value: clientMetadata.channel },
{ label: "提交", value: clientMetadata.commit },
{ label: "服务器", value: getDesktopServerUrl() || "未配置" },
{
label: "能力",
value: [
desktopCapabilities.secureSessionStorage ? "安全会话" : "浏览器会话",
desktopCapabilities.nativeFiles ? "原生文件" : "浏览器文件",
desktopCapabilities.systemNotifications ? "系统通知" : "无系统通知",
desktopCapabilities.automaticUpdates ? "自动更新" : "无自动更新",
].join(" / "),
},
];
const clientMetadataText = clientMetadataRows.map((row) => `${row.label}: ${row.value}`).join("\n");
const desktopNotificationsEnabled = ref(false);
const desktopNotificationLoading = ref(false);
const desktopUpdateChecking = ref(false);
const notificationPermission = ref<NotificationPermissionState>("unsupported");
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
@@ -247,72 +167,6 @@ const loadProfile = async () => {
avatarPreview.value = data.avatar_url || undefined;
};
const loadDesktopNotificationSubscription = async () => {
if (!isDesktop) return;
notificationPermission.value = await getNotificationPermission().catch(() => "unsupported");
const { data } = await getDesktopNotificationSubscription();
desktopNotificationsEnabled.value = data.enabled;
};
const notificationPermissionText = computed(() => {
if (!isDesktop) return "不可用";
if (notificationPermission.value === "granted") return "系统已允许";
if (notificationPermission.value === "denied") return "系统已拒绝";
if (notificationPermission.value === "prompt") return "等待授权";
return "不可用";
});
const notificationPermissionTagType = computed<"success" | "warning" | "danger" | "info">(() => {
if (notificationPermission.value === "granted") return "success";
if (notificationPermission.value === "denied") return "danger";
if (notificationPermission.value === "prompt") return "warning";
return "info";
});
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();
notificationPermission.value = permission;
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 () => {
if (!navigator.clipboard?.writeText) {
ElMessage.warning("当前环境无法访问剪贴板");
return;
}
await navigator.clipboard.writeText(clientMetadataText);
ElMessage.success("客户端信息已复制");
};
const checkDesktopUpdateNow = async () => {
if (desktopUpdateChecking.value) return;
desktopUpdateChecking.value = true;
try {
await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true });
} finally {
desktopUpdateChecking.value = false;
}
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
@@ -345,7 +199,7 @@ const onSubmit = async () => {
};
const selectAndUploadAvatar = async () => {
const [file] = await pickFiles({
const [file] = await pickFilesWithFeedback({
multiple: false,
accept: ["png", "jpg", "jpeg", "gif", "webp"],
title: TEXT.modules.profile.uploadAvatar,
@@ -368,7 +222,6 @@ const selectAndUploadAvatar = async () => {
onMounted(() => {
loadProfile();
loadDesktopNotificationSubscription().catch(() => {});
});
watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true });
@@ -483,59 +336,6 @@ 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-stack {
display: flex;
flex-direction: column;
gap: 8px;
}
.desktop-setting-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.desktop-setting-hint {
color: #7f92ad;
font-size: 12px;
}
.client-metadata-panel {
display: flex;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.client-metadata-list {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 6px 10px;
flex: 1;
min-width: 0;
margin: 0;
color: #40566f;
font-size: 12px;
}
.client-metadata-list dt {
color: #7f92ad;
font-weight: 700;
}
.client-metadata-list dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.section-heading {
margin-bottom: 18px;
padding-left: 112px;
+2 -2
View File
@@ -1954,7 +1954,7 @@ import {
type SetupWorkflowTagMeta,
} from "../../utils/setupPublishWorkflow";
import { useSetupConfig } from "../../composables/useSetupConfig";
import { saveFile } from "../../runtime";
import { saveFileWithFeedback } from "../../utils/fileTaskFeedback";
type SetupStepKey =
| "project-info"
@@ -5345,7 +5345,7 @@ ${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Ro
const blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
const safeVersionLabel = displayVersion.replace(/[^\w.-]/g, "_");
const filename = `setup-config-${safeVersionLabel}-${project.value.code || project.value.id}.xls`;
await saveFile({
await saveFileWithFeedback({
suggestedName: filename,
mimeType: "application/vnd.ms-excel;charset=utf-8",
data: blob,
@@ -378,8 +378,8 @@ 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";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
import { openFileWithFeedback, pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
const route = useRoute();
const auth = useAuthStore();
@@ -524,7 +524,7 @@ const uploadDirtyGuard = useDrawerDirtyGuard(() => ({
}));
const triggerFileInput = async () => {
const [file] = await pickFiles({
const [file] = await pickFilesWithFeedback({
multiple: false,
accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"],
title: "选择文档版本",
@@ -813,7 +813,7 @@ 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 });
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob });
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed); }
};
@@ -822,7 +822,7 @@ const openVersion = async (version: DocumentVersion) => {
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({
await openFileWithFeedback({
suggestedName: filename,
mimeType: contentType,
data: new Blob([response.data], { type: contentType }),
+11 -11
View File
@@ -480,7 +480,7 @@ watch(
.card-title {
font-size: 15px;
font-weight: 700;
color: #0f2345;
color: var(--unified-title-color);
}
.card-subtitle {
@@ -520,11 +520,11 @@ watch(
border-radius: 50%;
border: 3px solid #2f84c6;
box-sizing: border-box;
background: #ffffff;
background: var(--ctms-bg-card);
}
.plan-time-label {
color: #4b5563;
color: var(--ctms-text-secondary);
font-size: 11px;
}
@@ -546,10 +546,10 @@ watch(
}
.time-editor-group {
border: 1px solid #e8eef6;
border: 1px solid var(--ctms-border-color);
border-radius: 10px;
padding: 14px 16px 10px;
background: #fbfcfe;
background: var(--ctms-bg-muted);
}
.time-editor-group + .time-editor-group {
@@ -566,7 +566,7 @@ watch(
font-size: 14px;
font-weight: 700;
margin-bottom: 12px;
color: #1a3560;
color: var(--ctms-text-main);
display: flex;
align-items: center;
gap: 8px;
@@ -600,14 +600,14 @@ watch(
.duration-label {
font-size: 13px;
color: #6b7280;
color: var(--ctms-text-secondary);
font-weight: 500;
}
.duration-value {
font-size: 14px;
font-weight: 700;
color: #1a3560;
color: var(--ctms-text-main);
}
.time-editor-footer {
@@ -628,7 +628,7 @@ watch(
.time-editor-form :deep(.el-form-item__label) {
font-size: 13px;
font-weight: 600;
color: #4a6283;
color: var(--ctms-text-regular);
padding-bottom: 4px;
}
@@ -655,7 +655,7 @@ watch(
.status-detail {
padding-left: 14px;
color: #6b7280;
color: var(--ctms-text-secondary);
font-size: 11px;
}
@@ -684,7 +684,7 @@ watch(
display: flex;
align-items: center;
justify-content: center;
color: #8a97ab;
color: var(--ctms-text-secondary);
font-size: 14px;
font-weight: 500;
letter-spacing: 0.02em;
@@ -466,7 +466,7 @@ import {
import { fetchSites } from "../../api/sites";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
import { pickFiles, saveFile } from "../../runtime";
import { pickFilesWithFeedback, saveFileWithFeedback } from "../../utils/fileTaskFeedback";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import type { Site } from "../../types/api";
@@ -978,7 +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 });
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
await saveFileWithFeedback({ suggestedName: filename, mimeType: contentType, data: blob });
ElMessage.success("导出成功");
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
@@ -1010,7 +1010,7 @@ const importIssueFile = async (file: File) => {
};
const selectImportFile = async () => {
const [file] = await pickFiles({
const [file] = await pickFilesWithFeedback({
multiple: false,
accept: ["xlsx", "csv"],
title: "导入监查访视问题",