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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
/**
|
||||
* @Author: 橘子
|
||||
* @Project_description: Metapi 站点管理页
|
||||
* @Project_description: BoosAPI 站点管理页
|
||||
* @Description: 代码是我抄的,不会也是真的
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 '验证请求超时,请先检查站点或代理连通性后再添加账号。';
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user