fix(*): 画布与任务详情 - 优化生成参数展示、批量上传和默认模型状态
This commit is contained in:
196
web/FRONTEND_FEATURE_TOGGLE.md
Normal file
196
web/FRONTEND_FEATURE_TOGGLE.md
Normal file
@@ -0,0 +1,196 @@
|
||||
# 前端功能开关维护页面修改说明
|
||||
|
||||
## 修改内容
|
||||
|
||||
### 1. 类型定义更新
|
||||
|
||||
**文件**: `web/src/stores/use-user-store.ts`
|
||||
|
||||
在 `FeatureAvailability` 类型中添加了 `frontendModelsEnabled` 字段:
|
||||
|
||||
```typescript
|
||||
export type FeatureAvailability = {
|
||||
shortDramaEnabled: boolean;
|
||||
taskCenterEnabled: boolean;
|
||||
creditsEnabled: boolean;
|
||||
customChannelsEnabled: boolean;
|
||||
frontendModelsEnabled: boolean; // 新增
|
||||
desktopLocalChannelsEnabled: boolean;
|
||||
configured?: boolean;
|
||||
updatedBy?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
```
|
||||
|
||||
默认值设置为 `true`:
|
||||
|
||||
```typescript
|
||||
export const defaultFeatureAvailability: FeatureAvailability = {
|
||||
shortDramaEnabled: true,
|
||||
taskCenterEnabled: true,
|
||||
creditsEnabled: true,
|
||||
customChannelsEnabled: true,
|
||||
frontendModelsEnabled: true, // 新增
|
||||
desktopLocalChannelsEnabled: false,
|
||||
};
|
||||
```
|
||||
|
||||
### 2. 管理界面更新
|
||||
|
||||
**文件**: `web/src/pages/admin/components/feature-availability-panel.tsx`
|
||||
|
||||
#### 添加图标导入
|
||||
```typescript
|
||||
import { Sparkles } from "lucide-react";
|
||||
```
|
||||
|
||||
#### 扩展 FeatureKey 类型
|
||||
```typescript
|
||||
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled";
|
||||
```
|
||||
|
||||
#### 添加功能配置项
|
||||
在 `featureRows` 数组中添加了新的配置项:
|
||||
|
||||
```typescript
|
||||
{
|
||||
key: "frontendModelsEnabled",
|
||||
title: "前台模型目录",
|
||||
menu: "/admin/models",
|
||||
description: "开启时使用前台模型虚拟渠道(需配置路由),关闭时使用脱敏的系统渠道模型。管理员的模型配置入口始终保留。",
|
||||
icon: <Sparkles className="size-4" />
|
||||
}
|
||||
```
|
||||
|
||||
#### 更新保存函数
|
||||
在 `save` 函数中添加了对新字段的支持:
|
||||
|
||||
```typescript
|
||||
const result = await updateAdminFeatureAvailability({
|
||||
shortDramaEnabled: next.shortDramaEnabled,
|
||||
taskCenterEnabled: next.taskCenterEnabled,
|
||||
creditsEnabled: next.creditsEnabled,
|
||||
customChannelsEnabled: next.customChannelsEnabled,
|
||||
frontendModelsEnabled: next.frontendModelsEnabled, // 新增
|
||||
});
|
||||
```
|
||||
|
||||
#### 添加切换确认逻辑
|
||||
在 `toggle` 函数中添加了关闭前台模型时的二次确认:
|
||||
|
||||
```typescript
|
||||
// 前台模型关闭需要二次确认
|
||||
if (key === "frontendModelsEnabled" && !enabled) {
|
||||
modal.confirm({
|
||||
title: "切换到系统渠道模型?",
|
||||
content: "关闭后,用户将使用脱敏的系统渠道模型目录,任务创建时将禁止 logicalModelId。管理员的前台模型配置入口保持可见。",
|
||||
okText: "确认切换",
|
||||
cancelText: "取消",
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => save(key, false),
|
||||
});
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
## 用户界面效果
|
||||
|
||||
### 功能开关面板
|
||||
|
||||
在"功能开放"页面(`/admin/features`)中,会显示一个新的开关项:
|
||||
|
||||
- **图标**: ✨ (Sparkles)
|
||||
- **标题**: 前台模型目录
|
||||
- **菜单路径**: /admin/models
|
||||
- **描述**: 开启时使用前台模型虚拟渠道(需配置路由),关闭时使用脱敏的系统渠道模型。管理员的模型配置入口始终保留。
|
||||
- **默认状态**: 开启(true)
|
||||
|
||||
### 交互流程
|
||||
|
||||
1. **开启前台模型**
|
||||
- 点击开关即可开启
|
||||
- 用户侧将使用前台模型虚拟渠道
|
||||
- 任务创建必须提供 `logicalModelId`
|
||||
|
||||
2. **关闭前台模型**
|
||||
- 点击开关会弹出确认对话框
|
||||
- 确认后切换到系统渠道模型模式
|
||||
- 用户侧将使用脱敏的系统渠道模型目录
|
||||
- 任务创建禁止 `logicalModelId`
|
||||
|
||||
3. **管理员权限**
|
||||
- 无论开关状态如何,管理员的 `/admin/models` 入口始终保留
|
||||
- 管理员可以随时查看和配置前台模型
|
||||
|
||||
## 视觉设计
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 用户功能开放 5/5 已开放 │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 🎬 短剧创作 /projects [ON] │
|
||||
│ 关闭后隐藏短剧入口... │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ ☑️ 任务 /tasks [ON] │
|
||||
│ 关闭后仅隐藏并拦截任务中心页面... │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 💰 积分中心 /wallet [ON] │
|
||||
│ 关闭后隐藏用户积分入口... │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ 📡 自定义渠道 /settings?section... [ON] │
|
||||
│ 关闭后隐藏用户自定义渠道入口... │
|
||||
├─────────────────────────────────────────────────────────────────┤
|
||||
│ ✨ 前台模型目录 /admin/models [ON] │
|
||||
│ 开启时使用前台模型虚拟渠道(需配置路由), │
|
||||
│ 关闭时使用脱敏的系统渠道模型... │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 后端 API 适配
|
||||
|
||||
后端 API 已经支持 `frontendModelsEnabled` 字段:
|
||||
|
||||
- `GET /api/admin/feature-availability` - 读取配置(包含新字段)
|
||||
- `PUT /api/admin/feature-availability` - 更新配置(接受新字段)
|
||||
|
||||
## 测试建议
|
||||
|
||||
1. **开关切换测试**
|
||||
- 测试开启/关闭前台模型开关
|
||||
- 验证二次确认对话框是否正常显示
|
||||
- 验证保存后状态是否正确更新
|
||||
|
||||
2. **权限测试**
|
||||
- 验证只有管理员可以访问此页面
|
||||
- 验证非管理员无法修改此配置
|
||||
|
||||
3. **联动测试**
|
||||
- 开启前台模型后,验证用户侧模型选择器是否使用前台模型
|
||||
- 关闭前台模型后,验证用户侧模型选择器是否使用系统渠道模型
|
||||
- 验证管理员的 `/admin/models` 入口在两种模式下都可访问
|
||||
|
||||
4. **数据持久化测试**
|
||||
- 修改配置后刷新页面,验证配置是否保存
|
||||
- 验证配置在用户登录时正确加载
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **后端同步**: 前端修改依赖后端 API 支持,确保后端已部署相关更新
|
||||
2. **缓存清理**: 修改后建议清理浏览器缓存以确保类型更新生效
|
||||
3. **用户影响**: 切换模型模式会影响所有用户的模型选择界面,建议在低峰时段操作
|
||||
4. **回滚方案**: 如遇问题,可以通过管理界面快速切换回原模式
|
||||
|
||||
## 文件清单
|
||||
|
||||
### 修改的文件
|
||||
- `web/src/stores/use-user-store.ts` - 添加类型定义和默认值
|
||||
- `web/src/pages/admin/components/feature-availability-panel.tsx` - 添加UI和交互逻辑
|
||||
|
||||
### 依赖的后端文件
|
||||
- `internal/service/feature_availability.go` - 后端功能开关服务
|
||||
|
||||
---
|
||||
|
||||
**修改日期**: 2026-08-22
|
||||
**影响范围**: 管理员功能开关页面
|
||||
**用户影响**: 无,仅管理员可见
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { App, Button, Dropdown, Input, Modal, Segmented, Tag } from "antd";
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { App, Button, Dropdown, Input, Modal, Tag } from "antd";
|
||||
import type { MenuProps } from "antd";
|
||||
import { ChevronDown, Ellipsis, Lock, Plus, Unlock } from "lucide-react";
|
||||
|
||||
@@ -346,32 +346,12 @@ function NodeDockMenuButton({ menuId, label, icon, tools, openMenuId, onOpenChan
|
||||
|
||||
export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, readOnly = false, onUnauthorized }: { node: CanvasNodeData | null; open: boolean; onClose: () => void; onMetadataChange?: (nodeId: string, metadata: Partial<CanvasNodeMetadata>) => void; readOnly?: boolean; onUnauthorized?: () => void }) {
|
||||
const theme = canvasThemes[useThemeStore((state) => state.theme)];
|
||||
const [view, setView] = useState<"info" | "json">("info");
|
||||
const [assetTags, setAssetTags] = useState<string[]>([]);
|
||||
const [assetTagInput, setAssetTagInput] = useState("");
|
||||
const [assetCategory, setAssetCategory] = useState<CanvasAssetCategory>("other");
|
||||
const imageBytes = node?.type === CanvasNodeType.Image && node.metadata?.content ? getDataUrlByteSize(node.metadata.content) : 0;
|
||||
const batchCount = node?.type === CanvasNodeType.Image ? node.metadata?.batchChildIds?.length || 0 : 0;
|
||||
const nodeTypeLabel = node?.type === CanvasNodeType.Text ? "文本" : node?.type === CanvasNodeType.Script ? "分镜脚本" : node?.type === CanvasNodeType.Skill ? "技能" : node?.type === CanvasNodeType.Image ? "图片" : node?.type === CanvasNodeType.Video ? "视频" : node?.type === CanvasNodeType.Audio ? "音频" : node?.type === CanvasNodeType.Drawing ? "绘图" : node?.type === CanvasNodeType.Frame ? "背板" : "生成配置";
|
||||
const json = useMemo(() => {
|
||||
if (!node) return "";
|
||||
return JSON.stringify(
|
||||
node,
|
||||
(key, value) => {
|
||||
if (key === "title") return undefined;
|
||||
if (key === "content" && typeof value === "string" && value.startsWith("data:image/")) {
|
||||
return "[base64 image]";
|
||||
}
|
||||
return value;
|
||||
},
|
||||
2,
|
||||
);
|
||||
}, [node]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) setView("info");
|
||||
}, [node?.id, open]);
|
||||
|
||||
useEffect(() => {
|
||||
setAssetTags(node?.metadata?.assetTags || []);
|
||||
setAssetTagInput("");
|
||||
@@ -411,15 +391,6 @@ export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, rea
|
||||
<div className="text-[var(--fs-heading-lg)] font-semibold">节点信息</div>
|
||||
{node ? <div className="canvas-node-inspector-id">{node.id}</div> : null}
|
||||
</div>
|
||||
<Segmented
|
||||
size="small"
|
||||
value={view}
|
||||
onChange={(value) => setView(value as "info" | "json")}
|
||||
options={[
|
||||
{ label: "信息", value: "info" },
|
||||
{ label: "JSON", value: "json" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -431,11 +402,11 @@ export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, rea
|
||||
centered
|
||||
footer={null}
|
||||
onCancel={onClose}
|
||||
width="min(920px, calc(100vw - 32px))"
|
||||
styles={{ body: { paddingTop: 4 } }}
|
||||
>
|
||||
{node ? (
|
||||
<div className="canvas-node-inspector" style={{ color: theme.node.text }}>
|
||||
{view === "info" ? (
|
||||
<div className="thin-scrollbar canvas-node-inspector-scroll">
|
||||
<section className="canvas-node-inspector-section">
|
||||
<div className="canvas-node-inspector-section-heading"><span>基础信息</span><em>{node.metadata?.status || "idle"}</em></div>
|
||||
@@ -464,7 +435,16 @@ export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, rea
|
||||
{node.metadata?.prompt ? (
|
||||
<section className="canvas-node-inspector-section">
|
||||
<div className="canvas-node-inspector-section-heading"><span>提示词</span></div>
|
||||
<div className="canvas-node-inspector-copy">{node.metadata.prompt}</div>
|
||||
<div className="canvas-node-inspector-copy canvas-node-inspector-prompt">{node.metadata.prompt}</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{nodeGenerationRows(node).length ? (
|
||||
<section className="canvas-node-inspector-section">
|
||||
<div className="canvas-node-inspector-section-heading"><span>生成信息</span></div>
|
||||
<div className="canvas-node-inspector-facts">
|
||||
{nodeGenerationRows(node).map((item) => <InfoRow key={item.label} label={item.label} value={item.value} />)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -520,11 +500,6 @@ export function CanvasNodeInfoModal({ node, open, onClose, onMetadataChange, rea
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="thin-scrollbar canvas-node-inspector-json" style={{ color: theme.node.text }}>
|
||||
{json}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
@@ -539,3 +514,41 @@ function InfoRow({ label, value }: { label: string; value: ReactNode }) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function nodeGenerationRows(node: CanvasNodeData) {
|
||||
const metadata = node.metadata;
|
||||
if (!metadata) return [] as Array<{ label: string; value: string }>;
|
||||
const rows: Array<{ label: string; value: string }> = [];
|
||||
const add = (label: string, value: unknown) => {
|
||||
if (value === undefined || value === null || value === "") return;
|
||||
rows.push({ label, value: String(value) });
|
||||
};
|
||||
const addTime = (label: string, value?: string) => {
|
||||
if (!value) return;
|
||||
const timestamp = Date.parse(value);
|
||||
add(label, Number.isFinite(timestamp) ? new Date(timestamp).toLocaleString() : value);
|
||||
};
|
||||
const addDuration = (value?: number) => {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return;
|
||||
const totalSeconds = Math.max(0, Math.round(value / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
add("耗时", minutes ? `${minutes}分 ${seconds}秒` : `${seconds}秒`);
|
||||
};
|
||||
|
||||
add("模型", metadata.model);
|
||||
add("生成尺寸", metadata.size);
|
||||
add("分辨率", metadata.vquality || metadata.quality);
|
||||
add("秒数", metadata.seconds ? `${metadata.seconds} 秒` : undefined);
|
||||
add("生成声音", metadata.generateAudio === undefined ? undefined : metadata.generateAudio === "true" ? "开启" : "关闭");
|
||||
add("水印", metadata.watermark === undefined ? undefined : metadata.watermark === "true" ? "开启" : "关闭");
|
||||
if (metadata.references?.length) {
|
||||
const referenceNames = metadata.references.slice(0, 3).map((reference) => reference.split("/").pop() || reference).join("、");
|
||||
add("引用素材", `${metadata.references.length} 个${referenceNames ? `(${referenceNames}${metadata.references.length > 3 ? "…" : ""})` : ""}`);
|
||||
}
|
||||
addTime("创建时间", metadata.taskCreatedAt);
|
||||
addTime("开始时间", metadata.taskStartedAt);
|
||||
addTime("完成时间", metadata.taskCompletedAt);
|
||||
addDuration(metadata.taskDurationMs);
|
||||
return rows;
|
||||
}
|
||||
|
||||
@@ -188,7 +188,9 @@ export function videoResolutionLabel(value: string) {
|
||||
export function videoSizeLabel(value: string) {
|
||||
const ratio = normalizeSeedanceRatio(value);
|
||||
if (value === "adaptive" || value === "auto") return "自适应";
|
||||
if (ratio === value) return seedanceRatioOptions.find((item) => item.value === ratio)?.label || ratio;
|
||||
// The compact summary must mirror the selected value (for example 16:9),
|
||||
// while the settings panel can still use semantic labels such as 横屏.
|
||||
if (ratio === value) return ratio;
|
||||
const size = normalizeVideoSizeValue(value);
|
||||
return sizeOptions.find((item) => item.value === size)?.label || size;
|
||||
}
|
||||
|
||||
@@ -255,6 +255,9 @@ function completedTaskMetadata(task: GenerationTask): CanvasNodeMetadata {
|
||||
taskStatus: task.status,
|
||||
taskProgress: typeof task.progress === "number" && Number.isFinite(task.progress) ? Math.max(0, Math.min(100, Math.round(task.progress))) : 100,
|
||||
taskStage: task.stage,
|
||||
taskStartedAt: task.startedAt,
|
||||
taskCompletedAt: task.completedAt,
|
||||
taskDurationMs: task.startedAt && task.completedAt ? Math.max(0, Date.parse(task.completedAt) - Date.parse(task.startedAt)) : undefined,
|
||||
taskCreatedAt: task.createdAt || task.created_at,
|
||||
taskUpdatedAt: task.updatedAt || task.updated_at,
|
||||
errorDetails: undefined,
|
||||
|
||||
@@ -152,6 +152,9 @@ export function generationTaskMetadata(task: GenerationTask): CanvasNodeMetadata
|
||||
taskProgress: progress,
|
||||
taskStage: task.stage,
|
||||
taskProvider: task.provider,
|
||||
taskStartedAt: task.startedAt,
|
||||
taskCompletedAt: task.completedAt,
|
||||
taskDurationMs: task.startedAt && task.completedAt ? Math.max(0, Date.parse(task.completedAt) - Date.parse(task.startedAt)) : undefined,
|
||||
taskErrorCode: task.errorCode,
|
||||
taskOfficialStatus: task.officialStatus,
|
||||
taskReceiptRecorded: task.receiptRecorded,
|
||||
@@ -177,6 +180,9 @@ export function resetGenerationTaskMetadata(metadata: CanvasNodeMetadata | undef
|
||||
delete next.taskProgress;
|
||||
delete next.taskStage;
|
||||
delete next.taskProvider;
|
||||
delete next.taskStartedAt;
|
||||
delete next.taskCompletedAt;
|
||||
delete next.taskDurationMs;
|
||||
delete next.taskErrorCode;
|
||||
delete next.taskOfficialStatus;
|
||||
delete next.taskReceiptRecorded;
|
||||
|
||||
@@ -26,7 +26,7 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
|
||||
const config = useEffectiveConfig();
|
||||
return (
|
||||
<>
|
||||
<Modal title="任务详情" open={Boolean(task)} footer={null} width={760} onCancel={onCloseTask}>
|
||||
<Modal title="任务详情" open={Boolean(task)} footer={null} width="min(920px, calc(100vw - 32px))" onCancel={onCloseTask}>
|
||||
{task ? (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="grid grid-cols-2 gap-3 rounded-lg border p-3" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
|
||||
@@ -34,15 +34,20 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
|
||||
{generationTaskShowsProgress(task) ? <TaskDetailItem label="进度" value={`${task.progress ?? 0}%`} /> : null}
|
||||
<TaskDetailItem label="模型" value={task.model ? modelDisplayName(config, task.model) : "默认模型"} />
|
||||
<TaskDetailItem label="任务 ID" value={task.id} />
|
||||
<TaskDetailItem label="创建时间" value={formatTaskTime(task.createdAt)} />
|
||||
<TaskDetailItem label="开始时间" value={formatTaskTime(task.startedAt)} />
|
||||
<TaskDetailItem label="完成时间" value={formatTaskTime(task.completedAt)} />
|
||||
<TaskDetailItem label="耗时" value={formatTaskDuration(task)} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold" style={{ color: theme.node.muted }}>
|
||||
提示词
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto whitespace-pre-wrap rounded-lg p-3 text-xs leading-5" style={{ background: theme.node.fill }}>
|
||||
<div className="h-32 overflow-y-auto whitespace-pre-wrap rounded-lg p-3 text-xs leading-5" style={{ background: theme.node.fill }}>
|
||||
{task.prompt || "未记录"}
|
||||
</div>
|
||||
</div>
|
||||
<TaskGenerationParameters inputJson={task.inputJson} theme={theme} />
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold" style={{ color: theme.node.muted }}>
|
||||
任务日志
|
||||
@@ -108,3 +113,85 @@ export function CanvasProjectStatusDialogs({ theme, task, taskLogs, taskLoading,
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskGenerationParameters({ inputJson, theme }: { inputJson?: string; theme: CanvasProjectStatusDialogsProps["theme"] }) {
|
||||
const fields = taskParameterRows(inputJson);
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-2 text-xs font-semibold" style={{ color: theme.node.muted }}>生成参数</div>
|
||||
{fields.length ? (
|
||||
<div className="grid grid-cols-2 gap-x-5 gap-y-1 rounded-lg border p-3 sm:grid-cols-3" style={{ borderColor: theme.node.stroke, background: theme.node.panel }}>
|
||||
{fields.map((field) => <TaskDetailItem key={field.label} label={field.label} value={field.value} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg p-3 text-xs" style={{ background: theme.node.fill, color: theme.node.muted }}>暂无参数记录</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function taskParameterRows(inputJson?: string) {
|
||||
if (!inputJson) return [] as Array<{ label: string; value: string }>;
|
||||
let input: Record<string, unknown>;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(inputJson);
|
||||
input = asRecord(parsed);
|
||||
} catch {
|
||||
return [] as Array<{ label: string; value: string }>;
|
||||
}
|
||||
const config = asRecord(input.config);
|
||||
const rows: Array<{ label: string; value: string }> = [];
|
||||
const add = (label: string, value: unknown) => {
|
||||
if (value === undefined || value === null || value === "") return;
|
||||
rows.push({ label, value: String(value) });
|
||||
};
|
||||
add("尺寸 / 比例", config.size);
|
||||
add("分辨率", config.vquality || config.quality);
|
||||
add("秒数", config.videoSeconds === undefined ? undefined : `${config.videoSeconds} 秒`);
|
||||
add("生成数量", config.count);
|
||||
add("生成声音", booleanLabel(config.videoGenerateAudio));
|
||||
add("水印", booleanLabel(config.videoWatermark));
|
||||
add("音色", config.audioVoice);
|
||||
add("音频格式", config.audioFormat);
|
||||
add("音频速度", config.audioSpeed);
|
||||
addReference("引用图片", input.referenceImages, "图片");
|
||||
addReference("引用视频", input.referenceVideos, "视频");
|
||||
addReference("引用音频", input.referenceAudios, "音频");
|
||||
return rows;
|
||||
|
||||
function addReference(label: string, value: unknown, kind: string) {
|
||||
if (!Array.isArray(value) || !value.length) return;
|
||||
const names = value
|
||||
.map((item) => (typeof item === "object" && item !== null && "name" in item ? String((item as { name?: unknown }).name || "") : ""))
|
||||
.filter(Boolean);
|
||||
const suffix = names.length ? `(${names.slice(0, 3).join("、")}${names.length > 3 ? "…" : ""})` : "";
|
||||
rows.push({ label, value: `${value.length} 个${kind}${suffix}` });
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function booleanLabel(value: unknown) {
|
||||
if (value === true || value === "true") return "开启";
|
||||
if (value === false || value === "false") return "关闭";
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatTaskTime(value?: string) {
|
||||
if (!value) return "未记录";
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isFinite(timestamp) ? new Date(timestamp).toLocaleString() : value;
|
||||
}
|
||||
|
||||
function formatTaskDuration(task: GenerationTask) {
|
||||
const start = Date.parse(task.startedAt || task.createdAt);
|
||||
const end = task.completedAt ? Date.parse(task.completedAt) : task.status === "queued" || task.status === "running" ? Date.now() : Number.NaN;
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return task.completedAt ? "未记录" : "未完成";
|
||||
const milliseconds = Math.max(0, end - start);
|
||||
const totalSeconds = Math.round(milliseconds / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return minutes ? `${minutes}分 ${seconds}秒` : `${seconds}秒`;
|
||||
}
|
||||
|
||||
@@ -161,6 +161,9 @@ export function useCanvasGenerationExecutor({
|
||||
taskStage: undefined,
|
||||
taskProgress: undefined,
|
||||
taskCreatedAt: undefined,
|
||||
taskStartedAt: undefined,
|
||||
taskCompletedAt: undefined,
|
||||
taskDurationMs: undefined,
|
||||
errorDetails: controller.signal.aborted ? undefined : errorDetails,
|
||||
},
|
||||
}
|
||||
@@ -188,7 +191,7 @@ export function useCanvasGenerationExecutor({
|
||||
const errorDetails = generationErrorMessage(error);
|
||||
if (isPreparingEmptyImage)
|
||||
setNodes((current) =>
|
||||
current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, errorDetails } } : node)),
|
||||
current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_ERROR, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, taskStartedAt: undefined, taskCompletedAt: undefined, taskDurationMs: undefined, errorDetails } } : node)),
|
||||
);
|
||||
finishGenerationRequest(nodeId, controller);
|
||||
setRunningNodeId(null);
|
||||
@@ -215,7 +218,7 @@ export function useCanvasGenerationExecutor({
|
||||
}
|
||||
if (controller.signal.aborted) {
|
||||
if (isPreparingEmptyImage)
|
||||
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined } } : node)));
|
||||
setNodes((current) => current.map((node) => (node.id === nodeId ? { ...node, metadata: { ...node.metadata, status: NODE_STATUS_IDLE, taskStage: undefined, taskProgress: undefined, taskCreatedAt: undefined, taskStartedAt: undefined, taskCompletedAt: undefined, taskDurationMs: undefined } } : node)));
|
||||
finishGenerationRequest(nodeId, controller);
|
||||
setRunningNodeId(null);
|
||||
return;
|
||||
@@ -289,6 +292,9 @@ export function useCanvasGenerationExecutor({
|
||||
delete metadata.taskStage;
|
||||
delete metadata.taskCreatedAt;
|
||||
delete metadata.taskUpdatedAt;
|
||||
delete metadata.taskStartedAt;
|
||||
delete metadata.taskCompletedAt;
|
||||
delete metadata.taskDurationMs;
|
||||
return { ...node, metadata };
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -606,8 +606,13 @@ export function useCanvasUpload({
|
||||
void createImageAssetNode(asset, screenToCanvas(event.clientX, event.clientY));
|
||||
return;
|
||||
}
|
||||
const file = Array.from(event.dataTransfer.files).find((item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item));
|
||||
if (!file) return;
|
||||
const files = Array.from(event.dataTransfer.files).filter((item) => item.type.startsWith("image/") || item.type.startsWith("video/") || isAudioFile(item));
|
||||
if (!files.length) return;
|
||||
if (files.length > 1) {
|
||||
void handleUploadFiles(files);
|
||||
return;
|
||||
}
|
||||
const file = files[0];
|
||||
const position = screenToCanvas(event.clientX, event.clientY);
|
||||
const target = [...nodesRef.current].reverse().find((node) => {
|
||||
const compatible = (node.type === CanvasNodeType.Image && file.type.startsWith("image/"))
|
||||
@@ -622,7 +627,7 @@ export function useCanvasUpload({
|
||||
return;
|
||||
}
|
||||
void (isAudioFile(file) ? createAudioFileNode(file, position) : file.type.startsWith("video/") ? createVideoFileNode(file, position) : createImageFileNode(file, position));
|
||||
}, [createAudioFileNode, createImageAssetNode, createImageFileNode, createVideoFileNode, handleProjectChapterInsert, message, nodesRef, replaceNodeMedia, screenToCanvas]);
|
||||
}, [createAudioFileNode, createImageAssetNode, createImageFileNode, createVideoFileNode, handleProjectChapterInsert, handleUploadFiles, message, nodesRef, replaceNodeMedia, screenToCanvas]);
|
||||
|
||||
const handleFileDragEnter = useCallback((event: DragEvent<HTMLDivElement>) => {
|
||||
if (!hasDraggedFiles(event)) return;
|
||||
|
||||
@@ -529,6 +529,9 @@ export default function TasksPage() {
|
||||
<InfoItem label="模型" value={formatModelName(effectiveConfig, detailTask)} />
|
||||
<InfoItem label="尝试次数" value={`第 ${detailTask.attempts || 1} 次`} />
|
||||
<InfoItem label="创建时间" value={formatDate(detailTask.createdAt)} />
|
||||
<InfoItem label="开始时间" value={formatDate(detailTask.startedAt)} />
|
||||
<InfoItem label="完成时间" value={formatDate(detailTask.completedAt)} />
|
||||
<InfoItem label="耗时" value={formatTaskDuration(detailTask)} />
|
||||
{detailTask.providerCancelStatus ? <InfoItem label="上游取消" value={providerCancelStatusLabel(detailTask)} /> : null}
|
||||
{detailTask.providerCancelRequestedAt ? <InfoItem label="请求取消时间" value={formatDate(detailTask.providerCancelRequestedAt)} /> : null}
|
||||
</div>
|
||||
@@ -548,7 +551,8 @@ export default function TasksPage() {
|
||||
</div>
|
||||
{detailTask.error ? <pre className="task-detail-error max-h-28 overflow-auto whitespace-pre-wrap px-3 py-2 text-xs">{generationErrorMessage(detailTask.error)}</pre> : null}
|
||||
<TaskResultMedia value={detailTask.resultJson} taskType={detailTask.type} />
|
||||
<DetailBlock title="输入" value={detailLoading ? "详情加载中..." : formatTaskJson(detailTask.inputJson)} />
|
||||
<DetailBlock title="提示词" value={detailLoading ? "详情加载中..." : detailTask.prompt || "无"} tall />
|
||||
<TaskParameters inputJson={detailLoading ? undefined : detailTask.inputJson} />
|
||||
<DetailBlock title="结果" value={detailLoading ? "详情加载中..." : formatTaskJson(detailTask.resultJson)} />
|
||||
<div>
|
||||
<Typography.Text strong>日志</Typography.Text>
|
||||
@@ -685,28 +689,122 @@ function formatDate(value?: string) {
|
||||
return Number.isNaN(date.getTime()) ? "-" : date.toLocaleString();
|
||||
}
|
||||
|
||||
function InfoItem({ label, value }: { label: string; value: string }) {
|
||||
function formatTaskDuration(task: GenerationTask) {
|
||||
if (!task.createdAt) return "-";
|
||||
const start = new Date(task.startedAt || task.createdAt).getTime();
|
||||
const end = task.completedAt ? new Date(task.completedAt).getTime() : task.status === "queued" || task.status === "running" ? Date.now() : Number.NaN;
|
||||
if (!Number.isFinite(start) || !Number.isFinite(end) || end < start) return "-";
|
||||
const totalSeconds = Math.max(0, Math.floor((end - start) / 1000));
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return minutes ? `${minutes}分 ${seconds}秒` : `${seconds}秒`;
|
||||
}
|
||||
|
||||
function InfoItem({ label, value, wrap = false }: { label: string; value: string; wrap?: boolean }) {
|
||||
return (
|
||||
<div className="task-detail-fact min-w-0 px-3 py-2.5">
|
||||
<Typography.Text type="secondary" className="block text-xs">
|
||||
{label}
|
||||
</Typography.Text>
|
||||
<Typography.Text className="block truncate text-sm" title={value}>
|
||||
<Typography.Text className={`block text-sm ${wrap ? "whitespace-pre-wrap break-words" : "truncate"}`} title={value}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailBlock({ title, value }: { title: string; value: string }) {
|
||||
function DetailBlock({ title, value, tall = false }: { title: string; value: string; tall?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
<Typography.Text strong>{title}</Typography.Text>
|
||||
<pre className="mt-2 max-h-60 overflow-auto rounded-md bg-slate-950 p-3 text-xs leading-5 text-slate-100">{value}</pre>
|
||||
<pre className={`mt-2 overflow-auto rounded-md bg-slate-950 p-3 text-xs leading-5 text-slate-100 ${tall ? "h-40 whitespace-pre-wrap break-words" : "max-h-60"}`}>{value}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskParameters({ inputJson }: { inputJson?: string }) {
|
||||
const fields = taskParameterFields(inputJson);
|
||||
return (
|
||||
<div>
|
||||
<Typography.Text strong>参数</Typography.Text>
|
||||
{fields.length ? (
|
||||
<div className="task-detail-facts mt-2 grid text-sm sm:grid-cols-2">
|
||||
{fields.map((field) => <InfoItem key={field.label} label={field.label} value={field.value} wrap />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2 rounded-md bg-foreground/[.04] px-3 py-3 text-sm text-foreground/50">暂无参数记录</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function taskParameterFields(inputJson?: string) {
|
||||
const input = parseTaskInput(inputJson);
|
||||
if (!input) return [];
|
||||
const config = asRecord(input.config);
|
||||
const fields: Array<{ label: string; value: string }> = [];
|
||||
const add = (label: string, value: unknown) => {
|
||||
const text = formatParameterValue(value);
|
||||
if (text) fields.push({ label, value: text });
|
||||
};
|
||||
|
||||
add("模式", input.mode);
|
||||
add("尺寸 / 比例", config.size);
|
||||
add("分辨率", config.vquality || config.quality);
|
||||
add("时长", config.videoSeconds === undefined ? undefined : `${config.videoSeconds} 秒`);
|
||||
add("生成数量", config.count);
|
||||
add("生成声音", booleanParameter(config.videoGenerateAudio));
|
||||
add("水印", booleanParameter(config.videoWatermark));
|
||||
add("音色", config.audioVoice);
|
||||
add("音频格式", config.audioFormat);
|
||||
add("音频速度", config.audioSpeed);
|
||||
|
||||
add("参考图片", formatReferenceList(input.referenceImages, "图片"));
|
||||
add("参考视频", formatReferenceList(input.referenceVideos, "视频"));
|
||||
add("参考音频", formatReferenceList(input.referenceAudios, "音频"));
|
||||
add("遮罩图片", formatReferenceList(input.mask ? [input.mask] : [], "遮罩"));
|
||||
return fields;
|
||||
}
|
||||
|
||||
function parseTaskInput(value?: string): Record<string, unknown> | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return asRecord(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function formatParameterValue(value: unknown) {
|
||||
if (value === undefined || value === null || value === "") return "";
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
return "";
|
||||
}
|
||||
|
||||
function booleanParameter(value: unknown) {
|
||||
if (value === true || value === "true") return "是";
|
||||
if (value === false || value === "false") return "否";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function formatReferenceList(value: unknown, kind: string) {
|
||||
if (!Array.isArray(value) || !value.length) return "无";
|
||||
return value.map((item, index) => {
|
||||
const reference = asRecord(item);
|
||||
const name = typeof reference.name === "string" && reference.name.trim() && !/^https?:|^data:|^blob:/i.test(reference.name) ? reference.name.trim() : `${kind}${index + 1}`;
|
||||
const dimensions = typeof reference.width === "number" && typeof reference.height === "number" ? `${reference.width}×${reference.height}` : "";
|
||||
const duration = typeof reference.durationMs === "number" && reference.durationMs > 0 ? `${Math.round(reference.durationMs / 100) / 10}s` : "";
|
||||
const details = [dimensions, duration].filter(Boolean).join(",");
|
||||
return details ? `${name}(${details})` : name;
|
||||
}).join("、");
|
||||
}
|
||||
|
||||
function formatTaskJson(value?: string) {
|
||||
if (!value) return "无";
|
||||
try {
|
||||
|
||||
@@ -41,7 +41,7 @@ export const defaultFeatureAvailability: FeatureAvailability = {
|
||||
taskCenterEnabled: true,
|
||||
creditsEnabled: true,
|
||||
customChannelsEnabled: true,
|
||||
frontendModelsEnabled: true,
|
||||
frontendModelsEnabled: false,
|
||||
desktopLocalChannelsEnabled: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -7266,6 +7266,8 @@
|
||||
.canvas-node-inspector-help { margin-top: 8px; color: color-mix(in srgb, currentColor 43%, transparent); font-size: var(--fs-micro); line-height: 1.55; }
|
||||
.canvas-node-inspector-copy, .canvas-node-inspector-json, .canvas-node-inspector-notice { border: 0; border-radius: var(--r-md); background: color-mix(in srgb, currentColor 4%, transparent); }
|
||||
.canvas-node-inspector-copy { padding: 12px 14px; white-space: pre-wrap; overflow-wrap: anywhere; line-height: 1.7; }
|
||||
.canvas-node-info-modal { width: min(920px, calc(100vw - 32px)) !important; }
|
||||
.canvas-node-inspector-prompt { height: min(220px, 32vh); min-height: 120px; overflow: auto; }
|
||||
.canvas-node-inspector-subheading { margin: 14px 0 8px; color: color-mix(in srgb, currentColor 58%, transparent); }
|
||||
.canvas-node-inspector-tag-editor { display: flex; gap: 8px; }
|
||||
.canvas-node-inspector-tags { display: flex; min-height: 36px; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||
|
||||
@@ -266,6 +266,9 @@ export type CanvasNodeMetadata = {
|
||||
taskProgress?: number;
|
||||
taskStage?: string;
|
||||
taskProvider?: string;
|
||||
taskStartedAt?: string;
|
||||
taskCompletedAt?: string;
|
||||
taskDurationMs?: number;
|
||||
taskErrorCode?: string;
|
||||
taskOfficialStatus?: "pending" | "processing" | "completed" | "failed" | "cancelled";
|
||||
taskReceiptRecorded?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user