Compare commits

2 Commits

Author SHA1 Message Date
baiyanyun
a574b2c661 重构客户端左侧导航 2026-06-30 15:49:58 +08:00
baiyanyun
5d87c9eee7 重构客户端登录页并更新品牌图标 2026-06-30 15:09:46 +08:00
20 changed files with 1324 additions and 600 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "@qiming-ai/qimingclaw", "name": "@qiming-ai/qimingclaw",
"version": "0.9.7", "version": "0.9.7",
"description": "启明数字员工", "description": "陇电数字员工",
"main": "dist/main/main.js", "main": "dist/main/main.js",
"author": "Qiming Team", "author": "Qiming Team",
"license": "MIT", "license": "MIT",
@@ -128,7 +128,7 @@
}, },
"build": { "build": {
"appId": "com.qiming.qimingclaw", "appId": "com.qiming.qimingclaw",
"productName": "启明数字员工", "productName": "陇电数字员工",
"afterSign": "scripts/build/after-sign.js", "afterSign": "scripts/build/after-sign.js",
"electronLanguages": [ "electronLanguages": [
"en", "en",
@@ -297,8 +297,8 @@
"hardenedRuntime": true, "hardenedRuntime": true,
"extendInfo": { "extendInfo": {
"CFBundleIdentifier": "com.qiming.agent", "CFBundleIdentifier": "com.qiming.agent",
"CFBundleName": "启明数字员工", "CFBundleName": "陇电数字员工",
"CFBundleDisplayName": "启明数字员工", "CFBundleDisplayName": "陇电数字员工",
"NSDesktopFolderUsageDescription": "需要访问桌面文件夹以支持文件操作", "NSDesktopFolderUsageDescription": "需要访问桌面文件夹以支持文件操作",
"NSDocumentsFolderUsageDescription": "需要访问文档文件夹以支持文件操作", "NSDocumentsFolderUsageDescription": "需要访问文档文件夹以支持文件操作",
"NSDownloadsFolderUsageDescription": "需要访问下载文件夹以支持文件操作" "NSDownloadsFolderUsageDescription": "需要访问下载文件夹以支持文件操作"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 672 KiB

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -9,26 +9,13 @@ import React, {
} from "react"; } from "react";
import { import {
ConfigProvider, ConfigProvider,
Menu,
Badge,
Spin, Spin,
Button, Button,
Modal,
notification, notification,
message, message,
} from "antd"; } from "antd";
import type { PresetStatusColorType } from "antd/es/_util/colors"; import { ArrowLeftOutlined, ReloadOutlined } from "@ant-design/icons";
import {
SettingOutlined,
DashboardOutlined,
FolderOutlined,
InfoCircleOutlined,
SafetyOutlined,
FileTextOutlined,
TeamOutlined,
ArrowLeftOutlined,
ReloadOutlined,
ApiOutlined,
} from "@ant-design/icons";
import { import {
setupService, setupService,
authService, authService,
@@ -39,16 +26,14 @@ import {
syncConfigToServer, syncConfigToServer,
normalizeServerHost, normalizeServerHost,
loginAndRegister, loginAndRegister,
logout,
isLoggedIn as checkIsLoggedIn,
} from "./services/core/auth"; } from "./services/core/auth";
import { import { AUTH_KEYS, normalizeAgentEngine } from "@shared/constants";
APP_DISPLAY_NAME,
AUTH_KEYS,
normalizeAgentEngine,
} from "@shared/constants";
import type { QuickInitConfig } from "@shared/types/quickInit"; import type { QuickInitConfig } from "@shared/types/quickInit";
import type { UpdateState } from "@shared/types/updateTypes";
import { t, getCurrentLang } from "./services/core/i18n"; import { t, getCurrentLang } from "./services/core/i18n";
import SetupWizard from "./components/setup/SetupWizard"; import SetupWizard from "./components/setup/SetupWizard";
import LoginPage from "./components/setup/LoginPage";
import SetupDependencies from "./components/setup/SetupDependencies"; import SetupDependencies from "./components/setup/SetupDependencies";
import ClientPage from "./components/pages/ClientPage"; import ClientPage from "./components/pages/ClientPage";
import SettingsPage from "./components/pages/SettingsPage"; import SettingsPage from "./components/pages/SettingsPage";
@@ -58,6 +43,10 @@ import LogViewer from "./components/pages/LogViewer";
import PermissionsPage from "./components/pages/PermissionsPage"; import PermissionsPage from "./components/pages/PermissionsPage";
import SessionsPage from "./components/pages/SessionsPage"; import SessionsPage from "./components/pages/SessionsPage";
import MCPSettings from "./components/settings/MCPSettings"; import MCPSettings from "./components/settings/MCPSettings";
import AppSidebar, {
NavKey,
LegacyTabKey,
} from "./components/layout/AppSidebar";
import type { WebviewHeaderActions } from "./components/pages/SessionsPage"; import type { WebviewHeaderActions } from "./components/pages/SessionsPage";
import { createLogger } from "./services/utils/rendererLog"; import { createLogger } from "./services/utils/rendererLog";
import styles from "./styles/components/App.module.css"; import styles from "./styles/components/App.module.css";
@@ -110,27 +99,7 @@ type TabKey =
| "dependencies" | "dependencies"
| "permissions" | "permissions"
| "logs" | "logs"
| "about" | "about";
| "model";
// 状态配置(对齐 Tauri 客户端)
// 就绪、繁忙使用橙色warning、小点展示
const STATUS_CONFIG: Record<
string,
{ status: PresetStatusColorType; textKey: string }
> = {
idle: { status: "warning", textKey: "Claw.Agent.Status.idle" },
starting: { status: "processing", textKey: "Claw.Agent.Status.starting" },
running: { status: "success", textKey: "Claw.Agent.Status.running" },
busy: { status: "warning", textKey: "Claw.Agent.Status.busy" },
stopped: { status: "default", textKey: "Claw.Agent.Status.stopped" },
error: { status: "error", textKey: "Claw.Agent.Status.error" },
};
/** macOS/Linux 无 download-progress 时header tag 用本地模拟进度避免长期显示 0%。 */
const HEADER_SIMULATED_PROGRESS_CAP = 90;
const HEADER_SIMULATED_PROGRESS_INTERVAL_MS = 500;
const HEADER_SIMULATED_DURATION_MS = 45_000;
// 服务状态接口(与 ClientPage 共享) // 服务状态接口(与 ClientPage 共享)
export interface ServiceItem { export interface ServiceItem {
@@ -187,6 +156,9 @@ function App() {
// 初始化向导状态 // 初始化向导状态
// ============================================ // ============================================
const [isSetupComplete, setIsSetupComplete] = useState<boolean | null>(null); const [isSetupComplete, setIsSetupComplete] = useState<boolean | null>(null);
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null);
const [loginStep1Config, setLoginStep1Config] =
useState<Step1Config>(DEFAULT_STEP1_CONFIG);
const setupJustCompleted = useRef(false); const setupJustCompleted = useRef(false);
// 内存变量:标记服务是否由登录流程启动(不持久化) // 内存变量:标记服务是否由登录流程启动(不持久化)
const loginStartedRef = useRef(false); const loginStartedRef = useRef(false);
@@ -340,36 +312,21 @@ function App() {
// 核心状态 // 核心状态
// ============================================ // ============================================
const [activeTab, setActiveTab] = useState<TabKey>("client"); const [activeTab, setActiveTab] = useState<TabKey>("client");
const [activeNavKey, setActiveNavKey] = useState<NavKey>("overview");
const [sessionsAutoOpen, setSessionsAutoOpen] = useState(false); const [sessionsAutoOpen, setSessionsAutoOpen] = useState(false);
const [webviewActions, setWebviewActions] = const [webviewActions, setWebviewActions] =
useState<WebviewHeaderActions | null>(null); useState<WebviewHeaderActions | null>(null);
const [username, setUsername] = useState<string>(""); const [username, setUsername] = useState<string>("");
const [userEmail, setUserEmail] = useState<string>("");
const [onlineStatus, setOnlineStatus] = useState<boolean | null>(null); const [onlineStatus, setOnlineStatus] = useState<boolean | null>(null);
const [agentStatus, setAgentStatus] = useState<string>("idle");
const [services, setServices] = useState<ServiceItem[]>([]); const [services, setServices] = useState<ServiceItem[]>([]);
const [servicesLoading, setServicesLoading] = useState(true); const [servicesLoading, setServicesLoading] = useState(true);
const [guiMcpEnabled, setGuiMcpEnabled] = useState(false);
const [pollFailCount, setPollFailCount] = useState(0);
const [startingServices, setStartingServices] = useState<Set<string>>( const [startingServices, setStartingServices] = useState<Set<string>>(
new Set(), new Set(),
); );
const servicesPollTimer = useRef<ReturnType<typeof setInterval> | null>(null); const servicesPollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
/** 递增后通知 ClientPage 刷新账号状态(用户名等),与 reg 返回保持一致 */ /** 递增后通知 ClientPage 刷新账号状态(用户名等),与 reg 返回保持一致 */
const [authRefreshTrigger, setAuthRefreshTrigger] = useState(0); const [authRefreshTrigger, setAuthRefreshTrigger] = useState(0);
const [updateState, setUpdateState] = useState<UpdateState>({
status: "idle",
});
const [headerSimulatedPercent, setHeaderSimulatedPercent] = useState(0);
const headerSimulatedIntervalRef = useRef<ReturnType<
typeof setInterval
> | null>(null);
const statusExpectedKeys = useMemo(() => {
const keys = ["mcpProxy", "agent", "fileServer", "lanproxy"];
if (FEATURES.ENABLE_GUI_AGENT_SERVER && guiMcpEnabled) {
keys.splice(3, 0, "guiServer");
}
return keys;
}, [guiMcpEnabled]);
const getStartupServiceKeys = useCallback(async (): Promise<string[]> => { const getStartupServiceKeys = useCallback(async (): Promise<string[]> => {
const keys = ["mcpProxy", "agent", "fileServer", "lanproxy"]; const keys = ["mcpProxy", "agent", "fileServer", "lanproxy"];
if (!FEATURES.ENABLE_GUI_AGENT_SERVER) return keys; if (!FEATURES.ENABLE_GUI_AGENT_SERVER) return keys;
@@ -392,6 +349,10 @@ function App() {
const checkSetup = async () => { const checkSetup = async () => {
try { try {
const completed = await setupService.isSetupCompleted(); const completed = await setupService.isSetupCompleted();
const step1 = await setupService.getStep1Config();
setLoginStep1Config(step1);
const loggedIn = await checkIsLoggedIn();
setIsLoggedIn(loggedIn);
// 每次启动优先读取 quick init 配置 // 每次启动优先读取 quick init 配置
if (completed) { if (completed) {
@@ -411,6 +372,7 @@ function App() {
} catch (error) { } catch (error) {
log.error("Failed to check setup status:", error); log.error("Failed to check setup status:", error);
setIsSetupComplete(true); setIsSetupComplete(true);
setIsLoggedIn(false);
} }
}; };
checkSetup(); checkSetup();
@@ -429,6 +391,7 @@ function App() {
setUsername( setUsername(
user.displayName || user.username || t("Claw.App.defaultUsername"), user.displayName || user.username || t("Claw.App.defaultUsername"),
); );
setUserEmail(user.email || user.phone || user.username || "");
} }
// 加载在线状态 // 加载在线状态
@@ -445,12 +408,16 @@ function App() {
// ============================================ // ============================================
const handleAuthChange = useCallback(async () => { const handleAuthChange = useCallback(async () => {
const user = await authService.getAuthUser(); const user = await authService.getAuthUser();
const loggedIn = await checkIsLoggedIn();
setIsLoggedIn(loggedIn);
if (user) { if (user) {
setUsername( setUsername(
user.displayName || user.username || t("Claw.App.defaultUsername"), user.displayName || user.username || t("Claw.App.defaultUsername"),
); );
setUserEmail(user.email || user.phone || user.username || "");
} else { } else {
setUsername(""); setUsername("");
setUserEmail("");
} }
}, []); }, []);
@@ -459,6 +426,29 @@ function App() {
loginStartedRef.current = true; loginStartedRef.current = true;
}, []); }, []);
const ensureDefaultLoginConfig = useCallback(async () => {
const currentConfig = await setupService.getStep1Config();
const nextConfig: Step1Config = {
...DEFAULT_STEP1_CONFIG,
...currentConfig,
serverHost: currentConfig.serverHost || DEFAULT_STEP1_CONFIG.serverHost,
workspaceDir:
currentConfig.workspaceDir || DEFAULT_STEP1_CONFIG.workspaceDir,
};
await setupService.saveStep1Config(nextConfig);
setLoginStep1Config(nextConfig);
return nextConfig;
}, []);
const handleFullscreenLoginSuccess = useCallback(async () => {
await setupService.completeStep2();
await setupService.completeSetup();
setupJustCompleted.current = true;
setIsSetupComplete(true);
await handleAuthChange();
setAuthRefreshTrigger((v) => v + 1);
}, [handleAuthChange]);
// ============================================ // ============================================
// 主界面下必需依赖检查:仅当存在「未安装」或「错误」时进入依赖安装 // 主界面下必需依赖检查:仅当存在「未安装」或「错误」时进入依赖安装
// 版本以当前真实安装为准outdated 不触发(用户可在依赖 Tab 手动升级) // 版本以当前真实安装为准outdated 不触发(用户可在依赖 Tab 手动升级)
@@ -561,7 +551,6 @@ function App() {
]); ]);
const isGuiEnabled = const isGuiEnabled =
FEATURES.ENABLE_GUI_AGENT_SERVER && (guiEnabledRes?.enabled ?? false); FEATURES.ENABLE_GUI_AGENT_SERVER && (guiEnabledRes?.enabled ?? false);
setGuiMcpEnabled(isGuiEnabled);
items.push({ items.push({
key: "mcpProxy", key: "mcpProxy",
label: t("Claw.Service.mcp"), label: t("Claw.Service.mcp"),
@@ -614,10 +603,8 @@ function App() {
error: lpStatus?.error, error: lpStatus?.error,
}); });
setServices(items); setServices(items);
setPollFailCount(0);
} catch (error) { } catch (error) {
console.error("[App] pollServicesStatus failed:", error); console.error("[App] pollServicesStatus failed:", error);
setPollFailCount((count) => count + 1);
} finally { } finally {
setServicesLoading(false); setServicesLoading(false);
} }
@@ -803,57 +790,6 @@ function App() {
getStartupServiceKeys, getStartupServiceKeys,
]); ]);
// ============================================
// 根据服务状态计算 Agent 状态
// ============================================
// 根据服务状态计算 Agent 状态(对齐 Tauri 客户端逻辑)
useEffect(() => {
// 如果正在加载,保持当前状态不变(避免初始加载时的闪烁)
if (servicesLoading) {
return;
}
if (statusExpectedKeys.length === 0) {
setAgentStatus("idle");
return;
}
const serviceMap = new Map(services.map((s) => [s.key, s]));
const trackedServices = statusExpectedKeys.map((key) =>
serviceMap.get(key),
);
const runningCount = trackedServices.filter((s) => s?.running).length;
const totalCount = statusExpectedKeys.length;
const hasErrors = trackedServices.some((s) => !!s?.error);
const hasStartingServices = Array.from(startingServices).some((key) =>
statusExpectedKeys.includes(key),
);
const hasStaleServiceStatus = pollFailCount >= 2;
if (hasStaleServiceStatus) {
// 连续轮询失败时,避免继续展示可能过期的 running 状态。
setAgentStatus("busy");
} else if (hasErrors) {
setAgentStatus("error");
} else if (hasStartingServices) {
setAgentStatus("starting");
} else if (runningCount === totalCount && runningCount > 0) {
setAgentStatus("running");
} else if (runningCount > 0 && runningCount < totalCount) {
setAgentStatus("busy");
} else if (runningCount === 0) {
setAgentStatus("stopped");
} else {
setAgentStatus("idle");
}
}, [
services,
servicesLoading,
startingServices,
statusExpectedKeys,
pollFailCount,
]);
// 启动服务状态轮询 // 启动服务状态轮询
useEffect(() => { useEffect(() => {
if (isSetupComplete !== true) return; if (isSetupComplete !== true) return;
@@ -871,55 +807,6 @@ function App() {
}; };
}, [isSetupComplete]); }, [isSetupComplete]);
// ============================================
// 监听更新状态header tag 展示)
// ============================================
useEffect(() => {
const handler = (state: UpdateState) => {
if (state) setUpdateState(state);
};
window.electronAPI?.on("update:status", handler as any);
window.electronAPI?.app?.getUpdateState?.()?.then((state) => {
if (state) setUpdateState(state);
});
return () => {
window.electronAPI?.off("update:status", handler as any);
};
}, []);
useEffect(() => {
const isDownloading = updateState.status === "downloading";
const hasRealProgress = updateState.progress != null;
if (isDownloading && !hasRealProgress) {
setHeaderSimulatedPercent(0);
const increment =
(HEADER_SIMULATED_PROGRESS_CAP / HEADER_SIMULATED_DURATION_MS) *
HEADER_SIMULATED_PROGRESS_INTERVAL_MS;
const id = setInterval(() => {
setHeaderSimulatedPercent((prev) => {
const next = prev + increment;
return next >= HEADER_SIMULATED_PROGRESS_CAP
? HEADER_SIMULATED_PROGRESS_CAP
: next;
});
}, HEADER_SIMULATED_PROGRESS_INTERVAL_MS);
headerSimulatedIntervalRef.current = id;
return () => {
clearInterval(id);
headerSimulatedIntervalRef.current = null;
};
}
if (!isDownloading || hasRealProgress) {
if (headerSimulatedIntervalRef.current) {
clearInterval(headerSimulatedIntervalRef.current);
headerSimulatedIntervalRef.current = null;
}
setHeaderSimulatedPercent(0);
}
}, [updateState.status, updateState.progress]);
// ============================================ // ============================================
// 监听托盘/菜单事件 // 监听托盘/菜单事件
// ============================================ // ============================================
@@ -1034,65 +921,103 @@ function App() {
setIsSetupComplete(true); setIsSetupComplete(true);
}; };
// ============================================ const stopServicesForLogout = useCallback(async () => {
// 状态 Badge const toStop = services.filter((svc) => svc.running || !!svc.error);
// ============================================ for (const svc of toStop) {
const badge = STATUS_CONFIG[agentStatus] || STATUS_CONFIG.idle; 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 (e) {
console.error(`[App] Failed to stop ${svc.label}:`, e);
}
}
await window.electronAPI?.computerServer.stop().catch((e: unknown) => {
console.error("[App] Failed to stop computerServer:", e);
});
}, [services]);
const handleSidebarLogout = 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 {
await stopServicesForLogout();
await logout();
await handleAuthChange();
setIsLoggedIn(false);
setActiveTab("client");
setActiveNavKey("overview");
setServices([]);
setServicesLoading(false);
setStartingServices(new Set());
setOnlineStatus(false);
message.success(t("Claw.Auth.loggedOut"));
} catch {
message.error(t("Claw.Client.logoutFailed"));
}
},
});
}, [handleAuthChange, stopServicesForLogout]);
const handleNavSelect = useCallback((key: NavKey) => {
setActiveNavKey(key);
if (key === "overview") {
setActiveTab("client");
return;
}
if (key === "newTask") {
setSessionsAutoOpen(true);
setActiveTab("sessions");
return;
}
if (key === "aiChat") {
setActiveTab("sessions");
return;
}
if (key === "mcpLibrary") {
setActiveTab("mcp");
return;
}
if (key === "toolIntegration" || key === "systemSettings") {
setActiveTab("settings");
return;
}
setActiveTab("client");
}, []);
const handleLegacySelect = useCallback((key: LegacyTabKey) => {
setActiveTab(key);
const navByLegacyTab: Partial<Record<LegacyTabKey, NavKey>> = {
client: "overview",
sessions: "aiChat",
mcp: "mcpLibrary",
settings: "systemSettings",
};
setActiveNavKey(navByLegacyTab[key] || "systemSettings");
}, []);
// ============================================ // ============================================
// 平台检测 // 平台检测
// ============================================ // ============================================
const isMacOS = navigator.platform.toUpperCase().includes("MAC"); const isMacOS = navigator.platform.toUpperCase().includes("MAC");
// ============================================
// 菜单配置(对齐 Tauri 客户端)
// ============================================
const menuItems = useMemo(() => {
const items = [
{
key: "client",
icon: <DashboardOutlined />,
label: t("Claw.Menu.client"),
},
{
key: "sessions",
icon: <TeamOutlined />,
label: t("Claw.Menu.session"),
},
{
key: "mcp",
icon: <ApiOutlined />,
label: t("Claw.Menu.mcp"),
},
{
key: "settings",
icon: <SettingOutlined />,
label: t("Claw.Menu.settings"),
},
{
key: "dependencies",
icon: <FolderOutlined />,
label: t("Claw.Menu.dependencies"),
},
];
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"),
},
);
return items;
}, [isMacOS, i18nLang]);
// ============================================ // ============================================
// i18n Context value // i18n Context value
// ============================================ // ============================================
@@ -1106,7 +1031,8 @@ function App() {
// ============================================ // ============================================
if ( if (
isSetupComplete === null || isSetupComplete === null ||
(isSetupComplete && needsRequiredDepsReinstall === null) isLoggedIn === null ||
(isLoggedIn && isSetupComplete && needsRequiredDepsReinstall === null)
) { ) {
return ( return (
<I18nContext.Provider value={i18nContextValue}> <I18nContext.Provider value={i18nContextValue}>
@@ -1120,6 +1046,20 @@ function App() {
); );
} }
if (!isLoggedIn) {
return (
<I18nContext.Provider value={i18nContextValue}>
<ConfigProvider theme={currentTheme}>
<LoginPage
step1Config={loginStep1Config}
onLoginStart={ensureDefaultLoginConfig}
onLoginSuccess={handleFullscreenLoginSuccess}
/>
</ConfigProvider>
</I18nContext.Provider>
);
}
// ============================================ // ============================================
// 渲染:初始化向导 // 渲染:初始化向导
// ============================================ // ============================================
@@ -1162,9 +1102,8 @@ function App() {
value={{ themeMode, isDarkMode, setThemeMode: handleSetThemeMode }} value={{ themeMode, isDarkMode, setThemeMode: handleSetThemeMode }}
> >
<div className="app-container"> <div className="app-container">
{/* 顶部栏 */} {webviewActions && (
<div className="app-header"> <div className="app-header">
{webviewActions ? (
<div className={styles.headerWebviewActions}> <div className={styles.headerWebviewActions}>
<Button <Button
size="small" size="small"
@@ -1181,143 +1120,23 @@ function App() {
{t("Claw.App.refresh")} {t("Claw.App.refresh")}
</Button> </Button>
</div> </div>
) : (
<div className="app-header-logo">
<img
src="./32x32.png"
alt=""
style={{ width: 16, height: 16 }}
/>
<span className="app-header-title">{APP_DISPLAY_NAME}</span>
{updateState.status === "available" && (
<span
className="app-header-update-tag app-header-update-tag--available"
role="button"
tabIndex={0}
onClick={async () => {
if (updateState.canAutoUpdate === false) {
await window.electronAPI?.app?.openReleasesPage?.();
return;
}
try {
setUpdateState((prev) => ({
...prev,
status: "downloading",
progress: undefined,
}));
const res =
await window.electronAPI?.app?.downloadUpdate?.();
if (!res || !res.success) {
message.error(
res?.error || t("Claw.About.downloadFailed"),
);
setUpdateState((prev) => ({
...prev,
status: "available",
}));
}
} catch {
message.error(t("Claw.About.downloadFailed"));
setUpdateState((prev) => ({
...prev,
status: "available",
}));
}
}}
onKeyDown={(e) =>
(e.key === "Enter" || e.key === " ") &&
(e.target as HTMLElement).click()
}
>
{updateState.canAutoUpdate === false
? t("Claw.App.UpdateTag.newVersion", {
version: updateState.version,
})
: t("Claw.App.UpdateTag.download", {
version: updateState.version,
})}
</span>
)}
{updateState.status === "downloading" && (
<span className="app-header-update-tag app-header-update-tag--downloading">
{t("Claw.App.UpdateTag.downloading", {
percent: Math.round(
updateState.progress?.percent ??
headerSimulatedPercent,
),
})}
</span>
)}
{updateState.status === "downloaded" && (
<span
className="app-header-update-tag app-header-update-tag--install"
role="button"
tabIndex={0}
onClick={async () => {
try {
const res =
await window.electronAPI?.app?.installUpdate?.();
if (res && !res.success) {
message.error(
res.error || t("Claw.About.installFailed"),
);
}
} catch {
message.error(t("Claw.About.installFailed"));
}
}}
onKeyDown={(e) =>
(e.key === "Enter" || e.key === " ") &&
(e.target as HTMLElement).click()
}
>
{t("Claw.About.installUpdate")}
</span>
)}
</div>
)}
<div className={styles.headerRight}>
{username && (
<span className={styles.username}>{username}</span>
)}
<Badge
status={badge.status}
className={
agentStatus === "idle" || agentStatus === "busy"
? styles.badgeIdle
: undefined
}
text={
<span className={styles.badgeText}>{t(badge.textKey)}</span>
}
/>
</div> </div>
</div> )}
{/* 主体部分 */} {/* 主体部分 */}
<div className="app-body"> <div className="app-body">
{/* 左侧边栏 (hidden when webview is active) */} {/* 左侧边栏 (hidden when webview is active) */}
{!webviewActions && ( {!webviewActions && (
<div <AppSidebar
className={ activeNavKey={activeNavKey}
// 英文菜单文案通常更长,侧边栏适当加宽以减少截断;其他语言保持默认宽度 username={username}
i18nLang.toLowerCase().startsWith("en") email={userEmail}
? "app-sider app-sider-en" onlineStatus={onlineStatus}
: "app-sider" isMacOS={isMacOS}
} onNavSelect={handleNavSelect}
> onLegacySelect={handleLegacySelect}
<Menu onLogout={handleSidebarLogout}
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),
}))}
/>
</div>
)} )}
{/* 主内容区flex 子撑满,便于日志等页占满高度 */} {/* 主内容区flex 子撑满,便于日志等页占满高度 */}
@@ -1342,6 +1161,9 @@ function App() {
onNavigate={(tab) => { onNavigate={(tab) => {
if (tab === "sessions") setSessionsAutoOpen(true); if (tab === "sessions") setSessionsAutoOpen(true);
setActiveTab(tab); setActiveTab(tab);
setActiveNavKey(
tab === "sessions" ? "aiChat" : "overview",
);
}} }}
services={services} services={services}
servicesLoading={servicesLoading} servicesLoading={servicesLoading}

View File

@@ -0,0 +1,275 @@
import React from "react";
import { Popover } from "antd";
import {
AppstoreOutlined,
ApiOutlined,
BookOutlined,
BuildOutlined,
CommentOutlined,
DatabaseOutlined,
DeploymentUnitOutlined,
FileTextOutlined,
FolderOutlined,
InfoCircleOutlined,
LogoutOutlined,
MenuFoldOutlined,
PlusCircleOutlined,
ProjectOutlined,
SafetyOutlined,
SettingOutlined,
TeamOutlined,
ThunderboltOutlined,
ToolOutlined,
} from "@ant-design/icons";
import { APP_DISPLAY_NAME } from "@shared/constants";
import styles from "../../styles/components/AppSidebar.module.css";
export type NavKey =
| "overview"
| "newTask"
| "automation"
| "aiChat"
| "knowledge"
| "document"
| "dataFetch"
| "skills"
| "mcpLibrary"
| "workflow"
| "plugins"
| "toolIntegration"
| "systemSettings";
export type LegacyTabKey =
| "client"
| "sessions"
| "mcp"
| "settings"
| "dependencies"
| "permissions"
| "logs"
| "about";
interface NavItem {
key: NavKey;
label: string;
icon: React.ReactNode;
badge?: string;
}
interface LegacyItem {
key: LegacyTabKey;
label: string;
icon: React.ReactNode;
}
interface AppSidebarProps {
activeNavKey: NavKey;
username: string;
email?: string;
onlineStatus: boolean | null;
isMacOS: boolean;
onNavSelect: (key: NavKey) => void;
onLegacySelect: (key: LegacyTabKey) => void;
onLogout: () => void;
}
const workspaceItems: NavItem[] = [
{ key: "overview", label: "概览", icon: <AppstoreOutlined /> },
{
key: "newTask",
label: "新建任务",
icon: <PlusCircleOutlined />,
badge: "3",
},
{
key: "automation",
label: "自动化",
icon: <ThunderboltOutlined />,
badge: "运行中",
},
{ key: "aiChat", label: "AI 对话", icon: <CommentOutlined /> },
{ key: "knowledge", label: "知识库", icon: <BookOutlined /> },
{ key: "document", label: "文档编辑", icon: <FileTextOutlined /> },
{ key: "dataFetch", label: "智能取数", icon: <DatabaseOutlined /> },
];
const marketItems: NavItem[] = [
{ key: "skills", label: "技能库", icon: <BuildOutlined />, badge: "获取" },
{ key: "mcpLibrary", label: "MCP 库", icon: <ApiOutlined />, badge: "获取" },
{
key: "workflow",
label: "工作流",
icon: <ProjectOutlined />,
badge: "安装",
},
{
key: "plugins",
label: "插件",
icon: <DeploymentUnitOutlined />,
badge: "安装",
},
];
const settingItems: NavItem[] = [
{ key: "toolIntegration", label: "工具集成", icon: <ToolOutlined /> },
{ key: "systemSettings", label: "系统设置", icon: <SettingOutlined /> },
];
function getLegacyItems(isMacOS: boolean): LegacyItem[] {
const items: LegacyItem[] = [
{ key: "client", label: "客户端", icon: <AppstoreOutlined /> },
{ key: "sessions", label: "会话", icon: <TeamOutlined /> },
{ key: "mcp", label: "MCP", icon: <ApiOutlined /> },
{ key: "settings", label: "设置", icon: <SettingOutlined /> },
{ key: "dependencies", label: "依赖", icon: <FolderOutlined /> },
];
if (isMacOS) {
items.push({ key: "permissions", label: "授权", icon: <SafetyOutlined /> });
}
items.push(
{ key: "logs", label: "日志", icon: <FileTextOutlined /> },
{ key: "about", label: "关于", icon: <InfoCircleOutlined /> },
);
return items;
}
function getInitial(name: string): string {
const trimmed = name.trim();
if (!trimmed) return "陇";
return trimmed.slice(0, 1).toUpperCase();
}
function renderNavItem(
item: NavItem,
activeNavKey: NavKey,
onNavSelect: (key: NavKey) => void,
) {
const active = activeNavKey === item.key;
const isStatusBadge = item.badge === "运行中";
return (
<button
key={item.key}
type="button"
className={`${styles.navItem} ${active ? styles.navItemActive : ""}`}
onClick={() => onNavSelect(item.key)}
>
<span className={styles.navIcon}>{item.icon}</span>
<span className={styles.navLabel}>{item.label}</span>
{item.badge && (
<span
className={`${styles.navBadge} ${isStatusBadge ? styles.navBadgeSuccess : ""}`}
>
{item.badge}
</span>
)}
</button>
);
}
export default function AppSidebar({
activeNavKey,
username,
email,
onlineStatus,
isMacOS,
onNavSelect,
onLegacySelect,
onLogout,
}: AppSidebarProps) {
const displayName = username || "陈星宇";
const displayEmail = email || "xingyu@feitian.com";
const legacyItems = getLegacyItems(isMacOS);
const online = onlineStatus !== false;
const historyMenu = (
<div className={styles.historyMenu}>
<div className={styles.historyTitle}></div>
{legacyItems.map((item) => (
<button
key={item.key}
type="button"
className={styles.historyItem}
onClick={() => onLegacySelect(item.key)}
>
<span className={styles.historyIcon}>{item.icon}</span>
<span>{item.label}</span>
</button>
))}
</div>
);
return (
<aside className={styles.sidebar}>
<div className={styles.brand}>
<img className={styles.brandLogo} src="./icon.png" alt="" />
<div className={styles.brandText}>
<div className={styles.brandName}>{APP_DISPLAY_NAME}</div>
<div className={styles.brandSubTitle}>DIGITAL EMPLOYEE</div>
</div>
<Popover
content={historyMenu}
trigger="click"
placement="rightTop"
overlayClassName={styles.historyPopover}
>
<button
type="button"
className={styles.historyTrigger}
title="导航目录"
>
<MenuFoldOutlined />
</button>
</Popover>
</div>
<nav className={styles.nav}>
<section className={styles.navSection}>
<div className={styles.sectionTitle}></div>
{workspaceItems.map((item) =>
renderNavItem(item, activeNavKey, onNavSelect),
)}
</section>
<section className={styles.navSection}>
<div className={styles.sectionTitle}></div>
{marketItems.map((item) =>
renderNavItem(item, activeNavKey, onNavSelect),
)}
</section>
<section className={styles.navSection}>
<div className={styles.sectionTitle}></div>
{settingItems.map((item) =>
renderNavItem(item, activeNavKey, onNavSelect),
)}
</section>
</nav>
<div className={styles.footer}>
<div className={styles.userCard}>
<div className={styles.avatar}>{getInitial(displayName)}</div>
<div className={styles.userMeta}>
<div className={styles.userName}>{displayName}</div>
<div className={styles.userEmail}>{displayEmail}</div>
</div>
<span
className={`${styles.onlineDot} ${online ? styles.onlineDotActive : ""}`}
aria-label={online ? "在线" : "离线"}
/>
</div>
<button
type="button"
className={styles.logoutButton}
onClick={onLogout}
>
<LogoutOutlined />
<span>退</span>
</button>
</div>
</aside>
);
}

View File

@@ -27,7 +27,6 @@ import {
UserOutlined, UserOutlined,
LockOutlined, LockOutlined,
GlobalOutlined, GlobalOutlined,
LogoutOutlined,
PlayCircleOutlined, PlayCircleOutlined,
PoweroffOutlined, PoweroffOutlined,
SettingOutlined, SettingOutlined,
@@ -43,7 +42,6 @@ import {
import { QRCodeSVG } from "qrcode.react"; import { QRCodeSVG } from "qrcode.react";
import { import {
loginAndRegister, loginAndRegister,
logout,
getCurrentAuth, getCurrentAuth,
syncConfigToServer, syncConfigToServer,
} from "../../services/core/auth"; } from "../../services/core/auth";
@@ -235,65 +233,6 @@ function ClientPage({
} }
}; };
const handleLogout = async () => {
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 {
// 停止所有运行中或处于 error 状态的服务error 状态进程可能仍驻留)
const toStop = services.filter((s) => s.running || !!s.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();
} catch (e) {
console.error(`[ClientPage] Failed to stop ${svc.label}:`, e);
}
}
// computerServer 不在 services 列表中,需单独停止,避免进程残留导致端口冲突
await window.electronAPI?.computerServer
.stop()
.catch((e: unknown) => {
console.error("[ClientPage] Failed to stop computerServer:", e);
});
await logout();
// 退出登录后,默认回填上一次“服务域名”到登录输入框,减少用户重复输入。
// 回填优先级说明(从高到低):
// 1) displayDomainFallback本页面内最近一次明确业务域名用户输入/认证状态同步得到);
// 2) authState.domain当前登录态里记录的业务域名
// 3) step1_config.serverHost持久化配置兜底首次进入或刷新后也可恢复
// 说明:这里回填的是“业务域名”,仅用于登录输入体验,不改变 reg/lanproxy 的代理地址逻辑。
const step1 = (await window.electronAPI?.settings.get(
"step1_config",
)) as { serverHost?: string } | null;
const lastDomain =
displayDomainFallback ||
authState.domain ||
step1?.serverHost ||
"";
setLoginDomain(lastDomain);
setDisplayDomainFallback(lastDomain);
setAuthState({ isLoggedIn: false, username: null, domain: null });
onAuthChange?.();
} catch {
message.error(t("Claw.Client.logoutFailed"));
}
},
});
};
const getRedirectUrl = useCallback(() => { const getRedirectUrl = useCallback(() => {
if (!authState.domain || !authState.userId) return ""; if (!authState.domain || !authState.userId) return "";
return buildRedirectUrl(authState.domain, authState.userId); return buildRedirectUrl(authState.domain, authState.userId);
@@ -676,15 +615,6 @@ function ClientPage({
> >
{t("Claw.Client.qrCode")} {t("Claw.Client.qrCode")}
</Button> </Button>
<Button
type="text"
icon={<LogoutOutlined />}
onClick={handleLogout}
size="small"
danger
>
{t("Claw.Client.logout")}
</Button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,306 @@
.page {
min-height: 100vh;
width: 100%;
display: grid;
grid-template-columns: minmax(360px, 36.1vw) 1fr;
overflow: hidden;
background: #f4f4f1;
color: #171412;
}
.brandPanel {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: #1a1714;
color: #ffffff;
}
.brandContent {
width: 280px;
margin-top: 10px;
text-align: center;
}
.brandIcon {
position: relative;
width: 72px;
height: 72px;
margin: 0 auto 24px;
border-radius: 16px;
background: linear-gradient(135deg, #00a960 0%, #007d73 100%);
}
.brandIconHead {
position: absolute;
top: 25px;
left: 32px;
width: 8px;
height: 8px;
border: 2px solid #ffffff;
border-radius: 50%;
}
.brandIconBody {
position: absolute;
top: 38px;
left: 24px;
width: 24px;
height: 18px;
border: 2px solid #ffffff;
border-bottom: 0;
border-radius: 14px 14px 0 0;
}
.brandTitle {
margin-top: 0;
color: #ffffff;
font-size: 29px;
line-height: 40px;
font-weight: 700;
letter-spacing: 0;
}
.brandSubtitle {
margin-top: 20px;
color: rgba(255, 255, 255, 0.55);
font-size: 14px;
line-height: 20px;
letter-spacing: 8px;
text-indent: 8px;
}
.brandSlogan {
margin-top: 24px;
color: rgba(255, 255, 255, 0.74);
font-size: 15px;
line-height: 22px;
letter-spacing: 0;
}
.brandCopyright {
margin-top: 24px;
color: rgba(255, 255, 255, 0.38);
font-size: 12px;
line-height: 18px;
letter-spacing: 0;
}
.loginPanel {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 48px;
}
.card {
position: relative;
width: 420px;
height: 636px;
display: flex;
flex-direction: column;
background: #ffffff;
border: 1px solid #ededeb;
border-radius: 16px;
box-shadow: 0 18px 40px rgba(26, 23, 20, 0.08);
padding: 46px 48px 46px;
}
.header {
margin-bottom: 24px;
}
.title {
margin: 0;
color: #171412;
font-size: 28px;
line-height: 40px;
font-weight: 700;
letter-spacing: 0;
}
.subtitle {
margin: 20px 0 0;
color: #8c8883;
font-size: 14px;
line-height: 20px;
letter-spacing: 0;
}
.form {
display: flex;
flex-direction: column;
gap: 22px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
.label {
color: #171412;
font-size: 14px;
line-height: 20px;
letter-spacing: 0;
}
.input,
.input:global(.ant-input-affix-wrapper) {
width: 100%;
}
.input:global(.ant-input-affix-wrapper) {
height: 48px;
padding: 0 13px;
border: 1px solid #d7d3ce;
border-radius: 8px;
background: #fdfdfb;
box-shadow: none;
}
.input:global(.ant-input-affix-wrapper) :global(.ant-input) {
height: 46px;
color: #171412;
background: transparent;
font-size: 14px;
line-height: 46px;
}
.input:global(.ant-input-affix-wrapper) :global(.ant-input-prefix) {
margin-inline-end: 13px;
color: #8c8883;
font-size: 16px;
}
.input:global(.ant-input-affix-wrapper) :global(.ant-input-suffix) {
color: #8c8883;
font-size: 15px;
}
.input:global(.ant-input-affix-wrapper) :global(.ant-input-clear-icon) {
color: #c8c5bf;
font-size: 12px;
}
.input:global(.ant-input-affix-wrapper) :global(.ant-input::placeholder),
.input:global(.ant-input-affix-wrapper)
:global(.ant-input-password input::placeholder) {
color: #b7b3ad;
font-size: 14px;
}
.input:global(.ant-input-affix-wrapper-focused),
.input:global(.ant-input-affix-wrapper:hover) {
border-color: #00846d;
box-shadow: none;
}
.error {
height: 0;
margin-top: -16px;
color: #dc2626;
font-size: 12px;
line-height: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.loginButton {
width: 100%;
height: 48px;
margin-top: 2px;
border: 0;
border-radius: 8px;
background: linear-gradient(90deg, #00a455 0%, #007d73 100%);
color: #ffffff;
font-size: 16px;
font-weight: 600;
letter-spacing: 0;
box-shadow: 0 9px 16px rgba(0, 160, 88, 0.24);
}
.loginButton:hover,
.loginButton:focus {
background: linear-gradient(90deg, #00ae5d 0%, #00887c 100%) !important;
color: #ffffff !important;
}
.loginButton:active {
background: linear-gradient(90deg, #00964e 0%, #006e66 100%) !important;
}
.hint {
width: 100%;
margin: 24px 0 0;
color: #006f68;
font-size: 14px;
line-height: 20px;
font-weight: 600;
letter-spacing: 0;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
}
.hintIcon {
width: 11px;
height: 11px;
border: 1.5px solid currentColor;
border-radius: 2px;
}
.hintArrow {
font-size: 18px;
line-height: 16px;
}
.divider {
width: 100%;
height: 1px;
margin-top: 25px;
background: #e7e1dc;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-top: 25px;
color: #aaa5a0;
font-size: 12px;
line-height: 18px;
}
@media (max-width: 900px) {
.page {
grid-template-columns: 1fr;
}
.brandPanel {
display: none;
}
.loginPanel {
padding: 24px;
}
}
@media (max-height: 720px), (max-width: 520px) {
.loginPanel {
align-items: flex-start;
overflow: auto;
}
.card {
width: min(420px, calc(100vw - 48px));
height: auto;
min-height: 636px;
}
}

View File

@@ -0,0 +1,185 @@
import React, { useEffect, useMemo, useState } from "react";
import { Button, Input, message } from "antd";
import {
GlobalOutlined,
LockOutlined,
UserOutlined,
} from "@ant-design/icons";
import type { Step1Config } from "../../services/core/setup";
import {
getAuthErrorMessage,
loginAndRegister,
} from "../../services/core/auth";
import { t } from "../../services/core/i18n";
import styles from "./LoginPage.module.css";
interface LoginPageProps {
step1Config: Step1Config;
initialUsername?: string;
onLoginStart?: () => Promise<void>;
onLoginSuccess: () => Promise<void>;
}
function LoginPage({
step1Config,
initialUsername = "",
onLoginStart,
onLoginSuccess,
}: LoginPageProps) {
const [domain, setDomain] = useState(step1Config.serverHost || "");
const [username, setUsername] = useState(initialUsername);
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [retryCooldown, setRetryCooldown] = useState(0);
useEffect(() => {
setDomain(step1Config.serverHost || "");
}, [step1Config.serverHost]);
useEffect(() => {
setUsername(initialUsername);
}, [initialUsername]);
useEffect(() => {
if (retryCooldown <= 0) return;
const timer = window.setInterval(() => {
setRetryCooldown((prev) => Math.max(0, prev - 1));
}, 1000);
return () => window.clearInterval(timer);
}, [retryCooldown]);
const loginButtonText = useMemo(() => {
if (retryCooldown > 0) {
return t("Claw.Setup.login.pleaseWait", { seconds: retryCooldown });
}
return t("Claw.Setup.login.loginButton");
}, [retryCooldown]);
const handleLogin = async () => {
const normalizedDomain = domain.trim();
const normalizedUsername = username.trim();
if (!normalizedUsername || !password) {
message.warning(t("Claw.Setup.login.accountAndCodeRequired"));
return;
}
if (!normalizedDomain) {
message.warning(t("Claw.Setup.login.domainRequired"));
return;
}
setLoading(true);
setError("");
try {
await onLoginStart?.();
await loginAndRegister(normalizedUsername, password, {
suppressToast: true,
domain: normalizedDomain,
});
await onLoginSuccess();
} catch (loginError: any) {
const errorMessage = getAuthErrorMessage(loginError);
message.error(errorMessage);
setError(errorMessage);
setPassword("");
setRetryCooldown(3);
} finally {
setLoading(false);
}
};
return (
<div className={styles.page}>
<aside className={styles.brandPanel}>
<div className={styles.brandContent}>
<div className={styles.brandIcon} aria-hidden="true">
<span className={styles.brandIconHead} />
<span className={styles.brandIconBody} />
</div>
<div className={styles.brandTitle}></div>
<div className={styles.brandSubtitle}>DIGITAL EMPLOYEE</div>
<div className={styles.brandSlogan}></div>
<div className={styles.brandCopyright}>© 2026 </div>
</div>
</aside>
<main className={styles.loginPanel}>
<section className={styles.card}>
<div className={styles.header}>
<h1 className={styles.title}></h1>
<p className={styles.subtitle}></p>
</div>
<div className={styles.form}>
<label className={styles.field}>
<span className={styles.label}></span>
<Input
className={styles.input}
prefix={<GlobalOutlined />}
value={domain}
onChange={(event) => setDomain(event.target.value)}
placeholder="请输入认证地址"
autoComplete="off"
allowClear
spellCheck={false}
/>
</label>
<label className={styles.field}>
<span className={styles.label}></span>
<Input
className={styles.input}
prefix={<UserOutlined />}
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder="请输入用户名"
autoComplete="username"
/>
</label>
<label className={styles.field}>
<span className={styles.label}></span>
<Input.Password
className={styles.input}
prefix={<LockOutlined />}
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="请输入密码"
autoComplete="current-password"
onPressEnter={handleLogin}
/>
</label>
<div className={styles.error}>{error}</div>
<Button
className={styles.loginButton}
type="primary"
block
loading={loading}
disabled={retryCooldown > 0}
onClick={handleLogin}
>
{loginButtonText}
</Button>
</div>
<div className={styles.hint}>
<span className={styles.hintIcon} />
<span></span>
<span className={styles.hintArrow}></span>
</div>
<div className={styles.divider} />
<div className={styles.footer}>
<span>?</span>
<span>v2.0.1</span>
</div>
</section>
</main>
</div>
);
}
export default LoginPage;

View File

@@ -33,10 +33,7 @@ import {
SettingOutlined, SettingOutlined,
UserOutlined, UserOutlined,
CheckCircleOutlined, CheckCircleOutlined,
RobotOutlined,
FolderOpenOutlined, FolderOpenOutlined,
LockOutlined,
GlobalOutlined,
} from "@ant-design/icons"; } from "@ant-design/icons";
import { import {
setupService, setupService,
@@ -46,9 +43,7 @@ import {
import { import {
loginAndRegister, loginAndRegister,
normalizeServerHost, normalizeServerHost,
isLoggedIn as checkIsLoggedIn,
getCurrentAuth, getCurrentAuth,
getAuthErrorMessage,
logout, logout,
} from "../../services/core/auth"; } from "../../services/core/auth";
import { AUTH_KEYS } from "@shared/constants"; import { AUTH_KEYS } from "@shared/constants";
@@ -56,6 +51,7 @@ import type { QuickInitConfig } from "@shared/types/quickInit";
import SetupDependencies, { import SetupDependencies, {
type MockDependenciesApi, type MockDependenciesApi,
} from "./SetupDependencies"; } from "./SetupDependencies";
import LoginPage from "./LoginPage";
const { Text } = Typography; const { Text } = Typography;
@@ -94,19 +90,15 @@ function SetupWizard({
const [dependenciesReady, setDependenciesReady] = useState<boolean | null>( const [dependenciesReady, setDependenciesReady] = useState<boolean | null>(
null, null,
); );
const [currentStep, setCurrentStep] = useState(1); const [currentStep, setCurrentStep] = useState(2);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [step1Config, setStep1Config] = const [step1Config, setStep1Config] =
useState<Step1Config>(DEFAULT_STEP1_CONFIG); useState<Step1Config>(DEFAULT_STEP1_CONFIG);
const [username, setUsername] = useState(""); const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [completed, setCompleted] = useState(false); const [completed, setCompleted] = useState(false);
const [domain, setDomain] = useState(""); const [domain, setDomain] = useState("");
const [loginLoading, setLoginLoading] = useState(false);
const [loginError, setLoginError] = useState("");
const [retryCooldown, setRetryCooldown] = useState(0);
const [isAlreadyLoggedIn, setIsAlreadyLoggedIn] = useState(false); const [isAlreadyLoggedIn, setIsAlreadyLoggedIn] = useState(false);
const [checkingAuth, setCheckingAuth] = useState(false); const [checkingAuth, setCheckingAuth] = useState(true);
const [quickIniting, setQuickIniting] = useState(false); const [quickIniting, setQuickIniting] = useState(false);
const quickInitAttempted = useRef(false); const quickInitAttempted = useRef(false);
@@ -117,14 +109,14 @@ function SetupWizard({
if (skipDependencyCheck) { if (skipDependencyCheck) {
setDependenciesReady(true); setDependenciesReady(true);
const state = await setupService.getSetupState(); const state = await setupService.getSetupState();
if (state.completed) {
setCompleted(true);
onCompleteRef.current();
} else {
setCurrentStep(state.step1Completed ? 2 : 1);
}
const config = await setupService.getStep1Config(); const config = await setupService.getStep1Config();
setStep1Config(config); setStep1Config(config);
if (state.completed) {
setCompleted(true);
onComplete();
} else {
setCurrentStep(2);
}
setLoading(false); setLoading(false);
return; return;
} }
@@ -173,7 +165,7 @@ function SetupWizard({
); );
} }
} }
setCurrentStep(state.step1Completed ? 2 : 1); setCurrentStep(2);
} }
} else { } else {
// 有依赖缺失,进入安装流程 // 有依赖缺失,进入安装流程
@@ -203,6 +195,7 @@ function SetupWizard({
try { try {
// 1. 保存 step1 配置 // 1. 保存 step1 配置
const step1: Step1Config = { const step1: Step1Config = {
...DEFAULT_STEP1_CONFIG,
serverHost: normalizeServerHost(config.serverHost), serverHost: normalizeServerHost(config.serverHost),
agentPort: config.agentPort, agentPort: config.agentPort,
fileServerPort: config.fileServerPort, fileServerPort: config.fileServerPort,
@@ -247,9 +240,7 @@ function SetupWizard({
error, error,
); );
setQuickIniting(false); setQuickIniting(false);
// step1 可能已保存,从 step1 或 step2 继续 setCurrentStep(2);
const state = await setupService.getSetupState();
setCurrentStep(state.step1Completed ? 2 : 1);
} }
}, },
[onComplete], [onComplete],
@@ -272,9 +263,23 @@ function SetupWizard({
} }
} }
setCurrentStep(1); setCurrentStep(2);
}, [performQuickInit]); }, [performQuickInit]);
const ensureDefaultStep1Config = useCallback(async () => {
const currentConfig = await setupService.getStep1Config();
const nextConfig: Step1Config = {
...DEFAULT_STEP1_CONFIG,
...currentConfig,
serverHost: currentConfig.serverHost || DEFAULT_STEP1_CONFIG.serverHost,
workspaceDir:
currentConfig.workspaceDir || DEFAULT_STEP1_CONFIG.workspaceDir,
};
await setupService.saveStep1Config(nextConfig);
setStep1Config(nextConfig);
return nextConfig;
}, []);
// Check login status when entering step 2 // Check login status when entering step 2
const checkLoginStatus = useCallback(async () => { const checkLoginStatus = useCallback(async () => {
setCheckingAuth(true); setCheckingAuth(true);
@@ -300,15 +305,6 @@ function SetupWizard({
} }
}, [currentStep, checkLoginStatus]); }, [currentStep, checkLoginStatus]);
// Retry cooldown timer
useEffect(() => {
if (retryCooldown <= 0) return;
const timer = setInterval(() => {
setRetryCooldown((prev) => Math.max(0, prev - 1));
}, 1000);
return () => clearInterval(timer);
}, [retryCooldown]);
const handleStep1Submit = async () => { const handleStep1Submit = async () => {
if (!step1Config.fileServerPort) { if (!step1Config.fileServerPort) {
message.warning(t("Claw.Setup.basicConfig.fileServerPortRequired")); message.warning(t("Claw.Setup.basicConfig.fileServerPortRequired"));
@@ -327,49 +323,13 @@ function SetupWizard({
await setupService.saveStep1Config(step1Config); await setupService.saveStep1Config(step1Config);
setCurrentStep(2); setCurrentStep(2);
message.success(t("Claw.Setup.basicConfig.saved")); message.success(t("Claw.Setup.basicConfig.saved"));
} catch (error) { } catch {
message.error(t("Claw.Setup.basicConfig.saveFailed")); message.error(t("Claw.Setup.basicConfig.saveFailed"));
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
const handleLogin = async () => {
if (!username || !password) {
message.warning(t("Claw.Setup.login.accountAndCodeRequired"));
return;
}
const loginDomain = domain || step1Config.serverHost;
if (!loginDomain) {
message.warning(t("Claw.Setup.login.domainRequired"));
return;
}
setLoginLoading(true);
setLoginError("");
try {
await loginAndRegister(username, password, {
suppressToast: true,
domain: loginDomain,
});
setLoginError("");
await setupService.completeStep2();
await setupService.completeSetup();
setCompleted(true);
message.success(t("Claw.Setup.login.success"));
setTimeout(() => onComplete(), 2000);
} catch (error: any) {
const errorMessage = getAuthErrorMessage(error);
message.error(errorMessage);
setLoginError(errorMessage);
setPassword("");
setRetryCooldown(3);
} finally {
setLoginLoading(false);
}
};
const handleContinueLoggedIn = async () => { const handleContinueLoggedIn = async () => {
try { try {
await setupService.completeStep2(); await setupService.completeStep2();
@@ -381,6 +341,15 @@ function SetupWizard({
} }
}; };
const handleLoginSuccess = async () => {
await ensureDefaultStep1Config();
await setupService.completeStep2();
await setupService.completeSetup();
setCompleted(true);
message.success(t("Claw.Setup.login.success"));
setTimeout(() => onComplete(), 2000);
};
const handleLogout = async () => { const handleLogout = async () => {
try { try {
await logout(); await logout();
@@ -551,85 +520,11 @@ function SetupWizard({
} }
return ( return (
<Form layout="vertical"> <LoginPage
{loginError && ( step1Config={step1Config}
<Alert initialUsername={username}
message={t("Claw.Setup.login.failed")} onLoginSuccess={handleLoginSuccess}
description={ />
<Text
copyable={{ text: loginError }}
style={{
fontSize: 12,
color: "var(--color-text-secondary)",
}}
>
{loginError}
</Text>
}
type="error"
showIcon
style={{ marginBottom: 12 }}
/>
)}
<Form.Item label={t("Claw.Setup.login.domain")} required>
<Input
prefix={<GlobalOutlined />}
value={domain || step1Config.serverHost}
onChange={(e) => setDomain(e.target.value)}
placeholder={t("Claw.Setup.login.domainPlaceholder")}
autoComplete="off"
spellCheck={false}
/>
</Form.Item>
<Form.Item label={t("Claw.Setup.login.account")} required>
<Input
prefix={<UserOutlined />}
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder={t("Claw.Setup.login.accountPlaceholder")}
autoComplete="username"
/>
</Form.Item>
<Form.Item label={t("Claw.Setup.login.code")} required>
<Input.Password
prefix={<LockOutlined />}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("Claw.Setup.login.codePlaceholder")}
autoComplete="current-password"
/>
</Form.Item>
<Space style={{ width: "100%" }} direction="vertical">
<Space>
<Button onClick={() => setCurrentStep(1)}>
{t("Claw.Setup.login.prevStep")}
</Button>
<Button
type="primary"
onClick={handleLogin}
loading={loginLoading}
disabled={retryCooldown > 0}
>
{retryCooldown > 0
? t("Claw.Setup.login.pleaseWait", {
seconds: retryCooldown,
})
: t("Claw.Setup.login.loginButton")}
</Button>
</Space>
<div style={{ textAlign: "center" }}>
<Text
style={{ fontSize: 11, color: "var(--color-text-tertiary)" }}
>
{t("Claw.Setup.login.supportMultipleAccountTypes")}
</Text>
</div>
</Space>
</Form>
); );
default: default:
@@ -709,6 +604,22 @@ function SetupWizard({
); );
} }
if (
dependenciesReady &&
currentStep === 2 &&
!completed &&
!checkingAuth &&
!isAlreadyLoggedIn
) {
return (
<LoginPage
step1Config={step1Config}
initialUsername={username}
onLoginSuccess={handleLoginSuccess}
/>
);
}
// 阶段 2-4: 配置步骤 // 阶段 2-4: 配置步骤
return ( return (
<div style={styles.container}> <div style={styles.container}>

View File

@@ -6,7 +6,7 @@
<link rel="icon" type="image/png" href="/icon.png" /> <link rel="icon" type="image/png" href="/icon.png" />
<!-- 与 @shared/constants APP_DISPLAY_NAME 保持一致 --> <!-- 与 @shared/constants APP_DISPLAY_NAME 保持一致 -->
<title>启明</title> <title>陇电数字员工</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -3,32 +3,6 @@
* 包含顶部栏、布局等样式 * 包含顶部栏、布局等样式
*/ */
/* 顶部栏右侧信息区 */
.headerRight {
display: flex;
align-items: center;
gap: 12px;
}
/* 用户名 */
.username {
color: #6B7280;
font-size: 12px;
}
/* 状态徽章文字 */
.badgeText {
color: #6B7280;
font-size: 12px;
}
/* 就绪、繁忙状态:橙色小点 */
.badgeIdle :global(.ant-badge-status-dot) {
width: 6px;
height: 6px;
min-width: 6px;
}
/* Webview 模式下顶部栏左侧操作区(替代 logo */ /* Webview 模式下顶部栏左侧操作区(替代 logo */
.headerWebviewActions { .headerWebviewActions {
display: flex; display: flex;

View File

@@ -0,0 +1,321 @@
.sidebar {
width: 240px;
min-width: 240px;
height: 100%;
display: flex;
flex-direction: column;
background: #f1f2ef;
border-right: 1px solid #deded9;
color: #1f211f;
}
.brand {
height: 122px;
display: grid;
grid-template-columns: 38px 1fr 34px;
align-items: center;
gap: 10px;
padding: 26px 12px 18px 20px;
}
.brandLogo {
width: 36px;
height: 36px;
border-radius: 8px;
object-fit: cover;
box-shadow: 0 8px 18px rgba(217, 116, 74, 0.18);
}
.brandText {
min-width: 0;
}
.brandName {
font-size: 15px;
line-height: 22px;
font-weight: 700;
color: #20211f;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.brandSubTitle {
margin-top: 1px;
font-size: 9px;
line-height: 12px;
font-weight: 600;
color: #a09d98;
letter-spacing: 0;
white-space: nowrap;
}
.historyTrigger {
width: 30px;
height: 30px;
border: 0;
border-radius: 8px;
display: inline-flex;
align-items: center;
justify-content: center;
background: #e4e4df;
color: #656661;
cursor: pointer;
transition:
background-color 0.16s ease,
color 0.16s ease;
}
.historyTrigger:hover {
background: #dadad4;
color: #252622;
}
.nav {
flex: 1;
min-height: 0;
overflow-y: auto;
padding: 0 11px 16px;
}
.navSection {
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px solid #deded8;
}
.navSection:last-child {
border-bottom: 0;
margin-bottom: 0;
}
.sectionTitle {
height: 20px;
display: flex;
align-items: center;
padding: 0 2px;
font-size: 11px;
line-height: 16px;
color: #8e8b84;
}
.navItem {
position: relative;
width: 100%;
height: 34px;
border: 0;
border-radius: 6px;
display: flex;
align-items: center;
gap: 12px;
padding: 0 10px;
background: transparent;
color: #5e5d58;
font: inherit;
text-align: left;
cursor: pointer;
transition:
background-color 0.16s ease,
color 0.16s ease;
}
.navItem:hover {
background: #e8e8e2;
color: #2d2d29;
}
.navItemActive {
background: #eaded7;
color: #d66c47;
font-weight: 600;
}
.navItemActive::after {
content: "";
position: absolute;
top: 8px;
right: 10px;
width: 3px;
height: 18px;
border-radius: 2px;
background: #df7653;
}
.navIcon {
width: 18px;
min-width: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 15px;
}
.navLabel {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
}
.navBadge {
max-width: 46px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 7px;
border-radius: 9px;
background: #e6f0ff;
color: #4d8dea;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.navBadgeSuccess {
max-width: 52px;
background: #dff5e7;
color: #16b268;
}
.footer {
padding: 12px 11px 14px;
border-top: 1px solid #deded8;
}
.userCard {
height: 52px;
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid #deded8;
border-radius: 8px;
background: #f6f7f4;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #ec9a67, #df7653);
color: #ffffff;
font-size: 13px;
font-weight: 700;
}
.userMeta {
flex: 1;
min-width: 0;
}
.userName {
color: #252622;
font-size: 13px;
line-height: 18px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.userEmail {
color: #8e8b84;
font-size: 11px;
line-height: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.onlineDot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #b8b8b0;
}
.onlineDotActive {
background: #21bf63;
}
.logoutButton {
width: 100%;
height: 34px;
border: 0;
border-radius: 7px;
display: flex;
align-items: center;
gap: 8px;
margin-top: 9px;
padding: 0 12px;
background: #f1e3df;
color: #e64f45;
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition:
background-color 0.16s ease,
color 0.16s ease;
}
.logoutButton:hover {
background: #ead4cf;
color: #c8342e;
}
.historyPopover :global(.ant-popover-inner) {
padding: 8px;
border-radius: 10px;
box-shadow: 0 12px 28px rgba(22, 24, 26, 0.12);
}
.historyPopover :global(.ant-popover-arrow) {
display: none;
}
.historyMenu {
width: 176px;
}
.historyTitle {
padding: 4px 8px 8px;
color: #8e8b84;
font-size: 12px;
font-weight: 600;
}
.historyItem {
width: 100%;
height: 32px;
border: 0;
border-radius: 7px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
background: transparent;
color: #4e4e49;
font: inherit;
font-size: 13px;
text-align: left;
cursor: pointer;
}
.historyItem:hover {
background: #f4f4f1;
color: #d66c47;
}
.historyIcon {
width: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
}

View File

@@ -49,7 +49,7 @@ import {
describe("Constants", () => { describe("Constants", () => {
describe("App Identity", () => { describe("App Identity", () => {
it("should have consistent app name", () => { it("should have consistent app name", () => {
expect(APP_DISPLAY_NAME).toBe("启明数字员工"); expect(APP_DISPLAY_NAME).toBe("陇电数字员工");
expect(APP_NAME_IDENTIFIER).toBe("qimingclaw"); expect(APP_NAME_IDENTIFIER).toBe("qimingclaw");
}); });

View File

@@ -10,7 +10,7 @@ import type { AgentEngineType } from "@shared/types/electron";
// ==================== 应用名称 ==================== // ==================== 应用名称 ====================
/** 应用对外显示名称(窗口标题、关于、安装包名称等),与 package.json build.productName 保持一致 */ /** 应用对外显示名称(窗口标题、关于、安装包名称等),与 package.json build.productName 保持一致 */
export const APP_DISPLAY_NAME = "启明数字员工"; export const APP_DISPLAY_NAME = "陇电数字员工";
/** 应用技术标识(进程名、目录名等,小写字母),与 appId 等保持一致 */ /** 应用技术标识(进程名、目录名等,小写字母),与 appId 等保持一致 */
export const APP_NAME_IDENTIFIER = "qimingclaw"; export const APP_NAME_IDENTIFIER = "qimingclaw";