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
+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 {