完善桌面端交互体验与发布检查

This commit is contained in:
Cheng Zhou
2026-06-30 22:26:34 +08:00
parent 628ff8828b
commit c923f887a0
30 changed files with 2507 additions and 141 deletions
+302
View File
@@ -0,0 +1,302 @@
<template>
<div class="desktop-preferences">
<header class="preferences-header">
<div>
<p class="preferences-kicker">CTMS Desktop</p>
<h3>桌面偏好</h3>
</div>
<el-button text :icon="Close" aria-label="关闭桌面偏好" @click="emit('close-request')" />
</header>
<section class="preference-section">
<div class="section-title">连接</div>
<div class="server-card">
<span class="server-label">当前服务器</span>
<code>{{ desktopServerUrl || "未配置" }}</code>
<el-button size="small" @click="openServerSettings">服务器设置</el-button>
</div>
</section>
<section class="preference-section">
<div class="section-title">通知与更新</div>
<div class="preference-row">
<div>
<div class="row-title">系统通知</div>
<div class="row-desc">只显示不含项目详情的文件更新提示</div>
</div>
<div class="row-control">
<el-switch
v-model="desktopNotificationsEnabled"
:loading="desktopNotificationLoading"
@change="onDesktopNotificationChange"
/>
<el-tag size="small" :type="notificationPermissionTagType">{{ notificationPermissionText }}</el-tag>
</div>
</div>
<div class="preference-row">
<div>
<div class="row-title">桌面更新</div>
<div class="row-desc">正式版本按当前服务器发布源检查签名更新</div>
</div>
<el-button size="small" :disabled="!desktopUpdaterAvailable" :loading="desktopUpdateChecking" @click="checkDesktopUpdateNow">
检查更新
</el-button>
</div>
</section>
<section class="preference-section">
<div class="section-title">诊断信息</div>
<div class="metadata-panel">
<dl>
<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>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRouter } from "vue-router";
import { ElMessage } from "element-plus";
import { Close } from "@element-plus/icons-vue";
import {
getDesktopNotificationSubscription,
setDesktopNotificationSubscription,
} from "../api/desktopNotifications";
import {
clientRuntime,
getAppMetadata,
getDesktopServerUrl,
getNotificationPermission,
isDesktopUpdaterAvailable,
requestNotificationPermission,
type NotificationPermissionState,
} from "../runtime";
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
const emit = defineEmits<{
"close-request": [];
}>();
const router = useRouter();
const desktopServerUrl = ref(getDesktopServerUrl());
const clientMetadata = getAppMetadata();
const desktopCapabilities = clientRuntime.capabilities();
const desktopUpdaterAvailable = isDesktopUpdaterAvailable();
const desktopNotificationsEnabled = ref(false);
const desktopNotificationLoading = ref(false);
const desktopUpdateChecking = ref(false);
const notificationPermission = ref<NotificationPermissionState>("unsupported");
const clientMetadataRows = computed(() => [
{ label: "客户端", value: `${clientMetadata.clientType} ${clientMetadata.version}` },
{ label: "平台", value: clientMetadata.platform },
{ label: "构建通道", value: clientMetadata.channel },
{ label: "提交", value: clientMetadata.commit },
{ label: "服务器", value: desktopServerUrl.value || "未配置" },
{
label: "能力",
value: [
desktopCapabilities.secureSessionStorage ? "安全会话" : "浏览器会话",
desktopCapabilities.nativeFiles ? "原生文件" : "浏览器文件",
desktopCapabilities.systemNotifications ? "系统通知" : "无系统通知",
desktopCapabilities.automaticUpdates ? "自动更新" : "无自动更新",
].join(" / "),
},
]);
const notificationPermissionText = computed(() => {
if (notificationPermission.value === "granted") return "已授权";
if (notificationPermission.value === "denied") return "已拒绝";
if (notificationPermission.value === "prompt") return "待授权";
return "不可用";
});
const notificationPermissionTagType = computed(() => {
if (notificationPermission.value === "granted") return "success";
if (notificationPermission.value === "denied") return "danger";
if (notificationPermission.value === "prompt") return "warning";
return "info";
});
const loadNotificationState = async () => {
notificationPermission.value = await getNotificationPermission();
try {
const { data } = await getDesktopNotificationSubscription();
desktopNotificationsEnabled.value = data.enabled;
} catch {
desktopNotificationsEnabled.value = false;
}
};
const onDesktopNotificationChange = async (value: string | number | boolean) => {
if (desktopNotificationLoading.value) return;
desktopNotificationLoading.value = true;
try {
if (value) {
notificationPermission.value = await requestNotificationPermission();
if (notificationPermission.value !== "granted") {
desktopNotificationsEnabled.value = false;
return;
}
}
const { data } = await setDesktopNotificationSubscription(Boolean(value));
desktopNotificationsEnabled.value = data.enabled;
triggerDesktopNotificationPoll();
ElMessage.success(data.enabled ? "系统通知已开启" : "系统通知已关闭");
} catch (error: any) {
desktopNotificationsEnabled.value = !Boolean(value);
ElMessage.error(error?.response?.data?.detail || "系统通知设置失败");
} finally {
desktopNotificationLoading.value = false;
}
};
const checkDesktopUpdateNow = async () => {
if (desktopUpdateChecking.value) return;
desktopUpdateChecking.value = true;
try {
await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true });
} finally {
desktopUpdateChecking.value = false;
}
};
const copyClientMetadata = async () => {
const text = clientMetadataRows.value.map((row) => `${row.label}: ${row.value}`).join("\n");
await navigator.clipboard?.writeText(text);
ElMessage.success("诊断信息已复制");
};
const openServerSettings = () => {
emit("close-request");
router.push("/desktop/server-settings");
};
onMounted(() => {
void loadNotificationState();
});
</script>
<style scoped>
.desktop-preferences {
display: flex;
flex-direction: column;
gap: 18px;
}
.preferences-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.preferences-kicker {
margin: 0 0 4px;
color: #2563eb;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
}
h3 {
margin: 0;
color: #0f172a;
font-size: 20px;
}
.preference-section {
display: flex;
flex-direction: column;
gap: 10px;
}
.section-title {
color: #475569;
font-size: 12px;
font-weight: 700;
}
.server-card,
.preference-row,
.metadata-panel {
border: 1px solid #dbe6f2;
border-radius: 8px;
background: #ffffff;
}
.server-card {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 12px;
}
.server-label,
.row-desc {
color: #64748b;
font-size: 12px;
}
code {
overflow-wrap: anywhere;
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
}
.preference-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px;
}
.row-title {
color: #0f172a;
font-size: 14px;
font-weight: 700;
}
.row-control {
display: inline-flex;
align-items: center;
gap: 10px;
}
.metadata-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: start;
padding: 12px;
}
dl {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 8px 12px;
margin: 0;
}
dt {
color: #64748b;
font-size: 12px;
}
dd {
margin: 0;
color: #0f172a;
font-size: 12px;
overflow-wrap: anywhere;
}
</style>
+79 -9
View File
@@ -7,6 +7,21 @@
<p class="description">配置桌面客户端要连接的 CTMS 服务端入口业务数据仍由服务端统一保存和裁决</p>
</div>
<div v-if="currentServerUrl" class="current-server">
<span>当前服务器</span>
<code>{{ currentServerUrl }}</code>
</div>
<el-alert
v-if="connectionStatus"
class="connection-alert"
:type="connectionStatus.type"
:title="connectionStatus.title"
:description="connectionStatus.message"
show-icon
:closable="false"
/>
<el-form label-position="top" @submit.prevent>
<el-form-item label="服务器地址" :error="urlError">
<el-input
@@ -24,7 +39,9 @@
<div class="actions">
<el-button v-if="canCancel" size="large" @click="goBack">取消</el-button>
<el-button type="primary" size="large" :loading="saving" @click="save">保存并检查连接</el-button>
<el-button type="primary" size="large" :loading="saving" :disabled="!serverUrl.trim()" @click="save">
保存并检查连接
</el-button>
</div>
</el-form>
</section>
@@ -47,16 +64,33 @@ const currentServerUrl = getDesktopServerUrl();
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 canCancel = computed(() => Boolean(currentServerUrl));
const HEALTH_TIMEOUT_MS = 10_000;
const checkHealth = async (baseUrl: string) => {
const healthUrl = new URL("health", baseUrl).toString();
const response = await fetch(healthUrl, {
method: "GET",
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
const controller = new AbortController();
const timeout = window.setTimeout(() => controller.abort(), HEALTH_TIMEOUT_MS);
try {
const response = await fetch(healthUrl, {
method: "GET",
headers: { Accept: "application/json" },
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`服务器健康检查返回 HTTP ${response.status}`);
}
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error("连接超时,请确认服务端地址和网络状态");
}
if (error instanceof TypeError) {
throw new Error("网络请求失败,请确认地址、证书或 CORS 配置");
}
throw error;
} finally {
window.clearTimeout(timeout);
}
};
@@ -67,6 +101,7 @@ const clearSessionForServerChange = async () => {
const save = async () => {
urlError.value = "";
connectionStatus.value = null;
const normalized = normalizeDesktopServerUrl(serverUrl.value);
if (!normalized.ok) {
urlError.value = normalized.reason;
@@ -85,10 +120,21 @@ const save = async () => {
if (previous !== result.url) {
await clearSessionForServerChange();
}
connectionStatus.value = {
type: "success",
title: "连接已确认",
message: result.url,
};
ElMessage.success("服务器连接已确认");
router.replace("/login");
} catch {
urlError.value = "无法连接服务器的 /health,请确认地址和网络后重试";
} catch (error) {
const message = error instanceof Error ? error.message : "无法连接服务器的 /health";
connectionStatus.value = {
type: "error",
title: "连接检查失败",
message,
};
urlError.value = message;
} finally {
saving.value = false;
}
@@ -145,6 +191,30 @@ h1 {
line-height: 1.7;
}
.current-server {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: center;
margin-bottom: 20px;
padding: 10px 12px;
border: 1px solid #dbe6f2;
border-radius: 6px;
background: #f8fafc;
color: #64748b;
font-size: 13px;
}
.current-server code {
overflow-wrap: anywhere;
color: #1e293b;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.connection-alert {
margin-bottom: 18px;
}
.hint {
margin-top: -8px;
color: #64748b;
+54 -2
View File
@@ -100,6 +100,12 @@
<span class="tab-item active">账号登录</span>
</div>
<div v-if="showDesktopServerSettings" class="desktop-server-status">
<span class="desktop-server-label">服务器</span>
<code>{{ desktopServerUrl || "未配置" }}</code>
<RouterLink to="/desktop/server-settings" class="desktop-server-action">设置</RouterLink>
</div>
<!-- 退出通知 -->
<div v-if="logoutNotice" class="logout-notice" :class="'logout-notice--' + logoutNotice.type">
<div class="ln-icon">
@@ -249,7 +255,7 @@
</template>
<script setup lang="ts">
import { computed, reactive, ref, onMounted, watch } from "vue";
import { computed, reactive, ref, onMounted, onUnmounted, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, type FormInstance, type FormRules } from "element-plus";
// 从 package.json 读取版本号,构建时由 Vite 注入
@@ -260,7 +266,7 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchEmailDomains } from "../api/auth";
import { TEXT, requiredMessage } from "../locales";
import { isTauriRuntime } from "../runtime";
import { DESKTOP_SERVER_URL_CHANGED_EVENT, getDesktopServerUrl, isTauriRuntime } from "../runtime";
import {
consumeLogoutReason,
LOGOUT_REASON_AUTH_EXPIRED,
@@ -292,6 +298,11 @@ const logoutNotice = ref<{ type: "info" | "warning"; title: string; message: str
const loginError = ref<{ title: string; message?: string } | null>(null);
const protocolSections = authProtocolSections;
const showDesktopServerSettings = isTauriRuntime();
const desktopServerUrl = ref(getDesktopServerUrl());
const refreshDesktopServerUrl = () => {
desktopServerUrl.value = getDesktopServerUrl();
};
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
const availableEmailDomains = computed(() => Array.from(new Set([
@@ -345,6 +356,7 @@ const handleAccountPaste = (event: ClipboardEvent) => {
};
onMounted(async () => {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
await loadEmailDomains();
const reason = consumeLogoutReason();
if (reason === LOGOUT_REASON_TIMEOUT) {
@@ -358,6 +370,10 @@ onMounted(async () => {
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
});
onUnmounted(() => {
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshDesktopServerUrl);
});
watch(() => form.agreeProtocol, (checked) => localStorage.setItem(AGREE_PROTOCOL_KEY, String(checked)));
watch(() => [form.emailLocal, form.emailDomain], syncEmailFromParts);
@@ -786,6 +802,42 @@ const onSubmit = async () => {
border-radius: 2px;
}
.desktop-server-status {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
gap: 10px;
align-items: center;
margin: -18px 0 24px;
padding: 10px 12px;
border: 1px solid #dbe7f5;
border-radius: 8px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
}
.desktop-server-label {
color: #64748b;
font-weight: 700;
}
.desktop-server-status code {
min-width: 0;
overflow-wrap: anywhere;
color: #1e293b;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.desktop-server-action {
color: #2563eb;
font-weight: 700;
text-decoration: none;
}
.desktop-server-action:hover {
color: #1d4ed8;
}
/* ═══════════════════════
通知与错误提示
═══════════════════════ */
+117 -19
View File
@@ -70,16 +70,34 @@
<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>
<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">
<code>{{ clientMetadataText }}</code>
<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>
@@ -106,12 +124,18 @@ import {
} 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";
const emit = defineEmits<{
@@ -122,14 +146,29 @@ const emit = defineEmits<{
const auth = useAuthStore();
const isDesktop = isTauriRuntime();
const clientMetadata = getAppMetadata();
const clientMetadataText = [
`${clientMetadata.clientType} ${clientMetadata.version}`,
clientMetadata.platform,
clientMetadata.channel,
clientMetadata.commit,
].join(" · ");
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({
@@ -210,10 +249,26 @@ const loadProfile = async () => {
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;
@@ -221,6 +276,7 @@ const onDesktopNotificationChange = async (value: string | number | boolean) =>
const enable = Boolean(value);
if (enable) {
const permission = await requestNotificationPermission();
notificationPermission.value = permission;
if (permission !== "granted") {
desktopNotificationsEnabled.value = false;
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
@@ -239,10 +295,24 @@ const onDesktopNotificationChange = async (value: string | number | boolean) =>
};
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) => {
@@ -419,25 +489,53 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
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 {
margin-left: 12px;
color: #7f92ad;
font-size: 12px;
}
.client-metadata {
.client-metadata-panel {
display: flex;
align-items: center;
align-items: flex-start;
gap: 12px;
min-width: 0;
}
.client-metadata code {
overflow-wrap: anywhere;
.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;
@@ -121,7 +121,7 @@
{{ TEXT.common.labels.preview }}
</el-button>
<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">
打开
@@ -264,7 +264,13 @@
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
上传文件
</div>
<div class="upload-zone" :class="{ 'has-file': uploadFile }" @click="triggerFileInput">
<div
class="upload-zone"
:class="{ 'has-file': uploadFile }"
@click="triggerFileInput"
@dragover.prevent
@drop.prevent="handleUploadDrop"
>
<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>
@@ -344,7 +350,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute } from "vue-router";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { Edit, Upload, Share } from "@element-plus/icons-vue";
@@ -373,6 +379,7 @@ 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";
const route = useRoute();
const auth = useAuthStore();
@@ -380,6 +387,7 @@ const study = useStudyStore();
const { roleLabel, loadRoleTemplates } = useRoleTemplateMeta();
const { can } = usePermission();
const documentId = computed(() => route.params.id as string);
let desktopRefreshCleanup: (() => void) | undefined;
const loading = ref(false);
const errorMessage = ref("");
@@ -523,6 +531,10 @@ const triggerFileInput = async () => {
});
if (file) uploadFile.value = file;
};
const handleUploadDrop = (event: DragEvent) => {
const [file] = Array.from(event.dataTransfer?.files || []);
if (file) uploadFile.value = file;
};
const removeFile = () => { uploadFile.value = null; };
const distributeVisible = ref(false);
@@ -887,9 +899,14 @@ watch(previewVisible, (visible) => {
});
onMounted(async () => {
desktopRefreshCleanup = onDesktopRefreshCurrentView(loadDetail);
await loadRoleTemplates();
await loadDetail();
});
onBeforeUnmount(() => {
desktopRefreshCleanup?.();
});
</script>
<style scoped>
+11 -1
View File
@@ -164,7 +164,7 @@
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, computed, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { ElMessage, ElMessageBox, FormInstance, FormRules } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
@@ -178,6 +178,7 @@ import type { DocumentSummary } from "../../types/documents";
import type { Site } from "../../types/api";
import { displayDateTime, displayEnum, displayText } from "../../utils/display";
import { TEXT } from "../../locales";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
const route = useRoute();
const router = useRouter();
@@ -191,6 +192,7 @@ const editorVisible = ref(false);
const saving = ref(false);
const editingDocumentId = ref("");
const editorFormRef = ref<FormInstance>();
let desktopRefreshCleanup: (() => void) | undefined;
const trialId = computed(() => (route.params.trialId as string) || "");
@@ -436,6 +438,14 @@ onMounted(() => {
ensureTrialRoute();
load();
loadSites();
desktopRefreshCleanup = onDesktopRefreshCurrentView(() => {
load();
loadSites();
});
});
onBeforeUnmount(() => {
desktopRefreshCleanup?.();
});
watch(
@@ -27,3 +27,15 @@ describe("SubjectManagement drawer editor", () => {
expect(source).not.toContain('router.push("/subjects/new")');
});
});
describe("SubjectManagement desktop list workflow", () => {
it("selects rows for preview and opens details on explicit desktop actions", () => {
const source = readSubjectManagementSource();
expect(source).toContain("@row-click=\"selectSubject\"");
expect(source).toContain("@row-dblclick=\"openSubjectDetail\"");
expect(source).toContain("@row-contextmenu=\"openSubjectContextMenu\"");
expect(source).toContain("subject-preview-pane");
expect(source).toContain("subject-context-menu");
});
});
+323 -59
View File
@@ -24,6 +24,7 @@
</div>
</div>
<div class="toolbar-right">
<span v-if="selectedRows.length" class="selection-count">已选 {{ selectedRows.length }} </span>
<el-button v-if="canCreateSubject" type="primary" @click="goNew" class="create-btn">
<el-icon class="el-icon--left"><Plus /></el-icon>
{{ TEXT.common.actions.add }}{{ TEXT.modules.subjectManagement.subjectLabel }}
@@ -31,77 +32,140 @@
</div>
</div>
<el-table
:data="pagedItems"
v-loading="loading"
style="width: 100%"
class="subject-table"
:row-class-name="subjectRowClass"
@row-click="onRowClick"
table-layout="fixed"
>
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
<template #default="scope">
<div class="subject-info-cell">
<span class="cell-mono cell-nowrap">{{ scope.row.subject_no || TEXT.common.fallback }}</span>
<span v-for="badge in getAeBadges(scope.row)" :key="badge.type" :class="['ae-badge', `ae-badge-${badge.type}`]">
<div class="subject-workbench">
<div class="subject-table-pane" tabindex="0" @keydown.enter.prevent="openSelectedSubject">
<el-table
:data="pagedItems"
v-loading="loading"
style="width: 100%"
class="subject-table"
:row-class-name="subjectRowClass"
highlight-current-row
@selection-change="onSelectionChange"
@row-click="selectSubject"
@row-dblclick="openSubjectDetail"
@row-contextmenu="openSubjectContextMenu"
table-layout="fixed"
>
<el-table-column type="selection" width="42" />
<el-table-column prop="subject_no" :label="TEXT.modules.subjectManagement.screeningNo" show-overflow-tooltip>
<template #default="scope">
<div class="subject-info-cell">
<span class="cell-mono cell-nowrap">{{ scope.row.subject_no || TEXT.common.fallback }}</span>
<span v-for="badge in getAeBadges(scope.row)" :key="badge.type" :class="['ae-badge', `ae-badge-${badge.type}`]">
{{ badge.label }}
</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="scope"><span class="cell-nowrap">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</span></template>
</el-table-column>
<el-table-column prop="consent_date" :label="TEXT.common.fields.consentDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.consent_date) }}</span></template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="scope"><span class="cell-nowrap">{{ displaySubjectStatus(scope.row) }}</span></template>
</el-table-column>
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.enrollment_date) }}</span></template>
</el-table-column>
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.completion_date) }}</span></template>
</el-table-column>
<el-table-column v-if="canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right">
<template #default="scope">
<div class="cell-actions">
<el-button v-if="canDeleteSubject" link type="danger" size="small" :disabled="isInactiveSite(scope.row.site_id)" @click.stop="remove(scope.row)">
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div class="empty-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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</div>
<span>{{ TEXT.modules.subjectManagement.empty }}</span>
</div>
</template>
</el-table>
<div v-if="filteredItems.length > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[5, 10, 20]"
:total="filteredItems.length"
layout="prev, pager, next, sizes, total"
small
/>
</div>
</div>
<aside class="subject-preview-pane">
<template v-if="selectedSubject">
<div class="preview-head">
<div>
<div class="preview-kicker">当前参与者</div>
<div class="preview-title">{{ selectedSubject.subject_no || TEXT.common.fallback }}</div>
</div>
<el-button size="small" type="primary" @click="openSubjectDetail(selectedSubject)">打开详情</el-button>
</div>
<dl class="preview-list">
<dt>{{ TEXT.common.fields.site }}</dt>
<dd>{{ siteMap[selectedSubject.site_id] || TEXT.common.fallback }}</dd>
<dt>{{ TEXT.common.fields.status }}</dt>
<dd>{{ displaySubjectStatus(selectedSubject) }}</dd>
<dt>{{ TEXT.common.fields.consentDate }}</dt>
<dd>{{ displayDate(selectedSubject.consent_date) }}</dd>
<dt>{{ TEXT.common.fields.enrollmentDate }}</dt>
<dd>{{ displayDate(selectedSubject.enrollment_date) }}</dd>
<dt>{{ TEXT.common.fields.completionDate }}</dt>
<dd>{{ displayDate(selectedSubject.completion_date) }}</dd>
</dl>
<div v-if="getAeBadges(selectedSubject).length" class="preview-badges">
<span v-for="badge in getAeBadges(selectedSubject)" :key="badge.type" :class="['ae-badge', `ae-badge-${badge.type}`]">
{{ badge.label }}
</span>
</div>
</template>
</el-table-column>
<el-table-column :label="TEXT.common.fields.site" show-overflow-tooltip>
<template #default="scope"><span class="cell-nowrap">{{ siteMap[scope.row.site_id] || TEXT.common.fallback }}</span></template>
</el-table-column>
<el-table-column prop="consent_date" :label="TEXT.common.fields.consentDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.consent_date) }}</span></template>
</el-table-column>
<el-table-column prop="status" :label="TEXT.common.fields.status">
<template #default="scope"><span class="cell-nowrap">{{ displaySubjectStatus(scope.row) }}</span></template>
</el-table-column>
<el-table-column prop="enrollment_date" :label="TEXT.common.fields.enrollmentDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.enrollment_date) }}</span></template>
</el-table-column>
<el-table-column prop="completion_date" :label="TEXT.common.fields.completionDate">
<template #default="scope"><span class="cell-nowrap">{{ displayDate(scope.row.completion_date) }}</span></template>
</el-table-column>
<el-table-column v-if="canDeleteSubject" :label="TEXT.common.labels.actions" width="90" fixed="right">
<template #default="scope">
<div class="cell-actions">
<el-button v-if="canDeleteSubject" link type="danger" size="small" :disabled="isInactiveSite(scope.row.site_id)" @click.stop="remove(scope.row)">
{{ TEXT.common.actions.delete }}
</el-button>
</div>
</template>
</el-table-column>
<template #empty>
<div v-if="!loading" class="table-empty">
<div v-else class="preview-empty">
<div class="empty-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="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>
</div>
<span>{{ TEXT.modules.subjectManagement.empty }}</span>
<span>选择一行查看详情摘要</span>
</div>
</template>
</el-table>
<div v-if="filteredItems.length > 0" class="pagination-wrap">
<el-pagination
v-model:current-page="pagination.currentPage"
v-model:page-size="pagination.pageSize"
:page-sizes="[5, 10, 20]"
:total="filteredItems.length"
layout="prev, pager, next, sizes, total"
small
/>
</aside>
</div>
</div>
<div
v-if="contextMenu.visible"
class="subject-context-menu"
:style="{ left: `${contextMenu.x}px`, top: `${contextMenu.y}px` }"
@click.stop
>
<button type="button" @click="openSelectedSubject">打开详情</button>
<button type="button" @click="copySelectedSubjectNo">复制编号</button>
<button
v-if="canDeleteSubject"
type="button"
class="danger"
:disabled="!selectedSubject || isInactiveSite(selectedSubject.site_id)"
@click="selectedSubject && remove(selectedSubject)"
>
删除
</button>
</div>
<SubjectEditorDrawer v-model="subjectDrawerVisible" @success="handleSubjectEditorSuccess" />
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRouter } from "vue-router";
import { ElMessage, ElMessageBox } from "element-plus";
import { Plus } from "@element-plus/icons-vue";
@@ -114,6 +178,7 @@ import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { isSystemAdmin } from "../../utils/roles";
import { TEXT } from "../../locales";
import SubjectEditorDrawer from "../subjects/SubjectEditorDrawer.vue";
import { onDesktopRefreshCurrentView } from "../../composables/useDesktopRefresh";
const router = useRouter();
const auth = useAuthStore();
@@ -121,6 +186,9 @@ const study = useStudyStore();
const loading = ref(false);
const subjectDrawerVisible = ref(false);
const items = ref<any[]>([]);
const selectedSubjectId = ref("");
const selectedRows = ref<any[]>([]);
const contextMenu = ref({ visible: false, x: 0, y: 0 });
const siteOptions = ref<Array<{ id: string; name: string }>>([]);
const siteMap = ref<Record<string, string>>({});
const siteActiveMap = ref<Record<string, boolean>>({});
@@ -133,6 +201,7 @@ const pagination = ref({
currentPage: 1,
pageSize: 20,
});
let desktopRefreshCleanup: (() => void) | undefined;
const statusOptions = Object.entries(TEXT.enums.subjectStatus).map(([value, label]) => ({ value, label }));
const resetFilters = () => {
filters.value.keyword = "";
@@ -185,8 +254,45 @@ const load = async () => {
const goNew = () => { subjectDrawerVisible.value = true; };
const goDetail = (id: string) => router.push(`/subjects/${id}`);
const handleSubjectEditorSuccess = () => { load(); };
const onRowClick = (row: any) => { if (!row?.id) return; goDetail(row.id); };
const selectedSubject = computed(() => items.value.find((item) => item.id === selectedSubjectId.value) || null);
const selectSubject = (row: any) => {
contextMenu.value.visible = false;
if (!row?.id) return;
selectedSubjectId.value = row.id;
};
const openSubjectDetail = (row: any) => {
if (!row?.id) return;
goDetail(row.id);
};
const openSelectedSubject = () => {
contextMenu.value.visible = false;
if (selectedSubject.value?.id) goDetail(selectedSubject.value.id);
};
const onSelectionChange = (rows: any[]) => {
selectedRows.value = rows;
};
const openSubjectContextMenu = (row: any, _column: unknown, event: MouseEvent) => {
event.preventDefault();
if (!row?.id) return;
selectedSubjectId.value = row.id;
contextMenu.value = {
visible: true,
x: event.clientX,
y: event.clientY,
};
};
const closeSubjectContextMenu = () => {
contextMenu.value.visible = false;
};
const copySelectedSubjectNo = async () => {
contextMenu.value.visible = false;
const subjectNo = selectedSubject.value?.subject_no;
if (!subjectNo) return;
await navigator.clipboard?.writeText(subjectNo);
ElMessage.success("编号已复制");
};
const remove = async (row: any) => {
contextMenu.value.visible = false;
const studyId = study.currentStudy?.id;
if (!studyId) return;
if (!canDeleteSubject.value) return;
@@ -230,7 +336,11 @@ const pagedItems = computed(() => {
return sortedItems.value.slice(start, end);
});
const subjectRowClass = ({ row }: { row: any }) =>
`${row?.id ? "clickable-row" : ""}${isInactiveSite(row?.site_id) ? " row-inactive" : ""}`.trim();
[
row?.id ? "clickable-row" : "",
row?.id && row.id === selectedSubjectId.value ? "row-selected" : "",
isInactiveSite(row?.site_id) ? "row-inactive" : "",
].filter(Boolean).join(" ");
watch(() => [filters.value.keyword, filters.value.siteId, filters.value.status, pagination.value.pageSize], () => { pagination.value.currentPage = 1; });
watch(() => filteredItems.value.length, (total) => {
@@ -238,8 +348,30 @@ watch(() => filteredItems.value.length, (total) => {
if (pagination.value.currentPage > maxPage) pagination.value.currentPage = maxPage;
});
watch(() => study.currentSite, (newSite) => { filters.value.siteId = newSite?.id || ""; });
watch(pagedItems, (rows) => {
if (!rows.length) {
selectedSubjectId.value = "";
return;
}
if (!rows.some((item) => item.id === selectedSubjectId.value)) {
selectedSubjectId.value = rows[0].id;
}
});
onMounted(async () => { await loadSites(); load(); });
onMounted(async () => {
document.addEventListener("click", closeSubjectContextMenu);
desktopRefreshCleanup = onDesktopRefreshCurrentView(() => {
loadSites();
load();
});
await loadSites();
load();
});
onBeforeUnmount(() => {
document.removeEventListener("click", closeSubjectContextMenu);
desktopRefreshCleanup?.();
});
</script>
<style scoped>
@@ -256,6 +388,24 @@ onMounted(async () => { await loadSites(); load(); });
overflow: hidden;
}
.subject-workbench {
display: grid;
grid-template-columns: minmax(0, 1fr) 300px;
min-height: 520px;
}
.subject-table-pane {
min-width: 0;
border-right: 1px solid #edf1f7;
outline: none;
}
.subject-preview-pane {
min-width: 0;
padding: 16px;
background: #fbfcff;
}
/* ==================== Toolbar ==================== */
.table-card-toolbar {
display: flex;
@@ -281,6 +431,13 @@ onMounted(async () => { await loadSites(); load(); });
flex-shrink: 0;
}
.selection-count {
color: #64748b;
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.filter-item {
display: flex;
flex-direction: column;
@@ -342,6 +499,10 @@ onMounted(async () => { await loadSites(); load(); });
.subject-table :deep(.el-table__body tr) { cursor: pointer; transition: background 0.1s ease; }
.subject-table :deep(.el-table__body tr.row-selected > td) {
background: #edf5ff !important;
}
/* ==================== Cell Helpers ==================== */
.cell-nowrap { white-space: nowrap; }
.cell-mono { font-family: ui-monospace, SFMono-Regular, monospace; font-size: 13px; color: #0a0a0a; font-weight: 600; }
@@ -357,6 +518,106 @@ onMounted(async () => { await loadSites(); load(); });
gap: 8px;
}
.preview-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
padding-bottom: 14px;
border-bottom: 1px solid #e2e8f0;
}
.preview-kicker {
color: #64748b;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.preview-title {
margin-top: 4px;
color: #0f172a;
font-size: 18px;
font-weight: 800;
word-break: break-word;
}
.preview-list {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 10px 12px;
margin: 16px 0 0;
}
.preview-list dt {
color: #64748b;
font-size: 12px;
}
.preview-list dd {
margin: 0;
color: #0f172a;
font-size: 13px;
font-weight: 600;
overflow-wrap: anywhere;
}
.preview-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 16px;
}
.preview-empty {
min-height: 360px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: #94a3b8;
font-size: 13px;
}
.subject-context-menu {
position: fixed;
z-index: 3000;
min-width: 132px;
padding: 5px;
border: 1px solid #d7e2f0;
border-radius: 8px;
background: #ffffff;
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.18);
}
.subject-context-menu button {
display: block;
width: 100%;
height: 30px;
padding: 0 10px;
border: 0;
border-radius: 6px;
background: transparent;
color: #0f172a;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.subject-context-menu button:hover:not(:disabled) {
background: #eef4ff;
}
.subject-context-menu button.danger {
color: #dc2626;
}
.subject-context-menu button:disabled {
color: #cbd5e1;
cursor: not-allowed;
}
.ae-badge {
display: inline-flex;
align-items: center;
@@ -419,6 +680,9 @@ onMounted(async () => { await loadSites(); load(); });
/* ==================== Responsive ==================== */
@media (max-width: 960px) {
.subject-workbench { grid-template-columns: 1fr; }
.subject-table-pane { border-right: 0; }
.subject-preview-pane { display: none; }
.toolbar-filters { flex-direction: column; align-items: stretch; }
.filter-input, .filter-select { width: 100%; }
.table-card-toolbar { flex-direction: column; gap: 12px; align-items: stretch; }