feat(desktop): implement phase 1 tauri client

This commit is contained in:
Cheng Zhou
2026-06-30 17:25:03 +08:00
parent 4654a812a0
commit d1a6c957f7
45 changed files with 5473 additions and 27 deletions
+3
View File
@@ -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
+233
View File
@@ -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",
+5
View File
@@ -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",
+4367
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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"]
+3
View File
@@ -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"]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 293 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 659 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 233 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 846 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 194 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

+5
View File
@@ -0,0 +1,5 @@
pub fn run() {
tauri::Builder::default()
.run(tauri::generate_context!())
.expect("error while running CTMS desktop application");
}
+5
View File
@@ -0,0 +1,5 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
ctms_desktop_lib::run()
}
+31
View File
@@ -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"]
}
}
+11 -1
View File
@@ -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;
+11 -1
View File
@@ -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;
+20 -15
View File
@@ -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();
+20
View File
@@ -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;
};
+14
View File
@@ -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;
+7
View File
@@ -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();
+21
View File
@@ -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>
+15 -6
View File
@@ -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;
}
+19 -4
View File
@@ -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,
},
});