chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成

包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
iven
2026-03-29 10:46:26 +08:00
parent 9a5fad2b59
commit 5fdf96c3f5
268 changed files with 22011 additions and 3886 deletions

View File

@@ -0,0 +1,516 @@
/**
* LoginPage - 全屏登录页面
*
* 强制 SaaS 登录门禁页面。用户必须登录后才能进入系统。
* 深色渐变背景 + 毛玻璃卡片设计。
* 复用 saasStore 的 login/register/loginWithTotp 逻辑。
*/
import { useState, useEffect, useRef } from 'react';
import { motion } from 'framer-motion';
import {
LogIn,
UserPlus,
Eye,
EyeOff,
Loader2,
AlertCircle,
Mail,
Shield,
ShieldCheck,
ArrowLeft,
User,
} from 'lucide-react';
import { useSaaSStore } from '../store/saasStore';
import { cn } from '../lib/utils';
import { isTauriRuntime } from '../lib/tauri-gateway';
// === Constants ===
const PRODUCTION_SAAS_URL = 'https://saas.zclaw.com';
const DEV_SAAS_URL = 'http://127.0.0.1:8080';
// === Animation Variants ===
const cardVariants = {
hidden: { opacity: 0, y: 20, scale: 0.96 },
visible: {
opacity: 1,
y: 0,
scale: 1,
transition: { duration: 0.5, ease: [0.16, 1, 0.3, 1] as const },
},
};
const fieldVariants = {
hidden: { opacity: 0, y: 10 },
visible: (i: number) => ({
opacity: 1,
y: 0,
transition: { delay: 0.15 + i * 0.05, duration: 0.35, ease: 'easeOut' as const },
}),
};
// === ZCLAW Logo SVG ===
function ZclawLogo({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
>
<defs>
<linearGradient id="logoGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stopColor="#10b981" />
<stop offset="100%" stopColor="#14b8a6" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="16" fill="url(#logoGrad)" />
<text
x="32"
y="42"
textAnchor="middle"
fill="white"
fontSize="28"
fontWeight="bold"
fontFamily="system-ui, sans-serif"
>
Z
</text>
</svg>
);
}
// === Helpers ===
/** 根据运行环境自动选择 SaaS 服务器地址 */
function getSaasUrl(): string {
return isTauriRuntime() ? PRODUCTION_SAAS_URL : DEV_SAAS_URL;
}
// === Component ===
export function LoginPage() {
const {
login,
loginWithTotp,
register,
isLoading,
error: storeError,
totpRequired,
clearError,
} = useSaaSStore();
// Form state
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [email, setEmail] = 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 [totpCode, setTotpCode] = useState('');
const [showTotpStep, setShowTotpStep] = useState(false);
// Sync TOTP required from store & clear stale errors on mount
const mountedRef = useRef(false);
useEffect(() => {
clearError();
mountedRef.current = true;
}, [clearError]);
// Sync TOTP required from store
useEffect(() => {
if (totpRequired && !showTotpStep) {
setShowTotpStep(true);
}
}, [totpRequired, showTotpStep]);
// 屏蔽首次挂载时从 bootstrap/connectionStore 残留的 storeError
const displayError = mountedRef.current ? (storeError || localError) : localError;
const clearErrors = () => setLocalError(null);
// === Login Handler ===
const handleLoginSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearErrors();
if (!username.trim()) {
setLocalError('请输入用户名');
return;
}
if (!password) {
setLocalError('请输入密码');
return;
}
try {
await login(getSaasUrl(), username.trim(), password);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setLocalError(message);
}
};
// === Register Handler ===
const handleRegisterSubmit = async (e: React.FormEvent) => {
e.preventDefault();
clearErrors();
if (!username.trim()) {
setLocalError('请输入用户名');
return;
}
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;
}
try {
await register(
getSaasUrl(),
username.trim(),
email.trim(),
password,
displayName.trim() || undefined,
);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setLocalError(message);
}
};
// === TOTP Handler ===
const handleTotpSubmit = async () => {
if (totpCode.length !== 6) return;
clearErrors();
try {
await loginWithTotp(getSaasUrl(), username.trim(), password, totpCode);
setTotpCode('');
setShowTotpStep(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
setLocalError(message);
}
};
// === Tab Switch ===
const handleTabSwitch = (registerMode: boolean) => {
setIsRegister(registerMode);
clearErrors();
setConfirmPassword('');
setEmail('');
setDisplayName('');
};
const handleBackToLogin = () => {
setShowTotpStep(false);
setTotpCode('');
clearErrors();
};
// === Input class helper ===
const inputClass = cn(
'w-full px-4 py-3 rounded-lg text-sm text-white placeholder-white/40',
'bg-white/5 border border-white/10',
'focus:outline-none focus:ring-2 focus:ring-emerald-400/30 focus:border-emerald-400/50',
'transition-all duration-200',
'disabled:opacity-50 disabled:cursor-not-allowed',
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-hidden">
{/* Animated gradient background */}
<div
className="absolute inset-0 bg-gradient-to-br from-[#0c1445] via-[#1a1a5e] to-[#0d1b2a]"
style={{ backgroundSize: '200% 200%', animation: 'gradientShift 8s ease infinite' }}
/>
{/* Radial glow */}
<div className="absolute top-1/4 left-1/2 -translate-x-1/2 w-[600px] h-[600px] rounded-full bg-emerald-500/8 blur-[120px] pointer-events-none" />
<div className="absolute bottom-0 right-1/4 w-[400px] h-[400px] rounded-full bg-blue-500/10 blur-[100px] pointer-events-none" />
{/* Card */}
<motion.div
variants={cardVariants}
initial="hidden"
animate="visible"
className="relative z-10 w-full max-w-md mx-4"
>
<div className="bg-white/10 backdrop-blur-xl border border-white/20 rounded-2xl shadow-2xl p-8 md:p-10">
{/* TOTP Verification Step */}
{showTotpStep ? (
<div className="space-y-5">
<motion.div
custom={0}
variants={fieldVariants}
initial="hidden"
animate="visible"
className="text-center"
>
<div className="mx-auto w-12 h-12 rounded-full bg-emerald-500/20 flex items-center justify-center mb-4">
<Shield className="w-6 h-6 text-emerald-400" />
</div>
<h2 className="text-xl font-semibold text-white"></h2>
<p className="text-sm text-white/50 mt-1">
</p>
</motion.div>
<motion.div custom={1} variants={fieldVariants} initial="hidden" animate="visible">
<input
type="text"
inputMode="numeric"
maxLength={6}
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, ''))}
placeholder="000000"
autoComplete="one-time-code"
autoFocus
disabled={isLoading}
className={cn(inputClass, 'text-center font-mono text-2xl tracking-[0.5em] py-4')}
onKeyDown={(e) => {
if (e.key === 'Enter' && totpCode.length === 6) handleTotpSubmit();
}}
/>
</motion.div>
{displayError && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-start gap-2 text-sm text-red-300 bg-red-500/10 border border-red-500/20 rounded-lg p-3"
>
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{displayError}</span>
</motion.div>
)}
<motion.div custom={2} variants={fieldVariants} initial="hidden" animate="visible" className="flex gap-3">
<button
type="button"
onClick={handleBackToLogin}
disabled={isLoading}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 text-sm text-white/70 border border-white/15 rounded-lg hover:bg-white/5 transition-colors cursor-pointer"
>
<ArrowLeft className="w-4 h-4" />
</button>
<button
type="button"
onClick={handleTotpSubmit}
disabled={isLoading || totpCode.length !== 6}
className="flex-1 flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white text-sm font-medium rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
>
{isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<ShieldCheck className="w-4 h-4" />
)}
</button>
</motion.div>
</div>
) : (
<>
{/* Logo & Brand */}
<motion.div custom={0} variants={fieldVariants} initial="hidden" animate="visible" className="text-center mb-8">
<ZclawLogo className="w-16 h-16 mx-auto mb-4" />
<h1 className="text-2xl font-bold text-white tracking-wide">ZCLAW</h1>
<p className="text-sm text-white/50 mt-1">
{isRegister ? '创建账号,开始使用' : '登录以继续'}
</p>
</motion.div>
{/* Tab Switcher */}
<motion.div custom={1} variants={fieldVariants} initial="hidden" animate="visible" className="flex mb-6 bg-white/5 rounded-lg p-1">
<button
type="button"
onClick={() => handleTabSwitch(false)}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2 text-sm font-medium rounded-md transition-all cursor-pointer',
!isRegister
? 'bg-white/10 text-white shadow-sm'
: 'text-white/50 hover:text-white/70',
)}
>
<LogIn className="w-3.5 h-3.5" />
</button>
<button
type="button"
onClick={() => handleTabSwitch(true)}
className={cn(
'flex-1 flex items-center justify-center gap-1.5 py-2 text-sm font-medium rounded-md transition-all cursor-pointer',
isRegister
? 'bg-white/10 text-white shadow-sm'
: 'text-white/50 hover:text-white/70',
)}
>
<UserPlus className="w-3.5 h-3.5" />
</button>
</motion.div>
{/* Form */}
<form
onSubmit={isRegister ? handleRegisterSubmit : handleLoginSubmit}
className="space-y-4"
>
{/* Username */}
<motion.div custom={2} variants={fieldVariants} initial="hidden" animate="visible">
<div className="relative">
<User className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30" />
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="用户名"
autoComplete="username"
disabled={isLoading}
className={cn(inputClass, 'pl-10')}
/>
</div>
</motion.div>
{/* Email (Register only) */}
{isRegister && (
<motion.div custom={3} variants={fieldVariants} initial="hidden" animate="visible">
<div className="relative">
<Mail className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-white/30" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="邮箱地址"
autoComplete="email"
disabled={isLoading}
className={cn(inputClass, 'pl-10')}
/>
</div>
</motion.div>
)}
{/* Display Name (Register only, optional) */}
{isRegister && (
<motion.div custom={4} variants={fieldVariants} initial="hidden" animate="visible">
<input
type="text"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="显示名称(可选)"
autoComplete="name"
disabled={isLoading}
className={inputClass}
/>
</motion.div>
)}
{/* Password */}
<motion.div custom={isRegister ? 5 : 3} variants={fieldVariants} initial="hidden" animate="visible">
<div className="relative">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={isRegister ? '密码(至少 6 位)' : '密码'}
autoComplete={isRegister ? 'new-password' : 'current-password'}
disabled={isLoading}
className={cn(inputClass, 'pr-10')}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-white/30 hover:text-white/60 cursor-pointer"
tabIndex={-1}
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</motion.div>
{/* Confirm Password (Register only) */}
{isRegister && (
<motion.div custom={6} variants={fieldVariants} initial="hidden" animate="visible">
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="确认密码"
autoComplete="new-password"
disabled={isLoading}
className={inputClass}
/>
</motion.div>
)}
{/* Error Display */}
{displayError && (
<motion.div
initial={{ opacity: 0, y: -5 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-start gap-2 text-sm text-red-300 bg-red-500/10 border border-red-500/20 rounded-lg p-3"
>
<AlertCircle className="w-4 h-4 mt-0.5 flex-shrink-0" />
<span>{displayError}</span>
</motion.div>
)}
{/* Submit Button */}
<motion.div custom={isRegister ? 7 : 4} variants={fieldVariants} initial="hidden" animate="visible">
<button
type="submit"
disabled={isLoading}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-gradient-to-r from-emerald-500 to-teal-500 hover:from-emerald-600 hover:to-teal-600 text-white text-sm font-medium rounded-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer shadow-lg shadow-emerald-500/20 hover:shadow-emerald-500/30"
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{isRegister ? '注册中...' : '登录中...'}
</>
) : (
<>
{isRegister ? (
<><UserPlus className="w-4 h-4" /></>
) : (
<><LogIn className="w-4 h-4" /></>
)}
</>
)}
</button>
</motion.div>
</form>
{/* Version footer */}
<motion.p
custom={isRegister ? 8 : 5}
variants={fieldVariants}
initial="hidden"
animate="visible"
className="text-center text-xs text-white/25 mt-6"
>
ZCLAW AI Agent Platform
</motion.p>
</>
)}
</div>
</motion.div>
</div>
);
}