feat: polish workflow studio and workspace assets

This commit is contained in:
boos
2026-05-25 20:42:38 +08:00
parent cb35d5fc6e
commit 1ba293c262
29 changed files with 2299 additions and 158 deletions
+7 -1
View File
@@ -2022,7 +2022,13 @@ export const api = {
request<{ success: boolean; run: WorkflowRunSummary }>(`/api/workflow-runs/${encodeURIComponent(publicId)}`),
};
export const userApi = {
register: (data: { username: string; email: string; password: string }) =>
register: (data: {
username: string;
email: string;
password: string;
captchaId: string;
captchaCode: string;
}) =>
userRequest("/api/users/register", { method: "POST", body: JSON.stringify(data) }),
login: (data: { email: string; password: string }) =>
userRequest("/api/users/login", { method: "POST", body: JSON.stringify(data) }),
+66 -3
View File
@@ -101,6 +101,11 @@ const WELCOME_MESSAGE: AgentMessage = {
'你好,我是视频助手。把脚本、分镜或角色想法发给我;确认后我会分步生成角色四视图、场景图、TTS 和 ComfyUI 工作流。',
};
const MAX_ATTACHMENT_COUNT = 8;
const MAX_INLINE_IMAGE_BYTES = 12 * 1024 * 1024;
const MAX_INLINE_TOTAL_BYTES = 36 * 1024 * 1024;
const MAX_ATTACHMENT_BYTES = 64 * 1024 * 1024;
const QUICK_PROMPTS = [
'帮我把这个创意拆成角色、场景和镜头节奏',
'开始制作,先生成角色和场景,不要一次输出工作流',
@@ -308,6 +313,18 @@ function fileExtension(name: string): string {
return name.split('.').pop()?.trim().toLowerCase() || '';
}
function formatBytes(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 10 || unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
}
function isTextAttachment(file: File, mimeType: string): boolean {
if (mimeType.startsWith('text/')) return true;
if (['application/json', 'application/x-ndjson', 'application/xml'].includes(mimeType)) {
@@ -400,10 +417,35 @@ async function fileToAttachment(file: File): Promise<AgentAttachment> {
mediaKind,
};
if (mimeType.startsWith('image/') || mimeType.startsWith('video/') || mimeType.startsWith('audio/')) {
if (mimeType.startsWith('image/')) {
if (file.size > MAX_INLINE_IMAGE_BYTES) {
return {
...base,
text: [
`[图片素材: ${file.name}]`,
`类型: ${mimeType}`,
`大小: ${formatBytes(file.size)}`,
`图片超过 ${formatBytes(MAX_INLINE_IMAGE_BYTES)},已作为引用记录,未内联发送原图数据。`,
'如需精确参考,请压缩图片或上传较小版本。',
].join('\n'),
};
}
return { ...base, dataUrl: await fileToDataUrl(file) };
}
if (mimeType.startsWith('video/') || mimeType.startsWith('audio/')) {
return {
...base,
text: [
`[${mimeType.startsWith('video/') ? '视频' : '音频'}素材: ${file.name}]`,
`类型: ${mimeType}`,
`大小: ${formatBytes(file.size)}`,
'当前版本会记录该素材引用和用途说明,不会把完整二进制内容塞进对话请求。',
'请在输入框说明需要参考的镜头、节奏、音色或时间段;后续接入服务端解析后会自动提取媒体信息。',
].join('\n'),
};
}
if (isDocumentAttachment(file, mimeType) && !isTextAttachment(file, mimeType)) {
return {
...base,
@@ -1236,8 +1278,29 @@ export default function ComfyUIAgent() {
if (!files?.length) return;
setReadingAttachments(true);
try {
const next = await Promise.all(Array.from(files).slice(0, 8).map(fileToAttachment));
const withReferences = assignAttachmentReferences([...attachments, ...next].slice(0, 8));
const selectedFiles = Array.from(files).slice(0, MAX_ATTACHMENT_COUNT);
const oversized = selectedFiles.filter((file) => file.size > MAX_ATTACHMENT_BYTES);
if (oversized.length > 0) {
toast.error(`附件过大:${oversized[0].name} 超过 ${formatBytes(MAX_ATTACHMENT_BYTES)}`);
return;
}
const existingInlineBytes = attachments.reduce((sum, attachment) => (
sum + (attachment.dataUrl ? attachment.size : 0)
), 0);
const nextInlineBytes = selectedFiles.reduce((sum, file) => (
sum + (file.type.startsWith('image/') && file.size <= MAX_INLINE_IMAGE_BYTES ? file.size : 0)
), 0);
if (existingInlineBytes + nextInlineBytes > MAX_INLINE_TOTAL_BYTES) {
toast.error(`图片附件总量超过 ${formatBytes(MAX_INLINE_TOTAL_BYTES)},请减少数量或压缩后再上传`);
return;
}
if (files.length > MAX_ATTACHMENT_COUNT || attachments.length + selectedFiles.length > MAX_ATTACHMENT_COUNT) {
toast.info(`最多保留 ${MAX_ATTACHMENT_COUNT} 个附件,超出的部分已忽略。`);
}
const next = await Promise.all(selectedFiles.map(fileToAttachment));
const withReferences = assignAttachmentReferences([...attachments, ...next].slice(0, MAX_ATTACHMENT_COUNT));
setAttachments(withReferences);
if (next.some((attachment) => isDocumentAttachment(
new File([], attachment.name, { type: attachment.mimeType }),
+116
View File
@@ -0,0 +1,116 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import UserManagement from './UserManagement.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getUsers: vi.fn(),
getUserUsage: vi.fn(),
getUserKeys: vi.fn(),
createUser: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
createUserKey: vi.fn(),
deleteUserKey: vi.fn(),
resetUserKey: vi.fn(),
updateUserKey: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => (
typeof child === 'string' ? child : collectText(child)
)).join('');
}
async function flushLoad() {
await act(async () => {
vi.advanceTimersByTime(200);
await Promise.resolve();
await Promise.resolve();
});
}
describe('UserManagement', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('window', {
setTimeout,
clearTimeout,
prompt: vi.fn(),
confirm: vi.fn(),
});
vi.clearAllMocks();
apiMock.getUsers.mockResolvedValue({
data: [
{
id: 4,
username: 'autouser',
email: 'auto@test.com',
role: 'user',
status: 'active',
total_requests: 15,
total_cost: 0,
last_7_days_cost: 0,
},
],
stats: { total: 1, active: 1, disabled: 0, admins: 0 },
meta: { page: 1, pageSize: 25, total: 1, totalPages: 1 },
});
apiMock.getUserUsage.mockResolvedValue({
data: {
totalRequests: 15,
totalCost: 0,
last7DaysCost: 0,
trend: [{ day: '2026-05-25', requests: 2, cost: 0 }],
recentLogs: [
{
id: 1,
model: 'gpt-image-2',
cost: 0,
promptTokens: 10,
completionTokens: 0,
totalTokens: 10,
createdAt: '2026-05-25 11:30:00',
},
],
},
});
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
vi.clearAllMocks();
});
it('expands user usage with the camelCase API response without crashing', async () => {
let root!: ReturnType<typeof create>;
await act(async () => {
root = create(<UserManagement />);
});
await flushLoad();
const rows = root.root.findAll((node) => (
node.type === 'tr'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('autouser')
));
expect(rows.length).toBeGreaterThan(0);
await act(async () => {
rows[0].props.onClick();
await Promise.resolve();
await Promise.resolve();
});
const text = collectText(root.root);
expect(text).toContain('用量详情');
expect(text).toContain('gpt-image-2');
expect(apiMock.getUserUsage).toHaveBeenCalledWith(4);
});
});
+63 -9
View File
@@ -35,6 +35,30 @@ interface UserListMeta {
totalPages: number;
}
interface UserUsageTrendItem {
day: string;
requests: number;
cost: number;
}
interface UserUsageLogItem {
id: number | string;
model: string;
cost: number;
promptTokens: number;
completionTokens: number;
totalTokens: number;
createdAt?: string;
}
interface UserUsageData {
totalRequests: number;
totalCost: number;
last7DaysCost: number;
trend: UserUsageTrendItem[];
recentLogs: UserUsageLogItem[];
}
type UserFormMode = 'create' | 'edit';
interface UserFormState {
@@ -104,6 +128,35 @@ function normalizeMeta(result: any, users: User[], page: number, pageSize: numbe
};
}
function normalizeUserUsage(raw: any): UserUsageData {
const data = raw?.data ?? raw ?? {};
const trend = Array.isArray(data.trend) ? data.trend : [];
const recentLogs = Array.isArray(data.recentLogs)
? data.recentLogs
: Array.isArray(data.recent_logs)
? data.recent_logs
: [];
return {
totalRequests: Number(data.totalRequests ?? data.total_requests ?? 0),
totalCost: Number(data.totalCost ?? data.total_cost ?? 0),
last7DaysCost: Number(data.last7DaysCost ?? data.last_7_days_cost ?? 0),
trend: trend.map((item: any) => ({
day: String(item.day ?? ''),
requests: Number(item.requests ?? item.request_count ?? 0),
cost: Number(item.cost ?? 0),
})),
recentLogs: recentLogs.map((log: any, index: number) => ({
id: log.id ?? `${log.createdAt ?? log.created_at ?? 'log'}-${index}`,
model: String(log.model ?? '-'),
cost: Number(log.cost ?? 0),
promptTokens: Number(log.promptTokens ?? log.prompt_tokens ?? 0),
completionTokens: Number(log.completionTokens ?? log.completion_tokens ?? 0),
totalTokens: Number(log.totalTokens ?? log.total_tokens ?? 0),
createdAt: log.createdAt ?? log.created_at,
})),
};
}
function formatDate(value?: string): string {
if (!value) return '-';
const normalized = value.includes('T') ? value : value.replace(' ', 'T');
@@ -145,7 +198,7 @@ export default function UserManagement() {
const [form, setForm] = useState<UserFormState>(emptyForm);
const [deleteTarget, setDeleteTarget] = useState<User | 'selected' | null>(null);
const [expandedUserId, setExpandedUserId] = useState<number | null>(null);
const [usageData, setUsageData] = useState<any>(null);
const [usageData, setUsageData] = useState<UserUsageData | null>(null);
const [usageLoading, setUsageLoading] = useState(false);
const [keysData, setKeysData] = useState<any[]>([]);
const [keysLoading, setKeysLoading] = useState(false);
@@ -156,9 +209,10 @@ export default function UserManagement() {
setUsageLoading(true);
try {
const data = await api.getUserUsage(userId);
setUsageData(data.data);
setUsageData(normalizeUserUsage(data));
} catch (err: any) {
console.error('Failed to load usage:', err);
setUsageData(null);
} finally {
setUsageLoading(false);
}
@@ -630,15 +684,15 @@ export default function UserManagement() {
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16, marginBottom: 20 }}>
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>总请求数</div>
<div style={{ fontSize: 20, fontWeight: 600 }}>{usageData.total_requests.toLocaleString()}</div>
<div style={{ fontSize: 20, fontWeight: 600 }}>{usageData.totalRequests.toLocaleString()}</div>
</div>
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>总消耗</div>
<div style={{ fontSize: 20, fontWeight: 600, color: 'var(--color-primary)' }}>${usageData.total_cost.toFixed(4)}</div>
<div style={{ fontSize: 20, fontWeight: 600, color: 'var(--color-primary)' }}>${usageData.totalCost.toFixed(4)}</div>
</div>
<div style={{ padding: '12px 16px', background: 'var(--color-bg)', border: '1px solid var(--color-border)', borderRadius: 8 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>近7天消耗</div>
<div style={{ fontSize: 20, fontWeight: 600 }}>${usageData.last_7_days_cost.toFixed(4)}</div>
<div style={{ fontSize: 20, fontWeight: 600 }}>${usageData.last7DaysCost.toFixed(4)}</div>
</div>
</div>
@@ -678,15 +732,15 @@ export default function UserManagement() {
</tr>
</thead>
<tbody>
{usageData.recent_logs.map((log: any) => (
{usageData.recentLogs.map((log) => (
<tr key={log.id}>
<td>{formatDate(log.created_at)}</td>
<td>{formatDate(log.createdAt)}</td>
<td><code style={{ fontSize: 11 }}>{log.model}</code></td>
<td style={{ color: 'var(--color-primary)' }}>${log.cost.toFixed(5)}</td>
<td>{log.prompt_tokens}/{log.completion_tokens}/{log.total_tokens}</td>
<td>{log.promptTokens}/{log.completionTokens}/{log.totalTokens}</td>
</tr>
))}
{usageData.recent_logs.length === 0 && (
{usageData.recentLogs.length === 0 && (
<tr><td colSpan={4} style={{ textAlign: 'center', color: 'var(--color-text-muted)' }}>暂无日志</td></tr>
)}
</tbody>
+98 -3
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useCallback, useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { commitSession } from '../authSession.js';
import LoginShell from './auth/LoginShell.js';
@@ -9,9 +9,42 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [captchaId, setCaptchaId] = useState('');
const [captchaImage, setCaptchaImage] = useState('');
const [captchaCode, setCaptchaCode] = useState('');
const [captchaLoading, setCaptchaLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const loadCaptcha = useCallback(async () => {
setCaptchaLoading(true);
try {
const res = await fetch('/api/users/register-captcha', {
method: 'GET',
headers: { Accept: 'application/json' },
});
if (!res.ok) {
throw new Error(`验证码加载失败 (${res.status})`);
}
const payload = await res.json();
const challenge = payload?.data || payload;
setCaptchaId(String(challenge?.id || ''));
setCaptchaImage(String(challenge?.image || ''));
setCaptchaCode('');
} catch (err: any) {
setError(err?.message || '验证码加载失败');
setCaptchaId('');
setCaptchaImage('');
setCaptchaCode('');
} finally {
setCaptchaLoading(false);
}
}, []);
useEffect(() => {
void loadCaptcha();
}, [loadCaptcha]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!username.trim() || !email.trim() || !password) return;
@@ -23,13 +56,23 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
setError('密码至少 6 个字符');
return;
}
if (!captchaId || !captchaCode.trim()) {
setError('请输入图像验证码');
return;
}
setLoading(true);
setError('');
try {
const res = await fetch('/api/users/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: username.trim(), email: email.trim(), password }),
body: JSON.stringify({
username: username.trim(),
email: email.trim(),
password,
captchaId,
captchaCode: captchaCode.trim(),
}),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@@ -41,6 +84,7 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
navigate('/user/dashboard');
} catch (err: any) {
setError(err?.message || '注册失败');
void loadCaptcha();
} finally {
setLoading(false);
}
@@ -113,9 +157,60 @@ export default function UserRegister({ onLogin }: { onLogin?: () => void }) {
autoComplete="new-password"
required
/>
<label className="login-auth-label" htmlFor="reg-captcha-input">图像验证码</label>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 132px', gap: 10, alignItems: 'center' }}>
<input
id="reg-captcha-input"
type="text"
placeholder="输入右侧字符"
value={captchaCode}
onChange={(e) => { setCaptchaCode(e.target.value); setError(''); }}
className="login-auth-input"
autoComplete="off"
inputMode="text"
maxLength={8}
required
/>
<button
type="button"
onClick={() => void loadCaptcha()}
disabled={captchaLoading || loading}
aria-label="刷新图像验证码"
title="点击刷新验证码"
style={{
height: 44,
border: '1px solid var(--color-border)',
borderRadius: 8,
padding: 0,
background: 'var(--color-surface)',
cursor: captchaLoading || loading ? 'not-allowed' : 'pointer',
overflow: 'hidden',
}}
>
{captchaImage && !captchaLoading ? (
<img
src={captchaImage}
alt="图像验证码"
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
draggable={false}
/>
) : (
<span style={{ color: 'var(--color-text-muted)', fontSize: 12 }}>加载中...</span>
)}
</button>
</div>
<button
type="submit"
disabled={loading || !username.trim() || !email.trim() || !password || !confirmPassword}
disabled={
loading
|| captchaLoading
|| !username.trim()
|| !email.trim()
|| !password
|| !confirmPassword
|| !captchaId
|| !captchaCode.trim()
}
className="btn btn-primary login-auth-submit"
>
{loading ? (
+44 -14
View File
@@ -1,34 +1,51 @@
import React from 'react';
type WorkflowRuntime = {
mode?: string;
};
const LOCAL_WORKFLOW_URL = '/workflow-ui/';
const DOCKER_WORKFLOW_URL = '/api/comfyui/proxy/';
export default function Workflows() {
const [ready, setReady] = React.useState(false);
const [message, setMessage] = React.useState('正在加载工作流前端...');
const [workflowUrl, setWorkflowUrl] = React.useState(LOCAL_WORKFLOW_URL);
const [isCloud, setIsCloud] = React.useState(false);
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
const openFullscreen = () => {
window.open('/workflow-ui/', '_blank', 'noopener,noreferrer');
window.open(workflowUrl, '_blank', 'noopener,noreferrer');
};
const reloadFrame = () => {
if (iframeRef.current) {
iframeRef.current.src = '/workflow-ui/';
iframeRef.current.src = workflowUrl;
}
};
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');
});
const loadRuntime = async () => {
const runtimeResponse = await fetch('/api/workflow-ui/runtime', { signal: controller.signal });
const runtime = runtimeResponse.ok ? ((await runtimeResponse.json()) as WorkflowRuntime) : {};
const cloudMode = runtime.mode === 'cloud';
const dockerMode = runtime.mode === 'docker';
const targetUrl = dockerMode ? DOCKER_WORKFLOW_URL : LOCAL_WORKFLOW_URL;
if (cancelled) return;
setIsCloud(cloudMode);
setWorkflowUrl(targetUrl);
const response = await fetch(targetUrl, { signal: controller.signal });
if (cancelled) return;
setReady(response.ok);
setMessage(response.ok ? '' : 'Workflow Studio 前端未构建,请先执行 workflow-ui:install');
};
void loadRuntime().catch((error) => {
if (cancelled || error?.name === 'AbortError') return;
setReady(false);
setMessage('工作流入口不可用,请检查服务配置');
});
return () => {
cancelled = true;
controller.abort();
@@ -39,6 +56,8 @@ export default function Workflows() {
<div style={{ width: '100%', height: 'calc(100vh - 56px)', display: 'flex', flexDirection: 'column' }}>
<div style={toolbarStyle}>
<div style={toolbarTitleStyle}>工作流</div>
{!isCloud && workflowUrl === DOCKER_WORKFLOW_URL && <div style={cloudBadgeStyle}>Docker 完整版 ComfyUI</div>}
{isCloud && <div style={cloudBadgeStyle}>Comfy Cloud API 后端</div>}
<div style={toolbarActionsStyle}>
<button type="button" onClick={reloadFrame} disabled={!ready} style={toolbarButtonStyle}>
刷新
@@ -51,9 +70,10 @@ export default function Workflows() {
{ready ? (
<iframe
ref={iframeRef}
src="/workflow-ui/"
src={workflowUrl}
title="Workflow Studio"
allow="clipboard-read; clipboard-write"
referrerPolicy="no-referrer"
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
/>
) : (
@@ -79,6 +99,16 @@ const toolbarStyle: React.CSSProperties = {
background: 'var(--color-surface)',
};
const cloudBadgeStyle: React.CSSProperties = {
marginRight: 'auto',
border: '1px solid var(--color-border)',
borderRadius: 999,
padding: '3px 8px',
color: 'var(--color-text-secondary)',
fontSize: 12,
fontWeight: 700,
};
const toolbarTitleStyle: React.CSSProperties = {
fontSize: 14,
fontWeight: 800,