feat(saas): Phase 3 桌面端 SaaS 集成 — 客户端、Store、UI、LLM 适配器

- saas-client.ts: SaaS HTTP 客户端 (登录/注册/Token/模型列表/Chat Relay/配置同步)
- saasStore.ts: Zustand 状态管理 (登录态、连接模式、可用模型、localStorage 持久化)
- connectionStore.ts: 集成 SaaS 模式分支 (connect() 优先检查 SaaS 连接模式)
- llm-service.ts: SaasLLMAdapter 实现 (通过 SaaS Relay 代理 LLM 调用)
- SaaSLogin.tsx: 登录/注册表单 (服务器地址、用户名、密码、邮箱)
- SaaSStatus.tsx: 连接状态展示 (账号信息、健康检查、可用模型列表)
- SaaSSettings.tsx: SaaS 设置页面入口 (登录态切换、功能列表)
- SettingsLayout.tsx: 添加 SaaS 平台菜单项
- store/index.ts: 导出 useSaaSStore
This commit is contained in:
iven
2026-03-27 14:21:23 +08:00
parent a66b675675
commit 15450ca895
9 changed files with 1407 additions and 1 deletions

View File

@@ -0,0 +1,295 @@
import { useState } from 'react';
import { LogIn, UserPlus, Globe, Eye, EyeOff, Loader2, AlertCircle, Mail } from 'lucide-react';
interface SaaSLoginProps {
onLogin: (saasUrl: string, username: string, password: string) => Promise<void>;
onRegister?: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise<void>;
initialUrl?: string;
isLoggingIn?: boolean;
error?: string | null;
}
export function SaaSLogin({ onLogin, onRegister, initialUrl, isLoggingIn, error }: SaaSLoginProps) {
const [serverUrl, setServerUrl] = useState(initialUrl || '');
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [isRegister, setIsRegister] = useState(false);
const [localError, setLocalError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLocalError(null);
if (!serverUrl.trim()) {
setLocalError('请输入服务器地址');
return;
}
if (!username.trim()) {
setLocalError('请输入用户名');
return;
}
if (!password) {
setLocalError('请输入密码');
return;
}
if (isRegister) {
if (!email.trim()) {
setLocalError('请输入邮箱地址');
return;
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())) {
setLocalError('邮箱格式不正确');
return;
}
if (password.length < 6) {
setLocalError('密码长度至少 6 个字符');
return;
}
if (password !== confirmPassword) {
setLocalError('两次输入的密码不一致');
return;
}
if (onRegister) {
try {
await onRegister(
serverUrl.trim(),
username.trim(),
email.trim(),
password,
displayName.trim() || undefined,
);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setLocalError(message);
}
return;
}
}
try {
await onLogin(serverUrl.trim(), username.trim(), password);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setLocalError(message);
}
};
const displayError = error || localError;
const handleTabSwitch = (register: boolean) => {
setIsRegister(register);
setLocalError(null);
setConfirmPassword('');
setEmail('');
setDisplayName('');
};
return (
<div className="bg-white rounded-xl border border-gray-200 p-6 shadow-sm">
<h2 className="text-lg font-semibold text-gray-900 mb-1">
{isRegister ? '注册 SaaS 账号' : '登录 SaaS 平台'}
</h2>
<p className="text-sm text-gray-500 mb-5">
{isRegister
? '创建账号以使用 ZCLAW 云端服务'
: '连接到 ZCLAW SaaS 平台,解锁云端能力'}
</p>
{/* Tab Switcher */}
<div className="flex mb-5 border-b border-gray-200">
<button
type="button"
onClick={() => handleTabSwitch(false)}
className={`px-4 py-2.5 text-sm font-medium cursor-pointer transition-colors border-b-2 ${
!isRegister
? 'border-emerald-500 text-emerald-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<span className="flex items-center gap-1.5">
<LogIn className="w-3.5 h-3.5" />
</span>
</button>
{onRegister && (
<button
type="button"
onClick={() => handleTabSwitch(true)}
className={`px-4 py-2.5 text-sm font-medium cursor-pointer transition-colors border-b-2 ${
isRegister
? 'border-emerald-500 text-emerald-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
<span className="flex items-center gap-1.5">
<UserPlus className="w-3.5 h-3.5" />
</span>
</button>
)}
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="space-y-4">
{/* Server URL */}
<div>
<label htmlFor="saas-url" className="block text-sm font-medium text-gray-700 mb-1.5">
</label>
<div className="relative">
<Globe className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
id="saas-url"
type="url"
value={serverUrl}
onChange={(e) => setServerUrl(e.target.value)}
placeholder="https://saas.zclaw.com"
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
disabled={isLoggingIn}
/>
</div>
</div>
{/* Username */}
<div>
<label htmlFor="saas-username" className="block text-sm font-medium text-gray-700 mb-1.5">
</label>
<input
id="saas-username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="your-username"
autoComplete="username"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
disabled={isLoggingIn}
/>
</div>
{/* Email (Register only) */}
{isRegister && (
<div>
<label htmlFor="saas-email" className="block text-sm font-medium text-gray-700 mb-1.5">
</label>
<div className="relative">
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
id="saas-email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoComplete="email"
className="w-full pl-10 pr-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
disabled={isLoggingIn}
/>
</div>
</div>
)}
{/* Display Name (Register only, optional) */}
{isRegister && (
<div>
<label htmlFor="saas-display-name" className="block text-sm font-medium text-gray-700 mb-1.5">
<span className="text-gray-400 font-normal">()</span>
</label>
<input
id="saas-display-name"
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="ZCLAW User"
autoComplete="name"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
disabled={isLoggingIn}
/>
</div>
)}
{/* Password */}
<div>
<label htmlFor="saas-password" className="block text-sm font-medium text-gray-700 mb-1.5">
</label>
<div className="relative">
<input
id="saas-password"
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={isRegister ? '至少 6 个字符' : 'Enter password'}
autoComplete={isRegister ? 'new-password' : 'current-password'}
className="w-full px-3 pr-10 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
disabled={isLoggingIn}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 cursor-pointer"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
{/* Confirm Password (Register only) */}
{isRegister && (
<div>
<label htmlFor="saas-confirm-password" className="block text-sm font-medium text-gray-700 mb-1.5">
</label>
<input
id="saas-confirm-password"
type={showPassword ? 'text' : 'password'}
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter password"
autoComplete="new-password"
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500 bg-white text-gray-900"
disabled={isLoggingIn}
/>
</div>
)}
{/* Error Display */}
{displayError && (
<div className="flex items-start gap-2 text-sm text-red-600 bg-red-50 rounded-lg p-3">
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{displayError}</span>
</div>
)}
{/* Submit Button */}
<button
type="submit"
disabled={isLoggingIn}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-emerald-500 hover:bg-emerald-600 text-white text-sm font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{isLoggingIn ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{isRegister ? '注册中...' : '登录中...'}
</>
) : (
<>
{isRegister ? (
<><UserPlus className="w-4 h-4" /></>
) : (
<><LogIn className="w-4 h-4" /></>
)}
</>
)}
</button>
</form>
</div>
);
}

View File

@@ -0,0 +1,158 @@
import { useState } from 'react';
import { useSaaSStore } from '../../store/saasStore';
import { SaaSLogin } from './SaaSLogin';
import { SaaSStatus } from './SaaSStatus';
import { Cloud, Info } from 'lucide-react';
export function SaaSSettings() {
const isLoggedIn = useSaaSStore((s) => s.isLoggedIn);
const account = useSaaSStore((s) => s.account);
const saasUrl = useSaaSStore((s) => s.saasUrl);
const connectionMode = useSaaSStore((s) => s.connectionMode);
const login = useSaaSStore((s) => s.login);
const register = useSaaSStore((s) => s.register);
const logout = useSaaSStore((s) => s.logout);
const [showLogin, setShowLogin] = useState(!isLoggedIn);
const [loginError, setLoginError] = useState<string | null>(null);
const [isLoggingIn, setIsLoggingIn] = useState(false);
const handleLogin = async (url: string, username: string, password: string) => {
setIsLoggingIn(true);
setLoginError(null);
try {
await login(url, username, password);
setShowLogin(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '登录失败';
setLoginError(message);
} finally {
setIsLoggingIn(false);
}
};
const handleRegister = async (
url: string,
username: string,
email: string,
password: string,
displayName?: string,
) => {
setIsLoggingIn(true);
setLoginError(null);
try {
await register(url, username, email, password, displayName);
// register auto-logs in, no need for separate login call
setShowLogin(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '注册失败';
setLoginError(message);
} finally {
setIsLoggingIn(false);
}
};
const handleLogout = () => {
logout();
setShowLogin(true);
setLoginError(null);
};
return (
<div className="max-w-2xl">
<div className="flex items-center gap-3 mb-6">
<div className="w-9 h-9 rounded-lg bg-emerald-100 flex items-center justify-center">
<Cloud className="w-5 h-5 text-emerald-600" />
</div>
<div>
<h1 className="text-xl font-bold text-gray-900">SaaS </h1>
<p className="text-sm text-gray-500"> ZCLAW </p>
</div>
</div>
{/* Connection mode info */}
<div className="flex items-start gap-2 text-sm text-gray-500 bg-blue-50 rounded-lg border border-blue-100 p-3 mb-5">
<Info className="w-4 h-4 mt-0.5 text-blue-500 flex-shrink-0" />
<span>
: <strong className="text-gray-700">{connectionMode === 'saas' ? 'SaaS 云端' : connectionMode === 'gateway' ? 'Gateway' : '本地 Tauri'}</strong>
{connectionMode !== 'saas' && '连接 SaaS 平台可解锁云端同步、团队协作等高级功能。'}
</span>
</div>
{/* Login form or status display */}
{!showLogin ? (
<SaaSStatus
isLoggedIn={isLoggedIn}
account={account}
saasUrl={saasUrl}
onLogout={handleLogout}
onLogin={() => setShowLogin(true)}
/>
) : (
<SaaSLogin
onLogin={handleLogin}
onRegister={handleRegister}
initialUrl={saasUrl}
isLoggingIn={isLoggingIn}
error={loginError}
/>
)}
{/* Features list when logged in */}
{isLoggedIn && !showLogin && (
<div className="mt-6">
<h2 className="text-sm font-medium text-gray-500 uppercase tracking-wide mb-3">
</h2>
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
<div className="space-y-3">
<CloudFeatureRow
name="云端同步"
description="对话记录和配置自动同步到云端"
status="active"
/>
<CloudFeatureRow
name="团队协作"
description="与团队成员共享 Agent 和技能"
status={account?.role === 'admin' || account?.role === 'pro' ? 'active' : 'inactive'}
/>
<CloudFeatureRow
name="高级分析"
description="使用统计和用量分析仪表板"
status={account?.role === 'admin' || account?.role === 'pro' ? 'active' : 'inactive'}
/>
</div>
</div>
</div>
)}
</div>
);
}
function CloudFeatureRow({
name,
description,
status,
}: {
name: string;
description: string;
status: 'active' | 'inactive';
}) {
return (
<div className="flex items-center justify-between py-1">
<div>
<div className="text-sm font-medium text-gray-900">{name}</div>
<div className="text-xs text-gray-500">{description}</div>
</div>
<span
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
status === 'active'
? 'bg-emerald-100 text-emerald-700'
: 'bg-gray-100 text-gray-500'
}`}
>
{status === 'active' ? '可用' : '需要订阅'}
</span>
</div>
);
}

View File

@@ -0,0 +1,172 @@
import { useEffect, useState } from 'react';
import type { SaaSAccountInfo, SaaSModelInfo } from '../../lib/saas-client';
import { Cloud, CloudOff, LogOut, RefreshCw, Cpu, CheckCircle, XCircle, Loader2 } from 'lucide-react';
import { useSaaSStore } from '../../store/saasStore';
interface SaaSStatusProps {
isLoggedIn: boolean;
account: SaaSAccountInfo | null;
saasUrl: string;
onLogout: () => void;
onLogin: () => void;
}
export function SaaSStatus({ isLoggedIn, account, saasUrl, onLogout, onLogin }: SaaSStatusProps) {
const availableModels = useSaaSStore((s) => s.availableModels);
const fetchAvailableModels = useSaaSStore((s) => s.fetchAvailableModels);
const [checkingHealth, setCheckingHealth] = useState(false);
const [healthOk, setHealthOk] = useState<boolean | null>(null);
const [showDetails, setShowDetails] = useState(false);
useEffect(() => {
if (isLoggedIn) {
fetchAvailableModels();
}
}, [isLoggedIn, fetchAvailableModels]);
async function checkHealth() {
setCheckingHealth(true);
setHealthOk(null);
try {
const response = await fetch(`${saasUrl}/api/health`, {
signal: AbortSignal.timeout(5000),
});
setHealthOk(response.ok);
} catch {
setHealthOk(false);
} finally {
setCheckingHealth(false);
}
}
if (isLoggedIn && account) {
const displayName = account.display_name || account.username;
const initial = displayName[0].toUpperCase();
return (
<div className="space-y-4">
{/* Main status bar */}
<div className="flex items-center justify-between rounded-lg border border-emerald-200 bg-emerald-50 p-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-emerald-500 flex items-center justify-center text-white font-semibold text-sm flex-shrink-0">
{initial}
</div>
<div className="min-w-0">
<div className="font-medium text-gray-900 text-sm">{displayName}</div>
<div className="text-xs text-gray-500 truncate">{saasUrl}</div>
<span className="inline-block mt-0.5 text-xs px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 font-medium">
{account.role}
</span>
</div>
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<div className="flex items-center gap-1.5 text-emerald-600 text-xs">
<Cloud className="w-3.5 h-3.5" />
<span></span>
</div>
<button
onClick={() => setShowDetails(!showDetails)}
className="px-2 py-1.5 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
>
</button>
<button
onClick={onLogout}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer"
>
<LogOut className="w-3.5 h-3.5" />
</button>
</div>
</div>
{/* Expandable details */}
{showDetails && (
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm space-y-4">
{/* Health Check */}
<div className="flex justify-between items-center">
<span className="text-sm text-gray-700"></span>
<div className="flex items-center gap-2">
{healthOk === null && !checkingHealth && (
<span className="text-xs text-gray-400"></span>
)}
{checkingHealth && <Loader2 className="w-4 h-4 animate-spin text-gray-400" />}
{healthOk === true && (
<div className="flex items-center gap-1 text-green-600 text-sm">
<CheckCircle className="w-4 h-4" />
</div>
)}
{healthOk === false && (
<div className="flex items-center gap-1 text-red-500 text-sm">
<XCircle className="w-4 h-4" />
</div>
)}
<button
onClick={checkHealth}
disabled={checkingHealth}
className="p-1 text-gray-400 hover:text-gray-600 cursor-pointer disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${checkingHealth ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{/* Available Models */}
<div>
<div className="flex items-center gap-2 mb-2">
<Cpu className="w-4 h-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700">
({availableModels.length})
</span>
</div>
{availableModels.length === 0 ? (
<p className="text-sm text-gray-400 pl-6">
Provider Model
</p>
) : (
<div className="space-y-1 pl-6">
{availableModels.map((model) => (
<ModelRow key={model.id} model={model} />
))}
</div>
)}
</div>
</div>
)}
</div>
);
}
return (
<div className="flex items-center justify-between rounded-lg border border-gray-200 bg-gray-50 p-4">
<div className="flex items-center gap-3">
<CloudOff className="w-5 h-5 text-gray-400" />
<div>
<div className="font-medium text-gray-900 text-sm">SaaS </div>
<div className="text-xs text-gray-500"></div>
</div>
</div>
<button
onClick={onLogin}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-white bg-emerald-500 rounded-lg hover:bg-emerald-600 transition-colors cursor-pointer"
>
<Cloud className="w-3.5 h-3.5" />
</button>
</div>
);
}
function ModelRow({ model }: { model: SaaSModelInfo }) {
return (
<div className="flex items-center justify-between py-1.5 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-800">{model.alias || model.id}</span>
<div className="flex items-center gap-2 text-xs text-gray-400">
{model.supports_streaming && <span></span>}
{model.supports_vision && <span></span>}
<span className="font-mono">{(model.context_window / 1000).toFixed(0)}k</span>
</div>
</div>
);
}

View File

@@ -18,6 +18,7 @@ import {
Heart,
Key,
Database,
Cloud,
} from 'lucide-react';
import { silentErrorHandler } from '../../lib/error-utils';
import { General } from './General';
@@ -37,6 +38,7 @@ import { TaskList } from '../TaskList';
import { HeartbeatConfig } from '../HeartbeatConfig';
import { SecureStorage } from './SecureStorage';
import { VikingPanel } from '../VikingPanel';
import { SaaSSettings } from '../SaaS/SaaSSettings';
interface SettingsLayoutProps {
onBack: () => void;
@@ -54,6 +56,7 @@ type SettingsPage =
| 'privacy'
| 'security'
| 'storage'
| 'saas'
| 'viking'
| 'audit'
| 'tasks'
@@ -72,6 +75,7 @@ const menuItems: { id: SettingsPage; label: string; icon: React.ReactNode }[] =
{ id: 'workspace', label: '工作区', icon: <FolderOpen className="w-4 h-4" /> },
{ id: 'privacy', label: '数据与隐私', icon: <Shield className="w-4 h-4" /> },
{ id: 'storage', label: '安全存储', icon: <Key className="w-4 h-4" /> },
{ id: 'saas', label: 'SaaS 平台', icon: <Cloud className="w-4 h-4" /> },
{ id: 'viking', label: '语义记忆', icon: <Database className="w-4 h-4" /> },
{ id: 'security', label: '安全状态', icon: <Shield className="w-4 h-4" /> },
{ id: 'audit', label: '审计日志', icon: <ClipboardList className="w-4 h-4" /> },
@@ -97,6 +101,7 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) {
case 'workspace': return <Workspace />;
case 'privacy': return <Privacy />;
case 'storage': return <SecureStorage />;
case 'saas': return <SaaSSettings />;
case 'security': return (
<div className="space-y-6">
<div>