feat: optimize routes and registration UX
This commit is contained in:
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,6 +1786,9 @@ export default function TokenRoutes() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{initialLoading ? (
|
||||||
|
<RouteListSkeleton />
|
||||||
|
) : (
|
||||||
<div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}>
|
<div className={isMobile ? 'mobile-card-list' : 'route-card-grid'}>
|
||||||
{visibleRoutes.map((route) => {
|
{visibleRoutes.map((route) => {
|
||||||
const isExpanded = expandedRouteIds.includes(route.id);
|
const isExpanded = expandedRouteIds.includes(route.id);
|
||||||
@@ -1998,8 +2040,9 @@ export default function TokenRoutes() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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">
|
||||||
|
|||||||
@@ -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,24 +96,27 @@ 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">
|
||||||
|
<div className="login-register-grid">
|
||||||
|
<div className="login-field-group">
|
||||||
<label className="login-auth-label" htmlFor="reg-username-input">用户名</label>
|
<label className="login-auth-label" htmlFor="reg-username-input">用户名</label>
|
||||||
<input
|
<input
|
||||||
id="reg-username-input"
|
id="reg-username-input"
|
||||||
@@ -124,6 +128,8 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
|||||||
autoComplete="username"
|
autoComplete="username"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="login-field-group">
|
||||||
<label className="login-auth-label" htmlFor="reg-email-input">邮箱</label>
|
<label className="login-auth-label" htmlFor="reg-email-input">邮箱</label>
|
||||||
<input
|
<input
|
||||||
id="reg-email-input"
|
id="reg-email-input"
|
||||||
@@ -135,6 +141,8 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
|||||||
autoComplete="email"
|
autoComplete="email"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="login-field-group">
|
||||||
<label className="login-auth-label" htmlFor="reg-password-input">密码</label>
|
<label className="login-auth-label" htmlFor="reg-password-input">密码</label>
|
||||||
<input
|
<input
|
||||||
id="reg-password-input"
|
id="reg-password-input"
|
||||||
@@ -146,6 +154,8 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="login-field-group">
|
||||||
<label className="login-auth-label" htmlFor="reg-confirm-password-input">确认密码</label>
|
<label className="login-auth-label" htmlFor="reg-confirm-password-input">确认密码</label>
|
||||||
<input
|
<input
|
||||||
id="reg-confirm-password-input"
|
id="reg-confirm-password-input"
|
||||||
@@ -157,8 +167,11 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
|||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="login-captcha-panel">
|
||||||
|
<div className="login-field-group">
|
||||||
<label className="login-auth-label" htmlFor="reg-captcha-input">图像验证码</label>
|
<label className="login-auth-label" htmlFor="reg-captcha-input">图像验证码</label>
|
||||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 132px', gap: 10, alignItems: 'center' }}>
|
|
||||||
<input
|
<input
|
||||||
id="reg-captcha-input"
|
id="reg-captcha-input"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -171,34 +184,29 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
|||||||
maxLength={8}
|
maxLength={8}
|
||||||
required
|
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={
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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__';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user