feat: optimize routes and registration UX

This commit is contained in:
boos
2026-06-06 15:37:37 +08:00
parent dd874bae30
commit be1fc1df48
5 changed files with 416 additions and 105 deletions
+242
View File
@@ -11956,3 +11956,245 @@ textarea:focus {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
} }
/* ===== Routes Performance + Register OAuth Polish ===== */
.route-count-badge {
min-height: 32px;
border-radius: 999px;
}
.route-loading-skeleton {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 10px;
align-items: start;
margin-top: 10px;
}
.route-loading-card {
min-height: 116px;
padding: 14px;
border: 1px solid var(--console-line);
border-radius: 16px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-bg-card) 92%, transparent), color-mix(in srgb, var(--color-bg-card) 78%, var(--color-bg)));
box-shadow: var(--shadow-card);
}
.route-loading-card-head {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
gap: 11px;
align-items: center;
}
.route-loading-icon {
width: 38px;
height: 38px;
border-radius: 12px;
}
.route-loading-title {
width: min(220px, 72%);
height: 15px;
margin-bottom: 8px;
border-radius: 999px;
}
.route-loading-subtitle {
width: min(150px, 52%);
height: 11px;
border-radius: 999px;
}
.route-loading-lines {
display: grid;
gap: 8px;
margin-top: 16px;
}
.route-loading-lines .skeleton {
height: 10px;
border-radius: 999px;
}
.route-loading-lines .skeleton:nth-child(2) {
width: 68%;
}
.login-oauth-slot {
display: grid;
gap: 10px;
margin: 0 0 18px;
color: var(--color-text-muted);
}
.login-oauth-divider {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 10px;
align-items: center;
color: var(--color-text-muted);
font-size: 11px;
font-weight: 760;
}
.login-oauth-divider::before,
.login-oauth-divider::after {
height: 1px;
content: "";
background: var(--console-line);
}
.login-oauth-provider-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
.login-oauth-provider {
min-width: 0;
min-height: 42px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
padding: 0 10px;
border: 1px solid var(--console-line);
border-radius: 12px;
background: color-mix(in srgb, var(--color-bg-card) 80%, var(--color-bg));
color: var(--color-text-secondary);
font-size: 12px;
font-weight: 720;
}
.login-oauth-provider:disabled {
cursor: not-allowed;
opacity: 0.78;
}
.login-oauth-provider-mark {
width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
border-radius: 999px;
background: color-mix(in srgb, var(--color-primary) 10%, var(--color-bg-card));
color: var(--color-primary);
font-size: 11px;
font-weight: 840;
}
.login-oauth-desc {
margin: 0;
color: var(--color-text-muted);
font-size: 11.5px;
line-height: 1.55;
text-align: center;
}
.login-register-form {
display: grid;
gap: 12px;
}
.login-register-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.login-field-group {
display: grid;
gap: 7px;
}
.login-field-group .login-auth-label,
.login-field-group .login-auth-input,
.login-register-form .login-auth-label,
.login-register-form .login-auth-input {
margin: 0;
}
.login-captcha-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) 138px;
gap: 10px;
align-items: end;
}
.login-captcha-card {
height: 46px;
display: inline-flex;
align-items: center;
justify-content: center;
overflow: hidden;
border: 1px solid var(--console-line-strong);
border-radius: 12px;
background:
linear-gradient(180deg, color-mix(in srgb, var(--color-bg-card) 92%, transparent), color-mix(in srgb, var(--color-bg-card) 76%, var(--color-bg)));
color: var(--color-text-muted);
cursor: pointer;
}
.login-captcha-card:disabled {
cursor: not-allowed;
opacity: 0.7;
}
.login-captcha-card img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
}
.login-captcha-card span {
color: var(--color-text-muted);
font-size: 12px;
}
.login-register-hint {
padding: 10px 12px;
border: 1px solid color-mix(in srgb, var(--color-primary) 16%, var(--console-line));
border-radius: 12px;
background: color-mix(in srgb, var(--color-primary) 7%, var(--color-bg-card));
color: var(--color-text-secondary);
font-size: 11.5px;
line-height: 1.55;
}
.login-auth-footer-links {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.login-auth-footer-links a {
margin-left: 4px;
color: var(--color-primary);
text-decoration: none;
}
.login-auth-footer-links span:last-child a {
color: var(--color-text-muted);
}
@media (max-width: 768px) {
.route-loading-skeleton {
grid-template-columns: 1fr;
}
.login-oauth-provider-row,
.login-register-grid,
.login-captcha-panel {
grid-template-columns: 1fr;
}
.login-captcha-card {
width: 100%;
}
}
+56 -13
View File
@@ -95,6 +95,16 @@ const EMPTY_ROUTE_FORM: RouteEditorForm = {
}; };
const DESKTOP_DETAIL_ENTER_MS = 260; const DESKTOP_DETAIL_ENTER_MS = 260;
const DESKTOP_DETAIL_COLLAPSE_MS = 200; const DESKTOP_DETAIL_COLLAPSE_MS = 200;
const ROUTE_CANDIDATE_PRELOAD_DELAY_MS = 1600;
function scheduleRouteIdleWork(callback: () => void, timeout = 1200): () => void {
if (typeof requestIdleCallback === 'function') {
const idleId = requestIdleCallback(callback, { timeout });
return () => cancelIdleCallback(idleId);
}
const timer = globalThis.setTimeout(callback, Math.min(timeout, 300));
return () => globalThis.clearTimeout(timer);
}
function prefersReducedMotion(): boolean { function prefersReducedMotion(): boolean {
return typeof globalThis.matchMedia === 'function' return typeof globalThis.matchMedia === 'function'
@@ -178,6 +188,28 @@ export function DesktopDetailPanelPresence({
); );
} }
function RouteListSkeleton() {
return (
<div className="route-loading-skeleton" aria-label={tr('正在加载路由')}>
{Array.from({ length: 8 }).map((_, index) => (
<div key={`route-skeleton-${index}`} className="route-loading-card">
<div className="route-loading-card-head">
<div className="skeleton route-loading-icon" />
<div>
<div className="skeleton route-loading-title" />
<div className="skeleton route-loading-subtitle" />
</div>
</div>
<div className="route-loading-lines">
<div className="skeleton" />
<div className="skeleton" />
</div>
</div>
))}
</div>
);
}
export default function TokenRoutes() { export default function TokenRoutes() {
const navigate = useNavigate(); const navigate = useNavigate();
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]); const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
@@ -202,6 +234,7 @@ export default function TokenRoutes() {
const [form, setForm] = useState<RouteEditorForm>(EMPTY_ROUTE_FORM); const [form, setForm] = useState<RouteEditorForm>(EMPTY_ROUTE_FORM);
const [editingRouteId, setEditingRouteId] = useState<number | null>(null); const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [initialLoading, setInitialLoading] = useState(true);
const [rebuilding, setRebuilding] = useState(false); const [rebuilding, setRebuilding] = useState(false);
const [batchUpdatingRoutes, setBatchUpdatingRoutes] = useState(false); const [batchUpdatingRoutes, setBatchUpdatingRoutes] = useState(false);
const [batchSelectMode, setBatchSelectMode] = useState(false); const [batchSelectMode, setBatchSelectMode] = useState(false);
@@ -381,20 +414,26 @@ export default function TokenRoutes() {
useEffect(() => { useEffect(() => {
mountedRef.current = true; mountedRef.current = true;
(async () => { let cancelCandidatePreload: (() => void) | null = null;
const preloadTimer = globalThis.setTimeout(() => {
cancelCandidatePreload = scheduleRouteIdleWork(() => loadCandidates(), 1800);
}, ROUTE_CANDIDATE_PRELOAD_DELAY_MS);
void resumeRouteDecisionRefreshTask();
void (async () => {
try { try {
await resumeRouteDecisionRefreshTask();
await load(); await load();
} catch { } catch {
toast.error('加载路由配置失败'); toast.error('加载路由配置失败');
} finally {
if (mountedRef.current) setInitialLoading(false);
} }
// Preload candidates in background after first paint
const scheduleIdle = typeof requestIdleCallback === 'function' ? requestIdleCallback : (cb: () => void) => setTimeout(cb, 0);
scheduleIdle(() => loadCandidates());
})(); })();
return () => { return () => {
mountedRef.current = false; mountedRef.current = false;
decisionRefreshWatchSeqRef.current += 1; decisionRefreshWatchSeqRef.current += 1;
globalThis.clearTimeout(preloadTimer);
cancelCandidatePreload?.();
}; };
}, [resumeRouteDecisionRefreshTask, toast]); }, [resumeRouteDecisionRefreshTask, toast]);
@@ -1628,8 +1667,8 @@ export default function TokenRoutes() {
</button> </button>
</div> </div>
<span className="badge badge-info" style={{ fontSize: 12, fontWeight: 500, marginLeft: 'auto' }}> <span className="badge badge-info route-count-badge" style={{ fontSize: 12, fontWeight: 500, marginLeft: 'auto' }}>
{tr('共')} {filteredRoutes.length} {tr('条路由')} {initialLoading ? tr('正在加载路由') : `${tr('共')} ${filteredRoutes.length} ${tr('条路由')}`}
</span> </span>
</div> </div>
@@ -1747,8 +1786,11 @@ export default function TokenRoutes() {
</div> </div>
)} )}
<div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}> {initialLoading ? (
{visibleRoutes.map((route) => { <RouteListSkeleton />
) : (
<div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}>
{visibleRoutes.map((route) => {
const isExpanded = expandedRouteIds.includes(route.id); const isExpanded = expandedRouteIds.includes(route.id);
const isDesktopDetailClosing = closingDesktopDetailRouteIds.includes(route.id); const isDesktopDetailClosing = closingDesktopDetailRouteIds.includes(route.id);
const isReadOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true; const isReadOnlyRoute = route.kind === 'zero_channel' || route.readOnly === true || route.isVirtual === true;
@@ -1996,10 +2038,11 @@ export default function TokenRoutes() {
{detailPanel} {detailPanel}
</Fragment> </Fragment>
); );
})} })}
</div> </div>
)}
{shouldShowLoadMore && ( {!initialLoading && shouldShowLoadMore && (
<div <div
ref={loadMoreSentinelRef} ref={loadMoreSentinelRef}
style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--color-text-muted)' }} style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--color-text-muted)' }}
@@ -2008,7 +2051,7 @@ export default function TokenRoutes() {
</div> </div>
)} )}
{filteredRoutes.length === 0 && ( {!initialLoading && filteredRoutes.length === 0 && (
<div className="card"> <div className="card">
<div className="empty-state"> <div className="empty-state">
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
+83 -75
View File
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { commitSession } from '../authSession.js'; import { commitSession } from '../authSession.js';
import LoginShell from './auth/LoginShell.js'; import LoginShell from './auth/LoginShell.js';
import OAuthSlot from './auth/OAuthSlot.js';
export default function UserRegister({ onLogin }: { onLogin?: () => void }) { export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -95,110 +96,117 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
t={(text) => text} t={(text) => text}
variant="user" variant="user"
eyebrow="用户注册" eyebrow="用户注册"
title="创建账号" title="创建 BossAI 账号"
description="注册后即可使用 API 代理服务。" description="完成验证后会自动进入个人工作台,并生成你的默认 API Key。"
error={error} error={error}
footer={( footer={(
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}> <div className="login-auth-footer-links">
<span> <span>
已有账号? 已有账号?
<Link to="/user/login" style={{ color: 'var(--color-primary)', textDecoration: 'none', marginLeft: 4 }}> <Link to="/user/login">
登录 登录
</Link> </Link>
</span> </span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}> <span>
<Link to="/boos" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link> <Link to="/boos">管理员登录</Link>
</span> </span>
</div> </div>
)} )}
> >
<OAuthSlot t={(text) => text} variant="user" mode="register" />
<form onSubmit={handleSubmit} className="login-register-form"> <form onSubmit={handleSubmit} className="login-register-form">
<label className="login-auth-label" htmlFor="reg-username-input">用户名</label> <div className="login-register-grid">
<input <div className="login-field-group">
id="reg-username-input" <label className="login-auth-label" htmlFor="reg-username-input">用户名</label>
type="text" <input
placeholder="你的昵称" id="reg-username-input"
value={username} type="text"
onChange={(e) => { setUsername(e.target.value); setError(''); }} placeholder="你的昵称"
className="login-auth-input" value={username}
autoComplete="username" onChange={(e) => { setUsername(e.target.value); setError(''); }}
required className="login-auth-input"
/> autoComplete="username"
<label className="login-auth-label" htmlFor="reg-email-input">邮箱</label> required
<input />
id="reg-email-input" </div>
type="email" <div className="login-field-group">
placeholder="your@email.com" <label className="login-auth-label" htmlFor="reg-email-input">邮箱</label>
value={email} <input
onChange={(e) => { setEmail(e.target.value); setError(''); }} id="reg-email-input"
className="login-auth-input" type="email"
autoComplete="email" placeholder="your@email.com"
required value={email}
/> onChange={(e) => { setEmail(e.target.value); setError(''); }}
<label className="login-auth-label" htmlFor="reg-password-input">密码</label> className="login-auth-input"
<input autoComplete="email"
id="reg-password-input" required
type="password" />
placeholder="至少 6 位" </div>
value={password} <div className="login-field-group">
onChange={(e) => { setPassword(e.target.value); setError(''); }} <label className="login-auth-label" htmlFor="reg-password-input">密码</label>
className="login-auth-input" <input
autoComplete="new-password" id="reg-password-input"
required type="password"
/> placeholder="至少 6 位"
<label className="login-auth-label" htmlFor="reg-confirm-password-input">确认密码</label> value={password}
<input onChange={(e) => { setPassword(e.target.value); setError(''); }}
id="reg-confirm-password-input" className="login-auth-input"
type="password" autoComplete="new-password"
placeholder="再次输入密码" required
value={confirmPassword} />
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }} </div>
className="login-auth-input" <div className="login-field-group">
autoComplete="new-password" <label className="login-auth-label" htmlFor="reg-confirm-password-input">确认密码</label>
required <input
/> id="reg-confirm-password-input"
<label className="login-auth-label" htmlFor="reg-captcha-input">图像验证码</label> type="password"
<div style={{ display: 'grid', gridTemplateColumns: '1fr 132px', gap: 10, alignItems: 'center' }}> placeholder="再次输入密码"
<input value={confirmPassword}
id="reg-captcha-input" onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
type="text" className="login-auth-input"
placeholder="输入右侧字符" autoComplete="new-password"
value={captchaCode} required
onChange={(e) => { setCaptchaCode(e.target.value); setError(''); }} />
className="login-auth-input" </div>
autoComplete="off" </div>
inputMode="text" <div className="login-captcha-panel">
maxLength={8} <div className="login-field-group">
required <label className="login-auth-label" htmlFor="reg-captcha-input">图像验证码</label>
/> <input
id="reg-captcha-input"
type="text"
placeholder="输入右侧字符"
value={captchaCode}
onChange={(e) => { setCaptchaCode(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="off"
inputMode="text"
maxLength={8}
required
/>
</div>
<button <button
type="button" type="button"
className="login-captcha-card"
onClick={() => void loadCaptcha()} onClick={() => void loadCaptcha()}
disabled={captchaLoading || loading} disabled={captchaLoading || loading}
aria-label="刷新图像验证码" aria-label="刷新图像验证码"
title="点击刷新验证码" title="点击刷新验证码"
style={{
height: 44,
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: 0,
background: 'var(--color-surface)',
cursor: captchaLoading || loading ? 'not-allowed' : 'pointer',
overflow: 'hidden',
}}
> >
{captchaImage && !captchaLoading ? ( {captchaImage && !captchaLoading ? (
<img <img
src={captchaImage} src={captchaImage}
alt="图像验证码" alt="图像验证码"
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
draggable={false} draggable={false}
/> />
) : ( ) : (
<span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>加载中...</span> <span>加载中...</span>
)} )}
</button> </button>
</div> </div>
<div className="login-register-hint">
注册会受到验证码和频率限制保护,避免批量注册影响服务稳定性。
</div>
<button <button
type="submit" type="submit"
disabled={ disabled={
+34 -16
View File
@@ -3,6 +3,7 @@ import React from 'react';
export interface OAuthSlotProps { export interface OAuthSlotProps {
t: (text: string) => string; t: (text: string) => string;
variant?: 'user'; variant?: 'user';
mode?: 'login' | 'register';
} }
/** /**
@@ -15,27 +16,44 @@ export interface OAuthSlotProps {
* This component intentionally performs no network I/O, no navigation, and * This component intentionally performs no network I/O, no navigation, and
* does not touch `authSession`. Tests assert that property. * does not touch `authSession`. Tests assert that property.
*/ */
export default function OAuthSlot({ t }: OAuthSlotProps) { const OAUTH_PROVIDER_KEYS = [
{ key: 'google', label: 'Google' },
{ key: 'github', label: 'GitHub' },
{ key: 'wechat', label: '微信' },
];
export default function OAuthSlot({ t, mode = 'login' }: OAuthSlotProps) {
const title = mode === 'register' ? t('第三方账号快速注册') : t('第三方登录');
const desc = mode === 'register'
? t('OAuth 接入后可自动创建账号并绑定同一用户身份。')
: t('即将支持配置后的 OAuth 提供商。');
return ( return (
<div <div
data-testid="oauth-slot" data-testid="oauth-slot"
className="login-oauth-slot" className="login-oauth-slot"
style={{ data-mode={mode}
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> <div className="login-oauth-divider">
<span style={{ fontSize: 11 }}> <span>{title}</span>
{t('即将上线,敬请期待')} </div>
</span> <div className="login-oauth-provider-row" aria-label={title}>
{OAUTH_PROVIDER_KEYS.map((provider) => (
<button
key={provider.key}
type="button"
className="login-oauth-provider"
disabled
aria-disabled="true"
data-provider={provider.key}
title={t('等待管理员配置 OAuth 后启用')}
>
<span className="login-oauth-provider-mark">{provider.label.slice(0, 1)}</span>
<span>{provider.label}</span>
</button>
))}
</div>
<p className="login-oauth-desc">{desc}</p>
</div> </div>
); );
} }
+1 -1
View File
@@ -10,7 +10,7 @@ import {
} from '../../../shared/tokenRoutePatterns.js'; } from '../../../shared/tokenRoutePatterns.js';
export const AUTO_ROUTE_DECISION_LIMIT = 80; export const AUTO_ROUTE_DECISION_LIMIT = 80;
export const ROUTE_RENDER_CHUNK = 40; export const ROUTE_RENDER_CHUNK = 24;
export const ROUTE_BRAND_ICON_PREFIX = 'brand:'; export const ROUTE_BRAND_ICON_PREFIX = 'brand:';
export const ROUTE_ICON_NONE_VALUE = '__route_icon_none__'; export const ROUTE_ICON_NONE_VALUE = '__route_icon_none__';