feat: optimize routes and registration UX
This commit is contained in:
@@ -11956,3 +11956,245 @@ textarea:focus {
|
||||
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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,16 @@ const EMPTY_ROUTE_FORM: RouteEditorForm = {
|
||||
};
|
||||
const DESKTOP_DETAIL_ENTER_MS = 260;
|
||||
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 {
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const [routeSummaries, setRouteSummaries] = useState<RouteSummaryRow[]>([]);
|
||||
@@ -202,6 +234,7 @@ export default function TokenRoutes() {
|
||||
const [form, setForm] = useState<RouteEditorForm>(EMPTY_ROUTE_FORM);
|
||||
const [editingRouteId, setEditingRouteId] = useState<number | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [initialLoading, setInitialLoading] = useState(true);
|
||||
const [rebuilding, setRebuilding] = useState(false);
|
||||
const [batchUpdatingRoutes, setBatchUpdatingRoutes] = useState(false);
|
||||
const [batchSelectMode, setBatchSelectMode] = useState(false);
|
||||
@@ -381,20 +414,26 @@ export default function TokenRoutes() {
|
||||
|
||||
useEffect(() => {
|
||||
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 {
|
||||
await resumeRouteDecisionRefreshTask();
|
||||
await load();
|
||||
} catch {
|
||||
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 () => {
|
||||
mountedRef.current = false;
|
||||
decisionRefreshWatchSeqRef.current += 1;
|
||||
globalThis.clearTimeout(preloadTimer);
|
||||
cancelCandidatePreload?.();
|
||||
};
|
||||
}, [resumeRouteDecisionRefreshTask, toast]);
|
||||
|
||||
@@ -1628,8 +1667,8 @@ export default function TokenRoutes() {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span className="badge badge-info" style={{ fontSize: 12, fontWeight: 500, marginLeft: 'auto' }}>
|
||||
{tr('共')} {filteredRoutes.length} {tr('条路由')}
|
||||
<span className="badge badge-info route-count-badge" style={{ fontSize: 12, fontWeight: 500, marginLeft: 'auto' }}>
|
||||
{initialLoading ? tr('正在加载路由') : `${tr('共')} ${filteredRoutes.length} ${tr('条路由')}`}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1747,6 +1786,9 @@ export default function TokenRoutes() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{initialLoading ? (
|
||||
<RouteListSkeleton />
|
||||
) : (
|
||||
<div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}>
|
||||
{visibleRoutes.map((route) => {
|
||||
const isExpanded = expandedRouteIds.includes(route.id);
|
||||
@@ -1998,8 +2040,9 @@ export default function TokenRoutes() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{shouldShowLoadMore && (
|
||||
{!initialLoading && shouldShowLoadMore && (
|
||||
<div
|
||||
ref={loadMoreSentinelRef}
|
||||
style={{ textAlign: 'center', padding: '12px 0', fontSize: 12, color: 'var(--color-text-muted)' }}
|
||||
@@ -2008,7 +2051,7 @@ export default function TokenRoutes() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredRoutes.length === 0 && (
|
||||
{!initialLoading && filteredRoutes.length === 0 && (
|
||||
<div className="card">
|
||||
<div className="empty-state">
|
||||
<svg className="empty-state-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { commitSession } from '../authSession.js';
|
||||
import LoginShell from './auth/LoginShell.js';
|
||||
import OAuthSlot from './auth/OAuthSlot.js';
|
||||
|
||||
export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
@@ -95,24 +96,27 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
t={(text) => text}
|
||||
variant="user"
|
||||
eyebrow="用户注册"
|
||||
title="创建账号"
|
||||
description="注册后即可使用 API 代理服务。"
|
||||
title="创建 BossAI 账号"
|
||||
description="完成验证后会自动进入个人工作台,并生成你的默认 API Key。"
|
||||
error={error}
|
||||
footer={(
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
|
||||
<div className="login-auth-footer-links">
|
||||
<span>
|
||||
已有账号?
|
||||
<Link to="/user/login" style={{ color: 'var(--color-primary)', textDecoration: 'none', marginLeft: 4 }}>
|
||||
<Link to="/user/login">
|
||||
登录
|
||||
</Link>
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
<Link to="/boos" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
|
||||
<span>
|
||||
<Link to="/boos">管理员登录</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<OAuthSlot t={(text) => text} variant="user" mode="register" />
|
||||
<form onSubmit={handleSubmit} className="login-register-form">
|
||||
<div className="login-register-grid">
|
||||
<div className="login-field-group">
|
||||
<label className="login-auth-label" htmlFor="reg-username-input">用户名</label>
|
||||
<input
|
||||
id="reg-username-input"
|
||||
@@ -124,6 +128,8 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
autoComplete="username"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="login-field-group">
|
||||
<label className="login-auth-label" htmlFor="reg-email-input">邮箱</label>
|
||||
<input
|
||||
id="reg-email-input"
|
||||
@@ -135,6 +141,8 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="login-field-group">
|
||||
<label className="login-auth-label" htmlFor="reg-password-input">密码</label>
|
||||
<input
|
||||
id="reg-password-input"
|
||||
@@ -146,6 +154,8 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="login-field-group">
|
||||
<label className="login-auth-label" htmlFor="reg-confirm-password-input">确认密码</label>
|
||||
<input
|
||||
id="reg-confirm-password-input"
|
||||
@@ -157,8 +167,11 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
autoComplete="new-password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="login-captcha-panel">
|
||||
<div className="login-field-group">
|
||||
<label className="login-auth-label" htmlFor="reg-captcha-input">图像验证码</label>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 132px', gap: 10, alignItems: 'center' }}>
|
||||
<input
|
||||
id="reg-captcha-input"
|
||||
type="text"
|
||||
@@ -171,34 +184,29 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
maxLength={8}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="login-captcha-card"
|
||||
onClick={() => void loadCaptcha()}
|
||||
disabled={captchaLoading || loading}
|
||||
aria-label="刷新图像验证码"
|
||||
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 ? (
|
||||
<img
|
||||
src={captchaImage}
|
||||
alt="图像验证码"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>加载中...</span>
|
||||
<span>加载中...</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<div className="login-register-hint">
|
||||
注册会受到验证码和频率限制保护,避免批量注册影响服务稳定性。
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={
|
||||
|
||||
@@ -3,6 +3,7 @@ import React from 'react';
|
||||
export interface OAuthSlotProps {
|
||||
t: (text: string) => string;
|
||||
variant?: 'user';
|
||||
mode?: 'login' | 'register';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -15,27 +16,44 @@ export interface OAuthSlotProps {
|
||||
* 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) {
|
||||
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 (
|
||||
<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)',
|
||||
}}
|
||||
data-mode={mode}
|
||||
>
|
||||
<span>{t('第三方登录')}</span>
|
||||
<span style={{ fontSize: 11 }}>
|
||||
{t('即将上线,敬请期待')}
|
||||
</span>
|
||||
<div className="login-oauth-divider">
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../../../shared/tokenRoutePatterns.js';
|
||||
|
||||
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_ICON_NONE_VALUE = '__route_icon_none__';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user