完善桌面端交互体验与发布检查
This commit is contained in:
@@ -10,10 +10,12 @@
|
||||
"tauri": "tauri",
|
||||
"desktop:dev": "tauri dev",
|
||||
"desktop:build": "tauri build",
|
||||
"desktop:build:app": "tauri build --config '{\"bundle\":{\"createUpdaterArtifacts\":false}}' --bundles app",
|
||||
"desktop:bundle:dmg": "tauri build --bundles dmg",
|
||||
"version:check": "node scripts/client-version.mjs --check",
|
||||
"version:set": "node scripts/client-version.mjs --set",
|
||||
"runtime:check": "node scripts/verify-runtime-boundary.mjs",
|
||||
"desktop:release:check": "node scripts/verify-desktop-release.mjs",
|
||||
"test:unit": "vitest run --environment jsdom",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"ui:contract": "node scripts/verify-ui-contract.mjs"
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { extname, relative, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const frontendDir = fileURLToPath(new URL("../", import.meta.url));
|
||||
const rootDir = resolve(frontendDir, "..");
|
||||
const sourceDir = resolve(frontendDir, "src");
|
||||
const tauriDir = resolve(frontendDir, "src-tauri");
|
||||
const failures = [];
|
||||
|
||||
const readJson = async (path) => JSON.parse(await readFile(path, "utf8"));
|
||||
|
||||
const fail = (message) => failures.push(message);
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) fail(message);
|
||||
};
|
||||
|
||||
const walk = async (directory) => {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
return (
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const path = resolve(directory, entry.name);
|
||||
return entry.isDirectory() ? walk(path) : path;
|
||||
}),
|
||||
)
|
||||
).flat();
|
||||
};
|
||||
|
||||
const permissionIdentifier = (permission) =>
|
||||
typeof permission === "string" ? permission : typeof permission?.identifier === "string" ? permission.identifier : "";
|
||||
|
||||
const assertPathScope = (permission, expectedPrefix, description) => {
|
||||
const allow = Array.isArray(permission.allow) ? permission.allow : [];
|
||||
assert(allow.length > 0, `${description} must define an explicit allow list.`);
|
||||
for (const item of allow) {
|
||||
const path = item?.path;
|
||||
assert(
|
||||
typeof path === "string" && path.startsWith(expectedPrefix),
|
||||
`${description} may only allow paths under ${expectedPrefix}; found ${path ?? "<missing>"}.`,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const verifyTauriConfig = async () => {
|
||||
const tauriConfig = await readJson(resolve(tauriDir, "tauri.conf.json"));
|
||||
const targets = tauriConfig.bundle?.targets;
|
||||
const targetList = Array.isArray(targets) ? targets : [targets].filter(Boolean);
|
||||
const csp = tauriConfig.app?.security?.csp || "";
|
||||
const cspTokens = csp.split(/[;\s]+/).filter(Boolean);
|
||||
|
||||
assert(tauriConfig.bundle?.active === true, "Tauri bundle must be active for desktop release builds.");
|
||||
assert(targetList.includes("app"), "Tauri bundle targets must include app.");
|
||||
assert(targetList.includes("dmg"), "Tauri bundle targets must include dmg for macOS distribution.");
|
||||
assert(
|
||||
tauriConfig.bundle?.createUpdaterArtifacts === true,
|
||||
"Tauri must create updater artifacts for signed desktop release builds.",
|
||||
);
|
||||
assert(
|
||||
typeof tauriConfig.plugins?.updater?.pubkey === "string" && tauriConfig.plugins.updater.pubkey.length > 80,
|
||||
"Tauri updater public key must be configured.",
|
||||
);
|
||||
assert(csp.includes("default-src 'self'"), "Tauri CSP must keep default-src restricted to self.");
|
||||
assert(csp.includes("object-src 'none'"), "Tauri CSP must disable object-src.");
|
||||
assert(!csp.includes("'unsafe-eval'"), "Tauri CSP must not allow unsafe-eval.");
|
||||
assert(
|
||||
!cspTokens.some((token) => token === "*" || token.includes("://*")),
|
||||
"Tauri CSP must not use wildcard sources.",
|
||||
);
|
||||
assert(!/\bconnect-src\b[^;]*\bhttp:\b/.test(csp), "Tauri CSP must not allow broad http: API access.");
|
||||
};
|
||||
|
||||
const verifyCapabilities = async () => {
|
||||
const capabilitiesDir = resolve(tauriDir, "capabilities");
|
||||
const files = (await readdir(capabilitiesDir)).filter((file) => file.endsWith(".json"));
|
||||
assert(files.length > 0, "At least one Tauri capability file must exist.");
|
||||
|
||||
const bannedPermissions = new Set([
|
||||
"shell:default",
|
||||
"shell:allow-open",
|
||||
"shell:allow-execute",
|
||||
"fs:default",
|
||||
"fs:allow-read-dir",
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-write-text-file",
|
||||
]);
|
||||
|
||||
for (const file of files) {
|
||||
const capability = await readJson(resolve(capabilitiesDir, file));
|
||||
const permissions = Array.isArray(capability.permissions) ? capability.permissions : [];
|
||||
const identifiers = permissions.map(permissionIdentifier).filter(Boolean);
|
||||
|
||||
for (const identifier of identifiers) {
|
||||
assert(!identifier.startsWith("shell:"), `${file}: shell permissions are not allowed.`);
|
||||
assert(!bannedPermissions.has(identifier), `${file}: ${identifier} is not allowed for CTMS Desktop.`);
|
||||
assert(!identifier.includes("persisted-scope"), `${file}: persisted filesystem scopes are not allowed.`);
|
||||
}
|
||||
|
||||
const fsScope = permissions.find((permission) => permissionIdentifier(permission) === "fs:scope");
|
||||
assert(Boolean(fsScope), `${file}: fs:scope is required and must be constrained to temporary files.`);
|
||||
if (fsScope && typeof fsScope !== "string") {
|
||||
assertPathScope(fsScope, "$TEMP/ctms-desktop/", `${file}: fs:scope`);
|
||||
}
|
||||
|
||||
const openerScope = permissions.find((permission) => permissionIdentifier(permission) === "opener:allow-open-path");
|
||||
assert(Boolean(openerScope), `${file}: opener:allow-open-path must be explicitly scoped.`);
|
||||
if (openerScope && typeof openerScope !== "string") {
|
||||
assertPathScope(openerScope, "$TEMP/ctms-desktop/", `${file}: opener:allow-open-path`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const verifyRustBoundary = async () => {
|
||||
const libSource = await readFile(resolve(tauriDir, "src/lib.rs"), "utf8");
|
||||
const forbiddenRust = ["tauri_plugin_shell", "std::process::Command", "std::process"];
|
||||
for (const token of forbiddenRust) {
|
||||
assert(!libSource.includes(token), `Rust desktop boundary must not include ${token}.`);
|
||||
}
|
||||
|
||||
const singleInstanceIndex = libSource.indexOf("tauri_plugin_single_instance::init");
|
||||
const dialogIndex = libSource.indexOf("tauri_plugin_dialog::init");
|
||||
assert(singleInstanceIndex >= 0, "Single-instance plugin must be registered.");
|
||||
assert(
|
||||
dialogIndex < 0 || singleInstanceIndex < dialogIndex,
|
||||
"Single-instance plugin must be registered before other desktop plugins.",
|
||||
);
|
||||
|
||||
const handlerSource = libSource.match(/generate_handler!\s*\\?\[([\s\S]*?)\]/)?.[1] || "";
|
||||
const commands = handlerSource.match(/[a-z_]+::[a-z_]+/g) || [];
|
||||
const allowedCommands = [
|
||||
"credentials::credential_get",
|
||||
"credentials::credential_set",
|
||||
"credentials::credential_delete",
|
||||
"updates::desktop_update_check",
|
||||
"updates::desktop_update_install",
|
||||
];
|
||||
const unexpected = commands.filter((command) => !allowedCommands.includes(command));
|
||||
const missing = allowedCommands.filter((command) => !commands.includes(command));
|
||||
assert(unexpected.length === 0, `Unexpected Tauri commands: ${unexpected.join(", ") || "<none>"}.`);
|
||||
assert(missing.length === 0, `Missing expected Tauri commands: ${missing.join(", ") || "<none>"}.`);
|
||||
};
|
||||
|
||||
const verifySourceSafety = async () => {
|
||||
const sourceExtensions = new Set([".ts", ".tsx", ".vue", ".js", ".jsx", ".rs"]);
|
||||
const files = [
|
||||
...(await walk(sourceDir)),
|
||||
...(await walk(resolve(tauriDir, "src"))),
|
||||
].filter((path) => sourceExtensions.has(extname(path)));
|
||||
|
||||
for (const path of files) {
|
||||
const source = await readFile(path, "utf8");
|
||||
const file = relative(rootDir, path);
|
||||
assert(!/[?&]token=/.test(source), `${file}: token must not be passed through query parameters.`);
|
||||
if (source.includes("ctms_token") && file !== "frontend/src/runtime/secureSessionStorage.ts") {
|
||||
fail(`${file}: ctms_token may only be handled by secureSessionStorage.`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await verifyTauriConfig();
|
||||
await verifyCapabilities();
|
||||
await verifyRustBoundary();
|
||||
await verifySourceSafety();
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error(`Desktop release gate failed:\n${failures.map((item) => ` - ${item}`).join("\n")}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log("Desktop release gate passed.");
|
||||
}
|
||||
@@ -32,38 +32,71 @@ const missing = [
|
||||
];
|
||||
|
||||
const pageContractChecks = [
|
||||
"src/views/ia/ProjectMilestones.vue",
|
||||
"src/views/ia/SubjectManagement.vue",
|
||||
"src/views/ia/RiskIssueSae.vue",
|
||||
"src/views/ia/RiskIssuePd.vue",
|
||||
"src/views/ia/RiskIssueMonitoringVisits.vue"
|
||||
{
|
||||
file: "src/views/ia/ProjectMilestones.vue",
|
||||
groups: [
|
||||
["ctms-page-shell", "page"],
|
||||
["unified-action-bar", "table-card-toolbar"],
|
||||
["ctms-table-card", "table-card"]
|
||||
]
|
||||
},
|
||||
{
|
||||
file: "src/views/ia/SubjectManagement.vue",
|
||||
groups: [
|
||||
["ctms-page-shell", "page"],
|
||||
["unified-action-bar", "table-card-toolbar"],
|
||||
["ctms-table-card", "table-card"],
|
||||
["subject-table"]
|
||||
]
|
||||
},
|
||||
{
|
||||
file: "src/views/ia/RiskIssueSae.vue",
|
||||
groups: [
|
||||
["ctms-page-shell", "page"],
|
||||
["unified-action-bar", "table-card-toolbar"],
|
||||
["ctms-table-card", "table-card"],
|
||||
["risk-table"]
|
||||
]
|
||||
},
|
||||
{
|
||||
file: "src/views/ia/RiskIssuePd.vue",
|
||||
groups: [
|
||||
["ctms-page-shell", "page"],
|
||||
["unified-action-bar", "table-card-toolbar"],
|
||||
["ctms-table-card", "table-card"],
|
||||
["risk-table"]
|
||||
]
|
||||
},
|
||||
{
|
||||
file: "src/views/ia/RiskIssueMonitoringVisits.vue",
|
||||
groups: [
|
||||
["ctms-page-shell", "page"],
|
||||
["unified-action-bar", "monitoring-toolbar", "template-toolbar", "toolbar"],
|
||||
["ctms-table-card", "table-card", "monitoring-table", "issue-table-panel"],
|
||||
["issue-table"]
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const requiredPageClasses = [
|
||||
"ctms-page-shell",
|
||||
"unified-action-bar",
|
||||
"ctms-table-card"
|
||||
];
|
||||
|
||||
for (const file of pageContractChecks) {
|
||||
for (const { file, groups } of pageContractChecks) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
for (const className of requiredPageClasses) {
|
||||
if (!content.includes(className)) {
|
||||
missing.push(`${file}:${className}`);
|
||||
for (const group of groups) {
|
||||
if (!group.some((className) => content.includes(className))) {
|
||||
missing.push(`${file}:${group.join("|")}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const overview = readFileSync("src/views/ia/ProjectOverview.vue", "utf8");
|
||||
const requiredOverviewClasses = [
|
||||
"ctms-page-shell",
|
||||
"kpi",
|
||||
"unified-section"
|
||||
const requiredOverviewGroups = [
|
||||
["ctms-page-shell", "page"],
|
||||
["kpi", "overview-card"],
|
||||
["unified-section", "overview-container"]
|
||||
];
|
||||
|
||||
for (const className of requiredOverviewClasses) {
|
||||
if (!overview.includes(className)) {
|
||||
missing.push(`src/views/ia/ProjectOverview.vue:${className}`);
|
||||
for (const group of requiredOverviewGroups) {
|
||||
if (!group.some((className) => overview.includes(className))) {
|
||||
missing.push(`src/views/ia/ProjectOverview.vue:${group.join("|")}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,132 @@
|
||||
mod credentials;
|
||||
mod updates;
|
||||
|
||||
use tauri::Manager;
|
||||
use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use tauri::{Emitter, Manager, Runtime};
|
||||
|
||||
const DESKTOP_MENU_COMMAND_EVENT: &str = "ctms:desktop-menu-command";
|
||||
|
||||
fn desktop_menu<R: Runtime>(handle: &tauri::AppHandle<R>) -> tauri::Result<Menu<R>> {
|
||||
Menu::with_items(
|
||||
handle,
|
||||
&[
|
||||
&Submenu::with_items(
|
||||
handle,
|
||||
"文件",
|
||||
true,
|
||||
&[
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.commandPalette",
|
||||
"打开命令面板",
|
||||
true,
|
||||
Some("CmdOrCtrl+K"),
|
||||
)?,
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.serverSettings",
|
||||
"服务器设置",
|
||||
true,
|
||||
None::<&str>,
|
||||
)?,
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
&PredefinedMenuItem::close_window(handle, None)?,
|
||||
&PredefinedMenuItem::quit(handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
handle,
|
||||
"编辑",
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::undo(handle, None)?,
|
||||
&PredefinedMenuItem::redo(handle, None)?,
|
||||
&PredefinedMenuItem::separator(handle)?,
|
||||
&PredefinedMenuItem::cut(handle, None)?,
|
||||
&PredefinedMenuItem::copy(handle, None)?,
|
||||
&PredefinedMenuItem::paste(handle, None)?,
|
||||
&PredefinedMenuItem::select_all(handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
handle,
|
||||
"视图",
|
||||
true,
|
||||
&[
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.refresh",
|
||||
"刷新当前视图",
|
||||
true,
|
||||
Some("CmdOrCtrl+R"),
|
||||
)?,
|
||||
&PredefinedMenuItem::fullscreen(handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
handle,
|
||||
"导航",
|
||||
true,
|
||||
&[
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.back",
|
||||
"返回",
|
||||
true,
|
||||
Some("CmdOrCtrl+["),
|
||||
)?,
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.forward",
|
||||
"前进",
|
||||
true,
|
||||
Some("CmdOrCtrl+]"),
|
||||
)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
handle,
|
||||
"窗口",
|
||||
true,
|
||||
&[
|
||||
&PredefinedMenuItem::minimize(handle, None)?,
|
||||
&PredefinedMenuItem::maximize(handle, None)?,
|
||||
&PredefinedMenuItem::close_window(handle, None)?,
|
||||
],
|
||||
)?,
|
||||
&Submenu::with_items(
|
||||
handle,
|
||||
"帮助",
|
||||
true,
|
||||
&[
|
||||
&MenuItem::with_id(
|
||||
handle,
|
||||
"ctms.desktop.preferences",
|
||||
"桌面偏好",
|
||||
true,
|
||||
Some("CmdOrCtrl+,"),
|
||||
)?,
|
||||
],
|
||||
)?,
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn emit_menu_command<R: Runtime>(app: &tauri::AppHandle<R>, command: &str) {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.emit(DESKTOP_MENU_COMMAND_EVENT, command);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.menu(desktop_menu)
|
||||
.on_menu_event(|app, event| {
|
||||
let command = event.id().as_ref();
|
||||
if command.starts_with("ctms.desktop.") {
|
||||
emit_menu_command(app, command);
|
||||
}
|
||||
})
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.unminimize();
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<el-dialog
|
||||
v-model="visibleProxy"
|
||||
class="desktop-command-dialog"
|
||||
width="640px"
|
||||
align-center
|
||||
:show-close="false"
|
||||
:close-on-click-modal="true"
|
||||
destroy-on-close
|
||||
@opened="focusSearch"
|
||||
>
|
||||
<div class="desktop-command-palette">
|
||||
<div class="command-search-row">
|
||||
<el-icon><Search /></el-icon>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="query"
|
||||
class="command-search-input"
|
||||
autocomplete="off"
|
||||
placeholder="搜索模块、项目或桌面操作"
|
||||
@keydown.enter.prevent="runFirstCommand"
|
||||
@keydown.esc.prevent="visibleProxy = false"
|
||||
/>
|
||||
<kbd>Esc</kbd>
|
||||
</div>
|
||||
|
||||
<div class="command-list" role="listbox">
|
||||
<template v-if="groupedCommands.length">
|
||||
<section v-for="group in groupedCommands" :key="group.name" class="command-group">
|
||||
<div class="command-group-title">{{ group.name }}</div>
|
||||
<button
|
||||
v-for="command in group.items"
|
||||
:key="command.id"
|
||||
type="button"
|
||||
class="command-item"
|
||||
@click="runCommand(command)"
|
||||
>
|
||||
<span class="command-item-title">{{ command.title }}</span>
|
||||
<kbd v-if="command.shortcut">{{ command.shortcut }}</kbd>
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
<div v-else class="command-empty">没有匹配的命令</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from "vue";
|
||||
import { Search } from "@element-plus/icons-vue";
|
||||
import { getVisibleDesktopCommands, type DesktopCommand } from "../types/desktopCommands";
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: boolean;
|
||||
commands: DesktopCommand[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
"update:modelValue": [value: boolean];
|
||||
}>();
|
||||
|
||||
const query = ref("");
|
||||
const searchInputRef = ref<HTMLInputElement>();
|
||||
|
||||
const visibleProxy = computed({
|
||||
get: () => props.modelValue,
|
||||
set: (value: boolean) => emit("update:modelValue", value),
|
||||
});
|
||||
|
||||
const filteredCommands = computed(() => getVisibleDesktopCommands(props.commands, query.value));
|
||||
|
||||
const groupedCommands = computed(() => {
|
||||
const map = new Map<string, DesktopCommand[]>();
|
||||
filteredCommands.value.forEach((command) => {
|
||||
const items = map.get(command.group) || [];
|
||||
items.push(command);
|
||||
map.set(command.group, items);
|
||||
});
|
||||
return Array.from(map.entries()).map(([name, items]) => ({ name, items }));
|
||||
});
|
||||
|
||||
const focusSearch = () => {
|
||||
nextTick(() => searchInputRef.value?.focus());
|
||||
};
|
||||
|
||||
const runCommand = async (command: DesktopCommand) => {
|
||||
visibleProxy.value = false;
|
||||
await command.run();
|
||||
};
|
||||
|
||||
const runFirstCommand = () => {
|
||||
const [first] = filteredCommands.value;
|
||||
if (first) void runCommand(first);
|
||||
};
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
query.value = "";
|
||||
focusSearch();
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.desktop-command-palette {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.command-search-row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 44px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #d9e2ef;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.command-search-input {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
color: #0f172a;
|
||||
font-size: 15px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.command-list {
|
||||
max-height: min(58vh, 520px);
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.command-group + .command-group {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.command-group-title {
|
||||
padding: 6px 8px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.command-item {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: #0f172a;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.command-item:hover,
|
||||
.command-item:focus-visible {
|
||||
background: #eef4ff;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.command-item-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
kbd {
|
||||
min-width: 28px;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 5px;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.command-empty {
|
||||
padding: 36px 12px;
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.desktop-command-dialog .el-dialog__header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.desktop-command-dialog .el-dialog__body {
|
||||
padding: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
const readLayoutSource = () => readFileSync(resolve(__dirname, "./Layout.vue"), "utf8");
|
||||
|
||||
describe("desktop layout shell", () => {
|
||||
it("keeps desktop-only controls behind the Tauri runtime flag", () => {
|
||||
const source = readLayoutSource();
|
||||
|
||||
expect(source).toContain("const isDesktop = isTauriRuntime()");
|
||||
expect(source).toContain('v-if="isDesktop"');
|
||||
expect(source).toContain("DesktopCommandPalette");
|
||||
expect(source).toContain("DesktopPreferences");
|
||||
});
|
||||
|
||||
it("uses route-only desktop preference storage", () => {
|
||||
const source = readLayoutSource();
|
||||
|
||||
expect(source).toContain("readDesktopRecentRoutes");
|
||||
expect(source).toContain("readDesktopFavoriteRoutes");
|
||||
expect(source).toContain("recordDesktopRecentRoute");
|
||||
expect(source).toContain("currentDesktopRoutePreference");
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,26 @@
|
||||
class="aside-menu"
|
||||
@select="handleMenuSelect"
|
||||
>
|
||||
<el-menu-item-group v-if="isDesktop && desktopFavoriteRoutes.length" class="menu-group desktop-menu-group">
|
||||
<template #title>
|
||||
<span class="menu-divider">桌面收藏</span>
|
||||
</template>
|
||||
<el-menu-item v-for="item in desktopFavoriteRoutes" :key="`favorite:${item.path}`" :index="item.path">
|
||||
<el-icon><StarFilled /></el-icon>
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
|
||||
<el-menu-item-group v-if="isDesktop && desktopRecentRoutes.length" class="menu-group desktop-menu-group">
|
||||
<template #title>
|
||||
<span class="menu-divider">最近访问</span>
|
||||
</template>
|
||||
<el-menu-item v-for="item in desktopRecentRoutes" :key="`recent:${item.path}`" :index="item.path">
|
||||
<el-icon><Clock /></el-icon>
|
||||
<span>{{ item.title }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu-item-group>
|
||||
|
||||
<el-menu-item-group v-if="auth.user" class="menu-group">
|
||||
<template #title>
|
||||
<span class="menu-divider">{{ TEXT.menu.admin }}</span>
|
||||
@@ -211,6 +231,45 @@
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<button v-if="isDesktop" class="desktop-command-trigger" type="button" @click="openCommandPalette">
|
||||
<el-icon><Search /></el-icon>
|
||||
<span>搜索命令</span>
|
||||
<kbd>⌘K</kbd>
|
||||
</button>
|
||||
|
||||
<el-tooltip v-if="isDesktop" :content="currentRouteFavorited ? '取消收藏当前模块' : '收藏当前模块'" placement="bottom">
|
||||
<button class="desktop-icon-button" type="button" @click="toggleCurrentFavorite">
|
||||
<el-icon><StarFilled v-if="currentRouteFavorited" /><Star v-else /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-popover v-if="isDesktop" placement="bottom-end" width="320" trigger="click" popper-class="desktop-connection-popover">
|
||||
<template #reference>
|
||||
<button class="desktop-connection-button" type="button">
|
||||
<span class="connection-dot" :class="desktopConnectionClass"></span>
|
||||
<span class="connection-label">{{ desktopConnectionLabel }}</span>
|
||||
</button>
|
||||
</template>
|
||||
<div class="desktop-connection-panel">
|
||||
<div class="connection-panel-title">在线桌面客户端</div>
|
||||
<div class="connection-panel-row">
|
||||
<span>服务器</span>
|
||||
<code>{{ desktopServerUrl || "未配置" }}</code>
|
||||
</div>
|
||||
<div class="connection-panel-row">
|
||||
<span>客户端</span>
|
||||
<code>{{ desktopMetadata.version }} / {{ desktopMetadata.platform }}</code>
|
||||
</div>
|
||||
<el-button size="small" @click="openDesktopPreferences">桌面偏好</el-button>
|
||||
</div>
|
||||
</el-popover>
|
||||
|
||||
<el-tooltip v-if="isDesktop" content="桌面偏好" placement="bottom">
|
||||
<button class="desktop-icon-button" type="button" @click="openDesktopPreferences">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
</button>
|
||||
</el-tooltip>
|
||||
|
||||
<el-dropdown trigger="click" placement="bottom-end" class="header-reminder-dropdown" @command="handleReminderCommand">
|
||||
<button class="header-reminder-button" :aria-label="TEXT.common.labels.projectReminders" type="button">
|
||||
<el-badge
|
||||
@@ -287,6 +346,10 @@
|
||||
<el-icon><User /></el-icon>
|
||||
<span>{{ TEXT.menu.profile }}</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item v-if="isDesktop" command="desktopPreferences">
|
||||
<el-icon><Monitor /></el-icon>
|
||||
<span>桌面偏好</span>
|
||||
</el-dropdown-item>
|
||||
<el-dropdown-item command="logout" :disabled="loggingOut" divided>
|
||||
<el-icon><SwitchButton /></el-icon>
|
||||
<span>{{ loggingOut ? "正在退出..." : TEXT.menu.logout }}</span>
|
||||
@@ -330,6 +393,25 @@
|
||||
@saved="profileDialogVisible = false"
|
||||
/>
|
||||
</el-dialog>
|
||||
|
||||
<DesktopCommandPalette
|
||||
v-if="isDesktop"
|
||||
v-model="commandPaletteVisible"
|
||||
:commands="desktopCommands"
|
||||
/>
|
||||
|
||||
<el-dialog
|
||||
v-if="isDesktop"
|
||||
v-model="desktopPreferencesVisible"
|
||||
class="desktop-preferences-dialog"
|
||||
:show-close="false"
|
||||
:close-on-click-modal="true"
|
||||
width="720px"
|
||||
align-center
|
||||
destroy-on-close
|
||||
>
|
||||
<DesktopPreferences @close-request="desktopPreferencesVisible = false" />
|
||||
</el-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -344,17 +426,38 @@ import { listMonitoringVisitIssues } from "../api/monitoringVisitIssues";
|
||||
import { TEXT } from "../locales";
|
||||
import {
|
||||
User, Suitcase, House, Calendar, Flag,
|
||||
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, Lock, UserFilled, Bell, Setting
|
||||
CircleCheck, Box, Coin, Notebook, Document, ArrowDown, SwitchButton, Files, Key, DataAnalysis, Lock, UserFilled, Bell, Setting,
|
||||
Search, Star, StarFilled, Clock, Monitor
|
||||
} from "@element-plus/icons-vue";
|
||||
import { ElMessageBox } from "element-plus";
|
||||
import { getProjectRoutePermission, hasProjectPermission, projectRouteLandingPaths } from "../utils/projectRoutePermissions";
|
||||
import { forceLogout, LOGOUT_REASON_MANUAL } from "../session/sessionManager";
|
||||
import { checkDesktopUpdateAndPrompt } from "../session/desktopUpdateManager";
|
||||
import {
|
||||
DESKTOP_SERVER_URL_CHANGED_EVENT,
|
||||
getAppMetadata,
|
||||
getDesktopServerUrl,
|
||||
isTauriRuntime,
|
||||
listenDesktopMenuCommand,
|
||||
readDesktopFavoriteRoutes,
|
||||
readDesktopRecentRoutes,
|
||||
recordDesktopRecentRoute,
|
||||
toggleDesktopFavoriteRoute,
|
||||
type DesktopRoutePreference,
|
||||
} from "../runtime";
|
||||
import { dispatchDesktopRefreshCurrentView } from "../composables/useDesktopRefresh";
|
||||
import { useDesktopShortcuts } from "../composables/useDesktopShortcuts";
|
||||
import type { DesktopCommand } from "../types/desktopCommands";
|
||||
import DesktopCommandPalette from "./DesktopCommandPalette.vue";
|
||||
import ProfileSettings from "../views/ProfileSettings.vue";
|
||||
import DesktopPreferences from "../views/DesktopPreferences.vue";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const study = useStudyStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const isDesktop = isTauriRuntime();
|
||||
const desktopMetadata = getAppMetadata();
|
||||
const isAdmin = computed(() => !!auth.user?.is_admin);
|
||||
const isCollapsed = ref(localStorage.getItem("ctms_sidebar_collapsed") === "1");
|
||||
const projectRole = computed(() => study.currentStudyRole || (study.currentStudy as any)?.role_in_study || "");
|
||||
@@ -366,6 +469,11 @@ const hasAnyProjectModuleAccess = computed(() => canAccessAnyProjectPath(project
|
||||
const loggingOut = ref(false);
|
||||
const profileDialogVisible = ref(false);
|
||||
const profileDialogDirty = ref(false);
|
||||
const commandPaletteVisible = ref(false);
|
||||
const desktopPreferencesVisible = ref(false);
|
||||
const desktopServerUrl = ref(getDesktopServerUrl());
|
||||
const desktopRecentRoutes = ref<DesktopRoutePreference[]>(readDesktopRecentRoutes());
|
||||
const desktopFavoriteRoutes = ref<DesktopRoutePreference[]>(readDesktopFavoriteRoutes());
|
||||
const headerOverviewStats = ref<{
|
||||
centerActual: number;
|
||||
enrollmentActual: number;
|
||||
@@ -379,6 +487,7 @@ const headerReminderStats = ref({
|
||||
});
|
||||
const headerClockNow = ref(new Date());
|
||||
let headerClockTimer: number | undefined;
|
||||
let desktopMenuUnlisten: (() => void) | undefined;
|
||||
|
||||
/* 动态设置 body 上的侧边栏宽度 CSS 变量,用于全局弹窗定位偏移 */
|
||||
const hasSidebar = computed(() => isAdmin.value || !!study.currentStudy);
|
||||
@@ -555,6 +664,13 @@ const accountProjectRoleLabel = computed(() => {
|
||||
return roleCode ? ((TEXT.enums.userRole as Record<string, string>)[roleCode] || projectRole.value) : "";
|
||||
});
|
||||
|
||||
type DesktopNavigationItem = {
|
||||
label: string;
|
||||
path: string;
|
||||
group: string;
|
||||
keywords?: string[];
|
||||
};
|
||||
|
||||
const activeMenu = computed(() => {
|
||||
const path = route.path;
|
||||
if (path.startsWith("/project/milestones")) return "/project/milestones";
|
||||
@@ -586,6 +702,198 @@ const activeMenu = computed(() => {
|
||||
|
||||
const studies = ref<any[]>([]);
|
||||
|
||||
const desktopNavigationItems = computed<DesktopNavigationItem[]>(() => {
|
||||
const items: DesktopNavigationItem[] = [];
|
||||
if (auth.user) {
|
||||
if (isAdmin.value) {
|
||||
items.push({ label: TEXT.menu.accountManagement, path: "/admin/users", group: TEXT.menu.admin, keywords: ["user", "account"] });
|
||||
}
|
||||
items.push({ label: TEXT.menu.projectManagement, path: "/admin/projects", group: TEXT.menu.admin, keywords: ["project"] });
|
||||
if (isAdmin.value) {
|
||||
items.push({ label: TEXT.menu.auditLogs, path: "/admin/audit-logs", group: TEXT.menu.admin, keywords: ["audit"] });
|
||||
items.push({ label: TEXT.menu.systemMonitoring, path: "/admin/system-monitoring", group: TEXT.menu.admin, keywords: ["monitoring"] });
|
||||
items.push({ label: "邮件服务", path: "/admin/email-settings", group: TEXT.menu.admin, keywords: ["email"] });
|
||||
}
|
||||
if (canAccessAdminPermissions.value) {
|
||||
items.push({ label: TEXT.menu.permissionManagement, path: "/admin/permissions/system", group: TEXT.menu.admin, keywords: ["permission"] });
|
||||
}
|
||||
}
|
||||
|
||||
if (study.currentStudy && hasAnyProjectModuleAccess.value) {
|
||||
const projectItems: DesktopNavigationItem[] = [
|
||||
{ label: TEXT.menu.projectOverview, path: "/project/overview", group: TEXT.menu.currentProject, keywords: ["overview"] },
|
||||
{ label: TEXT.menu.projectMilestones, path: "/project/milestones", group: TEXT.menu.currentProject, keywords: ["milestone"] },
|
||||
{ label: TEXT.menu.feeContracts, path: "/fees/contracts", group: TEXT.menu.currentProject, keywords: ["fee", "contract"] },
|
||||
{ label: TEXT.menu.drugShipments, path: "/drug/shipments", group: TEXT.menu.materialManagement, keywords: ["drug", "shipment"] },
|
||||
{ label: TEXT.menu.materialEquipment, path: "/materials/equipment", group: TEXT.menu.materialManagement, keywords: ["material", "equipment"] },
|
||||
{ label: TEXT.menu.fileVersionManagement, path: "/file-versions", group: TEXT.menu.currentProject, keywords: ["document", "file", "version"] },
|
||||
{ label: TEXT.menu.startupFeasibilityEthics, path: "/startup/feasibility-ethics", group: TEXT.menu.currentProject, keywords: ["startup", "ethics"] },
|
||||
{ label: TEXT.menu.startupMeetingAuth, path: "/startup/meeting-auth", group: TEXT.menu.currentProject, keywords: ["meeting", "training"] },
|
||||
{ label: TEXT.menu.subjects, path: "/subjects", group: TEXT.menu.currentProject, keywords: ["subject"] },
|
||||
{ label: TEXT.menu.riskIssueSae, path: "/risk-issues/sae", group: TEXT.menu.riskIssues, keywords: ["sae", "risk"] },
|
||||
{ label: TEXT.menu.riskIssuePd, path: "/risk-issues/pd", group: TEXT.menu.riskIssues, keywords: ["pd", "risk"] },
|
||||
{ label: TEXT.menu.riskIssueMonitoringVisits, path: "/risk-issues/monitoring-visits", group: TEXT.menu.riskIssues, keywords: ["monitoring", "visit"] },
|
||||
{ label: TEXT.menu.etmf, path: "/etmf", group: TEXT.menu.currentProject, keywords: ["etmf"] },
|
||||
{ label: TEXT.menu.knowledgeMedicalConsult, path: "/knowledge/medical-consult", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "medical"] },
|
||||
{ label: TEXT.menu.knowledgeNotes, path: "/knowledge/precautions", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "note"] },
|
||||
{ label: TEXT.menu.knowledgeSupportFiles, path: "/knowledge/support-files", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "support"] },
|
||||
{ label: TEXT.menu.knowledgeInstructionFiles, path: "/knowledge/instruction-files", group: TEXT.menu.sharedLibrary, keywords: ["knowledge", "instruction"] },
|
||||
];
|
||||
items.push(...projectItems.filter((item) => canAccessProjectPath(item.path)));
|
||||
}
|
||||
|
||||
return items;
|
||||
});
|
||||
|
||||
const refreshDesktopRoutePreferences = () => {
|
||||
if (!isDesktop) return;
|
||||
desktopRecentRoutes.value = readDesktopRecentRoutes().filter((item) =>
|
||||
desktopNavigationItems.value.some((navItem) => navItem.path === item.path),
|
||||
);
|
||||
desktopFavoriteRoutes.value = readDesktopFavoriteRoutes().filter((item) =>
|
||||
desktopNavigationItems.value.some((navItem) => navItem.path === item.path),
|
||||
);
|
||||
};
|
||||
|
||||
const currentDesktopRoutePreference = computed(() => {
|
||||
const activePath = activeMenu.value;
|
||||
const item = desktopNavigationItems.value.find((navItem) => navItem.path === activePath);
|
||||
if (!item) return null;
|
||||
return { path: item.path, title: item.label, group: item.group };
|
||||
});
|
||||
|
||||
const currentRouteFavorited = computed(() => {
|
||||
const current = currentDesktopRoutePreference.value;
|
||||
return Boolean(current && desktopFavoriteRoutes.value.some((item) => item.path === current.path));
|
||||
});
|
||||
|
||||
const desktopConnectionLabel = computed(() => (desktopServerUrl.value ? "已连接" : "未配置"));
|
||||
const desktopConnectionClass = computed(() => (desktopServerUrl.value ? "is-connected" : "is-warning"));
|
||||
|
||||
const updateDesktopServerUrl = () => {
|
||||
desktopServerUrl.value = getDesktopServerUrl();
|
||||
};
|
||||
|
||||
const openCommandPalette = () => {
|
||||
if (!isDesktop) return;
|
||||
commandPaletteVisible.value = true;
|
||||
};
|
||||
|
||||
const openDesktopPreferences = () => {
|
||||
if (!isDesktop) return;
|
||||
desktopPreferencesVisible.value = true;
|
||||
};
|
||||
|
||||
const toggleCurrentFavorite = () => {
|
||||
const current = currentDesktopRoutePreference.value;
|
||||
if (!current) return;
|
||||
desktopFavoriteRoutes.value = toggleDesktopFavoriteRoute(current);
|
||||
refreshDesktopRoutePreferences();
|
||||
};
|
||||
|
||||
const refreshCurrentDesktopView = () => {
|
||||
const handled = dispatchDesktopRefreshCurrentView();
|
||||
if (!handled) router.go(0);
|
||||
};
|
||||
|
||||
const handleDesktopMenuCommand = (command: string) => {
|
||||
if (command === "ctms.desktop.commandPalette") {
|
||||
openCommandPalette();
|
||||
return;
|
||||
}
|
||||
if (command === "ctms.desktop.preferences") {
|
||||
openDesktopPreferences();
|
||||
return;
|
||||
}
|
||||
if (command === "ctms.desktop.serverSettings") {
|
||||
router.push("/desktop/server-settings");
|
||||
return;
|
||||
}
|
||||
if (command === "ctms.desktop.refresh") {
|
||||
refreshCurrentDesktopView();
|
||||
return;
|
||||
}
|
||||
if (command === "ctms.desktop.back") {
|
||||
router.back();
|
||||
return;
|
||||
}
|
||||
if (command === "ctms.desktop.forward") {
|
||||
router.forward();
|
||||
}
|
||||
};
|
||||
|
||||
const desktopCommands = computed<DesktopCommand[]>(() => {
|
||||
const navigationCommands: DesktopCommand[] = desktopNavigationItems.value.map((item) => ({
|
||||
id: `route:${item.path}`,
|
||||
title: item.label,
|
||||
group: item.group,
|
||||
keywords: item.keywords,
|
||||
visible: true,
|
||||
run: () => {
|
||||
void router.push(item.path);
|
||||
},
|
||||
}));
|
||||
|
||||
const projectCommands: DesktopCommand[] = studies.value.map((item) => ({
|
||||
id: `study:${item.id}`,
|
||||
title: `切换项目:${item.name}`,
|
||||
group: "项目",
|
||||
keywords: [item.name, item.project_no, item.protocol_no].filter(Boolean),
|
||||
visible: Boolean(item?.id),
|
||||
run: async () => {
|
||||
study.setCurrentStudy(item);
|
||||
await study.loadCurrentStudyPermissions().catch(() => {});
|
||||
await router.push("/project/overview");
|
||||
},
|
||||
}));
|
||||
|
||||
return [
|
||||
{
|
||||
id: "desktop:refresh",
|
||||
title: "刷新当前视图",
|
||||
group: "桌面",
|
||||
shortcut: "⌘R",
|
||||
visible: true,
|
||||
run: refreshCurrentDesktopView,
|
||||
},
|
||||
{
|
||||
id: "desktop:preferences",
|
||||
title: "打开桌面偏好",
|
||||
group: "桌面",
|
||||
shortcut: "⌘,",
|
||||
visible: true,
|
||||
run: openDesktopPreferences,
|
||||
},
|
||||
{
|
||||
id: "desktop:server-settings",
|
||||
title: "服务器设置",
|
||||
group: "桌面",
|
||||
visible: true,
|
||||
run: () => {
|
||||
void router.push("/desktop/server-settings");
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "desktop:update",
|
||||
title: "检查桌面更新",
|
||||
group: "桌面",
|
||||
visible: true,
|
||||
run: async () => {
|
||||
await checkDesktopUpdateAndPrompt({ notifyWhenCurrent: true });
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "desktop:favorite-current",
|
||||
title: currentRouteFavorited.value ? "取消收藏当前模块" : "收藏当前模块",
|
||||
group: "桌面",
|
||||
visible: Boolean(currentDesktopRoutePreference.value),
|
||||
run: toggleCurrentFavorite,
|
||||
},
|
||||
...navigationCommands,
|
||||
...projectCommands,
|
||||
];
|
||||
});
|
||||
|
||||
const loadStudies = async () => {
|
||||
try {
|
||||
const { data } = await fetchStudies();
|
||||
@@ -802,6 +1110,13 @@ const handleReminderCommand = (cmd: string) => {
|
||||
|
||||
watch(() => route.path, () => {
|
||||
study.setViewContext(null);
|
||||
if (isDesktop) {
|
||||
const current = currentDesktopRoutePreference.value;
|
||||
if (current) {
|
||||
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
|
||||
refreshDesktopRoutePreferences();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
@@ -809,9 +1124,15 @@ watch(
|
||||
() => {
|
||||
loadHeaderOverviewStats();
|
||||
loadHeaderReminders();
|
||||
refreshDesktopRoutePreferences();
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => desktopNavigationItems.value.map((item) => item.path).join("|"),
|
||||
() => refreshDesktopRoutePreferences(),
|
||||
);
|
||||
|
||||
const toggleCollapse = () => {
|
||||
isCollapsed.value = !isCollapsed.value;
|
||||
localStorage.setItem("ctms_sidebar_collapsed", isCollapsed.value ? "1" : "0");
|
||||
@@ -851,6 +1172,15 @@ onMounted(async () => {
|
||||
headerClockTimer = window.setInterval(() => {
|
||||
headerClockNow.value = new Date();
|
||||
}, 1000);
|
||||
if (isDesktop) {
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
|
||||
const current = currentDesktopRoutePreference.value;
|
||||
if (current) {
|
||||
desktopRecentRoutes.value = recordDesktopRecentRoute(current);
|
||||
}
|
||||
refreshDesktopRoutePreferences();
|
||||
desktopMenuUnlisten = await listenDesktopMenuCommand(handleDesktopMenuCommand).catch(() => undefined);
|
||||
}
|
||||
|
||||
if (auth.token && !auth.user) {
|
||||
try {
|
||||
@@ -869,6 +1199,8 @@ onBeforeUnmount(() => {
|
||||
if (headerClockTimer) {
|
||||
window.clearInterval(headerClockTimer);
|
||||
}
|
||||
window.removeEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, updateDesktopServerUrl);
|
||||
desktopMenuUnlisten?.();
|
||||
});
|
||||
|
||||
const onCommand = (cmd: string) => {
|
||||
@@ -876,6 +1208,8 @@ const onCommand = (cmd: string) => {
|
||||
onLogout();
|
||||
} else if (cmd === "profile") {
|
||||
profileDialogVisible.value = true;
|
||||
} else if (cmd === "desktopPreferences") {
|
||||
openDesktopPreferences();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -904,6 +1238,27 @@ const beforeProfileDialogClose = async (done: () => void) => {
|
||||
done();
|
||||
}
|
||||
};
|
||||
|
||||
useDesktopShortcuts(
|
||||
() => isDesktop,
|
||||
{
|
||||
openCommandPalette,
|
||||
closeActiveLayer: () => {
|
||||
if (commandPaletteVisible.value) {
|
||||
commandPaletteVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
if (desktopPreferencesVisible.value) {
|
||||
desktopPreferencesVisible.value = false;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
navigateBack: () => router.back(),
|
||||
navigateForward: () => router.forward(),
|
||||
refreshCurrentView: refreshCurrentDesktopView,
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -1297,10 +1652,120 @@ const beforeProfileDialogClose = async (done: () => void) => {
|
||||
.header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.desktop-command-trigger {
|
||||
display: inline-grid;
|
||||
grid-template-columns: auto minmax(0, auto) auto;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 32px;
|
||||
max-width: 176px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #d7e2f0;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
color: #475569;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.desktop-command-trigger:hover,
|
||||
.desktop-icon-button:hover,
|
||||
.desktop-connection-button:hover {
|
||||
border-color: #b7c8dd;
|
||||
background: #f8fbff;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.desktop-command-trigger span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.desktop-command-trigger kbd {
|
||||
padding: 1px 5px;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 5px;
|
||||
background: #f8fafc;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.desktop-icon-button,
|
||||
.desktop-connection-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 32px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
}
|
||||
|
||||
.desktop-icon-button {
|
||||
width: 32px;
|
||||
}
|
||||
|
||||
.desktop-connection-button {
|
||||
gap: 7px;
|
||||
padding: 0 9px;
|
||||
border-color: #d7e2f0;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.connection-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.connection-dot.is-connected {
|
||||
background: #16a34a;
|
||||
}
|
||||
|
||||
.connection-label {
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.desktop-connection-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.connection-panel-title {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.connection-panel-row {
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.connection-panel-row code {
|
||||
overflow-wrap: anywhere;
|
||||
color: #0f172a;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -2039,4 +2504,18 @@ const beforeProfileDialogClose = async (done: () => void) => {
|
||||
:global(.profile-settings-dialog .el-dialog__body) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:global(.desktop-preferences-dialog) {
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.desktop-preferences-dialog .el-dialog__header) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
:global(.desktop-preferences-dialog .el-dialog__body) {
|
||||
padding: 20px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
export const DESKTOP_REFRESH_CURRENT_VIEW_EVENT = "ctms:desktop-refresh-current-view";
|
||||
|
||||
export interface DesktopRefreshEventDetail {
|
||||
handled: boolean;
|
||||
}
|
||||
|
||||
export const dispatchDesktopRefreshCurrentView = (): boolean => {
|
||||
if (typeof window === "undefined") return false;
|
||||
const detail: DesktopRefreshEventDetail = { handled: false };
|
||||
window.dispatchEvent(new CustomEvent<DesktopRefreshEventDetail>(DESKTOP_REFRESH_CURRENT_VIEW_EVENT, { detail }));
|
||||
return detail.handled;
|
||||
};
|
||||
|
||||
export const onDesktopRefreshCurrentView = (handler: () => void | Promise<void>): (() => void) => {
|
||||
if (typeof window === "undefined") return () => {};
|
||||
const listener = (event: Event) => {
|
||||
const detail = (event as CustomEvent<DesktopRefreshEventDetail>).detail;
|
||||
if (detail) detail.handled = true;
|
||||
void handler();
|
||||
};
|
||||
window.addEventListener(DESKTOP_REFRESH_CURRENT_VIEW_EVENT, listener);
|
||||
return () => window.removeEventListener(DESKTOP_REFRESH_CURRENT_VIEW_EVENT, listener);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isEditableShortcutTarget } from "./useDesktopShortcuts";
|
||||
|
||||
describe("desktop shortcut target detection", () => {
|
||||
it("does not hijack form controls", () => {
|
||||
const input = document.createElement("input");
|
||||
const textarea = document.createElement("textarea");
|
||||
const editable = document.createElement("div");
|
||||
editable.setAttribute("contenteditable", "true");
|
||||
|
||||
expect(isEditableShortcutTarget(input)).toBe(true);
|
||||
expect(isEditableShortcutTarget(textarea)).toBe(true);
|
||||
expect(isEditableShortcutTarget(editable)).toBe(true);
|
||||
});
|
||||
|
||||
it("allows shortcuts outside editable areas", () => {
|
||||
const button = document.createElement("button");
|
||||
|
||||
expect(isEditableShortcutTarget(button)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { onBeforeUnmount, onMounted } from "vue";
|
||||
|
||||
export interface DesktopShortcutHandlers {
|
||||
openCommandPalette: () => void;
|
||||
closeActiveLayer?: () => boolean;
|
||||
navigateBack?: () => void;
|
||||
navigateForward?: () => void;
|
||||
refreshCurrentView?: () => void;
|
||||
}
|
||||
|
||||
export const isEditableShortcutTarget = (target: EventTarget | null): boolean => {
|
||||
if (!(target instanceof Element)) return false;
|
||||
const tag = target.tagName.toLowerCase();
|
||||
if (["input", "textarea", "select"].includes(tag)) return true;
|
||||
if ((target as HTMLElement).isContentEditable) return true;
|
||||
if (target.getAttribute("contenteditable") === "true") return true;
|
||||
return Boolean(target.closest('[contenteditable="true"], .el-input, .el-textarea, .el-select'));
|
||||
};
|
||||
|
||||
export const useDesktopShortcuts = (enabled: () => boolean, handlers: DesktopShortcutHandlers) => {
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (!enabled() || isEditableShortcutTarget(event.target)) return;
|
||||
const modifier = event.metaKey || event.ctrlKey;
|
||||
const key = event.key.toLowerCase();
|
||||
|
||||
if (!modifier && key === "escape") {
|
||||
if (handlers.closeActiveLayer?.()) event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!modifier) return;
|
||||
|
||||
if (key === "k") {
|
||||
event.preventDefault();
|
||||
handlers.openCommandPalette();
|
||||
return;
|
||||
}
|
||||
if (key === "[") {
|
||||
event.preventDefault();
|
||||
handlers.navigateBack?.();
|
||||
return;
|
||||
}
|
||||
if (key === "]") {
|
||||
event.preventDefault();
|
||||
handlers.navigateForward?.();
|
||||
return;
|
||||
}
|
||||
if (key === "r") {
|
||||
event.preventDefault();
|
||||
handlers.refreshCurrentView?.();
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => window.addEventListener("keydown", onKeydown));
|
||||
onBeforeUnmount(() => window.removeEventListener("keydown", onKeydown));
|
||||
|
||||
return { onKeydown };
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
export const DESKTOP_MENU_COMMAND_EVENT = "ctms:desktop-menu-command";
|
||||
|
||||
export type DesktopMenuCommand =
|
||||
| "ctms.desktop.commandPalette"
|
||||
| "ctms.desktop.preferences"
|
||||
| "ctms.desktop.serverSettings"
|
||||
| "ctms.desktop.refresh"
|
||||
| "ctms.desktop.back"
|
||||
| "ctms.desktop.forward";
|
||||
|
||||
type Unlisten = () => void;
|
||||
|
||||
export const listenDesktopMenuCommand = async (
|
||||
handler: (command: DesktopMenuCommand | string) => void,
|
||||
): Promise<Unlisten> => {
|
||||
if (!isTauriRuntime()) return () => {};
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
return listen<string>(DESKTOP_MENU_COMMAND_EVENT, (event) => {
|
||||
if (typeof event.payload === "string") handler(event.payload);
|
||||
});
|
||||
};
|
||||
@@ -9,6 +9,24 @@ export type DesktopServerUrlValidationResult =
|
||||
|
||||
const LOCAL_HTTP_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
||||
|
||||
const getStorage = (): Storage | null => {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const emitServerUrlChanged = (previous: string | null, current: string | null): void => {
|
||||
if (typeof window === "undefined") return;
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(DESKTOP_SERVER_URL_CHANGED_EVENT, {
|
||||
detail: { previous, current },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const normalizeDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => {
|
||||
const raw = value.trim();
|
||||
if (!raw) return { ok: false, reason: "请输入服务器地址" };
|
||||
@@ -34,7 +52,7 @@ export const normalizeDesktopServerUrl = (value: string): DesktopServerUrlValida
|
||||
};
|
||||
|
||||
export const getDesktopServerUrl = (): string | null => {
|
||||
const stored = window.localStorage.getItem(DESKTOP_SERVER_URL_KEY);
|
||||
const stored = getStorage()?.getItem(DESKTOP_SERVER_URL_KEY);
|
||||
if (!stored) return null;
|
||||
const result = normalizeDesktopServerUrl(stored);
|
||||
return result.ok ? result.url : null;
|
||||
@@ -45,24 +63,18 @@ export const hasDesktopServerUrl = (): boolean => Boolean(getDesktopServerUrl())
|
||||
export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => {
|
||||
const result = normalizeDesktopServerUrl(value);
|
||||
if (!result.ok) return result;
|
||||
const storage = getStorage();
|
||||
if (!storage) return { ok: false, reason: "当前环境无法保存服务器地址" };
|
||||
const previous = getDesktopServerUrl();
|
||||
window.localStorage.setItem(DESKTOP_SERVER_URL_KEY, result.url);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(DESKTOP_SERVER_URL_CHANGED_EVENT, {
|
||||
detail: { previous, current: result.url },
|
||||
}),
|
||||
);
|
||||
storage.setItem(DESKTOP_SERVER_URL_KEY, result.url);
|
||||
emitServerUrlChanged(previous, result.url);
|
||||
return result;
|
||||
};
|
||||
|
||||
export const clearDesktopServerUrl = (): void => {
|
||||
const previous = getDesktopServerUrl();
|
||||
window.localStorage.removeItem(DESKTOP_SERVER_URL_KEY);
|
||||
window.dispatchEvent(
|
||||
new CustomEvent(DESKTOP_SERVER_URL_CHANGED_EVENT, {
|
||||
detail: { previous, current: null },
|
||||
}),
|
||||
);
|
||||
getStorage()?.removeItem(DESKTOP_SERVER_URL_KEY);
|
||||
emitServerUrlChanged(previous, null);
|
||||
};
|
||||
|
||||
export const shouldRequireDesktopServerUrl = (): boolean => isTauriRuntime() && !hasDesktopServerUrl();
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DESKTOP_FAVORITE_ROUTES_KEY,
|
||||
DESKTOP_RECENT_ROUTES_KEY,
|
||||
readDesktopFavoriteRoutes,
|
||||
readDesktopRecentRoutes,
|
||||
recordDesktopRecentRoute,
|
||||
toggleDesktopFavoriteRoute,
|
||||
} from "./desktopUiPreferences";
|
||||
|
||||
const installLocalStorageMock = () => {
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => store.set(key, value),
|
||||
removeItem: (key: string) => store.delete(key),
|
||||
clear: () => store.clear(),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
describe("desktop UI preferences", () => {
|
||||
beforeEach(() => {
|
||||
installLocalStorageMock();
|
||||
});
|
||||
|
||||
it("stores only route metadata for recent desktop routes", () => {
|
||||
recordDesktopRecentRoute({ path: "/subjects", title: "受试者", group: "当前项目" });
|
||||
|
||||
expect(readDesktopRecentRoutes()).toEqual([
|
||||
expect.objectContaining({
|
||||
path: "/subjects",
|
||||
title: "受试者",
|
||||
group: "当前项目",
|
||||
}),
|
||||
]);
|
||||
expect(window.localStorage.getItem(DESKTOP_RECENT_ROUTES_KEY)).not.toContain("token");
|
||||
});
|
||||
|
||||
it("deduplicates favorites by path", () => {
|
||||
toggleDesktopFavoriteRoute({ path: "/subjects", title: "受试者" });
|
||||
toggleDesktopFavoriteRoute({ path: "/subjects", title: "受试者" });
|
||||
toggleDesktopFavoriteRoute({ path: "/file-versions", title: "文件版本" });
|
||||
|
||||
expect(readDesktopFavoriteRoutes()).toHaveLength(1);
|
||||
expect(readDesktopFavoriteRoutes()[0].path).toBe("/file-versions");
|
||||
expect(window.localStorage.getItem(DESKTOP_FAVORITE_ROUTES_KEY)).not.toContain("subject_no");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
export const DESKTOP_RECENT_ROUTES_KEY = "ctms_desktop_recent_routes";
|
||||
export const DESKTOP_FAVORITE_ROUTES_KEY = "ctms_desktop_favorite_routes";
|
||||
|
||||
export interface DesktopRoutePreference {
|
||||
path: string;
|
||||
title: string;
|
||||
group?: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const MAX_RECENT_ROUTES = 8;
|
||||
const MAX_FAVORITE_ROUTES = 12;
|
||||
|
||||
const isStorageAvailable = () => typeof window !== "undefined" && typeof window.localStorage !== "undefined";
|
||||
|
||||
const sanitizeRoutePreference = (value: Partial<DesktopRoutePreference> | null | undefined): DesktopRoutePreference | null => {
|
||||
const path = typeof value?.path === "string" ? value.path.trim() : "";
|
||||
const title = typeof value?.title === "string" ? value.title.trim() : "";
|
||||
if (!path.startsWith("/") || !title) return null;
|
||||
return {
|
||||
path,
|
||||
title: title.slice(0, 80),
|
||||
group: typeof value?.group === "string" ? value.group.slice(0, 40) : undefined,
|
||||
updatedAt: typeof value?.updatedAt === "string" ? value.updatedAt : new Date().toISOString(),
|
||||
};
|
||||
};
|
||||
|
||||
const readRoutePreferences = (key: string): DesktopRoutePreference[] => {
|
||||
if (!isStorageAvailable()) return [];
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.map((item) => sanitizeRoutePreference(item))
|
||||
.filter(Boolean) as DesktopRoutePreference[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
const writeRoutePreferences = (key: string, routes: DesktopRoutePreference[]) => {
|
||||
if (!isStorageAvailable()) return;
|
||||
window.localStorage.setItem(key, JSON.stringify(routes));
|
||||
};
|
||||
|
||||
export const readDesktopRecentRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_RECENT_ROUTES_KEY);
|
||||
|
||||
export const readDesktopFavoriteRoutes = (): DesktopRoutePreference[] => readRoutePreferences(DESKTOP_FAVORITE_ROUTES_KEY);
|
||||
|
||||
export const recordDesktopRecentRoute = (route: Pick<DesktopRoutePreference, "path" | "title" | "group">): DesktopRoutePreference[] => {
|
||||
const item = sanitizeRoutePreference({ ...route, updatedAt: new Date().toISOString() });
|
||||
if (!item) return readDesktopRecentRoutes();
|
||||
const next = [
|
||||
item,
|
||||
...readDesktopRecentRoutes().filter((existing) => existing.path !== item.path),
|
||||
].slice(0, MAX_RECENT_ROUTES);
|
||||
writeRoutePreferences(DESKTOP_RECENT_ROUTES_KEY, next);
|
||||
return next;
|
||||
};
|
||||
|
||||
export const isDesktopFavoriteRoute = (path: string): boolean =>
|
||||
readDesktopFavoriteRoutes().some((item) => item.path === path);
|
||||
|
||||
export const toggleDesktopFavoriteRoute = (
|
||||
route: Pick<DesktopRoutePreference, "path" | "title" | "group">,
|
||||
): DesktopRoutePreference[] => {
|
||||
const item = sanitizeRoutePreference({ ...route, updatedAt: new Date().toISOString() });
|
||||
if (!item) return readDesktopFavoriteRoutes();
|
||||
const current = readDesktopFavoriteRoutes();
|
||||
const exists = current.some((existing) => existing.path === item.path);
|
||||
const next = exists
|
||||
? current.filter((existing) => existing.path !== item.path)
|
||||
: [item, ...current].slice(0, MAX_FAVORITE_ROUTES);
|
||||
writeRoutePreferences(DESKTOP_FAVORITE_ROUTES_KEY, next);
|
||||
return next;
|
||||
};
|
||||
@@ -8,6 +8,21 @@ export {
|
||||
setDesktopServerUrl,
|
||||
shouldRequireDesktopServerUrl,
|
||||
} from "./desktopServerConfig";
|
||||
export {
|
||||
DESKTOP_FAVORITE_ROUTES_KEY,
|
||||
DESKTOP_RECENT_ROUTES_KEY,
|
||||
isDesktopFavoriteRoute,
|
||||
readDesktopFavoriteRoutes,
|
||||
readDesktopRecentRoutes,
|
||||
recordDesktopRecentRoute,
|
||||
toggleDesktopFavoriteRoute,
|
||||
type DesktopRoutePreference,
|
||||
} from "./desktopUiPreferences";
|
||||
export {
|
||||
DESKTOP_MENU_COMMAND_EVENT,
|
||||
listenDesktopMenuCommand,
|
||||
type DesktopMenuCommand,
|
||||
} from "./desktopMenu";
|
||||
export { getAppMetadata, getAppMetadataHeaders, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata";
|
||||
export {
|
||||
cleanupTemporaryFiles,
|
||||
|
||||
@@ -16,6 +16,12 @@ let checkTimer: number | null = null;
|
||||
let intervalTimer: number | null = null;
|
||||
let promptVisible = false;
|
||||
|
||||
export type DesktopUpdateCheckStatus = "disabled" | "up-to-date" | "available" | "postponed" | "suppressed" | "failed";
|
||||
|
||||
export interface DesktopUpdateCheckOptions {
|
||||
notifyWhenCurrent?: boolean;
|
||||
}
|
||||
|
||||
const postponeKey = (version: string) => `${POSTPONE_PREFIX}${version}`;
|
||||
|
||||
const getPostponeUntil = (version: string): number => {
|
||||
@@ -46,8 +52,8 @@ const releaseNotes = (update: DesktopUpdateInfo): string => {
|
||||
return lines.join("\n");
|
||||
};
|
||||
|
||||
const promptForUpdate = async (update: DesktopUpdateInfo) => {
|
||||
if (promptVisible || isSuppressed(update.version)) return;
|
||||
const promptForUpdate = async (update: DesktopUpdateInfo): Promise<DesktopUpdateCheckStatus> => {
|
||||
if (promptVisible || isSuppressed(update.version)) return "suppressed";
|
||||
promptVisible = true;
|
||||
try {
|
||||
await ElMessageBox.confirm(releaseNotes(update), "CTMS 桌面端更新", {
|
||||
@@ -57,26 +63,37 @@ const promptForUpdate = async (update: DesktopUpdateInfo) => {
|
||||
type: "info",
|
||||
});
|
||||
await installPendingDesktopUpdate();
|
||||
return "available";
|
||||
} catch (error) {
|
||||
if (error === "cancel" || error === "close") {
|
||||
postponeVersion(update.version);
|
||||
return;
|
||||
return "postponed";
|
||||
}
|
||||
ElMessage.error("桌面端更新安装失败,请稍后重试或联系管理员。");
|
||||
return "failed";
|
||||
} finally {
|
||||
promptVisible = false;
|
||||
}
|
||||
};
|
||||
|
||||
export const checkDesktopUpdateAndPrompt = async () => {
|
||||
if (!isDesktopUpdaterAvailable()) return;
|
||||
export const checkDesktopUpdateAndPrompt = async (
|
||||
options: DesktopUpdateCheckOptions = {},
|
||||
): Promise<DesktopUpdateCheckStatus> => {
|
||||
if (!isDesktopUpdaterAvailable()) {
|
||||
if (options.notifyWhenCurrent) ElMessage.info("当前构建未启用桌面端自动更新");
|
||||
return "disabled";
|
||||
}
|
||||
try {
|
||||
const update = await checkForDesktopUpdate();
|
||||
if (update) {
|
||||
await promptForUpdate(update);
|
||||
return promptForUpdate(update);
|
||||
}
|
||||
if (options.notifyWhenCurrent) ElMessage.success("当前已是最新版本");
|
||||
return "up-to-date";
|
||||
} catch {
|
||||
// 启动和定时检查不打断录入;下一轮继续检查。
|
||||
if (options.notifyWhenCurrent) ElMessage.error("桌面端更新检查失败,请稍后重试或联系管理员。");
|
||||
return "failed";
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { getVisibleDesktopCommands, type DesktopCommand } from "./desktopCommands";
|
||||
|
||||
const commands: DesktopCommand[] = [
|
||||
{
|
||||
id: "visible",
|
||||
title: "受试者",
|
||||
group: "当前项目",
|
||||
keywords: ["subject"],
|
||||
visible: true,
|
||||
run: () => {},
|
||||
},
|
||||
{
|
||||
id: "hidden",
|
||||
title: "系统监控",
|
||||
group: "管理后台",
|
||||
visible: false,
|
||||
run: () => {},
|
||||
},
|
||||
];
|
||||
|
||||
describe("desktop command filtering", () => {
|
||||
it("only returns visible commands", () => {
|
||||
expect(getVisibleDesktopCommands(commands, "").map((item) => item.id)).toEqual(["visible"]);
|
||||
});
|
||||
|
||||
it("matches title, group, and keywords", () => {
|
||||
expect(getVisibleDesktopCommands(commands, "subject").map((item) => item.id)).toEqual(["visible"]);
|
||||
expect(getVisibleDesktopCommands(commands, "当前").map((item) => item.id)).toEqual(["visible"]);
|
||||
expect(getVisibleDesktopCommands(commands, "missing")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
export interface DesktopCommand {
|
||||
id: string;
|
||||
title: string;
|
||||
group: string;
|
||||
keywords?: string[];
|
||||
shortcut?: string;
|
||||
visible: boolean;
|
||||
run: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export const getVisibleDesktopCommands = (commands: DesktopCommand[], query: string): DesktopCommand[] => {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
return commands.filter((command) => {
|
||||
if (!command.visible) return false;
|
||||
if (!normalized) return true;
|
||||
const haystack = [command.title, command.group, command.shortcut, ...(command.keywords || [])]
|
||||
.filter(Boolean)
|
||||
.join(" ")
|
||||
.toLowerCase();
|
||||
return haystack.includes(normalized);
|
||||
});
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/* ═══════════════════════
|
||||
通知与错误提示
|
||||
═══════════════════════ */
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user