feat(desktop-client): add settings window and switch default API to production

- Open a dedicated settings BrowserWindow with general / login / diagnostics / about tabs
- Persist openAtLogin and keepRunningInBackground via desktop-settings.json; hide main window instead of quitting when background mode is on
- Default API base URL now points to api.shengxintui.com; LoginView and SettingsView share a single constant
- Restore wangyihao session cookies on probe and silent refresh, and accept the legacy http://mp.163.com console origin
- Drop the standalone diagnostics view in favor of the settings tab

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-30 22:00:01 +08:00
parent 40ef60eae2
commit baff51295a
13 changed files with 1110 additions and 226 deletions
+12 -8
View File
@@ -277,19 +277,22 @@ const ZOL_LOGIN_REDIRECT_URLS = [
"https://service.zol.com.cn/user/login",
];
const WANGYI_API_ORIGIN = "https://mp.163.com";
const WANGYI_CONSOLE_ORIGIN = "http://mp.163.com";
const WANGYI_CONSOLE_ORIGIN = "https://mp.163.com";
const WANGYI_LEGACY_CONSOLE_ORIGIN = "http://mp.163.com";
const WANGYI_API_REFERER = `${WANGYI_API_ORIGIN}/subscribe_v4/index.html`;
const WANGYI_INFO_URL = `${WANGYI_API_ORIGIN}/wemedia/info.do`;
const WANGYI_CONSOLE_URL = `${WANGYI_CONSOLE_ORIGIN}/subscribe_v4/index.html`;
const WANGYI_LOGIN_REDIRECT_URLS = [
`${WANGYI_API_ORIGIN}/login.html`,
`${WANGYI_CONSOLE_ORIGIN}/login.html`,
`${WANGYI_LEGACY_CONSOLE_ORIGIN}/login.html`,
"https://reg.163.com/",
"https://dl.reg.163.com/",
];
const WANGYI_COOKIE_URLS = [
WANGYI_API_ORIGIN,
WANGYI_CONSOLE_ORIGIN,
WANGYI_LEGACY_CONSOLE_ORIGIN,
"https://reg.163.com/",
"https://dl.reg.163.com/",
];
@@ -2092,6 +2095,10 @@ export async function probePublishAccountSession(
};
}
if (account.platform === "wangyihao") {
await restoreWangyihaoSessionCookies(account.id, handle.session);
}
const definition = platformBindingDefinitions[account.platform];
if (!definition) {
return {
@@ -2460,6 +2467,10 @@ export async function silentRefreshPublishAccountSession(
return false;
}
if (account.platform === "wangyihao") {
await restoreWangyihaoSessionCookies(account.id, handle.session);
}
const definition = platformBindingDefinitions[account.platform];
if (!definition) {
return false;
@@ -2537,13 +2548,6 @@ export async function inspectPublishAccountLocalSession(
const profile = await resolvePublishAccountProfileFromHandle(account, handle);
if (!profile) {
if (account.platform === "wangyihao") {
return {
availability: "expired",
profile: null,
};
}
const consoleReady = await verifyPublishAccountConsoleAccess(account, handle).catch(() => false);
if (consoleReady) {
return {
@@ -0,0 +1,114 @@
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { app } from "electron/main";
export interface DesktopAppSettings {
openAtLogin: boolean;
keepRunningInBackground: boolean;
}
export type DesktopAppSettingKey = keyof DesktopAppSettings;
const DEFAULT_DESKTOP_APP_SETTINGS: DesktopAppSettings = {
openAtLogin: false,
keepRunningInBackground: true,
};
let cachedSettings: DesktopAppSettings = { ...DEFAULT_DESKTOP_APP_SETTINGS };
function settingsPath(): string {
return join(app.getPath("userData"), "desktop-settings.json");
}
function normalizeSettings(input: unknown): DesktopAppSettings {
if (!input || typeof input !== "object") {
return { ...DEFAULT_DESKTOP_APP_SETTINGS };
}
const candidate = input as Partial<Record<DesktopAppSettingKey, unknown>>;
return {
openAtLogin:
typeof candidate.openAtLogin === "boolean"
? candidate.openAtLogin
: DEFAULT_DESKTOP_APP_SETTINGS.openAtLogin,
keepRunningInBackground:
typeof candidate.keepRunningInBackground === "boolean"
? candidate.keepRunningInBackground
: DEFAULT_DESKTOP_APP_SETTINGS.keepRunningInBackground,
};
}
function readPersistedSettings(): DesktopAppSettings {
try {
return normalizeSettings(JSON.parse(readFileSync(settingsPath(), "utf8")));
} catch {
return { ...DEFAULT_DESKTOP_APP_SETTINGS };
}
}
function persistSettings(settings: DesktopAppSettings): void {
try {
writeFileSync(settingsPath(), JSON.stringify(settings), "utf8");
} catch (error) {
console.warn("[desktop-settings] persist failed", error);
}
}
function readSystemOpenAtLogin(fallback: boolean): boolean {
try {
return app.getLoginItemSettings().openAtLogin;
} catch (error) {
console.warn("[desktop-settings] read login item failed", error);
return fallback;
}
}
function applyOpenAtLogin(openAtLogin: boolean): boolean {
try {
app.setLoginItemSettings({
openAtLogin,
openAsHidden: false,
});
return readSystemOpenAtLogin(openAtLogin);
} catch (error) {
console.warn("[desktop-settings] apply login item failed", error);
return openAtLogin;
}
}
export function initDesktopAppSettings(): DesktopAppSettings {
cachedSettings = readPersistedSettings();
cachedSettings = {
...cachedSettings,
openAtLogin: applyOpenAtLogin(cachedSettings.openAtLogin),
};
persistSettings(cachedSettings);
return getDesktopAppSettings();
}
export function getDesktopAppSettings(): DesktopAppSettings {
cachedSettings = {
...cachedSettings,
openAtLogin: readSystemOpenAtLogin(cachedSettings.openAtLogin),
};
return { ...cachedSettings };
}
export function setDesktopAppSetting(
key: DesktopAppSettingKey,
value: boolean,
): DesktopAppSettings {
const next: DesktopAppSettings = {
...cachedSettings,
[key]: value,
};
if (key === "openAtLogin") {
next.openAtLogin = applyOpenAtLogin(value);
}
cachedSettings = next;
persistSettings(cachedSettings);
return getDesktopAppSettings();
}
+113 -2
View File
@@ -16,6 +16,12 @@ import {
startDesktopHealthIndicator,
syncDesktopHealthIndicator,
} from "./app-issue-indicator";
import {
getDesktopAppSettings,
initDesktopAppSettings,
setDesktopAppSetting,
type DesktopAppSettingKey,
} from "./app-settings";
import { bindPublishAccount, openPublishAccountConsole } from "./account-binder";
import { installObservedGlobalFetch, registerObservedRequestRendererTarget } from "./network-observer";
import { initProcessMetricsSampler } from "./process-metrics";
@@ -67,10 +73,13 @@ process.on("uncaughtException", (error) => {
let mainWindow: ElectronBrowserWindow | null = null;
let loginWindow: ElectronBrowserWindow | null = null;
let settingsWindow: ElectronBrowserWindow | null = null;
let mainRendererContents: ElectronWebContents | null = null;
let quitReleaseInFlight = false;
const forceCloseWindows = new WeakSet<ElectronBrowserWindow>();
type WindowMode = "login" | "main";
type RendererWindowMode = WindowMode | "settings";
type WindowModeSource = "auth-state" | "login" | "logout";
interface WindowSizePreset {
@@ -91,6 +100,14 @@ const WINDOW_SIZES: Record<WindowMode, WindowSizePreset> = {
main: { width: 1320, height: 860, minWidth: 900, minHeight: 600, resizable: true },
};
const SETTINGS_WINDOW_SIZE: WindowSizePreset = {
width: 920,
height: 640,
minWidth: 760,
minHeight: 520,
resizable: true,
};
function windowModePath(): string {
return join(app.getPath("userData"), "window-mode.json");
}
@@ -155,11 +172,23 @@ function currentLoginWindow(): ElectronBrowserWindow | null {
return loginWindow;
}
function currentSettingsWindow(): ElectronBrowserWindow | null {
if (!settingsWindow) {
return null;
}
if (settingsWindow.isDestroyed()) {
settingsWindow = null;
return null;
}
return settingsWindow;
}
function currentActiveWindow(): ElectronBrowserWindow | null {
return currentMainWindow() ?? currentLoginWindow();
}
function closeWindowAfterIpcReturn(window: ElectronBrowserWindow): void {
forceCloseWindows.add(window);
setTimeout(() => {
if (!window.isDestroyed()) {
window.close();
@@ -197,6 +226,16 @@ async function ensureLoginWindow(): Promise<ElectronBrowserWindow> {
return window;
}
async function ensureSettingsWindow(): Promise<ElectronBrowserWindow> {
const existing = currentSettingsWindow();
if (existing) {
return existing;
}
const window = await createSettingsWindow();
settingsWindow = window;
return window;
}
async function ensureWindow(mode: WindowMode): Promise<ElectronBrowserWindow> {
return mode === "main" ? ensureMainWindow() : ensureLoginWindow();
}
@@ -238,6 +277,16 @@ async function switchWindowMode(mode: WindowMode, _request: WindowModeRequest =
if (previous && previous !== target && !previous.isDestroyed()) {
closeWindowAfterIpcReturn(previous);
}
const settings = currentSettingsWindow();
if (mode === "login" && settings) {
closeWindowAfterIpcReturn(settings);
}
}
async function openSettingsWindow(): Promise<void> {
const window = await ensureSettingsWindow();
await revealWindow(window);
}
function rendererURL(): string | null {
@@ -248,7 +297,7 @@ function preloadPath(): string {
return join(__dirname, "../preload/bridge.cjs");
}
async function loadRenderer(window: ElectronBrowserWindow, mode: WindowMode): Promise<void> {
async function loadRenderer(window: ElectronBrowserWindow, mode: RendererWindowMode): Promise<void> {
const devServerURL = rendererURL();
if (devServerURL) {
const url = new URL(devServerURL);
@@ -265,7 +314,7 @@ async function loadRenderer(window: ElectronBrowserWindow, mode: WindowMode): Pr
});
}
async function mountRendererWebContents(window: ElectronBrowserWindow, mode: WindowMode): Promise<void> {
async function mountRendererWebContents(window: ElectronBrowserWindow, mode: RendererWindowMode): Promise<void> {
const contents = window.webContents;
contents.setWindowOpenHandler(() => ({ action: "deny" }));
@@ -321,6 +370,18 @@ async function createAppWindow(mode: WindowMode): Promise<ElectronBrowserWindow>
}
});
window.on("close", (event) => {
if (
mode === "main"
&& !quitReleaseInFlight
&& !forceCloseWindows.has(window)
&& getDesktopAppSettings().keepRunningInBackground
) {
event.preventDefault();
window.hide();
}
});
window.on("minimize", () => {
console.warn("[desktop-main] main window minimize event", {
at: new Date().toISOString(),
@@ -345,6 +406,41 @@ async function createAppWindow(mode: WindowMode): Promise<ElectronBrowserWindow>
return window;
}
async function createSettingsWindow(): Promise<ElectronBrowserWindow> {
const initial = SETTINGS_WINDOW_SIZE;
const window = new BrowserWindow({
width: initial.width,
height: initial.height,
minWidth: initial.minWidth,
minHeight: initial.minHeight,
show: false,
title: "GEO Rankly Desktop 设置",
icon: createDesktopHealthIcon("normal"),
titleBarStyle: "hiddenInset",
trafficLightPosition: { x: 14, y: 15 },
resizable: initial.resizable,
maximizable: initial.resizable,
fullscreenable: false,
autoHideMenuBar: true,
backgroundColor: nativeTheme.shouldUseDarkColors ? "#15191a" : "#f2f4f7",
webPreferences: {
preload: preloadPath(),
sandbox: true,
contextIsolation: true,
nodeIntegration: false,
},
});
window.once("closed", () => {
if (settingsWindow === window) {
settingsWindow = null;
}
});
await mountRendererWebContents(window, "settings");
return window;
}
function flattenError(error: unknown): Error {
if (error instanceof Error) {
const copy = new Error(error.message || "ipc_handler_failed");
@@ -450,6 +546,17 @@ function registerBridgeHandlers(): void {
await shell.openExternal(url);
return null;
});
safeHandle("desktop:open-settings-window", async () => {
await openSettingsWindow();
return null;
});
ipcMain.handle("desktop:get-app-settings", () => getDesktopAppSettings());
safeHandle("desktop:set-app-setting", async (_event, key: DesktopAppSettingKey, value: boolean) => {
if (key !== "openAtLogin" && key !== "keepRunningInBackground") {
throw new Error("unsupported_app_setting");
}
return setDesktopAppSetting(key, value);
});
ipcMain.handle(
"desktop:runtime-session-sync",
(_event, session: DesktopRuntimeSessionSyncRequest | null) => {
@@ -479,6 +586,7 @@ if (!hasSingleInstanceLock) {
} else {
app.whenReady().then(async () => {
currentWindowMode = readPersistedWindowMode();
initDesktopAppSettings();
registerBridgeHandlers();
await initSessionRegistry();
initAccountHealth();
@@ -508,6 +616,9 @@ if (!hasSingleInstanceLock) {
});
app.on("window-all-closed", () => {
if (getDesktopAppSettings().keepRunningInBackground) {
return;
}
if (process.platform !== "darwin") {
app.quit();
}
+11
View File
@@ -27,6 +27,11 @@ interface WindowModeRequest {
animate?: boolean;
}
interface DesktopAppSettings {
openAtLogin: boolean;
keepRunningInBackground: boolean;
}
const rendererProxyRequestChannel = "desktop:renderer-devtools-proxy:request";
const rendererProxyResponseChannel = "desktop:renderer-devtools-proxy:response";
@@ -120,6 +125,12 @@ const desktopBridge = {
ipcRenderer.invoke("desktop:retry-publish-task", taskId) as Promise<CreatePublishJobResponse>,
openExternalUrl: (url: string) =>
ipcRenderer.invoke("desktop:open-external-url", url) as Promise<null>,
openSettingsWindow: () =>
ipcRenderer.invoke("desktop:open-settings-window") as Promise<null>,
getAppSettings: () =>
ipcRenderer.invoke("desktop:get-app-settings") as Promise<DesktopAppSettings>,
setAppSetting: (key: keyof DesktopAppSettings, value: boolean) =>
ipcRenderer.invoke("desktop:set-app-setting", key, value) as Promise<DesktopAppSettings>,
syncRuntimeSession: (session: DesktopRuntimeSessionSyncRequest | null) =>
ipcRenderer.invoke("desktop:runtime-session-sync", session) as Promise<null>,
releaseRuntimeSession: (revoke?: boolean) =>
+18 -3
View File
@@ -4,9 +4,10 @@ import { useDesktopSession } from "./composables/useDesktopSession";
const DesktopShell = defineAsyncComponent(() => import("./components/DesktopShell.vue"));
const LoginView = defineAsyncComponent(() => import("./views/LoginView.vue"));
const SettingsWindow = defineAsyncComponent(() => import("./views/SettingsWindow.vue"));
const { isAuthenticated, syncRuntimeSession } = useDesktopSession();
type RendererWindowMode = "auto" | "login" | "main";
type RendererWindowMode = "auto" | "login" | "main" | "settings";
interface WindowModeBridge {
setWindowMode?: (
@@ -17,7 +18,7 @@ interface WindowModeBridge {
function resolveRendererWindowMode(): RendererWindowMode {
const mode = new URLSearchParams(window.location.search).get("window");
return mode === "login" || mode === "main" ? mode : "auto";
return mode === "login" || mode === "main" || mode === "settings" ? mode : "auto";
}
function getModeBridge(): WindowModeBridge | undefined {
@@ -31,7 +32,9 @@ function syncWindowMode(authenticated: boolean): void {
}
const rendererWindowMode = resolveRendererWindowMode();
document.documentElement.dataset.windowMode = rendererWindowMode;
const usesAutomaticWindowMode = rendererWindowMode === "auto";
const showsSettingsSurface = rendererWindowMode === "settings";
const showsMainSurface = computed(() =>
rendererWindowMode === "main" || (rendererWindowMode === "auto" && isAuthenticated.value),
);
@@ -63,6 +66,18 @@ onMounted(() => {
</script>
<template>
<DesktopShell v-if="showsMainSurface" />
<SettingsWindow v-if="showsSettingsSurface" />
<DesktopShell v-else-if="showsMainSurface" />
<LoginView v-else />
</template>
<style>
html,
body,
#app {
width: 100%;
height: 100%;
overflow: hidden;
}
</style>
@@ -6,8 +6,8 @@ import {
SendOutlined,
LinkOutlined,
RobotOutlined,
ToolOutlined,
LogoutOutlined,
SettingOutlined,
} from "@ant-design/icons-vue";
import { useDesktopRuntime } from "../composables/useDesktopRuntime";
@@ -61,13 +61,6 @@ const navItems = computed(() => {
count: accounts.filter((item) => monitoringIDs.has(item.platform)).length,
icon: RobotOutlined,
},
{
to: "/diagnostics",
title: "诊断",
description: "vault、scheduler、transport 与原始快照",
count: data?.clients.length ?? 0,
icon: ToolOutlined,
},
];
});
@@ -78,6 +71,10 @@ const liveClientLabel = computed(() => snapshot.value?.clients[0]?.deviceName ??
const clientLabel = computed(
() => liveClientLabel.value ?? session.value?.desktopClient?.device_name ?? "当前设备尚未注册 client",
);
function openSettingsWindow() {
void window.desktopBridge.app.openSettingsWindow();
}
</script>
<template>
@@ -128,11 +125,18 @@ const clientLabel = computed(
<span class="device-name-text">{{ clientLabel }}</span>
</div>
</a-tooltip>
<a-tooltip title="退出登录" placement="top">
<button type="button" class="logout-action-btn" @click="logout">
<LogoutOutlined />
</button>
</a-tooltip>
<div class="action-cluster">
<a-tooltip title="设置" placement="top">
<button type="button" class="footer-icon-btn" @click="openSettingsWindow">
<SettingOutlined />
</button>
</a-tooltip>
<a-tooltip title="退出登录" placement="top">
<button type="button" class="footer-icon-btn footer-icon-btn--danger" @click="logout">
<LogoutOutlined />
</button>
</a-tooltip>
</div>
</div>
</section>
@@ -428,7 +432,13 @@ h1 {
text-overflow: ellipsis;
}
.logout-action-btn {
.action-cluster {
display: flex;
align-items: center;
gap: 6px;
}
.footer-icon-btn {
width: 32px;
height: 32px;
border-radius: 8px;
@@ -441,9 +451,22 @@ h1 {
border: 1px solid transparent;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
}
.logout-action-btn:hover {
.footer-icon-btn:hover {
background: #f1f5f9;
color: #1677ff;
border-color: #e2e8f0;
}
.footer-icon-btn.router-link-active {
background: #e6f4ff;
color: #1677ff;
border-color: #e6f4ff;
}
.footer-icon-btn--danger:hover {
background: #fef2f2;
color: #ef4444;
border-color: #fee2e2;
@@ -33,6 +33,7 @@ interface ResolvedDeviceInfo {
const sessionStorageKey = "geo.desktop.session.v1";
const apiBaseURLStorageKey = "geo.desktop.api-base-url";
export const DEFAULT_API_BASE_URL = import.meta.env.VITE_DESKTOP_API_BASE_URL ?? "https://api.shengxintui.com";
const session = shallowRef<DesktopSession | null>(readStoredSession());
const apiBaseURL = shallowRef(readStoredApiBaseURL());
@@ -75,14 +76,14 @@ function persistSession(next: DesktopSession | null): void {
function readStoredApiBaseURL(): string {
if (typeof window === "undefined") {
return "http://localhost:8080";
return DEFAULT_API_BASE_URL;
}
return window.localStorage.getItem(apiBaseURLStorageKey) ?? "http://localhost:8080";
return window.localStorage.getItem(apiBaseURLStorageKey) ?? DEFAULT_API_BASE_URL;
}
function persistApiBaseURL(value: string): void {
const trimmed = value.trim() || "http://localhost:8080";
const trimmed = value.trim() || DEFAULT_API_BASE_URL;
apiBaseURL.value = trimmed;
if (typeof window !== "undefined") {
@@ -499,6 +500,12 @@ async function logout(): Promise<void> {
}
if (typeof window !== "undefined") {
window.addEventListener("storage", (event) => {
if (event.key === apiBaseURLStorageKey) {
apiBaseURL.value = event.newValue?.trim() || DEFAULT_API_BASE_URL;
void syncRuntimeSessionToMain();
}
});
void syncStoredDesktopClientDeviceInfo();
}
+8
View File
@@ -11,6 +11,11 @@ import type { DesktopRuntimeSnapshot } from "./types";
export {};
declare global {
interface DesktopAppSettings {
openAtLogin: boolean;
keepRunningInBackground: boolean;
}
interface Window {
desktopBridge: {
app: {
@@ -40,6 +45,9 @@ declare global {
listPublishTasks(params?: ListDesktopPublishTasksParams): Promise<DesktopPublishTaskListResponse>;
retryPublishTask(taskId: string): Promise<CreatePublishJobResponse>;
openExternalUrl(url: string): Promise<null>;
openSettingsWindow(): Promise<null>;
getAppSettings(): Promise<DesktopAppSettings>;
setAppSetting(key: keyof DesktopAppSettings, value: boolean): Promise<DesktopAppSettings>;
syncRuntimeSession(session: DesktopRuntimeSessionSyncRequest | null): Promise<null>;
releaseRuntimeSession(revoke?: boolean): Promise<null>;
setWindowMode(mode: "login" | "main", request?: {
+2 -5
View File
@@ -23,11 +23,8 @@ const routes: RouteRecordRaw[] = [
component: () => import("./views/AiPlatformsView.vue"),
},
{ path: "/accounts", redirect: "/media-platforms" },
{
path: "/diagnostics",
name: "diagnostics",
component: () => import("./views/DiagnosticsView.vue"),
},
{ path: "/settings", redirect: "/" },
{ path: "/diagnostics", redirect: "/" },
];
export const router = createRouter({
@@ -1,188 +0,0 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useDesktopRuntime } from "../composables/useDesktopRuntime";
const { snapshot } = useDesktopRuntime();
const subsystemEntries = computed(() =>
Object.entries(snapshot.value?.subsystems ?? {}).map(([key, value]) => ({
key,
value: JSON.stringify(value, null, 2),
})),
);
const copied = ref(false);
function copyLogs() {
if (!snapshot.value) return;
const payload = JSON.stringify(snapshot.value, null, 2);
navigator.clipboard.writeText(payload).then(() => {
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2500);
});
}
</script>
<template>
<section class="page">
<header class="hero">
<div class="hero-copy">
<p class="eyebrow">SYSTEM DIAGNOSTICS</p>
<h2>系统诊断</h2>
<p class="hero-summary">
桌面客户端底层运行环境与依赖项巡检此页面记录的任务补偿日志本地路由锁与异常状态主要用于开发侧进行问题上卷与 Trace 回顾
</p>
<div class="actions">
<a-button type="primary" :ghost="copied" @click="copyLogs">
{{ copied ? "已复制到剪贴板" : "一键包复制发给研发排查" }}
</a-button>
</div>
</div>
</header>
<section class="panel">
<div class="panel-header">
<h3>运行时底层快照日志</h3>
</div>
<div class="panel-body">
<a-collapse :bordered="false" class="custom-collapse">
<a-collapse-panel
v-for="entry in subsystemEntries"
:key="entry.key"
:header="`Subsystem: ${entry.key}`"
class="collapse-item"
>
<pre class="json-code">{{ entry.value }}</pre>
</a-collapse-panel>
<a-collapse-panel
key="raw"
header="完整 Runtime 原始快照 (Full Engine Snapshot)"
class="collapse-item"
>
<pre class="json-code">{{ JSON.stringify(snapshot, null, 2) }}</pre>
</a-collapse-panel>
</a-collapse>
</div>
</section>
</section>
</template>
<style scoped>
.page {
display: grid;
gap: 20px;
}
.hero {
display: block;
}
.hero-copy {
padding: 24px;
border-radius: 12px;
border: 1px solid #e6edf5;
background: #ffffff;
}
.eyebrow,
.hero-summary,
h2 {
margin: 0;
}
.eyebrow {
color: #8c8c8c;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
}
h2 {
margin-top: 8px;
font-size: 24px;
font-weight: 600;
color: #1a1a1a;
line-height: 1.4;
}
.hero-summary {
margin-top: 12px;
max-width: 800px;
color: #8c8c8c;
line-height: 1.6;
font-size: 13px;
}
.actions {
margin-top: 20px;
}
.panel {
background: #ffffff;
border-radius: 12px;
border: 1px solid #e6edf5;
display: flex;
flex-direction: column;
}
.panel-header {
padding: 20px 24px;
border-bottom: 1px solid #e6edf5;
}
.panel-header h3 {
margin: 0;
font-size: 16px;
font-weight: 600;
color: #1a1a1a;
}
.panel-body {
padding: 24px;
}
.custom-collapse {
background: transparent !important;
}
.collapse-item {
border-radius: 8px !important;
border: 1px solid #e6edf5 !important;
margin-bottom: 12px;
background: #fafafb;
overflow: hidden;
}
:deep(.ant-collapse-item:last-child) {
margin-bottom: 0;
}
:deep(.ant-collapse-header) {
font-weight: 500;
color: #1a1a1a !important;
padding: 12px 16px !important;
}
:deep(.ant-collapse-content) {
border-top: 1px dashed #e6edf5 !important;
background: #ffffff !important;
}
.json-code {
margin: 0;
padding: 16px;
background: #f8fafc;
border-radius: 6px;
border: 1px solid #e2e8f0;
color: #334155;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
max-height: 500px;
overflow-y: auto;
}
</style>
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, reactive, ref } from "vue";
import { useDesktopSession } from "../composables/useDesktopSession";
import { DEFAULT_API_BASE_URL, useDesktopSession } from "../composables/useDesktopSession";
const { apiBaseURL, error, pending, setApiBaseURL, login } = useDesktopSession();
@@ -172,7 +172,7 @@ onBeforeUnmount(() => {
<input
v-model.trim="form.apiBaseURL"
type="text"
placeholder="http://localhost:8080"
:placeholder="DEFAULT_API_BASE_URL"
spellcheck="false"
/>
</label>
@@ -0,0 +1,775 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import {
InfoCircleOutlined,
LoginOutlined,
SlidersOutlined,
ToolOutlined,
} from "@ant-design/icons-vue";
import { DEFAULT_API_BASE_URL, useDesktopSession } from "../composables/useDesktopSession";
import { useDesktopRuntime } from "../composables/useDesktopRuntime";
const { snapshot } = useDesktopRuntime();
const { apiBaseURL, setApiBaseURL } = useDesktopSession();
type SectionKey =
| "general"
| "notifications"
| "storage"
| "shortcuts"
| "permissions"
| "login"
| "theme"
| "diagnostics"
| "about";
const sections = [
{ key: "general" as const, title: "通用", icon: SlidersOutlined },
{ key: "login" as const, title: "登录设置", icon: LoginOutlined },
{ key: "diagnostics" as const, title: "诊断", icon: ToolOutlined },
{ key: "about" as const, title: "关于GEO", icon: InfoCircleOutlined },
];
const activeSection = ref<SectionKey>("general");
const appSettings = ref<DesktopAppSettings>({
openAtLogin: false,
keepRunningInBackground: true,
});
const serverBaseURL = ref(apiBaseURL.value);
const settingsLoaded = ref(false);
const settingsError = ref<string | null>(null);
const serverSettingsMessage = ref<string | null>(null);
const serverSettingsError = ref<string | null>(null);
const savingSetting = ref<keyof DesktopAppSettings | null>(null);
const activeTitle = computed(() => sections.find((item) => item.key === activeSection.value)?.title ?? "设置");
const subsystemEntries = computed(() =>
Object.entries(snapshot.value?.subsystems ?? {}).map(([key, value]) => ({
key,
value: JSON.stringify(value, null, 2),
})),
);
const clientCount = computed(() => snapshot.value?.clients.length ?? 0);
const appVersion = computed(() => snapshot.value?.app.version ?? "0.1.0");
const hasServerSettingChanged = computed(() => normalizeServerURLInput(serverBaseURL.value) !== apiBaseURL.value);
const generalRows = computed(() => [
{
key: "openAtLogin" as const,
label: "开机自动启动",
enabled: appSettings.value.openAtLogin,
},
{
key: "keepRunningInBackground" as const,
label: "保持后台运行",
enabled: appSettings.value.keepRunningInBackground,
},
]);
const copied = ref(false);
function copyLogs() {
if (!snapshot.value) return;
const payload = JSON.stringify(snapshot.value, null, 2);
navigator.clipboard.writeText(payload).then(() => {
copied.value = true;
setTimeout(() => {
copied.value = false;
}, 2500);
});
}
function openServiceTerms() {
void window.desktopBridge.app.openExternalUrl("https://shengxintui.com/terms");
}
function normalizeServerURLInput(value: string): string {
return value.trim() || DEFAULT_API_BASE_URL;
}
function validateServerURL(value: string): string | null {
try {
const parsed = new URL(value);
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
return "服务器地址必须以 http:// 或 https:// 开头";
}
return null;
} catch {
return "请输入有效的服务器地址";
}
}
function saveServerSettings() {
const next = normalizeServerURLInput(serverBaseURL.value);
const validationError = validateServerURL(next);
serverSettingsMessage.value = null;
serverSettingsError.value = validationError;
if (validationError) {
return;
}
setApiBaseURL(next);
serverBaseURL.value = apiBaseURL.value;
serverSettingsMessage.value = "已保存,后续登录和桌面运行会使用此地址";
}
function resetServerSettings() {
serverBaseURL.value = DEFAULT_API_BASE_URL;
saveServerSettings();
}
async function loadAppSettings() {
settingsError.value = null;
try {
appSettings.value = await window.desktopBridge.app.getAppSettings();
} catch (error) {
settingsError.value = error instanceof Error ? error.message : "设置读取失败";
} finally {
settingsLoaded.value = true;
}
}
async function toggleAppSetting(key: keyof DesktopAppSettings) {
if (!settingsLoaded.value || savingSetting.value) {
return;
}
const previous = appSettings.value;
const value = !previous[key];
appSettings.value = { ...previous, [key]: value };
savingSetting.value = key;
settingsError.value = null;
try {
appSettings.value = await window.desktopBridge.app.setAppSetting(key, value);
} catch (error) {
appSettings.value = previous;
settingsError.value = error instanceof Error ? error.message : "设置保存失败";
} finally {
savingSetting.value = null;
}
}
onMounted(() => {
void loadAppSettings();
});
watch(apiBaseURL, (next) => {
serverBaseURL.value = next;
});
</script>
<template>
<section class="settings-shell">
<aside class="settings-nav">
<nav class="settings-nav__list" aria-label="设置分类">
<button
v-for="item in sections"
:key="item.key"
type="button"
class="settings-nav__item"
:class="{ 'is-active': activeSection === item.key }"
@click="activeSection = item.key"
>
<component :is="item.icon" class="settings-nav__icon" />
<span>{{ item.title }}</span>
<span v-if="item.key === 'diagnostics' && clientCount > 0" class="settings-nav__badge">
{{ clientCount }}
</span>
</button>
</nav>
</aside>
<main class="settings-main">
<header class="settings-heading">
<h1>{{ activeTitle }}</h1>
</header>
<div class="settings-body">
<section v-if="activeSection === 'about'" class="about-page">
<div class="about-version-card">
<div class="about-logo" aria-hidden="true">
<span class="brand-ring"></span>
<span class="brand-core"></span>
</div>
<strong>版本: {{ appVersion }}</strong>
</div>
<footer class="about-footer">
<p>
Copyright © 2026 省心推. All Rights Reserved.
<a href="https://shengxintui.com/terms" @click.prevent="openServiceTerms">服务协议</a>
</p>
<p class="tech-line">
基于 <span class="tech-mark"></span><strong>省心推</strong> Desktop 技术架构
</p>
</footer>
</section>
<section v-else-if="activeSection === 'diagnostics'" class="diagnostics-page">
<div class="diagnostics-card">
<div class="card-title-row">
<div>
<h2>运行时快照</h2>
<p>客户端调度器与发布通道状态</p>
</div>
<button type="button" class="copy-button" @click="copyLogs">
{{ copied ? "已复制" : "复制日志" }}
</button>
</div>
<a-collapse :bordered="false" class="diagnostics-collapse">
<a-collapse-panel
v-for="entry in subsystemEntries"
:key="entry.key"
:header="entry.key"
class="collapse-item"
>
<pre class="json-code">{{ entry.value }}</pre>
</a-collapse-panel>
<a-collapse-panel key="raw" header="完整 Runtime 快照" class="collapse-item">
<pre class="json-code">{{ JSON.stringify(snapshot, null, 2) }}</pre>
</a-collapse-panel>
</a-collapse>
</div>
</section>
<section v-else-if="activeSection === 'login'" class="settings-form-page">
<div class="server-settings-card">
<div class="server-settings-row">
<div class="server-settings-copy">
<strong>服务器地址</strong>
<span>默认连接省心推官方服务私有化部署时可改为客户自己的网关 API 地址</span>
</div>
<input
v-model.trim="serverBaseURL"
class="server-input"
type="url"
:placeholder="DEFAULT_API_BASE_URL"
spellcheck="false"
@keydown.enter.prevent="saveServerSettings"
/>
</div>
<div class="server-actions">
<span class="server-current">当前: {{ apiBaseURL }}</span>
<button type="button" class="server-button" @click="resetServerSettings">
恢复默认
</button>
<button
type="button"
class="server-button server-button--primary"
:disabled="!hasServerSettingChanged"
@click="saveServerSettings"
>
保存
</button>
</div>
</div>
<p v-if="serverSettingsError" class="settings-error">{{ serverSettingsError }}</p>
<p v-else-if="serverSettingsMessage" class="settings-success">{{ serverSettingsMessage }}</p>
</section>
<section v-else-if="activeSection === 'general'" class="settings-form-page">
<div class="settings-list-card">
<div
v-for="row in generalRows"
:key="row.key"
class="settings-row"
:class="{ 'is-disabled': !settingsLoaded || (savingSetting !== null && savingSetting !== row.key) }"
role="switch"
:aria-checked="row.enabled"
tabindex="0"
@click="toggleAppSetting(row.key)"
@keydown.enter.prevent="toggleAppSetting(row.key)"
@keydown.space.prevent="toggleAppSetting(row.key)"
>
<span>{{ row.label }}</span>
<a-switch
class="row-switch"
:checked="row.enabled"
:loading="savingSetting === row.key"
:disabled="!settingsLoaded"
/>
</div>
</div>
<p v-if="settingsError" class="settings-error">{{ settingsError }}</p>
</section>
<section v-else class="settings-form-page">
<div class="settings-list-card">
<div class="settings-row settings-row--static">
<span>暂无可配置项</span>
<span class="row-value">-</span>
</div>
</div>
</section>
</div>
</main>
</section>
</template>
<style scoped>
.settings-shell {
display: grid;
grid-template-columns: 220px minmax(0, 1fr);
width: 100vw;
height: 100vh;
overflow: hidden;
background: #f4f4f5;
color: #0a0a0a;
font-family: "PingFang SC", "Microsoft YaHei", "Segoe UI", sans-serif;
}
.settings-nav {
height: 100vh;
padding: 50px 10px 18px;
background: #dedee4;
-webkit-app-region: drag;
}
.settings-nav__list {
display: flex;
flex-direction: column;
gap: 14px;
}
.settings-nav__item {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
min-height: 44px;
padding: 0 16px;
border: 0;
border-radius: 8px;
background: transparent;
color: #030303;
cursor: pointer;
font-size: 16px;
font-weight: 600;
line-height: 1;
text-align: left;
transition: background-color 0.16s ease;
-webkit-app-region: no-drag;
}
.settings-nav__item:hover {
background: rgba(197, 197, 203, 0.64);
}
.settings-nav__item.is-active {
background: #c9c9cf;
}
.settings-nav__icon {
width: 20px;
font-size: 20px;
color: #080808;
}
.settings-nav__badge {
margin-left: auto;
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 7px;
border-radius: 11px;
background: #c4c4ca;
color: #111111;
font-size: 12px;
font-weight: 700;
}
.settings-main {
position: relative;
display: flex;
min-width: 0;
height: 100vh;
flex-direction: column;
overflow: hidden;
background: #f6f6f7;
}
.settings-heading {
display: flex;
align-items: center;
height: 76px;
padding: 0 42px;
border-bottom: 1px solid #e7e7e8;
background: #f7f7f8;
-webkit-app-region: drag;
}
.settings-heading h1 {
margin: 0;
color: #000000;
font-size: 22px;
font-weight: 800;
line-height: 1;
}
.settings-body {
position: relative;
flex: 1;
min-height: 0;
overflow: auto;
}
.about-page {
position: relative;
min-height: 100%;
padding: 28px 44px 108px;
}
.about-version-card {
display: flex;
align-items: center;
min-height: 78px;
gap: 22px;
padding: 0 30px;
border-radius: 8px;
background: #ffffff;
}
.about-version-card strong {
color: #050505;
font-size: 17px;
font-weight: 800;
}
.about-logo {
position: relative;
display: grid;
width: 52px;
height: 52px;
flex: 0 0 auto;
place-items: center;
}
.brand-ring,
.brand-core {
position: absolute;
border-radius: 50%;
}
.brand-ring {
inset: 0;
border: 1px solid #8ec8ff;
background: #e5f3ff;
}
.brand-core {
width: 22px;
height: 22px;
background: #1677ff;
box-shadow: 0 3px 8px rgba(22, 119, 255, 0.35);
}
.about-footer {
position: absolute;
left: 0;
right: 0;
bottom: 34px;
color: #777777;
font-size: 14px;
text-align: center;
}
.about-footer p {
margin: 0;
}
.about-footer a {
color: #1677ff;
font-weight: 600;
text-decoration: none;
}
.tech-line {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 4px;
margin-top: 14px !important;
}
.tech-mark {
color: #d59a4a;
font-size: 24px;
line-height: 1;
}
.diagnostics-page,
.settings-form-page {
padding: 28px 44px;
}
.diagnostics-card,
.settings-list-card {
overflow: hidden;
border-radius: 8px;
background: #ffffff;
}
.server-settings-card {
display: flex;
flex-direction: column;
gap: 18px;
padding: 22px 28px;
border-radius: 8px;
background: #ffffff;
}
.server-settings-row {
display: grid;
grid-template-columns: minmax(180px, 0.72fr) minmax(260px, 1fr);
gap: 24px;
align-items: center;
}
.server-settings-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 7px;
}
.server-settings-copy strong {
color: #090909;
font-size: 15px;
font-weight: 800;
}
.server-settings-copy span {
color: #777777;
font-size: 13px;
font-weight: 600;
line-height: 1.45;
}
.server-input {
width: 100%;
height: 38px;
padding: 0 12px;
border: 1px solid #d8d8dc;
border-radius: 7px;
background: #f7f7f8;
color: #111111;
font-size: 14px;
font-weight: 600;
outline: none;
}
.server-input:focus {
border-color: #b7c6db;
background: #ffffff;
}
.server-actions {
display: flex;
align-items: center;
gap: 10px;
border-top: 1px solid #eeeeef;
padding-top: 16px;
}
.server-current {
min-width: 0;
flex: 1;
overflow: hidden;
color: #777777;
font-size: 13px;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.server-button {
height: 32px;
padding: 0 14px;
border: 0;
border-radius: 6px;
background: #eeeeef;
color: #111111;
cursor: pointer;
font-size: 13px;
font-weight: 800;
}
.server-button:hover:not(:disabled) {
background: #e3e3e5;
}
.server-button:disabled {
cursor: default;
opacity: 0.5;
}
.server-button--primary {
background: #1677ff;
color: #ffffff;
}
.server-button--primary:hover:not(:disabled) {
background: #0f6bea;
}
.card-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 22px 28px;
border-bottom: 1px solid #eeeeef;
}
.card-title-row h2 {
margin: 0;
color: #050505;
font-size: 18px;
font-weight: 800;
}
.card-title-row p {
margin: 6px 0 0;
color: #808080;
font-size: 13px;
}
.copy-button {
height: 32px;
padding: 0 16px;
border: 0;
border-radius: 6px;
background: #eeeeef;
color: #111111;
cursor: pointer;
font-size: 13px;
font-weight: 700;
}
.diagnostics-collapse {
background: transparent !important;
}
.collapse-item {
border-bottom: 1px solid #eeeeef !important;
background: #ffffff;
}
:deep(.ant-collapse-header) {
padding: 14px 28px !important;
color: #111111 !important;
font-weight: 700;
}
:deep(.ant-collapse-content) {
border-top: 1px solid #eeeeef !important;
}
.json-code {
max-height: 330px;
margin: 0;
padding: 16px;
overflow: auto;
border-radius: 6px;
background: #f5f5f6;
color: #333333;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-all;
}
.settings-row {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
min-height: 56px;
padding: 0 28px;
border: 0;
border-bottom: 1px solid #eeeeef;
background: #ffffff;
color: #090909;
cursor: pointer;
font-size: 15px;
font-weight: 700;
text-align: left;
outline: none;
}
.settings-row:last-child {
border-bottom: 0;
}
.settings-row:hover:not(.is-disabled):not(.settings-row--static) {
background: #fafafa;
}
.settings-row:focus-visible {
background: #f3f6fb;
}
.settings-row.is-disabled {
cursor: default;
opacity: 0.74;
}
.settings-row--static,
.settings-row--static:hover {
cursor: default;
background: #ffffff;
opacity: 1;
}
.row-switch {
pointer-events: none;
}
.row-value {
color: #7d7d7d;
font-weight: 600;
}
.settings-error {
margin: 12px 4px 0;
color: #d93025;
font-size: 13px;
font-weight: 600;
}
.settings-success {
margin: 12px 4px 0;
color: #27824a;
font-size: 13px;
font-weight: 600;
}
@media (max-width: 820px) {
.settings-shell {
grid-template-columns: 190px minmax(0, 1fr);
}
.settings-nav__item {
font-size: 15px;
}
.settings-heading,
.about-page,
.diagnostics-page,
.settings-form-page {
padding-left: 28px;
padding-right: 28px;
}
.server-settings-row {
grid-template-columns: 1fr;
gap: 14px;
}
}
</style>
@@ -0,0 +1,7 @@
<script setup lang="ts">
import SettingsView from "./SettingsView.vue";
</script>
<template>
<SettingsView />
</template>