148 lines
5.6 KiB
TypeScript
148 lines
5.6 KiB
TypeScript
import type { ReactElement } from 'react'
|
||
import { useEffect, useState } from 'react'
|
||
import { Button, Card, Form, Input, Space, Typography } from '@arco-design/web-react'
|
||
import {
|
||
IconBook,
|
||
IconCheckCircleFill,
|
||
IconCloseCircleFill,
|
||
IconLaunch,
|
||
IconLoading,
|
||
IconSafe,
|
||
IconUserGroup,
|
||
} from '@arco-design/web-react/icon'
|
||
|
||
const { Title, Text, Paragraph } = Typography
|
||
|
||
type ConnectionStatus = 'checking' | 'online' | 'offline'
|
||
|
||
export function LoginPage({ onLogin }: { onLogin: (input: { server: string; email: string; password: string }) => void }) {
|
||
const [server, setServer] = useState('http://localhost:9150')
|
||
const [email, setEmail] = useState('demo@senlin.ai')
|
||
const [password, setPassword] = useState('password123')
|
||
const [status, setStatus] = useState<ConnectionStatus>('checking')
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController()
|
||
const timer = window.setTimeout(() => {
|
||
void checkServerStatus(server, controller.signal, setStatus)
|
||
}, 300)
|
||
|
||
return () => {
|
||
controller.abort()
|
||
window.clearTimeout(timer)
|
||
}
|
||
}, [server])
|
||
|
||
return (
|
||
<section className="login-screen">
|
||
<Card className="login-card" bordered>
|
||
<div className="login-brand-panel">
|
||
<div className="brand-lockup large">
|
||
<span className="brand-symbol">S</span>
|
||
<Title heading={2}>森林Agent</Title>
|
||
</div>
|
||
<Text className="login-slogan">私有部署 · 安全可控的智能协作平台</Text>
|
||
<Text type="secondary">知识沉淀 · 团队协作 · AI Agent</Text>
|
||
<Space className="login-footer-links" size={24}>
|
||
<Text type="secondary">v1.0.0</Text>
|
||
<Text type="secondary">隐私政策</Text>
|
||
<Text type="secondary">服务协议</Text>
|
||
</Space>
|
||
</div>
|
||
|
||
<div className="login-form-panel">
|
||
<Title heading={4}>登录到您的 SenlinAI</Title>
|
||
<Text type="secondary">连接私有工作台</Text>
|
||
<Form layout="vertical" className="login-form">
|
||
<Form.Item label="服务器地址(IP 或域名优先)">
|
||
<Input value={server} onChange={setServer} placeholder="例如:https://10.0.0.15:8080" suffix={<IconLaunch />} />
|
||
<ConnectionCard status={status} onCheck={() => checkServerStatus(server, undefined, setStatus)} />
|
||
</Form.Item>
|
||
|
||
<Form.Item label="账号">
|
||
<Input value={email} onChange={setEmail} placeholder="请输入用户名或邮箱" />
|
||
</Form.Item>
|
||
<Form.Item label="密码">
|
||
<Input.Password value={password} onChange={setPassword} placeholder="请输入密码" />
|
||
</Form.Item>
|
||
<div className="login-row">
|
||
<Space size={8}>
|
||
<span className="check-dot" />
|
||
<Text>记住服务器地址</Text>
|
||
</Space>
|
||
<Button type="text" size="mini">无法连接?</Button>
|
||
</div>
|
||
<Button type="primary" long size="large" onClick={() => onLogin({ server, email, password })}>
|
||
登录
|
||
</Button>
|
||
</Form>
|
||
</div>
|
||
|
||
<div className="login-value-panel">
|
||
<Title heading={3}>安全 · 专注 · 高效</Title>
|
||
<Paragraph>
|
||
SenlinAI 是面向知识工作者与内部团队的私有部署工作台,帮助团队在统一空间中收集信息、整理知识、协同决策。
|
||
</Paragraph>
|
||
<ValuePoint icon={<IconSafe />} title="私有部署,数据自有" desc="所有数据与模型运行在您的环境中,安全可控。" />
|
||
<ValuePoint icon={<IconBook />} title="知识沉淀,结构清晰" desc="从收集到整理,形成可复用的团队知识库。" />
|
||
<ValuePoint icon={<IconUserGroup />} title="协同高效,聚焦成果" desc="围绕项目与任务协同,减少沟通成本,专注交付。" />
|
||
</div>
|
||
</Card>
|
||
</section>
|
||
)
|
||
}
|
||
|
||
function ConnectionCard({ status, onCheck }: { status: ConnectionStatus; onCheck: () => void }) {
|
||
const icon = status === 'checking' ? <IconLoading /> : status === 'online' ? <IconCheckCircleFill /> : <IconCloseCircleFill />
|
||
const title = status === 'checking' ? '正在检查' : status === 'online' ? '连接正常' : '连接异常'
|
||
|
||
return (
|
||
<div className={`connection-card ${status}`}>
|
||
{icon}
|
||
<div>
|
||
<Text className="connection-title">{title}</Text>
|
||
</div>
|
||
<Button type="text" size="mini" loading={status === 'checking'} onClick={onCheck}>
|
||
重新检查
|
||
</Button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
async function checkServerStatus(
|
||
server: string,
|
||
signal: AbortSignal | undefined,
|
||
setStatus: (status: ConnectionStatus) => void,
|
||
) {
|
||
const baseUrl = normalizeBaseUrl(server)
|
||
setStatus('checking')
|
||
|
||
try {
|
||
const response = await fetch(`${baseUrl}/api/status`, { signal })
|
||
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
||
const payload = await response.json() as { timestamp_ms?: number }
|
||
if (typeof payload.timestamp_ms !== 'number') throw new Error('invalid status payload')
|
||
setStatus('online')
|
||
} catch (error) {
|
||
if (error instanceof DOMException && error.name === 'AbortError') return
|
||
setStatus('offline')
|
||
}
|
||
}
|
||
|
||
function normalizeBaseUrl(value: string) {
|
||
const trimmed = value.trim()
|
||
return trimmed.endsWith('/') ? trimmed.slice(0, -1) : trimmed
|
||
}
|
||
|
||
function ValuePoint({ icon, title, desc }: { icon: ReactElement; title: string; desc: string }) {
|
||
return (
|
||
<div className="value-point">
|
||
<span>{icon}</span>
|
||
<div>
|
||
<Text className="value-title">{title}</Text>
|
||
<Text type="secondary">{desc}</Text>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|