feat(desktop): implement phase 1 tauri client
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
+20
-15
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user