feat(desktop): implement phase 1 tauri client
@@ -1,6 +1,9 @@
|
||||
# Frontend feature flags and timeline overrides
|
||||
VITE_RUNTIME_ENV=production
|
||||
VITE_ALLOW_INSECURE_DEV_LOGIN=false
|
||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||
# Set to 8888 when Vite HMR is accessed through the Docker nginx dev entry.
|
||||
VITE_HMR_CLIENT_PORT=
|
||||
VITE_STARTUP_SUBMIT_ACCEPT_TIMEOUT_MONTHS=3
|
||||
VITE_STARTUP_ACCEPT_TIMEOUT_MONTHS=3
|
||||
VITE_STARTUP_ACCEPT_APPROVAL_TIMEOUT_MONTHS=6
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/node": "^20.10.5",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
@@ -1354,6 +1355,238 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@tauri-apps/cli": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.4.tgz",
|
||||
"integrity": "sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"bin": {
|
||||
"tauri": "tauri.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tauri"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tauri-apps/cli-darwin-arm64": "2.11.4",
|
||||
"@tauri-apps/cli-darwin-x64": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-arm64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-gnu": "2.11.4",
|
||||
"@tauri-apps/cli-linux-x64-musl": "2.11.4",
|
||||
"@tauri-apps/cli-win32-arm64-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-ia32-msvc": "2.11.4",
|
||||
"@tauri-apps/cli-win32-x64-msvc": "2.11.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-arm64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz",
|
||||
"integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-darwin-x64": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz",
|
||||
"integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz",
|
||||
"integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.4.tgz",
|
||||
"integrity": "sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"glibc"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-linux-x64-musl": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz",
|
||||
"integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"libc": [
|
||||
"musl"
|
||||
],
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
|
||||
"version": "2.11.4",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz",
|
||||
"integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0 OR MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri",
|
||||
"desktop:dev": "tauri dev",
|
||||
"desktop:build": "tauri build",
|
||||
"desktop:bundle:dmg": "tauri build -- --bundles dmg",
|
||||
"test:unit": "vitest run --environment jsdom",
|
||||
"type-check": "vue-tsc --noEmit",
|
||||
"ui:contract": "node scripts/verify-ui-contract.mjs"
|
||||
@@ -22,6 +26,7 @@
|
||||
"vue-router": "^4.2.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.11.4",
|
||||
"@types/node": "^20.10.5",
|
||||
"@vitejs/plugin-vue": "^6.0.3",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "ctms-desktop"
|
||||
version = "0.1.0"
|
||||
description = "CTMS desktop client"
|
||||
authors = ["Huapont"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "ctms_desktop_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
time = { version = "=0.3.36", default-features = false, features = ["std", "parsing", "formatting", "macros"] }
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capability for the CTMS desktop window.",
|
||||
"windows": ["main"],
|
||||
"permissions": ["core:default"]
|
||||
}
|
||||
|
After Width: | Height: | Size: 293 B |
|
After Width: | Height: | Size: 659 B |
|
After Width: | Height: | Size: 99 B |
|
After Width: | Height: | Size: 154 B |
|
After Width: | Height: | Size: 233 B |
|
After Width: | Height: | Size: 322 B |
|
After Width: | Height: | Size: 341 B |
|
After Width: | Height: | Size: 756 B |
|
After Width: | Height: | Size: 96 B |
|
After Width: | Height: | Size: 846 B |
|
After Width: | Height: | Size: 114 B |
|
After Width: | Height: | Size: 166 B |
|
After Width: | Height: | Size: 194 B |
|
After Width: | Height: | Size: 123 B |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
@@ -0,0 +1,5 @@
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running CTMS desktop application");
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
ctms_desktop_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CTMS",
|
||||
"version": "0.1.0",
|
||||
"identifier": "cn.huapont.ctms.desktop",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"title": "CTMS",
|
||||
"width": 1440,
|
||||
"height": 900,
|
||||
"minWidth": 1180,
|
||||
"minHeight": 760
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": ["app", "dmg"]
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,21 @@
|
||||
import axios from "axios";
|
||||
import type { AxiosResponse } from "axios";
|
||||
import { resolveApiBaseUrl } from "../runtime/apiBaseUrl";
|
||||
import { DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime/desktopServerConfig";
|
||||
|
||||
const authClient = axios.create({
|
||||
baseURL: "/",
|
||||
baseURL: resolveApiBaseUrl(),
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
export const refreshAuthClientBaseUrl = (): void => {
|
||||
authClient.defaults.baseURL = resolveApiBaseUrl();
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshAuthClientBaseUrl);
|
||||
}
|
||||
|
||||
export type ExtendResponse = {
|
||||
accessToken: string;
|
||||
expiresAt: string;
|
||||
|
||||
@@ -3,12 +3,22 @@ import { ElMessage } from "element-plus";
|
||||
import { getToken } from "../utils/auth";
|
||||
import type { ApiError } from "../types/api";
|
||||
import { TEXT } from "../locales";
|
||||
import { resolveApiBaseUrl } from "../runtime/apiBaseUrl";
|
||||
import { DESKTOP_SERVER_URL_CHANGED_EVENT } from "../runtime/desktopServerConfig";
|
||||
|
||||
const instance: AxiosInstance = axios.create({
|
||||
baseURL: "/",
|
||||
baseURL: resolveApiBaseUrl(),
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
export const refreshApiBaseUrl = (): void => {
|
||||
instance.defaults.baseURL = resolveApiBaseUrl();
|
||||
};
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, refreshApiBaseUrl);
|
||||
}
|
||||
|
||||
const NETWORK_RETRY_LIMIT = 10;
|
||||
const NETWORK_RETRY_DELAY_MS = 30000;
|
||||
|
||||
|
||||
@@ -12,22 +12,27 @@ import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { getToken } from "./utils/auth";
|
||||
import { useStudyStore } from "./store/study";
|
||||
import { shouldRequireDesktopServerUrl } from "./runtime/desktopServerConfig";
|
||||
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
dayjs.locale("zh-cn");
|
||||
app.use(ElementPlus, { locale: zhCn });
|
||||
const bootstrap = async () => {
|
||||
const app = createApp(App);
|
||||
const pinia = createPinia();
|
||||
app.use(pinia);
|
||||
dayjs.locale("zh-cn");
|
||||
app.use(ElementPlus, { locale: zhCn });
|
||||
|
||||
// 初始化项目上下文
|
||||
const studyStore = useStudyStore();
|
||||
studyStore.loadCurrentStudy();
|
||||
if (getToken()) {
|
||||
await studyStore.rehydrateStudyForLastUser();
|
||||
await studyStore.loadCurrentStudyPermissions().catch(() => {});
|
||||
}
|
||||
// 初始化项目上下文
|
||||
const studyStore = useStudyStore();
|
||||
studyStore.loadCurrentStudy();
|
||||
if (getToken() && !shouldRequireDesktopServerUrl()) {
|
||||
await studyStore.rehydrateStudyForLastUser();
|
||||
await studyStore.loadCurrentStudyPermissions().catch(() => {});
|
||||
}
|
||||
|
||||
app.use(router);
|
||||
await router.isReady();
|
||||
app.use(router);
|
||||
await router.isReady();
|
||||
|
||||
app.mount("#app");
|
||||
app.mount("#app");
|
||||
};
|
||||
|
||||
bootstrap();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { RouteLocationNormalized } from "vue-router";
|
||||
import { hasDesktopServerUrl, shouldRequireDesktopServerUrl } from "../runtime/desktopServerConfig";
|
||||
import { isTauriRuntime } from "../runtime/platform";
|
||||
|
||||
const DESKTOP_SETTINGS_PATH = "/desktop/server-settings";
|
||||
|
||||
export const resolveDesktopRouteRedirect = (to: RouteLocationNormalized): string | null => {
|
||||
const isSettingsRoute = to.path === DESKTOP_SETTINGS_PATH;
|
||||
|
||||
if (!isTauriRuntime()) {
|
||||
return isSettingsRoute ? "/login" : null;
|
||||
}
|
||||
|
||||
if (shouldRequireDesktopServerUrl()) {
|
||||
return isSettingsRoute ? null : DESKTOP_SETTINGS_PATH;
|
||||
}
|
||||
|
||||
if (isSettingsRoute || hasDesktopServerUrl()) return null;
|
||||
return DESKTOP_SETTINGS_PATH;
|
||||
};
|
||||
@@ -10,6 +10,7 @@ import Layout from "../components/Layout.vue";
|
||||
import Login from "../views/Login.vue";
|
||||
import Register from "../views/Register.vue";
|
||||
import ForgotPassword from "../views/ForgotPassword.vue";
|
||||
import DesktopServerSettings from "../views/DesktopServerSettings.vue";
|
||||
import StudyHome from "../views/StudyHome.vue";
|
||||
import FaqDetail from "../views/FaqDetail.vue";
|
||||
import AuditLogs from "../views/admin/AuditLogs.vue";
|
||||
@@ -55,6 +56,7 @@ import PrecautionDetail from "../views/knowledge/PrecautionDetail.vue";
|
||||
import SubjectForm from "../views/subjects/SubjectForm.vue";
|
||||
import SubjectDetail from "../views/subjects/SubjectDetail.vue";
|
||||
import { TEXT } from "../locales";
|
||||
import { resolveDesktopRouteRedirect } from "./desktopGuard";
|
||||
|
||||
const SYSTEM_PERMISSION_READ = "system:permissions:read";
|
||||
const SYSTEM_PERMISSION_PROJECT_CONFIG = "system:permissions:project_config";
|
||||
@@ -78,6 +80,12 @@ const routes: RouteRecordRaw[] = [
|
||||
component: ForgotPassword,
|
||||
meta: { public: true, title: TEXT.modules.auth.forgotTitle },
|
||||
},
|
||||
{
|
||||
path: "/desktop/server-settings",
|
||||
name: "DesktopServerSettings",
|
||||
component: DesktopServerSettings,
|
||||
meta: { public: true, title: "服务器设置" },
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
component: Layout,
|
||||
@@ -504,6 +512,12 @@ const ensureProjectPermissionAccess = async (
|
||||
};
|
||||
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const desktopRedirect = resolveDesktopRouteRedirect(to);
|
||||
if (desktopRedirect) {
|
||||
next({ path: desktopRedirect });
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = useAuthStore();
|
||||
const studyStore = useStudyStore();
|
||||
const getToken = () => auth.token;
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { getDesktopServerUrl } from "./desktopServerConfig";
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
export const resolveApiBaseUrl = (): string => {
|
||||
if (!isTauriRuntime()) return "/";
|
||||
return getDesktopServerUrl() || "/";
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
DESKTOP_SERVER_URL_CHANGED_EVENT,
|
||||
DESKTOP_SERVER_URL_KEY,
|
||||
getDesktopServerUrl,
|
||||
normalizeDesktopServerUrl,
|
||||
setDesktopServerUrl,
|
||||
shouldRequireDesktopServerUrl,
|
||||
} from "./desktopServerConfig";
|
||||
|
||||
const setTauriRuntime = (enabled: boolean) => {
|
||||
if (enabled) {
|
||||
Object.defineProperty(window, "__TAURI_INTERNALS__", { value: {}, configurable: true });
|
||||
} else {
|
||||
Reflect.deleteProperty(window, "__TAURI_INTERNALS__");
|
||||
}
|
||||
};
|
||||
|
||||
const createMemoryStorage = (): Storage => {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear: vi.fn(() => store.clear()),
|
||||
getItem: vi.fn((key: string) => store.get(key) ?? null),
|
||||
key: vi.fn((index: number) => Array.from(store.keys())[index] ?? null),
|
||||
removeItem: vi.fn((key: string) => store.delete(key)),
|
||||
setItem: vi.fn((key: string, value: string) => store.set(key, value)),
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(window, "localStorage", {
|
||||
value: createMemoryStorage(),
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.localStorage.clear();
|
||||
setTauriRuntime(false);
|
||||
});
|
||||
|
||||
describe("desktop server config", () => {
|
||||
it("normalizes server URLs to an origin with trailing slash", () => {
|
||||
expect(normalizeDesktopServerUrl("https://ctms.example.com/api")).toEqual({
|
||||
ok: true,
|
||||
url: "https://ctms.example.com/",
|
||||
});
|
||||
});
|
||||
|
||||
it("allows local HTTP but rejects non-local HTTP", () => {
|
||||
expect(normalizeDesktopServerUrl("http://localhost:8000")).toEqual({
|
||||
ok: true,
|
||||
url: "http://localhost:8000/",
|
||||
});
|
||||
expect(normalizeDesktopServerUrl("http://ctms.example.com").ok).toBe(false);
|
||||
});
|
||||
|
||||
it("stores valid desktop server URLs and emits a change event", () => {
|
||||
const listener = vi.fn();
|
||||
window.addEventListener(DESKTOP_SERVER_URL_CHANGED_EVENT, listener);
|
||||
|
||||
const result = setDesktopServerUrl("https://ctms.example.com");
|
||||
|
||||
expect(result).toEqual({ ok: true, url: "https://ctms.example.com/" });
|
||||
expect(window.localStorage.getItem(DESKTOP_SERVER_URL_KEY)).toBe("https://ctms.example.com/");
|
||||
expect(getDesktopServerUrl()).toBe("https://ctms.example.com/");
|
||||
expect(listener).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("requires a server URL only inside the Tauri runtime", () => {
|
||||
expect(shouldRequireDesktopServerUrl()).toBe(false);
|
||||
setTauriRuntime(true);
|
||||
expect(shouldRequireDesktopServerUrl()).toBe(true);
|
||||
setDesktopServerUrl("https://ctms.example.com");
|
||||
expect(shouldRequireDesktopServerUrl()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { isTauriRuntime } from "./platform";
|
||||
|
||||
export const DESKTOP_SERVER_URL_KEY = "ctms_desktop_server_url";
|
||||
export const DESKTOP_SERVER_URL_CHANGED_EVENT = "ctms:desktop-server-url-changed";
|
||||
|
||||
export type DesktopServerUrlValidationResult =
|
||||
| { ok: true; url: string }
|
||||
| { ok: false; reason: string };
|
||||
|
||||
const LOCAL_HTTP_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
|
||||
|
||||
export const normalizeDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => {
|
||||
const raw = value.trim();
|
||||
if (!raw) return { ok: false, reason: "请输入服务器地址" };
|
||||
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
return { ok: false, reason: "服务器地址格式不正确" };
|
||||
}
|
||||
|
||||
if (parsed.username || parsed.password) {
|
||||
return { ok: false, reason: "服务器地址不能包含用户名或密码" };
|
||||
}
|
||||
|
||||
const isHttps = parsed.protocol === "https:";
|
||||
const isLocalHttp = parsed.protocol === "http:" && LOCAL_HTTP_HOSTS.has(parsed.hostname);
|
||||
if (!isHttps && !isLocalHttp) {
|
||||
return { ok: false, reason: "非本地服务必须使用 HTTPS" };
|
||||
}
|
||||
|
||||
return { ok: true, url: `${parsed.origin}/` };
|
||||
};
|
||||
|
||||
export const getDesktopServerUrl = (): string | null => {
|
||||
const stored = window.localStorage.getItem(DESKTOP_SERVER_URL_KEY);
|
||||
if (!stored) return null;
|
||||
const result = normalizeDesktopServerUrl(stored);
|
||||
return result.ok ? result.url : null;
|
||||
};
|
||||
|
||||
export const hasDesktopServerUrl = (): boolean => Boolean(getDesktopServerUrl());
|
||||
|
||||
export const setDesktopServerUrl = (value: string): DesktopServerUrlValidationResult => {
|
||||
const result = normalizeDesktopServerUrl(value);
|
||||
if (!result.ok) return result;
|
||||
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 },
|
||||
}),
|
||||
);
|
||||
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 },
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
export const shouldRequireDesktopServerUrl = (): boolean => isTauriRuntime() && !hasDesktopServerUrl();
|
||||
@@ -0,0 +1,21 @@
|
||||
export type RuntimePlatform = "web" | "macos" | "windows" | "linux";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__TAURI__?: unknown;
|
||||
__TAURI_INTERNALS__?: unknown;
|
||||
}
|
||||
}
|
||||
|
||||
export const isTauriRuntime = (): boolean =>
|
||||
typeof window !== "undefined" && Boolean(window.__TAURI__ || window.__TAURI_INTERNALS__);
|
||||
|
||||
export const getRuntimePlatform = (): RuntimePlatform => {
|
||||
if (!isTauriRuntime() || typeof navigator === "undefined") return "web";
|
||||
|
||||
const platform = navigator.platform.toLowerCase();
|
||||
const userAgent = navigator.userAgent.toLowerCase();
|
||||
if (platform.includes("mac") || userAgent.includes("mac os x")) return "macos";
|
||||
if (platform.includes("win") || userAgent.includes("windows")) return "windows";
|
||||
return "linux";
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="desktop-settings-page">
|
||||
<section class="settings-panel">
|
||||
<div class="panel-header">
|
||||
<p class="eyebrow">CTMS Desktop</p>
|
||||
<h1>服务器设置</h1>
|
||||
<p class="description">配置桌面客户端要连接的 CTMS 服务端入口。业务数据仍由服务端统一保存和裁决。</p>
|
||||
</div>
|
||||
|
||||
<el-form label-position="top" @submit.prevent>
|
||||
<el-form-item label="服务器地址" :error="urlError">
|
||||
<el-input
|
||||
v-model.trim="serverUrl"
|
||||
size="large"
|
||||
placeholder="https://ctms.example.com"
|
||||
autocomplete="url"
|
||||
@keyup.enter="save"
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<div class="hint">
|
||||
允许 HTTPS 服务地址;本地开发可使用 http://localhost 或 http://127.0.0.1。
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<el-button v-if="canCancel" size="large" @click="goBack">取消</el-button>
|
||||
<el-button type="primary" size="large" :loading="saving" @click="save">保存并检查连接</el-button>
|
||||
</div>
|
||||
</el-form>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { useAuthStore } from "../store/auth";
|
||||
import { useStudyStore } from "../store/study";
|
||||
import { getDesktopServerUrl, normalizeDesktopServerUrl, setDesktopServerUrl } from "../runtime/desktopServerConfig";
|
||||
|
||||
const router = useRouter();
|
||||
const auth = useAuthStore();
|
||||
const studyStore = useStudyStore();
|
||||
|
||||
const currentServerUrl = getDesktopServerUrl();
|
||||
const serverUrl = ref(currentServerUrl || "");
|
||||
const urlError = ref("");
|
||||
const saving = ref(false);
|
||||
const canCancel = computed(() => Boolean(currentServerUrl));
|
||||
|
||||
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 clearSessionForServerChange = () => {
|
||||
auth.logout();
|
||||
studyStore.clearCurrentStudy();
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
urlError.value = "";
|
||||
const normalized = normalizeDesktopServerUrl(serverUrl.value);
|
||||
if (!normalized.ok) {
|
||||
urlError.value = normalized.reason;
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
try {
|
||||
await checkHealth(normalized.url);
|
||||
const previous = getDesktopServerUrl();
|
||||
const result = setDesktopServerUrl(normalized.url);
|
||||
if (!result.ok) {
|
||||
urlError.value = result.reason;
|
||||
return;
|
||||
}
|
||||
if (previous !== result.url) {
|
||||
clearSessionForServerChange();
|
||||
}
|
||||
ElMessage.success("服务器连接已确认");
|
||||
router.replace("/login");
|
||||
} catch {
|
||||
urlError.value = "无法连接服务器的 /health,请确认地址和网络后重试";
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
router.back();
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.desktop-settings-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 32px;
|
||||
background: #f6f8fb;
|
||||
}
|
||||
|
||||
.settings-panel {
|
||||
width: min(100%, 520px);
|
||||
padding: 32px;
|
||||
border: 1px solid #d9e2ef;
|
||||
border-radius: 8px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 8px;
|
||||
color: #2563eb;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
color: #0f172a;
|
||||
font-size: 28px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.description {
|
||||
margin: 12px 0 0;
|
||||
color: #475569;
|
||||
font-size: 14px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-top: -8px;
|
||||
color: #64748b;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 28px;
|
||||
}
|
||||
</style>
|
||||
@@ -198,6 +198,9 @@
|
||||
<!-- 注册与忘记密码(左右分立) -->
|
||||
<div class="forgot-register-row">
|
||||
<RouterLink to="/forgot-password" class="forgot-link">忘记密码?</RouterLink>
|
||||
<RouterLink v-if="showDesktopServerSettings" to="/desktop/server-settings" class="server-settings-link">
|
||||
服务器设置
|
||||
</RouterLink>
|
||||
<RouterLink to="/register" class="register-link">新用户注册</RouterLink>
|
||||
</div>
|
||||
</el-form>
|
||||
@@ -257,6 +260,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/platform";
|
||||
import {
|
||||
consumeLogoutReason,
|
||||
LOGOUT_REASON_AUTH_EXPIRED,
|
||||
@@ -287,6 +291,7 @@ const protocolDialogVisible = ref(false);
|
||||
const logoutNotice = ref<{ type: "info" | "warning"; title: string; message: string } | null>(null);
|
||||
const loginError = ref<{ title: string; message?: string } | null>(null);
|
||||
const protocolSections = authProtocolSections;
|
||||
const showDesktopServerSettings = isTauriRuntime();
|
||||
|
||||
const normalizeDomain = (value: string) => value.trim().toLowerCase().replace(/^@/, "");
|
||||
const availableEmailDomains = computed(() => Array.from(new Set([
|
||||
@@ -353,7 +358,7 @@ onMounted(async () => {
|
||||
form.agreeProtocol = localStorage.getItem(AGREE_PROTOCOL_KEY) === "true";
|
||||
});
|
||||
|
||||
watch(() => form.agreeProtocol, (v) => localStorage.setItem(AGREE_PROTOCOL_KEY, String(v)));
|
||||
watch(() => form.agreeProtocol, (checked) => localStorage.setItem(AGREE_PROTOCOL_KEY, String(checked)));
|
||||
watch(() => [form.emailLocal, form.emailDomain], syncEmailFromParts);
|
||||
|
||||
const openProtocolDialog = () => { protocolDialogVisible.value = true; };
|
||||
@@ -371,7 +376,11 @@ const onSubmit = async () => {
|
||||
const studyStore = useStudyStore();
|
||||
const userKey = auth.user?.email || form.email;
|
||||
await studyStore.restoreStudyForUser(userKey, { preferActive: !!auth.user?.is_admin });
|
||||
router.push(studyStore.currentStudy ? "/project/overview" : auth.user?.is_admin ? "/admin/users" : "/admin/projects");
|
||||
if (studyStore.currentStudy) {
|
||||
router.push("/project/overview");
|
||||
} else {
|
||||
router.push(auth.user?.is_admin ? "/admin/users" : "/admin/projects");
|
||||
}
|
||||
} catch (error: any) {
|
||||
const status = error?.response?.status;
|
||||
const detail: string = error?.response?.data?.detail || error?.response?.data?.message || "";
|
||||
@@ -704,8 +713,8 @@ const onSubmit = async () => {
|
||||
|
||||
/* 卡片容器 */
|
||||
.login-card-container {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
width: clamp(480px, 34vw, 560px);
|
||||
max-width: calc(100vw - 40px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
@@ -1040,7 +1049,7 @@ const onSubmit = async () => {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.forgot-link, .register-link {
|
||||
.forgot-link, .register-link, .server-settings-link {
|
||||
font-size: 13px;
|
||||
color: #2563eb;
|
||||
text-decoration: none;
|
||||
@@ -1048,7 +1057,7 @@ const onSubmit = async () => {
|
||||
transition: color 0.15s;
|
||||
}
|
||||
|
||||
.forgot-link:hover, .register-link:hover {
|
||||
.forgot-link:hover, .register-link:hover, .server-settings-link:hover {
|
||||
color: #1d4ed8;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,15 @@ import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const devApiProxyTarget = process.env.VITE_DEV_API_PROXY_TARGET || "http://backend:8000";
|
||||
const hmrClientPort = Number(process.env.VITE_HMR_CLIENT_PORT || "");
|
||||
const tauriDevHost = process.env.TAURI_DEV_HOST;
|
||||
const tauriPlatform = process.env.TAURI_ENV_PLATFORM;
|
||||
const tauriDebug = process.env.TAURI_ENV_DEBUG === "true";
|
||||
|
||||
export default defineConfig({
|
||||
clearScreen: false,
|
||||
envPrefix: ["VITE_", "TAURI_ENV_"],
|
||||
plugins: [
|
||||
vue(),
|
||||
{
|
||||
@@ -27,16 +35,23 @@ export default defineConfig({
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
host: tauriDevHost || true,
|
||||
port: 5173,
|
||||
hmr: {
|
||||
clientPort: 8888,
|
||||
strictPort: true,
|
||||
hmr: hmrClientPort ? { clientPort: hmrClientPort } : undefined,
|
||||
watch: {
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: "http://backend:8000",
|
||||
target: devApiProxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
target: tauriPlatform === "windows" ? "chrome105" : "safari13",
|
||||
minify: tauriDebug ? false : "esbuild",
|
||||
sourcemap: tauriDebug,
|
||||
},
|
||||
});
|
||||
|
||||