feat: rebrand Metapi to BoosAPI, add ComfyUI agent and user management

- Rename project from Metapi to BoosAPI across all UI and server strings
- Add ComfyUI conversational agent page (/comfyui-agent)
- Add user management system (register, login, API keys, admin)
- Update Dockerfile and database schema
- Add ComfyUI workflow nodes support
- Update shared contracts and platform configurations

Signed-off-by: Boos4721 <boos4721@icloud.com>
This commit is contained in:
2026-05-15 20:20:58 +08:00
parent cdb12a9819
commit 9cfe8427b5
91 changed files with 13595 additions and 1358 deletions
+36 -65
View File
@@ -34,12 +34,16 @@ const ProgramLogs = lazy(() => import('./pages/ProgramLogs.js'));
const Models = lazy(() => import('./pages/Models.js'));
const About = lazy(() => import('./pages/About.js'));
const ModelTester = lazy(() => import('./pages/ModelTester.js'));
const ComfyUI = lazy(() => import('./pages/ComfyUI.js'));
const ComfyUIAgent = lazy(() => import('./pages/ComfyUIAgent.js'));
const Monitors = lazy(() => import('./pages/Monitors.js'));
const OAuthManagement = lazy(() => import('./pages/OAuthManagement.js'));
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 UserManagement = lazy(() => import('./pages/UserManagement.js'));
type ThemeMode = 'system' | 'light' | 'dark';
type UserProfile = {
name: string;
avatarSeed: string;
@@ -59,9 +63,7 @@ const DICEBEAR_STYLES = [
'lorelei-neutral',
'fun-emoji',
] as const;
type DicebearStyle = typeof DICEBEAR_STYLES[number];
function resolveStoredThemeMode(): ThemeMode {
const saved = localStorage.getItem(THEME_MODE_STORAGE_KEY);
if (saved === 'system' || saved === 'light' || saved === 'dark') return saved;
@@ -69,14 +71,12 @@ function resolveStoredThemeMode(): ThemeMode {
if (legacy === 'light' || legacy === 'dark') return legacy;
return 'system';
}
function createRandomAvatarSeed(): string {
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
return `seed-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
function hashString(input: string): number {
let hash = 0;
for (let i = 0; i < input.length; i += 1) {
@@ -85,12 +85,10 @@ function hashString(input: string): number {
}
return Math.abs(hash);
}
function pickDicebearStyle(seed: string): DicebearStyle {
const index = hashString(seed || 'default') % DICEBEAR_STYLES.length;
return DICEBEAR_STYLES[index];
}
function buildDicebearAvatarUrl(style: string, seed: string): string {
const safeStyle = DICEBEAR_STYLES.includes(style as DicebearStyle)
? style
@@ -98,7 +96,6 @@ function buildDicebearAvatarUrl(style: string, seed: string): string {
const safeSeed = (seed || 'default').trim() || 'default';
return `https://api.dicebear.com/9.x/${safeStyle}/svg?seed=${encodeURIComponent(safeSeed)}`;
}
function resolveStoredProfile(): UserProfile {
try {
const raw = localStorage.getItem(USER_PROFILE_STORAGE_KEY);
@@ -127,7 +124,6 @@ function resolveStoredProfile(): UserProfile {
return { name: '管理员', avatarSeed, avatarStyle: pickDicebearStyle(avatarSeed) };
}
}
export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (text: string) => string }) {
const [token, setToken] = useState('');
const [loading, setLoading] = useState(false);
@@ -146,7 +142,6 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
description: t('按成本、延迟、成功率自动选择最优通道,故障自动转移'),
},
];
const handleLogin = async () => {
if (!token) return;
setLoading(true);
@@ -180,7 +175,6 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
setLoading(false);
}
};
return (
<div className="login-shell">
<div className="login-surface animate-scale-in">
@@ -188,11 +182,11 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
<div className="login-brand-header">
<div className="brand-mark-frame brand-mark-frame-hero">
<div className="brand-mark-canvas">
<img src="/logo.png" alt="Metapi" className="login-brand-logo" />
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
</div>
</div>
<div className="login-brand-summary">
<div className="login-brand-name">Metapi</div>
<div className="login-brand-name">BoosAPI</div>
<div className="login-brand-kicker">{t('中转站的中转站')}</div>
</div>
</div>
@@ -239,7 +233,6 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
</a>
</div>
</section>
<section className="login-auth-stage">
<div className="login-auth-panel">
<div className="login-auth-eyebrow">{t('管理员入口')}</div>
@@ -280,7 +273,6 @@ export function Login({ onLogin, t }: { onLogin: (token: string) => void; t: (te
</div>
);
}
function UserProfileModal({
open,
profile,
@@ -298,7 +290,6 @@ function UserProfileModal({
const [avatarSeed, setAvatarSeed] = useState(profile.avatarSeed);
const [avatarStyle, setAvatarStyle] = useState(profile.avatarStyle);
const [error, setError] = useState('');
useEffect(() => {
if (!open) return;
setName(profile.name);
@@ -306,9 +297,7 @@ function UserProfileModal({
setAvatarStyle(profile.avatarStyle);
setError('');
}, [open, profile]);
const avatarUrl = buildDicebearAvatarUrl(avatarStyle, avatarSeed);
const inputStyle: React.CSSProperties = {
width: '100%',
padding: '10px 14px',
@@ -319,13 +308,11 @@ function UserProfileModal({
background: 'var(--color-bg)',
color: 'var(--color-text-primary)',
};
const handleRandomAvatar = () => {
const nextSeed = createRandomAvatarSeed();
setAvatarSeed(nextSeed);
setAvatarStyle(pickDicebearStyle(nextSeed));
};
const handleSubmit = () => {
const normalizedName = name.trim();
if (!normalizedName) {
@@ -344,7 +331,6 @@ function UserProfileModal({
: pickDicebearStyle(avatarSeed),
});
};
return (
<CenteredModal
open={open}
@@ -371,7 +357,6 @@ function UserProfileModal({
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>{t('右上角头像实时预览')}</div>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>{t('用户名')}</div>
<input
@@ -384,7 +369,6 @@ function UserProfileModal({
style={inputStyle}
/>
</div>
<div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 6 }}>
{t('头像(Dicebear 随机) · 风格:')}{avatarStyle}
@@ -393,7 +377,6 @@ function UserProfileModal({
{t('换一个随机头像')}
</button>
</div>
{error && (
<div className="alert alert-error">
{error}
@@ -402,7 +385,6 @@ function UserProfileModal({
</CenteredModal>
);
}
export const sidebarGroups = [
{
label: '控制台',
@@ -416,7 +398,8 @@ export const sidebarGroups = [
{ to: '/checkin', 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 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg> },
{ to: '/routes', 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="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" /></svg> },
{ to: '/logs', 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> },
{ to: '/monitor', 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="M3 5a2 2 0 012-2h14a2 2 0 012 2v11a2 2 0 01-2 2h-5l-2.5 3-2.5-3H5a2 2 0 01-2-2V5z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M7 10h3l1.5-2.5L14 13l1.5-3H17" /></svg> },
{ to: '/video-agent', label: 'ComfyUI', 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: '/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> },
],
},
{
@@ -426,22 +409,20 @@ export const sidebarGroups = [
{ to: '/events', 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 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l4.414 4.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg> },
{ to: '/settings/import-export', 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="M7 7h10M7 12h6m-6 5h10M5 3h14a2 2 0 012 2v14a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2z" /></svg> },
{ to: '/settings/notify', 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="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9" /></svg> },
{ to: '/users', 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="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197m13.5-9a2.5 2.5 0 11-5 0 2.5 2.5 0 015 0z" /></svg> },
],
},
];
const topNavItems = [
{ label: '控制台', to: '/' },
{ label: '模型广场', to: '/models' },
{ label: '模型操练场', to: '/playground' },
{ label: 'ComfyUI', to: '/video-agent' },
{ label: '关于', to: '/about' },
];
function PageTransition({ children }: { children: React.ReactNode }) {
const location = useLocation();
return <div key={location.pathname} className="page-enter">{children}</div>;
}
function RouteLoadingFallback() {
return (
<div className="animate-fade-in" style={{ padding: 16 }}>
@@ -450,7 +431,6 @@ function RouteLoadingFallback() {
</div>
);
}
function AppShell() {
const { language, toggleLanguage, t } = useI18n();
const [authed, setAuthed] = useState(() => hasValidAuthSession(localStorage));
@@ -480,21 +460,17 @@ function AppShell() {
const displayName = rawDisplayName ? (rawDisplayName === '管理员' ? t('管理员') : rawDisplayName) : t('管理员');
const resolvedThemeLabel = resolvedTheme === 'dark' ? t('深色') : t('浅色');
const avatarUrl = buildDicebearAvatarUrl(userProfile.avatarStyle, userProfile.avatarSeed);
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const sync = () => setSystemPrefersDark(media.matches);
sync();
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', sync);
return () => media.removeEventListener('change', sync);
}
media.addListener(sync);
return () => media.removeListener(sync);
}, []);
useEffect(() => {
document.documentElement.setAttribute('data-theme', resolvedTheme);
localStorage.setItem(THEME_MODE_STORAGE_KEY, themeMode);
@@ -504,17 +480,14 @@ function AppShell() {
localStorage.setItem('theme', themeMode);
}
}, [resolvedTheme, themeMode]);
useEffect(() => {
document.documentElement.setAttribute('data-layout', isMobile ? 'mobile' : 'desktop');
}, [isMobile]);
useEffect(() => {
if (!isMobile && drawerOpen) {
setDrawerOpen(false);
}
}, [drawerOpen, isMobile]);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
@@ -525,26 +498,21 @@ function AppShell() {
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, []);
useEffect(() => {
if (!authed) return;
let cancelled = false;
const pollEvents = async () => {
try {
const recentEvents = await api.getEvents('limit=30');
if (cancelled) return;
const rows = Array.isArray(recentEvents) ? recentEvents : [];
const unread = rows.filter((r: any) => !r.read).length;
setUnreadCount(unread);
const maxId = rows.reduce((acc: number, row: any) => Math.max(acc, Number(row?.id) || 0), 0);
if (latestTaskEventIdRef.current === 0) {
latestTaskEventIdRef.current = maxId;
return;
}
const newTaskEvents = rows
.filter((row: any) => (
(Number(row?.id) || 0) > latestTaskEventIdRef.current
@@ -553,7 +521,6 @@ function AppShell() {
))
.sort((a: any, b: any) => (a.id || 0) - (b.id || 0))
.slice(-3);
for (const event of newTaskEvents) {
const message = event?.message || event?.title || t('任务状态已更新');
if (event?.level === 'error') {
@@ -564,7 +531,6 @@ function AppShell() {
toast.success(message);
}
}
if (maxId > latestTaskEventIdRef.current) {
latestTaskEventIdRef.current = maxId;
}
@@ -572,7 +538,6 @@ function AppShell() {
// ignore polling errors
}
};
void pollEvents();
const timer = setInterval(() => { void pollEvents(); }, 15000);
return () => {
@@ -580,28 +545,23 @@ function AppShell() {
clearInterval(timer);
};
}, [authed, toast]);
useEffect(() => {
if (!authed) return;
const check = () => {
if (hasValidAuthSession(localStorage)) return;
setAuthed(false);
toast.info(t('会话已过期,请重新登录'));
};
check();
const timer = setInterval(check, 60_000);
return () => clearInterval(timer);
}, [authed, toast]);
useEffect(() => {
if (!authed) return;
if (localStorage.getItem(FIRST_USE_DOC_REMINDER_KEY)) return;
localStorage.setItem(FIRST_USE_DOC_REMINDER_KEY, '1');
toast.info(`${t('首次使用建议先阅读站点文档:')}${SITE_DOCS_URL}`);
}, [authed, t, toast]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (userMenuRef.current && !userMenuRef.current.contains(e.target as Node)) {
@@ -614,12 +574,10 @@ function AppShell() {
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const handleSelectThemeMode = (nextMode: ThemeMode) => {
setThemeMode(nextMode);
setShowThemeMenu(false);
};
const handleSaveProfile = (nextProfile: UserProfile) => {
const normalizedSeed = nextProfile.avatarSeed.trim() || createRandomAvatarSeed();
const normalized = {
@@ -634,14 +592,29 @@ 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;
}
return <Login t={t} onLogin={(token) => {
persistAuthSession(localStorage, token);
setAuthed(true);
}} />;
}
return (
<>
<header className="topbar">
@@ -658,8 +631,8 @@ function AppShell() {
</button>
)}
<div className="topbar-logo">
<img src="/logo.png" alt="Metapi" style={{ width: 28, height: 28, borderRadius: 6 }} />
<span className="topbar-logo-text">Metapi</span>
<img src="/logo.png" alt="BoosAPI" style={{ width: 28, height: 28, borderRadius: 6 }} />
<span className="topbar-logo-text">BoosAPI</span>
</div>
<nav className="topbar-nav">
{topNavItems.map((item) => (
@@ -780,7 +753,6 @@ function AppShell() {
</div>
</div>
</header>
<div className="app-layout">
{isMobile ? (
<MobileDrawer
@@ -790,8 +762,8 @@ function AppShell() {
closeLabel={t('关闭导航')}
>
<div className="mobile-drawer-header">
<img src="/logo.png" alt="Metapi" />
<span>Metapi</span>
<img src="/logo.png" alt="BoosAPI" />
<span>BoosAPI</span>
</div>
<nav className="mobile-nav">
{sidebarGroups.map((group) => (
@@ -854,7 +826,6 @@ function AppShell() {
</button>
</aside>
)}
<main className="main-content">
<PageTransition>
<Suspense fallback={<RouteLoadingFallback />}>
@@ -875,15 +846,16 @@ function AppShell() {
<Route path="/settings/import-export" element={<ImportExport />} />
<Route path="/settings/notify" element={<NotificationSettings />} />
<Route path="/models" element={<Models />} />
<Route path="/playground" element={<ModelTester />} />
<Route path="/video-agent" element={<ComfyUI />} />
<Route path="/comfyui-agent" element={<ComfyUIAgent />} />
<Route path="/about" element={<About />} />
<Route path="/users" element={<UserManagement />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</Suspense>
</PageTransition>
</main>
</div>
<UserProfileModal
open={showProfileModal}
profile={userProfile}
@@ -895,7 +867,6 @@ function AppShell() {
</>
);
}
export default function App() {
return (
<I18nProvider>
+104 -1
View File
@@ -1,4 +1,4 @@
import { clearAuthSession, getAuthToken } from "./authSession.js";
import { clearAuthSession, getAuthToken, getUserToken, clearUserSession } from "./authSession.js";
type BufferLike = {
from(data: ArrayBuffer): { toString(encoding: "base64"): string };
@@ -156,6 +156,82 @@ async function fetchAuthenticatedResponse(
}
}
async function fetchUserResponse(
url: string,
options: RequestOptions = {},
): Promise<Response> {
const {
timeoutMs = 30_000,
signal: externalSignal,
...fetchOptions
} = options;
const controller = new AbortController();
let timeoutHandle: ReturnType<typeof setTimeout> | null = setTimeout(() => {
controller.abort();
}, timeoutMs);
let cleanupExternalSignal = () => {};
if (externalSignal) {
if (externalSignal.aborted) {
controller.abort();
} else {
const abortHandler = () => controller.abort();
externalSignal.addEventListener("abort", abortHandler, { once: true });
cleanupExternalSignal = () =>
externalSignal.removeEventListener("abort", abortHandler);
}
}
const token = getUserToken(localStorage);
if (!token) {
clearUserSession(localStorage);
throw new Error("User session expired");
}
const headers = new Headers(fetchOptions.headers ?? {});
headers.set("Authorization", `Bearer ${token}`);
if (fetchOptions.body && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
try {
const res = await fetch(url, {
...fetchOptions,
signal: controller.signal,
headers,
});
if (res.status === 401 || res.status === 403) {
clearUserSession(localStorage);
throw new Error("User session expired");
}
return res;
} catch (error: any) {
if (error?.name === "AbortError") {
if (externalSignal?.aborted) throw error;
throw new Error(
`请求超时(${Math.max(1, Math.round(timeoutMs / 1000))}s)`,
);
}
throw error;
} finally {
if (timeoutHandle) {
clearTimeout(timeoutHandle);
timeoutHandle = null;
}
cleanupExternalSignal();
}
}
async function userRequest<T = any>(
url: string,
options: RequestOptions = {},
): Promise<T> {
const res = await fetchUserResponse(url, options);
if (!res.ok) {
throw new Error(await extractResponseErrorMessage(res));
}
return res.json() as Promise<T>;
}
async function request<T = any>(
url: string,
options: RequestOptions = {},
@@ -1508,4 +1584,31 @@ export const api = {
body: JSON.stringify(data),
});
},
// Admin user management
getUsers: () => request("/api/admin/users"),
getUserById: (id: number) => request(`/api/admin/users/${id}`),
updateUser: (id: number, data: { role?: string; status?: string; username?: string }) =>
request(`/api/admin/users/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteUser: (id: number) =>
request(`/api/admin/users/${id}`, { method: "DELETE" }),
};
export const userApi = {
register: (data: { username: string; email: string; password: string }) =>
userRequest("/api/users/register", { method: "POST", body: JSON.stringify(data) }),
login: (data: { email: string; password: string }) =>
userRequest("/api/users/login", { method: "POST", body: JSON.stringify(data) }),
me: () => userRequest("/api/users/me"),
updateMe: (data: { username?: string }) =>
userRequest("/api/users/me", { method: "PATCH", body: JSON.stringify(data) }),
changePassword: (data: { oldPassword: string; newPassword: string }) =>
userRequest("/api/users/me/password", { method: "POST", body: JSON.stringify(data) }),
getApiKeys: () => userRequest("/api/user-api-keys"),
createApiKey: (data: any) =>
userRequest("/api/user-api-keys", { method: "POST", body: JSON.stringify(data) }),
updateApiKey: (id: number, data: any) =>
userRequest(`/api/user-api-keys/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
deleteApiKey: (id: number) =>
userRequest(`/api/user-api-keys/${id}`, { method: "DELETE" }),
};
+1 -1
View File
@@ -3,7 +3,7 @@ import { clearAuthSession } from './authSession.js';
export const THEME_MODE_STORAGE_KEY = 'theme_mode';
export const LEGACY_THEME_STORAGE_KEY = 'theme';
export const USER_PROFILE_STORAGE_KEY = 'user_profile';
export const FIRST_USE_DOC_REMINDER_KEY = 'metapi_first_use_docs_reminder_seen_v1';
export const FIRST_USE_DOC_REMINDER_KEY = 'boosapi_first_use_docs_reminder_seen_v1';
type StorageLike = {
getItem?: (key: string) => string | null;
+49
View File
@@ -1,5 +1,7 @@
const AUTH_TOKEN_STORAGE_KEY = 'auth_token';
const AUTH_TOKEN_EXPIRES_AT_STORAGE_KEY = 'auth_token_expires_at';
const USER_JWT_STORAGE_KEY = 'user_jwt_token';
const USER_JWT_EXPIRES_AT_STORAGE_KEY = 'user_jwt_expires_at';
export const AUTH_SESSION_DURATION_MS = 12 * 60 * 60 * 1000;
type StorageLike = {
@@ -67,3 +69,50 @@ export function getAuthToken(storage?: StorageLike | null, nowMs = Date.now()):
export function hasValidAuthSession(storage?: StorageLike | null, nowMs = Date.now()): boolean {
return !!getAuthToken(storage, nowMs);
}
export function clearUserSession(storage?: StorageLike | null): void {
const target = resolveStorage(storage);
if (!target) return;
target.removeItem(USER_JWT_STORAGE_KEY);
target.removeItem(USER_JWT_EXPIRES_AT_STORAGE_KEY);
}
export function persistUserSession(
storage: StorageLike | null | undefined,
token: string,
ttlMs = AUTH_SESSION_DURATION_MS,
nowMs = Date.now(),
): void {
const target = resolveStorage(storage);
if (!target) return;
const cleanToken = (token || '').trim();
if (!cleanToken) {
clearUserSession(target);
return;
}
const expiresAt = nowMs + Math.max(1, Math.trunc(ttlMs));
target.setItem(USER_JWT_STORAGE_KEY, cleanToken);
target.setItem(USER_JWT_EXPIRES_AT_STORAGE_KEY, String(expiresAt));
}
export function getUserToken(storage?: StorageLike | null, nowMs = Date.now()): string | null {
const target = resolveStorage(storage);
if (!target) return null;
const token = (target.getItem(USER_JWT_STORAGE_KEY) || '').trim();
if (!token) return null;
const expiresAtRaw = target.getItem(USER_JWT_EXPIRES_AT_STORAGE_KEY);
if (!expiresAtRaw) {
persistUserSession(target, token, AUTH_SESSION_DURATION_MS, nowMs);
return token;
}
const expiresAt = Number(expiresAtRaw);
if (!Number.isFinite(expiresAt) || expiresAt <= nowMs) {
clearUserSession(target);
return null;
}
return token;
}
export function hasValidUserSession(storage?: StorageLike | null, nowMs = Date.now()): boolean {
return !!getUserToken(storage, nowMs);
}
+1 -1
View File
@@ -1,2 +1,2 @@
export const SITE_DOCS_URL = 'https://metapi.cita777.me';
export const SITE_DOCS_URL = 'https://boosapi.cita777.me';
export const SITE_GITHUB_URL = 'https://github.com/cita-777';
+3 -3
View File
@@ -191,7 +191,7 @@ export const zhToEnSupplemental: Record<string, string> = {
'共 ${models.length} 个模型': 'Total ${models.length} models',
'关闭': 'closure',
'关于': 'about',
'关于 Metapi': 'About metapi',
'关于 BoosAPI': 'About BoosAPI',
'管理端 IP 白名单': 'Management IP whitelist',
'管理员': 'administrator',
'管理员登录 Token 已被修改,请使用新 Token 登录。': 'The administrator login token has been modified, please use the new token to log in.',
@@ -259,7 +259,7 @@ export const zhToEnSupplemental: Record<string, string> = {
'拉取分组失败': 'Failed to pull group',
'拉取分组失败,已回退 default': 'Failed to pull the group and has fallen back to default',
'冷却中': 'Cooling down',
'例如 metapi': 'For example metapi',
'例如 BoosAPI': 'For example metapi',
'例如: admin@example.com': 'For example: admin@example.com',
'例如: smtp.qq.com': 'For example: smtp.qq.com',
'例如: target@example.com': 'For example: target@example.com',
@@ -770,7 +770,7 @@ export const zhToEnSupplemental: Record<string, string> = {
'LDOH Cookie 已更新': 'LDOH Cookie updated',
'LDOH Cookie 已清空': 'LDOH Cookie Cleared',
'LinuxDo 可用性监控': 'LinuxDo availability monitoring',
'metapi 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'metapi is completely self-hosted, all data (accounts, tokens, routes, logs) is stored in a local SQLite database and no data is sent to any third party. Proxy requests only travel directly between your server and the upstream site.',
'BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'boosapi is completely self-hosted, all data (accounts, tokens, routes, logs) is stored in a local SQLite database and no data is sent to any third party. Proxy requests only travel directly between your server and the upstream site.',
'model 不能为空': 'model cannot be empty',
'modelLimitsEnabled 参数无效': 'modelLimitsEnabled parameter is invalid',
'models 必须是非空数组': 'models must be a non-empty array',
+63 -3
View File
@@ -71,7 +71,7 @@ const zhToEn: Record<string, string> = {
'模型操练场': 'Model Playground',
'模型测试': 'Model Testing',
'关于': 'About',
'关于 Metapi': 'About Metapi',
'关于 BoosAPI': 'About BoosAPI',
'站点文档': 'Site Docs',
'任务状态已更新': 'Task status updated',
'会话已过期,请重新登录': 'Session expired, please sign in again',
@@ -208,7 +208,7 @@ const zhToEn: Record<string, string> = {
'代理端点': 'Proxy Endpoints',
'路由行为': 'Routing Behavior',
'指标口径': 'Metric Notes',
'metapi 将多个上游兼容供应商聚合为统一的 OpenAI / Claude 下游兼容入口。': 'Metapi aggregates multiple upstream compatible providers into a unified OpenAI / Claude compatible downstream endpoint.',
'BoosAPI 将多个上游兼容供应商聚合为统一的 OpenAI / Claude 下游兼容入口。': 'BoosAPI aggregates multiple upstream compatible providers into a unified OpenAI / Claude compatible downstream endpoint.',
'核心目标:自动签到、自动模型发现、自动路由重建、统一代理可观测性。': 'Core goals: auto check-in, auto model discovery, auto route rebuild, and unified proxy observability.',
'1. 路由根据模型可用性自动生成。': '1. Routes are auto-generated based on model availability.',
'2. 当模型或账号发生变更时,路由通道会自动重建。': '2. Route channels are auto-rebuilt when models or accounts change.',
@@ -309,7 +309,67 @@ const zhToEn: Record<string, string> = {
'零配置嵌入式数据库': 'Zero-config embedded database',
'项目链接': 'Project Links',
'数据与隐私': 'Data & Privacy',
'Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'Metapi is fully self-hosted. All data (accounts, tokens, routes, logs) is stored in a local SQLite database and never sent to any third party. Proxy requests travel directly between your server and upstream sites.',
'BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。': 'BoosAPI is fully self-hosted. All data (accounts, tokens, routes, logs) is stored in a local SQLite database and never sent to any third party. Proxy requests travel directly between your server and upstream sites.',
// User SaaS translations
'用户登录': 'User Login',
'用户注册': 'User Register',
'欢迎回来': 'Welcome Back',
'使用邮箱和密码登录你的账号。': 'Sign in with your email and password.',
'还没有账号?': "Don't have an account?",
'已有账号?': 'Already have an account?',
'管理员登录': 'Admin Sign In',
'创建账号': 'Create Account',
'注册后即可使用 API 代理服务。': 'Register to use the API proxy service.',
'注册': 'Register',
'密码至少 6 个字符': 'Password must be at least 6 characters',
'两次密码不一致': 'Passwords do not match',
'登录中...': 'Signing in...',
'注册中...': 'Registering...',
'注册失败': 'Registration failed',
'登录失败,请检查邮箱和密码': 'Login failed. Please check your email and password.',
'确认密码': 'Confirm Password',
'至少 6 位': 'At least 6 characters',
'再次输入密码': 'Re-enter password',
'你的昵称': 'Your nickname',
'用户中心': 'User Portal',
'邮箱': 'Email',
'密码': 'Password',
'个人信息': 'Profile',
'修改密码': 'Change Password',
'API 密钥': 'API Keys',
'新建密钥': 'New Key',
'当前密码': 'Current Password',
'新密码': 'New Password',
'确认新密码': 'Confirm New Password',
'修改密码': 'Change Password',
'修改中...': 'Changing...',
'密码已更新': 'Password updated',
'密钥名称': 'Key Name',
'暂无 API 密钥': 'No API Keys',
'用户名': 'Username',
'角色': 'Role',
'状态': 'Status',
'注册时间': 'Registered',
'正常': 'Active',
'已禁用': 'Disabled',
'管理员': 'Admin',
'普通用户': 'User',
'启用': 'Enabled',
'禁用': 'Disabled',
'创建': 'Create',
'创建中...': 'Creating...',
'保存': 'Save',
'编辑': 'Edit',
'删除': 'Delete',
'取消': 'Cancel',
'密钥已创建,请立即复制(不会再次显示):': 'Key created. Copy now (won\'t show again):',
'关闭': 'Close',
'退出': 'Sign Out',
'用户管理': 'User Management',
'共': 'Total',
'个用户': ' users',
'总用户': 'Total Users',
};
for (const [source, target] of Object.entries(zhToEnSupplemental)) {
+2 -2
View File
@@ -3,8 +3,8 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Metapi - 中转站的中转站,聚合多个 AI API 中转站为统一网关" />
<title>Metapi</title>
<meta name="description" content="BoosAPI - 中转站的中转站,聚合多个 AI API 中转站为统一网关" />
<title>BoosAPI</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
+6 -6
View File
@@ -29,8 +29,8 @@ const TECH_STACK = [
];
const LINKS = [
{ label: 'GitHub', href: 'https://github.com/cita-777/metapi', icon: '📂' },
{ label: 'Docker Hub', href: 'https://hub.docker.com/r/1467078763/metapi', icon: '🐳' },
{ label: 'GitHub', href: 'https://github.com/cita-777/boosapi', icon: '📂' },
{ label: 'Docker Hub', href: 'https://hub.docker.com/r/1467078763/boosapi', icon: '🐳' },
{ label: '站点文档', href: SITE_DOCS_URL, icon: '📚' },
];
@@ -82,7 +82,7 @@ export default function About() {
<div className="animate-fade-in" style={{ maxWidth: 860 }}>
{/* Header */}
<div className="page-header" style={{ marginBottom: 14 }}>
<h2 className="page-title">{tr('关于 Metapi')}</h2>
<h2 className="page-title">{tr('关于 BoosAPI')}</h2>
</div>
{/* Hero card */}
@@ -90,11 +90,11 @@ export default function About() {
<div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 14 }}>
<img
src="/logo.png"
alt="Metapi"
alt="BoosAPI"
style={{ width: 48, height: 48, borderRadius: 12, flexShrink: 0 }}
/>
<div>
<div style={{ fontSize: 18, fontWeight: 700 }}>Metapi</div>
<div style={{ fontSize: 18, fontWeight: 700 }}>BoosAPI</div>
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>{currentVersion}</div>
</div>
</div>
@@ -206,7 +206,7 @@ export default function About() {
<div className="card animate-slide-up stagger-5" style={{ padding: 22 }}>
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 10 }}>{tr('数据与隐私')}</h3>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
{tr('Metapi 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。')}
{tr('BoosAPI 完全自托管,所有数据(账号、令牌、路由、日志)均存储在本地 SQLite 数据库中,不会向任何第三方发送数据。代理请求仅在你的服务器与上游站点之间直连传输。')}
</div>
</div>
</div>
+1 -1
View File
@@ -1821,7 +1821,7 @@ export default function Accounts() {
color: "var(--color-text-muted)",
}}
>
配置 refresh_token 后,metapi 会在 JWT 临近过期或
配置 refresh_token 后,BoosAPI 会在 JWT 临近过期或
401 时自动续期并回写新 token。
</div>
</div>
+37
View File
@@ -0,0 +1,37 @@
import React from 'react';
export default function ComfyUI() {
const [isDark, setIsDark] = React.useState(true);
React.useEffect(() => {
const el = document.documentElement;
const check = () => setIsDark(el.getAttribute('data-theme') !== 'light');
check();
const mo = new MutationObserver(check);
mo.observe(el, { attributes: true, attributeFilter: ['data-theme'] });
return () => mo.disconnect();
}, []);
return (
<div
style={{
width: '100%',
height: 'calc(100vh - 56px)',
display: 'flex',
flexDirection: 'column',
background: isDark ? '#1a1a2e' : '#f5f5f5',
}}
>
<iframe
src="/comfyui"
style={{
width: '100%',
height: '100%',
border: 'none',
}}
title="ComfyUI"
allow="clipboard-read; clipboard-write"
/>
</div>
);
}
+425
View File
@@ -0,0 +1,425 @@
import { useState, useRef, useEffect } from 'react';
interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
images?: Array<{ url: string; character?: string; angle?: string }>;
scenes?: Array<{ id: number; description: string; prompt: string }>;
tts?: Array<{ character: string; text: string; url: string }>;
workflowJson?: any;
}
export default function ComfyUIAgent() {
const [messages, setMessages] = useState<Message[]>([
{
id: 'welcome',
role: 'system',
content: '🎬 你好!我是 AI 视频生成助手。请告诉我你的视频脚本或创意,我可以帮你:\n\n1️⃣ 分析脚本结构(角色、场景、对话)\n2️⃣ 生成角色 4 角度图像(正面/背面/左侧/右侧)\n3️⃣ 生成场景背景图\n4️⃣ 生成角色语音(TTS)\n5️⃣ 导出 ComfyUI 工作流\n\n直接发脚本给我,我们开始吧!',
},
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [streamingContent, setStreamingContent] = useState('');
const chatEndRef = useRef<HTMLDivElement>(null);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages, streamingContent]);
const sendMessage = async () => {
if (!input.trim() || loading) return;
const userMsg: Message = {
id: `user-${Date.now()}`,
role: 'user',
content: input.trim(),
};
setMessages((prev) => [...prev, userMsg]);
setInput('');
setLoading(true);
setStreamingContent('');
const abortController = new AbortController();
abortRef.current = abortController;
try {
const apiMessages = [...messages, userMsg].map((m) => ({
role: m.role === 'system' ? 'system' : m.role,
content: m.content,
}));
const resp = await fetch('/api/comfyui-agent/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages: apiMessages }),
signal: abortController.signal,
});
if (!resp.ok) {
setStreamingContent('请求失败,请重试');
return;
}
const reader = resp.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buffer = '';
let textBuffer = '';
const images: Message['images'] = [];
const scenes: Message['scenes'] = [];
const ttsList: Message['tts'] = [];
let msgId = `agent-${Date.now()}`;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const parts = buffer.split('\n');
buffer = parts.pop() || '';
for (const part of parts) {
if (!part.startsWith('data: ')) continue;
const data = part.slice(6).trim();
if (!data) continue;
try {
const event = JSON.parse(data);
switch (event.type) {
case 'text':
textBuffer += event.content || '';
setStreamingContent(textBuffer);
break;
case 'character_prompts':
textBuffer += `\n\n📐 **${event.character}** 角色提示词已生成`;
setStreamingContent(textBuffer);
break;
case 'image':
images.push({ url: event.url, character: event.character, angle: event.angle });
textBuffer += `\n🖼️ 生成 ${event.character} [${event.angle}]`;
setStreamingContent(textBuffer);
break;
case 'scene':
scenes.push({ id: event.id, description: event.description, prompt: event.prompt });
textBuffer += `\n🌄 场景 ${event.id}: ${event.description}`;
setStreamingContent(textBuffer);
break;
case 'tts':
ttsList.push({ character: event.character, text: event.text, url: event.url });
textBuffer += `\n🔊 ${event.character}: "${event.text.slice(0, 30)}..."`;
setStreamingContent(textBuffer);
break;
case 'comfyui_workflow':
textBuffer += '\n\n📦 ComfyUI 工作流已就绪,点击上方「导出」按钮下载';
setStreamingContent(textBuffer);
break;
case 'done':
break;
}
} catch {
// skip malformed JSON
}
}
}
const finalContent = textBuffer || (await resp.text()).slice(0, 500);
setMessages((prev) => [
...prev,
{
id: msgId,
role: 'assistant',
content: finalContent,
images: images.length > 0 ? images : undefined,
scenes: scenes.length > 0 ? scenes : undefined,
tts: ttsList.length > 0 ? ttsList : undefined,
},
]);
setStreamingContent('');
} catch (e: any) {
if (e.name !== 'AbortError') {
setStreamingContent(`错误: ${e.message}`);
}
} finally {
setLoading(false);
abortRef.current = null;
}
};
const stopGeneration = () => {
abortRef.current?.abort();
setLoading(false);
if (streamingContent) {
setMessages((prev) => [
...prev,
{ id: `agent-${Date.now()}`, role: 'assistant', content: streamingContent },
]);
setStreamingContent('');
}
};
const exportWorkflow = () => {
// Simple ComfyUI workflow JSON for video generation
const workflow = {
name: 'AI Video Project',
version: 1,
nodes: messages
.filter((m) => m.images && m.images.length > 0)
.flatMap((m) =>
(m.images || []).map((img, i) => ({
id: `img-${i}`,
type: 'LoadImage',
url: img.url,
character: img.character,
angle: img.angle,
}))
),
};
const blob = new Blob([JSON.stringify(workflow, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'comfyui-workflow.json';
a.click();
URL.revokeObjectURL(url);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
return (
<div
style={{
width: '100%',
height: 'calc(100vh - 56px)',
display: 'flex',
flexDirection: 'column',
background: 'var(--color-bg, #0f0f1a)',
}}
>
{/* Header */}
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '12px 20px',
borderBottom: '1px solid var(--color-border, #2a2a3e)',
background: 'var(--color-surface, #1a1a2e)',
}}
>
<h1 style={{ fontSize: '16px', fontWeight: 600, margin: 0, color: 'var(--color-text, #e0e0f0)' }}>
🎬 AI 视频助手(对话)
</h1>
<button
onClick={exportWorkflow}
style={{
padding: '6px 16px',
borderRadius: '6px',
border: '1px solid var(--color-border, #2a2a3e)',
background: 'var(--color-surface-raised, #252540)',
color: 'var(--color-text, #e0e0f0)',
fontSize: '13px',
cursor: 'pointer',
}}
>
📦 导出 ComfyUI
</button>
</div>
{/* Messages */}
<div
style={{
flex: 1,
overflowY: 'auto',
padding: '16px 20px',
display: 'flex',
flexDirection: 'column',
gap: '12px',
}}
>
{messages.map((msg) => (
<div
key={msg.id}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: msg.role === 'user' ? 'flex-end' : 'flex-start',
}}
>
<div
style={{
maxWidth: '85%',
padding: '10px 14px',
borderRadius: '12px',
background:
msg.role === 'user'
? 'var(--color-primary, #6366f1)'
: msg.role === 'system'
? 'transparent'
: 'var(--color-surface-raised, #252540)',
color:
msg.role === 'user'
? '#fff'
: 'var(--color-text, #e0e0f0)',
fontSize: '14px',
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{msg.content}
</div>
{/* Images */}
{msg.images && msg.images.length > 0 && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '8px',
marginTop: '8px',
}}
>
{msg.images.map((img, i) => (
<div key={i} style={{ textAlign: 'center' }}>
<img
src={img.url}
alt={`${img.character || ''} ${img.angle || ''}`}
style={{
width: '150px',
height: '150px',
objectFit: 'cover',
borderRadius: '8px',
border: '1px solid var(--color-border, #2a2a3e)',
}}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
<div
style={{
fontSize: '11px',
color: 'var(--color-text-secondary, #888)',
marginTop: '2px',
}}
>
{img.character} {img.angle === 'front' ? '正面' : img.angle === 'back' ? '背面' : img.angle === 'left' ? '左侧' : img.angle === 'right' ? '右侧' : img.angle}
</div>
</div>
))}
</div>
)}
</div>
))}
{/* Streaming message */}
{streamingContent && (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<div
style={{
maxWidth: '85%',
padding: '10px 14px',
borderRadius: '12px',
background: 'var(--color-surface-raised, #252540)',
color: 'var(--color-text, #e0e0f0)',
fontSize: '14px',
lineHeight: 1.6,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
>
{streamingContent}
<span style={{ animation: 'blink 1s step-end infinite', marginLeft: 2 }}>▌</span>
</div>
</div>
)}
<div ref={chatEndRef} />
</div>
{/* Input */}
<div
style={{
padding: '12px 20px',
borderTop: '1px solid var(--color-border, #2a2a3e)',
background: 'var(--color-surface, #1a1a2e)',
}}
>
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入脚本内容或创作需求..."
rows={2}
disabled={loading}
style={{
flex: 1,
padding: '10px 14px',
borderRadius: '8px',
border: '1px solid var(--color-border, #2a2a3e)',
background: 'var(--color-bg, #0f0f1a)',
color: 'var(--color-text, #e0e0f0)',
fontSize: '14px',
resize: 'none',
outline: 'none',
}}
/>
{loading ? (
<button
onClick={stopGeneration}
style={{
padding: '10px 20px',
borderRadius: '8px',
border: 'none',
background: '#ef4444',
color: '#fff',
fontSize: '14px',
cursor: 'pointer',
whiteSpace: 'nowrap',
}}
>
■ 停止
</button>
) : (
<button
onClick={sendMessage}
disabled={!input.trim()}
style={{
padding: '10px 20px',
borderRadius: '8px',
border: 'none',
background: !input.trim() ? '#555' : '#6366f1',
color: '#fff',
fontSize: '14px',
cursor: !input.trim() ? 'not-allowed' : 'pointer',
whiteSpace: 'nowrap',
}}
>
发送
</button>
)}
</div>
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
+6 -6
View File
@@ -165,7 +165,7 @@ function parseImportSummary(raw: string): ParsedSummary | null {
const legacyPrefs = Boolean(data.data?.preferences);
const profilesCount = Array.isArray(data.apiCredentialProfiles?.profiles) ? data.apiCredentialProfiles.profiles.length : 0;
const bookmarksCount = Array.isArray(accountsSection?.bookmarks) ? accountsSection.bookmarks.length : 0;
const isNativeMetapiBackup = Boolean(
const isNativeBoosapiBackup = Boolean(
accountsSection
&& Array.isArray(accountsSection.sites)
&& Array.isArray(accountsSection.accountTokens)
@@ -183,7 +183,7 @@ function parseImportSummary(raw: string): ParsedSummary | null {
));
const isAllApiHubV2 = Boolean(
accountsSection
&& !isNativeMetapiBackup
&& !isNativeBoosapiBackup
&& hasLegacyAccountRows
&& Array.isArray(accountsSection.accounts)
&& (
@@ -363,9 +363,9 @@ export default function ImportExport() {
const data = await api.exportBackup(type);
const date = new Date().toISOString().split('T')[0];
const fileName: Record<BackupType, string> = {
all: `metapi-backup-${date}.json`,
accounts: `metapi-accounts-${date}.json`,
preferences: `metapi-preferences-${date}.json`,
all: `boosapi-backup-${date}.json`,
accounts: `boosapi-accounts-${date}.json`,
preferences: `boosapi-preferences-${date}.json`,
};
downloadJsonFile(data, fileName[type]);
toast.success('导出成功');
@@ -730,7 +730,7 @@ export default function ImportExport() {
<input
value={webdavConfig.fileUrl}
onChange={(e) => setWebdavConfig((prev) => ({ ...prev, fileUrl: e.target.value }))}
placeholder="https://dav.example.com/backups/metapi.json"
placeholder="https://dav.example.com/backups/boosapi.json"
style={settingsInputStyle}
/>
</div>
+1 -1
View File
@@ -108,7 +108,7 @@ export default function Monitors() {
<div>
<h2 className="page-title">{tr('监控内嵌')}</h2>
<div style={{ marginTop: 6, fontSize: 13, color: 'var(--color-text-muted)' }}>
在 metapi 内查看外部站点监控页面。
在 BoosAPI 内查看外部站点监控页面。
</div>
</div>
<div style={{ display: 'flex', gap: 8 }}>
+3 -3
View File
@@ -2313,16 +2313,16 @@ export default function OAuthManagement() {
{renderGuideCard(
'本地部署',
'metapi 和浏览器在同一台机器时,不需要 SSH 隧道。直接点击“连接”,在弹窗里完成授权即可。',
'BoosAPI 和浏览器在同一台机器时,不需要 SSH 隧道。直接点击“连接”,在弹窗里完成授权即可。',
<div className="oauth-guide-copy">
如果浏览器能直接访问上面的 localhost 回调地址,授权完成后会自动回到 metapi。
如果浏览器能直接访问上面的 localhost 回调地址,授权完成后会自动回到 BoosAPI。
</div>,
)}
{activeSession.instructions.sshTunnelCommand
? renderGuideCard(
'云端部署',
'metapi 部署在 VPS、容器或远程主机时,浏览器访问到的是你自己电脑的 localhost。先在本地开 SSH 隧道,再继续登录。',
'BoosAPI 部署在 VPS、容器或远程主机时,浏览器访问到的是你自己电脑的 localhost。先在本地开 SSH 隧道,再继续登录。',
<div className="oauth-guide-block-list">
<div className="oauth-guide-block-label">常规 SSH 隧道</div>
{renderCodeBlock(activeSession.instructions.sshTunnelCommand)}
+3 -3
View File
@@ -74,7 +74,7 @@ type ProxyDebugTraceDetailState = {
type ProxyDebugTraceAttempt = ProxyDebugTraceDetail["attempts"][number];
type StoredDebugPreviewPayload = {
__metapiTruncated?: boolean;
__boosapiTruncated?: boolean;
preview?: string;
originalBytes?: number;
storedBytes?: number;
@@ -85,7 +85,7 @@ const DEFAULT_PAGE_SIZE = 50;
const TRACE_TABLE_LIMIT = 20;
const DEBUG_TRACE_PAGE_SIZE = 5;
const PROXY_LOGS_DEBUG_TRACE_PANEL_STORAGE_KEY =
"metapi.proxyLogs.debugTracePanelExpanded";
"boosapi.proxyLogs.debugTracePanelExpanded";
const PROXY_LOG_CLIENT_FAMILY_LABELS: Record<string, string> = {
codex: "Codex",
claude_code: "Claude Code",
@@ -678,7 +678,7 @@ function parseStoredDebugPreview(value: unknown): {
if (
parsed &&
typeof parsed === "object" &&
parsed.__metapiTruncated &&
parsed.__boosapiTruncated &&
typeof parsed.preview === "string"
) {
const originalBytes = Number(parsed.originalBytes || 0);
+5 -5
View File
@@ -1781,7 +1781,7 @@ export default function Settings() {
<div style={settingsModernTitleBlockStyle}>
<div style={settingsModernTitleStyle}>Codex 上游传输与会话并发</div>
<div style={settingsModernDescriptionStyle}>
默认采用 HTTP 优先。只有这里开启后,metapi 才会在 Codex 请求上尝试把上游升级为 WebSocket。下游 Codex 客户端也必须同时启用 `/v1/responses` websocket,单开这里不会生效。
默认采用 HTTP 优先。只有这里开启后,BoosAPI 才会在 Codex 请求上尝试把上游升级为 WebSocket。下游 Codex 客户端也必须同时启用 `/v1/responses` websocket,单开这里不会生效。
</div>
</div>
<div style={settingsModernPillRowStyle}>
@@ -1795,7 +1795,7 @@ export default function Settings() {
</div>
<label style={settingsModernToggleStyle}>
<div style={settingsModernToggleCopyStyle}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 metapi 到 Codex 上游使用 WebSocket</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 BoosAPI 到 Codex 上游使用 WebSocket</span>
<span style={{ fontSize: 12, lineHeight: 1.7, color: 'var(--color-text-muted)' }}>
仅在下游 Codex 客户端已同步开启 `/v1/responses` websocket 时启用;否则仍按 HTTP 优先执行。
</span>
@@ -1878,7 +1878,7 @@ export default function Settings() {
<div style={settingsModernTitleBlockStyle}>
<div style={{ ...settingsModernTitleStyle, color: 'var(--color-danger)' }}>批量测活</div>
<div style={settingsModernDescriptionStyle}>
默认关闭。开启后,metapi 会在后台定时对活跃账号模型发送最小化探测请求,用来校正“/models 能看到但实际不可用”的假阳性。
默认关闭。开启后,BoosAPI 会在后台定时对活跃账号模型发送最小化探测请求,用来校正“/models 能看到但实际不可用”的假阳性。
</div>
</div>
<div style={settingsModernPillRowStyle}>
@@ -1904,7 +1904,7 @@ export default function Settings() {
</div>
<label style={settingsModernToggleStyle}>
<div style={settingsModernToggleCopyStyle}>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 metapi 后台主动批量测活</span>
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-secondary)' }}>允许 BoosAPI 后台主动批量测活</span>
<span style={{ fontSize: 12, lineHeight: 1.7, color: 'var(--color-text-muted)' }}>
首次从关闭切换到开启时,需要手动输入确认语句;关闭时可直接保存。
</span>
@@ -2581,7 +2581,7 @@ export default function Settings() {
<div className="card animate-slide-up stagger-7" style={{ padding: 20, border: '1px solid color-mix(in srgb, var(--color-danger) 30%, var(--color-border))' }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 10, color: 'var(--color-danger)' }}>危险操作</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 12 }}>
重新初始化系统会清空当前 metapi 使用中的全部数据库内容;若当前运行在外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据,然后切回默认 SQLite。
重新初始化系统会清空当前 BoosAPI 使用中的全部数据库内容;若当前运行在外部 MySQL/Postgres,也会先清空该外部库中的 BoosAPI 数据,然后切回默认 SQLite。
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.8, marginBottom: 14 }}>
完成后管理员 Token 会重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{FACTORY_RESET_ADMIN_TOKEN}</code>,当前会话会立即退出并刷新页面。
+1 -1
View File
@@ -1,6 +1,6 @@
/**
* @Author: 橘子
* @Project_description: Metapi 站点管理页
* @Project_description: BoosAPI 站点管理页
* @Description: 代码是我抄的,不会也是真的
*/
import { useEffect, useMemo, useRef, useState } from 'react';
+1 -1
View File
@@ -1041,7 +1041,7 @@ export default function TokenRoutes() {
};
const handleDeleteChannel = async (channelId: number, routeId: number) => {
const dismissedKey = 'metapi:channel-delete-warning-dismissed';
const dismissedKey = 'boosapi:channel-delete-warning-dismissed';
const dismissed = localStorage.getItem(dismissedKey) === 'true';
if (!dismissed) {
const dontAskAgain = { checked: false };
+1 -1
View File
@@ -1099,7 +1099,7 @@ export function TokensPanel({ embedded = false, onEmbeddedActionsChange }: Token
<input
value={form.name}
onChange={(e) => setForm((prev) => ({ ...prev, name: e.target.value }))}
placeholder="例如 metapi"
placeholder="例如 BoosAPI"
style={inputStyle}
/>
</div>
+368
View File
@@ -0,0 +1,368 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { userApi } from '../api.js';
import { clearUserSession, getUserToken } from '../authSession.js';
interface UserProfile {
id: number;
username: string;
email: string;
role: string;
status: string;
createdAt: string;
}
interface ApiKey {
id: number;
name: string;
key?: string;
description?: string;
enabled: boolean;
createdAt?: string;
}
export default function UserDashboard() {
const navigate = useNavigate();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [keys, setKeys] = useState<ApiKey[]>([]);
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');
return false;
}
return true;
}, [navigate]);
const loadData = useCallback(async () => {
if (!checkAuth()) return;
try {
const [profileRes, keysRes] = await Promise.all([
userApi.me(),
userApi.getApiKeys(),
]);
setProfile(profileRes.user);
setKeys(Array.isArray(keysRes) ? keysRes : (keysRes.keys || []));
} catch (err: any) {
setError(err?.message || '加载失败');
clearUserSession(localStorage);
navigate('/user/login');
} finally {
setLoading(false);
}
}, [checkAuth, navigate]);
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 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>
);
}
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 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>
</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>
)}
{/* 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>
<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>
{/* 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 />
</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}`}
</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',
}}>
{key.enabled ? '启用' : '禁用'}
</span>
<button onClick={() => handleDeleteKey(key.id)} className="btn btn-ghost danger" style={{ fontSize: 12, padding: '2px 8px' }}>删除</button>
</div>
</div>
))}
</div>
)}
</section>
</div>
</div>
);
}
+107
View File
@@ -0,0 +1,107 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { persistUserSession } from '../authSession.js';
export default function UserLogin() {
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!email.trim() || !password) return;
setLoading(true);
setError('');
try {
const res = await fetch('/api/users/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim(), password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || `登录失败 (${res.status})`);
}
const result = await res.json();
persistUserSession(localStorage, result.token);
navigate('/user/dashboard');
} catch (err: any) {
setError(err?.message || '登录失败,请检查邮箱和密码');
} finally {
setLoading(false);
}
};
return (
<div className="login-shell">
<div className="login-surface animate-scale-in" style={{ maxWidth: 420 }}>
<section className="login-brand-panel login-brand-panel-light" style={{ padding: '28px 24px' }}>
<div className="login-brand-header" style={{ justifyContent: 'center' }}>
<div className="brand-mark-frame brand-mark-frame-hero">
<div className="brand-mark-canvas">
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
</div>
</div>
<div className="login-brand-summary" style={{ textAlign: 'center' }}>
<div className="login-brand-name">BoosAPI</div>
<div className="login-brand-kicker">用户登录</div>
</div>
</div>
</section>
<section className="login-auth-stage">
<div className="login-auth-panel">
<h2 className="login-auth-title">欢迎回来</h2>
<p className="login-auth-copy">使用邮箱和密码登录你的账号。</p>
<form onSubmit={handleSubmit}>
<label className="login-auth-label" htmlFor="user-email-input">邮箱</label>
<input
id="user-email-input"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="email"
required
/>
<label className="login-auth-label" htmlFor="user-password-input" style={{ marginTop: 12 }}>密码</label>
<input
id="user-password-input"
type="password"
placeholder="••••••••"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="current-password"
required
/>
{error && (
<div className="alert alert-error animate-shake" style={{ marginBottom: 12, marginTop: 8 }}>
{error}
</div>
)}
<button
type="submit"
disabled={loading || !email.trim() || !password}
className="btn btn-primary login-auth-submit"
style={{ marginTop: 4 }}
>
{loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 登录中...</> : '登录'}
</button>
</form>
<div className="login-auth-footer" style={{ flexDirection: 'column', gap: 8, marginTop: 16 }}>
<span>
还没有账号?<Link to="/user/register" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>注册</Link>
</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
<Link to="/" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
</span>
</div>
</div>
</section>
</div>
</div>
);
}
+204
View File
@@ -0,0 +1,204 @@
import React, { useState, useEffect, useCallback } from 'react';
import { api } from '../api.js';
interface User {
id: number;
username: string;
email: string;
role: string;
status: string;
createdAt: string;
}
export default function UserManagement() {
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [editingUser, setEditingUser] = useState<number | null>(null);
const [editRole, setEditRole] = useState('');
const [editStatus, setEditStatus] = useState('');
const loadUsers = useCallback(async () => {
try {
const result = await api.getUsers();
setUsers(Array.isArray(result.users) ? result.users : []);
} catch (err: any) {
setError(err?.message || '加载用户列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { loadUsers(); }, [loadUsers]);
const handleUpdateUser = async (id: number) => {
try {
await api.updateUser(id, { role: editRole, status: editStatus });
setEditingUser(null);
await loadUsers();
} catch (err: any) {
setError(err?.message || '更新失败');
}
};
const handleDisableUser = async (id: number) => {
try {
await api.deleteUser(id);
await loadUsers();
} catch (err: any) {
setError(err?.message || '操作失败');
}
};
if (loading) {
return (
<div className="animate-fade-in" style={{ padding: 16 }}>
<div className="skeleton" style={{ width: 220, height: 24, marginBottom: 16 }} />
<div className="skeleton" style={{ width: '100%', height: 120, borderRadius: 12 }} />
</div>
);
}
return (
<div className="animate-fade-in">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h1 style={{ fontSize: 18, fontWeight: 600, margin: 0 }}>用户管理</h1>
<span style={{ fontSize: 13, color: 'var(--color-text-muted)' }}>
共 {users.length} 个用户
</span>
</div>
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
{/* Stats cards */}
<div style={{ display: 'flex', gap: 12, marginBottom: 16 }}>
{[
{ label: '总用户', value: users.length, color: '#818cf8' },
{ label: '管理员', value: users.filter((u) => u.role === 'admin').length, color: '#4ade80' },
{ label: '已禁用', value: users.filter((u) => u.status === 'disabled').length, color: '#f87171' },
].map((stat) => (
<div key={stat.label} style={{
flex: 1, padding: '14px 16px', borderRadius: 10,
background: 'var(--color-surface, #1a1a2e)',
border: '1px solid var(--color-border, #2a2a3e)',
}}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 4 }}>{stat.label}</div>
<div style={{ fontSize: 22, fontWeight: 700, color: stat.color }}>{stat.value}</div>
</div>
))}
</div>
{/* Users table */}
<div style={{
background: 'var(--color-surface, #1a1a2e)',
borderRadius: 12, border: '1px solid var(--color-border, #2a2a3e)',
overflow: 'hidden',
}}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ borderBottom: '1px solid var(--color-border, #2a2a3e)' }}>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>ID</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>用户名</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>邮箱</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>角色</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>状态</th>
<th style={{ padding: '12px 16px', textAlign: 'left', fontWeight: 600, color: 'var(--color-text-muted)' }}>注册时间</th>
<th style={{ padding: '12px 16px', textAlign: 'right', fontWeight: 600, color: 'var(--color-text-muted)' }}>操作</th>
</tr>
</thead>
<tbody>
{users.length === 0 ? (
<tr>
<td colSpan={7} style={{ padding: 24, textAlign: 'center', color: 'var(--color-text-muted)' }}>
暂无用户
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} style={{ borderBottom: '1px solid var(--color-border, #2a2a3e)' }}>
<td style={{ padding: '12px 16px', fontFamily: 'monospace', fontSize: 12 }}>{user.id}</td>
<td style={{ padding: '12px 16px' }}>{user.username}</td>
<td style={{ padding: '12px 16px', color: 'var(--color-text-secondary)' }}>{user.email}</td>
<td style={{ padding: '12px 16px' }}>
{editingUser === user.id ? (
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value)}
style={{
padding: '4px 8px', borderRadius: 4, fontSize: 12,
border: '1px solid var(--color-border)',
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
}}
>
<option value="user">user</option>
<option value="admin">admin</option>
</select>
) : (
<span style={{
fontSize: 12, padding: '2px 8px', borderRadius: 4,
background: user.role === 'admin' ? 'rgba(99,102,241,0.15)' : 'rgba(34,197,94,0.15)',
color: user.role === 'admin' ? '#818cf8' : '#4ade80',
}}>
{user.role === 'admin' ? '管理员' : '用户'}
</span>
)}
</td>
<td style={{ padding: '12px 16px' }}>
{editingUser === user.id ? (
<select
value={editStatus}
onChange={(e) => setEditStatus(e.target.value)}
style={{
padding: '4px 8px', borderRadius: 4, fontSize: 12,
border: '1px solid var(--color-border)',
background: 'var(--color-bg)', color: 'var(--color-text-primary)',
}}
>
<option value="active">active</option>
<option value="disabled">disabled</option>
</select>
) : (
<span style={{
fontSize: 12, padding: '2px 8px', borderRadius: 4,
background: user.status === 'active' ? 'rgba(34,197,94,0.15)' : 'rgba(239,68,68,0.15)',
color: user.status === 'active' ? '#4ade80' : '#f87171',
}}>
{user.status === 'active' ? '正常' : '禁用'}
</span>
)}
</td>
<td style={{ padding: '12px 16px', fontSize: 12, color: 'var(--color-text-muted)' }}>
{user.createdAt?.slice(0, 10) || '-'}
</td>
<td style={{ padding: '12px 16px', textAlign: 'right' }}>
{editingUser === user.id ? (
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button onClick={() => handleUpdateUser(user.id)} className="btn btn-primary" style={{ fontSize: 12, padding: '4px 12px' }}>保存</button>
<button onClick={() => setEditingUser(null)} className="btn btn-ghost" style={{ fontSize: 12, padding: '4px 12px' }}>取消</button>
</div>
) : (
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
<button
onClick={() => { setEditingUser(user.id); setEditRole(user.role); setEditStatus(user.status); }}
className="btn btn-ghost"
style={{ fontSize: 12, padding: '4px 12px' }}
>
编辑
</button>
{user.status !== 'disabled' && (
<button onClick={() => handleDisableUser(user.id)} className="btn btn-ghost danger" style={{ fontSize: 12, padding: '4px 12px' }}>
禁用
</button>
)}
</div>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { persistUserSession } from '../authSession.js';
export default function UserRegister() {
const navigate = useNavigate();
const [username, setUsername] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username.trim() || !email.trim() || !password) return;
if (password !== confirmPassword) {
setError('两次密码不一致');
return;
}
if (password.length < 6) {
setError('密码至少 6 个字符');
return;
}
setLoading(true);
setError('');
try {
const res = await fetch('/api/users/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.trim(), email: email.trim(), password }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data?.error || `注册失败 (${res.status})`);
}
const result = await res.json();
persistUserSession(localStorage, result.token);
navigate('/user/dashboard');
} catch (err: any) {
setError(err?.message || '注册失败');
} finally {
setLoading(false);
}
};
return (
<div className="login-shell">
<div className="login-surface animate-scale-in" style={{ maxWidth: 420 }}>
<section className="login-brand-panel login-brand-panel-light" style={{ padding: '28px 24px' }}>
<div className="login-brand-header" style={{ justifyContent: 'center' }}>
<div className="brand-mark-frame brand-mark-frame-hero">
<div className="brand-mark-canvas">
<img src="/logo.png" alt="BoosAPI" className="login-brand-logo" />
</div>
</div>
<div className="login-brand-summary" style={{ textAlign: 'center' }}>
<div className="login-brand-name">BoosAPI</div>
<div className="login-brand-kicker">用户注册</div>
</div>
</div>
</section>
<section className="login-auth-stage">
<div className="login-auth-panel">
<h2 className="login-auth-title">创建账号</h2>
<p className="login-auth-copy">注册后即可使用 API 代理服务。</p>
<form onSubmit={handleSubmit}>
<label className="login-auth-label" htmlFor="reg-username-input">用户名</label>
<input
id="reg-username-input"
type="text"
placeholder="你的昵称"
value={username}
onChange={(e) => { setUsername(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="username"
required
/>
<label className="login-auth-label" htmlFor="reg-email-input" style={{ marginTop: 12 }}>邮箱</label>
<input
id="reg-email-input"
type="email"
placeholder="your@email.com"
value={email}
onChange={(e) => { setEmail(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="email"
required
/>
<label className="login-auth-label" htmlFor="reg-password-input" style={{ marginTop: 12 }}>密码</label>
<input
id="reg-password-input"
type="password"
placeholder="至少 6 位"
value={password}
onChange={(e) => { setPassword(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="new-password"
required
/>
<label className="login-auth-label" htmlFor="reg-confirm-password-input" style={{ marginTop: 12 }}>确认密码</label>
<input
id="reg-confirm-password-input"
type="password"
placeholder="再次输入密码"
value={confirmPassword}
onChange={(e) => { setConfirmPassword(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="new-password"
required
/>
{error && (
<div className="alert alert-error animate-shake" style={{ marginBottom: 12, marginTop: 8 }}>
{error}
</div>
)}
<button
type="submit"
disabled={loading || !username.trim() || !email.trim() || !password || !confirmPassword}
className="btn btn-primary login-auth-submit"
style={{ marginTop: 4 }}
>
{loading ? <><span className="spinner spinner-sm" style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }} /> 注册中...</> : '注册'}
</button>
</form>
<div className="login-auth-footer" style={{ flexDirection: 'column', gap: 8, marginTop: 16 }}>
<span>
已有账号?<Link to="/user/login" style={{ color: 'var(--color-primary)', textDecoration: 'none' }}>登录</Link>
</span>
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
<Link to="/" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
</span>
</div>
</div>
</section>
</div>
</div>
);
}
@@ -29,7 +29,7 @@ export function normalizeVerifyFailureMessage(message: unknown): string {
if (!text) return '验证失败';
const lowered = text.toLowerCase();
if (isNetworkFailureMessage(text)) {
return '无法连接到 metapi 服务端,请检查服务状态或网络连接';
return '无法连接到 BoosAPI 服务端,请检查服务状态或网络连接';
}
if (lowered.includes('user id mismatch') || lowered.includes('does not match this token')) {
return '填写的用户 ID 与当前 Token / Cookie 不匹配';
@@ -43,7 +43,7 @@ export function buildVerifyFailureHint(result: VerifyResultLike): string | null
return '这不是 Token 错误判断。请检查填写的用户 ID 是否与当前 Token / Cookie 属于同一账号。';
}
if (isNetworkFailureMessage(result.message)) {
return '这不是 Token 错误判断。请检查 metapi 服务是否在线,以及目标站点或代理是否可达。';
return '这不是 Token 错误判断。请检查 BoosAPI 服务是否在线,以及目标站点或代理是否可达。';
}
if (isTimeoutFailureMessage(result.message)) {
return '这不是 Token 错误判断。目标站点响应超时,请稍后重试或检查代理/网络。';
@@ -63,7 +63,7 @@ export function buildAddAccountPrereqHint(result: VerifyResultLike): string {
return '请先修正用户 ID 并重新验证,验证成功后才能添加账号。';
}
if (isNetworkFailureMessage(result.message)) {
return '验证请求未成功完成,请先检查 metapi 服务、站点网络或代理配置。';
return '验证请求未成功完成,请先检查 BoosAPI 服务、站点网络或代理配置。';
}
if (isTimeoutFailureMessage(result.message)) {
return '验证请求超时,请先检查站点或代理连通性后再添加账号。';
+1 -1
View File
@@ -216,7 +216,7 @@ const VALID_PROTOCOLS: ReadonlySet<string> = new Set(['openai', 'responses', 'cl
const VALID_CONVERSATION_DRAFT_STATUSES: ReadonlySet<string> = new Set(['pending', 'uploading', 'uploaded', 'error']);
const VALID_PROXY_METHODS: ReadonlySet<string> = new Set(['POST', 'GET', 'DELETE']);
const VALID_REQUEST_KINDS: ReadonlySet<string> = new Set(['json', 'multipart', 'empty']);
const LOCAL_PROXY_FILE_ID_PREFIX = 'file-metapi-';
const LOCAL_PROXY_FILE_ID_PREFIX = 'file-boosapi-';
let messageCounter = 0;
+2 -2
View File
@@ -43,10 +43,10 @@ export default function FactoryResetModal({
<div className="modal-header" style={{ color: 'var(--color-danger)' }}>确认重新初始化系统</div>
<div className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
这是不可逆操作。系统会清空当前 metapi 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
这是不可逆操作。系统会清空当前 BoosAPI 使用中的全部数据库内容,并在成功后立即退出当前登录状态。
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 metapi 数据。</div>
<div>• 当前若使用外部 MySQL/Postgres,也会先清空该外部库中的 BoosAPI 数据。</div>
<div>• 系统随后会强制切回默认 SQLite。</div>
<div>• 管理员 Token 将重置为 <code style={{ fontFamily: 'var(--font-mono)' }}>{adminToken}</code>。</div>
<div>• 完成后会立即退出登录并刷新页面,回到当前首装初始状态。</div>
@@ -52,7 +52,7 @@ export default function ModelAvailabilityProbeConfirmModal({
<div id={titleId} className="modal-header" style={{ color: 'var(--color-danger)' }}>确认开启批量测活</div>
<div id={descriptionId} className="modal-body" style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ padding: 12, borderRadius: 'var(--radius-sm)', background: 'var(--color-danger-bg)', color: 'var(--color-danger)', fontSize: 12, lineHeight: 1.8 }}>
开启后,metapi 会在后台对活跃账号模型做最小化探测请求。这可能被部分中转站视为批量测活或异常行为,请务必先确认你的中转站明确允许此类探测。
开启后,BoosAPI 会在后台对活跃账号模型做最小化探测请求。这可能被部分中转站视为批量测活或异常行为,请务必先确认你的中转站明确允许此类探测。
</div>
<div style={{ fontSize: 13, color: 'var(--color-text-secondary)', lineHeight: 1.9 }}>
请手动输入以下整句后再开启:
@@ -89,7 +89,7 @@ const DEFAULT_CONFIG: NonNullable<UpdateCenterStatus['config']> = {
namespace: 'default',
releaseName: '',
chartRef: '',
imageRepository: '1467078763/metapi',
imageRepository: '1467078763/boosapi',
githubReleasesEnabled: true,
dockerHubTagsEnabled: true,
defaultDeploySource: 'github-release',
@@ -614,7 +614,7 @@ export default function UpdateCenterSection() {
value={config.helperBaseUrl}
onChange={(e) => setConfig((prev) => ({ ...prev, helperBaseUrl: e.target.value }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
placeholder="http://metapi-deploy-helper.namespace.svc.cluster.local:9850"
placeholder="http://boosapi-deploy-helper.namespace.svc.cluster.local:9850"
/>
</label>
<label>
@@ -643,7 +643,7 @@ export default function UpdateCenterSection() {
value={config.releaseName}
onChange={(e) => setConfig((prev) => ({ ...prev, releaseName: e.target.value }))}
style={inputStyle}
placeholder="metapi"
placeholder="boosapi"
/>
</label>
<label>
@@ -652,7 +652,7 @@ export default function UpdateCenterSection() {
value={config.chartRef}
onChange={(e) => setConfig((prev) => ({ ...prev, chartRef: e.target.value }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
placeholder="oci://ghcr.io/cita-777/charts/metapi"
placeholder="oci://ghcr.io/cita-777/charts/boosapi"
/>
</label>
<label>
@@ -661,7 +661,7 @@ export default function UpdateCenterSection() {
value={config.imageRepository}
onChange={(e) => setConfig((prev) => ({ ...prev, imageRepository: e.target.value }))}
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
placeholder="1467078763/metapi"
placeholder="1467078763/boosapi"
/>
</label>
</div>