fix(auth): highlight failed server connections
This commit is contained in:
@@ -36,6 +36,7 @@ const workspace = {
|
||||
await page.route('http://localhost:9150/api/v1/**', async (route) => {
|
||||
const request = route.request()
|
||||
const path = new URL(request.url()).pathname
|
||||
if (path === '/api/v1/status') return route.fulfill({ json: { status: 'unavailable' } })
|
||||
if (path === '/api/v1/auth/login') return route.fulfill({ json: { token: 'mini-token' } })
|
||||
if (path === '/api/v1/projects') {
|
||||
if (request.method() === 'POST') return route.fulfill({ status: 201, json: workspace.project })
|
||||
@@ -57,6 +58,11 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => {
|
||||
})
|
||||
|
||||
await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle' })
|
||||
await page.waitForSelector('.mini-connection.offline')
|
||||
const offlineConnection = await page.locator('.mini-connection').evaluate((element) => ({
|
||||
text: element.textContent,
|
||||
borderColor: getComputedStyle(element).borderColor,
|
||||
}))
|
||||
await page.screenshot({ path: 'test-results/mini-login.png', fullPage: true })
|
||||
const loginDefaults = await page.locator('.login-note input').evaluateAll((inputs) => inputs.map((input) => input.value))
|
||||
await page.locator('.login-note input').nth(2).press('Enter')
|
||||
@@ -96,6 +102,7 @@ await browser.close()
|
||||
await server.close()
|
||||
|
||||
const failures = []
|
||||
if (!offlineConnection.text?.includes('连接异常') || offlineConnection.borderColor !== 'rgb(245, 63, 63)') failures.push(`offline connection must be red: ${JSON.stringify(offlineConnection)}`)
|
||||
if (loginDefaults[1] !== 'demo@senlin.ai' || loginDefaults[2] !== 'password123') failures.push(`login defaults failed: ${JSON.stringify(loginDefaults)}`)
|
||||
if (errors.length) failures.push(`console errors: ${errors.join('; ')}`)
|
||||
if (home.width !== 420) failures.push(`expected 420px shell, got ${home.width}`)
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
IconFile, IconFolder, IconPlus, IconRefresh, IconRobot, IconSearch, IconSettings,
|
||||
} from '@arco-design/web-react/icon'
|
||||
import {
|
||||
ApiError, createAISession, createCron, createProject, createTask, fetchAISessions, fetchExperts,
|
||||
ApiError, checkServerConnection, createAISession, createCron, createProject, createTask, fetchAISessions, fetchExperts,
|
||||
fetchProjects, fetchWorkspace, login, updateTask, uploadSource,
|
||||
type AISession, type ApiSession, type Expert, type Project, type Workspace,
|
||||
} from './api'
|
||||
@@ -14,6 +14,7 @@ const { Text, Title } = Typography
|
||||
type View = 'home' | 'tasks' | 'notes' | 'ai' | 'more'
|
||||
type DockSide = 'left' | 'right'
|
||||
type Action = 'project' | 'task' | 'cron' | null
|
||||
type ConnectionStatus = 'checking' | 'online' | 'offline'
|
||||
|
||||
const navItems: Array<{ id: View; label: string; icon: React.ReactNode }> = [
|
||||
{ id: 'home', label: '项目', icon: <IconApps /> },
|
||||
@@ -311,6 +312,26 @@ function Login({ onLogin }: { onLogin: (session: ApiSession) => void }) {
|
||||
const [password, setPassword] = useState('password123')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [connection, setConnection] = useState<ConnectionStatus>('checking')
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
const timer = window.setTimeout(() => void checkConnection(controller.signal), 300)
|
||||
return () => {
|
||||
controller.abort()
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}, [server])
|
||||
|
||||
async function checkConnection(signal?: AbortSignal) {
|
||||
setConnection('checking')
|
||||
try {
|
||||
const online = await checkServerConnection(server, signal)
|
||||
if (!signal?.aborted) setConnection(online ? 'online' : 'offline')
|
||||
} catch (requestError) {
|
||||
if (!(requestError instanceof DOMException && requestError.name === 'AbortError')) setConnection('offline')
|
||||
}
|
||||
}
|
||||
|
||||
async function submitLogin() {
|
||||
if (loading) return
|
||||
@@ -332,6 +353,11 @@ function Login({ onLogin }: { onLogin: (session: ApiSession) => void }) {
|
||||
<Title heading={3}>森林AI Mini</Title>
|
||||
<Text type="secondary">停靠在屏幕一侧,随时记录与推进项目</Text>
|
||||
<label>服务器地址<Input value={server} onChange={setServer} /></label>
|
||||
<div className={`mini-connection ${connection}`} role="status">
|
||||
<i />
|
||||
<strong>{connection === 'checking' ? '正在检查' : connection === 'online' ? '连接正常' : '连接异常'}</strong>
|
||||
<button type="button" onClick={() => void checkConnection()}>{connection === 'checking' ? '检查中' : '重新检查'}</button>
|
||||
</div>
|
||||
<label>邮箱<Input value={email} onChange={setEmail} /></label>
|
||||
<label>密码<Input.Password value={password} onChange={setPassword} /></label>
|
||||
{error ? <Alert type="error" content={error} /> : null}
|
||||
|
||||
@@ -94,6 +94,18 @@ export async function login(server: string, email: string, password: string): Pr
|
||||
return { baseUrl, token: response.token }
|
||||
}
|
||||
|
||||
export async function checkServerConnection(server: string, signal?: AbortSignal) {
|
||||
try {
|
||||
const response = await fetch(`${normalizeBaseUrl(server)}/api/v1/status`, { signal })
|
||||
if (!response.ok) return false
|
||||
const payload = await response.json() as { timestamp?: unknown }
|
||||
return typeof payload.timestamp === 'string'
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') throw error
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchProjects(session: ApiSession) {
|
||||
useSessionBase(session)
|
||||
return request<Project[]>('/api/v1/projects', { token: session.token })
|
||||
|
||||
@@ -224,6 +224,12 @@ html[data-dock="left"] .mini-shell { margin-right: auto; }
|
||||
.login-note label, .mini-form label { display: grid; gap: 5px; color: #4e5969; font-size: 11px; }
|
||||
.login-note .arco-input-wrapper, .mini-form .arco-input-wrapper { border-radius: 9px; background: rgba(255,255,255,.82); }
|
||||
.login-note > small { color: var(--muted); text-align: center; }
|
||||
.mini-connection { display: grid; grid-template-columns: 9px 1fr auto; align-items: center; gap: 8px; margin-top: -5px; border: 1px solid #bedaff; border-radius: 9px; background: #f2f8ff; padding: 8px 10px; color: #165dff; font-size: 11px; }
|
||||
.mini-connection > i { width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
|
||||
.mini-connection > strong { font-weight: 600; }
|
||||
.mini-connection > button { border: 0; background: transparent; padding: 0; color: inherit; cursor: pointer; font-size: 11px; }
|
||||
.mini-connection.online { border-color: #7be188; background: #f0fff4; color: #168a2f; }
|
||||
.mini-connection.offline { border-color: #f53f3f; background: #fff2f0; color: #d91d35; }
|
||||
|
||||
.mini-modal .arco-modal { width: min(380px, calc(100vw - 24px)); }
|
||||
.mini-modal .arco-modal-content { border-radius: 15px; }
|
||||
|
||||
@@ -191,6 +191,26 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.connection-card.checking {
|
||||
border-color: #bedaff;
|
||||
background: #f2f8ff;
|
||||
color: #165dff;
|
||||
}
|
||||
|
||||
.connection-card.checking .connection-title {
|
||||
color: #165dff;
|
||||
}
|
||||
|
||||
.connection-card.offline {
|
||||
border-color: #f53f3f;
|
||||
background: #fff2f0;
|
||||
color: #d91d35;
|
||||
}
|
||||
|
||||
.connection-card.offline .connection-title {
|
||||
color: #d91d35;
|
||||
}
|
||||
|
||||
.login-value-panel {
|
||||
display: grid;
|
||||
align-content: center;
|
||||
|
||||
Reference in New Issue
Block a user