feat: merge opus comfyui and login updates

This commit is contained in:
boos
2026-05-21 16:32:04 +02:00
parent c16479da7c
commit 9c84759498
105 changed files with 20160 additions and 1034 deletions
+14 -2
View File
@@ -307,7 +307,19 @@ export default function Accounts() {
const verifyFailureHint = buildVerifyFailureHint(verifyResult);
const addAccountPrereqHint = buildAddAccountPrereqHint(verifyResult);
const setSegment = (nextSegment: ConnectionsSegment) => {
const setSegment = (
nextSegment: ConnectionsSegment,
event?: React.MouseEvent | React.KeyboardEvent,
) => {
// Guard against synthetic clicks dispatched by third-party libraries
// (e.g. @visactor/vrender's event manager has been observed re-firing
// the segmented-button onClick after unrelated interactions, which
// silently navigates the page back to the default "session" segment
// mid-session). Only honour the call when it was driven by a real
// user-initiated DOM event, or when it was invoked programmatically
// without an event argument (e.g. test fixtures).
if (event && !event.isTrusted) return;
if (nextSegment === activeSegment) return;
const params = new URLSearchParams(location.search);
if (nextSegment === "session") params.delete("segment");
else params.set("segment", nextSegment);
@@ -1437,7 +1449,7 @@ export default function Accounts() {
<button
key={segment.value}
type="button"
onClick={() => setSegment(segment.value)}
onClick={(event) => setSegment(segment.value, event)}
data-tooltip={segment.tooltip}
data-tooltip-side={segment.tooltipSide}
data-tooltip-align={segment.tooltipAlign}
+27 -9
View File
@@ -1,7 +1,13 @@
import React from 'react';
import { useLocation } from 'react-router-dom';
import { getSessionToken } from '../authSession.js';
export default function ComfyUI() {
const [isDark, setIsDark] = React.useState(true);
const location = useLocation();
const audience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const token = getSessionToken(audience, localStorage);
const iframeSrc = token ? `/comfy/?token=${encodeURIComponent(token)}` : '';
React.useEffect(() => {
const el = document.documentElement;
@@ -22,16 +28,28 @@ export default function ComfyUI() {
background: isDark ? '#1a1a2e' : '#f5f5f5',
}}
>
<iframe
src="/comfyui"
style={{
width: '100%',
{iframeSrc ? (
<iframe
src={iframeSrc}
style={{
width: '100%',
height: '100%',
border: 'none',
}}
title="ComfyUI"
allow="clipboard-read; clipboard-write"
/>
) : (
<div style={{
height: '100%',
border: 'none',
}}
title="ComfyUI"
allow="clipboard-read; clipboard-write"
/>
display: 'grid',
placeItems: 'center',
color: 'var(--color-text-muted)',
fontSize: 14,
}}>
登录状态已过期,请重新登录。
</div>
)}
</div>
);
}
+216 -10
View File
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react';
import { getAuthToken } from '../authSession.js';
import { useLocation } from 'react-router-dom';
import { getSessionToken } from '../authSession.js';
import { useToast } from '../components/Toast.js';
import { filterModelTesterModelNames } from './helpers/modelTesterSession.js';
type AgentRole = 'user' | 'assistant' | 'system';
@@ -54,6 +56,11 @@ type StreamAccumulator = {
workflowJson: unknown;
};
type StoredAgentSession = {
messages?: AgentMessage[];
selectedModel?: string;
};
const WELCOME_MESSAGE: AgentMessage = {
id: 'welcome',
role: 'system',
@@ -71,6 +78,53 @@ function createId(prefix: string) {
return `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function sessionStorageKey(audience: 'admin' | 'user') {
return `boosapi.comfyuiAgent.${audience}.session`;
}
function isValidMessage(value: unknown): value is AgentMessage {
if (!value || typeof value !== 'object') return false;
const item = value as Partial<AgentMessage>;
return (
typeof item.id === 'string' &&
(item.role === 'user' || item.role === 'assistant' || item.role === 'system') &&
typeof item.content === 'string'
);
}
function readStoredSession(audience: 'admin' | 'user'): StoredAgentSession {
try {
const raw = localStorage.getItem(sessionStorageKey(audience));
if (!raw) return {};
const parsed = JSON.parse(raw) as StoredAgentSession;
const messages = Array.isArray(parsed.messages)
? parsed.messages.filter(isValidMessage)
: undefined;
return {
messages: messages?.length ? messages : undefined,
selectedModel:
typeof parsed.selectedModel === 'string' ? parsed.selectedModel : undefined,
};
} catch {
return {};
}
}
function persistStoredSession(
audience: 'admin' | 'user',
messages: AgentMessage[],
selectedModel: string,
) {
try {
localStorage.setItem(
sessionStorageKey(audience),
JSON.stringify({ messages, selectedModel }),
);
} catch {
// Ignore quota/private-mode failures; chat still works for the current page.
}
}
function angleLabel(angle?: string) {
if (angle === 'front') return '正面';
if (angle === 'back') return '背面';
@@ -124,11 +178,25 @@ async function readSseStream(
export default function ComfyUIAgent() {
const toast = useToast();
const [messages, setMessages] = useState<AgentMessage[]>([WELCOME_MESSAGE]);
const location = useLocation();
const sessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
const initialStoredSession = useMemo(
() => readStoredSession(sessionAudience),
[sessionAudience],
);
const [messages, setMessages] = useState<AgentMessage[]>(
() => initialStoredSession.messages || [WELCOME_MESSAGE],
);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState<StreamAccumulator | null>(null);
const [status, setStatus] = useState<AgentStatus | null>(null);
const [modelOptions, setModelOptions] = useState<string[]>([]);
const [selectedModel, setSelectedModel] = useState(
() => initialStoredSession.selectedModel || '',
);
const [modelSearch, setModelSearch] = useState('');
const [loadingModels, setLoadingModels] = useState(false);
const chatEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
@@ -139,13 +207,38 @@ export default function ComfyUIAgent() {
}
return streaming?.workflowJson || null;
}, [messages, streaming]);
const filteredModelOptions = useMemo(
() => {
const filtered = filterModelTesterModelNames(modelOptions, modelSearch).slice(0, 80);
if (selectedModel && !filtered.includes(selectedModel)) {
return [selectedModel, ...filtered];
}
return filtered;
},
[modelOptions, modelSearch, selectedModel],
);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [messages, streaming?.content]);
useEffect(() => {
const token = getAuthToken(localStorage);
const stored = readStoredSession(sessionAudience);
setMessages(stored.messages || [WELCOME_MESSAGE]);
setSelectedModel(stored.selectedModel || '');
setModelSearch('');
setStreaming(null);
setLoading(false);
abortRef.current?.abort();
abortRef.current = null;
}, [sessionAudience]);
useEffect(() => {
persistStoredSession(sessionAudience, messages, selectedModel);
}, [messages, selectedModel, sessionAudience]);
useEffect(() => {
const token = getSessionToken(sessionAudience, localStorage);
if (!token) return;
void fetch('/api/comfyui-agent/status', {
headers: { Authorization: `Bearer ${token}` },
@@ -153,7 +246,40 @@ export default function ComfyUIAgent() {
.then((response) => (response.ok ? response.json() : null))
.then((payload) => setStatus(payload || null))
.catch(() => setStatus(null));
}, []);
}, [sessionAudience]);
useEffect(() => {
const loadRouteModels = async () => {
const token = getSessionToken(sessionAudience, localStorage);
if (!token) return;
setLoadingModels(true);
try {
const response = await fetch('/api/comfyui-agent/models', {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
const names = Array.isArray(payload?.models)
? payload.models
.map((model: unknown) => (typeof model === 'string' ? model.trim() : ''))
.filter(Boolean)
: [];
setModelOptions(names);
setSelectedModel((current) => {
if (current && names.includes(current)) return current;
const configuredModel = String(payload?.defaultModel || status?.model || '').trim();
if (configuredModel && names.includes(configuredModel)) return configuredModel;
return names[0] || configuredModel;
});
} catch {
toast.error('加载模型路由失败');
} finally {
setLoadingModels(false);
}
};
void loadRouteModels();
}, [sessionAudience, status?.model, toast]);
const exportWorkflow = () => {
const workflow = latestWorkflow || {
@@ -250,7 +376,7 @@ export default function ComfyUIAgent() {
const content = input.trim();
if (!content || loading) return;
const token = getAuthToken(localStorage);
const token = getSessionToken(sessionAudience, localStorage);
if (!token) {
toast.error('登录状态已过期,请重新登录');
return;
@@ -291,7 +417,10 @@ export default function ComfyUIAgent() {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ messages: payloadMessages }),
body: JSON.stringify({
messages: payloadMessages,
model: selectedModel || status?.model,
}),
signal: controller.signal,
});
@@ -395,12 +524,45 @@ export default function ComfyUIAgent() {
<div>
<h1 style={titleStyle}>视频助手</h1>
<div style={statusStyle}>
模型 {status?.model || '加载中'} · 上游 {status?.upstreamBase || '检测中'} · ComfyUI {status?.comfyui?.status || 'unknown'}
模型 {selectedModel || status?.model || '加载中'} · 上游 {status?.upstreamBase || '检测中'} · ComfyUI {status?.comfyui?.status || 'unknown'}
</div>
</div>
<button type="button" onClick={exportWorkflow} style={secondaryButtonStyle}>
导出工作流
</button>
<div style={headerActionsStyle}>
<label style={modelPickerStyle}>
<span style={modelPickerLabelStyle}>对话模型</span>
<input
value={modelSearch}
onChange={(event) => setModelSearch(event.target.value)}
placeholder={loadingModels ? '加载模型...' : '搜索模型路由'}
disabled={loading || loadingModels || modelOptions.length === 0}
style={modelSearchInputStyle}
/>
<select
value={selectedModel}
onChange={(event) => {
setSelectedModel(event.target.value);
setModelSearch('');
}}
disabled={loading || loadingModels || modelOptions.length === 0}
style={modelSelectStyle}
>
{modelOptions.length === 0 ? (
<option value={status?.model || ''}>
{loadingModels ? '模型加载中' : (status?.model || '暂无模型路由')}
</option>
) : filteredModelOptions.length === 0 ? (
<option value={selectedModel}>没有匹配模型</option>
) : (
filteredModelOptions.map((model) => (
<option key={model} value={model}>{model}</option>
))
)}
</select>
</label>
<button type="button" onClick={exportWorkflow} style={secondaryButtonStyle}>
导出工作流
</button>
</div>
</header>
<section style={quickPromptStyle}>
@@ -506,6 +668,14 @@ const headerStyle: CSSProperties = {
background: 'var(--color-surface)',
};
const headerActionsStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 10,
flexWrap: 'wrap',
};
const titleStyle: CSSProperties = {
margin: 0,
fontSize: 18,
@@ -518,6 +688,42 @@ const statusStyle: CSSProperties = {
color: 'var(--color-text-muted)',
};
const modelPickerStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'minmax(130px, 180px) minmax(180px, 260px)',
gap: 8,
alignItems: 'end',
};
const modelPickerLabelStyle: CSSProperties = {
gridColumn: '1 / -1',
fontSize: 12,
fontWeight: 700,
color: 'var(--color-text-muted)',
};
const modelSearchInputStyle: CSSProperties = {
minHeight: 36,
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '7px 10px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 12,
outline: 'none',
};
const modelSelectStyle: CSSProperties = {
minHeight: 36,
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: '7px 10px',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
fontSize: 12,
outline: 'none',
};
const quickPromptStyle: CSSProperties = {
display: 'flex',
gap: 8,
+3 -3
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '../api.js';
import { clearAuthSession, getAuthToken } from '../authSession.js';
import { clearSession, getSessionToken } from '../authSession.js';
import {
DEBUG_TABS,
DEFAULT_INPUTS,
@@ -1555,8 +1555,8 @@ export default function ModelTester() {
try {
const response = await api.proxyTestStream(payload, controller.signal);
if (response.status === 401 || response.status === 403) {
const hadToken = Boolean(getAuthToken(localStorage));
clearAuthSession(localStorage);
const hadToken = Boolean(getSessionToken('admin', localStorage));
clearSession('admin', localStorage);
if (hadToken) window.location.reload();
throw new Error('会话已过期');
}
+12 -3
View File
@@ -266,14 +266,20 @@ async function copyTextToClipboard(text: string) {
document.body.removeChild(textarea);
}
function formatLatency(ms: number) {
function formatLatency(ms?: number | null) {
if (typeof ms !== "number" || !Number.isFinite(ms)) {
return "-";
}
if (ms >= 1000) {
return `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 1)}s`;
}
return `${ms}ms`;
}
function latencyColor(ms: number) {
function latencyColor(ms?: number | null) {
if (typeof ms !== "number" || !Number.isFinite(ms)) {
return "var(--color-text-muted)";
}
if (ms >= 3000) return "var(--color-danger)";
if (ms >= 2000)
return "color-mix(in srgb, var(--color-warning) 30%, var(--color-danger))";
@@ -285,7 +291,10 @@ function latencyColor(ms: number) {
return "var(--color-success)";
}
function latencyBgColor(ms: number) {
function latencyBgColor(ms?: number | null) {
if (typeof ms !== "number" || !Number.isFinite(ms)) {
return "var(--color-bg-muted, transparent)";
}
if (ms >= 3000)
return "color-mix(in srgb, var(--color-danger) 12%, transparent)";
if (ms >= 1000)
+2 -2
View File
@@ -25,7 +25,7 @@ import {
resolveRoutingProfilePreset,
type RoutingWeights,
} from './helpers/routingProfiles.js';
import { clearAuthSession } from '../authSession.js';
import { clearSession } from '../authSession.js';
import { clearAppInstallationState } from '../appLocalState.js';
import { tr } from '../i18n.js';
import { generateDownstreamSkKey } from './helpers/generateDownstreamSkKey.js';
@@ -2615,7 +2615,7 @@ export default function Settings() {
</button>
<button
onClick={() => {
clearAuthSession(localStorage);
clearSession('admin', localStorage);
window.location.reload();
}}
className="btn btn-danger"
+8 -3
View File
@@ -6,7 +6,7 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { api } from '../api.js';
import { getAuthToken } from '../authSession.js';
import { getSessionToken } from '../authSession.js';
import { getBrand } from '../components/BrandIcon.js';
import CenteredModal from '../components/CenteredModal.js';
import ResponsiveFilterPanel from '../components/ResponsiveFilterPanel.js';
@@ -568,9 +568,14 @@ export default function Sites() {
const handleSaveDisabledModels = async () => {
if (!editor || editor.mode !== 'edit') return;
const siteId = editor.editingSiteId;
if (typeof siteId !== 'number' || !Number.isFinite(siteId)) {
toast.error('未选中有效站点,无法保存禁用模型');
return;
}
setDisabledModelsSaving(true);
try {
await api.updateSiteDisabledModels(editor.editingSiteId, disabledModels);
await api.updateSiteDisabledModels(siteId, disabledModels);
try {
await api.rebuildRoutes(false, false);
toast.success('禁用模型列表已保存,路由已重建');
@@ -621,7 +626,7 @@ export default function Sites() {
setProbeCompleted(false);
try {
const token = getAuthToken(localStorage);
const token = getSessionToken('admin', localStorage);
const params = new URLSearchParams({ scope: probeScope });
if (probeScope === 'single' && probeModel.trim()) params.set('modelName', probeModel.trim());
const threshold = parseInt(probeLatencyThreshold, 10);
+3 -3
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { userApi } from '../api.js';
import { clearUserSession, getUserToken } from '../authSession.js';
import { clearSession, getSessionToken } from '../authSession.js';
interface UserProfile {
id: number;
@@ -45,7 +45,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
const [error, setError] = useState('');
const checkAuth = useCallback(() => {
if (!getUserToken(localStorage)) {
if (!getSessionToken('user', localStorage)) {
navigate('/user/login');
return false;
}
@@ -75,7 +75,7 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
useEffect(() => { loadData(); }, [loadData]);
const handleLogout = () => {
clearUserSession(localStorage);
clearSession('user', localStorage);
onLogout?.();
navigate('/user/login', { replace: true });
};
+4 -106
View File
@@ -1,109 +1,7 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { persistUserSession } from '../authSession.js';
import UserLoginPage from './auth/UserLoginPage.js';
import { useI18n } from '../i18n.js';
export default function UserLogin({ onLogin }: { onLogin?: () => void }) {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim() || !password) return;
setLoading(true);
setError('');
try {
const res = await fetch('/api/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
const errMsg = typeof data?.error === 'string' ? data.error : (data?.error?.message || data?.message || `登录失败 (${res.status})`);
throw new Error(errMsg);
}
const result = await res.json();
persistUserSession(localStorage, result.token);
onLogin?.();
navigate('/user/dashboard');
} catch (err: any) {
setError(err?.message || '登录失败,请检查邮箱和密码');
} finally {
setLoading(false);
}
};
return (
<div className="login-shell">
<div className="login-surface animate-scale-in" style={{ maxWidth: 420 }}>
<section className="login-brand-panel login-brand-panel-light" style={{ padding: '28px 24px' }}>
<div className="login-brand-header" style={{ justifyContent: 'center' }}>
<div className="brand-mark-frame brand-mark-frame-hero">
<div className="brand-mark-canvas">
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
</div>
</div>
<div className="login-brand-summary" style={{ textAlign: 'center' }}>
<div className="login-brand-name">BoosAPI</div>
<div className="login-brand-kicker">用户登录</div>
</div>
</div>
</section>
<section className="login-auth-stage">
<div className="login-auth-panel">
<h2 className="login-auth-title">欢迎回来</h2>
<p className="login-auth-copy">使用邮箱和密码登录你的账号。</p>
<form onSubmit={handleSubmit}>
<label className="login-auth-label" htmlFor="user-email-input">邮箱</label>
<input
id="user-email-input"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="email"
required
/>
<label className="login-auth-label" htmlFor="user-password-input" style={{ marginTop: 12 }}>密码</label>
<input
id="user-password-input"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="current-password"
required
/>
{error && (
<div className="alert alert-error animate-shake" style={{ marginBottom: 12, marginTop: 8 }}>
{error}
</div>
)}
<button
type="submit"
disabled={loading || !email.trim() || !password}
className="btn btn-primary login-auth-submit"
style={{ marginTop: 4 }}
>
{loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 登录中...</> : '登录'}
</button>
</form>
<div className="login-auth-footer" style={{ flexDirection: 'column', gap: 8, marginTop: 16 }}>
<span>
还没有账号?<Link to="/user/register" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>注册</Link>
</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
<Link to="/" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
</span>
</div>
</div>
</section>
</div>
</div>
);
const { t } = useI18n();
return <UserLoginPage t={t} onLogin={onLogin} />;
}
+2 -2
View File
@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { persistUserSession } from '../authSession.js';
import { commitSession } from '../authSession.js';
export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
const navigate = useNavigate();
@@ -35,7 +35,7 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
throw new Error(data?.error || `注册失败 (${res.status})`);
}
const result = await res.json();
persistUserSession(localStorage, result.token);
commitSession('user', result.token, undefined, localStorage);
onLogin?.();
navigate('/user/dashboard');
} catch (err: any) {
+111
View File
@@ -0,0 +1,111 @@
import React, { useState } from 'react';
import { commitSession } from '../../authSession.js';
import { resolveLoginErrorMessage } from '../../loginError.js';
import LoginShell from './LoginShell.js';
interface AdminLoginPageProps {
t: (text: string) => string;
/** Called with the admin auth token when login succeeds. */
onLogin: (token: string) => void;
}
async function readErrorReason(res: Response): Promise<string> {
try {
const text = await res.text();
if (!text) return '';
try {
const parsed = JSON.parse(text) as { message?: unknown; error?: unknown };
if (typeof parsed.message === 'string') return parsed.message;
if (typeof parsed.error === 'string') return parsed.error;
const nestedMessage =
parsed.error && typeof parsed.error === 'object'
? (parsed.error as { message?: unknown }).message
: undefined;
if (typeof nestedMessage === 'string') return nestedMessage;
return text;
} catch {
return text;
}
} catch {
return '';
}
}
export default function AdminLoginPage({ t, onLogin }: AdminLoginPageProps) {
const [token, setToken] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleTokenLogin = async () => {
if (!token.trim()) return;
setLoading(true);
setError('');
try {
const res = await fetch('/api/settings/auth/info', {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
commitSession('admin', token);
onLogin(token);
return;
}
const reason = await readErrorReason(res);
setError(t(resolveLoginErrorMessage(res.status, reason)));
} catch {
setError(t(resolveLoginErrorMessage('network', '')));
} finally {
setLoading(false);
}
};
const canSubmit = !!token.trim();
return (
<LoginShell
t={t}
variant="admin"
eyebrow={t('管理员入口')}
title={t('登录')}
description={t('请输入管理员令牌后继续。')}
error={error}
footer={<span>{t('管理员登录后继续。')}</span>}
>
<label className="login-auth-label" htmlFor="admin-token-input">
{t('管理员令牌')}
</label>
<input
id="admin-token-input"
type="password"
placeholder={t('管理员令牌')}
value={token}
onChange={(e) => {
setToken(e.target.value);
setError('');
}}
onKeyDown={(e) => e.key === 'Enter' && handleTokenLogin()}
className="login-auth-input"
autoComplete="current-password"
/>
<button
onClick={handleTokenLogin}
disabled={loading || !canSubmit}
className="btn btn-primary login-auth-submit"
>
{loading ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>
{t('验证中...')}
</>
) : (
t('登录')
)}
</button>
<div className="login-auth-note">
{t('仅校验本地服务访问权限,不会把令牌发送到第三方。')}
</div>
</LoginShell>
);
}
+130
View File
@@ -0,0 +1,130 @@
import React from 'react';
import { SITE_DOCS_URL, SITE_GITHUB_URL } from '../../docsLink.js';
export type LoginShellVariant = 'admin' | 'user';
interface LoginShellProps {
t: (text: string) => string;
eyebrow: string;
title: string;
description: React.ReactNode;
error?: string;
/** When set, the brand panel renders the rich admin layout (capability list, footer links). */
variant?: LoginShellVariant;
/** Footer rendered under the form (registration links, mode hints, etc). */
footer?: React.ReactNode;
children: React.ReactNode;
}
const ADMIN_CAPABILITY_KEYS: Array<{ titleKey: string; descKey: string }> = [
{ titleKey: '统一代理网关', descKey: '一个 Key、一个入口,兼容 OpenAI / Claude 下游格式' },
{ titleKey: '自动模型发现', descKey: '上游新增模型自动出现在模型列表,零配置路由生成' },
{ titleKey: '智能路由引擎', descKey: '按成本、延迟、成功率自动选择最优通道,故障自动转移' },
];
export default function LoginShell({
t,
eyebrow,
title,
description,
error,
variant = 'admin',
footer,
children,
}: LoginShellProps) {
return (
<div className="login-shell" data-variant={variant}>
<div className="login-surface animate-scale-in">
<section
className="login-brand-panel login-brand-panel-light"
style={variant === 'user' ? { padding: '28px 24px' } : undefined}
>
<div
className="login-brand-header"
style={variant === 'user' ? { justifyContent: 'center' } : undefined}
>
<div className="brand-mark-frame brand-mark-frame-hero">
<div className="brand-mark-canvas">
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
</div>
</div>
<div
className="login-brand-summary"
style={variant === 'user' ? { textAlign: 'center' } : undefined}
>
<div className="login-brand-name">BoosAPI</div>
<div className="login-brand-kicker">
{variant === 'user' ? t('用户登录') : t('中转站的中转站')}
</div>
</div>
</div>
{variant === 'admin' && (
<>
<div className="login-brand-copy-block">
<p className="login-brand-copy">
{t('把分散的 New API / One API / OneHub 等站点聚合成统一网关,自动发现模型、智能路由、成本更优。')}
</p>
</div>
<div className="login-compat-line">
{t('兼容 New API / One API / OneHub / DoneHub / Veloera / AnyRouter / Sub2API')}
</div>
<div className="login-capability-list">
{ADMIN_CAPABILITY_KEYS.map((feature, index) => (
<div key={feature.titleKey} className="login-capability-row">
<div className="login-capability-index">
{String(index + 1).padStart(2, '0')}
</div>
<div className="login-capability-content">
<div className="login-capability-title">{t(feature.titleKey)}</div>
<p className="login-capability-desc">{t(feature.descKey)}</p>
</div>
</div>
))}
</div>
<div className="login-brand-footer">
<a
href={SITE_GITHUB_URL}
target="_blank"
rel="noopener noreferrer"
className="login-icon-link"
aria-label="GitHub"
title="GitHub"
>
<svg viewBox="0 0 24 24" aria-hidden="true" className="login-icon-link-svg">
<path
fill="currentColor"
d="M12 2C6.48 2 2 6.59 2 12.25c0 4.53 2.87 8.38 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-1.04-.01-1.88-2.78.62-3.37-1.22-3.37-1.22-.45-1.2-1.11-1.52-1.11-1.52-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.9 1.57 2.35 1.12 2.92.85.09-.67.35-1.12.64-1.38-2.22-.26-4.55-1.15-4.55-5.13 0-1.13.39-2.05 1.03-2.77-.1-.26-.45-1.31.1-2.73 0 0 .84-.28 2.75 1.06A9.3 9.3 0 0 1 12 6.91c.85 0 1.71.12 2.51.35 1.91-1.34 2.75-1.06 2.75-1.06.55 1.42.2 2.47.1 2.73.64.72 1.03 1.64 1.03 2.77 0 3.99-2.34 4.86-4.57 5.12.36.33.68.97.68 1.96 0 1.42-.01 2.56-.01 2.91 0 .27.18.59.69.49A10.27 10.27 0 0 0 22 12.25C22 6.59 17.52 2 12 2Z"
/>
</svg>
</a>
<a
href={SITE_DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className="login-doc-link"
>
{t('部署文档')}
</a>
</div>
</>
)}
</section>
<section className="login-auth-stage">
<div className="login-auth-panel">
<div className="login-auth-eyebrow">{eyebrow}</div>
<h2 className="login-auth-title">{title}</h2>
<p className="login-auth-copy">{description}</p>
{children}
{error && (
<div className="alert alert-error animate-shake" style={{ marginBottom: 12 }}>
{error}
</div>
)}
{footer && <div className="login-auth-footer">{footer}</div>}
</div>
</section>
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import React from 'react';
export interface OAuthSlotProps {
t: (text: string) => string;
variant?: 'user';
}
/**
* Placeholder for the third-party login entry point.
*
* Renders a stable DOM node (`data-testid="oauth-slot"`) so a follow-up
* change can attach the real provider list and `commitSession('user', token)`
* exchange flow without touching `UserLoginPage`.
*
* This component intentionally performs no network I/O, no navigation, and
* does not touch `authSession`. Tests assert that property.
*/
export default function OAuthSlot({ t }: OAuthSlotProps) {
return (
<div
data-testid="oauth-slot"
className="login-oauth-slot"
style={{
marginTop: 16,
padding: '12px 0',
borderTop: '1px dashed var(--color-border)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: 6,
fontSize: 12,
color: 'var(--color-text-muted)',
}}
>
<span>{t('第三方登录')}</span>
<span style={{ fontSize: 11 }}>
{t('即将上线,敬请期待')}
</span>
</div>
);
}
+257
View File
@@ -0,0 +1,257 @@
import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { commitSession } from '../../authSession.js';
import { resolveLoginErrorMessage } from '../../loginError.js';
import LoginShell from './LoginShell.js';
import OAuthSlot from './OAuthSlot.js';
interface UserLoginPageProps {
t: (text: string) => string;
/** Optional callback invoked after a successful login + persistence. */
onLogin?: () => void;
}
type UserMode = 'account' | 'apiKey';
function readErrorReason(data: any): string {
if (!data) return '';
if (typeof data.error === 'string') return data.error;
const nested =
data.error && typeof data.error === 'object'
? (data.error as { message?: unknown }).message
: undefined;
return typeof nested === 'string' ? nested : (data.message || '');
}
export default function UserLoginPage({ t, onLogin }: UserLoginPageProps) {
const navigate = useNavigate();
const [mode, setMode] = useState<UserMode>('account');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [apiKey, setApiKey] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleAccountSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!email.trim() || !password) return;
setLoading(true);
setError('');
try {
const res = await fetch('/api/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password }),
});
const data = await res.json().catch(() => null);
if (!res.ok) {
setError(t(resolveLoginErrorMessage(res.status, String(readErrorReason(data)))));
return;
}
const jwt = String(data?.token || data?.data?.token || '').trim();
if (!jwt) {
setError(t(resolveLoginErrorMessage(401, '')));
return;
}
commitSession('user', jwt);
onLogin?.();
navigate('/user/dashboard');
} catch {
setError(t(resolveLoginErrorMessage('network', '')));
} finally {
setLoading(false);
}
};
const handleApiKeySubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!apiKey.trim()) return;
setLoading(true);
setError('');
try {
const res = await fetch('/api/users/login-api-key', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ apiKey: apiKey.trim() }),
});
const data = await res.json().catch(() => null);
if (!res.ok) {
setError(t(resolveLoginErrorMessage(res.status, String(readErrorReason(data)))));
return;
}
const jwt = String(data?.token || data?.data?.token || '').trim();
if (!jwt) {
setError(t(resolveLoginErrorMessage(401, '')));
return;
}
commitSession('user', jwt);
onLogin?.();
navigate('/user/dashboard');
} catch {
setError(t(resolveLoginErrorMessage('network', '')));
} finally {
setLoading(false);
}
};
const description = mode === 'account'
? t('使用邮箱和密码登录你的账号。')
: t('使用账户中已签发的 API Key 直接登录。');
const footer = (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
<span>
{t('还没有账号?')}
<Link
to="/user/register"
style={{ color: 'var(--color-primary)', textDecoration: 'none', marginLeft: 4 }}
>
{t('注册')}
</Link>
</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
<Link to="/boos" style={{ color: 'var(--color-text-muted)' }}>
← {t('管理员登录')}
</Link>
</span>
</div>
);
return (
<LoginShell
t={t}
variant="user"
eyebrow={t('用户入口')}
title={t('欢迎回来')}
description={description}
error={error}
footer={footer}
>
<div className="login-mode-tabs" role="tablist" aria-label={t('登录方式')}>
<button
type="button"
role="tab"
aria-selected={mode === 'account'}
className={`login-mode-tab ${mode === 'account' ? 'active' : ''}`}
onClick={() => {
setMode('account');
setError('');
}}
>
{t('邮箱密码')}
</button>
<button
type="button"
role="tab"
aria-selected={mode === 'apiKey'}
className={`login-mode-tab ${mode === 'apiKey' ? 'active' : ''}`}
onClick={() => {
setMode('apiKey');
setError('');
}}
>
{t('API Key')}
</button>
</div>
{mode === 'account' ? (
<form onSubmit={handleAccountSubmit}>
<label className="login-auth-label" htmlFor="user-email-input">
{t('邮箱')}
</label>
<input
id="user-email-input"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => {
setEmail(e.target.value);
setError('');
}}
className="login-auth-input"
autoComplete="email"
required
/>
<label
className="login-auth-label"
htmlFor="user-password-input"
style={{ marginTop: 12 }}
>
{t('密码')}
</label>
<input
id="user-password-input"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => {
setPassword(e.target.value);
setError('');
}}
className="login-auth-input"
autoComplete="current-password"
required
/>
<button
type="submit"
disabled={loading || !email.trim() || !password}
className="btn btn-primary login-auth-submit"
style={{ marginTop: 12 }}
>
{loading ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>
{t('登录中...')}
</>
) : (
t('登录')
)}
</button>
</form>
) : (
<form onSubmit={handleApiKeySubmit}>
<label className="login-auth-label" htmlFor="user-api-key-input">
{t('API Key')}
</label>
<input
id="user-api-key-input"
type="password"
placeholder="sk-..."
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value);
setError('');
}}
className="login-auth-input"
autoComplete="off"
spellCheck={false}
required
/>
<button
type="submit"
disabled={loading || !apiKey.trim()}
className="btn btn-primary login-auth-submit"
style={{ marginTop: 12 }}
>
{loading ? (
<>
<span
className="spinner spinner-sm"
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
/>
{t('登录中...')}
</>
) : (
t('使用 API Key 登录')
)}
</button>
<div className="login-auth-note">
{t('API Key 可在「我的 API Key」页面创建,仅识别已绑定到本账号的 Key。')}
</div>
</form>
)}
<OAuthSlot t={t} variant="user" />
</LoginShell>
);
}
@@ -15,6 +15,13 @@ describe('accountConnection helpers', () => {
capabilities: { proxyOnly: true },
accessToken: 'api-token-ignored-by-explicit-mode',
})).toBe('session');
expect(resolveAccountCredentialMode({
extraConfig: '{"credentialMode":"apikey"}',
accessToken: 'api-key-is-still-stored-as-access-token',
})).toBe('apikey');
expect(resolveAccountCredentialMode({
extra_config: '{"credentialMode":"session"}',
})).toBe('session');
expect(resolveAccountCredentialMode({})).toBe('apikey');
});
@@ -2,6 +2,21 @@ export function resolveAccountCredentialMode(account: any): 'session' | 'apikey'
const rawMode = String(account?.credentialMode || '').trim().toLowerCase();
if (rawMode === 'apikey') return 'apikey';
if (rawMode === 'session') return 'session';
const rawExtraConfig = typeof account?.extraConfig === 'string'
? account.extraConfig
: typeof account?.extra_config === 'string'
? account.extra_config
: '';
if (rawExtraConfig.trim()) {
try {
const parsed = JSON.parse(rawExtraConfig);
const parsedMode = String(parsed?.credentialMode || '').trim().toLowerCase();
if (parsedMode === 'apikey') return 'apikey';
if (parsedMode === 'session') return 'session';
} catch {
// Ignore invalid legacy extra_config and continue with older fallbacks.
}
}
if (typeof account?.capabilities?.proxyOnly === 'boolean') {
return account.capabilities.proxyOnly ? 'apikey' : 'session';
}