feat: polish workflow studio and workspace assets
This commit is contained in:
+7
-1
@@ -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) }),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user