完善桌面体验与系统通知收口

This commit is contained in:
Cheng Zhou
2026-07-02 15:13:08 +08:00
parent a17fa618cd
commit 3a866accd9
12 changed files with 428 additions and 23 deletions
+43 -3
View File
@@ -134,7 +134,13 @@
:loading="desktopNotificationLoading"
@change="onDesktopNotificationChange"
/>
<el-tag size="small" :type="notificationPermissionTagType">{{ notificationPermissionText }}</el-tag>
<el-tag v-if="showNotificationPermissionTag" size="small" :type="notificationPermissionTagType">
{{ notificationPermissionText }}
</el-tag>
<el-button size="small" :loading="desktopNotificationTestLoading" @click="sendDesktopNotificationTest">
<el-icon><Bell /></el-icon>
<span>测试通知</span>
</el-button>
</div>
</div>
</section>
@@ -199,6 +205,7 @@ import {
requestNotificationPermission,
setDesktopServerUrl,
setDesktopThemePreference,
showSystemNotificationProbe,
type DesktopThemePreference,
type NotificationPermissionState,
} from "../runtime";
@@ -257,6 +264,7 @@ const desktopCapabilities = clientRuntime.capabilities();
const desktopUpdaterAvailable = isDesktopUpdaterAvailable();
const desktopNotificationsEnabled = ref(false);
const desktopNotificationLoading = ref(false);
const desktopNotificationTestLoading = ref(false);
const desktopUpdateChecking = ref(false);
const desktopTheme = ref<DesktopThemePreference>(readDesktopThemePreference());
const notificationPermission = ref<NotificationPermissionState>("unsupported");
@@ -286,14 +294,14 @@ const clientMetadataRows = computed(() => [
]);
const notificationPermissionText = computed(() => {
if (notificationPermission.value === "granted") return "已授权";
if (notificationPermission.value === "denied") return "已拒绝";
if (notificationPermission.value === "prompt") return "待授权";
return "不可用";
});
const showNotificationPermissionTag = computed(() => notificationPermission.value !== "granted");
const notificationPermissionTagType = computed(() => {
if (notificationPermission.value === "granted") return "success";
if (notificationPermission.value === "denied") return "danger";
if (notificationPermission.value === "prompt") return "warning";
return "info";
@@ -594,6 +602,31 @@ const onDesktopNotificationChange = async (value: string | number | boolean) =>
}
};
const sendDesktopNotificationTest = async () => {
if (desktopNotificationTestLoading.value) return;
desktopNotificationTestLoading.value = true;
try {
notificationPermission.value = await getNotificationPermission();
if (notificationPermission.value !== "granted") {
notificationPermission.value = await requestNotificationPermission();
}
if (notificationPermission.value !== "granted") {
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
return;
}
const sent = await showSystemNotificationProbe();
if (!sent) {
ElMessage.warning("系统通知未发送,请确认系统通知权限已开启");
return;
}
ElMessage.success("已调用系统通知");
} catch {
ElMessage.error("系统通知发送失败");
} finally {
desktopNotificationTestLoading.value = false;
}
};
const checkDesktopUpdateNow = async () => {
if (desktopUpdateChecking.value) return;
desktopUpdateChecking.value = true;
@@ -1113,6 +1146,10 @@ h3 {
padding: 15px 16px;
}
.preference-row > div:first-child {
min-width: 0;
}
.row-title {
color: var(--pref-text-primary);
font-size: 13.5px;
@@ -1123,11 +1160,14 @@ h3 {
margin-top: 3px;
color: var(--pref-text-secondary);
font-size: 12px;
overflow-wrap: anywhere;
}
.row-control {
display: inline-flex;
align-items: center;
justify-content: flex-end;
flex-wrap: wrap;
gap: 10px;
flex-shrink: 0;
}
@@ -229,6 +229,7 @@ const goBack = () => {
<style scoped>
.desktop-settings-page {
box-sizing: border-box;
min-height: 100vh;
display: flex;
align-items: center;
@@ -239,7 +240,9 @@ const goBack = () => {
.settings-panel {
width: min(100%, 520px);
max-height: calc(100vh - 64px);
padding: 32px;
overflow: auto;
border: 1px solid #d9e2ef;
border-radius: 8px;
background: #ffffff;
@@ -324,6 +327,11 @@ h1 {
font-weight: 700;
}
.diagnostic-grid code {
min-width: 0;
overflow-wrap: anywhere;
}
.actions {
display: flex;
justify-content: flex-end;
+27 -1
View File
@@ -458,6 +458,7 @@ const onSubmit = async () => {
.login-split-container {
display: flex;
min-width: 0;
width: 100vw;
min-height: 100vh;
}
@@ -467,6 +468,7 @@ const onSubmit = async () => {
═══════════════════════ */
.login-left-brand {
flex: 1;
min-width: 0;
background: linear-gradient(135deg, #e0f2fe 0%, #e0e7ff 50%, #f3e8ff 100%);
padding: 60px 80px;
position: relative;
@@ -715,6 +717,7 @@ const onSubmit = async () => {
═══════════════════════ */
.login-right-form {
flex: 1;
min-width: 0;
background: #ffffff;
padding: 60px 80px;
position: relative;
@@ -848,6 +851,7 @@ const onSubmit = async () => {
}
.desktop-server-action {
white-space: nowrap;
color: #2563eb;
font-weight: 700;
text-decoration: none;
@@ -884,7 +888,7 @@ const onSubmit = async () => {
.ln-icon { width: 16px; height: 16px; flex-shrink: 0; margin-top: 1px; }
.ln-icon svg { width: 100%; height: 100%; }
.ln-body { display: flex; flex-direction: column; gap: 2px; }
.ln-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.ln-body strong { font-size: 12px; font-weight: 700; }
.ln-body span { font-size: 11px; opacity: 0.9; }
@@ -1326,6 +1330,28 @@ const onSubmit = async () => {
}
}
@media (max-width: 1280px) {
.login-left-brand {
padding: 48px 52px;
}
.login-right-form {
padding: 48px 56px;
}
.brand-main-title {
font-size: 34px;
}
.feature-card {
padding: 14px 16px;
}
.login-card-container {
width: min(440px, 100%);
}
}
@media (max-width: 480px) {
.login-brand-header {
margin-bottom: 28px;
+104 -2
View File
@@ -64,6 +64,22 @@
</el-form-item>
</section>
<section class="form-section form-section--diagnostics">
<div class="section-heading">
<span class="section-kicker">Diagnostics</span>
<h4>客户端诊断</h4>
</div>
<div class="diagnostics-panel">
<dl>
<template v-for="row in clientDiagnosticRows" :key="row.label">
<dt>{{ row.label }}</dt>
<dd>{{ row.value }}</dd>
</template>
</dl>
<el-button size="small" :icon="DocumentCopy" @click="copyClientDiagnostics">复制诊断信息</el-button>
</div>
</section>
<div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</div>
@@ -77,11 +93,12 @@
import { computed, onMounted, reactive, ref, watch } from "vue";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { Close, Upload } from "@element-plus/icons-vue";
import { Close, DocumentCopy, Upload } from "@element-plus/icons-vue";
import { updateProfile, fetchMe, uploadAvatar } from "../api/auth";
import { useAuthStore } from "../store/auth";
import { TEXT, requiredMessage } from "../locales";
import { pickFilesWithFeedback } from "../utils/fileTaskFeedback";
import { clientRuntime, getAppMetadata, getDesktopServerUrl } from "../runtime";
const emit = defineEmits<{
"close-request": [];
@@ -106,12 +123,36 @@ const savedProfile = ref({
const avatarPreview = ref<string | undefined>();
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 clientMetadata = getAppMetadata();
const hasUnsavedChanges = computed(
() =>
form.full_name !== savedProfile.value.full_name ||
form.clinical_department !== savedProfile.value.clinical_department ||
Boolean(form.current_password || form.password || form.confirmPassword)
);
const clientDiagnosticRows = computed(() => {
const capabilities = clientRuntime.capabilities();
return [
{ label: "客户端", value: `${clientMetadata.clientType} ${clientMetadata.version}` },
{ label: "平台", value: clientMetadata.platform },
{ label: "构建通道", value: clientMetadata.channel },
{ label: "提交", value: clientMetadata.commit },
{
label: "服务器",
value: clientMetadata.clientType === "desktop" ? getDesktopServerUrl() || "未配置" : clientRuntime.apiBaseUrl(),
},
{
label: "能力",
value: [
capabilities.serverConfiguration ? "服务器配置" : "固定入口",
capabilities.secureSessionStorage ? "安全会话" : "浏览器会话",
capabilities.nativeFiles ? "原生文件" : "浏览器文件",
capabilities.systemNotifications ? "系统通知" : "无系统通知",
capabilities.automaticUpdates ? "自动更新" : "无自动更新",
].join(" / "),
},
];
});
const rules: FormRules<typeof form> = {
full_name: [{ required: true, message: requiredMessage(TEXT.common.fields.name), trigger: "blur" }],
@@ -220,6 +261,16 @@ const selectAndUploadAvatar = async () => {
}
};
const copyClientDiagnostics = async () => {
const text = clientDiagnosticRows.value.map((row) => `${row.label}: ${row.value}`).join("\n");
if (!navigator.clipboard?.writeText) {
ElMessage.warning("当前环境无法访问剪贴板");
return;
}
await navigator.clipboard.writeText(text);
ElMessage.success("诊断信息已复制");
};
onMounted(() => {
loadProfile();
});
@@ -235,11 +286,14 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
.profile-layout {
display: grid;
grid-template-columns: 260px minmax(0, 1fr);
min-height: 620px;
height: min(720px, calc(100vh - 64px));
min-height: 0;
}
.profile-aside {
min-height: 0;
padding: 48px 32px;
overflow: hidden;
border-right: 1px solid #e5ebf2;
background: linear-gradient(180deg, #f8fbfd 0%, #f1f5f9 100%);
}
@@ -295,7 +349,9 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
}
.profile-main {
min-height: 0;
padding: 42px 48px 36px;
overflow: auto;
background: #fff;
}
@@ -336,6 +392,12 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
border-top: 1px solid #e5ebf2;
}
.form-section--diagnostics {
margin-top: 26px;
padding-top: 28px;
border-top: 1px solid #e5ebf2;
}
.section-heading {
margin-bottom: 18px;
padding-left: 112px;
@@ -378,6 +440,42 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
box-shadow: 0 0 0 1px #3f8f6b inset, 0 0 0 3px rgba(63, 143, 107, 0.12);
}
.diagnostics-panel {
display: flex;
flex-direction: column;
gap: 14px;
margin-left: 112px;
padding: 16px;
border: 1px solid #dfe6ee;
border-radius: 10px;
background: #f8fafc;
}
.diagnostics-panel dl {
display: grid;
grid-template-columns: 76px minmax(0, 1fr);
gap: 8px 12px;
margin: 0;
}
.diagnostics-panel dt {
color: #7f92ad;
font-size: 12px;
font-weight: 700;
}
.diagnostics-panel dd {
min-width: 0;
margin: 0;
color: #1f2a3d;
font-size: 12px;
overflow-wrap: anywhere;
}
.diagnostics-panel :deep(.el-button) {
align-self: flex-start;
}
.actions {
margin-top: 28px;
padding-left: 112px;
@@ -420,6 +518,10 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
padding-left: 0;
}
.diagnostics-panel {
margin-left: 0;
}
.actions {
text-align: left;
}