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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user