收口账号菜单侧栏导航

This commit is contained in:
baiyanyun
2026-06-12 16:33:18 +08:00
parent 81d2797035
commit 6323987a0d
10 changed files with 458 additions and 71 deletions

View File

@@ -13,22 +13,25 @@ import {
Badge,
Spin,
Button,
Dropdown,
Modal,
notification,
message,
} from "antd";
import type { MenuProps } from "antd";
import type { PresetStatusColorType } from "antd/es/_util/colors";
import {
SettingOutlined,
DashboardOutlined,
FolderOutlined,
InfoCircleOutlined,
SafetyOutlined,
FileTextOutlined,
TeamOutlined,
ArrowLeftOutlined,
ReloadOutlined,
ApiOutlined,
AppstoreOutlined,
SkinOutlined,
QuestionCircleOutlined,
SyncOutlined,
LogoutOutlined,
UserOutlined,
} from "@ant-design/icons";
import {
setupService,
@@ -40,6 +43,7 @@ import {
syncConfigToServer,
normalizeServerHost,
loginAndRegister,
logout as authLogout,
} from "./services/core/auth";
import {
APP_DISPLAY_NAME,
@@ -65,6 +69,14 @@ import { createLogger } from "./services/utils/rendererLog";
import styles from "./styles/components/App.module.css";
import { lightTheme, darkTheme } from "./styles/theme";
import { FEATURES } from "@shared/featureFlags";
import {
ACCOUNT_MENU_KEYS,
PRIMARY_NAV_KEYS,
getAccountMenuAction,
type AccountMenuKey,
type AppTabKey,
type SettingsFocusTarget,
} from "./utils/sidebarNavigation";
// 主题类型
export type ThemeMode = "light" | "dark" | "system";
@@ -104,17 +116,7 @@ export function useI18nLang(): I18nContextValue {
}
// Tab 类型定义(对齐 Tauri 客户端)
type TabKey =
| "client"
| "overview"
| "sessions"
| "mcp"
| "settings"
| "dependencies"
| "permissions"
| "logs"
| "about"
| "model";
type TabKey = AppTabKey;
// 状态配置(对齐 Tauri 客户端)
// 就绪、繁忙使用橙色warning、小点展示
@@ -343,10 +345,14 @@ function App() {
// 核心状态
// ============================================
const [activeTab, setActiveTab] = useState<TabKey>("client");
const [settingsFocusTarget, setSettingsFocusTarget] =
useState<SettingsFocusTarget | null>(null);
const [settingsFocusNonce, setSettingsFocusNonce] = useState(0);
const [sessionsAutoOpen, setSessionsAutoOpen] = useState(false);
const [webviewActions, setWebviewActions] =
useState<WebviewHeaderActions | null>(null);
const [username, setUsername] = useState<string>("");
const [userAvatar, setUserAvatar] = useState<string>("");
const [onlineStatus, setOnlineStatus] = useState<boolean | null>(null);
const [agentStatus, setAgentStatus] = useState<string>("idle");
const [services, setServices] = useState<ServiceItem[]>([]);
@@ -432,6 +438,7 @@ function App() {
setUsername(
user.displayName || user.username || t("Claw.App.defaultUsername"),
);
setUserAvatar(user.avatar || "");
}
// 加载在线状态
@@ -452,8 +459,56 @@ function App() {
setUsername(
user.displayName || user.username || t("Claw.App.defaultUsername"),
);
setUserAvatar(user.avatar || "");
} else {
setUsername("");
setUserAvatar("");
}
}, []);
const navigateToSettings = useCallback((focus?: SettingsFocusTarget) => {
if (focus) {
setSettingsFocusTarget(focus);
setSettingsFocusNonce((value) => value + 1);
} else {
setSettingsFocusTarget(null);
}
setActiveTab("settings");
}, []);
const handleCheckUpdate = useCallback(async () => {
setUpdateState((prev) => ({ ...prev, status: "checking" }));
try {
const result = await window.electronAPI?.app?.checkUpdate();
if (!result) {
setUpdateState({ status: "idle" });
return;
}
if (result.alreadyChecking) {
const state = await window.electronAPI?.app?.getUpdateState?.();
if (state) setUpdateState(state);
return;
}
if (result.error) {
message.error(
t("Claw.About.checkFailedWithDetail", { error: result.error }),
);
} else if (!result.hasUpdate) {
message.info(t("Claw.About.alreadyLatest"));
}
const authoritative = await window.electronAPI?.app?.getUpdateState?.();
if (authoritative) {
setUpdateState(authoritative);
} else if (result.error || !result.hasUpdate) {
setUpdateState({ status: "idle" });
}
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
message.error(t("Claw.About.checkFailedWithDetail", { error: detail }));
setUpdateState({ status: "idle" });
}
}, []);
@@ -626,6 +681,71 @@ function App() {
}
}, []);
const handleLogout = useCallback(() => {
Modal.confirm({
title: t("Claw.Client.logoutConfirm"),
content: t("Claw.Client.logoutConfirmDetail"),
okText: t("Claw.Client.logout"),
cancelText: t("Claw.Client.cancel"),
okButtonProps: { danger: true },
onOk: async () => {
try {
const toStop = services.filter((svc) => svc.running || !!svc.error);
for (const svc of toStop) {
try {
if (svc.key === "agent") {
await window.electronAPI?.agent.destroy();
} else if (svc.key === "fileServer") {
await window.electronAPI?.fileServer.stop();
} else if (svc.key === "lanproxy") {
await window.electronAPI?.lanproxy.stop();
} else if (svc.key === "mcpProxy") {
await window.electronAPI?.mcp.stop();
} else if (svc.key === "guiServer") {
await window.electronAPI?.guiServer?.stop();
}
} catch (error) {
console.error(`[App] Failed to stop ${svc.label}:`, error);
}
}
await window.electronAPI?.computerServer.stop().catch((error) => {
console.error("[App] Failed to stop computerServer:", error);
});
await authLogout();
await handleAuthChange();
setAuthRefreshTrigger((value) => value + 1);
await pollServicesStatus();
setActiveTab("client");
} catch {
message.error(t("Claw.Client.logoutFailed"));
}
},
});
}, [handleAuthChange, pollServicesStatus, services]);
const handleAccountMenuClick = useCallback(
({ key }: { key: string }) => {
const action = getAccountMenuAction(key as AccountMenuKey);
if ("tab" in action) {
if (action.tab === "settings") {
navigateToSettings(action.settingsFocus);
} else {
setSettingsFocusTarget(null);
setActiveTab(action.tab);
}
return;
}
if (action.command === "checkUpdate") {
handleCheckUpdate();
} else if (action.command === "logout") {
handleLogout();
}
},
[handleCheckUpdate, handleLogout, navigateToSettings],
);
// ============================================
// 逐个启动服务(实时更新状态)
// ============================================
@@ -776,6 +896,7 @@ function App() {
user.username ||
t("Claw.App.defaultUsername"),
);
setUserAvatar(user.avatar || "");
}
setAuthRefreshTrigger((v) => v + 1);
await startServicesSequentially(await getStartupServiceKeys());
@@ -1042,64 +1163,66 @@ function App() {
// ============================================
const badge = STATUS_CONFIG[agentStatus] || STATUS_CONFIG.idle;
// ============================================
// 平台检测
// ============================================
const isMacOS = navigator.platform.toUpperCase().includes("MAC");
// ============================================
// 菜单配置(对齐 Tauri 客户端)
// ============================================
const menuItems = useMemo(() => {
const items = [
{
const itemMap = {
client: {
key: "client",
icon: <DashboardOutlined />,
label: t("Claw.Menu.client"),
},
{
overview: {
key: "overview",
icon: <AppstoreOutlined />,
label: t("Claw.Menu.overview"),
},
{
sessions: {
key: "sessions",
icon: <TeamOutlined />,
label: t("Claw.Menu.session"),
},
{
key: "mcp",
icon: <ApiOutlined />,
label: t("Claw.Menu.mcp"),
},
{
} satisfies Record<
(typeof PRIMARY_NAV_KEYS)[number],
NonNullable<MenuProps["items"]>[number]
>;
return PRIMARY_NAV_KEYS.map((key) => itemMap[key]);
}, [i18nLang]);
const accountMenuItems = useMemo<MenuProps["items"]>(() => {
const itemMap = {
settings: {
key: "settings",
icon: <SettingOutlined />,
label: t("Claw.Menu.settings"),
label: t("Claw.AccountMenu.settings"),
},
{
key: "dependencies",
icon: <FolderOutlined />,
label: t("Claw.Menu.dependencies"),
appearance: {
key: "appearance",
icon: <SkinOutlined />,
label: t("Claw.AccountMenu.appearance"),
},
];
if (isMacOS) {
items.push({
key: "permissions",
icon: <SafetyOutlined />,
label: t("Claw.Menu.authorization"),
});
}
items.push(
{ key: "logs", icon: <FileTextOutlined />, label: t("Claw.Menu.logs") },
{
key: "about",
icon: <InfoCircleOutlined />,
label: t("Claw.Menu.about"),
helpFeedback: {
key: "helpFeedback",
icon: <QuestionCircleOutlined />,
label: t("Claw.AccountMenu.helpFeedback"),
},
);
return items;
}, [isMacOS, i18nLang]);
checkUpdate: {
key: "checkUpdate",
icon: <SyncOutlined />,
label: t("Claw.AccountMenu.checkUpdate"),
},
logout: {
key: "logout",
danger: true,
icon: <LogoutOutlined />,
label: t("Claw.AccountMenu.logout"),
},
} satisfies Record<AccountMenuKey, NonNullable<MenuProps["items"]>[number]>;
return ACCOUNT_MENU_KEYS.map((key) => itemMap[key]);
}, [i18nLang]);
// ============================================
// i18n Context value
@@ -1310,22 +1433,56 @@ function App() {
className={
// 英文菜单文案通常更长,侧边栏适当加宽以减少截断;其他语言保持默认宽度
i18nLang.toLowerCase().startsWith("en")
? "app-sider app-sider-en"
: "app-sider"
? `app-sider app-sider-en ${styles.siderWithAccount}`
: `app-sider ${styles.siderWithAccount}`
}
>
<div className={styles.siderNavArea}>
<Menu
mode="inline"
inlineIndent={0}
selectedKeys={[activeTab]}
items={menuItems.map((item) => ({
key: item.key,
icon: item.icon,
label: item.label,
onClick: () => setActiveTab(item.key as TabKey),
selectedKeys={
PRIMARY_NAV_KEYS.includes(activeTab as any)
? [activeTab]
: []
}
items={menuItems?.map((item) => ({
...item,
onClick: () => setActiveTab(item?.key as TabKey),
}))}
/>
</div>
<Dropdown
trigger={["click"]}
placement="topLeft"
menu={{
items: accountMenuItems,
onClick: handleAccountMenuClick,
}}
>
<button
type="button"
className={styles.accountButton}
aria-label={t("Claw.AccountMenu.open")}
>
<span className={styles.accountAvatar}>
{userAvatar ? (
<img src={userAvatar} alt="" />
) : (
<UserOutlined />
)}
</span>
<span className={styles.accountText}>
<span className={styles.accountName}>
{username || t("Claw.App.defaultUsername")}
</span>
<span className={styles.accountHint}>
{t("Claw.AccountMenu.hint")}
</span>
</span>
</button>
</Dropdown>
</div>
)}
{/* 主内容区flex 子撑满,便于日志等页占满高度 */}
@@ -1370,7 +1527,12 @@ function App() {
/>
)}
{activeTab === "mcp" && <MCPSettings />}
{activeTab === "settings" && <SettingsPage />}
{activeTab === "settings" && (
<SettingsPage
focusTarget={settingsFocusTarget}
focusNonce={settingsFocusNonce}
/>
)}
{activeTab === "dependencies" && <DependenciesPage />}
{activeTab === "permissions" && <PermissionsPage />}
{activeTab === "logs" && <LogViewer />}

View File

@@ -7,7 +7,13 @@
* - 系统设置(主题、开机自启动、日志目录)
*/
import React, { useState, useEffect, useCallback, Suspense } from "react";
import React, {
useState,
useEffect,
useCallback,
Suspense,
useRef,
} from "react";
import {
Button,
Form,
@@ -59,6 +65,7 @@ import i18next from "../../services/i18n";
import styles from "../../styles/components/ClientPage.module.css";
import { useTheme, useI18nLang, type ThemeMode } from "../../App";
import type { SettingsFocusTarget } from "../../utils/sidebarNavigation";
import type {
SandboxCapabilities,
SandboxPolicy,
@@ -95,9 +102,18 @@ const LOCAL_LANG_OPTIONS = [
{ value: "zh-hk", label: t("Claw.Settings.system.langChineseHK") },
];
export default function SettingsPage() {
interface SettingsPageProps {
focusTarget?: SettingsFocusTarget | null;
focusNonce?: number;
}
export default function SettingsPage({
focusTarget,
focusNonce = 0,
}: SettingsPageProps) {
// 主题
const { themeMode, setThemeMode } = useTheme();
const appearanceRowRef = useRef<HTMLDivElement | null>(null);
// 服务配置
const [form] = Form.useForm<Step1Config>();
@@ -264,7 +280,24 @@ export default function SettingsPage() {
handleAutolaunchChanged as any,
);
};
}, [loadConfig, loadAiConfig, loadSystemSettings, loadSandboxState]);
}, [
loadConfig,
loadAiConfig,
loadSystemSettings,
loadSandboxState,
loadGuiMcpState,
]);
useEffect(() => {
if (focusTarget !== "appearance") return;
window.requestAnimationFrame(() => {
appearanceRowRef.current?.scrollIntoView({
behavior: "smooth",
block: "center",
});
appearanceRowRef.current?.focus({ preventScroll: true });
});
}, [focusTarget, focusNonce]);
// ========== 加载语言列表 ==========
useEffect(() => {
@@ -786,7 +819,11 @@ export default function SettingsPage() {
</div>
{/* 主题设置 */}
<div className={styles.serviceRow}>
<div
ref={appearanceRowRef}
className={styles.serviceRow}
tabIndex={-1}
>
<div className={styles.serviceInfo}>
<div>
<span className={styles.serviceLabel}>

View File

@@ -36,6 +36,7 @@ export interface AuthUserInfo {
id?: number;
username: string;
displayName?: string;
avatar?: string;
token?: string;
userId?: string;
email?: string;

View File

@@ -35,3 +35,81 @@
align-items: center;
gap: 6px;
}
.siderWithAccount {
display: flex;
flex-direction: column;
overflow: hidden;
}
.siderNavArea {
flex: 1;
min-height: 0;
overflow-y: auto;
padding-top: 4px;
}
.accountButton {
width: calc(100% - 16px);
min-height: 54px;
margin: 8px;
padding: 8px;
border: 0;
border-radius: var(--border-radius);
background: transparent;
color: var(--color-text);
display: flex;
align-items: center;
gap: 8px;
text-align: left;
cursor: pointer;
transition: background-color 0.16s ease;
}
.accountButton:hover,
.accountButton:focus-visible {
background: var(--color-bg-section-header);
outline: none;
}
.accountAvatar {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color-bg-elevated);
color: var(--color-icon);
border: 1px solid var(--color-border);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
}
.accountAvatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.accountText {
min-width: 0;
display: flex;
flex-direction: column;
line-height: 1.25;
}
.accountName {
color: var(--color-text);
font-size: 13px;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.accountHint {
color: var(--color-text-tertiary);
font-size: 11px;
margin-top: 2px;
}

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import {
ACCOUNT_MENU_KEYS,
PRIMARY_NAV_KEYS,
getAccountMenuAction,
} from "./sidebarNavigation";
describe("sidebar navigation", () => {
it("keeps only client, overview, and sessions in the primary sidebar", () => {
expect(PRIMARY_NAV_KEYS).toEqual(["client", "overview", "sessions"]);
});
it("maps account menu entries to internal navigation or commands", () => {
expect(ACCOUNT_MENU_KEYS).toEqual([
"settings",
"appearance",
"helpFeedback",
"checkUpdate",
"logout",
]);
expect(getAccountMenuAction("settings")).toEqual({ tab: "settings" });
expect(getAccountMenuAction("appearance")).toEqual({
tab: "settings",
settingsFocus: "appearance",
});
expect(getAccountMenuAction("helpFeedback")).toEqual({ tab: "about" });
expect(getAccountMenuAction("checkUpdate")).toEqual({
command: "checkUpdate",
});
expect(getAccountMenuAction("logout")).toEqual({ command: "logout" });
});
});

View File

@@ -0,0 +1,49 @@
export type AppTabKey =
| "client"
| "overview"
| "sessions"
| "mcp"
| "settings"
| "dependencies"
| "permissions"
| "logs"
| "about"
| "model";
export type SettingsFocusTarget = "appearance";
export type AccountMenuKey =
| "settings"
| "appearance"
| "helpFeedback"
| "checkUpdate"
| "logout";
export type AccountMenuAction =
| { tab: AppTabKey; settingsFocus?: SettingsFocusTarget }
| { command: "checkUpdate" | "logout" };
export const PRIMARY_NAV_KEYS = ["client", "overview", "sessions"] as const;
export const ACCOUNT_MENU_KEYS = [
"settings",
"appearance",
"helpFeedback",
"checkUpdate",
"logout",
] as const;
export function getAccountMenuAction(key: AccountMenuKey): AccountMenuAction {
switch (key) {
case "settings":
return { tab: "settings" };
case "appearance":
return { tab: "settings", settingsFocus: "appearance" };
case "helpFeedback":
return { tab: "about" };
case "checkUpdate":
return { command: "checkUpdate" };
case "logout":
return { command: "logout" };
}
}

View File

@@ -172,6 +172,13 @@
"Claw.Menu.authorization": "Authorization",
"Claw.Menu.logs": "Logs",
"Claw.Menu.about": "About",
"Claw.AccountMenu.open": "Open account menu",
"Claw.AccountMenu.hint": "Account & settings",
"Claw.AccountMenu.settings": "Settings",
"Claw.AccountMenu.appearance": "Appearance",
"Claw.AccountMenu.helpFeedback": "Help & Feedback",
"Claw.AccountMenu.checkUpdate": "Check for Updates",
"Claw.AccountMenu.logout": "Log Out",
"Claw.App.ConfigSyncFailed": "Config Sync Failed",
"Claw.App.ConfigSyncFailedDetail": "Unable to connect to server for latest config. Services not restarted. Please check your network and try again.",
"Claw.App.Retry": "Retry",

View File

@@ -172,6 +172,13 @@
"Claw.Menu.authorization": "授权",
"Claw.Menu.logs": "日志",
"Claw.Menu.about": "关于",
"Claw.AccountMenu.open": "打开账户菜单",
"Claw.AccountMenu.hint": "账户与设置",
"Claw.AccountMenu.settings": "设置",
"Claw.AccountMenu.appearance": "外观",
"Claw.AccountMenu.helpFeedback": "帮助与反馈",
"Claw.AccountMenu.checkUpdate": "检查更新",
"Claw.AccountMenu.logout": "退出登录",
"Claw.App.ConfigSyncFailed": "配置同步失败",
"Claw.App.ConfigSyncFailedDetail": "无法连接到服务器获取最新配置,服务未重启。请检查网络后重试。",
"Claw.App.Retry": "重试",

View File

@@ -172,6 +172,13 @@
"Claw.Menu.authorization": "授權",
"Claw.Menu.logs": "日誌",
"Claw.Menu.about": "關於",
"Claw.AccountMenu.open": "開啟帳戶選單",
"Claw.AccountMenu.hint": "帳戶與設定",
"Claw.AccountMenu.settings": "設定",
"Claw.AccountMenu.appearance": "外觀",
"Claw.AccountMenu.helpFeedback": "幫助與反饋",
"Claw.AccountMenu.checkUpdate": "檢查更新",
"Claw.AccountMenu.logout": "退出登入",
"Claw.App.ConfigSyncFailed": "配置同步失敗",
"Claw.App.ConfigSyncFailedDetail": "無法連接到服務器獲取最新配置,服務未重啟。請檢查網絡後重試。",
"Claw.App.Retry": "重試",

View File

@@ -172,6 +172,13 @@
"Claw.Menu.authorization": "授權",
"Claw.Menu.logs": "日誌",
"Claw.Menu.about": "關於",
"Claw.AccountMenu.open": "開啟帳戶選單",
"Claw.AccountMenu.hint": "帳戶與設定",
"Claw.AccountMenu.settings": "設定",
"Claw.AccountMenu.appearance": "外觀",
"Claw.AccountMenu.helpFeedback": "幫助與回饋",
"Claw.AccountMenu.checkUpdate": "檢查更新",
"Claw.AccountMenu.logout": "退出登入",
"Claw.App.ConfigSyncFailed": "配置同步失敗",
"Claw.App.ConfigSyncFailedDetail": "無法連接到服務器獲取最新配置,服務未重啟。請檢查網絡後重試。",
"Claw.App.Retry": "重試",