feat: 新增普通用户界面
- 重构 App.tsx 路由,分离普通用户/管理员导航 (userSidebarGroups / userAuthed 状态) - UserDashboard 重写为概览页:用量统计、最近记录、快速入口 - 新增 UsageRecords 使用记录页(分页) - 新增 UserKeys 密钥管理页(创建/删除 sk-user- 密钥) - 新增 UserProfile 个人资料页(修改用户名/密码) - UserLogin 增加 onLogin 回调 - api.ts 增加 userApi 工具方法
This commit is contained in:
+61
-24
@@ -42,6 +42,9 @@ const SiteAnnouncements = lazy(() => import('./pages/SiteAnnouncements.js'));
|
||||
const UserLogin = lazy(() => import('./pages/UserLogin.js'));
|
||||
const UserRegister = lazy(() => import('./pages/UserRegister.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'));
|
||||
type ThemeMode = 'system' | 'light' | 'dark';
|
||||
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 = [
|
||||
{ label: '控制台', to: '/' },
|
||||
{ label: '模型广场', to: '/models' },
|
||||
@@ -554,6 +573,7 @@ function RouteLoadingFallback() {
|
||||
function AppShell() {
|
||||
const { language, toggleLanguage, t } = useI18n();
|
||||
const [authed, setAuthed] = useState(() => hasValidAuthSession(localStorage));
|
||||
const [userAuthed, setUserAuthed] = useState(() => hasValidUserSession(localStorage));
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||
const userMenuRef = useRef<HTMLDivElement>(null);
|
||||
@@ -712,29 +732,37 @@ function AppShell() {
|
||||
setShowProfileModal(false);
|
||||
toast.success(t('个人信息已保存'));
|
||||
};
|
||||
if (!authed) {
|
||||
// User-facing public routes (no admin auth needed)
|
||||
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;
|
||||
}
|
||||
const isUserPage = location.pathname.startsWith('/user/');
|
||||
const isPublicUserPage = ['/user/login', '/user/register'].includes(location.pathname);
|
||||
|
||||
if (!authed && !isUserPage) {
|
||||
return <Login t={t} onLogin={(token) => {
|
||||
persistAuthSession(localStorage, token);
|
||||
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 (
|
||||
<>
|
||||
<header className="topbar">
|
||||
@@ -755,7 +783,7 @@ function AppShell() {
|
||||
<span className="topbar-logo-text">BoosAPI</span>
|
||||
</div>
|
||||
<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' : ''}`}>
|
||||
{t(item.label)}
|
||||
</NavLink>
|
||||
@@ -886,14 +914,14 @@ function AppShell() {
|
||||
<span>BoosAPI</span>
|
||||
</div>
|
||||
<nav className="mobile-nav">
|
||||
{sidebarGroups.map((group) => (
|
||||
{currentSidebarGroups.map((group) => (
|
||||
<div key={group.label} className="mobile-nav-group">
|
||||
<div className="mobile-nav-label">{t(group.label)}</div>
|
||||
{group.items.map((item) => (
|
||||
<NavLink
|
||||
key={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' : ''}`}
|
||||
onClick={() => setDrawerOpen(false)}
|
||||
>
|
||||
@@ -905,7 +933,7 @@ function AppShell() {
|
||||
))}
|
||||
<div className="mobile-nav-group">
|
||||
<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
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
@@ -920,14 +948,14 @@ function AppShell() {
|
||||
</MobileDrawer>
|
||||
) : (
|
||||
<aside className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}>
|
||||
{sidebarGroups.map((group) => (
|
||||
{currentSidebarGroups.map((group) => (
|
||||
<div key={group.label} className="sidebar-group">
|
||||
{!sidebarCollapsed && <div className="sidebar-group-label">{t(group.label)}</div>}
|
||||
{group.items.map((item) => (
|
||||
<NavLink
|
||||
key={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' : ''}`}
|
||||
data-tooltip={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="/about" element={<About />} />
|
||||
<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>
|
||||
</Suspense>
|
||||
</PageTransition>
|
||||
|
||||
@@ -1840,4 +1840,7 @@ export const userApi = {
|
||||
userRequest(`/api/user-api-keys/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
deleteApiKey: (id: number) =>
|
||||
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"),
|
||||
};
|
||||
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
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 { clearUserSession, getUserToken } from '../authSession.js';
|
||||
|
||||
@@ -21,31 +21,29 @@ interface ApiKey {
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const [profile, setProfile] = useState<UserProfile | null>(null);
|
||||
const [keys, setKeys] = useState<ApiKey[]>([]);
|
||||
const [summary, setSummary] = useState<UserSummary | null>(null);
|
||||
const [recentLogs, setRecentLogs] = useState<LogItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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(() => {
|
||||
if (!getUserToken(localStorage)) {
|
||||
navigate('/user/login');
|
||||
@@ -57,311 +55,165 @@ export default function UserDashboard() {
|
||||
const loadData = useCallback(async () => {
|
||||
if (!checkAuth()) return;
|
||||
try {
|
||||
const [profileRes, keysRes] = await Promise.all([
|
||||
const [profileRes, keysRes, summaryRes, logsRes] = await Promise.all([
|
||||
userApi.me(),
|
||||
userApi.getApiKeys(),
|
||||
userApi.getSummary(),
|
||||
userApi.getLogs({ limit: 5 }),
|
||||
]);
|
||||
setProfile(profileRes.user);
|
||||
setKeys(Array.isArray(keysRes) ? keysRes : (keysRes.keys || []));
|
||||
setSummary(summaryRes);
|
||||
setRecentLogs(logsRes.items || []);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '加载失败');
|
||||
clearUserSession(localStorage);
|
||||
navigate('/user/login');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [checkAuth, navigate]);
|
||||
}, [checkAuth]);
|
||||
|
||||
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) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
if (!profile) {
|
||||
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>
|
||||
);
|
||||
}
|
||||
if (!profile) return null;
|
||||
|
||||
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',
|
||||
};
|
||||
const successRate = summary && summary.totalRequests > 0
|
||||
? ((summary.successRequests / summary.totalRequests) * 100).toFixed(1)
|
||||
: '0.0';
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
minHeight: '100vh',
|
||||
background: 'var(--color-bg, #0f0f1a)',
|
||||
color: 'var(--color-text, #e0e0f0)',
|
||||
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>
|
||||
<div className="page-container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<header style={{ marginBottom: '24px' }}>
|
||||
<h1 style={{ fontSize: '24px', fontWeight: 700, margin: '0 0 8px 0' }}>你好, {profile.username}</h1>
|
||||
<p style={{ color: 'var(--color-text-muted)', margin: 0 }}>欢迎回到 BoosAPI 用户中心。</p>
|
||||
</header>
|
||||
|
||||
<div style={{ maxWidth: 720, margin: '0 auto', padding: '24px 16px', display: 'flex', flexDirection: 'column', gap: 24 }}>
|
||||
{error && (
|
||||
<div className="alert alert-error">{error}</div>
|
||||
)}
|
||||
{/* Summary Cards */}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))', gap: '20px', marginBottom: '32px' }}>
|
||||
<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 */}
|
||||
<section style={{
|
||||
background: 'var(--color-surface, #1a1a2e)',
|
||||
borderRadius: 12, padding: 20,
|
||||
border: '1px solid var(--color-border, #2a2a3e)',
|
||||
}}>
|
||||
<h2 style={{ fontSize: 16, fontWeight: 600, margin: '0 0 16px 0' }}>个人信息</h2>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>用户名</div>
|
||||
{editingName ? (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input value={newName} onChange={(e) => setNewName(e.target.value)} style={inputStyle} autoFocus />
|
||||
<button onClick={handleSaveName} className="btn btn-primary" style={{ whiteSpace: 'nowrap' }}>保存</button>
|
||||
<button onClick={() => setEditingName(false)} className="btn btn-ghost">取消</button>
|
||||
</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 style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(350px, 1fr))', gap: '24px' }}>
|
||||
{/* Left Column: Recent Logs & Quick Entries */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '24px' }}>
|
||||
{/* Quick Entries */}
|
||||
<section className="card" style={{ padding: '20px' }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: '0 0 16px 0' }}>快速入口</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<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' }}>
|
||||
<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>
|
||||
<span>视频工作台</span>
|
||||
</Link>
|
||||
<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' }}>
|
||||
<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>
|
||||
<span>视频助手</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
</section>
|
||||
|
||||
{/* Password Change */}
|
||||
<section style={{
|
||||
background: 'var(--color-surface, #1a1a2e)',
|
||||
borderRadius: 12, padding: 20,
|
||||
border: '1px solid var(--color-border, #2a2a3e)',
|
||||
}}>
|
||||
<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 />
|
||||
{/* Recent Records */}
|
||||
<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 }}>最近记录</h2>
|
||||
<Link to="/user/usage" style={{ fontSize: '13px', color: 'var(--color-primary)', textDecoration: 'none' }}>查看全部</Link>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>新密码</div>
|
||||
<input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} style={inputStyle} required />
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>确认新密码</div>
|
||||
<input type="password" value={confirmPw} onChange={(e) => setConfirmPw(e.target.value)} style={inputStyle} required />
|
||||
</div>
|
||||
{pwMsg && <div style={{ fontSize: 12, color: pwMsg.includes('已更新') ? 'var(--color-primary)' : '#f87171' }}>{pwMsg}</div>}
|
||||
<button type="submit" disabled={pwLoading || !oldPw || !newPw || !confirmPw} className="btn btn-primary" style={{ alignSelf: 'flex-start' }}>
|
||||
{pwLoading ? '修改中...' : '修改密码'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* 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}`}
|
||||
{recentLogs.length === 0 ? (
|
||||
<div style={{ textAlign: 'center', padding: '20px', color: 'var(--color-text-muted)', fontSize: '13px' }}>暂无记录</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
|
||||
{recentLogs.map(log => (
|
||||
<div key={log.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', paddingBottom: '10px', borderBottom: '1px solid var(--color-border-light)' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 500 }}>{log.model_requested}</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)' }}>{new Date(log.created_at).toLocaleString()}</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<span className={`badge badge-${log.status === 'success' ? 'success' : 'danger'}`} style={{ fontSize: '10px' }}>
|
||||
{log.status === 'success' ? '成功' : '失败'}
|
||||
</span>
|
||||
<div style={{ fontSize: '11px', color: 'var(--color-text-muted)', marginTop: '2px' }}>{log.latency_ms}ms</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<span style={{
|
||||
fontSize: 11, padding: '2px 6px', borderRadius: 3,
|
||||
background: key.enabled ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
|
||||
color: key.enabled ? '#4ade80' : '#f87171',
|
||||
}}>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</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 ? '启用' : '禁用'}
|
||||
</span>
|
||||
<button onClick={() => handleDeleteKey(key.id)} className="btn btn-ghost danger" style={{ fontSize: 12, padding: '2px 8px' }}>删除</button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{keys.length > 3 && (
|
||||
<div style={{ textAlign: 'center', fontSize: '12px', color: 'var(--color-text-muted)' }}>
|
||||
还有 {keys.length - 3} 个密钥...
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { persistUserSession } from '../authSession.js';
|
||||
|
||||
export default function UserLogin() {
|
||||
export default function UserLogin({ onLogin }: { onLogin?: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
@@ -26,6 +26,7 @@ export default function UserLogin() {
|
||||
}
|
||||
const result = await res.json();
|
||||
persistUserSession(localStorage, result.token);
|
||||
onLogin?.();
|
||||
navigate('/user/dashboard');
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '登录失败,请检查邮箱和密码');
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user