feat: add workflow studio and postgres support
This commit is contained in:
@@ -7,8 +7,8 @@ const FEATURES = [
|
||||
{ icon: 'RT', title: '模型路由与容灾', desc: '按模型、账号、站点和通道优先级调度,失败后自动冷却并切换到可用通道。' },
|
||||
{ icon: 'AC', title: '站点与账号管理', desc: '集中维护站点、账号、API Key、OAuth、下游 Key 和普通用户可用模型。' },
|
||||
{ icon: 'MD', title: '模型广场', desc: '聚合各站点模型可用性、账号覆盖、绑定令牌与路由状态,便于快速修路由。' },
|
||||
{ icon: 'CU', title: 'ComfyUI 工作台', desc: '内置多租户 ComfyUI 容器池,支持用 BoosAPI 模型驱动文本、图像和视频节点。' },
|
||||
{ icon: 'AG', title: 'ComfyUI Agent', desc: '面向工作流创作的对话助手,支持附件、多会话、用户 API Key 和模型路由选择。' },
|
||||
{ icon: 'WF', title: '工作流画布', desc: '沿用 ComfyUI 节点画布体验,后续接入 BoosAPI 模型路由、图像、视频、音频和工具节点。' },
|
||||
{ icon: 'AG', title: '视频助手', desc: '面向工作流创作的对话助手,支持附件、多会话、用户 API Key 和模型路由选择。' },
|
||||
{ icon: 'PI', title: '生图助手', desc: '提供提示词模板、比例尺寸控制和图像模型调用,管理员与普通用户均可使用。' },
|
||||
{ icon: 'VD', title: '视频任务模板', desc: '支持火山方舟 Step1 创建视频任务、Step2 查询任务结果,并可复用 BoosAPI 模型路由。' },
|
||||
{ icon: 'CK', title: '签到与余额刷新', desc: '按站点/账号执行签到、余额刷新、健康检查和连接状态追踪。' },
|
||||
@@ -17,18 +17,18 @@ const FEATURES = [
|
||||
|
||||
const TECH_STACK = [
|
||||
{ name: 'Rust', desc: '后端核心服务,提供代理、路由、认证、任务和静态资源服务。' },
|
||||
{ name: 'Axum / Tokio', desc: '异步 HTTP 服务、SSE、后台任务和容器生命周期管理。' },
|
||||
{ name: 'Axum / Tokio', desc: '异步 HTTP 服务、SSE、后台任务和代理执行。' },
|
||||
{ name: 'SQLx', desc: '类型化数据库访问与迁移管理。' },
|
||||
{ name: 'SQLite', desc: '默认嵌入式数据库,适合单机部署和低运维成本场景。' },
|
||||
{ name: 'React / Vite', desc: '管理后台、普通用户面板、模型测试器和 Agent 界面。' },
|
||||
{ name: 'ComfyUI / Docker', desc: '视频工作台和多租户工作流容器运行环境。' },
|
||||
{ name: 'External ComfyUI', desc: '可选外部 ComfyUI 代理;BoosAPI 不再内置 Docker 工作台。' },
|
||||
];
|
||||
|
||||
const ENDPOINTS = [
|
||||
{ path: '/v1/chat/completions', desc: 'OpenAI 兼容对话代理,支持非流式与流式。' },
|
||||
{ path: '/v1/messages', desc: 'Claude Messages API 兼容入口,支持 Anthropic 请求、响应和流式事件。' },
|
||||
{ path: '/v1/responses', desc: 'Responses API 兼容代理,可接入支持该协议的上游。' },
|
||||
{ path: '/v1/images/generations', desc: '图像生成代理,供生图助手和 ComfyUI 图像节点使用。' },
|
||||
{ path: '/v1/images/generations', desc: '图像生成代理,供生图助手和工作流图像节点使用。' },
|
||||
{ path: '/v1/videos', desc: '视频生成任务入口,已适配火山方舟任务创建。' },
|
||||
{ path: '/v1/videos/{id}', desc: '视频任务查询入口,已适配火山方舟任务轮询。' },
|
||||
{ path: '/v1/models', desc: '仅暴露当前可路由、未冷却、账号有效的模型列表。' },
|
||||
@@ -56,10 +56,10 @@ export default function About() {
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
|
||||
{tr('BoosAPI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、ComfyUI、图像生成、视频任务和普通用户面板收束到同一个 Rust 服务中,让前端、Agent 和 ComfyUI 节点都能使用后台已经配置好的模型。')}
|
||||
{tr('BoosAPI 是一个自托管 AI 模型网关与工作台。它把多站点账号、下游 Key、模型路由、图像生成、视频任务、ComfyUI 风格工作流和普通用户面板收束到同一个 Rust 服务中,让前端、Agent 和工作流节点都能使用后台已经配置好的模型。')}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 14 }}>
|
||||
{['Rust Backend', 'OpenAI Compatible', 'ComfyUI', 'Picture Agent', 'Volcengine Video'].map((tag) => (
|
||||
{['Rust Backend', 'OpenAI Compatible', 'ComfyUI Canvas', 'Picture Agent', 'Volcengine Video'].map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
style={{
|
||||
@@ -171,7 +171,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('BoosAPI 完全自托管,账号、令牌、路由、日志、普通用户和工作台状态均保存在你的运行环境中。代理请求只在你的服务器、ComfyUI 容器与已配置上游之间流转;内置系统 Key 会被保护,后台列表中只展示脱敏值。')}
|
||||
{tr('BoosAPI 完全自托管,账号、令牌、路由、日志、普通用户和工作台状态均保存在你的运行环境中。代理请求只在你的服务器、可选外部 ComfyUI 与已配置上游之间流转;内置系统 Key 会被保护,后台列表中只展示脱敏值。')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -493,7 +493,37 @@ export default function Accounts() {
|
||||
credentialMode,
|
||||
skipModelFetch: tokenForm.skipModelFetch,
|
||||
});
|
||||
const createdAccountIds = result?.batch
|
||||
? Array.isArray(result?.data)
|
||||
? result.data
|
||||
.map((item: any) => Number(item?.id))
|
||||
.filter((id: number) => Number.isFinite(id) && id > 0)
|
||||
: []
|
||||
: Number(result?.id) > 0
|
||||
? [Number(result.id)]
|
||||
: [];
|
||||
if (result?.batch) {
|
||||
if (
|
||||
activeSegment === "apikey" &&
|
||||
createdAccountIds.length > 0
|
||||
) {
|
||||
try {
|
||||
const syncResults = await Promise.allSettled(
|
||||
createdAccountIds.map((accountId: number) =>
|
||||
api.checkModels(accountId),
|
||||
),
|
||||
);
|
||||
const syncedCount = syncResults.filter(
|
||||
(item) => item.status === "fulfilled",
|
||||
).length;
|
||||
if (syncedCount > 0) {
|
||||
await api.rebuildRoutes(false, false);
|
||||
toast.success("批量 API Key 已自动同步模型并重建路由");
|
||||
}
|
||||
} catch (syncErr: any) {
|
||||
toast.error(syncErr?.message || "批量连接已添加,但自动建路由失败");
|
||||
}
|
||||
}
|
||||
closeAddPanel();
|
||||
const createdCount = Number(result.createdCount) || 0;
|
||||
const failedCount = Number(result.failedCount) || 0;
|
||||
@@ -527,6 +557,7 @@ export default function Accounts() {
|
||||
createdAccountId,
|
||||
recommendedModels,
|
||||
);
|
||||
await api.rebuildRoutes(false, false);
|
||||
seededRecommendedModels = true;
|
||||
} catch (seedErr: any) {
|
||||
toast.error(seedErr?.message || "连接已添加,但推荐模型补录失败");
|
||||
@@ -549,6 +580,52 @@ export default function Accounts() {
|
||||
`已补入 ${recommendedModels.length} 个推荐模型并重建路由`,
|
||||
);
|
||||
}
|
||||
const shouldAutoSyncModels =
|
||||
credentialMode === "apikey" &&
|
||||
!tokenForm.skipModelFetch &&
|
||||
createdAccountIds.length > 0;
|
||||
if (shouldAutoSyncModels) {
|
||||
if (
|
||||
!result?.batch &&
|
||||
verifyResult?.success &&
|
||||
Array.isArray(verifyResult.models) &&
|
||||
verifyResult.models.length > 0
|
||||
) {
|
||||
try {
|
||||
await api.addAccountAvailableModels(
|
||||
createdAccountIds[0],
|
||||
verifyResult.models,
|
||||
);
|
||||
await api.rebuildRoutes(false, false);
|
||||
toast.success("已自动同步模型并重建路由");
|
||||
} catch (syncErr: any) {
|
||||
toast.error(syncErr?.message || "连接已添加,但模型同步失败");
|
||||
}
|
||||
} else {
|
||||
const syncResults = await Promise.allSettled(
|
||||
createdAccountIds.map((accountId: number) =>
|
||||
api.checkModels(accountId),
|
||||
),
|
||||
);
|
||||
const syncedCount = syncResults.filter(
|
||||
(item) => item.status === "fulfilled",
|
||||
).length;
|
||||
const failedCount = syncResults.length - syncedCount;
|
||||
if (syncedCount > 0) {
|
||||
try {
|
||||
await api.rebuildRoutes(false, false);
|
||||
toast.success(
|
||||
`已自动同步 ${syncedCount} 个连接的模型并重建路由`,
|
||||
);
|
||||
} catch (rebuildErr: any) {
|
||||
toast.error(rebuildErr?.message || "模型已同步,但路由重建失败");
|
||||
}
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
toast.error(`有 ${failedCount} 个连接同步模型失败`);
|
||||
}
|
||||
}
|
||||
}
|
||||
load(true);
|
||||
} catch (e: any) {
|
||||
toast.error(e.message || "添加失败");
|
||||
|
||||
+93
-135
@@ -1,16 +1,20 @@
|
||||
import React from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { getSessionToken } from '../authSession.js';
|
||||
|
||||
export default function ComfyUI() {
|
||||
const [isDark, setIsDark] = React.useState(true);
|
||||
const [iframeSrc, setIframeSrc] = React.useState('');
|
||||
const [starting, setStarting] = React.useState(true);
|
||||
const [message, setMessage] = React.useState('正在启动 ComfyUI...');
|
||||
const [error, setError] = React.useState('');
|
||||
const location = useLocation();
|
||||
const audience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||
const token = getSessionToken(audience, localStorage);
|
||||
const [available, setAvailable] = React.useState(false);
|
||||
const [message, setMessage] = React.useState('正在检查外部 ComfyUI...');
|
||||
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
const openFullscreen = () => {
|
||||
window.open('/api/comfyui/proxy/', '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const reloadFrame = () => {
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = '/api/comfyui/proxy/';
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const el = document.documentElement;
|
||||
@@ -23,121 +27,25 @@ export default function ComfyUI() {
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const encodedToken = token ? encodeURIComponent(token) : '';
|
||||
const requestTimeoutMs = 35_000;
|
||||
|
||||
const fetchWithTimeout = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => controller.abort(), requestTimeoutMs);
|
||||
try {
|
||||
return await fetch(input, { ...init, signal: controller.signal });
|
||||
} finally {
|
||||
window.clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
|
||||
const resolveIframeFromPayload = (payload: any) => {
|
||||
const comfyui = payload?.comfyui || payload || {};
|
||||
const mode = String(comfyui?.mode || '').toLowerCase();
|
||||
const status = String(comfyui?.status || '').toLowerCase();
|
||||
const lastError = String(comfyui?.lastError || comfyui?.last_error || '').trim();
|
||||
|
||||
if (mode === 'container') {
|
||||
if (status === 'running') return `/comfy/?token=${encodedToken}`;
|
||||
if (status === 'unhealthy' || status === 'failed') {
|
||||
throw new Error(lastError || 'ComfyUI 容器启动失败');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
if (status && status !== 'disabled') {
|
||||
return '/api/comfyui/proxy/';
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
throw new Error(lastError);
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
setIframeSrc('');
|
||||
setStarting(false);
|
||||
setMessage('登录状态已过期,请重新登录。');
|
||||
setError('');
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setStarting(true);
|
||||
setIframeSrc('');
|
||||
setError('');
|
||||
setMessage('正在启动 ComfyUI...');
|
||||
|
||||
const start = async () => {
|
||||
try {
|
||||
const response = await fetchWithTimeout('/api/video-agent/start', {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (!cancelled) {
|
||||
if (!response.ok) {
|
||||
throw new Error(`启动接口返回 ${response.status}`);
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
const nextSrc = resolveIframeFromPayload(payload);
|
||||
if (nextSrc) {
|
||||
setIframeSrc(nextSrc);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (!cancelled) {
|
||||
setMessage('ComfyUI 启动较慢,正在检查状态...');
|
||||
if (e?.name !== 'AbortError') setError(e?.message || '启动请求失败');
|
||||
}
|
||||
}
|
||||
|
||||
const deadline = Date.now() + 120_000;
|
||||
while (!cancelled && Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetchWithTimeout('/api/video-agent/status', {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
if (response.ok) {
|
||||
const payload = await response.json().catch(() => null);
|
||||
const nextSrc = resolveIframeFromPayload(payload);
|
||||
if (nextSrc) {
|
||||
setIframeSrc(nextSrc);
|
||||
return;
|
||||
}
|
||||
setMessage('ComfyUI 仍在启动,等待容器就绪...');
|
||||
} else {
|
||||
setError(`状态接口返回 ${response.status}`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.name === 'AbortError' ? '状态检查超时' : (e?.message || '状态检查失败'));
|
||||
}
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 2000));
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setStarting(false);
|
||||
setMessage('ComfyUI 启动超时');
|
||||
setError((current) => current || '请检查 /api/video-agent/status、Docker 容器日志和 4000 后端进程。');
|
||||
}
|
||||
};
|
||||
|
||||
void start().finally(() => {
|
||||
if (!cancelled) setStarting(false);
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
void fetch('/api/comfyui/proxy/', { method: 'GET', signal: controller.signal })
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setAvailable(response.ok);
|
||||
setMessage(response.ok
|
||||
? ''
|
||||
: '外部 ComfyUI 不可达。请在 config.toml 中配置 comfyui.base_url,或使用工作流。');
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled || err?.name === 'AbortError') return;
|
||||
setAvailable(false);
|
||||
setMessage('外部 ComfyUI 不可达。请在 config.toml 中配置 comfyui.base_url,或使用工作流。');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, [token]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -149,15 +57,23 @@ export default function ComfyUI() {
|
||||
background: isDark ? '#1a1a2e' : '#f5f5f5',
|
||||
}}
|
||||
>
|
||||
{iframeSrc ? (
|
||||
<div style={toolbarStyle}>
|
||||
<div style={toolbarTitleStyle}>外部 ComfyUI</div>
|
||||
<div style={toolbarActionsStyle}>
|
||||
<button type="button" onClick={reloadFrame} disabled={!available} style={toolbarButtonStyle}>
|
||||
刷新
|
||||
</button>
|
||||
<button type="button" onClick={openFullscreen} disabled={!available} style={primaryToolbarButtonStyle}>
|
||||
新窗口打开
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{available ? (
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
border: 'none',
|
||||
}}
|
||||
title="ComfyUI"
|
||||
ref={iframeRef}
|
||||
src="/api/comfyui/proxy/"
|
||||
style={{ width: '100%', height: '100%', border: 'none' }}
|
||||
title="External ComfyUI"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
/>
|
||||
) : (
|
||||
@@ -168,16 +84,58 @@ export default function ComfyUI() {
|
||||
color: 'var(--color-text-muted)',
|
||||
fontSize: 14,
|
||||
textAlign: 'center',
|
||||
gap: 8,
|
||||
padding: 24,
|
||||
}}>
|
||||
<div>{starting ? message : message}</div>
|
||||
{error && (
|
||||
<div style={{ color: '#ef4444', fontSize: 12, maxWidth: 520 }}>
|
||||
{error}
|
||||
<div>
|
||||
<div style={{ fontWeight: 700, color: 'var(--color-text-primary)', marginBottom: 8 }}>
|
||||
内置 Docker ComfyUI 工作台已移除
|
||||
</div>
|
||||
)}
|
||||
<div style={{ maxWidth: 560, lineHeight: 1.7 }}>{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toolbarStyle: React.CSSProperties = {
|
||||
minHeight: 44,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid var(--color-border-light)',
|
||||
background: 'var(--color-surface)',
|
||||
};
|
||||
|
||||
const toolbarTitleStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const toolbarActionsStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
const toolbarButtonStyle: React.CSSProperties = {
|
||||
minHeight: 30,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8,
|
||||
padding: '5px 10px',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const primaryToolbarButtonStyle: React.CSSProperties = {
|
||||
...toolbarButtonStyle,
|
||||
borderColor: 'var(--color-primary)',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
};
|
||||
|
||||
+873
-35
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,340 @@
|
||||
import React, { CSSProperties, useMemo, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { fetchSessionResponse } from '../api.js';
|
||||
import type { SessionAudience } from '../authSession.js';
|
||||
|
||||
type Mode = 'audio' | 'image' | 'text';
|
||||
|
||||
const MODE_OPTIONS: Array<{ value: Mode; label: string; description: string }> = [
|
||||
{ value: 'audio', label: '音频驱动', description: '人物图 + 音频,适合口播、播报和虚拟主播。' },
|
||||
{ value: 'image', label: '图像驱动', description: '人物图 + 台词,交给上游数字人/视频模型生成。' },
|
||||
{ value: 'text', label: '文本生成', description: '只输入角色描述和台词,让模型生成数字人视频任务。' },
|
||||
];
|
||||
|
||||
const MODEL_PRESETS = [
|
||||
'longcat-video-avatar',
|
||||
'doubao-seedance-1-0-lite-i2v-250428',
|
||||
'doubao-seedance-1-0-lite-t2v-250428',
|
||||
'wan2-1-14b-i2v-250225',
|
||||
];
|
||||
|
||||
const VOICE_STYLE_OPTIONS = ['自然口播', '新闻播报', '带货讲解', '课程讲师', '双人对话'];
|
||||
|
||||
async function parseError(response: Response) {
|
||||
const text = await response.text().catch(() => '');
|
||||
if (!text) return `HTTP ${response.status}`;
|
||||
try {
|
||||
const value = JSON.parse(text);
|
||||
return value?.error?.message || value?.message || text;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function extractTaskId(value: any): string {
|
||||
return value?.response?.id
|
||||
|| value?.response?.task_id
|
||||
|| value?.response?.data?.id
|
||||
|| value?.response?.data?.task_id
|
||||
|| value?.id
|
||||
|| value?.task_id
|
||||
|| '';
|
||||
}
|
||||
|
||||
function copyText(value: string) {
|
||||
if (!value) return;
|
||||
void navigator.clipboard?.writeText(value);
|
||||
}
|
||||
|
||||
export default function DigitalHuman() {
|
||||
const location = useLocation();
|
||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||
const [mode, setMode] = useState<Mode>('audio');
|
||||
const [model, setModel] = useState(MODEL_PRESETS[0]);
|
||||
const [prompt, setPrompt] = useState('一位专业中文数字人主播,坐在简洁演播室中,面向镜头自然讲解 BoosAPI 的模型路由和工作流能力。');
|
||||
const [script, setScript] = useState('大家好,欢迎使用 BoosAPI。今天我们演示如何用统一模型路由生成数字人口播视频。');
|
||||
const [imageUrl, setImageUrl] = useState('');
|
||||
const [audioUrl, setAudioUrl] = useState('');
|
||||
const [voiceStyle, setVoiceStyle] = useState(VOICE_STYLE_OPTIONS[0]);
|
||||
const [resolution, setResolution] = useState('720p');
|
||||
const [ratio, setRatio] = useState('9:16');
|
||||
const [duration, setDuration] = useState(8);
|
||||
const [refImageIndex, setRefImageIndex] = useState(0);
|
||||
const [audioCfg, setAudioCfg] = useState(4.5);
|
||||
const [segments, setSegments] = useState(false);
|
||||
const [multiPerson, setMultiPerson] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<any>(null);
|
||||
|
||||
const bodyPreview = useMemo(() => ({
|
||||
model: model.trim(),
|
||||
prompt: [
|
||||
prompt.trim(),
|
||||
script.trim() ? `台词:${script.trim()}` : '',
|
||||
`风格:${voiceStyle}`,
|
||||
].filter(Boolean).join('\n'),
|
||||
mode,
|
||||
ratio,
|
||||
resolution,
|
||||
duration,
|
||||
audio_url: mode === 'audio' ? audioUrl.trim() : undefined,
|
||||
image_url: mode !== 'text' ? imageUrl.trim() : undefined,
|
||||
audio_cfg: audioCfg,
|
||||
ref_img_index: refImageIndex,
|
||||
segmented: segments,
|
||||
multi_person: multiPerson,
|
||||
}), [audioCfg, audioUrl, duration, imageUrl, mode, model, multiPerson, prompt, ratio, refImageIndex, resolution, script, segments, voiceStyle]);
|
||||
|
||||
const submit = async () => {
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
setResult(null);
|
||||
try {
|
||||
const response = await fetchSessionResponse(audience, '/api/digital-human/generate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(bodyPreview),
|
||||
timeoutMs: 300_000,
|
||||
});
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
setResult(await response.json());
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '数字人任务创建失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const taskId = extractTaskId(result);
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">数字人</h1>
|
||||
<p className="page-subtitle">参考 LongCat-Video Avatar 的任务形态:人物图、音频、台词、分段和多人数字人编排,底层走 BoosAPI 视频模型路由。</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={submitting}>
|
||||
{submitting ? '创建中...' : '创建数字人任务'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="alert alert-error" style={{ marginBottom: 12 }}>{error}</div>}
|
||||
|
||||
<div style={layoutStyle}>
|
||||
<section style={panelStyle}>
|
||||
<div style={modeGridStyle}>
|
||||
{MODE_OPTIONS.map((item) => (
|
||||
<button
|
||||
key={item.value}
|
||||
type="button"
|
||||
onClick={() => setMode(item.value)}
|
||||
style={mode === item.value ? activeModeStyle : modeStyle}
|
||||
>
|
||||
<strong>{item.label}</strong>
|
||||
<span>{item.description}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={formGridStyle}>
|
||||
<label style={fieldStyle}>
|
||||
<span>模型</span>
|
||||
<input value={model} onChange={(event) => setModel(event.target.value)} list="digital-human-models" style={inputStyle} />
|
||||
<datalist id="digital-human-models">
|
||||
{MODEL_PRESETS.map((item) => <option key={item} value={item} />)}
|
||||
</datalist>
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span>画幅</span>
|
||||
<select value={ratio} onChange={(event) => setRatio(event.target.value)} style={inputStyle}>
|
||||
<option value="9:16">9:16 竖屏</option>
|
||||
<option value="16:9">16:9 横屏</option>
|
||||
<option value="1:1">1:1 方屏</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span>分辨率</span>
|
||||
<select value={resolution} onChange={(event) => setResolution(event.target.value)} style={inputStyle}>
|
||||
<option value="720p">720p</option>
|
||||
<option value="1080p">1080p</option>
|
||||
<option value="480p">480p</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span>时长(秒)</span>
|
||||
<input type="number" min={1} max={120} value={duration} onChange={(event) => setDuration(Number(event.target.value) || 1)} style={inputStyle} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label style={fieldStyle}>
|
||||
<span>人物/场景描述</span>
|
||||
<textarea value={prompt} onChange={(event) => setPrompt(event.target.value)} rows={4} style={textareaStyle} />
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span>台词/脚本</span>
|
||||
<textarea value={script} onChange={(event) => setScript(event.target.value)} rows={5} style={textareaStyle} />
|
||||
</label>
|
||||
|
||||
<div style={formGridStyle}>
|
||||
{mode !== 'text' && (
|
||||
<label style={fieldStyle}>
|
||||
<span>人物图 URL</span>
|
||||
<input value={imageUrl} onChange={(event) => setImageUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</label>
|
||||
)}
|
||||
{mode === 'audio' && (
|
||||
<label style={fieldStyle}>
|
||||
<span>音频 URL</span>
|
||||
<input value={audioUrl} onChange={(event) => setAudioUrl(event.target.value)} placeholder="https://..." style={inputStyle} />
|
||||
</label>
|
||||
)}
|
||||
<label style={fieldStyle}>
|
||||
<span>口播风格</span>
|
||||
<select value={voiceStyle} onChange={(event) => setVoiceStyle(event.target.value)} style={inputStyle}>
|
||||
{VOICE_STYLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span>ref_img_index</span>
|
||||
<input type="number" min={0} value={refImageIndex} onChange={(event) => setRefImageIndex(Number(event.target.value) || 0)} style={inputStyle} />
|
||||
</label>
|
||||
<label style={fieldStyle}>
|
||||
<span>audio CFG</span>
|
||||
<input type="number" min={0} step={0.1} value={audioCfg} onChange={(event) => setAudioCfg(Number(event.target.value) || 0)} style={inputStyle} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={toggleRowStyle}>
|
||||
<label><input type="checkbox" checked={segments} onChange={(event) => setSegments(event.target.checked)} /> 分段长视频</label>
|
||||
<label><input type="checkbox" checked={multiPerson} onChange={(event) => setMultiPerson(event.target.checked)} /> 多人数字人</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside style={panelStyle}>
|
||||
<h2 style={sideTitleStyle}>任务请求</h2>
|
||||
<pre style={codeStyle}>{JSON.stringify(bodyPreview, null, 2)}</pre>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(bodyPreview, null, 2))}>复制请求</button>
|
||||
|
||||
<h2 style={{ ...sideTitleStyle, marginTop: 18 }}>返回结果</h2>
|
||||
{result ? (
|
||||
<>
|
||||
{taskId && <div style={taskIdStyle}>任务 ID:{taskId}</div>}
|
||||
<pre style={codeStyle}>{JSON.stringify(result.response || result, null, 2)}</pre>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => copyText(JSON.stringify(result.response || result, null, 2))}>复制结果</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-state" style={{ minHeight: 160 }}>
|
||||
<div className="empty-state-title">暂无任务</div>
|
||||
<div className="empty-state-desc">创建后会显示上游返回,可用任务 ID 到模型站点或后续查询接口查看状态。</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const layoutStyle: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'minmax(0, 1.25fr) minmax(320px, 0.75fr)',
|
||||
gap: 16,
|
||||
};
|
||||
|
||||
const panelStyle: CSSProperties = {
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-surface)',
|
||||
borderRadius: 8,
|
||||
padding: 16,
|
||||
};
|
||||
|
||||
const modeGridStyle: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||
gap: 10,
|
||||
marginBottom: 16,
|
||||
};
|
||||
|
||||
const modeStyle: CSSProperties = {
|
||||
textAlign: 'left',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-bg)',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const activeModeStyle: CSSProperties = {
|
||||
...modeStyle,
|
||||
borderColor: 'var(--color-primary)',
|
||||
boxShadow: '0 0 0 2px color-mix(in srgb, var(--color-primary) 15%, transparent)',
|
||||
};
|
||||
|
||||
const formGridStyle: CSSProperties = {
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))',
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
};
|
||||
|
||||
const fieldStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 6,
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-secondary)',
|
||||
};
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
borderRadius: 6,
|
||||
padding: '10px 12px',
|
||||
fontSize: 13,
|
||||
};
|
||||
|
||||
const textareaStyle: CSSProperties = {
|
||||
...inputStyle,
|
||||
minHeight: 110,
|
||||
resize: 'vertical',
|
||||
};
|
||||
|
||||
const toggleRowStyle: CSSProperties = {
|
||||
display: 'flex',
|
||||
gap: 16,
|
||||
flexWrap: 'wrap',
|
||||
fontSize: 13,
|
||||
color: 'var(--color-text-secondary)',
|
||||
};
|
||||
|
||||
const sideTitleStyle: CSSProperties = {
|
||||
margin: '0 0 10px',
|
||||
fontSize: 14,
|
||||
};
|
||||
|
||||
const codeStyle: CSSProperties = {
|
||||
maxHeight: 280,
|
||||
overflow: 'auto',
|
||||
background: 'var(--color-bg)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 6,
|
||||
padding: 12,
|
||||
fontSize: 12,
|
||||
whiteSpace: 'pre-wrap',
|
||||
};
|
||||
|
||||
const taskIdStyle: CSSProperties = {
|
||||
marginBottom: 8,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 6,
|
||||
background: 'color-mix(in srgb, var(--color-primary) 10%, transparent)',
|
||||
color: 'var(--color-text-primary)',
|
||||
fontSize: 12,
|
||||
};
|
||||
+151
-86
@@ -1,6 +1,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { api, userApi } from '../api.js';
|
||||
import { BrandGlyph, getBrand, hashColor, BrandIcon, type BrandInfo } from '../components/BrandIcon.js';
|
||||
import SiteBadgeLink from '../components/SiteBadgeLink.js';
|
||||
import { useToast } from '../components/Toast.js';
|
||||
@@ -125,6 +125,24 @@ function renderGroupPricingValue(pricing: ModelGroupPricing): string {
|
||||
return `${pricing.perCallTotal ?? 0} USD / call`;
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.left = '-9999px';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
|
||||
const PAGE_SIZES = [10, 20, 50];
|
||||
|
||||
function compareModels(a: ModelRow, b: ModelRow, sortBy: SortColumn, sortDir: 'asc' | 'desc'): number {
|
||||
@@ -153,6 +171,9 @@ function compareModels(a: ModelRow, b: ModelRow, sortBy: SortColumn, sortDir: 'a
|
||||
/* ---- component ---- */
|
||||
export default function Models() {
|
||||
const toast = useToast();
|
||||
const location = useLocation();
|
||||
const isUserView = location.pathname.startsWith('/user/');
|
||||
const marketplaceApi = isUserView ? userApi : api;
|
||||
const [data, setData] = useState<ModelsMarketplaceResponse>({ models: [] });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -172,7 +193,6 @@ export default function Models() {
|
||||
const filterPanelPresence = useAnimatedVisibility(!isMobile && !filterCollapsed, 220);
|
||||
const latestPrimaryRequestRef = useRef(0);
|
||||
const latestMetadataRequestRef = useRef(0);
|
||||
const location = useLocation();
|
||||
const siteIdByName = useMemo(() => {
|
||||
const index = new Map<string, number>();
|
||||
for (const model of data.models) {
|
||||
@@ -192,7 +212,7 @@ export default function Models() {
|
||||
setMetadataHydrating(false);
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.getModelsMarketplace({
|
||||
const res = await marketplaceApi.getModelsMarketplace({
|
||||
refresh,
|
||||
includePricing: false,
|
||||
});
|
||||
@@ -216,7 +236,7 @@ export default function Models() {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [toast]);
|
||||
}, [marketplaceApi, toast]);
|
||||
|
||||
const hydrateMarketplaceMetadata = useCallback(async (baseModels: ModelRow[]) => {
|
||||
if (!shouldHydrateMarketplaceMetadata(baseModels)) return;
|
||||
@@ -225,7 +245,7 @@ export default function Models() {
|
||||
const baseRequestId = latestPrimaryRequestRef.current;
|
||||
setMetadataHydrating(true);
|
||||
try {
|
||||
const res = await api.getModelsMarketplace({
|
||||
const res = await marketplaceApi.getModelsMarketplace({
|
||||
includePricing: true,
|
||||
});
|
||||
if (metadataRequestId !== latestMetadataRequestRef.current) return;
|
||||
@@ -244,7 +264,7 @@ export default function Models() {
|
||||
setMetadataHydrating(false);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
}, [marketplaceApi]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isMobile) return;
|
||||
@@ -311,6 +331,7 @@ export default function Models() {
|
||||
|
||||
/* ---- derived: site list ---- */
|
||||
const siteMap = useMemo(() => {
|
||||
if (isUserView) return [];
|
||||
const m = new Map<string, number>();
|
||||
for (const model of data.models) {
|
||||
for (const a of model.accounts) {
|
||||
@@ -318,7 +339,7 @@ export default function Models() {
|
||||
}
|
||||
}
|
||||
return [...m.entries()].sort((a, b) => b[1] - a[1]);
|
||||
}, [data.models]);
|
||||
}, [data.models, isUserView]);
|
||||
|
||||
/* ---- filtered ---- */
|
||||
const filteredModels = useMemo(() => {
|
||||
@@ -366,8 +387,8 @@ export default function Models() {
|
||||
};
|
||||
}) : filteredModels;
|
||||
|
||||
return [...scopedModels].sort((a, b) => compareModels(a, b, sortBy, sortDir));
|
||||
}, [filteredModels, activeSite, sortBy, sortDir]);
|
||||
return [...scopedModels].sort((a, b) => compareModels(a, b, isUserView ? 'name' : sortBy, isUserView ? 'asc' : sortDir));
|
||||
}, [filteredModels, activeSite, sortBy, sortDir, isUserView]);
|
||||
|
||||
/* ---- pagination ---- */
|
||||
const totalPages = Math.max(1, Math.ceil(detailModels.length / pageSize));
|
||||
@@ -395,10 +416,15 @@ export default function Models() {
|
||||
: null;
|
||||
|
||||
/* ---- copy ---- */
|
||||
const copyName = (name: string) => {
|
||||
navigator.clipboard.writeText(name).catch(() => { });
|
||||
setCopied(name);
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
const copyName = async (name: string) => {
|
||||
try {
|
||||
await copyTextToClipboard(name);
|
||||
setCopied(name);
|
||||
toast.success(tr('已复制模型名'));
|
||||
setTimeout(() => setCopied(null), 1500);
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || tr('复制模型名失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const filterControls = (
|
||||
@@ -441,27 +467,29 @@ export default function Models() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
{tr('供应商')}
|
||||
{activeSite && <button onClick={() => setActiveSite(null)}>{tr('重置')}</button>}
|
||||
</div>
|
||||
{siteMap.map(([site, count]) => (
|
||||
<div
|
||||
key={site}
|
||||
className={`filter-item ${activeSite === site ? 'active' : ''}`}
|
||||
onClick={() => setActiveSite(activeSite === site ? null : site)}
|
||||
>
|
||||
<span className="filter-item-icon" style={{ background: hashColor(site), color: 'white', fontSize: 9, borderRadius: 4 }}>
|
||||
{site.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{site}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
{!isUserView && (
|
||||
<div className="filter-panel-section">
|
||||
<div className="filter-panel-title">
|
||||
{tr('供应商')}
|
||||
{activeSite && <button onClick={() => setActiveSite(null)}>{tr('重置')}</button>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{siteMap.map(([site, count]) => (
|
||||
<div
|
||||
key={site}
|
||||
className={`filter-item ${activeSite === site ? 'active' : ''}`}
|
||||
onClick={() => setActiveSite(activeSite === site ? null : site)}
|
||||
>
|
||||
<span className="filter-item-icon" style={{ background: hashColor(site), color: 'white', fontSize: 9, borderRadius: 4 }}>
|
||||
{site.slice(0, 2).toUpperCase()}
|
||||
</span>
|
||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{site}</span>
|
||||
<span className="filter-item-count">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="filter-panel-section">
|
||||
{!isUserView && <div className="filter-panel-section">
|
||||
<div className="filter-panel-title">{tr('排序方式')}</div>
|
||||
{[
|
||||
{ key: 'accountCount' as SortColumn, label: tr('账号数') },
|
||||
@@ -490,7 +518,7 @@ export default function Models() {
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -626,15 +654,17 @@ export default function Models() {
|
||||
/>
|
||||
</div>
|
||||
{/* Quick stats */}
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-muted)', alignItems: 'center' }}>
|
||||
<span data-tooltip={tr('所有模型 accountCount 累计值,同一账号在多个模型中会重复计数')}>
|
||||
{tr('覆盖档位')} <b style={{ color: 'var(--color-text-primary)' }}>{totalCoverageSlots}</b>
|
||||
</span>
|
||||
<span data-tooltip={tr('当前筛选范围内去重后的唯一账号数')}>
|
||||
{tr('去重账号')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b>
|
||||
</span>
|
||||
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{formatLatency(avgLatency)}</b></span>
|
||||
</div>
|
||||
{!isUserView && (
|
||||
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--color-text-muted)', alignItems: 'center' }}>
|
||||
<span data-tooltip={tr('所有模型 accountCount 累计值,同一账号在多个模型中会重复计数')}>
|
||||
{tr('覆盖档位')} <b style={{ color: 'var(--color-text-primary)' }}>{totalCoverageSlots}</b>
|
||||
</span>
|
||||
<span data-tooltip={tr('当前筛选范围内去重后的唯一账号数')}>
|
||||
{tr('去重账号')} <b style={{ color: 'var(--color-text-primary)' }}>{uniqueAccountCount}</b>
|
||||
</span>
|
||||
<span>{tr('平均延迟')} <b style={{ color: getMetricColor(avgLatency) }}>{formatLatency(avgLatency)}</b></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Empty */}
|
||||
@@ -660,14 +690,14 @@ export default function Models() {
|
||||
<div className="model-card-info">
|
||||
<div className="model-card-name">{m.name}</div>
|
||||
<div className="model-card-meta">
|
||||
<span>
|
||||
{!isUserView && <span>
|
||||
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
|
||||
{m.accountCount} {tr('个账号')}
|
||||
</span>
|
||||
<span>
|
||||
</span>}
|
||||
{!isUserView && <span>
|
||||
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" /></svg>
|
||||
{m.tokenCount} {tr('令牌')}
|
||||
</span>
|
||||
</span>}
|
||||
<span
|
||||
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
|
||||
style={{ fontVariantNumeric: 'tabular-nums' }}
|
||||
@@ -685,7 +715,16 @@ export default function Models() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="model-card-actions" onClick={e => e.stopPropagation()}>
|
||||
<button className="model-card-action-btn" data-tooltip={tr('复制模型名')} aria-label={tr('复制模型名')} onClick={() => copyName(m.name)}>
|
||||
<button
|
||||
type="button"
|
||||
className="model-card-action-btn"
|
||||
data-tooltip={tr('复制模型名')}
|
||||
aria-label={tr('复制模型名')}
|
||||
onClick={(event) => {
|
||||
event?.stopPropagation?.();
|
||||
void copyName(m.name);
|
||||
}}
|
||||
>
|
||||
{copied === m.name ? (
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
) : (
|
||||
@@ -709,7 +748,7 @@ export default function Models() {
|
||||
{getBrand(m.name) && (
|
||||
<span className="model-tag model-tag-purple">{getBrand(m.name)!.name}</span>
|
||||
)}
|
||||
{m.accounts.map(a => a.site).filter((v, i, arr) => arr.indexOf(v) === i).map(site => (
|
||||
{!isUserView && m.accounts.map(a => a.site).filter((v, i, arr) => arr.indexOf(v) === i).map(site => (
|
||||
<span key={site} className="model-tag model-tag-blue">{site}</span>
|
||||
))}
|
||||
{m.successRate != null && m.successRate >= 90 && (
|
||||
@@ -750,35 +789,48 @@ export default function Models() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card" style={{ padding: 10 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('分组计费')}</div>
|
||||
{m.pricingSources.length > 0 ? (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{m.pricingSources.map((source) => (
|
||||
<div
|
||||
key={`${source.siteId}-${source.accountId}`}
|
||||
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
|
||||
>
|
||||
<div style={{ fontSize: 12, marginBottom: 6 }}>
|
||||
<SiteBadgeLink siteId={source.siteId} siteName={source.siteName} badgeStyle={{ fontSize: 11 }} /> · {source.username || `ID:${source.accountId}`}
|
||||
{!isUserView && (
|
||||
<div className="card" style={{ padding: 10 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, marginBottom: 6 }}>{tr('分组计费')}</div>
|
||||
{m.pricingSources.length > 0 ? (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
{m.pricingSources.map((source) => (
|
||||
<div
|
||||
key={`${source.siteId}-${source.accountId}`}
|
||||
style={{ border: '1px solid var(--color-border-light)', borderRadius: 8, padding: 8 }}
|
||||
>
|
||||
<div style={{ fontSize: 12, marginBottom: 6 }}>
|
||||
{isUserView ? tr('隐藏账号详情') : <><SiteBadgeLink siteId={source.siteId} siteName={source.siteName} badgeStyle={{ fontSize: 11 }} /> · {source.username || `ID:${source.accountId}`}</>}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{Object.entries(source.groupPricing).map(([group, pricing]) => (
|
||||
<span key={group} className="badge badge-info">
|
||||
{group}: {renderGroupPricingValue(pricing)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
{Object.entries(source.groupPricing).map(([group, pricing]) => (
|
||||
<span key={group} className="badge badge-info">
|
||||
{group}: {renderGroupPricingValue(pricing)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="badge badge-muted">{metadataHydrating ? tr('正在加载价格元数据...') : tr('暂无价格元数据')}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="badge badge-muted">{metadataHydrating ? tr('正在加载价格元数据...') : tr('暂无价格元数据')}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
{isUserView ? (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-secondary)' }}>{tr('品牌')}</div>
|
||||
<div className="card" style={{ padding: 10 }}>
|
||||
{getBrand(m.name) ? (
|
||||
<span className="model-tag model-tag-purple">{getBrand(m.name)!.name}</span>
|
||||
) : (
|
||||
<span className="badge badge-muted">{tr('未识别品牌')}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : isMobile ? (
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--color-text-secondary)' }}>{tr('账号明细')}</div>
|
||||
{m.accounts.map((a) => (
|
||||
@@ -828,8 +880,8 @@ export default function Models() {
|
||||
<tbody>
|
||||
{m.accounts.map(a => (
|
||||
<tr key={a.id}>
|
||||
<td><SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} /></td>
|
||||
<td style={{ fontSize: 12 }}>{a.username || `ID:${a.id}`}</td>
|
||||
<td>{isUserView ? <span className="badge badge-muted">{tr('隐藏')}</span> : <SiteBadgeLink siteId={siteIdByName.get(a.site)} siteName={a.site} badgeClassName="badge badge-info" badgeStyle={{ fontSize: 11 }} />}</td>
|
||||
<td style={{ fontSize: 12 }}>{isUserView ? tr('隐藏') : (a.username || `ID:${a.id}`)}</td>
|
||||
<td style={{ display: 'flex', gap: 4, flexWrap: 'wrap' }}>
|
||||
{a.tokens.length > 0 ? a.tokens.map(t => (
|
||||
<span key={t.id} className={`badge ${t.isDefault ? 'badge-success' : 'badge-muted'}`} style={{ fontSize: 11 }}>{t.name}</span>
|
||||
@@ -864,12 +916,16 @@ export default function Models() {
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('name'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
|
||||
{tr('模型名称')} {sortBy === 'name' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
|
||||
</th>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('accountCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
|
||||
{tr('账号数')} {sortBy === 'accountCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
|
||||
</th>
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('tokenCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
|
||||
{tr('令牌数')} {sortBy === 'tokenCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
|
||||
</th>
|
||||
{!isUserView && (
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('accountCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
|
||||
{tr('账号数')} {sortBy === 'accountCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
|
||||
</th>
|
||||
)}
|
||||
{!isUserView && (
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('tokenCount'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
|
||||
{tr('令牌数')} {sortBy === 'tokenCount' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
|
||||
</th>
|
||||
)}
|
||||
<th style={{ cursor: 'pointer' }} onClick={() => { setSortBy('avgLatency'); setSortDir(d => d === 'asc' ? 'desc' : 'asc'); }}>
|
||||
{tr('延迟')} {sortBy === 'avgLatency' ? (sortDir === 'desc' ? '↓' : '↑') : ''}
|
||||
</th>
|
||||
@@ -893,8 +949,8 @@ export default function Models() {
|
||||
{m.name}
|
||||
</code>
|
||||
</td>
|
||||
<td><span className="badge badge-info">{m.accountCount}</span></td>
|
||||
<td><span className="badge badge-muted">{m.tokenCount}</span></td>
|
||||
{!isUserView && <td><span className="badge badge-info">{m.accountCount}</span></td>}
|
||||
{!isUserView && <td><span className="badge badge-muted">{m.tokenCount}</span></td>}
|
||||
<td>
|
||||
<span
|
||||
className={`badge ${getLatencyBadgeClass(m.avgLatency)}`}
|
||||
@@ -912,7 +968,16 @@ export default function Models() {
|
||||
</span>
|
||||
</td>
|
||||
<td onClick={e => e.stopPropagation()}>
|
||||
<button className="model-card-action-btn" data-tooltip={tr('复制')} aria-label={tr('复制')} onClick={() => copyName(m.name)}>
|
||||
<button
|
||||
type="button"
|
||||
className="model-card-action-btn"
|
||||
data-tooltip={tr('复制')}
|
||||
aria-label={tr('复制')}
|
||||
onClick={(event) => {
|
||||
event?.stopPropagation?.();
|
||||
void copyName(m.name);
|
||||
}}
|
||||
>
|
||||
{copied === m.name ? (
|
||||
<svg width="12" height="12" fill="none" viewBox="0 0 24 24" stroke="var(--color-success)"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /></svg>
|
||||
) : (
|
||||
@@ -923,7 +988,7 @@ export default function Models() {
|
||||
</tr>
|
||||
{isExpanded ? (
|
||||
<tr className="log-detail-row">
|
||||
<td colSpan={7} style={{ padding: 0 }}>
|
||||
<td colSpan={isUserView ? 5 : 7} style={{ padding: 0 }}>
|
||||
<div className="anim-collapse is-open">
|
||||
<div className="anim-collapse-inner">
|
||||
<div style={{ padding: '12px 16px 12px 54px' }}>
|
||||
|
||||
+390
-13
@@ -13,6 +13,36 @@ type AgentImage = {
|
||||
};
|
||||
|
||||
type PictureMode = 'generate' | 'edit';
|
||||
type PictureJobStatus = 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled';
|
||||
|
||||
type PictureJob = {
|
||||
id: string;
|
||||
status: PictureJobStatus | string;
|
||||
model?: string | null;
|
||||
message?: string | null;
|
||||
error?: string | null;
|
||||
progress?: number;
|
||||
progressTotal?: number;
|
||||
result?: {
|
||||
prompt?: string;
|
||||
model?: string;
|
||||
mode?: PictureMode | string;
|
||||
response?: any;
|
||||
images?: any[];
|
||||
};
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
finishedAt?: string | null;
|
||||
};
|
||||
|
||||
function staleRunningHint(job: PictureJob) {
|
||||
if (job.status !== 'pending' && job.status !== 'running') return '';
|
||||
const updatedAt = parseServerTime(job.updatedAt || job.createdAt);
|
||||
if (!Number.isFinite(updatedAt)) return '';
|
||||
const ageMs = Date.now() - updatedAt;
|
||||
if (ageMs < 180_000) return '';
|
||||
return '图片模型响应时间较长,任务仍在后台等待。大型提示词或高峰期通常需要 3-5 分钟。';
|
||||
}
|
||||
|
||||
type PictureAgentStatus = {
|
||||
success?: boolean;
|
||||
@@ -47,6 +77,7 @@ type GalleryItem = {
|
||||
|
||||
const DEFAULT_PROMPT = 'A precise editorial product photo of a translucent glass robot painting a small neon city, sharp focus, clean background';
|
||||
const TEMPLATE_GALLERY_JSON_URL = 'https://weiyyin.github.io/linux-do-gpt-image/linux_do_2036927_prompts_images.filtered.json';
|
||||
const TEMPLATE_LIMIT = 144;
|
||||
const QUALITY_OPTIONS = ['auto', 'low', 'medium', 'high'];
|
||||
const ASPECT_OPTIONS = [
|
||||
{ value: '1:1', label: '1:1 方图', width: 1024, height: 1024 },
|
||||
@@ -84,7 +115,13 @@ function makeId() {
|
||||
}
|
||||
|
||||
function extractImageUrls(payload: any): string[] {
|
||||
const data = Array.isArray(payload?.data) ? payload.data : [];
|
||||
const data = Array.isArray(payload?.data)
|
||||
? payload.data
|
||||
: Array.isArray(payload?.response?.data)
|
||||
? payload.response.data
|
||||
: Array.isArray(payload?.images)
|
||||
? payload.images
|
||||
: [];
|
||||
return data
|
||||
.map((item: any) => {
|
||||
if (typeof item?.url === 'string' && item.url.trim()) return item.url.trim();
|
||||
@@ -112,6 +149,28 @@ function getFilename(image: AgentImage) {
|
||||
return `shengtu-${safeModel}-${new Date(image.createdAt).toISOString().slice(0, 19).replace(/[:T]/g, '-')}.png`;
|
||||
}
|
||||
|
||||
function parseServerTime(value?: string | null): number {
|
||||
if (!value) return Number.NaN;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return Number.NaN;
|
||||
const normalized = trimmed.includes('T') ? trimmed : trimmed.replace(' ', 'T');
|
||||
const withTimezone = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(normalized)
|
||||
? normalized
|
||||
: `${normalized}Z`;
|
||||
return Date.parse(withTimezone);
|
||||
}
|
||||
|
||||
function formatLocalTime(value: number | string | null | undefined): string {
|
||||
const timestamp = typeof value === 'number' ? value : parseServerTime(value);
|
||||
if (!Number.isFinite(timestamp)) return '-';
|
||||
return new Date(timestamp).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
}
|
||||
|
||||
function clampDimension(value: number) {
|
||||
if (!Number.isFinite(value)) return 1024;
|
||||
return Math.min(4096, Math.max(256, Math.round(value)));
|
||||
@@ -168,7 +227,62 @@ function templateSize(prompt: string) {
|
||||
return 'auto';
|
||||
}
|
||||
|
||||
function normalizeGalleryItems(items: GalleryItem[], limit = 48): PromptTemplate[] {
|
||||
function isPictureModel(model: string) {
|
||||
const normalized = model.trim().toLowerCase();
|
||||
if (!normalized) return false;
|
||||
const imageKeywords = [
|
||||
'gpt-image',
|
||||
'dall-e',
|
||||
'dalle',
|
||||
'midjourney',
|
||||
'recraft',
|
||||
'kolors',
|
||||
'cogview',
|
||||
'seedream',
|
||||
'sdxl',
|
||||
'stable-diffusion',
|
||||
'stablediffusion',
|
||||
'pixart',
|
||||
'playground',
|
||||
'firefly',
|
||||
'imagen',
|
||||
'flux',
|
||||
'image',
|
||||
'img',
|
||||
'canvas',
|
||||
'painting',
|
||||
'draw',
|
||||
];
|
||||
if (imageKeywords.some((keyword) => normalized.includes(keyword))) return true;
|
||||
const chatKeywords = [
|
||||
'claude',
|
||||
'chatgpt',
|
||||
'gpt-4',
|
||||
'gpt-3',
|
||||
'gpt-5',
|
||||
'o1',
|
||||
'o3',
|
||||
'o4',
|
||||
'gemini',
|
||||
'deepseek',
|
||||
'qwen',
|
||||
'llama',
|
||||
'mistral',
|
||||
'command',
|
||||
'kimi',
|
||||
'doubao',
|
||||
'glm',
|
||||
'hunyuan',
|
||||
'spark',
|
||||
'baichuan',
|
||||
'ernie',
|
||||
'wenxin',
|
||||
'grok',
|
||||
];
|
||||
return !chatKeywords.some((keyword) => normalized.includes(keyword));
|
||||
}
|
||||
|
||||
function normalizeGalleryItems(items: GalleryItem[], limit = TEMPLATE_LIMIT): PromptTemplate[] {
|
||||
return items
|
||||
.map<PromptTemplate | null>((item) => {
|
||||
const prompt = (item.prompt || '').trim();
|
||||
@@ -194,7 +308,7 @@ function normalizeGalleryItems(items: GalleryItem[], limit = 48): PromptTemplate
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async function loadGalleryTemplatesDirectly(limit = 48): Promise<PromptTemplate[]> {
|
||||
async function loadGalleryTemplatesDirectly(limit = TEMPLATE_LIMIT): Promise<PromptTemplate[]> {
|
||||
const response = await fetch(TEMPLATE_GALLERY_JSON_URL, { cache: 'no-store' });
|
||||
if (!response.ok) throw new Error(`gallery HTTP ${response.status}`);
|
||||
const items = await response.json();
|
||||
@@ -221,20 +335,68 @@ export default function PictureAgent() {
|
||||
const [sourceFile, setSourceFile] = useState<File | null>(null);
|
||||
const [sourcePreview, setSourcePreview] = useState('');
|
||||
const [images, setImages] = useState<AgentImage[]>([]);
|
||||
const [jobs, setJobs] = useState<PictureJob[]>([]);
|
||||
const [selectedId, setSelectedId] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [cancellingJobId, setCancellingJobId] = useState('');
|
||||
const [clearingJobs, setClearingJobs] = useState(false);
|
||||
const [jobDialogOpen, setJobDialogOpen] = useState(false);
|
||||
const [bootError, setBootError] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const selectedImage = images.find((image) => image.id === selectedId) ?? images[0] ?? null;
|
||||
const activeJobs = jobs.filter((job) => job.status === 'pending' || job.status === 'running');
|
||||
const modeLabel = mode === 'edit' ? '图片编辑' : '文生图';
|
||||
const resultLabel = selectedImage?.mode === 'edit' ? '编辑结果' : '生成结果';
|
||||
const resolvedSize = buildSize(aspect, customWidth, customHeight);
|
||||
const latestJob = jobs[0] ?? null;
|
||||
const latestJobLabel = latestJob?.status === 'running'
|
||||
? '正在处理最新任务'
|
||||
: latestJob?.status === 'pending'
|
||||
? '最新任务已排队'
|
||||
: latestJob?.status === 'failed'
|
||||
? '最新任务失败'
|
||||
: latestJob?.status === 'cancelled'
|
||||
? '最新任务已取消'
|
||||
: latestJob?.status === 'succeeded'
|
||||
? '最新任务已完成'
|
||||
: '暂无最新任务';
|
||||
const terminalJobs = useMemo(
|
||||
() => jobs.filter((job) => job.status === 'succeeded' || job.status === 'failed' || job.status === 'cancelled'),
|
||||
[jobs],
|
||||
);
|
||||
|
||||
const authedFetch = useCallback((url: string, options: RequestInit & { timeoutMs?: number } = {}) => {
|
||||
return fetchSessionResponse(audience, url, options);
|
||||
}, [audience]);
|
||||
|
||||
const hydrateImagesFromJobs = useCallback((items: PictureJob[]) => {
|
||||
const completed = items.filter((job) => job.status === 'succeeded');
|
||||
if (!completed.length) return;
|
||||
setImages((current) => {
|
||||
const seen = new Set(current.map((image) => image.url));
|
||||
const next: AgentImage[] = [];
|
||||
for (const job of completed) {
|
||||
const urls = extractImageUrls(job.result || {});
|
||||
for (const url of urls) {
|
||||
if (seen.has(url)) continue;
|
||||
seen.add(url);
|
||||
next.push({
|
||||
id: makeId(),
|
||||
url,
|
||||
prompt: job.result?.prompt || '',
|
||||
model: job.result?.model || model || 'image',
|
||||
mode: job.result?.mode === 'edit' ? 'edit' : 'generate',
|
||||
createdAt: parseServerTime(job.finishedAt) || Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!next.length) return current;
|
||||
setSelectedId((selected) => selected || next[0]?.id || '');
|
||||
return [...next, ...current].slice(0, 48);
|
||||
});
|
||||
}, [model]);
|
||||
|
||||
useEffect(() => {
|
||||
let ignore = false;
|
||||
async function load() {
|
||||
@@ -247,10 +409,11 @@ export default function PictureAgent() {
|
||||
setTemplatesLoading(true);
|
||||
setTemplatesError('');
|
||||
try {
|
||||
const [statusRes, modelsRes, templatesRes] = await Promise.all([
|
||||
const [statusRes, modelsRes, templatesRes, jobsRes] = await Promise.all([
|
||||
authedFetch('/api/picture-agent/status', { timeoutMs: 15_000 }),
|
||||
authedFetch('/api/picture-agent/models', { timeoutMs: 15_000 }),
|
||||
authedFetch('/api/picture-agent/templates?limit=48', { timeoutMs: 30_000 }),
|
||||
authedFetch(`/api/picture-agent/templates?limit=${TEMPLATE_LIMIT}`, { timeoutMs: 30_000 }),
|
||||
authedFetch('/api/picture-agent/jobs?limit=20', { timeoutMs: 15_000 }),
|
||||
]);
|
||||
if (!statusRes.ok) throw new Error(await parseError(statusRes));
|
||||
if (!modelsRes.ok) throw new Error(await parseError(modelsRes));
|
||||
@@ -262,17 +425,32 @@ export default function PictureAgent() {
|
||||
nextTemplates = Array.isArray(templatePayload?.templates) ? templatePayload.templates : [];
|
||||
} else {
|
||||
try {
|
||||
nextTemplates = await loadGalleryTemplatesDirectly(48);
|
||||
nextTemplates = await loadGalleryTemplatesDirectly(TEMPLATE_LIMIT);
|
||||
} catch {
|
||||
setTemplatesError(await parseError(templatesRes));
|
||||
}
|
||||
}
|
||||
let nextJobs: PictureJob[] = [];
|
||||
if (jobsRes.ok) {
|
||||
const jobsPayload = await jobsRes.json();
|
||||
nextJobs = Array.isArray(jobsPayload?.jobs) ? jobsPayload.jobs : [];
|
||||
}
|
||||
if (ignore) return;
|
||||
const availableModels = Array.isArray(nextModels?.models) ? nextModels.models : [];
|
||||
const availableModels = Array.isArray(nextModels?.models)
|
||||
? nextModels.models.filter((item: string) => isPictureModel(item))
|
||||
: [];
|
||||
setStatus(nextStatus);
|
||||
setModels(availableModels);
|
||||
setTemplates(nextTemplates);
|
||||
setModel((current) => current || nextModels?.defaultModel || availableModels[0] || 'gpt-image-2');
|
||||
setJobs(nextJobs);
|
||||
hydrateImagesFromJobs(nextJobs);
|
||||
setModel((current) => {
|
||||
if (current && isPictureModel(current)) return current;
|
||||
const fallback = nextModels?.defaultModel && isPictureModel(nextModels.defaultModel)
|
||||
? nextModels.defaultModel
|
||||
: availableModels[0];
|
||||
return fallback || 'gpt-image-2';
|
||||
});
|
||||
} catch (err: any) {
|
||||
if (!ignore) setBootError(err?.message || '生图助手初始化失败');
|
||||
} finally {
|
||||
@@ -283,7 +461,36 @@ export default function PictureAgent() {
|
||||
return () => {
|
||||
ignore = true;
|
||||
};
|
||||
}, [audience, authedFetch, token]);
|
||||
}, [audience, authedFetch, hydrateImagesFromJobs, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeJobs.length || !token) return;
|
||||
let stopped = false;
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
const updated = await Promise.all(activeJobs.map(async (job) => {
|
||||
const response = await authedFetch(`/api/picture-agent/jobs/${encodeURIComponent(job.id)}`, { timeoutMs: 15_000 });
|
||||
if (!response.ok) return job;
|
||||
const payload = await response.json();
|
||||
return payload?.job || job;
|
||||
}));
|
||||
if (stopped) return;
|
||||
setJobs((current) => {
|
||||
const map = new Map(current.map((job) => [job.id, job]));
|
||||
for (const job of updated) map.set(job.id, job);
|
||||
const next = Array.from(map.values()).sort((a, b) => String(b.createdAt || '').localeCompare(String(a.createdAt || '')));
|
||||
hydrateImagesFromJobs(next);
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// Keep polling quietly; task status is also visible after refresh.
|
||||
}
|
||||
}, 1800);
|
||||
return () => {
|
||||
stopped = true;
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [activeJobs, authedFetch, hydrateImagesFromJobs, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sourceFile) {
|
||||
@@ -322,7 +529,7 @@ export default function PictureAgent() {
|
||||
timeoutMs: 180_000,
|
||||
});
|
||||
} else {
|
||||
response = await authedFetch('/api/picture-agent/images/generations', {
|
||||
response = await authedFetch('/api/picture-agent/jobs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
model: model.trim(),
|
||||
@@ -330,12 +537,17 @@ export default function PictureAgent() {
|
||||
size: resolvedSize,
|
||||
quality,
|
||||
}),
|
||||
timeoutMs: 180_000,
|
||||
timeoutMs: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
const payload = await response.json();
|
||||
if (mode === 'generate' && payload?.job) {
|
||||
setJobs((current) => [payload.job, ...current.filter((job) => job.id !== payload.job.id)].slice(0, 20));
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
const urls = extractImageUrls(payload);
|
||||
if (urls.length === 0) throw new Error('上游没有返回图片 URL 或 base64 图片。');
|
||||
|
||||
@@ -357,6 +569,56 @@ export default function PictureAgent() {
|
||||
}
|
||||
};
|
||||
|
||||
const refreshJobs = async () => {
|
||||
const response = await authedFetch('/api/picture-agent/jobs?limit=20', { timeoutMs: 15_000 });
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
const payload = await response.json();
|
||||
const nextJobs = Array.isArray(payload?.jobs) ? payload.jobs : [];
|
||||
setJobs(nextJobs);
|
||||
hydrateImagesFromJobs(nextJobs);
|
||||
};
|
||||
|
||||
const clearJobs = async () => {
|
||||
if (clearingJobs) return;
|
||||
setClearingJobs(true);
|
||||
setError('');
|
||||
try {
|
||||
const response = await authedFetch('/api/picture-agent/jobs/clear', {
|
||||
method: 'POST',
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
await refreshJobs();
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '清空日志失败');
|
||||
} finally {
|
||||
setClearingJobs(false);
|
||||
}
|
||||
};
|
||||
|
||||
const cancelJob = async (jobId: string) => {
|
||||
if (!jobId || cancellingJobId) return;
|
||||
setCancellingJobId(jobId);
|
||||
setError('');
|
||||
try {
|
||||
const response = await authedFetch(`/api/picture-agent/jobs/${encodeURIComponent(jobId)}/cancel`, {
|
||||
method: 'POST',
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
if (!response.ok) throw new Error(await parseError(response));
|
||||
const payload = await response.json();
|
||||
if (payload?.job) {
|
||||
setJobs((current) => current.map((job) => job.id === jobId ? payload.job : job));
|
||||
} else {
|
||||
await refreshJobs();
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '取消任务失败');
|
||||
} finally {
|
||||
setCancellingJobId('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = (image: AgentImage | null) => {
|
||||
if (!image) return;
|
||||
const link = document.createElement('a');
|
||||
@@ -390,6 +652,59 @@ export default function PictureAgent() {
|
||||
setError('');
|
||||
};
|
||||
|
||||
const renderJobCard = (job: PictureJob) => {
|
||||
const total = Math.max(1, Number(job.progressTotal || 4));
|
||||
const progress = Math.min(total, Math.max(0, Number(job.progress || 0)));
|
||||
const percent = Math.round((progress / total) * 100);
|
||||
const hint = staleRunningHint(job);
|
||||
const isActive = job.status === 'pending' || job.status === 'running';
|
||||
const isFinished = !isActive;
|
||||
const statusText = job.status === 'succeeded'
|
||||
? '完成'
|
||||
: job.status === 'failed'
|
||||
? '失败'
|
||||
: job.status === 'cancelled'
|
||||
? '已取消'
|
||||
: `${percent}%`;
|
||||
return (
|
||||
<div key={job.id} className={`picture-agent-job picture-agent-job-${job.status}`}>
|
||||
<div className="picture-agent-job-row picture-agent-job-row-top">
|
||||
<div className="picture-agent-job-title-wrap">
|
||||
<strong>{job.result?.prompt || job.message || '生图任务'}</strong>
|
||||
<span>{job.model || job.result?.model || '未知模型'}</span>
|
||||
</div>
|
||||
<span className={`picture-agent-job-status picture-agent-job-status-${job.status}`}>{statusText}</span>
|
||||
</div>
|
||||
<div className="picture-agent-job-progress-line">
|
||||
<div className="picture-agent-job-progress-labels">
|
||||
<span>{job.message || job.id}</span>
|
||||
<em>{percent}%</em>
|
||||
</div>
|
||||
<div className="picture-agent-job-progress">
|
||||
<span style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="picture-agent-job-meta">
|
||||
<span>{formatLocalTime(job.createdAt)}</span>
|
||||
{job.error ? <em>{job.error}</em> : <span>{isFinished ? '已结束' : '进行中'}</span>}
|
||||
</div>
|
||||
{hint ? <div className="picture-agent-job-hint">{hint}</div> : null}
|
||||
{isActive ? (
|
||||
<button
|
||||
type="button"
|
||||
className="picture-agent-job-cancel"
|
||||
disabled={cancellingJobId === job.id}
|
||||
onClick={() => void cancelJob(job.id)}
|
||||
>
|
||||
{cancellingJobId === job.id ? '取消中...' : '取消任务'}
|
||||
</button>
|
||||
) : (
|
||||
<div className="picture-agent-job-terminal">{job.result?.images?.length ? `结果 ${job.result.images.length} 张` : '可在历史结果中查看'}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (bootError) {
|
||||
return (
|
||||
<div className="empty-state" style={{ minHeight: 320 }}>
|
||||
@@ -539,8 +854,18 @@ export default function PictureAgent() {
|
||||
|
||||
{error && <div className="alert alert-error">{error}</div>}
|
||||
<button type="button" className="btn btn-primary picture-agent-submit" disabled={!canSubmit} onClick={handleSubmit}>
|
||||
{loading ? '处理中...' : mode === 'edit' ? '编辑图片' : '生成图片'}
|
||||
{loading ? '提交中...' : mode === 'edit' ? '编辑图片' : '生成图片'}
|
||||
</button>
|
||||
|
||||
<div className="picture-agent-job-summary">
|
||||
<div>
|
||||
<strong>生图进度</strong>
|
||||
<span>{latestJobLabel} · {jobs.length} 条记录</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setJobDialogOpen(true)}>
|
||||
查看进度
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="picture-agent-canvas">
|
||||
@@ -581,7 +906,7 @@ export default function PictureAgent() {
|
||||
<div className="picture-agent-board-meta">
|
||||
<div>
|
||||
<strong>{resultLabel}</strong>
|
||||
<span>{selectedImage.model} · {new Date(selectedImage.createdAt).toLocaleTimeString()}</span>
|
||||
<span>{selectedImage.model} · {formatLocalTime(selectedImage.createdAt)}</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => handleDownload(selectedImage)}>下载</button>
|
||||
</div>
|
||||
@@ -618,6 +943,58 @@ export default function PictureAgent() {
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{jobDialogOpen && (
|
||||
<div
|
||||
className="picture-agent-job-dialog-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => {
|
||||
if (event.target === event.currentTarget) setJobDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
<section
|
||||
className="picture-agent-job-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="picture-agent-job-dialog-title"
|
||||
>
|
||||
<div className="picture-agent-job-dialog-head">
|
||||
<div>
|
||||
<strong id="picture-agent-job-dialog-title">生图进度</strong>
|
||||
<span>{latestJobLabel} · {jobs.length} 条记录</span>
|
||||
</div>
|
||||
<div className="picture-agent-job-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => {
|
||||
refreshJobs().catch((err: any) => setError(err?.message || '刷新任务失败'));
|
||||
}}
|
||||
>
|
||||
刷新
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
onClick={() => void clearJobs()}
|
||||
disabled={clearingJobs || terminalJobs.length === 0}
|
||||
>
|
||||
{clearingJobs ? '清理中...' : '清空日志'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setJobDialogOpen(false)}>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{jobs.length === 0 ? (
|
||||
<div className="picture-agent-job-empty">提交后会先进入队列,完成后会自动出现在这里和使用记录里。</div>
|
||||
) : (
|
||||
<div className="picture-agent-job-list picture-agent-job-list-dialog">
|
||||
{jobs.map(renderJobCard)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ interface LogItem {
|
||||
status: string | null;
|
||||
http_status: number | null;
|
||||
latency_ms: number | null;
|
||||
first_byte_latency_ms?: number | null;
|
||||
prompt_tokens: number | null;
|
||||
completion_tokens: number | null;
|
||||
total_tokens: number | null;
|
||||
@@ -42,6 +43,23 @@ function latencyColor(ms: number | null): string {
|
||||
return 'var(--color-danger)';
|
||||
}
|
||||
|
||||
function displayLatency(log: LogItem): string {
|
||||
const firstByte = log.first_byte_latency_ms;
|
||||
const total = log.latency_ms;
|
||||
if (firstByte != null && Number.isFinite(firstByte)) {
|
||||
return `${firstByte}ms / ${total != null ? `${total}ms` : '-'}`;
|
||||
}
|
||||
return total != null ? `${total}ms` : '-';
|
||||
}
|
||||
|
||||
function latencyTextColor(log: LogItem): string {
|
||||
const firstByte = log.first_byte_latency_ms;
|
||||
if (firstByte != null && Number.isFinite(firstByte)) {
|
||||
return latencyColor(firstByte);
|
||||
}
|
||||
return latencyColor(log.latency_ms);
|
||||
}
|
||||
|
||||
export default function UsageRecords() {
|
||||
const [logs, setLogs] = useState<LogItem[]>([]);
|
||||
const [summary, setSummary] = useState<Summary | null>(null);
|
||||
@@ -181,6 +199,9 @@ export default function UsageRecords() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
延迟优先显示首字延迟,Tokens 与费用依赖上游返回的 usage 数据。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
@@ -236,8 +257,8 @@ export default function UsageRecords() {
|
||||
<span style={{ marginLeft: 4, fontSize: 10, color: 'var(--color-text-muted)' }}>流式</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ fontSize: 13, color: latencyColor(log.latency_ms), fontFamily: 'var(--font-mono)' }}>
|
||||
{log.latency_ms != null ? `${log.latency_ms}ms` : '-'}
|
||||
<td style={{ fontSize: 13, color: latencyTextColor(log), fontFamily: 'var(--font-mono)' }}>
|
||||
{displayLatency(log)}
|
||||
</td>
|
||||
<td style={{ fontSize: 12, fontFamily: 'var(--font-mono)' }}>
|
||||
{log.total_tokens != null ? log.total_tokens.toLocaleString() : '-'}
|
||||
|
||||
@@ -157,9 +157,9 @@ export default function UserDashboard({ onLogout }: { onLogout?: () => void }) {
|
||||
<section className="card" style={{ padding: '20px' }}>
|
||||
<h2 style={{ fontSize: '16px', fontWeight: 600, margin: '0 0 16px 0' }}>快速入口</h2>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '12px' }}>
|
||||
<Link to="/user/video-agent" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', height: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>
|
||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M15 10l4.553-2.276A1 1 0 0121 8.618v6.764a1 1 0 01-1.447.894L15 14M5 18h8a2 2 0 002-2V8a2 2 0 00-2-2H5a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg>
|
||||
<span>视频工作台</span>
|
||||
<Link to="/user/workflows" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', height: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>
|
||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 6h5v5H4zM15 4h5v5h-5zM15 15h5v5h-5zM9 8h6M17.5 9v6M9 11l6 6" /></svg>
|
||||
<span>工作流</span>
|
||||
</Link>
|
||||
<Link to="/user/comfyui-agent" className="btn btn-ghost" style={{ border: '1px solid var(--color-border)', height: 'auto', padding: '16px', display: 'flex', flexDirection: 'column', gap: '8px', alignItems: 'center' }}>
|
||||
<svg width="24" height="24" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M14.752 11.168l-3.197-2.132A1 1 0 0010 9.87v4.263a1 1 0 001.555.832l3.197-2.132a1 1 0 000-1.664z" /><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import { commitSession } from '../authSession.js';
|
||||
import LoginShell from './auth/LoginShell.js';
|
||||
|
||||
export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
const navigate = useNavigate();
|
||||
@@ -46,95 +47,90 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
|
||||
};
|
||||
|
||||
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
|
||||
<LoginShell
|
||||
t={(text) => text}
|
||||
variant="user"
|
||||
eyebrow="用户注册"
|
||||
title="创建账号"
|
||||
description="注册后即可使用 API 代理服务。"
|
||||
error={error}
|
||||
footer={(
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'center' }}>
|
||||
<span>
|
||||
已有账号?
|
||||
<Link to="/user/login" style={{ color: 'var(--color-primary)', textDecoration: 'none', marginLeft: 4 }}>
|
||||
登录
|
||||
</Link>
|
||||
</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
<Link to="/boos" style={{ color: 'var(--color-text-muted)' }}>← 管理员登录</Link>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<form onSubmit={handleSubmit} className="login-register-form">
|
||||
<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">邮箱</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">密码</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">确认密码</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
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || !username.trim() || !email.trim() || !password || !confirmPassword}
|
||||
className="btn btn-primary login-auth-submit"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner spinner-sm"
|
||||
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
|
||||
/>
|
||||
<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>
|
||||
注册中...
|
||||
</>
|
||||
) : (
|
||||
'注册'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</LoginShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import React from 'react';
|
||||
|
||||
export default function Workflows() {
|
||||
const [ready, setReady] = React.useState(false);
|
||||
const [message, setMessage] = React.useState('正在加载工作流前端...');
|
||||
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
|
||||
|
||||
const openFullscreen = () => {
|
||||
window.open('/workflow-ui/', '_blank', 'noopener,noreferrer');
|
||||
};
|
||||
|
||||
const reloadFrame = () => {
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = '/workflow-ui/';
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
void fetch('/workflow-ui/', { signal: controller.signal })
|
||||
.then((response) => {
|
||||
if (cancelled) return;
|
||||
setReady(response.ok);
|
||||
setMessage(response.ok ? '' : 'Workflow Studio 前端未构建,请先执行 workflow-ui:install');
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled || error?.name === 'AbortError') return;
|
||||
setReady(false);
|
||||
setMessage('Workflow Studio 前端未构建,请先执行 workflow-ui:install');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={toolbarStyle}>
|
||||
<div style={toolbarTitleStyle}>工作流</div>
|
||||
<div style={toolbarActionsStyle}>
|
||||
<button type="button" onClick={reloadFrame} disabled={!ready} style={toolbarButtonStyle}>
|
||||
刷新
|
||||
</button>
|
||||
<button type="button" onClick={openFullscreen} style={primaryToolbarButtonStyle}>
|
||||
新窗口打开
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{ready ? (
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src="/workflow-ui/"
|
||||
title="Workflow Studio"
|
||||
allow="clipboard-read; clipboard-write"
|
||||
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
|
||||
/>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: 'grid', placeItems: 'center', padding: 24, textAlign: 'center' }}>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, marginBottom: 8 }}>Workflow Studio</div>
|
||||
<div style={{ color: 'var(--color-text-secondary)', lineHeight: 1.7 }}>{message}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const toolbarStyle: React.CSSProperties = {
|
||||
minHeight: 44,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '8px 12px',
|
||||
borderBottom: '1px solid var(--color-border-light)',
|
||||
background: 'var(--color-surface)',
|
||||
};
|
||||
|
||||
const toolbarTitleStyle: React.CSSProperties = {
|
||||
fontSize: 14,
|
||||
fontWeight: 800,
|
||||
color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const toolbarActionsStyle: React.CSSProperties = {
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
};
|
||||
|
||||
const toolbarButtonStyle: React.CSSProperties = {
|
||||
minHeight: 30,
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 8,
|
||||
padding: '5px 10px',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
};
|
||||
|
||||
const primaryToolbarButtonStyle: React.CSSProperties = {
|
||||
...toolbarButtonStyle,
|
||||
borderColor: 'var(--color-primary)',
|
||||
background: 'var(--color-primary)',
|
||||
color: '#fff',
|
||||
fontWeight: 700,
|
||||
};
|
||||
@@ -2,13 +2,14 @@ import React, { CSSProperties, useCallback, useEffect, useMemo, useState } from
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { api, fetchSessionResponse, userApi, type WorkspaceAsset } from '../api.js';
|
||||
import type { SessionAudience } from '../authSession.js';
|
||||
import { formatDateTimeLocal } from './helpers/checkinLogTime.js';
|
||||
|
||||
const SOURCE_OPTIONS = [
|
||||
{ value: 'all', label: '全部来源' },
|
||||
{ value: 'picture-agent', label: '生图助手' },
|
||||
{ value: 'comfyui-agent', label: '视频助手' },
|
||||
{ value: 'video-agent', label: '视频工作台' },
|
||||
{ value: 'comfyui', label: 'ComfyUI' },
|
||||
{ value: 'workflow-studio', label: '工作流' },
|
||||
{ value: 'comfyui', label: '外部 ComfyUI' },
|
||||
];
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
@@ -46,6 +47,10 @@ function assetTitle(asset: WorkspaceAsset) {
|
||||
return asset.title || asset.prompt || asset.fileName || asset.publicId;
|
||||
}
|
||||
|
||||
function assetCreatedAtLabel(asset: WorkspaceAsset) {
|
||||
return formatDateTimeLocal(asset.createdAt);
|
||||
}
|
||||
|
||||
export default function Workspace() {
|
||||
const location = useLocation();
|
||||
const audience: SessionAudience = location.pathname.startsWith('/user/') ? 'user' : 'admin';
|
||||
@@ -167,7 +172,7 @@ export default function Workspace() {
|
||||
<div>
|
||||
<h1 className="page-title">工作空间</h1>
|
||||
<p className="page-subtitle">
|
||||
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'} · 自动归档生图助手、视频助手和 ComfyUI 产出
|
||||
{audience === 'user' ? '你的创作成品库' : '全部用户的创作成品库'} · 自动归档生图助手、视频助手、工作流和外部 ComfyUI 产出
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-actions">
|
||||
@@ -235,7 +240,7 @@ export default function Workspace() {
|
||||
{items.length === 0 ? (
|
||||
<div className="empty-state" style={{ minHeight: 360, gridColumn: '1 / -1' }}>
|
||||
<div className="empty-state-title">{loading ? '正在加载工作空间' : '暂无成品产出'}</div>
|
||||
<div className="empty-state-desc">生图助手、视频助手和 ComfyUI 的产出会归档到这里。</div>
|
||||
<div className="empty-state-desc">生图助手、视频助手、工作流和外部 ComfyUI 的产出会归档到这里。</div>
|
||||
</div>
|
||||
) : items.map((asset) => {
|
||||
const preview = previewUrls[asset.publicId] || asset.remoteUrl || '';
|
||||
@@ -267,8 +272,8 @@ export default function Workspace() {
|
||||
<strong style={cardTitleStyle}>{assetTitle(asset)}</strong>
|
||||
<span style={metaStyle}>{sourceLabel(asset.source)} · {asset.model || '未记录模型'}</span>
|
||||
<div style={cardFooterStyle}>
|
||||
<span style={statusPillStyle}>{statusLabel(asset.status)}</span>
|
||||
<span style={metaStyle}>{asset.createdAt || ''}</span>
|
||||
<span style={statusPillStyle}>{statusLabel(asset.status)}</span>
|
||||
<span style={metaStyle}>{assetCreatedAtLabel(asset)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -303,6 +308,7 @@ export default function Workspace() {
|
||||
<div><span>模型</span><strong>{selected.model || '未记录'}</strong></div>
|
||||
<div><span>大小</span><strong>{formatBytes(selected.sizeBytes)}</strong></div>
|
||||
<div><span>文件</span><strong>{selected.hasLocalFile ? '本地归档' : '远程引用'}</strong></div>
|
||||
<div><span>时间</span><strong>{assetCreatedAtLabel(selected)}</strong></div>
|
||||
</div>
|
||||
{selected.prompt && (
|
||||
<div style={promptPanelStyle}>
|
||||
|
||||
@@ -5,16 +5,18 @@ import { ToastProvider } from '../components/Toast.js';
|
||||
import Accounts from './Accounts.js';
|
||||
import { installAccountsSnapshotCompat } from './testApiCompat.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getAccountsSnapshot: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
getAccountTokens: vi.fn(),
|
||||
addAccount: vi.fn(),
|
||||
addAccountAvailableModels: vi.fn(),
|
||||
},
|
||||
}));
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getAccounts: vi.fn(),
|
||||
getAccountsSnapshot: vi.fn(),
|
||||
getSites: vi.fn(),
|
||||
getAccountTokens: vi.fn(),
|
||||
addAccount: vi.fn(),
|
||||
addAccountAvailableModels: vi.fn(),
|
||||
checkModels: vi.fn(),
|
||||
rebuildRoutes: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
@@ -44,6 +46,15 @@ describe('Accounts CodingPlan initialization', () => {
|
||||
{ id: 10, name: 'Aliyun CodingPlan', url: 'https://coding.dashscope.aliyuncs.com/v1', platform: 'openai', status: 'active' },
|
||||
]);
|
||||
apiMock.getAccountTokens.mockResolvedValue([]);
|
||||
apiMock.checkModels.mockResolvedValue({
|
||||
message: '模型刷新成功',
|
||||
refresh: {
|
||||
status: 'success',
|
||||
modelCount: 3,
|
||||
modelsPreview: ['qwen3-coder-plus', 'qwen3.5-plus', 'glm-5'],
|
||||
},
|
||||
});
|
||||
apiMock.rebuildRoutes.mockResolvedValue({ success: true });
|
||||
apiMock.addAccount.mockResolvedValue({
|
||||
id: 18,
|
||||
siteId: 10,
|
||||
@@ -73,7 +84,7 @@ describe('Accounts CodingPlan initialization', () => {
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain('添加 API Key 连接');
|
||||
expect(rendered).toContain('阿里云 CodingPlan / OpenAI');
|
||||
expect(rendered).toContain('阿里云 Coding Plan (OpenAI)');
|
||||
expect(rendered).toContain('qwen3-coder-plus');
|
||||
|
||||
const checkedBoxes = root.root.findAll((node) => (
|
||||
@@ -110,8 +121,10 @@ describe('Accounts CodingPlan initialization', () => {
|
||||
}));
|
||||
expect(apiMock.addAccountAvailableModels).toHaveBeenCalledWith(
|
||||
18,
|
||||
expect.arrayContaining(['qwen3-coder-plus', 'qwen3.5-plus', 'glm-5']),
|
||||
['qwen3-coder-plus', 'qwen3.5-plus'],
|
||||
);
|
||||
expect(apiMock.checkModels).not.toHaveBeenCalled();
|
||||
expect(apiMock.rebuildRoutes).toHaveBeenCalledWith(false, false);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
@@ -127,6 +140,14 @@ describe('Accounts CodingPlan initialization', () => {
|
||||
tokenType: 'apikey',
|
||||
queued: false,
|
||||
});
|
||||
apiMock.checkModels.mockResolvedValue({
|
||||
message: '模型刷新成功',
|
||||
refresh: {
|
||||
status: 'success',
|
||||
modelCount: 2,
|
||||
modelsPreview: ['deepseek-chat', 'deepseek-reasoner'],
|
||||
},
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
@@ -142,7 +163,7 @@ describe('Accounts CodingPlan initialization', () => {
|
||||
await flushMicrotasks();
|
||||
|
||||
const rendered = JSON.stringify(root.toJSON());
|
||||
expect(rendered).toContain('DeepSeek / OpenAI');
|
||||
expect(rendered).toContain('DeepSeek (OpenAI)');
|
||||
expect(rendered).toContain('deepseek-chat');
|
||||
expect(rendered).toContain('deepseek-reasoner');
|
||||
|
||||
@@ -175,6 +196,8 @@ describe('Accounts CodingPlan initialization', () => {
|
||||
19,
|
||||
['deepseek-chat', 'deepseek-reasoner'],
|
||||
);
|
||||
expect(apiMock.checkModels).not.toHaveBeenCalled();
|
||||
expect(apiMock.rebuildRoutes).toHaveBeenCalledWith(false, false);
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
|
||||
@@ -31,14 +31,20 @@ async function readErrorReason(res: Response): Promise<string> {
|
||||
}
|
||||
}
|
||||
|
||||
type AdminLoginMode = 'token' | 'password';
|
||||
|
||||
export default function AdminLoginPage({ t, onLogin }: AdminLoginPageProps) {
|
||||
const [mode, setMode] = useState<AdminLoginMode>('token');
|
||||
const [token, setToken] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [loadingMode, setLoadingMode] = useState<AdminLoginMode | null>(null);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleTokenLogin = async () => {
|
||||
const handleTokenLogin = async (event?: React.FormEvent) => {
|
||||
event?.preventDefault();
|
||||
if (!token.trim()) return;
|
||||
setLoading(true);
|
||||
setLoadingMode('token');
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/settings/auth/info', {
|
||||
@@ -54,58 +60,198 @@ export default function AdminLoginPage({ t, onLogin }: AdminLoginPageProps) {
|
||||
} catch {
|
||||
setError(t(resolveLoginErrorMessage('network', '')));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingMode(null);
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit = !!token.trim();
|
||||
const handlePasswordLogin = async (event?: React.FormEvent) => {
|
||||
event?.preventDefault();
|
||||
if (!email.trim() || !password) return;
|
||||
setLoadingMode('password');
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch('/api/auth/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.trim(), password }),
|
||||
});
|
||||
const text = await res.text();
|
||||
let parsed: {
|
||||
token?: unknown;
|
||||
user?: { role?: unknown };
|
||||
data?: { token?: unknown; user?: { role?: unknown } };
|
||||
message?: unknown;
|
||||
error?: unknown;
|
||||
} = {};
|
||||
if (text) {
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
parsed = { message: text };
|
||||
}
|
||||
}
|
||||
if (!res.ok) {
|
||||
const reason =
|
||||
typeof parsed.message === 'string'
|
||||
? parsed.message
|
||||
: typeof parsed.error === 'string'
|
||||
? parsed.error
|
||||
: text;
|
||||
setError(t(resolveLoginErrorMessage(res.status, reason)));
|
||||
return;
|
||||
}
|
||||
|
||||
const jwt = typeof parsed.token === 'string' ? parsed.token : parsed.data?.token;
|
||||
const role =
|
||||
typeof parsed.user?.role === 'string' ? parsed.user.role : parsed.data?.user?.role;
|
||||
if (typeof jwt !== 'string' || !jwt) {
|
||||
setError(t('登录响应缺少访问令牌。'));
|
||||
return;
|
||||
}
|
||||
if (role !== 'admin') {
|
||||
setError(t('该账号不是管理员账号。'));
|
||||
return;
|
||||
}
|
||||
commitSession('admin', jwt);
|
||||
onLogin(jwt);
|
||||
} catch {
|
||||
setError(t(resolveLoginErrorMessage('network', '')));
|
||||
} finally {
|
||||
setLoadingMode(null);
|
||||
}
|
||||
};
|
||||
|
||||
const isLoading = loadingMode !== null;
|
||||
const canTokenSubmit = !!token.trim();
|
||||
const canPasswordSubmit = !!email.trim() && !!password;
|
||||
|
||||
return (
|
||||
<LoginShell
|
||||
t={t}
|
||||
variant="admin"
|
||||
eyebrow={t('管理员入口')}
|
||||
title={t('登录')}
|
||||
description={t('请输入管理员令牌后继续。')}
|
||||
title={t('管理员登录')}
|
||||
description={t('使用管理员令牌,或管理员账号密码进入控制台。')}
|
||||
error={error}
|
||||
footer={<span>{t('管理员登录后继续。')}</span>}
|
||||
footer={<span>{t('管理员身份用于管理站点、模型路由、用户与计费。')}</span>}
|
||||
>
|
||||
<label className="login-auth-label" htmlFor="admin-token-input">
|
||||
{t('管理员令牌')}
|
||||
</label>
|
||||
<input
|
||||
id="admin-token-input"
|
||||
type="password"
|
||||
placeholder={t('管理员令牌')}
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleTokenLogin()}
|
||||
className="login-auth-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
onClick={handleTokenLogin}
|
||||
disabled={loading || !canSubmit}
|
||||
className="btn btn-primary login-auth-submit"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner spinner-sm"
|
||||
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
|
||||
/>
|
||||
{t('验证中...')}
|
||||
</>
|
||||
) : (
|
||||
t('登录')
|
||||
)}
|
||||
</button>
|
||||
<div className="login-auth-note">
|
||||
{t('仅校验本地服务访问权限,不会把令牌发送到第三方。')}
|
||||
<div className="login-mode-tabs" role="tablist" aria-label={t('管理员登录方式')}>
|
||||
<button
|
||||
type="button"
|
||||
className={`login-mode-tab${mode === 'token' ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
setMode('token');
|
||||
setError('');
|
||||
}}
|
||||
aria-selected={mode === 'token'}
|
||||
>
|
||||
Auth Token
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`login-mode-tab${mode === 'password' ? ' active' : ''}`}
|
||||
onClick={() => {
|
||||
setMode('password');
|
||||
setError('');
|
||||
}}
|
||||
aria-selected={mode === 'password'}
|
||||
>
|
||||
{t('账号密码')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'token' ? (
|
||||
<form onSubmit={handleTokenLogin}>
|
||||
<label className="login-auth-label" htmlFor="admin-token-input">
|
||||
{t('管理员令牌')}
|
||||
</label>
|
||||
<input
|
||||
id="admin-token-input"
|
||||
type="password"
|
||||
placeholder={t('管理员令牌')}
|
||||
value={token}
|
||||
onChange={(e) => {
|
||||
setToken(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
className="login-auth-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !canTokenSubmit}
|
||||
className="btn btn-primary login-auth-submit"
|
||||
>
|
||||
{loadingMode === 'token' ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner spinner-sm"
|
||||
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
|
||||
/>
|
||||
{t('验证中...')}
|
||||
</>
|
||||
) : (
|
||||
t('进入控制台')
|
||||
)}
|
||||
</button>
|
||||
<div className="login-auth-note">
|
||||
{t('仅校验本地服务访问权限,不会把令牌发送到第三方。')}
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form onSubmit={handlePasswordLogin}>
|
||||
<label className="login-auth-label" htmlFor="admin-email-input">
|
||||
{t('管理员邮箱')}
|
||||
</label>
|
||||
<input
|
||||
id="admin-email-input"
|
||||
type="email"
|
||||
placeholder={t('管理员邮箱')}
|
||||
value={email}
|
||||
onChange={(e) => {
|
||||
setEmail(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
className="login-auth-input"
|
||||
autoComplete="username"
|
||||
/>
|
||||
<label className="login-auth-label" htmlFor="admin-password-input">
|
||||
{t('密码')}
|
||||
</label>
|
||||
<input
|
||||
id="admin-password-input"
|
||||
type="password"
|
||||
placeholder={t('密码')}
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
className="login-auth-input"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !canPasswordSubmit}
|
||||
className="btn btn-primary login-auth-submit"
|
||||
>
|
||||
{loadingMode === 'password' ? (
|
||||
<>
|
||||
<span
|
||||
className="spinner spinner-sm"
|
||||
style={{ borderTopColor: 'white', borderColor: 'rgba(255,255,255,0.3)' }}
|
||||
/>
|
||||
{t('验证中...')}
|
||||
</>
|
||||
) : (
|
||||
t('进入控制台')
|
||||
)}
|
||||
</button>
|
||||
<div className="login-auth-note">
|
||||
{t('只有 role=admin 的账号可以进入管理员控制台。')}
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</LoginShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { SITE_DOCS_URL, SITE_GITHUB_URL } from '../../docsLink.js';
|
||||
|
||||
export type LoginShellVariant = 'admin' | 'user';
|
||||
|
||||
@@ -82,31 +81,6 @@ export default function LoginShell({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="login-brand-footer">
|
||||
<a
|
||||
href={SITE_GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="login-icon-link"
|
||||
aria-label="GitHub"
|
||||
title="GitHub"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" className="login-icon-link-svg">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 2C6.48 2 2 6.59 2 12.25c0 4.53 2.87 8.38 6.84 9.73.5.1.68-.22.68-.49 0-.24-.01-1.04-.01-1.88-2.78.62-3.37-1.22-3.37-1.22-.45-1.2-1.11-1.52-1.11-1.52-.91-.64.07-.63.07-.63 1 .07 1.53 1.06 1.53 1.06.9 1.57 2.35 1.12 2.92.85.09-.67.35-1.12.64-1.38-2.22-.26-4.55-1.15-4.55-5.13 0-1.13.39-2.05 1.03-2.77-.1-.26-.45-1.31.1-2.73 0 0 .84-.28 2.75 1.06A9.3 9.3 0 0 1 12 6.91c.85 0 1.71.12 2.51.35 1.91-1.34 2.75-1.06 2.75-1.06.55 1.42.2 2.47.1 2.73.64.72 1.03 1.64 1.03 2.77 0 3.99-2.34 4.86-4.57 5.12.36.33.68.97.68 1.96 0 1.42-.01 2.56-.01 2.91 0 .27.18.59.69.49A10.27 10.27 0 0 0 22 12.25C22 6.59 17.52 2 12 2Z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href={SITE_DOCS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="login-doc-link"
|
||||
>
|
||||
{t('部署文档')}
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Models from './Models.js';
|
||||
|
||||
const { apiMock, toastMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getModelsMarketplace: vi.fn(),
|
||||
},
|
||||
toastMock: {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({ api: apiMock }));
|
||||
vi.mock('../components/Toast.js', () => ({
|
||||
ToastProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
useToast: () => toastMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => (
|
||||
typeof child === 'string' ? child : collectText(child)
|
||||
)).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Models copy action', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getModelsMarketplace.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
name: 'doubao-seedance-2-0-260128',
|
||||
accountCount: 1,
|
||||
tokenCount: 1,
|
||||
avgLatency: null,
|
||||
successRate: null,
|
||||
description: null,
|
||||
tags: [],
|
||||
supportedEndpointTypes: [],
|
||||
pricingSources: [],
|
||||
accounts: [],
|
||||
},
|
||||
],
|
||||
});
|
||||
Object.defineProperty(globalThis.navigator, 'clipboard', {
|
||||
value: { writeText: vi.fn().mockResolvedValue(undefined) },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('copies the model name from the action button', async () => {
|
||||
let root!: ReturnType<typeof create>;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/models']}>
|
||||
<ToastProvider>
|
||||
<Models />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const copyBtn = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& node.props['aria-label'] === '复制模型名'
|
||||
));
|
||||
await act(async () => {
|
||||
await copyBtn.props.onClick();
|
||||
});
|
||||
|
||||
expect(globalThis.navigator.clipboard.writeText).toHaveBeenCalledWith('doubao-seedance-2-0-260128');
|
||||
expect(toastMock.success).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../components/Toast.js';
|
||||
import Models from './Models.js';
|
||||
|
||||
const { apiMock, userApiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getModelsMarketplace: vi.fn(),
|
||||
},
|
||||
userApiMock: {
|
||||
getModelsMarketplace: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
userApi: userApiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => (
|
||||
typeof child === 'string' ? child : collectText(child)
|
||||
)).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('Models user view', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
userApiMock.getModelsMarketplace.mockResolvedValue({
|
||||
models: [
|
||||
{
|
||||
name: 'gpt-4o',
|
||||
accountCount: 1,
|
||||
tokenCount: 1,
|
||||
avgLatency: null,
|
||||
successRate: null,
|
||||
description: null,
|
||||
tags: [],
|
||||
supportedEndpointTypes: [],
|
||||
pricingSources: [],
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
site: 'Site A',
|
||||
username: 'tester',
|
||||
latency: null,
|
||||
balance: 1,
|
||||
tokens: [{ id: 1, name: 'default', isDefault: true }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('hides vendor and account details for user path', async () => {
|
||||
let root!: ReturnType<typeof create>;
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/user/models']}>
|
||||
<ToastProvider>
|
||||
<Models />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('模型广场');
|
||||
expect(text).not.toContain('供应商');
|
||||
expect(text).not.toContain('排序方式');
|
||||
expect(text).not.toContain('账号数');
|
||||
expect(text).not.toContain('令牌数');
|
||||
expect(text).not.toContain('Site A');
|
||||
expect(text).not.toContain('tester');
|
||||
expect(userApiMock.getModelsMarketplace).toHaveBeenCalled();
|
||||
expect(apiMock.getModelsMarketplace).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user