feat(desktop): implement phase 2 native capabilities
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-06-30 21:21:55 +08:00
parent 7c721d4e5c
commit 628ff8828b
64 changed files with 3516 additions and 222 deletions
+61
View File
@@ -8,6 +8,12 @@
"name": "ctms-frontend",
"version": "0.1.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-fs": "^2.4.0",
"@tauri-apps/plugin-notification": "^2.3.0",
"@tauri-apps/plugin-opener": "^2.5.0",
"@tauri-apps/plugin-updater": "^2.9.0",
"axios": "^1.6.8",
"date-fns": "^3.6.0",
"echarts": "^6.0.0",
@@ -1355,6 +1361,16 @@
"win32"
]
},
"node_modules/@tauri-apps/api": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.1.tgz",
"integrity": "sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.11.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
@@ -1587,6 +1603,51 @@
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.1.tgz",
"integrity": "sha512-OK1UBXYt+ojcmxMktzzuyonYIFta8CmAASpX+CA+DTGK24KlHjhYI6x2iOJ/TjZF4N7/ACK1oFmEOjIY9IhzOQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-fs": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.5.1.tgz",
"integrity": "sha512-9Lz+Jopp6QyeEWhlpkMx4R/+P9HgR+AVAI4vOZhlT8Xaymtz8iVI/Ov984/XTqgJz/5gz5NretqPB/XEMS3NhQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-opener": {
"version": "2.5.4",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.4.tgz",
"integrity": "sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.11.0"
}
},
"node_modules/@tauri-apps/plugin-updater": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-updater/-/plugin-updater-2.10.1.tgz",
"integrity": "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+6
View File
@@ -19,6 +19,12 @@
"ui:contract": "node scripts/verify-ui-contract.mjs"
},
"dependencies": {
"@tauri-apps/api": "^2.8.0",
"@tauri-apps/plugin-dialog": "^2.4.0",
"@tauri-apps/plugin-fs": "^2.4.0",
"@tauri-apps/plugin-notification": "^2.3.0",
"@tauri-apps/plugin-opener": "^2.5.0",
"@tauri-apps/plugin-updater": "^2.9.0",
"axios": "^1.6.8",
"date-fns": "^3.6.0",
"echarts": "^6.0.0",
+1377 -7
View File
File diff suppressed because it is too large Load Diff
+16
View File
@@ -14,7 +14,23 @@ tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-notification = "2"
tauri-plugin-opener = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
time = { version = "=0.3.36", default-features = false, features = ["std", "parsing", "formatting", "macros"] }
url = "2"
[target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "4.1.2", default-features = false, features = ["v1", "apple-native-keyring-store"] }
[target.'cfg(windows)'.dependencies]
keyring = { version = "4.1.2", default-features = false, features = ["v1", "windows-native-keyring-store"] }
[features]
default = ["custom-protocol"]
+21 -1
View File
@@ -3,5 +3,25 @@
"identifier": "default",
"description": "Default capability for the CTMS desktop window.",
"windows": ["main"],
"permissions": ["core:default"]
"permissions": [
"core:default",
"dialog:allow-open",
"dialog:allow-save",
"fs:allow-read-file",
"fs:allow-write-file",
"fs:allow-remove",
{
"identifier": "fs:scope",
"allow": [
{ "path": "$TEMP/ctms-desktop/**" }
]
},
"notification:default",
{
"identifier": "opener:allow-open-path",
"allow": [
{ "path": "$TEMP/ctms-desktop/**" }
]
}
]
}
+114
View File
@@ -0,0 +1,114 @@
use sha2::{Digest, Sha256};
use url::Url;
const CREDENTIAL_SERVICE: &str = "cn.huapont.ctms.desktop.session";
fn credential_account(server_origin: &str) -> Result<String, String> {
let parsed = Url::parse(server_origin).map_err(|_| "服务器地址格式不正确".to_string())?;
let is_https = parsed.scheme() == "https";
let is_local_http = parsed.scheme() == "http"
&& matches!(
parsed.host_str(),
Some("localhost") | Some("127.0.0.1") | Some("::1")
);
if !is_https && !is_local_http {
return Err("非本地服务必须使用 HTTPS".to_string());
}
if parsed.username() != "" || parsed.password().is_some() {
return Err("服务器地址不能包含凭据".to_string());
}
let origin = parsed.origin().ascii_serialization();
let digest = Sha256::digest(origin.as_bytes());
Ok(format!("{digest:x}"))
}
#[cfg(any(target_os = "macos", windows))]
fn get_entry(server_origin: &str) -> Result<keyring::Entry, String> {
let account = credential_account(server_origin)?;
keyring::Entry::new(CREDENTIAL_SERVICE, &account)
.map_err(|error| format!("无法访问系统凭据库:{error}"))
}
#[tauri::command]
pub async fn credential_get(server_origin: String) -> Result<Option<String>, String> {
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
let entry = get_entry(&server_origin)?;
return match entry.get_password() {
Ok(token) => Ok(Some(token)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(error) => Err(format!("读取系统凭据失败:{error}")),
};
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let _ = credential_account(&server_origin)?;
Err("当前平台不支持系统凭据存储".to_string())
}
})
.await
.map_err(|error| format!("读取系统凭据任务失败:{error}"))?
}
#[tauri::command]
pub async fn credential_set(server_origin: String, token: String) -> Result<(), String> {
if token.trim().is_empty() {
return Err("拒绝保存空凭据".to_string());
}
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
return get_entry(&server_origin)?
.set_password(&token)
.map_err(|error| format!("保存系统凭据失败:{error}"));
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let _ = credential_account(&server_origin)?;
Err("当前平台不支持系统凭据存储".to_string())
}
})
.await
.map_err(|error| format!("保存系统凭据任务失败:{error}"))?
}
#[tauri::command]
pub async fn credential_delete(server_origin: String) -> Result<(), String> {
tauri::async_runtime::spawn_blocking(move || {
#[cfg(any(target_os = "macos", windows))]
{
let entry = get_entry(&server_origin)?;
return match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(error) => Err(format!("删除系统凭据失败:{error}")),
};
}
#[cfg(not(any(target_os = "macos", windows)))]
{
let _ = credential_account(&server_origin)?;
Err("当前平台不支持系统凭据存储".to_string())
}
})
.await
.map_err(|error| format!("删除系统凭据任务失败:{error}"))?
}
#[cfg(test)]
mod tests {
use super::credential_account;
#[test]
fn account_is_stable_for_same_origin() {
assert_eq!(
credential_account("https://ctms.example.com/path").unwrap(),
credential_account("https://ctms.example.com/other").unwrap()
);
}
#[test]
fn rejects_insecure_remote_origin() {
assert!(credential_account("http://ctms.example.com").is_err());
assert!(credential_account("http://localhost:8000").is_ok());
}
}
+25
View File
@@ -1,5 +1,30 @@
mod credentials;
mod updates;
use tauri::Manager;
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
let _ = window.unminimize();
let _ = window.show();
let _ = window.set_focus();
}
}))
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.manage(updates::PendingUpdate::default())
.invoke_handler(tauri::generate_handler![
credentials::credential_get,
credentials::credential_set,
credentials::credential_delete,
updates::desktop_update_check,
updates::desktop_update_install,
])
.run(tauri::generate_context!())
.expect("error while running CTMS desktop application");
}
+116
View File
@@ -0,0 +1,116 @@
use std::sync::Mutex;
use serde::Serialize;
use tauri::{AppHandle, State};
use tauri_plugin_updater::{Update, UpdaterExt};
use url::Url;
#[derive(Default)]
pub struct PendingUpdate(pub Mutex<Option<Update>>);
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DesktopUpdateMetadata {
version: String,
current_version: String,
notes: Option<String>,
date: Option<String>,
}
fn normalized_origin(raw: &str) -> Result<Url, String> {
let parsed = Url::parse(raw).map_err(|_| "invalid server origin".to_string())?;
let scheme = parsed.scheme();
if parsed.username() != "" || parsed.password().is_some() {
return Err("server origin must not include credentials".to_string());
}
let host = parsed
.host_str()
.ok_or_else(|| "server origin must include a host".to_string())?;
let localhost = matches!(host, "localhost" | "127.0.0.1" | "::1");
if scheme != "https" && !(scheme == "http" && localhost) {
return Err("desktop updates require HTTPS outside localhost".to_string());
}
let port = parsed.port().map(|value| format!(":{value}")).unwrap_or_default();
Url::parse(&format!("{scheme}://{host}{port}/")).map_err(|_| "invalid normalized origin".to_string())
}
fn update_endpoint(server_origin: &str) -> Result<Url, String> {
let origin = normalized_origin(server_origin)?;
origin
.join("desktop-updates/stable/latest.json")
.map_err(|_| "invalid update endpoint".to_string())
}
#[tauri::command]
pub async fn desktop_update_check(
app: AppHandle,
pending_update: State<'_, PendingUpdate>,
server_origin: String,
) -> Result<Option<DesktopUpdateMetadata>, String> {
let endpoint = update_endpoint(&server_origin)?;
let update = app
.updater_builder()
.endpoints(vec![endpoint])
.map_err(|err| err.to_string())?
.build()
.map_err(|err| err.to_string())?
.check()
.await
.map_err(|err| err.to_string())?;
let metadata = update.as_ref().map(|update| DesktopUpdateMetadata {
version: update.version.clone(),
current_version: update.current_version.clone(),
notes: update.body.clone(),
date: update.date.map(|value| value.to_string()),
});
let mut guard = pending_update
.0
.lock()
.map_err(|_| "pending update lock poisoned".to_string())?;
*guard = update;
Ok(metadata)
}
#[tauri::command]
pub async fn desktop_update_install(
app: AppHandle,
pending_update: State<'_, PendingUpdate>,
) -> Result<(), String> {
let update = {
let mut guard = pending_update
.0
.lock()
.map_err(|_| "pending update lock poisoned".to_string())?;
guard
.take()
.ok_or_else(|| "there is no pending update".to_string())?
};
update
.download_and_install(|_, _| {}, || {})
.await
.map_err(|err| err.to_string())?;
app.restart();
}
#[cfg(test)]
mod tests {
use super::update_endpoint;
#[test]
fn update_endpoint_uses_fixed_https_path() {
let endpoint = update_endpoint("https://ctms.example.com/app/").unwrap();
assert_eq!(
endpoint.as_str(),
"https://ctms.example.com/desktop-updates/stable/latest.json"
);
}
#[test]
fn update_endpoint_allows_localhost_http_only() {
assert!(update_endpoint("http://localhost:8888").is_ok());
assert!(update_endpoint("http://ctms.example.com").is_err());
}
}
+8 -2
View File
@@ -21,11 +21,17 @@
}
],
"security": {
"csp": null
"csp": "default-src 'self' customprotocol: asset:; connect-src 'self' ipc: http://ipc.localhost https: http://localhost:* http://127.0.0.1:*; img-src 'self' asset: blob: data: https: http://localhost:* http://127.0.0.1:*; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'self' blob:; object-src 'none'; base-uri 'self'; form-action 'self'"
}
},
"bundle": {
"active": true,
"targets": ["app", "dmg"]
"targets": ["app", "dmg"],
"createUpdaterArtifacts": true
},
"plugins": {
"updater": {
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDIwQjI5MUZFMjQ2NUM5QwpSV1NjWEViaUh5a0xBdE52U2Rhb29mZlVYZ3lnWGlDVGs1WE1RUGoyeWtSWW9pNzBnNW9qUGNaaAo="
}
}
}
+4
View File
@@ -8,9 +8,13 @@
<script setup lang="ts">
import { initSessionManager } from "./session/sessionManager";
import { initDesktopNotificationManager } from "./session/desktopNotificationManager";
import { initDesktopUpdateManager } from "./session/desktopUpdateManager";
import SessionTimeoutPrompt from "./components/SessionTimeoutPrompt.vue";
initSessionManager();
initDesktopNotificationManager();
initDesktopUpdateManager();
</script>
<style scoped>
+5
View File
@@ -19,3 +19,8 @@ export const uploadAttachment = (
};
export const deleteAttachment = (attachmentId: string) => apiDelete(`/api/v1/attachments/${attachmentId}`);
export const downloadAttachment = (attachmentId: string) =>
apiGet<Blob>(`/api/v1/attachments/${attachmentId}/download`, {
responseType: "blob",
});
+8
View File
@@ -68,4 +68,12 @@ export const updateProfile = (payload: {
password?: string;
}) => apiPatch<UserMeResponse>("/api/v1/auth/me", payload);
export const uploadAvatar = (file: File) => {
const formData = new FormData();
formData.append("file", file);
return apiPost<UserMeResponse>("/api/v1/auth/me/avatar", formData, {
headers: { "Content-Type": "multipart/form-data" },
});
};
export default api;
+7 -1
View File
@@ -1,6 +1,6 @@
import axios from "axios";
import type { AxiosResponse } from "axios";
import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime";
import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT, getAppMetadataHeaders } from "../runtime";
const authClient = axios.create({
baseURL: clientRuntime.apiBaseUrl(),
@@ -15,6 +15,12 @@ if (typeof window !== "undefined") {
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshAuthClientBaseUrl);
}
authClient.interceptors.request.use((config) => {
config.headers = config.headers || {};
Object.assign(config.headers, getAppMetadataHeaders());
return config;
});
export type ExtendResponse = {
accessToken: string;
expiresAt: string;
+6 -4
View File
@@ -3,7 +3,7 @@ import { ElMessage } from "element-plus";
import { getToken } from "../utils/auth";
import type { ApiError } from "../types/api";
import { TEXT } from "../locales";
import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime";
import { clientRuntime, DESKTOP_SERVER_URL_CHANGED_EVENT, getAppMetadataHeaders } from "../runtime";
const instance: AxiosInstance = axios.create({
baseURL: clientRuntime.apiBaseUrl(),
@@ -25,6 +25,7 @@ export type ApiRequestConfig = AxiosRequestConfig & {
suppressErrorMessage?: boolean;
_retry?: boolean;
_networkRetryCount?: number;
disableNetworkRetry?: boolean;
};
const wait = (ms: number) => new Promise((resolve) => window.setTimeout(resolve, ms));
@@ -44,13 +45,14 @@ const clearInvalidStudyAndNavigate = async () => {
const forceAuthExpiredLogout = async () => {
const { forceLogout, LOGOUT_REASON_AUTH_EXPIRED } = await import("../session/sessionManager");
forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
await forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
};
instance.interceptors.request.use((config: InternalAxiosRequestConfig & ApiRequestConfig) => {
config.headers = config.headers || {};
Object.assign(config.headers, getAppMetadataHeaders());
const token = getToken();
if (token) {
config.headers = config.headers || {};
(config.headers as Record<string, string>).Authorization = `Bearer ${token}`;
}
return config;
@@ -71,7 +73,7 @@ instance.interceptors.response.use(
// 认证相关的错误由具体页面自行处理,避免重复提示
return Promise.reject(error);
}
if (!status && error.config) {
if (!status && error.config && !(error.config as ApiRequestConfig).disableNetworkRetry) {
const config = error.config as ApiRequestConfig;
const retryCount = config._networkRetryCount || 0;
if (retryCount < NETWORK_RETRY_LIMIT) {
+45
View File
@@ -0,0 +1,45 @@
import { apiGet, apiPost, apiPut } from "./axios";
import type { NotificationItem } from "../types/notifications";
export interface DesktopNotificationSubscription {
enabled: boolean;
enabled_at?: string | null;
}
export interface DesktopNotificationClaim {
claim_token?: string | null;
lease_expires_at?: string | null;
items: NotificationItem[];
}
export const getDesktopNotificationSubscription = () =>
apiGet<DesktopNotificationSubscription>("/api/v1/desktop-notifications/subscription", {
suppressErrorMessage: true,
disableNetworkRetry: true,
});
export const setDesktopNotificationSubscription = (enabled: boolean) =>
apiPut<DesktopNotificationSubscription>("/api/v1/desktop-notifications/subscription", { enabled }, {
disableNetworkRetry: true,
});
export const claimDesktopNotifications = (limit = 20) =>
apiPost<DesktopNotificationClaim>("/api/v1/desktop-notifications/claim", { limit }, {
suppressErrorMessage: true,
disableNetworkRetry: true,
});
export const acknowledgeDesktopNotifications = (claimToken: string, deliveredIds: string[]) =>
apiPost<void>("/api/v1/desktop-notifications/ack", {
claim_token: claimToken,
delivered_ids: deliveredIds,
}, {
suppressErrorMessage: true,
disableNetworkRetry: true,
});
export const markDesktopNotificationRead = (distributionId: string) =>
apiPost<void>(`/api/v1/desktop-notifications/${distributionId}/read`, undefined, {
suppressErrorMessage: true,
disableNetworkRetry: true,
});
+2
View File
@@ -71,6 +71,8 @@ export const fetchAccessLogs = (params: {
export const fetchSecurityAccessLogs = (params?: {
status_min?: number;
auth_status?: string;
client_type?: string;
client_version?: string;
page?: number;
page_size?: number;
}) => apiGet<SecurityAccessLogsResponse>(`/api/v1/permission-monitoring/security-logs`, { params });
@@ -1,6 +1,7 @@
import { auditExportColumns } from "./auditExportColumns";
import { formatAuditRows } from "./auditExportFormatter";
import type { AuditEvent } from "..";
import { saveFile } from "../../runtime";
const BOM = "\ufeff";
@@ -19,13 +20,13 @@ export interface AuditExportOptions {
fileName: string;
}
export const exportAuditCsv = (events: AuditEvent[], options: AuditExportOptions) => {
export const exportAuditCsv = async (events: AuditEvent[], options: AuditExportOptions) => {
const rows = formatAuditRows(events);
const csv = buildCsv(rows);
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`;
link.click();
URL.revokeObjectURL(link.href);
await saveFile({
suggestedName: options.fileName.endsWith(".csv") ? options.fileName : `${options.fileName}.csv`,
mimeType: "text/csv;charset=utf-8",
data: blob,
});
};
+4 -4
View File
@@ -817,8 +817,8 @@ const toggleCollapse = () => {
localStorage.setItem("ctms_sidebar_collapsed", isCollapsed.value ? "1" : "0");
};
const logoutImmediately = () => {
auth.logout();
const logoutImmediately = async () => {
await auth.logout();
study.clearCurrentStudy();
router.replace("/login");
};
@@ -838,7 +838,7 @@ const onLogout = async () => {
if (!confirmed) return;
loggingOut.value = true;
try {
forceLogout(LOGOUT_REASON_MANUAL);
await forceLogout(LOGOUT_REASON_MANUAL);
} finally {
window.setTimeout(() => {
loggingOut.value = false;
@@ -856,7 +856,7 @@ onMounted(async () => {
try {
await auth.fetchMe();
} catch {
logoutImmediately();
await logoutImmediately();
return;
}
}
@@ -154,6 +154,7 @@ import type {
} from "@/types/api";
import { Search as SearchIcon } from "@element-plus/icons-vue";
import { useRoleTemplateMeta } from "@/composables/useRoleTemplateMeta";
import { saveFile } from "@/runtime";
const props = withDefaults(defineProps<{ showSecurityLog?: boolean }>(), {
showSecurityLog: false,
@@ -311,7 +312,8 @@ const buildSecurityTerminalLine = (row: SecurityAccessLogItem) => {
const account = row.account_label || "未知账号";
const auth = SECURITY_AUTH_LABELS[row.auth_status] || row.auth_status;
const userAgent = row.user_agent || "-";
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
const client = [row.client_type, row.client_version, row.client_platform].filter(Boolean).join("/") || "unknown";
return `${formatTerminalTime(row.created_at)} [SECURITY] status=${row.status_code} auth_status=${row.auth_status}/${auth} account_label=${account} client=${client} client_ip=${ip} method=${row.method} path=${row.path} ua=${userAgent} cost=${row.elapsed_ms.toFixed(1)}ms`;
};
const terminalLogRows = computed(() =>
@@ -515,14 +517,9 @@ const openSecurityLogDialog = () => {
scrollSecurityTerminalToBottom();
};
const downloadLogFile = (fileName: string, lines: string[]) => {
const downloadLogFile = async (fileName: string, lines: string[]) => {
const blob = new Blob([`${lines.join("\n")}\n`], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = fileName;
link.click();
URL.revokeObjectURL(url);
await saveFile({ suggestedName: fileName, mimeType: "text/plain;charset=utf-8", data: blob });
};
const downloadInterfaceLog = () => {
@@ -70,7 +70,7 @@ const continueSession = () => {
};
const logoutNow = () => {
forceLogout(LOGOUT_REASON_MANUAL);
void forceLogout(LOGOUT_REASON_MANUAL);
};
watch(
+21 -1
View File
@@ -11,9 +11,12 @@
<el-input v-model="contentProxy" type="textarea" rows="3" :placeholder="TEXT.common.placeholders.input" />
<div v-if="allowAttachments" class="upload-row">
<div class="upload-label">{{ TEXT.common.labels.attachments }}</div>
<el-upload v-model:file-list="fileListProxy" :auto-upload="false" multiple list-type="picture" class="thread-upload">
<el-upload v-if="!nativeFiles" v-model:file-list="fileListProxy" :auto-upload="false" multiple list-type="picture" class="thread-upload">
<el-button size="small" class="upload-button">{{ TEXT.common.actions.upload }}</el-button>
</el-upload>
<el-button v-else size="small" class="upload-button" @click="pickNativeAttachments">
{{ TEXT.common.actions.upload }}
</el-button>
</div>
<div class="actions">
<el-button v-if="showCancel" size="small" @click="$emit('cancel')">{{ TEXT.common.actions.cancel }}</el-button>
@@ -26,6 +29,7 @@
import { computed } from "vue";
import type { UploadUserFile } from "element-plus";
import { displayDateTime, displayUser } from "../utils/display";
import { clientRuntime, pickFiles } from "../runtime";
import { TEXT } from "../locales";
const props = defineProps<{
@@ -46,6 +50,7 @@ const emit = defineEmits<{
(e: "cancel"): void;
(e: "clear-quote"): void;
}>();
const nativeFiles = clientRuntime.capabilities().nativeFiles;
const contentProxy = computed({
get: () => props.modelValue,
@@ -58,6 +63,21 @@ const fileListProxy = computed({
});
const quoteContent = (item: any) => (item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback);
const pickNativeAttachments = async () => {
const files = await pickFiles({ multiple: true, title: TEXT.common.labels.attachments });
const existing = new Set(props.fileList.map((item) => `${item.name}:${item.size}`));
const additions: UploadUserFile[] = files
.filter((file) => !existing.has(`${file.name}:${file.size}`))
.map((file) => ({
name: file.name,
size: file.size,
raw: file as any,
status: "ready",
uid: Date.now() + Math.floor(Math.random() * 100000),
}));
emit("update:fileList", [...props.fileList, ...additions]);
};
</script>
<style scoped>
+35 -5
View File
@@ -45,6 +45,9 @@
</template>
<script setup lang="ts">
import { onBeforeUnmount, reactive, watch } from "vue";
import { downloadAttachment } from "../api/attachments";
import { saveFile } from "../runtime";
import { displayDateTime, displayUser } from "../utils/display";
import { TEXT } from "../locales";
@@ -68,13 +71,37 @@ const quoteContent = (item: any) =>
const contentText = (item: any) =>
item?.is_deleted ? TEXT.modules.knowledgeMedicalConsult.quoteDeleted : item?.content || TEXT.common.fallback;
const attachmentUrl = (id: string) => {
const token = localStorage.getItem("ctms_token");
return token ? `/api/v1/attachments/${id}/download?token=${token}` : `/api/v1/attachments/${id}/download`;
const attachmentObjectUrls = reactive<Record<string, string>>({});
const attachmentUrl = (id: string) => attachmentObjectUrls[id] || "";
const revokeAttachmentUrls = () => {
Object.values(attachmentObjectUrls).forEach((url) => URL.revokeObjectURL(url));
Object.keys(attachmentObjectUrls).forEach((key) => delete attachmentObjectUrls[key]);
};
const download = (id: string) => {
window.open(attachmentUrl(id), "_blank");
const loadImageUrls = async () => {
revokeAttachmentUrls();
const files = Object.values(props.attachmentsMap).flat().filter(isImage);
await Promise.all(files.map(async (file) => {
try {
const response = await downloadAttachment(file.id);
attachmentObjectUrls[file.id] = URL.createObjectURL(
new Blob([response.data], { type: response.headers?.["content-type"] || file.content_type }),
);
} catch {
attachmentObjectUrls[file.id] = "";
}
}));
};
const download = async (id: string) => {
const file = Object.values(props.attachmentsMap).flat().find((item) => item.id === id);
const response = await downloadAttachment(id);
await saveFile({
suggestedName: file?.filename || "attachment",
mimeType: response.headers?.["content-type"] || file?.content_type,
data: response.data,
});
};
const isImage = (file: any) => {
@@ -88,6 +115,9 @@ const isHighlighted = (item: any) => {
if (!props.highlightIds?.length) return false;
return props.highlightIds.includes(item?.id);
};
watch(() => props.attachmentsMap, () => void loadImageUrls(), { deep: true, immediate: true });
onBeforeUnmount(revokeAttachmentUrls);
</script>
<style scoped>
@@ -5,6 +5,7 @@
<span>{{ headerTitle }}</span>
<div v-if="canShowUploader && tableUploadGroup" class="immediate-upload">
<el-upload
v-if="!nativeFiles"
:http-request="uploadImmediate"
:show-file-list="false"
:limit="1"
@@ -13,6 +14,9 @@
>
<el-button type="primary" :loading="isImmediateUploading">{{ TEXT.common.actions.upload }}</el-button>
</el-upload>
<el-button v-else type="primary" :loading="isImmediateUploading" @click="pickImmediateNative">
{{ TEXT.common.actions.upload }}
</el-button>
<el-progress v-if="tableProgress > 0 && tableProgress < 100" :percentage="tableProgress" :stroke-width="6" />
</div>
</div>
@@ -41,6 +45,7 @@
{{ TEXT.common.labels.preview }}
</el-button>
<el-button link type="primary" size="small" @click="download(scope.row)">{{ TEXT.common.actions.download }}</el-button>
<el-button link type="primary" size="small" @click="openExternally(scope.row)">打开</el-button>
<el-button
v-if="canDelete(scope.row)"
link
@@ -58,6 +63,7 @@
<div v-else class="upload-grid" :class="{ 'upload-grid--three': uploadCardColumns === 3 }">
<div v-for="group in uploadGroups" :key="group.key" class="attachment-upload-item">
<el-upload
v-if="!nativeFiles"
class="attachment-card-upload"
:show-file-list="false"
multiple
@@ -79,6 +85,21 @@
</div>
</div>
</el-upload>
<div
v-else
class="attachment-upload-card"
:class="{
'is-disabled': !canUploadGroup(group),
'attachment-upload-card--centered': centerUploadCardContent,
}"
@click="pickPendingNative(group)"
>
<div v-if="showUploadCardLabels" class="upload-card-label">{{ group.label }}</div>
<div class="upload-trigger">
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<span class="upload-text">{{ group.uploadText || "点击上传文件" }}</span>
</div>
</div>
<div v-if="pendingMap[group.key]?.length" class="pending-list">
<div
v-for="item in pendingMap[group.key]"
@@ -122,7 +143,7 @@
import { computed, onMounted, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { UploadFilled } from "@element-plus/icons-vue";
import { fetchAttachments, deleteAttachment, uploadAttachment } from "../../api/attachments";
import { fetchAttachments, deleteAttachment, downloadAttachment, uploadAttachment } from "../../api/attachments";
import { formatFileSize } from "./attachmentUtils";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
@@ -131,6 +152,7 @@ import { displayDateTime, getUserDisplayName } from "../../utils/display";
import { getAttachmentPermissionKey } from "../../utils/attachmentPermissions";
import { isApiPermissionAllowed } from "../../utils/apiPermissionValue";
import { TEXT } from "../../locales";
import { clientRuntime, openFile, pickFiles, saveFile } from "../../runtime";
type AttachmentEntityGroup = {
entityType: string;
@@ -182,6 +204,7 @@ const previewType = ref<"image" | "pdf" | "other">("other");
const previewTitle = ref(TEXT.common.labels.preview);
const previewError = ref("");
const previewLoading = ref(false);
const nativeFiles = clientRuntime.capabilities().nativeFiles;
const headerTitle = computed(() => props.title || TEXT.common.labels.attachments);
const displayMode = computed(() => props.mode || "table");
const maxSize = computed(() => props.maxSizeMb ?? 50);
@@ -240,10 +263,9 @@ const validateFile = (file: File) => {
const pendingFileKey = (file: File) => `${file.name}-${file.size}-${file.lastModified}`;
const queuePendingUpload = (fileType: string, uploadFile: any) => {
const queuePendingFile = (fileType: string, file: File) => {
const group = uploadGroups.value.find((item) => item.key === fileType);
if (!group || !canUploadGroup(group)) return;
const file = uploadFile?.raw as File | undefined;
if (!file || !validateFile(file)) return;
const next = pendingMap[fileType] || [];
if (!next.some((item) => pendingFileKey(item.file) === pendingFileKey(file))) {
@@ -251,6 +273,17 @@ const queuePendingUpload = (fileType: string, uploadFile: any) => {
}
};
const queuePendingUpload = (fileType: string, uploadFile: any) => {
const file = uploadFile?.raw as File | undefined;
if (file) queuePendingFile(fileType, file);
};
const pickPendingNative = async (group: UploadGroup) => {
if (!canUploadGroup(group)) return;
const selected = await pickFiles({ multiple: true, title: group.label });
selected.forEach((file) => queuePendingFile(group.key, file));
};
const removePendingUpload = (fileType: string, item: PendingUploadItem) => {
pendingMap[fileType] = (pendingMap[fileType] || []).filter((current) => pendingFileKey(current.file) !== pendingFileKey(item.file));
};
@@ -317,6 +350,11 @@ const uploadImmediate = async (options: any) => {
}
};
const pickImmediateNative = async () => {
const [file] = await pickFiles({ multiple: false, title: headerTitle.value });
if (file) await uploadImmediate({ file });
};
const load = async () => {
if (!props.studyId || !props.entityId) return;
loading.value = true;
@@ -352,25 +390,35 @@ const uploaderLabel = (row: any) => {
return row.uploaded_by_id || row.uploaded_by || TEXT.common.fallback;
};
const getDownloadUrl = (row: any) => {
const token = localStorage.getItem("ctms_token");
return row?.url || (token ? `/api/v1/attachments/${row.id}/download?token=${token}` : `/api/v1/attachments/${row.id}/download`);
const fetchAttachmentBlob = async (row: any): Promise<Blob> => {
const response = await downloadAttachment(row.id);
return new Blob([response.data], {
type: response.headers?.["content-type"] || row?.content_type || "application/octet-stream",
});
};
const getPreviewUrl = (row: any) => {
const token = localStorage.getItem("ctms_token");
return token ? `/api/v1/attachments/${row.id}/preview?token=${token}` : `/api/v1/attachments/${row.id}/preview`;
const download = async (row: any) => {
try {
await saveFile({
suggestedName: row?.filename || "download",
mimeType: row?.content_type,
data: await fetchAttachmentBlob(row),
});
} catch {
ElMessage.error(TEXT.common.messages.downloadFailed);
}
};
const download = (row: any) => {
const url = getDownloadUrl(row);
const link = document.createElement("a");
link.href = url;
link.download = row?.filename || "download";
link.rel = "noopener";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
const openExternally = async (row: any) => {
try {
await openFile({
suggestedName: row?.filename || "attachment",
mimeType: row?.content_type,
data: await fetchAttachmentBlob(row),
});
} catch {
ElMessage.error(TEXT.common.messages.previewNotSupported);
}
};
const getFileName = (row: any) => (row?.filename || "").toLowerCase();
@@ -386,37 +434,29 @@ const detectPreviewType = (row: any) => {
return "other";
};
const previewImage = async (downloadUrl: string) => {
const previewImage = async (row: any) => {
previewLoading.value = true;
try {
const token = localStorage.getItem("ctms_token");
const headers: Record<string, string> = {};
if (token) headers.Authorization = `Bearer ${token}`;
const response = await fetch(downloadUrl, { headers });
if (!response.ok) throw new Error("preview failed");
const blob = await response.blob();
const blob = await fetchAttachmentBlob(row);
if (previewObjectUrl.value) URL.revokeObjectURL(previewObjectUrl.value);
previewObjectUrl.value = URL.createObjectURL(blob);
previewUrl.value = previewObjectUrl.value;
} catch {
previewUrl.value = downloadUrl;
previewError.value = TEXT.common.messages.previewNotSupported;
} finally {
previewLoading.value = false;
}
};
const preview = (row: any) => {
const preview = async (row: any) => {
previewError.value = "";
previewType.value = detectPreviewType(row);
previewTitle.value = row?.filename || TEXT.common.labels.preview;
const downloadUrl = getDownloadUrl(row);
if (previewType.value === "image") {
if (previewType.value === "image" || previewType.value === "pdf") {
previewUrl.value = "";
previewImage(downloadUrl);
} else if (previewType.value === "pdf") {
previewUrl.value = getPreviewUrl(row);
await previewImage(row);
} else {
previewUrl.value = downloadUrl;
previewUrl.value = "";
}
if (previewType.value === "other") {
previewError.value = TEXT.common.messages.previewNotSupported;
+7 -1
View File
@@ -12,9 +12,15 @@ import App from "./App.vue";
import router from "./router";
import { getToken } from "./utils/auth";
import { useStudyStore } from "./store/study";
import { shouldRequireDesktopServerUrl } from "./runtime";
import { cleanupTemporaryFiles, initializeSecureSessionStorage, shouldRequireDesktopServerUrl } from "./runtime";
const bootstrap = async () => {
await initializeSecureSessionStorage().catch((error) => {
console.error("Secure session storage initialization failed", error);
});
await cleanupTemporaryFiles().catch((error) => {
console.warn("Desktop temporary file cleanup failed", error);
});
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
+1 -1
View File
@@ -527,7 +527,7 @@ router.beforeEach(async (to, _from, next) => {
await auth.fetchMe();
} catch {
// 有 token 但无法获取用户,强制回登录,避免进入“无用户上下文”页面
auth.logout();
await auth.logout();
next({ path: "/login" });
return;
}
+11
View File
@@ -24,3 +24,14 @@ export const getAppMetadata = (): AppMetadata => ({
clientType: isTauriRuntime() ? "desktop" : "web",
platform: getRuntimePlatform(),
});
export const getAppMetadataHeaders = (): Record<string, string> => {
const metadata = getAppMetadata();
return {
"X-CTMS-Client-Type": metadata.clientType,
"X-CTMS-Client-Version": metadata.version,
"X-CTMS-Client-Platform": metadata.platform,
"X-CTMS-Build-Channel": metadata.channel,
"X-CTMS-Build-Commit": metadata.commit,
};
};
+32 -4
View File
@@ -1,6 +1,16 @@
import { resolveApiBaseUrl } from "./apiBaseUrl";
import { getAppMetadata } from "./appMetadata";
import * as files from "./files";
import * as notifications from "./notifications";
import * as updates from "./updates";
import { isTauriRuntime } from "./platform";
import {
clearSessionToken,
getSessionToken,
initializeSecureSessionStorage,
isSecureSessionStorageAvailable,
setSessionToken,
} from "./secureSessionStorage";
export interface RuntimeCapabilities {
serverConfiguration: boolean;
@@ -14,16 +24,34 @@ export interface ClientRuntime {
apiBaseUrl(): string;
metadata: typeof getAppMetadata;
capabilities(): RuntimeCapabilities;
secureSessionStorage: {
initialize: typeof initializeSecureSessionStorage;
get: typeof getSessionToken;
set: typeof setSessionToken;
clear: typeof clearSessionToken;
};
files: typeof files;
notifications: typeof notifications;
updates: typeof updates;
}
export const clientRuntime: ClientRuntime = {
apiBaseUrl: resolveApiBaseUrl,
metadata: getAppMetadata,
secureSessionStorage: {
initialize: initializeSecureSessionStorage,
get: getSessionToken,
set: setSessionToken,
clear: clearSessionToken,
},
files,
notifications,
updates,
capabilities: () => ({
serverConfiguration: isTauriRuntime(),
nativeFiles: false,
systemNotifications: false,
secureSessionStorage: false,
automaticUpdates: false,
nativeFiles: isTauriRuntime(),
systemNotifications: isTauriRuntime(),
secureSessionStorage: isTauriRuntime() && isSecureSessionStorageAvailable(),
automaticUpdates: updates.isDesktopUpdaterAvailable(),
}),
};
+145
View File
@@ -0,0 +1,145 @@
import { isTauriRuntime } from "./platform";
export interface FilePickerOptions {
multiple?: boolean;
accept?: string[];
title?: string;
}
export interface FileOutput {
suggestedName: string;
mimeType?: string;
data: Blob | ArrayBuffer | Uint8Array | string;
}
export type SaveFileResult = "saved" | "cancelled";
const sanitizeFileName = (value: string): string => {
const normalized = value.replace(/[<>:"/\\|?*\u0000-\u001f]/g, "_").replace(/^\.+/, "").trim();
return normalized.slice(0, 180) || "download";
};
const extensionFilters = (accept: string[] | undefined) => {
if (!accept?.length) return undefined;
const extensions = accept
.map((value) => value.trim().replace(/^\./, ""))
.filter((value) => value && !value.includes("/") && /^[a-zA-Z0-9]+$/.test(value));
return extensions.length ? [{ name: "可选文件", extensions }] : undefined;
};
const toBlobPart = (data: FileOutput["data"]): BlobPart => {
if (typeof data === "string" || data instanceof Blob || data instanceof ArrayBuffer) return data;
const copy = new Uint8Array(data.byteLength);
copy.set(data);
return copy;
};
const toBlob = (output: FileOutput): Blob =>
output.data instanceof Blob
? output.data
: new Blob([toBlobPart(output.data)], { type: output.mimeType || "application/octet-stream" });
const toBytes = async (output: FileOutput): Promise<Uint8Array> =>
new Uint8Array(await toBlob(output).arrayBuffer());
const webPickFiles = (options: FilePickerOptions): Promise<File[]> =>
new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.multiple = Boolean(options.multiple);
input.accept = options.accept?.map((item) => (item.includes("/") ? item : `.${item.replace(/^\./, "")}`)).join(",") || "";
input.style.display = "none";
input.addEventListener(
"change",
() => {
resolve(Array.from(input.files || []));
input.remove();
},
{ once: true },
);
document.body.appendChild(input);
input.click();
});
export const pickFiles = async (options: FilePickerOptions = {}): Promise<File[]> => {
if (!isTauriRuntime()) return webPickFiles(options);
const [{ open }, { readFile }] = await Promise.all([
import("@tauri-apps/plugin-dialog"),
import("@tauri-apps/plugin-fs"),
]);
const selected = await open({
title: options.title,
multiple: Boolean(options.multiple),
directory: false,
filters: extensionFilters(options.accept),
});
const paths = selected ? (Array.isArray(selected) ? selected : [selected]) : [];
return Promise.all(
paths.map(async (path) => {
const bytes = await readFile(path);
const name = path.split(/[\\/]/).pop() || "upload";
return new File([bytes], name, { lastModified: Date.now() });
}),
);
};
export const saveFile = async (output: FileOutput): Promise<SaveFileResult> => {
const suggestedName = sanitizeFileName(output.suggestedName);
if (!isTauriRuntime()) {
const url = URL.createObjectURL(toBlob(output));
const link = document.createElement("a");
link.href = url;
link.download = suggestedName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
return "saved";
}
const [{ save }, { writeFile }] = await Promise.all([
import("@tauri-apps/plugin-dialog"),
import("@tauri-apps/plugin-fs"),
]);
const path = await save({ defaultPath: suggestedName });
if (!path) return "cancelled";
await writeFile(path, await toBytes(output));
return "saved";
};
export const openFile = async (output: FileOutput): Promise<void> => {
if (!isTauriRuntime()) {
const url = URL.createObjectURL(toBlob(output));
const link = document.createElement("a");
link.href = url;
link.target = "_blank";
link.rel = "noopener";
document.body.appendChild(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 60_000);
return;
}
const [{ tempDir, join }, { mkdir, writeFile }, { openPath }] = await Promise.all([
import("@tauri-apps/api/path"),
import("@tauri-apps/plugin-fs"),
import("@tauri-apps/plugin-opener"),
]);
const root = await join(await tempDir(), "ctms-desktop");
const directory = await join(root, crypto.randomUUID());
await mkdir(directory, { recursive: true });
const path = await join(directory, sanitizeFileName(output.suggestedName));
await writeFile(path, await toBytes(output));
await openPath(path);
};
export const cleanupTemporaryFiles = async (): Promise<void> => {
if (!isTauriRuntime()) return;
const [{ tempDir, join }, { exists, remove }] = await Promise.all([
import("@tauri-apps/api/path"),
import("@tauri-apps/plugin-fs"),
]);
const root = await join(await tempDir(), "ctms-desktop");
if (await exists(root)) await remove(root, { recursive: true });
};
+29 -1
View File
@@ -8,5 +8,33 @@ export {
setDesktopServerUrl,
shouldRequireDesktopServerUrl,
} from "./desktopServerConfig";
export { getAppMetadata, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata";
export { getAppMetadata, getAppMetadataHeaders, type AppMetadata, type BuildChannel, type ClientType } from "./appMetadata";
export {
cleanupTemporaryFiles,
openFile,
pickFiles,
saveFile,
type FileOutput,
type FilePickerOptions,
type SaveFileResult,
} from "./files";
export {
getNotificationPermission,
requestNotificationPermission,
showSystemNotification,
type NotificationPermissionState,
} from "./notifications";
export { getRuntimePlatform, isTauriRuntime, type RuntimePlatform } from "./platform";
export {
clearSessionToken,
getSessionToken,
initializeSecureSessionStorage,
isSecureSessionStorageAvailable,
setSessionToken,
} from "./secureSessionStorage";
export {
checkForDesktopUpdate,
installPendingDesktopUpdate,
isDesktopUpdaterAvailable,
type DesktopUpdateInfo,
} from "./updates";
+25
View File
@@ -0,0 +1,25 @@
import { isTauriRuntime } from "./platform";
export type NotificationPermissionState = "granted" | "denied" | "prompt" | "unsupported";
export const getNotificationPermission = async (): Promise<NotificationPermissionState> => {
if (!isTauriRuntime()) return "unsupported";
const { isPermissionGranted } = await import("@tauri-apps/plugin-notification");
return (await isPermissionGranted()) ? "granted" : "prompt";
};
export const requestNotificationPermission = async (): Promise<NotificationPermissionState> => {
if (!isTauriRuntime()) return "unsupported";
const { isPermissionGranted, requestPermission } = await import("@tauri-apps/plugin-notification");
if (await isPermissionGranted()) return "granted";
return (await requestPermission()) === "granted" ? "granted" : "denied";
};
export const showSystemNotification = async (): Promise<void> => {
if (!isTauriRuntime()) return;
const { sendNotification } = await import("@tauri-apps/plugin-notification");
sendNotification({
title: "CTMS 文件更新",
body: "有新的文件版本待查看",
});
};
@@ -0,0 +1,117 @@
import { getDesktopServerUrl } from "./desktopServerConfig";
import { isTauriRuntime } from "./platform";
const LEGACY_TOKEN_KEY = "ctms_token";
let cachedToken: string | null = null;
let initialized = false;
let activeServerOrigin: string | null = null;
let secureStorageAvailable = false;
const invokeCredential = async <T>(
command: "credential_get" | "credential_set" | "credential_delete",
args: Record<string, string>,
): Promise<T> => {
const { invoke } = await import("@tauri-apps/api/core");
return invoke<T>(command, args);
};
const isUsableJwt = (token: string): boolean => {
const segments = token.split(".");
if (segments.length !== 3) return false;
try {
const base64 = segments[1].replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
const payload = JSON.parse(atob(padded));
return typeof payload.exp !== "number" || payload.exp * 1000 > Date.now();
} catch {
return false;
}
};
export const initializeSecureSessionStorage = async (): Promise<void> => {
if (initialized) return;
initialized = true;
if (!isTauriRuntime()) {
cachedToken = window.localStorage.getItem(LEGACY_TOKEN_KEY);
secureStorageAvailable = true;
return;
}
const legacyToken = window.localStorage.getItem(LEGACY_TOKEN_KEY);
window.localStorage.removeItem(LEGACY_TOKEN_KEY);
activeServerOrigin = getDesktopServerUrl();
if (!activeServerOrigin) {
cachedToken = null;
secureStorageAvailable = true;
return;
}
try {
if (legacyToken && isUsableJwt(legacyToken)) {
await invokeCredential<void>("credential_set", {
serverOrigin: activeServerOrigin,
token: legacyToken,
});
cachedToken = legacyToken;
} else {
cachedToken = await invokeCredential<string | null>("credential_get", {
serverOrigin: activeServerOrigin,
});
}
secureStorageAvailable = true;
} catch (error) {
cachedToken = null;
secureStorageAvailable = false;
throw error;
}
};
export const getSessionToken = (): string | null => {
if (!initialized && !isTauriRuntime() && typeof localStorage !== "undefined") {
return localStorage.getItem(LEGACY_TOKEN_KEY);
}
return cachedToken;
};
export const setSessionToken = async (token: string): Promise<void> => {
if (!token) throw new Error("拒绝保存空 token");
if (!isTauriRuntime()) {
window.localStorage.setItem(LEGACY_TOKEN_KEY, token);
cachedToken = token;
initialized = true;
secureStorageAvailable = true;
return;
}
const serverOrigin = getDesktopServerUrl();
if (!serverOrigin) throw new Error("尚未配置桌面服务器地址");
await invokeCredential<void>("credential_set", { serverOrigin, token });
activeServerOrigin = serverOrigin;
cachedToken = token;
initialized = true;
secureStorageAvailable = true;
};
export const clearSessionToken = async (): Promise<void> => {
cachedToken = null;
if (!isTauriRuntime()) {
if (typeof localStorage !== "undefined") localStorage.removeItem(LEGACY_TOKEN_KEY);
return;
}
const serverOrigin = activeServerOrigin || getDesktopServerUrl();
activeServerOrigin = getDesktopServerUrl();
if (!serverOrigin) return;
await invokeCredential<void>("credential_delete", { serverOrigin });
};
export const isSecureSessionStorageAvailable = (): boolean => secureStorageAvailable;
export const resetSecureSessionStorageForTests = (): void => {
cachedToken = null;
initialized = false;
activeServerOrigin = null;
secureStorageAvailable = false;
};
+39
View File
@@ -0,0 +1,39 @@
import { getDesktopServerUrl } from "./desktopServerConfig";
import { getAppMetadata } from "./appMetadata";
import { isTauriRuntime } from "./platform";
export interface DesktopUpdateInfo {
version: string;
currentVersion: string;
notes?: string | null;
date?: string | null;
}
type TauriCore = typeof import("@tauri-apps/api/core");
const loadTauriCore = async (): Promise<TauriCore | null> => {
if (!isTauriRuntime()) return null;
return import("@tauri-apps/api/core");
};
export const isDesktopUpdaterAvailable = (): boolean => {
if (!isTauriRuntime()) return false;
const metadata = getAppMetadata();
return metadata.channel === "release" || import.meta.env.VITE_ENABLE_DESKTOP_UPDATES === "true";
};
export const checkForDesktopUpdate = async (): Promise<DesktopUpdateInfo | null> => {
if (!isDesktopUpdaterAvailable()) return null;
const serverOrigin = getDesktopServerUrl();
if (!serverOrigin) return null;
const tauri = await loadTauriCore();
if (!tauri) return null;
return tauri.invoke<DesktopUpdateInfo | null>("desktop_update_check", { serverOrigin });
};
export const installPendingDesktopUpdate = async (): Promise<void> => {
if (!isDesktopUpdaterAvailable()) return;
const tauri = await loadTauriCore();
if (!tauri) return;
await tauri.invoke("desktop_update_install");
};
@@ -0,0 +1,76 @@
import {
acknowledgeDesktopNotifications,
claimDesktopNotifications,
getDesktopNotificationSubscription,
} from "../api/desktopNotifications";
import { getToken } from "../utils/auth";
import {
getNotificationPermission,
isTauriRuntime,
showSystemNotification,
} from "../runtime";
const POLL_INTERVAL_MS = 60_000;
const MAX_BACKOFF_MS = 15 * 60_000;
let initialized = false;
let timer: number | null = null;
let failureCount = 0;
const schedule = (delay: number) => {
if (timer !== null) window.clearTimeout(timer);
timer = window.setTimeout(() => void poll(), delay);
};
const poll = async () => {
if (!getToken()) {
failureCount = 0;
schedule(POLL_INTERVAL_MS);
return;
}
try {
const permission = await getNotificationPermission();
if (permission !== "granted") {
schedule(POLL_INTERVAL_MS);
return;
}
const { data: subscription } = await getDesktopNotificationSubscription();
if (!subscription.enabled) {
schedule(POLL_INTERVAL_MS);
return;
}
const { data } = await claimDesktopNotifications();
const deliveredIds: string[] = [];
for (const item of data.items) {
await showSystemNotification();
deliveredIds.push(item.id);
}
if (data.claim_token && deliveredIds.length) {
await acknowledgeDesktopNotifications(data.claim_token, deliveredIds);
}
failureCount = 0;
schedule(POLL_INTERVAL_MS);
} catch {
failureCount += 1;
schedule(Math.min(POLL_INTERVAL_MS * 2 ** failureCount, MAX_BACKOFF_MS));
}
};
export const initDesktopNotificationManager = (): void => {
if (initialized || !isTauriRuntime()) return;
initialized = true;
schedule(5_000);
};
export const triggerDesktopNotificationPoll = (): void => {
if (!initialized) return;
failureCount = 0;
schedule(0);
};
export const stopDesktopNotificationManager = (): void => {
if (timer !== null) window.clearTimeout(timer);
timer = null;
initialized = false;
failureCount = 0;
};
@@ -0,0 +1,105 @@
import { ElMessage, ElMessageBox } from "element-plus";
import {
checkForDesktopUpdate,
installPendingDesktopUpdate,
isDesktopUpdaterAvailable,
type DesktopUpdateInfo,
} from "../runtime";
const INITIAL_CHECK_DELAY_MS = 30_000;
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
const POSTPONE_MS = 24 * 60 * 60 * 1000;
const POSTPONE_PREFIX = "ctms_desktop_update_postponed:";
let initialized = false;
let checkTimer: number | null = null;
let intervalTimer: number | null = null;
let promptVisible = false;
const postponeKey = (version: string) => `${POSTPONE_PREFIX}${version}`;
const getPostponeUntil = (version: string): number => {
try {
const raw = window.localStorage.getItem(postponeKey(version));
return raw ? Number(raw) || 0 : 0;
} catch {
return 0;
}
};
const postponeVersion = (version: string) => {
try {
window.localStorage.setItem(postponeKey(version), String(Date.now() + POSTPONE_MS));
} catch {
/* ignore */
}
};
const isSuppressed = (version: string): boolean => getPostponeUntil(version) > Date.now();
const releaseNotes = (update: DesktopUpdateInfo): string => {
const lines = [`发现 CTMS 桌面端新版本 ${update.version}(当前 ${update.currentVersion})。`];
if (update.notes?.trim()) {
lines.push("", "发布说明:", update.notes.trim());
}
lines.push("", "确认后将下载、验签、安装并重启应用。");
return lines.join("\n");
};
const promptForUpdate = async (update: DesktopUpdateInfo) => {
if (promptVisible || isSuppressed(update.version)) return;
promptVisible = true;
try {
await ElMessageBox.confirm(releaseNotes(update), "CTMS 桌面端更新", {
confirmButtonText: "立即更新并重启",
cancelButtonText: "稍后",
distinguishCancelAndClose: true,
type: "info",
});
await installPendingDesktopUpdate();
} catch (error) {
if (error === "cancel" || error === "close") {
postponeVersion(update.version);
return;
}
ElMessage.error("桌面端更新安装失败,请稍后重试或联系管理员。");
} finally {
promptVisible = false;
}
};
export const checkDesktopUpdateAndPrompt = async () => {
if (!isDesktopUpdaterAvailable()) return;
try {
const update = await checkForDesktopUpdate();
if (update) {
await promptForUpdate(update);
}
} catch {
// 启动和定时检查不打断录入;下一轮继续检查。
}
};
export const initDesktopUpdateManager = () => {
if (initialized || !isDesktopUpdaterAvailable()) return;
initialized = true;
checkTimer = window.setTimeout(() => {
void checkDesktopUpdateAndPrompt();
}, INITIAL_CHECK_DELAY_MS);
intervalTimer = window.setInterval(() => {
void checkDesktopUpdateAndPrompt();
}, CHECK_INTERVAL_MS);
};
export const stopDesktopUpdateManager = () => {
if (checkTimer) {
window.clearTimeout(checkTimer);
checkTimer = null;
}
if (intervalTimer) {
window.clearInterval(intervalTimer);
intervalTimer = null;
}
initialized = false;
promptVisible = false;
};
+14 -14
View File
@@ -1,7 +1,7 @@
import router from "../router";
import { useAuthStore } from "../store/auth";
import { useSessionStore } from "../store/session";
import { getToken, setToken, clearToken } from "../utils/auth";
import { getToken, setToken } from "../utils/auth";
import { extendToken } from "../api/authClient";
import { parseJwtExp } from "./jwt";
@@ -30,7 +30,7 @@ const getTimeoutAt = (session: ReturnType<typeof useSessionStore>) =>
const reconcileSessionState = (now: number = Date.now()) => {
const session = useSessionStore();
if (now >= getTimeoutAt(session)) {
forceLogout(LOGOUT_REASON_TIMEOUT);
void forceLogout(LOGOUT_REASON_TIMEOUT);
return false;
}
return true;
@@ -56,11 +56,11 @@ const handleBroadcast = (message: BroadcastMessage) => {
}
if (message.type === "TOKEN_UPDATED") {
auth.setToken(message.token);
setToken(message.token);
void setToken(message.token);
session.resetActivity();
}
if (message.type === "LOGOUT") {
performLogout(message.reason);
void performLogout(message.reason);
}
};
@@ -156,25 +156,25 @@ export const consumeLogoutReason = () => {
}
};
const performLogout = (reason?: string) => {
const performLogout = async (reason?: string) => {
setLogoutReason(reason);
const auth = useAuthStore();
const session = useSessionStore();
auth.logout();
const logoutPromise = auth.logout();
session.resetActivity();
clearToken();
router.replace("/login");
await logoutPromise;
};
export const forceLogout = (reason?: string) => {
performLogout(reason);
export const forceLogout = async (reason?: string) => {
await performLogout(reason);
broadcast({ type: "LOGOUT", reason });
};
const updateToken = (token: string) => {
const updateToken = async (token: string) => {
const auth = useAuthStore();
auth.setToken(token);
setToken(token);
await setToken(token);
broadcast({ type: "TOKEN_UPDATED", token });
};
@@ -194,15 +194,15 @@ export const extendAccessToken = async (reason: "early" | "response-401") => {
try {
const { data } = await extendToken(token);
session.setLastExtendAt(Date.now());
updateToken(data.accessToken);
await updateToken(data.accessToken);
return { token: data.accessToken, authFailed: false };
} catch (err: any) {
const status = err?.response?.status;
if (status === 401) {
forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
await forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
authFailed = true;
} else if (status === 403) {
forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
await forceLogout(LOGOUT_REASON_AUTH_EXPIRED);
authFailed = true;
}
return { token: null, authFailed };
+1 -1
View File
@@ -92,7 +92,7 @@ describe("auth store logout", () => {
session.recordUserActivity(staleTs);
session.setLastExtendAt(staleTs);
session.showTimeoutWarning(staleTs + 30_000);
auth.logout();
await auth.logout();
expect(session.lastUserActiveAt).toBeGreaterThan(staleTs);
expect(session.lastExtendAt).toBe(0);
+3 -3
View File
@@ -25,7 +25,7 @@ export const useAuthStore = defineStore("auth", () => {
? await devLogin({ email, password })
: await encryptedLogin(email, password);
token.value = data.access_token;
setToken(data.access_token);
await setToken(data.access_token);
useSessionStore().resetActivity();
localStorage.setItem(LAST_LOGIN_EMAIL_KEY, email);
const me = await fetchMeAction();
@@ -62,7 +62,7 @@ export const useAuthStore = defineStore("auth", () => {
return data;
};
const logout = () => {
const logout = async () => {
const studyStore = useStudyStore();
const sessionStore = useSessionStore();
const userKey = user.value?.email || localStorage.getItem(LAST_LOGIN_EMAIL_KEY) || "";
@@ -71,7 +71,7 @@ export const useAuthStore = defineStore("auth", () => {
user.value = null;
forceLogin.value = false;
sessionStore.resetActivity();
clearToken();
await clearToken();
studyStore.clearCurrentStudy();
};
+5
View File
@@ -376,6 +376,11 @@ export interface SecurityAccessLogItem {
ip_city: string;
ip_isp: string;
user_agent: string | null;
client_type: string | null;
client_version: string | null;
client_platform: string | null;
build_channel: string | null;
build_commit: string | null;
auth_status: "ANONYMOUS" | "INVALID_TOKEN" | "AUTHENTICATED" | string;
user_identifier: string | null;
account_label: string;
+4
View File
@@ -8,4 +8,8 @@ export interface NotificationItem {
change_summary?: string | null;
effective_at?: string | null;
created_at: string;
study_id?: string | null;
study_name?: string | null;
delivered_at?: string | null;
read_at?: string | null;
}
+4 -8
View File
@@ -1,11 +1,7 @@
const TOKEN_KEY = "ctms_token";
import { clearSessionToken, getSessionToken, setSessionToken } from "../runtime";
export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY);
export const getToken = (): string | null => getSessionToken();
export const setToken = (token: string): void => {
localStorage.setItem(TOKEN_KEY, token);
};
export const setToken = (token: string): Promise<void> => setSessionToken(token);
export const clearToken = (): void => {
localStorage.removeItem(TOKEN_KEY);
};
export const clearToken = (): Promise<void> => clearSessionToken();
+3 -3
View File
@@ -60,8 +60,8 @@ const checkHealth = async (baseUrl: string) => {
}
};
const clearSessionForServerChange = () => {
auth.logout();
const clearSessionForServerChange = async () => {
await auth.logout();
studyStore.clearCurrentStudy();
};
@@ -83,7 +83,7 @@ const save = async () => {
return;
}
if (previous !== result.url) {
clearSessionForServerChange();
await clearSessionForServerChange();
}
ElMessage.success("服务器连接已确认");
router.replace("/login");
+128 -33
View File
@@ -10,19 +10,9 @@
<div class="avatar-name">{{ form.full_name || TEXT.modules.profile.title }}</div>
<div class="avatar-email">{{ form.email }}</div>
</div>
<el-upload
class="avatar-uploader"
:show-file-list="false"
accept="image/png,image/jpeg,image/gif,image/webp"
action="/api/v1/auth/me/avatar"
name="file"
:headers="uploadHeaders"
:before-upload="beforeAvatarUpload"
:on-success="onAvatarUploaded"
:on-error="onAvatarError"
>
<el-button :icon="Upload" class="upload-button">{{ TEXT.modules.profile.uploadAvatar }}</el-button>
</el-upload>
<el-button :icon="Upload" class="upload-button avatar-uploader" @click="selectAndUploadAvatar">
{{ TEXT.modules.profile.uploadAvatar }}
</el-button>
</div>
</aside>
@@ -74,6 +64,27 @@
</el-form-item>
</section>
<section class="form-section form-section--desktop">
<div class="section-heading">
<span class="section-kicker">Desktop</span>
<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>
</el-form-item>
<el-form-item label="客户端信息">
<div class="client-metadata">
<code>{{ clientMetadataText }}</code>
<el-button size="small" @click="copyClientMetadata">复制</el-button>
</div>
</el-form-item>
</section>
<div class="actions">
<el-button type="primary" :loading="submitting" @click="onSubmit">{{ TEXT.common.actions.save }}</el-button>
</div>
@@ -85,12 +96,22 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue";
import type { FormInstance, FormRules, UploadRawFile } from "element-plus";
import type { FormInstance, FormRules } from "element-plus";
import { ElMessage } from "element-plus";
import { Close, Upload } from "@element-plus/icons-vue";
import { updateProfile, fetchMe } from "../api/auth";
import { updateProfile, fetchMe, uploadAvatar } from "../api/auth";
import {
getDesktopNotificationSubscription,
setDesktopNotificationSubscription,
} from "../api/desktopNotifications";
import { useAuthStore } from "../store/auth";
import { getToken } from "../utils/auth";
import {
getAppMetadata,
isTauriRuntime,
pickFiles,
requestNotificationPermission,
} from "../runtime";
import { triggerDesktopNotificationPoll } from "../session/desktopNotificationManager";
import { TEXT, requiredMessage } from "../locales";
const emit = defineEmits<{
@@ -99,6 +120,16 @@ const emit = defineEmits<{
saved: [];
}>();
const auth = useAuthStore();
const isDesktop = isTauriRuntime();
const clientMetadata = getAppMetadata();
const clientMetadataText = [
`${clientMetadata.clientType} ${clientMetadata.version}`,
clientMetadata.platform,
clientMetadata.channel,
clientMetadata.commit,
].join(" · ");
const desktopNotificationsEnabled = ref(false);
const desktopNotificationLoading = ref(false);
const formRef = ref<FormInstance>();
const submitting = ref(false);
const form = reactive({
@@ -114,9 +145,6 @@ const savedProfile = ref({
clinical_department: "",
});
const avatarPreview = ref<string | undefined>();
const uploadHeaders = computed<Record<string, string>>(() => ({
Authorization: `Bearer ${getToken() || ""}`,
}));
const profileInitial = computed(() => (form.full_name?.charAt(0) || form.email?.charAt(0) || "?").toUpperCase());
const avatarAcceptedTypes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"]);
const hasUnsavedChanges = computed(
@@ -180,6 +208,41 @@ const loadProfile = async () => {
avatarPreview.value = data.avatar_url || undefined;
};
const loadDesktopNotificationSubscription = async () => {
if (!isDesktop) return;
const { data } = await getDesktopNotificationSubscription();
desktopNotificationsEnabled.value = data.enabled;
};
const onDesktopNotificationChange = async (value: string | number | boolean) => {
if (!isDesktop || desktopNotificationLoading.value) return;
desktopNotificationLoading.value = true;
try {
const enable = Boolean(value);
if (enable) {
const permission = await requestNotificationPermission();
if (permission !== "granted") {
desktopNotificationsEnabled.value = false;
ElMessage.warning("系统通知权限未开启,请在系统设置中允许 CTMS 通知");
return;
}
}
const { data } = await setDesktopNotificationSubscription(enable);
desktopNotificationsEnabled.value = data.enabled;
if (data.enabled) triggerDesktopNotificationPoll();
} catch (error: any) {
desktopNotificationsEnabled.value = !Boolean(value);
ElMessage.error(error?.response?.data?.detail || "通知设置保存失败");
} finally {
desktopNotificationLoading.value = false;
}
};
const copyClientMetadata = async () => {
await navigator.clipboard.writeText(clientMetadataText);
ElMessage.success("客户端信息已复制");
};
const onSubmit = async () => {
if (!formRef.value) return;
await formRef.value.validate(async (valid) => {
@@ -211,24 +274,31 @@ const onSubmit = async () => {
});
};
const onAvatarUploaded = async () => {
await auth.fetchMe();
avatarPreview.value = auth.user?.avatar_url || undefined;
ElMessage.success(TEXT.modules.profile.avatarUpdated);
};
const beforeAvatarUpload = (file: UploadRawFile) => {
if (avatarAcceptedTypes.has(file.type)) return true;
ElMessage.error(TEXT.modules.profile.avatarTypeInvalid);
return false;
};
const onAvatarError = (err: any) => {
ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed);
const selectAndUploadAvatar = async () => {
const [file] = await pickFiles({
multiple: false,
accept: ["png", "jpg", "jpeg", "gif", "webp"],
title: TEXT.modules.profile.uploadAvatar,
});
if (!file) return;
const extensionAllowed = /\.(png|jpe?g|gif|webp)$/i.test(file.name);
if (!avatarAcceptedTypes.has(file.type) && !extensionAllowed) {
ElMessage.error(TEXT.modules.profile.avatarTypeInvalid);
return;
}
try {
await uploadAvatar(file);
await auth.fetchMe();
avatarPreview.value = auth.user?.avatar_url || undefined;
ElMessage.success(TEXT.modules.profile.avatarUpdated);
} catch (err: any) {
ElMessage.error(err?.response?.data?.message || TEXT.modules.profile.avatarUploadFailed);
}
};
onMounted(() => {
loadProfile();
loadDesktopNotificationSubscription().catch(() => {});
});
watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: true });
@@ -343,6 +413,31 @@ watch(hasUnsavedChanges, (dirty) => emit("dirty-change", dirty), { immediate: tr
border-top: 1px solid #e5ebf2;
}
.form-section--desktop {
margin-top: 26px;
padding-top: 28px;
border-top: 1px solid #e5ebf2;
}
.desktop-setting-hint {
margin-left: 12px;
color: #7f92ad;
font-size: 12px;
}
.client-metadata {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.client-metadata code {
overflow-wrap: anywhere;
color: #40566f;
font-size: 12px;
}
.section-heading {
margin-bottom: 18px;
padding-left: 112px;
+7 -4
View File
@@ -76,7 +76,7 @@
<el-skeleton v-if="loading.notifications" :rows="3" animated />
<StateEmpty v-else-if="!notifications.length" :description="TEXT.modules.projectOverview.notificationsEmpty" />
<div v-else class="notification-list">
<div class="notification-item" v-for="item in notifications" :key="item.id">
<div class="notification-item" :class="{ 'is-unread': !item.read_at }" v-for="item in notifications" :key="item.id">
<div class="notification-main">
<div class="notification-title">
<span class="notification-doc">{{ item.document_title }}</span>
@@ -87,7 +87,7 @@
<span v-if="item.change_summary" class="notification-summary">{{ item.change_summary }}</span>
</div>
</div>
<el-button link type="primary" @click="openDocument(item.document_id)">
<el-button link type="primary" @click="openDocument(item)">
{{ TEXT.common.actions.view }}
</el-button>
</div>
@@ -111,6 +111,7 @@ import { useAuthStore } from "../store/auth";
import { useStudyStore } from "../store/study";
import { fetchFinanceSummary, fetchOverdueAesCount, fetchProgress } from "../api/dashboard";
import { listNotifications } from "../api/notifications";
import { markDesktopNotificationRead } from "../api/desktopNotifications";
import KpiCard from "../components/KpiCard.vue";
import QuickActions from "../components/QuickActions.vue";
import { List, Warning, Money } from "@element-plus/icons-vue";
@@ -202,8 +203,10 @@ const formatDate = (value?: string | null) => {
return value.replace("T", " ").replace("Z", "").split(".")[0];
};
const openDocument = (documentId: string) => {
router.push(`/documents/${documentId}`);
const openDocument = async (item: NotificationItem) => {
await markDesktopNotificationRead(item.id).catch(() => {});
item.read_at = new Date().toISOString();
router.push(`/documents/${item.document_id}`);
};
onMounted(() => {
+7 -9
View File
@@ -1954,6 +1954,7 @@ import {
type SetupWorkflowTagMeta,
} from "../../utils/setupPublishWorkflow";
import { useSetupConfig } from "../../composables/useSetupConfig";
import { saveFile } from "../../runtime";
type SetupStepKey =
| "project-info"
@@ -5174,7 +5175,7 @@ const buildExcelWorksheetXml = (
return `<Worksheet ss:Name="${excelEscapeXml(sheetName)}"><Table>${metaXml}<Row/>${headerXml}${dataXml}</Table></Worksheet>`;
};
const downloadVersionRaw = (versionItem: StudySetupConfigVersionItem) => {
const downloadVersionRaw = async (versionItem: StudySetupConfigVersionItem) => {
if (!project.value) return;
const displayVersion = getDisplayVersionLabel(versionItem.version);
const branchName = versionItem.branch_name || "main";
@@ -5342,16 +5343,13 @@ ${buildExcelWorksheetXml("第6步-中心确认", metaRows, step6Headers, step6Ro
</Workbook>`;
const blob = new Blob([`\uFEFF${workbookXml}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const safeVersionLabel = displayVersion.replace(/[^\w.-]/g, "_");
const filename = `setup-config-${safeVersionLabel}-${project.value.code || project.value.id}.xls`;
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
await saveFile({
suggestedName: filename,
mimeType: "application/vnd.ms-excel;charset=utf-8",
data: blob,
});
};
const removeVersion = async (targetVersion: number) => {
+29 -13
View File
@@ -123,6 +123,9 @@
<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">
打开
</el-button>
<el-button v-if="canDeleteDocument" link type="danger" size="small" @click="confirmDeleteVersion(row)">
{{ TEXT.common.actions.delete }}
</el-button>
@@ -262,7 +265,6 @@
上传文件
</div>
<div class="upload-zone" :class="{ 'has-file': uploadFile }" @click="triggerFileInput">
<input type="file" ref="fileInputRef" @change="onFileChange" class="file-input-hidden" accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.png,.jpg" />
<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>
@@ -370,6 +372,7 @@ import { useRoleTemplateMeta } from "../../composables/useRoleTemplateMeta";
import { usePermission } from "../../utils/permission";
import StateError from "../../components/StateError.vue";
import StateLoading from "../../components/StateLoading.vue";
import { openFile, pickFiles, saveFile } from "../../runtime";
const route = useRoute();
const auth = useAuthStore();
@@ -494,7 +497,6 @@ const editorDirtyGuard = useDrawerDirtyGuard(() => editorForm);
const uploadVisible = ref(false);
const uploading = ref(false);
const uploadFormRef = ref<FormInstance>();
const fileInputRef = ref<HTMLInputElement | null>(null);
const uploadForm = reactive({
version_no: "",
version_date: "",
@@ -513,8 +515,15 @@ const uploadDirtyGuard = useDrawerDirtyGuard(() => ({
: null,
}));
const triggerFileInput = () => { fileInputRef.value?.click(); };
const removeFile = () => { uploadFile.value = null; if (fileInputRef.value) fileInputRef.value.value = ""; };
const triggerFileInput = async () => {
const [file] = await pickFiles({
multiple: false,
accept: ["pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "png", "jpg", "jpeg"],
title: "选择文档版本",
});
if (file) uploadFile.value = file;
};
const removeFile = () => { uploadFile.value = null; };
const distributeVisible = ref(false);
const distributing = ref(false);
@@ -706,11 +715,6 @@ const openUpload = () => {
uploadVisible.value = true;
};
const onFileChange = (event: Event) => {
const target = event.target as HTMLInputElement;
if (target.files && target.files[0]) uploadFile.value = target.files[0];
};
const submitUpload = async () => {
if (!canUpdateDocument.value) { ElMessage.warning("权限不足"); return; }
if (!uploadFormRef.value) return;
@@ -797,13 +801,25 @@ const downloadVersion = async (version: DocumentVersion) => {
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
const blob = new Blob([response.data], { type: contentType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a"); link.href = url; link.download = filename;
document.body.appendChild(link); link.click(); link.remove();
window.URL.revokeObjectURL(url);
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
} catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed); }
};
const openVersion = async (version: DocumentVersion) => {
try {
const response = await downloadDocumentVersion(version.id);
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || `document-${version.version_no || version.id}.bin`;
await openFile({
suggestedName: filename,
mimeType: contentType,
data: new Blob([response.data], { type: contentType }),
});
} catch (e: any) {
ElMessage.error(e?.response?.data?.message || TEXT.common.messages.downloadFailed);
}
};
const confirmDeleteVersion = async (version: DocumentVersion) => {
if (!version?.id) return;
if (!canDeleteDocument.value) { ElMessage.warning("权限不足"); return; }
@@ -132,20 +132,17 @@
<el-icon><Document /></el-icon>
<span>问题导入模板</span>
</el-button>
<el-upload
<el-button
v-if="canCreateIssue"
ref="importUploadRef"
:auto-upload="false"
:show-file-list="false"
accept=".xlsx,.csv"
:on-change="onImportChange"
:disabled="importing"
class="toolbar-button toolbar-button-warning"
type="warning"
plain
:loading="importing"
@click="selectImportFile"
>
<el-button class="toolbar-button toolbar-button-warning" type="warning" plain :loading="importing">
<el-icon><Upload /></el-icon>
<span>导入问题</span>
</el-button>
</el-upload>
<el-icon><Upload /></el-icon>
<span>导入问题</span>
</el-button>
</div>
<div class="toolbar-icons">
<el-button circle title="刷新" @click="loadIssues">
@@ -455,7 +452,7 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from "vue";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules, type UploadFile, type UploadInstance } from "element-plus";
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from "element-plus";
import { CircleCheck, Delete, Document, Download, Location, Plus, Refresh, Search, Upload } from "@element-plus/icons-vue";
import {
createMonitoringVisitIssue,
@@ -469,6 +466,7 @@ import {
import { fetchSites } from "../../api/sites";
import StateEmpty from "../../components/StateEmpty.vue";
import { TEXT } from "../../locales";
import { pickFiles, saveFile } from "../../runtime";
import { useAuthStore } from "../../store/auth";
import { useStudyStore } from "../../store/study";
import type { Site } from "../../types/api";
@@ -523,7 +521,6 @@ const viewDialogVisible = ref(false);
const formMode = ref<"create" | "edit">("create");
const editingIssueId = ref("");
const createFormRef = ref<FormInstance>();
const importUploadRef = ref<UploadInstance>();
const allItems = ref<MonitoringIssueRow[]>([]);
const viewIssue = ref<MonitoringIssueRow | null>(null);
const sitesLoading = ref(false);
@@ -981,14 +978,7 @@ const handleExportExcel = async () => {
const contentType = response.headers?.["content-type"] || "application/octet-stream";
const filename = getFilename(response.headers?.["content-disposition"]) || "监查访视问题.xlsx";
const blob = new Blob([response.data], { type: contentType });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
await saveFile({ suggestedName: filename, mimeType: contentType, data: blob });
ElMessage.success("导出成功");
} catch (e: any) {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.downloadFailed);
@@ -997,11 +987,9 @@ const handleExportExcel = async () => {
}
};
const onImportChange = async (uploadFile: UploadFile) => {
const importIssueFile = async (file: File) => {
const studyId = study.currentStudy?.id;
if (!studyId || !canCreateIssue.value) return;
const file = uploadFile.raw;
if (!file) return;
importing.value = true;
try {
@@ -1018,10 +1006,18 @@ const onImportChange = async (uploadFile: UploadFile) => {
ElMessage.error(e?.response?.data?.detail || TEXT.common.messages.uploadFailed);
} finally {
importing.value = false;
importUploadRef.value?.clearFiles();
}
};
const selectImportFile = async () => {
const [file] = await pickFiles({
multiple: false,
accept: ["xlsx", "csv"],
title: "导入监查访视问题",
});
if (file) await importIssueFile(file);
};
watch(
() => study.currentStudy?.id,
(studyId) => {