feat: 新增普通用户界面

- 重构 App.tsx 路由,分离普通用户/管理员导航
  (userSidebarGroups / userAuthed 状态)
- UserDashboard 重写为概览页:用量统计、最近记录、快速入口
- 新增 UsageRecords 使用记录页(分页)
- 新增 UserKeys 密钥管理页(创建/删除 sk-user- 密钥)
- 新增 UserProfile 个人资料页(修改用户名/密码)
- UserLogin 增加 onLogin 回调
- api.ts 增加 userApi 工具方法
This commit is contained in:
boos
2026-05-19 02:32:58 +08:00
parent 4f0c4959bf
commit bf00296a90
7 changed files with 656 additions and 313 deletions
+61 -24
View File
@@ -42,6 +42,9 @@ const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
const UserLogin = lazy(() => import('./pages/UserLogin.js')); const UserLogin = lazy(() => import('./pages/UserLogin.js'));
const UserRegister = lazy(() => import('./pages/UserRegister.js')); const UserRegister = lazy(() => import('./pages/UserRegister.js'));
const UserDashboard = lazy(() => import('./pages/UserDashboard.js')); const UserDashboard = lazy(() => import('./pages/UserDashboard.js'));
const UsageRecords = lazy(() => import('./pages/UsageRecords.js'));
const UserKeys = lazy(() => import('./pages/UserKeys.js'));
const UserProfile = lazy(() => import('./pages/UserProfile.js'));
const UserManagement = lazy(() => import('./pages/UserManagement.js')); const UserManagement = lazy(() => import('./pages/UserManagement.js'));
type ThemeMode = 'system' | 'light' | 'dark'; type ThemeMode = 'system' | 'light' | 'dark';
type UserProfile = { type UserProfile = {
@@ -532,6 +535,22 @@ export const sidebarGroups = [
], ],
}, },
]; ];
export const userSidebarGroups = [
{
label: '概览',
items: [
{ to: '/user/dashboard', label: '个人中心', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg> },
{ to: '/user/usage', label: '使用记录', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /></svg> },
],
},
{
label: '视频应用',
items: [
{ to: '/user/video-agent', label: '视频工作台', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> },
{ to: '/user/comfyui-agent', label: '视频助手', icon: <svg className="sidebar-item-icon" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
],
},
];
const topNavItems = [ const topNavItems = [
{ label: '控制台', to: '/' }, { label: '控制台', to: '/' },
{ label: '模型广场', to: '/models' }, { label: '模型广场', to: '/models' },
@@ -554,6 +573,7 @@ function RouteLoadingFallback() {
function AppShell() { function AppShell() {
const { language, toggleLanguage, t } = useI18n(); const { language, toggleLanguage, t } = useI18n();
const [authed, setAuthed] = useState(() => hasValidAuthSession(localStorage)); const [authed, setAuthed] = useState(() => hasValidAuthSession(localStorage));
const [userAuthed, setUserAuthed] = useState(() => hasValidUserSession(localStorage));
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [showUserMenu, setShowUserMenu] = useState(false); const [showUserMenu, setShowUserMenu] = useState(false);
const userMenuRef = useRef<HTMLDivElement>(null); const userMenuRef = useRef<HTMLDivElement>(null);
@@ -712,29 +732,37 @@ function AppShell() {
setShowProfileModal(false); setShowProfileModal(false);
toast.success(t('个人信息已保存')); toast.success(t('个人信息已保存'));
}; };
if (!authed) { const isUserPage = location.pathname.startsWith('/user/');
// User-facing public routes (no admin auth needed) const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname);
const publicUserRoutes = (
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
<Route path="/user/login" element={<UserLogin />} />
<Route path="/user/register" element={<UserRegister />} />
<Route path="/user/dashboard" element={<UserDashboard />} />
<Route path="*" element={<Navigate to="/user/login" />} />
</Routes>
</Suspense>
);
// Check if current path is a user route
if (location.pathname.startsWith('/user/')) {
return publicUserRoutes;
}
if (!authed && !isUserPage) {
return <Login t={t} onLogin={(token) => { return <Login t={t} onLogin={(token) => {
persistAuthSession(localStorage, token); persistAuthSession(localStorage, token);
setAuthed(true); setAuthed(true);
}} />; }} />;
} }
if (isUserPage && !userAuthed && !isPublicUserPage) {
return <Navigate to="/user/login" />;
}
if (isUserPage && isPublicUserPage) {
return (
<Suspense fallback={<RouteLoadingFallback />}>
<Routes>
<Route path="/user/login" element={<UserLogin onLogin={() => setUserAuthed(true)} />} />
<Route path="/user/register" element={<UserRegister />} />
</Routes>
</Suspense>
);
}
const currentSidebarGroups = isUserPage ? userSidebarGroups : sidebarGroups;
const currentTopNavItems = isUserPage ? [
{ label: '个人中心', to: '/user/dashboard' },
{ label: '视频工作台', to: '/user/video-agent' },
{ label: '视频助手', to: '/user/comfyui-agent' },
] : topNavItems;
return ( return (
<> <>
<header className="topbar"> <header className="topbar">
@@ -755,7 +783,7 @@ function AppShell() {
<span className="topbar-logo-text">BoosAPI</span> <span className="topbar-logo-text">BoosAPI</span>
</div> </div>
<nav className="topbar-nav"> <nav className="topbar-nav">
{topNavItems.map((item) => ( {currentTopNavItems.map((item) => (
<NavLink key={item.to} to={item.to} end className={({ isActive }) => `topbar-nav-item ${isActive ? 'active' : ''}`}> <NavLink key={item.to} to={item.to} end className={({ isActive }) => `topbar-nav-item ${isActive ? 'active' : ''}`}>
{t(item.label)} {t(item.label)}
</NavLink> </NavLink>
@@ -886,14 +914,14 @@ function AppShell() {
<span>BoosAPI</span> <span>BoosAPI</span>
</div> </div>
<nav className="mobile-nav"> <nav className="mobile-nav">
{sidebarGroups.map((group) => ( {currentSidebarGroups.map((group) => (
<div key={group.label} className="mobile-nav-group"> <div key={group.label} className="mobile-nav-group">
<div className="mobile-nav-label">{t(group.label)}</div> <div className="mobile-nav-label">{t(group.label)}</div>
{group.items.map((item) => ( {group.items.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
end={item.to === '/' || item.to === '/settings'} end={item.to === '/' || item.to === '/user/dashboard' || item.to === '/settings'}
className={({ isActive }) => `mobile-nav-item ${isActive ? 'active' : ''}`} className={({ isActive }) => `mobile-nav-item ${isActive ? 'active' : ''}`}
onClick={() => setDrawerOpen(false)} onClick={() => setDrawerOpen(false)}
> >
@@ -905,7 +933,7 @@ function AppShell() {
))} ))}
<div className="mobile-nav-group"> <div className="mobile-nav-group">
<div className="mobile-nav-label">{t('更多')}</div> <div className="mobile-nav-label">{t('更多')}</div>
{topNavItems.filter((n) => n.to !== '/').map((item) => ( {currentTopNavItems.filter((n) => n.to !== '/' && n.to !== '/user/dashboard').map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
@@ -920,14 +948,14 @@ function AppShell() {
</MobileDrawer> </MobileDrawer>
) : ( ) : (
<aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}> <aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
{sidebarGroups.map((group) => ( {currentSidebarGroups.map((group) => (
<div key={group.label} className="sidebar-group"> <div key={group.label} className="sidebar-group">
{!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>} {!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>}
{group.items.map((item) => ( {group.items.map((item) => (
<NavLink <NavLink
key={item.to} key={item.to}
to={item.to} to={item.to}
end={item.to === '/' || item.to === '/settings'} end={item.to === '/' || item.to === '/user/dashboard' || item.to === '/settings'}
className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`} className={({ isActive }) => `sidebar-item ${isActive ? 'active' : ''}`}
data-tooltip={sidebarCollapsed ? t(item.label) : undefined} data-tooltip={sidebarCollapsed ? t(item.label) : undefined}
aria-label={sidebarCollapsed ? t(item.label) : undefined} aria-label={sidebarCollapsed ? t(item.label) : undefined}
@@ -970,7 +998,16 @@ function AppShell() {
<Route path="/comfyui-agent" element={<ComfyUIAgent />} /> <Route path="/comfyui-agent" element={<ComfyUIAgent />} />
<Route path="/about" element={<About />} /> <Route path="/about" element={<About />} />
<Route path="/users" element={<UserManagement />} /> <Route path="/users" element={<UserManagement />} />
<Route path="*" element={<Navigate to="/" />} />
{/* User specific pages */}
<Route path="/user/dashboard" element={<UserDashboard />} />
<Route path="/user/dashboard/keys" element={<UserKeys />} />
<Route path="/user/dashboard/profile" element={<UserProfile />} />
<Route path="/user/usage" element={<UsageRecords />} />
<Route path="/user/video-agent" element={<ComfyUI />} />
<Route path="/user/comfyui-agent" element={<ComfyUIAgent />} />
<Route path="*" element={<Navigate to={isUserPage ? "/user/dashboard" : "/"} />} />
</Routes> </Routes>
</Suspense> </Suspense>
</PageTransition> </PageTransition>
+3
View File
@@ -1840,4 +1840,7 @@ export const userApi = {
userRequest(`/api/user-api-keys/${id}`, { method: "PATCH", body: JSON.stringify(data) }), userRequest(`/api/user-api-keys/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteApiKey: (id: number) => deleteApiKey: (id: number) =>
userRequest(`/api/user-api-keys/${id}`, { method: "DELETE" }), userRequest(`/api/user-api-keys/${id}`, { method: "DELETE" }),
getLogs: (params?: { limit?: number; offset?: number }) =>
userRequest(`/api/user/logs${buildQueryString(params)}`),
getSummary: () => userRequest("/api/user/summary"),
}; };
+143
View File
@@ -0,0 +1,143 @@
import React, { useState, useEffect, useCallback } from 'react';
import { userApi } from '../api.js';
import { useToast } from '../components/Toast.js';
interface LogItem {
id: number;
model_requested: string;
model_actual: string;
status: string;
http_status: number;
latency_ms: number;
total_tokens: number;
estimated_cost: number;
error_message?: string;
created_at: string;
}
export default function UsageRecords() {
const [logs, setLogs] = useState<LogItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const [page, setPage] = useState(1);
const pageSize = 20;
const toast = useToast();
const loadLogs = useCallback(async () => {
setLoading(true);
try {
const res = await userApi.getLogs({
limit: pageSize,
offset: (page - 1) * pageSize,
});
setLogs(res.items || []);
setTotal(res.total || 0);
} catch (err: any) {
toast.error(err?.message || '加载记录失败');
} finally {
setLoading(false);
}
}, [page, toast]);
useEffect(() => {
loadLogs();
}, [loadLogs]);
const totalPages = Math.ceil(total / pageSize);
return (
<div className="page-container" style={{ padding: '24px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h1 style={{ fontSize: '20px', fontWeight: 600, margin: 0 }}>使用记录</h1>
<button className="btn btn-ghost" onClick={loadLogs} disabled={loading}>
刷新
</button>
</div>
<div className="card" style={{ overflow: 'hidden' }}>
<div style={{ overflowX: 'auto' }}>
<table className="table">
<thead>
<tr>
<th>时间</th>
<th>请求模型</th>
<th>状态</th>
<th>耗时</th>
<th>Tokens</th>
<th>预估成本</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px' }}>
<div className="spinner" style={{ margin: '0 auto' }} />
</td>
</tr>
) : logs.length === 0 ? (
<tr>
<td colSpan={6} style={{ textAlign: 'center', padding: '40px', color: 'var(--color-text-muted)' }}>
暂无使用记录
</td>
</tr>
) : (
logs.map((log) => (
<tr key={log.id}>
<td style={{ fontSize: '12px' }}>{new Date(log.created_at).toLocaleString()}</td>
<td>
<div style={{ fontWeight: 500 }}>{log.model_requested}</div>
{log.model_actual && log.model_actual !== log.model_requested && (
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)' }}>→ {log.model_actual}</div>
)}
</td>
<td>
<span className={`badge badge-${log.status === 'success' ? 'success' : 'danger'}`}>
{log.status === 'success' ? '成功' : '失败'}
{log.http_status && ` (${log.http_status})`}
</span>
{log.error_message && (
<div style={{ fontSize: '11px', color: 'var(--color-danger)', marginTop: '2px', maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={log.error_message}>
{log.error_message}
</div>
)}
</td>
<td>{log.latency_ms}ms</td>
<td>{log.total_tokens || 0}</td>
<td>${log.estimated_cost ? log.estimated_cost.toFixed(6) : '0.000000'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
{totalPages > 1 && (
<div style={{ padding: '12px 20px', borderTop: '1px solid var(--color-border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div style={{ fontSize: '13px', color: 'var(--color-text-muted)' }}>
共 {total} 条记录
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button
className="btn btn-ghost btn-sm"
disabled={page === 1 || loading}
onClick={() => setPage(page - 1)}
>
上一页
</button>
<span style={{ fontSize: '13px', display: 'flex', alignItems: 'center' }}>
{page} / {totalPages}
</span>
<button
className="btn btn-ghost btn-sm"
disabled={page === totalPages || loading}
onClick={() => setPage(page + 1)}
>
下一页
</button>
</div>
</div>
)}
</div>
</div>
);
}
+140 -288
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { userApi } from '../api.js'; import { userApi } from '../api.js';
import { clearUserSession, getUserToken } from '../authSession.js'; import { clearUserSession, getUserToken } from '../authSession.js';
@@ -21,31 +21,29 @@ interface ApiKey {
createdAt?: string; createdAt?: string;
} }
interface UserSummary {
totalRequests: number;
successRequests: number;
totalCost: number;
}
interface LogItem {
id: number;
model_requested: string;
status: string;
latency_ms: number;
created_at: string;
}
export default function UserDashboard() { export default function UserDashboard() {
const navigate = useNavigate(); const navigate = useNavigate();
const [profile, setProfile] = useState<UserProfile | null>(null); const [profile, setProfile] = useState<UserProfile | null>(null);
const [keys, setKeys] = useState<ApiKey[]>([]); const [keys, setKeys] = useState<ApiKey[]>([]);
const [summary, setSummary] = useState<UserSummary | null>(null);
const [recentLogs, setRecentLogs] = useState<LogItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState(''); const [error, setError] = useState('');
// Profile edit
const [editingName, setEditingName] = useState(false);
const [newName, setNewName] = useState('');
const [saveMsg, setSaveMsg] = useState('');
// Password change
const [oldPw, setOldPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [pwMsg, setPwMsg] = useState('');
const [pwLoading, setPwLoading] = useState(false);
// New API key
const [showNewKey, setShowNewKey] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [createdKey, setCreatedKey] = useState<ApiKey | null>(null);
const [keyLoading, setKeyLoading] = useState(false);
const checkAuth = useCallback(() => { const checkAuth = useCallback(() => {
if (!getUserToken(localStorage)) { if (!getUserToken(localStorage)) {
navigate('/user/login'); navigate('/user/login');
@@ -57,311 +55,165 @@ export default function UserDashboard() {
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
if (!checkAuth()) return; if (!checkAuth()) return;
try { try {
const [profileRes, keysRes] = await Promise.all([ const [profileRes, keysRes, summaryRes, logsRes] = await Promise.all([
userApi.me(), userApi.me(),
userApi.getApiKeys(), userApi.getApiKeys(),
userApi.getSummary(),
userApi.getLogs({ limit: 5 }),
]); ]);
setProfile(profileRes.user); setProfile(profileRes.user);
setKeys(Array.isArray(keysRes) ? keysRes : (keysRes.keys || [])); setKeys(Array.isArray(keysRes) ? keysRes : (keysRes.keys || []));
setSummary(summaryRes);
setRecentLogs(logsRes.items || []);
} catch (err: any) { } catch (err: any) {
setError(err?.message || '加载失败'); setError(err?.message || '加载失败');
clearUserSession(localStorage);
navigate('/user/login');
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [checkAuth, navigate]); }, [checkAuth]);
useEffect(() => { loadData(); }, [loadData]); useEffect(() => { loadData(); }, [loadData]);
const handleSaveName = async () => {
if (!newName.trim() || !profile) return;
try {
await userApi.updateMe({ username: newName.trim() });
setProfile({ ...profile, username: newName.trim() });
setEditingName(false);
setSaveMsg('已保存');
setTimeout(() => setSaveMsg(''), 2000);
} catch (err: any) {
setSaveMsg(err?.message || '保存失败');
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (newPw !== confirmPw) { setPwMsg('两次密码不一致'); return; }
if (newPw.length < 6) { setPwMsg('密码至少 6 个字符'); return; }
setPwLoading(true);
setPwMsg('');
try {
await userApi.changePassword({ oldPassword: oldPw, newPassword: newPw });
setPwMsg('密码已更新');
setOldPw(''); setNewPw(''); setConfirmPw('');
} catch (err: any) {
setPwMsg(err?.message || '修改失败');
} finally {
setPwLoading(false);
}
};
const handleCreateKey = async () => {
if (!newKeyName.trim()) return;
setKeyLoading(true);
try {
const result = await userApi.createApiKey({ name: newKeyName.trim() });
setCreatedKey(result.key);
setKeys((prev) => [...prev, result.key]);
setNewKeyName('');
} catch (err: any) {
setSaveMsg(err?.message || '创建失败');
} finally {
setKeyLoading(false);
}
};
const handleDeleteKey = async (id: number) => {
try {
await userApi.deleteApiKey(id);
setKeys((prev) => prev.filter((k) => k.id !== id));
} catch (err: any) {
setError(err?.message || '删除失败');
}
};
const handleLogout = () => {
clearUserSession(localStorage);
navigate('/user/login');
};
if (loading) { if (loading) {
return ( return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100vh', background: 'var(--color-bg, #0f0f1a)' }}> <div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%', minHeight: '400px' }}>
<div className="spinner" /> <div className="spinner" />
</div> </div>
); );
} }
if (!profile) { if (!profile) return null;
return (
<div className="login-shell">
<div className="login-surface animate-scale-in" style={{ maxWidth: 420 }}>
<div className="login-auth-stage">
<div className="login-auth-panel" style={{ textAlign: 'center', padding: '40px 24px' }}>
<p style={{ color: 'var(--color-text-muted)', marginBottom: 16 }}>{error || '未登录'}</p>
<button onClick={() => navigate('/user/login')} className="btn btn-primary">去登录</button>
</div>
</div>
</div>
</div>
);
}
const inputStyle: React.CSSProperties = { const successRate = summary && summary.totalRequests > 0
width: '100%', ? ((summary.successRequests / summary.totalRequests) * 100).toFixed(1)
padding: '10px 14px', : '0.0';
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
boxSizing: 'border-box',
};
return ( return (
<div style={{ <div className="page-container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
minHeight: '100vh', <header style={{ marginBottom: '24px' }}>
background: 'var(--color-bg, #0f0f1a)', <h1 style={{ fontSize: '24px', fontWeight: 700, margin: '0 0 8px 0' }}>你好, {profile.username}</h1>
color: 'var(--color-text, #e0e0f0)', <p style={{ color: 'var(--color-text-muted)', margin: 0 }}>欢迎回到 BoosAPI 用户中心。</p>
fontFamily: 'system-ui, -apple-system, sans-serif',
}}>
{/* Header */}
<header style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '12px 24px', borderBottom: '1px solid var(--color-border, #2a2a3e)',
background: 'var(--color-surface, #1a1a2e)',
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<img src="/logo.png" alt="BoosAPI" style={{ width: 28, height: 28, borderRadius: 6 }} />
<span style={{ fontSize: 16, fontWeight: 600 }}>BoosAPI</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>用户中心</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<span style={{ fontSize: 13, color: 'var(--color-text-secondary)' }}>{profile.email}</span>
<button onClick={handleLogout} className="btn btn-ghost" style={{ fontSize: 13 }}>退出</button>
</div>
</header> </header>
<div style={{ maxWidth: 720, margin: '0 auto', padding: '24px 16px', display: 'flex', flexDirection: 'column', gap: 24 }}> {/* Summary Cards */}
{error && ( <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '20px', marginBottom: '32px' }}>
<div className="alert alert-error">{error}</div> <div className="card" style={{ padding: '20px' }}>
)} <div style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '8px' }}>总请求次数</div>
<div style={{ fontSize: '28px', fontWeight: 700 }}>{summary?.totalRequests || 0}</div>
</div>
<div className="card" style={{ padding: '20px' }}>
<div style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '8px' }}>请求成功率</div>
<div style={{ fontSize: '28px', fontWeight: 700, color: 'var(--color-success)' }}>{successRate}%</div>
</div>
<div className="card" style={{ padding: '20px' }}>
<div style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '8px' }}>总消耗金额</div>
<div style={{ fontSize: '28px', fontWeight: 700 }}>${summary?.totalCost ? summary.totalCost.toFixed(4) : '0.0000'}</div>
</div>
</div>
{/* Profile Section */} <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(350px, 1fr))', gap: '24px' }}>
<section style={{ {/* Left Column: Recent Logs & Quick Entries */}
background: 'var(--color-surface, #1a1a2e)', <div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
borderRadius: 12, padding: 20, {/* Quick Entries */}
border: '1px solid var(--color-border, #2a2a3e)', <section className="card" style={{ padding: '20px' }}>
}}> <h2 style={{ fontSize: '16px', fontWeight: 600, margin: '0 0 16px 0' }}>快速入口</h2>
<h2 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 16px 0' }}>个人信息</h2> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}> <Link to="/user/video-agent" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', height: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>
<div> <svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>用户名</div> <span>视频工作台</span>
{editingName ? ( </Link>
<div style={{ display: 'flex', gap: 8 }}> <Link to="/user/comfyui-agent" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', height: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>
<input value={newName} onChange={(e) => setNewName(e.target.value)} style={inputStyle} autoFocus /> <svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
<button onClick={handleSaveName} className="btn btn-primary" style={{ whiteSpace: 'nowrap' }}>保存</button> <span>视频助手</span>
<button onClick={() => setEditingName(false)} className="btn btn-ghost">取消</button> </Link>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 14 }}>{profile.username}</span>
<button onClick={() => { setNewName(profile.username); setEditingName(true); }} className="btn btn-ghost" style={{ fontSize: 12, padding: '2px 8px' }}>编辑</button>
</div>
)}
{saveMsg && <div style={{ fontSize: 12, color: 'var(--color-primary)', marginTop: 4 }}>{saveMsg}</div>}
</div> </div>
<div> </section>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>邮箱</div>
<span style={{ fontSize: 14 }}>{profile.email}</span>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>角色</div>
<span style={{
fontSize: 12, padding: '2px 8px', borderRadius: 4,
background: profile.role === 'admin' ? 'rgba(99,102,241,0.15)' : 'rgba(34,197,94,0.15)',
color: profile.role === 'admin' ? '#818cf8' : '#4ade80',
}}>
{profile.role === 'admin' ? '管理员' : '普通用户'}
</span>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>状态</div>
<span style={{
fontSize: 12, padding: '2px 8px', borderRadius: 4,
background: profile.status === 'active' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
color: profile.status === 'active' ? '#4ade80' : '#f87171',
}}>
{profile.status === 'active' ? '正常' : '已禁用'}
</span>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>注册时间</div>
<span style={{ fontSize: 14 }}>{profile.createdAt}</span>
</div>
</div>
</section>
{/* Password Change */} {/* Recent Records */}
<section style={{ <section className="card" style={{ padding: '20px' }}>
background: 'var(--color-surface, #1a1a2e)', <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
borderRadius: 12, padding: 20, <h2 style={{ fontSize: '16px', fontWeight: 600, margin: 0 }}>最近记录</h2>
border: '1px solid var(--color-border, #2a2a3e)', <Link to="/user/usage" style={{ fontSize: '13px', color: 'var(--color-primary)', textDecoration: 'none' }}>查看全部</Link>
}}>
<h2 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 16px 0' }}>修改密码</h2>
<form onSubmit={handleChangePassword} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>当前密码</div>
<input type="password" value={oldPw} onChange={(e) => setOldPw(e.target.value)} style={inputStyle} required />
</div> </div>
<div> {recentLogs.length === 0 ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>新密码</div> <div style={{ textAlign: 'center', padding: '20px', color: 'var(--color-text-muted)', fontSize: '13px' }}>暂无记录</div>
<input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} style={inputStyle} required /> ) : (
</div> <div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
<div> {recentLogs.map(log => (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>确认新密码</div> <div key={log.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '10px', borderBottom: '1px solid var(--color-border-light)' }}>
<input type="password" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)} style={inputStyle} required /> <div>
</div> <div style={{ fontSize: '14px', fontWeight: 500 }}>{log.model_requested}</div>
{pwMsg && <div style={{ fontSize: 12, color: pwMsg.includes('已更新') ? 'var(--color-primary)' : '#f87171' }}>{pwMsg}</div>} <div style={{ fontSize: '11px', color: 'var(--color-text-muted)' }}>{new Date(log.created_at).toLocaleString()}</div>
<button type="submit" disabled={pwLoading || !oldPw || !newPw || !confirmPw} className="btn btn-primary" style={{ alignSelf: 'flex-start' }}> </div>
{pwLoading ? '修改中...' : '修改密码'} <div style={{ textAlign: 'right' }}>
</button> <span className={`badge badge-${log.status === 'success' ? 'success' : 'danger'}`} style={{ fontSize: '10px' }}>
</form> {log.status === 'success' ? '成功' : '失败'}
</section> </span>
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)', marginTop: '2px' }}>{log.latency_ms}ms</div>
{/* API Keys */}
<section style={{
background: 'var(--color-surface, #1a1a2e)',
borderRadius: 12, padding: 20,
border: '1px solid var(--color-border, #2a2a3e)',
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ fontSize: 16, fontWeight: 600, margin: 0 }}>API 密钥</h2>
<button onClick={() => setShowNewKey(!showNewKey)} className="btn btn-primary" style={{ fontSize: 13 }}>
{showNewKey ? '取消' : '新建密钥'}
</button>
</div>
{showNewKey && (
<div style={{
padding: 12, marginBottom: 12, borderRadius: 8,
background: 'var(--color-bg, #0f0f1a)',
border: '1px solid var(--color-border, #2a2a3e)',
}}>
{createdKey ? (
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 8 }}>密钥已创建,请立即复制(不会再次显示):</div>
<div style={{
padding: '8px 12px', borderRadius: 6, fontSize: 13, fontFamily: 'monospace',
background: 'rgba(99,102,241,0.1)', border: '1px solid rgba(99,102,241,0.3)',
wordBreak: 'break-all', marginBottom: 8,
}}>
{createdKey.key || 'sk-user-...'}
</div>
<button onClick={() => { setCreatedKey(null); setShowNewKey(false); }} className="btn btn-ghost" style={{ fontSize: 12 }}>关闭</button>
</div>
) : (
<div style={{ display: 'flex', gap: 8 }}>
<input
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="密钥名称"
style={{ ...inputStyle, flex: 1 }}
autoFocus
/>
<button onClick={handleCreateKey} disabled={keyLoading || !newKeyName.trim()} className="btn btn-primary" style={{ whiteSpace: 'nowrap' }}>
{keyLoading ? '创建中...' : '创建'}
</button>
</div>
)}
</div>
)}
{keys.length === 0 ? (
<div style={{ fontSize: 13, color: 'var(--color-text-muted)', padding: '16px 0', textAlign: 'center' }}>
暂无 API 密钥
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
{keys.map((key) => (
<div key={key.id} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '10px 12px', borderRadius: 8,
background: 'var(--color-bg, #0f0f1a)',
border: '1px solid var(--color-border, #2a2a3e)',
}}>
<div>
<div style={{ fontSize: 14, fontWeight: 500 }}>{key.name}</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 2 }}>
{key.key ? key.key.slice(0, 20) + '...' : ''}
{key.description && ` · ${key.description}`}
</div> </div>
</div> </div>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> ))}
<span style={{ </div>
fontSize: 11, padding: '2px 6px', borderRadius: 3, )}
background: key.enabled ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)', </section>
color: key.enabled ? '#4ade80' : '#f87171', </div>
}}>
{/* Right Column: API Keys */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
<section className="card" style={{ padding: '20px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '16px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: 0 }}>API 密钥</h2>
<button className="btn btn-primary btn-sm" onClick={() => navigate('/user/dashboard/keys')}>管理</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{keys.length === 0 ? (
<div style={{ textAlign: 'center', padding: '20px', color: 'var(--color-text-muted)', fontSize: '13px' }}>尚未创建密钥</div>
) : (
keys.slice(0, 3).map(key => (
<div key={key.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px', borderRadius: '8px', background: 'var(--color-bg)' }}>
<div>
<div style={{ fontSize: '14px', fontWeight: 500 }}>{key.name}</div>
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)', fontFamily: 'monospace' }}>
{key.key ? `${key.key.slice(0, 12)}...` : '********'}
</div>
</div>
<span className={`badge badge-${key.enabled ? 'success' : 'danger'}`} style={{ fontSize: '10px' }}>
{key.enabled ? '启用' : '禁用'} {key.enabled ? '启用' : '禁用'}
</span> </span>
<button onClick={() => handleDeleteKey(key.id)} className="btn btn-ghost danger" style={{ fontSize: 12, padding: '2px 8px' }}>删除</button>
</div> </div>
))
)}
{keys.length > 3 && (
<div style={{ textAlign: 'center', fontSize: '12px', color: 'var(--color-text-muted)' }}>
还有 {keys.length - 3} 个密钥...
</div> </div>
))} )}
</div> </div>
)} </section>
</section>
{/* Account Info */}
<section className="card" style={{ padding: '20px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: '0 0 16px 0' }}>账号信息</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px', fontSize: '14px' }}>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--color-text-muted)' }}>邮箱</span>
<span>{profile.email}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--color-text-muted)' }}>账号角色</span>
<span className="badge badge-success">{profile.role === 'admin' ? '管理员' : '普通用户'}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--color-text-muted)' }}>注册时间</span>
<span>{new Date(profile.createdAt).toLocaleDateString()}</span>
</div>
</div>
<div style={{ marginTop: '20px', display: 'flex', gap: '10px' }}>
<button className="btn btn-ghost btn-sm" style={{ flex: 1, border: '1px solid var(--color-border)' }} onClick={() => navigate('/user/dashboard/profile')}>修改资料</button>
<button className="btn btn-ghost btn-sm danger" style={{ flex: 1, border: '1px solid var(--color-border)' }} onClick={() => { clearUserSession(localStorage); navigate('/user/login'); }}>退出登录</button>
</div>
</section>
</div>
</div> </div>
</div> </div>
); );
+152
View File
@@ -0,0 +1,152 @@
import React, { useState, useEffect, useCallback } from 'react';
import { userApi } from '../api.js';
interface ApiKey {
id: number;
name: string;
key?: string;
description?: string;
enabled: boolean;
createdAt?: string;
}
export default function UserKeys() {
const [keys, setKeys] = useState<ApiKey[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// New API key
const [showNewKey, setShowNewKey] = useState(false);
const [newKeyName, setNewKeyName] = useState('');
const [createdKey, setCreatedKey] = useState<ApiKey | null>(null);
const [keyLoading, setKeyLoading] = useState(false);
const loadData = useCallback(async () => {
try {
const keysRes = await userApi.getApiKeys();
setKeys(Array.isArray(keysRes) ? keysRes : (keysRes.keys || []));
} catch (err: any) {
setError(err?.message || '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadData(); }, [loadData]);
const handleCreateKey = async () => {
if (!newKeyName.trim()) return;
setKeyLoading(true);
try {
const result = await userApi.createApiKey({ name: newKeyName.trim() });
setCreatedKey(result.key);
setKeys((prev) => [...prev, result.key]);
setNewKeyName('');
} catch (err: any) {
setError(err?.message || '创建失败');
} finally {
setKeyLoading(false);
}
};
const handleDeleteKey = async (id: number) => {
if (!window.confirm('确定要删除这个密钥吗?')) return;
try {
await userApi.deleteApiKey(id);
setKeys((prev) => prev.filter((k) => k.id !== id));
} catch (err: any) {
setError(err?.message || '删除失败');
}
};
if (loading) return <div className="spinner" style={{ margin: '40px auto' }} />;
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
boxSizing: 'border-box',
};
return (
<div className="page-container" style={{ padding: '24px', maxWidth: '800px', margin: '0 auto' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '20px' }}>
<h1 style={{ fontSize: '20px', fontWeight: 600, margin: 0 }}>API 密钥管理</h1>
<button onClick={() => { setShowNewKey(!showNewKey); setCreatedKey(null); }} className="btn btn-primary">
{showNewKey ? '取消' : '新建密钥'}
</button>
</div>
{error && <div className="alert alert-error" style={{ marginBottom: '20px' }}>{error}</div>}
{showNewKey && (
<div className="card" style={{ padding: '20px', marginBottom: '24px' }}>
{createdKey ? (
<div>
<div style={{ fontSize: '13px', color: 'var(--color-text-muted)', marginBottom: '12px' }}>
密钥已创建,请立即复制(不会再次完整显示):
</div>
<div style={{
padding: '12px', borderRadius: '8px', fontSize: '14px', fontFamily: 'monospace',
background: 'rgba(99,102,241,0.1)', border: '1px solid rgba(99,102,241,0.3)',
wordBreak: 'break-all', marginBottom: '16px', color: 'var(--color-primary)'
}}>
{createdKey.key}
</div>
<button onClick={() => { setCreatedKey(null); setShowNewKey(false); }} className="btn btn-ghost">关闭</button>
</div>
) : (
<div style={{ display: 'flex', gap: '12px' }}>
<input
value={newKeyName}
onChange={(e) => setNewKeyName(e.target.value)}
placeholder="密钥名称(如:我的工作台)"
style={inputStyle}
autoFocus
/>
<button onClick={handleCreateKey} disabled={keyLoading || !newKeyName.trim()} className="btn btn-primary" style={{ whiteSpace: 'nowrap' }}>
{keyLoading ? '创建中...' : '立即创建'}
</button>
</div>
)}
</div>
)}
<div className="card">
{keys.length === 0 ? (
<div style={{ padding: '40px', textAlign: 'center', color: 'var(--color-text-muted)' }}>
暂无 API 密钥,点击右上角新建一个。
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column' }}>
{keys.map((key, index) => (
<div key={key.id} style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '16px 20px',
borderBottom: index === keys.length - 1 ? 'none' : '1px solid var(--color-border-light)'
}}>
<div>
<div style={{ fontSize: '15px', fontWeight: 600 }}>{key.name}</div>
<div style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginTop: '4px', fontFamily: 'monospace' }}>
{key.key ? `${key.key.slice(0, 16)}...` : '********'}
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span className={`badge badge-${key.enabled ? 'success' : 'danger'}`}>
{key.enabled ? '已启用' : '已禁用'}
</span>
<button onClick={() => handleDeleteKey(key.id)} className="btn btn-ghost danger btn-sm">删除</button>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}
+2 -1
View File
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { persistUserSession } from '../authSession.js'; import { persistUserSession } from '../authSession.js';
export default function UserLogin() { export default function UserLogin({ onLogin }: { onLogin?: () => void }) {
const navigate = useNavigate(); const navigate = useNavigate();
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
@@ -26,6 +26,7 @@ export default function UserLogin() {
} }
const result = await res.json(); const result = await res.json();
persistUserSession(localStorage, result.token); persistUserSession(localStorage, result.token);
onLogin?.();
navigate('/user/dashboard'); navigate('/user/dashboard');
} catch (err: any) { } catch (err: any) {
setError(err?.message || '登录失败,请检查邮箱和密码'); setError(err?.message || '登录失败,请检查邮箱和密码');
+155
View File
@@ -0,0 +1,155 @@
import React, { useState, useEffect, useCallback } from 'react';
import { userApi } from '../api.js';
import { useToast } from '../components/Toast.js';
interface UserProfileData {
id: number;
username: string;
email: string;
role: string;
status: string;
createdAt: string;
}
export default function UserProfile() {
const [profile, setProfile] = useState<UserProfileData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const toast = useToast();
// Profile edit
const [editingName, setEditingName] = useState(false);
const [newName, setNewName] = useState('');
const [saveLoading, setSaveLoading] = useState(false);
// Password change
const [oldPw, setOldPw] = useState('');
const [newPw, setNewPw] = useState('');
const [confirmPw, setConfirmPw] = useState('');
const [pwLoading, setPwLoading] = useState(false);
const loadData = useCallback(async () => {
try {
const profileRes = await userApi.me();
setProfile(profileRes.user);
setNewName(profileRes.user.username);
} catch (err: any) {
setError(err?.message || '加载失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadData(); }, [loadData]);
const handleSaveName = async () => {
if (!newName.trim() || !profile) return;
setSaveLoading(true);
try {
await userApi.updateMe({ username: newName.trim() });
setProfile({ ...profile, username: newName.trim() });
setEditingName(false);
toast.success('用户名已更新');
} catch (err: any) {
toast.error(err?.message || '保存失败');
} finally {
setSaveLoading(false);
}
};
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault();
if (newPw !== confirmPw) { toast.error('两次新密码输入不一致'); return; }
if (newPw.length < 6) { toast.error('新密码至少需要 6 个字符'); return; }
setPwLoading(true);
try {
await userApi.changePassword({ oldPassword: oldPw, newPassword: newPw });
toast.success('密码修改成功');
setOldPw(''); setNewPw(''); setConfirmPw('');
} catch (err: any) {
toast.error(err?.message || '修改失败');
} finally {
setPwLoading(false);
}
};
if (loading) return <div className="spinner" style={{ margin: '40px auto' }} />;
if (!profile) return <div className="alert alert-error">无法加载用户信息</div>;
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 14px',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: 13,
outline: 'none',
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
boxSizing: 'border-box',
};
return (
<div className="page-container" style={{ padding: '24px', maxWidth: '600px', margin: '0 auto' }}>
<h1 style={{ fontSize: '20px', fontWeight: 600, marginBottom: '24px' }}>个人资料</h1>
<section className="card" style={{ padding: '24px', marginBottom: '24px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '20px' }}>基本信息</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<div>
<div style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '6px' }}>用户名</div>
{editingName ? (
<div style={{ display: 'flex', gap: '8px' }}>
<input value={newName} onChange={(e) => setNewName(e.target.value)} style={inputStyle} autoFocus />
<button onClick={handleSaveName} disabled={saveLoading || !newName.trim()} className="btn btn-primary btn-sm">保存</button>
<button onClick={() => { setEditingName(false); setNewName(profile.username); }} className="btn btn-ghost btn-sm">取消</button>
</div>
) : (
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '15px', fontWeight: 500 }}>{profile.username}</span>
<button onClick={() => setEditingName(true)} className="btn btn-ghost btn-sm">编辑</button>
</div>
)}
</div>
<div>
<div style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '6px' }}>邮箱地址</div>
<div style={{ fontSize: '15px' }}>{profile.email}</div>
</div>
<div style={{ display: 'flex', gap: '40px' }}>
<div>
<div style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '6px' }}>账号状态</div>
<span className={`badge badge-${profile.status === 'active' ? 'success' : 'danger'}`}>
{profile.status === 'active' ? '正常' : '禁用'}
</span>
</div>
<div>
<div style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '6px' }}>注册时间</div>
<div style={{ fontSize: '14px' }}>{new Date(profile.createdAt).toLocaleDateString()}</div>
</div>
</div>
</div>
</section>
<section className="card" style={{ padding: '24px' }}>
<h2 style={{ fontSize: '16px', fontWeight: 600, marginBottom: '20px' }}>修改密码</h2>
<form onSubmit={handleChangePassword} style={{ display: 'flex', flexDirection: 'column', gap: '16px' }}>
<div>
<label style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '6px', display: 'block' }}>当前密码</label>
<input type="password" value={oldPw} onChange={(e) => setOldPw(e.target.value)} style={inputStyle} required />
</div>
<div>
<label style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '6px', display: 'block' }}>新密码</label>
<input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} style={inputStyle} required />
</div>
<div>
<label style={{ fontSize: '12px', color: 'var(--color-text-muted)', marginBottom: '6px', display: 'block' }}>确认新密码</label>
<input type="password" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)} style={inputStyle} required />
</div>
<button type="submit" disabled={pwLoading || !oldPw || !newPw || !confirmPw} className="btn btn-primary" style={{ marginTop: '8px' }}>
{pwLoading ? '正在提交...' : '更新密码'}
</button>
</form>
</section>
</div>
);
}