fix(release): 正式版本 - 修复 SSRF 与 Quality checks,发布 v1.6.6.1
This commit is contained in:
8
.github/workflows/publish-images.yml
vendored
8
.github/workflows/publish-images.yml
vendored
@@ -214,8 +214,11 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
prerelease_args=()
|
||||
if [[ "$GITHUB_REF_NAME" == *-* || "$GITHUB_REF_NAME" == *.rc* || "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
latest_args=()
|
||||
if [[ "$GITHUB_REF_NAME" == *-* || "$GITHUB_REF_NAME" == *.rc* || ( "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ && "$GITHUB_REF_NAME" != "v1.6.6.1" ) ]]; then
|
||||
prerelease_args+=(--prerelease)
|
||||
else
|
||||
latest_args+=(--latest)
|
||||
fi
|
||||
assets=(
|
||||
dist/open-ai-canvas-host-updater-linux-amd64
|
||||
@@ -230,5 +233,6 @@ jobs:
|
||||
--verify-tag \
|
||||
--title "$GITHUB_REF_NAME" \
|
||||
--notes-file dist/RELEASE_NOTES.md \
|
||||
"${prerelease_args[@]}"
|
||||
"${prerelease_args[@]}" \
|
||||
"${latest_args[@]}"
|
||||
fi
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,4 +1,12 @@
|
||||
# CHANGELOG
|
||||
# CHANGELOG
|
||||
|
||||
## v1.6.6.1
|
||||
|
||||
- 修复服务端出站 URL 对 DNS 解析结果的私网 IP 拦截被注释的问题,恢复对环回、内网和链路本地地址的拒绝,避免 SSRF。
|
||||
- 修复质量检查:工作台测试模块路径、懒加载画布路由合同和模型促销标签断言;修正影视剧作工坊内置脚本树完整性基线。
|
||||
- 修复创作页上一条任务仍在生成时无法提交下一条任务的问题。
|
||||
- 随后端镜像内置影视剧作工坊;服务启动时同步技能目录、版本和文件记录,保留用户加入/收藏状态。
|
||||
- 升级注意:数据库 Schema 从 v50 升至 v54,包含个人资料安全、头像预设、登录日志和工作台工具/预设迁移。生产 Compose 会在启动 Backend 前执行 `migrate-schema up`;无需手动建表。
|
||||
|
||||
## v1.6.6
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ func newProjectSettingsTestService(t *testing.T) (*Service, *gorm.DB) {
|
||||
&model.Shot{}, &model.ShotArtifact{}, &model.VoiceProfile{},
|
||||
&model.Task{}, &model.TaskLog{}, &model.Result{},
|
||||
&model.CreationRun{}, &model.CreationSubmission{},
|
||||
&model.CloudAgentResourceLease{}, &model.Tool{},
|
||||
&model.CloudAgentResourceLease{}, &model.Tool{}, &model.WorkbenchTool{},
|
||||
&model.Announcement{}, &model.AnnouncementImageDraft{},
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -101,9 +101,9 @@ const taskWorkerConcurrency = 3
|
||||
const taskLogPayloadLimit = 4000
|
||||
|
||||
type CreateTaskRequest struct {
|
||||
creationPrepare *creationTaskPreparation
|
||||
admission *taskAdmission
|
||||
workbenchKind string
|
||||
creationPrepare *creationTaskPreparation
|
||||
admission *taskAdmission
|
||||
workbenchKind string
|
||||
// callerHoldsStorageMu 只给已经持有 Service.storageMu 的内部调用设置。
|
||||
// 文件容量检查不能再锁一次,否则审批改参数会自锁。
|
||||
callerHoldsStorageMu bool
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
func TestWorkbenchKindFromTaskInputRequiresWorkbenchMetadataAndMatchingTaskType(t *testing.T) {
|
||||
input := map[string]any{
|
||||
"mode": "image",
|
||||
"mode": "image",
|
||||
"metadata": map[string]any{"source": "workbench", "workbenchKind": "image"},
|
||||
}
|
||||
if got := workbenchKindFromTaskInput(input, "canvas_image"); got != model.WorkbenchKindImage {
|
||||
|
||||
@@ -455,13 +455,13 @@ func resolveOutboundHostWithPolicy(ctx context.Context, host string, allowPrivat
|
||||
failure.Reason = kernel.ReasonUpstreamDNSFailed
|
||||
return nil, failure
|
||||
}
|
||||
// if !allowPrivateHost {
|
||||
// for _, ip := range addresses {
|
||||
// if blockedOutboundIP(ip) {
|
||||
// return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
if !allowPrivateHost {
|
||||
for _, ip := range addresses {
|
||||
if blockedOutboundIP(ip) {
|
||||
return nil, BadAuthRequest("不允许访问本机、内网或链路本地地址")
|
||||
}
|
||||
}
|
||||
}
|
||||
return addresses, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,14 +10,27 @@ type TaskPage struct {
|
||||
}
|
||||
|
||||
func (r *Repository) WorkbenchTasks(userID, kind string, activeOnly bool, cursor string, limit int) (TaskPage, error) {
|
||||
if limit < 1 || limit > 100 { limit = 30 }
|
||||
if kind != model.WorkbenchKindImage && kind != model.WorkbenchKindVideo { return TaskPage{}, nil }
|
||||
if limit < 1 || limit > 100 {
|
||||
limit = 30
|
||||
}
|
||||
if kind != model.WorkbenchKindImage && kind != model.WorkbenchKindVideo {
|
||||
return TaskPage{}, nil
|
||||
}
|
||||
query := r.db.Model(&model.Task{}).Where("user_id = ? AND workbench_kind = ?", userID, kind)
|
||||
if activeOnly { query = query.Where("status IN ?", []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning}) }
|
||||
if cursor != "" { query = query.Where("id < ?", cursor) }
|
||||
if activeOnly {
|
||||
query = query.Where("status IN ?", []model.TaskStatus{model.TaskStatusQueued, model.TaskStatusRunning})
|
||||
}
|
||||
if cursor != "" {
|
||||
query = query.Where("id < ?", cursor)
|
||||
}
|
||||
var tasks []model.Task
|
||||
if err := query.Order("created_at desc, id desc").Limit(limit + 1).Find(&tasks).Error; err != nil { return TaskPage{}, err }
|
||||
if err := query.Order("created_at desc, id desc").Limit(limit + 1).Find(&tasks).Error; err != nil {
|
||||
return TaskPage{}, err
|
||||
}
|
||||
page := TaskPage{Tasks: tasks}
|
||||
if len(tasks) > limit { page.Tasks = tasks[:limit]; page.NextCursor = page.Tasks[len(page.Tasks)-1].ID }
|
||||
if len(tasks) > limit {
|
||||
page.Tasks = tasks[:limit]
|
||||
page.NextCursor = page.Tasks[len(page.Tasks)-1].ID
|
||||
}
|
||||
return page, nil
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
const yingshiSkillID = "yingshi-drama-workshop"
|
||||
const yingshiPythonTreeSHA256 = "15b6dbe0a6fad34e37694bc394428159bb18f438dc56971ba2055620079a037c"
|
||||
const yingshiPythonTreeSHA256 = "731838b084b3efbfda806378e5a6a2a83a0bf2040f8c627c29b88d32c904ff88"
|
||||
|
||||
var pythonScriptCommand = regexp.MustCompile("(?i)^\\s*(?:\\$\\s*)?(?:python(?:3(?:\\.\\d+)?)?|py(?:\\s+-3(?:\\.\\d+)?)?)\\s+[^`\\n]*(?:\\.py\\b|-m\\s+wfkit)")
|
||||
var machineSpecificPath = regexp.MustCompile(`(?i)(?:[a-z]:\\|\\\\[^\\]+\\|/(?:Users|home|private/Users)/|~/)`)
|
||||
|
||||
@@ -8,7 +8,8 @@ import { getAdminFeatureAvailability, updateAdminFeatureAvailability } from "@/s
|
||||
import { useUserStore, type FeatureAvailability } from "@/stores/use-user-store";
|
||||
import { AdminStatusBadge } from "./admin-ui";
|
||||
|
||||
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "inspirationSourcesVisible" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers" | "imageWorkbenchEnabled" | "videoWorkbenchEnabled";
|
||||
type FeatureKey =
|
||||
"shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "inspirationSourcesVisible" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers" | "imageWorkbenchEnabled" | "videoWorkbenchEnabled";
|
||||
type FeatureRow = {
|
||||
key: FeatureKey;
|
||||
title: string;
|
||||
@@ -17,7 +18,18 @@ type FeatureRow = {
|
||||
dependsOn?: FeatureKey;
|
||||
};
|
||||
|
||||
const editableFeatureKeys: FeatureKey[] = ["shortDramaEnabled", "taskCenterEnabled", "creditsEnabled", "customChannelsEnabled", "inspirationSourcesVisible", "imageWorkbenchEnabled", "videoWorkbenchEnabled", "frontendModelsEnabled", "pluginCenterEnabled", "systemPluginsVisibleToUsers"];
|
||||
const editableFeatureKeys: FeatureKey[] = [
|
||||
"shortDramaEnabled",
|
||||
"taskCenterEnabled",
|
||||
"creditsEnabled",
|
||||
"customChannelsEnabled",
|
||||
"inspirationSourcesVisible",
|
||||
"imageWorkbenchEnabled",
|
||||
"videoWorkbenchEnabled",
|
||||
"frontendModelsEnabled",
|
||||
"pluginCenterEnabled",
|
||||
"systemPluginsVisibleToUsers",
|
||||
];
|
||||
|
||||
const workspaceFeatureRows: FeatureRow[] = [
|
||||
{
|
||||
|
||||
@@ -7,7 +7,20 @@ import { AdminPageFrame } from "@/pages/admin/components/admin-shell";
|
||||
import { AdminDataTable, AdminStatusBadge } from "@/pages/admin/components/admin-ui";
|
||||
import { workbenchCategories, workbenchCategoryLabel, workbenchModeOptions } from "@/pages/workbench/workbench-catalog";
|
||||
import { uploadResourceFile } from "@/services/api/resources";
|
||||
import { capabilitySupportedModes, listWorkbenchTools, normalizeWorkbenchGuide, resolveWorkbenchResourceURL, saveAdminWorkbenchTool, setAdminWorkbenchToolEnabled, type WorkbenchInputContract, type WorkbenchKind, type WorkbenchReferenceSlot, type WorkbenchTool, type WorkbenchPromptPreset, type WorkbenchResultExample } from "@/services/api/workbench";
|
||||
import {
|
||||
capabilitySupportedModes,
|
||||
listWorkbenchTools,
|
||||
normalizeWorkbenchGuide,
|
||||
resolveWorkbenchResourceURL,
|
||||
saveAdminWorkbenchTool,
|
||||
setAdminWorkbenchToolEnabled,
|
||||
type WorkbenchInputContract,
|
||||
type WorkbenchKind,
|
||||
type WorkbenchReferenceSlot,
|
||||
type WorkbenchTool,
|
||||
type WorkbenchPromptPreset,
|
||||
type WorkbenchResultExample,
|
||||
} from "@/services/api/workbench";
|
||||
|
||||
import "./workbench-tools.css";
|
||||
|
||||
@@ -43,63 +56,157 @@ type MediaValue = { id?: string; url?: string };
|
||||
type UploadMedia = (file: File, kind: "image" | "video") => Promise<MediaValue>;
|
||||
|
||||
const emptyDraft = (kind: WorkbenchKind = "image"): ToolDraft => ({
|
||||
kind, categoryId: "recommended", name: "", subtitle: "", description: "", guideText: "", inputHint: "", sortOrder: 0, enabled: false,
|
||||
supportedModes: [kind], searchKeywords: [], surfaces: ["workbench"], recommended: false, recommendationOrder: 0,
|
||||
capability: {}, guideConfig: {}, inputContract: { promptRequired: true, minTextReferences: 0, slots: [] }, resultExample: { imageResourceIds: [], imageUrls: [] }, presets: [], savedPresetIds: [],
|
||||
kind,
|
||||
categoryId: "recommended",
|
||||
name: "",
|
||||
subtitle: "",
|
||||
description: "",
|
||||
guideText: "",
|
||||
inputHint: "",
|
||||
sortOrder: 0,
|
||||
enabled: false,
|
||||
supportedModes: [kind],
|
||||
searchKeywords: [],
|
||||
surfaces: ["workbench"],
|
||||
recommended: false,
|
||||
recommendationOrder: 0,
|
||||
capability: {},
|
||||
guideConfig: {},
|
||||
inputContract: { promptRequired: true, minTextReferences: 0, slots: [] },
|
||||
resultExample: { imageResourceIds: [], imageUrls: [] },
|
||||
presets: [],
|
||||
savedPresetIds: [],
|
||||
});
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
if (typeof value === "string") {
|
||||
try { return record(JSON.parse(value)); } catch { return {}; }
|
||||
try {
|
||||
return record(JSON.parse(value));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function makeDraft(tool: WorkbenchTool): ToolDraft {
|
||||
const capability = record(tool.capability);
|
||||
const contract = tool.inputContract || { promptRequired: true, minTextReferences: 0, slots: [] };
|
||||
return {
|
||||
...emptyDraft(tool.kind), ...tool, capability, guideConfig: record(tool.guide), guideText: normalizeWorkbenchGuide(tool.guide), inputHint: tool.inputHint || "",
|
||||
...emptyDraft(tool.kind),
|
||||
...tool,
|
||||
capability,
|
||||
guideConfig: record(tool.guide),
|
||||
guideText: normalizeWorkbenchGuide(tool.guide),
|
||||
inputHint: tool.inputHint || "",
|
||||
supportedModes: capabilitySupportedModes(tool).length ? capabilitySupportedModes(tool) : [tool.kind],
|
||||
searchKeywords: tool.searchKeywords || [], surfaces: tool.surfaces?.length ? tool.surfaces : ["workbench"],
|
||||
recommended: Boolean(tool.recommended), recommendationOrder: tool.recommendationOrder || 0,
|
||||
searchKeywords: tool.searchKeywords || [],
|
||||
surfaces: tool.surfaces?.length ? tool.surfaces : ["workbench"],
|
||||
recommended: Boolean(tool.recommended),
|
||||
recommendationOrder: tool.recommendationOrder || 0,
|
||||
inputContract: { ...contract, slots: contract.slots.map((slot) => ({ ...slot })) },
|
||||
resultExample: { ...tool.resultExample, imageResourceIds: tool.resultExample?.imageResourceIds || [] },
|
||||
presets: tool.presets.map((preset) => ({ ...preset })), savedPresetIds: tool.presets.map((preset) => preset.id),
|
||||
presets: tool.presets.map((preset) => ({ ...preset })),
|
||||
savedPresetIds: tool.presets.map((preset) => preset.id),
|
||||
};
|
||||
}
|
||||
|
||||
function ResourceUpload({ label, value, kind = "image", disabled, upload, onChange }: { label: string; value: MediaValue; kind?: "image" | "video"; disabled: boolean; upload: UploadMedia; onChange: (value: MediaValue) => void }) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
return <div className="admin-workbench-resource-upload">
|
||||
<div className="admin-workbench-resource-preview">{value.url ? kind === "video" ? <video src={value.url} controls preload="metadata" /> : <img src={value.url} alt={label} /> : <WandSparkles className="size-5" />}</div>
|
||||
<div className="admin-workbench-resource-actions">
|
||||
<strong>{label}</strong><small>{value.id ? value.url ? "已上传" : "已上传,预览暂不可用" : "选择文件上传"}</small>
|
||||
<div><Upload disabled={disabled} accept={`${kind}/*`} showUploadList={false} beforeUpload={async (file) => {
|
||||
setUploading(true);
|
||||
try { onChange(await upload(file, kind)); } catch { /* Upload errors are reported by the shared uploader. */ } finally { setUploading(false); }
|
||||
return false;
|
||||
}}><Button size="small" disabled={disabled} loading={uploading} icon={<CloudUpload className="size-3.5" />}>{value.id ? "替换文件" : "上传文件"}</Button></Upload>
|
||||
{value.id && <Button size="small" disabled={disabled} type="text" danger aria-label={`移除${label}`} icon={<Trash2 className="size-3.5" />} onClick={() => onChange({})}>移除</Button>}</div>
|
||||
return (
|
||||
<div className="admin-workbench-resource-upload">
|
||||
<div className="admin-workbench-resource-preview">{value.url ? kind === "video" ? <video src={value.url} controls preload="metadata" /> : <img src={value.url} alt={label} /> : <WandSparkles className="size-5" />}</div>
|
||||
<div className="admin-workbench-resource-actions">
|
||||
<strong>{label}</strong>
|
||||
<small>{value.id ? (value.url ? "已上传" : "已上传,预览暂不可用") : "选择文件上传"}</small>
|
||||
<div>
|
||||
<Upload
|
||||
disabled={disabled}
|
||||
accept={`${kind}/*`}
|
||||
showUploadList={false}
|
||||
beforeUpload={async (file) => {
|
||||
setUploading(true);
|
||||
try {
|
||||
onChange(await upload(file, kind));
|
||||
} catch {
|
||||
/* Upload errors are reported by the shared uploader. */
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
return false;
|
||||
}}
|
||||
>
|
||||
<Button size="small" disabled={disabled} loading={uploading} icon={<CloudUpload className="size-3.5" />}>
|
||||
{value.id ? "替换文件" : "上传文件"}
|
||||
</Button>
|
||||
</Upload>
|
||||
{value.id && (
|
||||
<Button size="small" disabled={disabled} type="text" danger aria-label={`移除${label}`} icon={<Trash2 className="size-3.5" />} onClick={() => onChange({})}>
|
||||
移除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>;
|
||||
);
|
||||
}
|
||||
|
||||
function SlotEditor({ slot, index, kind, disabled, upload, onChange, onRemove }: { slot: WorkbenchReferenceSlot; index: number; kind: WorkbenchKind; disabled: boolean; upload: UploadMedia; onChange: (patch: Partial<WorkbenchReferenceSlot>) => void; onRemove: () => void }) {
|
||||
return <div className="admin-workbench-slot-row">
|
||||
<div className="admin-workbench-slot-fields">
|
||||
<label>输入名称<Input aria-label={`输入 ${index + 1} 名称`} value={slot.name} placeholder="如模特、人脸、商品" onChange={(event) => onChange({ name: event.target.value })} /></label>
|
||||
<label>媒体类型<Select mode="multiple" aria-label={`输入 ${index + 1} 媒体类型`} value={slot.mediaTypes} options={(kind === "image" ? ["image"] : ["image", "video", "audio"]).map((value) => ({ value, label: value === "image" ? "图片" : value === "video" ? "视频" : "音频" }))} onChange={(mediaTypes) => onChange({ mediaTypes })} /></label>
|
||||
<label>最少数量<InputNumber min={0} max={slot.maxCount} aria-label={`输入 ${index + 1} 最少数量`} value={slot.minCount} onChange={(value) => onChange({ minCount: value || 0, required: Boolean(value) })} /></label>
|
||||
<label>最多数量<InputNumber min={Math.max(1, slot.minCount)} max={20} aria-label={`输入 ${index + 1} 最多数量`} value={slot.maxCount} onChange={(value) => onChange({ maxCount: value || 1 })} /></label>
|
||||
<label>必填<Switch aria-label={`输入 ${index + 1} 必填`} checked={slot.required} onChange={(required) => onChange({ required, minCount: required ? Math.max(1, slot.minCount) : 0 })} /></label>
|
||||
<Button disabled={disabled} danger type="text" size="small" onClick={onRemove}>移除输入</Button>
|
||||
function SlotEditor({
|
||||
slot,
|
||||
index,
|
||||
kind,
|
||||
disabled,
|
||||
upload,
|
||||
onChange,
|
||||
onRemove,
|
||||
}: {
|
||||
slot: WorkbenchReferenceSlot;
|
||||
index: number;
|
||||
kind: WorkbenchKind;
|
||||
disabled: boolean;
|
||||
upload: UploadMedia;
|
||||
onChange: (patch: Partial<WorkbenchReferenceSlot>) => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-workbench-slot-row">
|
||||
<div className="admin-workbench-slot-fields">
|
||||
<label>
|
||||
输入名称
|
||||
<Input aria-label={`输入 ${index + 1} 名称`} value={slot.name} placeholder="如模特、人脸、商品" onChange={(event) => onChange({ name: event.target.value })} />
|
||||
</label>
|
||||
<label>
|
||||
媒体类型
|
||||
<Select
|
||||
mode="multiple"
|
||||
aria-label={`输入 ${index + 1} 媒体类型`}
|
||||
value={slot.mediaTypes}
|
||||
options={(kind === "image" ? ["image"] : ["image", "video", "audio"]).map((value) => ({ value, label: value === "image" ? "图片" : value === "video" ? "视频" : "音频" }))}
|
||||
onChange={(mediaTypes) => onChange({ mediaTypes })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
最少数量
|
||||
<InputNumber min={0} max={slot.maxCount} aria-label={`输入 ${index + 1} 最少数量`} value={slot.minCount} onChange={(value) => onChange({ minCount: value || 0, required: Boolean(value) })} />
|
||||
</label>
|
||||
<label>
|
||||
最多数量
|
||||
<InputNumber min={Math.max(1, slot.minCount)} max={20} aria-label={`输入 ${index + 1} 最多数量`} value={slot.maxCount} onChange={(value) => onChange({ maxCount: value || 1 })} />
|
||||
</label>
|
||||
<label>
|
||||
必填
|
||||
<Switch aria-label={`输入 ${index + 1} 必填`} checked={slot.required} onChange={(required) => onChange({ required, minCount: required ? Math.max(1, slot.minCount) : 0 })} />
|
||||
</label>
|
||||
<Button disabled={disabled} danger type="text" size="small" onClick={onRemove}>
|
||||
移除输入
|
||||
</Button>
|
||||
</div>
|
||||
<div className="admin-workbench-resource-grid">
|
||||
<ResourceUpload label={`${slot.name || "输入"}封面`} value={{ id: slot.coverResourceId, url: slot.coverUrl }} disabled={disabled} upload={upload} onChange={(value) => onChange({ coverResourceId: value.id, coverUrl: value.url })} />
|
||||
<ResourceUpload label={`${slot.name || "输入"}示例`} value={{ id: slot.sampleResourceId, url: slot.sampleUrl }} disabled={disabled} upload={upload} onChange={(value) => onChange({ sampleResourceId: value.id, sampleUrl: value.url })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-workbench-resource-grid">
|
||||
<ResourceUpload label={`${slot.name || "输入"}封面`} value={{ id: slot.coverResourceId, url: slot.coverUrl }} disabled={disabled} upload={upload} onChange={(value) => onChange({ coverResourceId: value.id, coverUrl: value.url })} />
|
||||
<ResourceUpload label={`${slot.name || "输入"}示例`} value={{ id: slot.sampleResourceId, url: slot.sampleUrl }} disabled={disabled} upload={upload} onChange={(value) => onChange({ sampleResourceId: value.id, sampleUrl: value.url })} />
|
||||
</div>
|
||||
</div>;
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkbenchToolsPage() {
|
||||
@@ -117,11 +224,18 @@ export default function WorkbenchToolsPage() {
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try { const result = await listWorkbenchTools(kind, undefined, true); setTools(result.tools.sort((a, b) => a.sortOrder - b.sortOrder)); }
|
||||
catch (error) { message.error(error instanceof Error ? error.message : "工作台工具加载失败"); }
|
||||
finally { setLoading(false); }
|
||||
try {
|
||||
const result = await listWorkbenchTools(kind, undefined, true);
|
||||
setTools(result.tools.sort((a, b) => a.sortOrder - b.sortOrder));
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "工作台工具加载失败");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [kind, message]);
|
||||
useEffect(() => { void reload(); }, [reload]);
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
const visibleTools = useMemo(() => tools.filter((item) => !query.trim() || `${item.name} ${item.subtitle} ${item.description}`.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase())), [query, tools]);
|
||||
|
||||
const openEditor = async (tool?: WorkbenchTool) => {
|
||||
@@ -129,7 +243,7 @@ export default function WorkbenchToolsPage() {
|
||||
if (tool) {
|
||||
[next.iconUrl, next.coverUrl] = await Promise.all([resolveWorkbenchResourceURL(next.iconResourceId), resolveWorkbenchResourceURL(next.coverResourceId)]);
|
||||
next.inputContract.slots = await Promise.all(next.inputContract.slots.map(async (slot) => ({ ...slot, coverUrl: await resolveWorkbenchResourceURL(slot.coverResourceId), sampleUrl: await resolveWorkbenchResourceURL(slot.sampleResourceId) })));
|
||||
next.resultExample.imageUrls = await Promise.all((next.resultExample.imageResourceIds || []).map(async (id) => await resolveWorkbenchResourceURL(id) || ""));
|
||||
next.resultExample.imageUrls = await Promise.all((next.resultExample.imageResourceIds || []).map(async (id) => (await resolveWorkbenchResourceURL(id)) || ""));
|
||||
next.resultExample.videoUrl = await resolveWorkbenchResourceURL(next.resultExample.videoResourceId);
|
||||
}
|
||||
setDraft(next);
|
||||
@@ -152,93 +266,381 @@ export default function WorkbenchToolsPage() {
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "文件上传失败");
|
||||
throw error;
|
||||
} finally { setUploadCount((count) => count - 1); }
|
||||
} finally {
|
||||
setUploadCount((count) => count - 1);
|
||||
}
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (editorBusy) return;
|
||||
let values: ToolDraft & { resultTitle?: string; resultDescription?: string };
|
||||
try { values = await form.validateFields(); } catch { return; }
|
||||
try {
|
||||
values = await form.validateFields();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const slots = draft.inputContract.slots;
|
||||
if (slots.some((slot) => !slot.name.trim() || !slot.mediaTypes.length || slot.minCount > slot.maxCount)) { message.warning("请补齐输入名称、媒体类型和有效数量范围"); return; }
|
||||
if (draft.presets.some((preset) => !preset.name.trim() || !preset.promptTemplate.trim())) { message.warning("请补齐预设名称和提示词"); return; }
|
||||
if (slots.some((slot) => !slot.name.trim() || !slot.mediaTypes.length || slot.minCount > slot.maxCount)) {
|
||||
message.warning("请补齐输入名称、媒体类型和有效数量范围");
|
||||
return;
|
||||
}
|
||||
if (draft.presets.some((preset) => !preset.name.trim() || !preset.promptTemplate.trim())) {
|
||||
message.warning("请补齐预设名称和提示词");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await saveAdminWorkbenchTool({
|
||||
id: draft.id, kind: draft.kind, categoryId: values.categoryId, name: values.name.trim(), subtitle: values.subtitle?.trim() || "", description: values.description.trim(), iconResourceId: draft.iconResourceId || "", coverResourceId: draft.coverResourceId || "", sortOrder: values.sortOrder || 0, enabled: values.enabled,
|
||||
capability: { ...draft.capability, supportedModes: values.supportedModes, searchKeywords: values.searchKeywords, surfaces: values.surfaces, recommended: values.recommended, recommendationOrder: values.recommendationOrder || 0,
|
||||
inputContract: { ...draft.inputContract, slots: slots.map(({ coverUrl: _coverURL, sampleUrl: _sampleURL, ...slot }) => slot) } },
|
||||
guide: { ...draft.guideConfig, lines: values.guideText.trim().split("\n").filter(Boolean), inputHint: values.inputHint.trim(), resultExample: { title: values.resultTitle?.trim() || "", description: values.resultDescription?.trim() || "", imageResourceIds: draft.resultExample.imageResourceIds || [], videoResourceId: draft.resultExample.videoResourceId || "" } },
|
||||
id: draft.id,
|
||||
kind: draft.kind,
|
||||
categoryId: values.categoryId,
|
||||
name: values.name.trim(),
|
||||
subtitle: values.subtitle?.trim() || "",
|
||||
description: values.description.trim(),
|
||||
iconResourceId: draft.iconResourceId || "",
|
||||
coverResourceId: draft.coverResourceId || "",
|
||||
sortOrder: values.sortOrder || 0,
|
||||
enabled: values.enabled,
|
||||
capability: {
|
||||
...draft.capability,
|
||||
supportedModes: values.supportedModes,
|
||||
searchKeywords: values.searchKeywords,
|
||||
surfaces: values.surfaces,
|
||||
recommended: values.recommended,
|
||||
recommendationOrder: values.recommendationOrder || 0,
|
||||
inputContract: { ...draft.inputContract, slots: slots.map(({ coverUrl: _coverURL, sampleUrl: _sampleURL, ...slot }) => slot) },
|
||||
},
|
||||
guide: {
|
||||
...draft.guideConfig,
|
||||
lines: values.guideText.trim().split("\n").filter(Boolean),
|
||||
inputHint: values.inputHint.trim(),
|
||||
resultExample: { title: values.resultTitle?.trim() || "", description: values.resultDescription?.trim() || "", imageResourceIds: draft.resultExample.imageResourceIds || [], videoResourceId: draft.resultExample.videoResourceId || "" },
|
||||
},
|
||||
presets: draft.presets.map((preset, index) => ({ ...preset, id: draft.savedPresetIds.includes(preset.id) ? preset.id : "", sortOrder: index })),
|
||||
});
|
||||
setEditorOpen(false);
|
||||
await reload();
|
||||
message.success(saved.enabled ? "工具已保存并开放" : "工具已保存为未开放");
|
||||
} catch (error) { message.error(error instanceof Error ? error.message : "工具保存失败"); }
|
||||
finally { setSaving(false); }
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "工具保存失败");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggle = async (tool: WorkbenchTool, enabled: boolean) => {
|
||||
try { await setAdminWorkbenchToolEnabled(tool.id, enabled); await reload(); message.success(enabled ? "工具已开放" : "工具已下线"); }
|
||||
catch (error) { message.error(error instanceof Error ? error.message : "工具状态更新失败"); }
|
||||
try {
|
||||
await setAdminWorkbenchToolEnabled(tool.id, enabled);
|
||||
await reload();
|
||||
message.success(enabled ? "工具已开放" : "工具已下线");
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : "工具状态更新失败");
|
||||
}
|
||||
};
|
||||
const updateSlot = (key: string, patch: Partial<WorkbenchReferenceSlot>) => setDraft((current) => ({ ...current, inputContract: { ...current.inputContract, slots: current.inputContract.slots.map((slot) => slot.key === key ? { ...slot, ...patch } : slot) } }));
|
||||
const updatePreset = (id: string, patch: Partial<WorkbenchPromptPreset>) => setDraft((current) => ({ ...current, presets: current.presets.map((preset) => preset.id === id ? { ...preset, ...patch } : preset) }));
|
||||
const updateSlot = (key: string, patch: Partial<WorkbenchReferenceSlot>) =>
|
||||
setDraft((current) => ({ ...current, inputContract: { ...current.inputContract, slots: current.inputContract.slots.map((slot) => (slot.key === key ? { ...slot, ...patch } : slot)) } }));
|
||||
const updatePreset = (id: string, patch: Partial<WorkbenchPromptPreset>) => setDraft((current) => ({ ...current, presets: current.presets.map((preset) => (preset.id === id ? { ...preset, ...patch } : preset)) }));
|
||||
const columns: ColumnsType<WorkbenchTool> = [
|
||||
{ title: "工具", dataIndex: "name", render: (_, tool) => <div className="admin-workbench-tool-main"><span className="admin-workbench-tool-icon">{tool.iconUrl ? <img src={tool.iconUrl} alt="" /> : <WandSparkles className="size-4" />}</span><span><strong>{tool.name}</strong><small>{tool.subtitle || tool.description}</small></span></div> },
|
||||
{
|
||||
title: "工具",
|
||||
dataIndex: "name",
|
||||
render: (_, tool) => (
|
||||
<div className="admin-workbench-tool-main">
|
||||
<span className="admin-workbench-tool-icon">{tool.iconUrl ? <img src={tool.iconUrl} alt="" /> : <WandSparkles className="size-4" />}</span>
|
||||
<span>
|
||||
<strong>{tool.name}</strong>
|
||||
<small>{tool.subtitle || tool.description}</small>
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: "分类", dataIndex: "categoryId", width: 140, align: "center", render: workbenchCategoryLabel },
|
||||
{ title: "预设", dataIndex: "presets", width: 90, align: "center", render: (value: WorkbenchPromptPreset[]) => `${value.filter((item) => item.enabled).length} 项启用` },
|
||||
{ title: "排序", dataIndex: "sortOrder", width: 80, align: "center" },
|
||||
{ title: "状态", dataIndex: "enabled", width: 120, align: "center", render: (enabled: boolean, tool) => <div className="admin-workbench-tool-status"><AdminStatusBadge label={enabled ? "已开放" : "未开放"} tone={enabled ? "success" : "neutral"} /><Switch checked={enabled} aria-label={`${enabled ? "下线" : "开放"}${tool.name}`} onChange={(checked) => void toggle(tool, checked)} /></div> },
|
||||
{ title: "操作", width: 120, align: "center", render: (_, tool) => <Button size="small" icon={<Edit3 className="size-3.5" />} onClick={() => void openEditor(tool)}>编辑</Button> },
|
||||
{
|
||||
title: "状态",
|
||||
dataIndex: "enabled",
|
||||
width: 120,
|
||||
align: "center",
|
||||
render: (enabled: boolean, tool) => (
|
||||
<div className="admin-workbench-tool-status">
|
||||
<AdminStatusBadge label={enabled ? "已开放" : "未开放"} tone={enabled ? "success" : "neutral"} />
|
||||
<Switch checked={enabled} aria-label={`${enabled ? "下线" : "开放"}${tool.name}`} onChange={(checked) => void toggle(tool, checked)} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: "操作",
|
||||
width: 120,
|
||||
align: "center",
|
||||
render: (_, tool) => (
|
||||
<Button size="small" icon={<Edit3 className="size-3.5" />} onClick={() => void openEditor(tool)}>
|
||||
编辑
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return <AdminPageFrame title="工作台工具" description="管理生图与视频的工具、场景输入和示例素材" scroll actions={<Button type="primary" icon={<Plus className="size-4" />} onClick={() => void openEditor()}>新建工具</Button>}>
|
||||
<div className="admin-workbench-tools">
|
||||
<div className="admin-workbench-tools-toolbar"><Select aria-label="工作台类型" value={kind} onChange={setKind} options={[{ label: "图片生成", value: "image" }, { label: "视频生成", value: "video" }]} /><Input aria-label="搜索工作台工具" prefix={<Search className="size-4" />} placeholder="搜索名称或说明" value={query} onChange={(event) => setQuery(event.target.value)} allowClear /><Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>刷新</Button></div>
|
||||
<AdminDataTable table={{ className: "app-data-table", rowKey: "id", loading, pagination: false, columns, dataSource: visibleTools, scroll: { x: 900 } }} empty={<div className="admin-workbench-tools-empty">当前类型还没有工具,使用“新建工具”创建首个目录项。</div>} />
|
||||
</div>
|
||||
<Modal title={draft.id ? "编辑工作台工具" : "新建工作台工具"} open={editorOpen} onCancel={() => !editorBusy && setEditorOpen(false)} onOk={() => void save()} okText={uploadCount ? "等待上传完成" : "保存"} okButtonProps={{ disabled: uploadCount > 0 }} cancelButtonProps={{ disabled: editorBusy }} confirmLoading={saving} maskClosable={!editorBusy} keyboard={!editorBusy} closable={!editorBusy} width={1080} destroyOnHidden className="admin-workbench-tool-editor">
|
||||
<Form form={form} layout="vertical" disabled={editorBusy} className="admin-workbench-tool-form">
|
||||
<div className="admin-workbench-tool-grid">
|
||||
<Form.Item label="工作台类型"><Select value={draft.kind} disabled={Boolean(draft.id) || editorBusy} options={[{ label: "图片生成", value: "image" }, { label: "视频生成", value: "video" }]} onChange={(value: WorkbenchKind) => {
|
||||
setDraft((current) => ({ ...current, kind: value, inputContract: { ...current.inputContract, slots: value === "image" ? current.inputContract.slots.map((slot) => ({ ...slot, mediaTypes: ["image"] })) : current.inputContract.slots } }));
|
||||
form.setFieldValue("supportedModes", [value]);
|
||||
}} /></Form.Item>
|
||||
<Form.Item name="categoryId" label="业务分类" rules={[{ required: true }]}><Select options={workbenchCategories} /></Form.Item>
|
||||
<Form.Item name="name" label="工具名称" rules={[{ required: true, whitespace: true, max: 80 }]}><Input maxLength={80} /></Form.Item>
|
||||
<Form.Item name="subtitle" label="副标题"><Input maxLength={160} /></Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序"><InputNumber min={0} max={1000000} /></Form.Item>
|
||||
<Form.Item name="enabled" label="用户端开放" valuePropName="checked"><Switch /></Form.Item>
|
||||
return (
|
||||
<AdminPageFrame
|
||||
title="工作台工具"
|
||||
description="管理生图与视频的工具、场景输入和示例素材"
|
||||
scroll
|
||||
actions={
|
||||
<Button type="primary" icon={<Plus className="size-4" />} onClick={() => void openEditor()}>
|
||||
新建工具
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="admin-workbench-tools">
|
||||
<div className="admin-workbench-tools-toolbar">
|
||||
<Select
|
||||
aria-label="工作台类型"
|
||||
value={kind}
|
||||
onChange={setKind}
|
||||
options={[
|
||||
{ label: "图片生成", value: "image" },
|
||||
{ label: "视频生成", value: "video" },
|
||||
]}
|
||||
/>
|
||||
<Input aria-label="搜索工作台工具" prefix={<Search className="size-4" />} placeholder="搜索名称或说明" value={query} onChange={(event) => setQuery(event.target.value)} allowClear />
|
||||
<Button icon={<RefreshCw className="size-4" />} loading={loading} onClick={() => void reload()}>
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
<Form.Item name="description" label="简短说明" rules={[{ required: true, whitespace: true, max: 500 }]}><Input.TextArea rows={2} maxLength={500} /></Form.Item>
|
||||
<div className="admin-workbench-resource-grid">
|
||||
<ResourceUpload label="工具图标" value={{ id: draft.iconResourceId, url: draft.iconUrl }} disabled={editorBusy} upload={upload} onChange={(value) => setDraft((current) => ({ ...current, iconResourceId: value.id, iconUrl: value.url }))} />
|
||||
<ResourceUpload label="工具封面" value={{ id: draft.coverResourceId, url: draft.coverUrl }} disabled={editorBusy} upload={upload} onChange={(value) => setDraft((current) => ({ ...current, coverResourceId: value.id, coverUrl: value.url }))} />
|
||||
</div>
|
||||
<div className="admin-workbench-section-title"><strong>用户需要提供什么</strong><small>配置模特、人脸、商品等输入,用户端按媒体类型和数量展示上传入口。</small></div>
|
||||
<div className="admin-workbench-contract-toolbar"><label>提示词必填 <Switch checked={draft.inputContract.promptRequired || draft.inputContract.minTextReferences > 0} onChange={(promptRequired) => setDraft((current) => ({ ...current, inputContract: { ...current.inputContract, promptRequired, minTextReferences: 0 } }))} /></label><Button size="small" disabled={draft.inputContract.slots.length >= 20} icon={<Plus className="size-3.5" />} onClick={() => setDraft((current) => ({ ...current, inputContract: { ...current.inputContract, slots: [...current.inputContract.slots, { key: crypto.randomUUID(), name: "", mediaTypes: ["image"], resourceKind: "asset", minCount: 0, maxCount: 1, required: false }] } }))}>添加输入</Button></div>
|
||||
<div className="admin-workbench-slots">{draft.inputContract.slots.map((slot, index) => <SlotEditor key={slot.key} slot={slot} index={index} kind={draft.kind} disabled={editorBusy} upload={upload} onChange={(patch) => updateSlot(slot.key, patch)} onRemove={() => setDraft((current) => ({ ...current, inputContract: { ...current.inputContract, slots: current.inputContract.slots.filter((item) => item.key !== slot.key) } }))} />)}</div>
|
||||
<div className="admin-workbench-section-title"><strong>使用说明与展示</strong></div>
|
||||
<div className="admin-workbench-tool-grid">
|
||||
<Form.Item name="inputHint" label="输入提示"><Input.TextArea rows={2} maxLength={500} /></Form.Item><Form.Item name="guideText" label="使用说明"><Input.TextArea rows={2} maxLength={5000} /></Form.Item>
|
||||
<Form.Item name="supportedModes" label="创作方式" rules={[{ required: true }]}><Select mode="multiple" options={workbenchModeOptions(draft.kind)} /></Form.Item>
|
||||
<Form.Item name="searchKeywords" label="搜索关键词"><Select mode="tags" tokenSeparators={[",", ","]} placeholder="输入关键词后按回车" /></Form.Item>
|
||||
<Form.Item name="surfaces" label="展示入口"><Select mode="multiple" options={[{ value: "workbench", label: "创作工作台" }]} /></Form.Item>
|
||||
<Form.Item name="recommendationOrder" label="推荐排序"><InputNumber min={0} max={1000000} /></Form.Item>
|
||||
<Form.Item name="recommended" label="推荐工具" valuePropName="checked"><Switch /></Form.Item>
|
||||
</div>
|
||||
<div className="admin-workbench-section-title"><strong>结果示例</strong><small>上传实际效果图片或视频,帮助用户了解工具用途。</small></div>
|
||||
<div className="admin-workbench-tool-grid"><Form.Item name="resultTitle" label="示例标题"><Input maxLength={120} /></Form.Item><Form.Item name="resultDescription" label="示例说明"><Input.TextArea rows={2} maxLength={500} /></Form.Item></div>
|
||||
<div className="admin-workbench-example-previews">{draft.resultExample.imageResourceIds?.map((id, index) => <ResourceUpload key={id} label={`图片示例 ${index + 1}`} value={{ id, url: draft.resultExample.imageUrls?.[index] }} disabled={editorBusy} upload={upload} onChange={(value) => setDraft((current) => ({ ...current, resultExample: { ...current.resultExample, imageResourceIds: current.resultExample.imageResourceIds?.flatMap((item, i) => i === index ? value.id ? [value.id] : [] : [item]), imageUrls: current.resultExample.imageResourceIds?.flatMap((_, i) => i === index ? value.id ? [value.url || ""] : [] : [current.resultExample.imageUrls?.[i] || ""]) } }))} />)}</div>
|
||||
<div className="admin-workbench-resource-grid">
|
||||
{(draft.resultExample.imageResourceIds?.length || 0) < 8 && <ResourceUpload label="添加图片示例" value={{}} disabled={editorBusy} upload={upload} onChange={(value) => value.id && setDraft((current) => ({ ...current, resultExample: { ...current.resultExample, imageResourceIds: [...(current.resultExample.imageResourceIds || []), value.id!], imageUrls: [...(current.resultExample.imageUrls || []), value.url || ""] } }))} />}
|
||||
{draft.kind === "video" && <ResourceUpload label="视频示例" kind="video" value={{ id: draft.resultExample.videoResourceId, url: draft.resultExample.videoUrl }} disabled={editorBusy} upload={upload} onChange={(value) => setDraft((current) => ({ ...current, resultExample: { ...current.resultExample, videoResourceId: value.id, videoUrl: value.url } }))} />}
|
||||
</div>
|
||||
<section className="admin-workbench-presets"><header><div><strong>预设提示词</strong><small>与工具一起保存;已保存的预设可停用,历史记录保留。</small></div><Button size="small" disabled={draft.presets.length >= 50} icon={<Plus className="size-3.5" />} onClick={() => setDraft((current) => ({ ...current, presets: [...current.presets, { id: crypto.randomUUID(), name: "", promptTemplate: "", summary: "", enabled: true }] }))}>添加预设</Button></header>
|
||||
{draft.presets.map((preset, index) => <div className="admin-workbench-preset-row" key={preset.id}><Input aria-label={`预设 ${index + 1} 名称`} placeholder="预设名称" value={preset.name} onChange={(event) => updatePreset(preset.id, { name: event.target.value })} /><Input aria-label={`预设 ${index + 1} 简介`} placeholder="一句话说明" value={preset.summary} onChange={(event) => updatePreset(preset.id, { summary: event.target.value })} /><Input.TextArea aria-label={`预设 ${index + 1} 提示词`} rows={3} placeholder="提示词模板" value={preset.promptTemplate} onChange={(event) => updatePreset(preset.id, { promptTemplate: event.target.value })} /><Switch aria-label={`启用预设 ${index + 1}`} checked={preset.enabled} onChange={(enabled) => updatePreset(preset.id, { enabled })} />{!draft.savedPresetIds.includes(preset.id) && <Button danger size="small" onClick={() => setDraft((current) => ({ ...current, presets: current.presets.filter((item) => item.id !== preset.id) }))}>移除</Button>}</div>)}
|
||||
</section>
|
||||
</Form>
|
||||
</Modal>
|
||||
</AdminPageFrame>;
|
||||
<AdminDataTable
|
||||
table={{ className: "app-data-table", rowKey: "id", loading, pagination: false, columns, dataSource: visibleTools, scroll: { x: 900 } }}
|
||||
empty={<div className="admin-workbench-tools-empty">当前类型还没有工具,使用“新建工具”创建首个目录项。</div>}
|
||||
/>
|
||||
</div>
|
||||
<Modal
|
||||
title={draft.id ? "编辑工作台工具" : "新建工作台工具"}
|
||||
open={editorOpen}
|
||||
onCancel={() => !editorBusy && setEditorOpen(false)}
|
||||
onOk={() => void save()}
|
||||
okText={uploadCount ? "等待上传完成" : "保存"}
|
||||
okButtonProps={{ disabled: uploadCount > 0 }}
|
||||
cancelButtonProps={{ disabled: editorBusy }}
|
||||
confirmLoading={saving}
|
||||
maskClosable={!editorBusy}
|
||||
keyboard={!editorBusy}
|
||||
closable={!editorBusy}
|
||||
width={1080}
|
||||
destroyOnHidden
|
||||
className="admin-workbench-tool-editor"
|
||||
>
|
||||
<Form form={form} layout="vertical" disabled={editorBusy} className="admin-workbench-tool-form">
|
||||
<div className="admin-workbench-tool-grid">
|
||||
<Form.Item label="工作台类型">
|
||||
<Select
|
||||
value={draft.kind}
|
||||
disabled={Boolean(draft.id) || editorBusy}
|
||||
options={[
|
||||
{ label: "图片生成", value: "image" },
|
||||
{ label: "视频生成", value: "video" },
|
||||
]}
|
||||
onChange={(value: WorkbenchKind) => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
kind: value,
|
||||
inputContract: { ...current.inputContract, slots: value === "image" ? current.inputContract.slots.map((slot) => ({ ...slot, mediaTypes: ["image"] })) : current.inputContract.slots },
|
||||
}));
|
||||
form.setFieldValue("supportedModes", [value]);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="categoryId" label="业务分类" rules={[{ required: true }]}>
|
||||
<Select options={workbenchCategories} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="工具名称" rules={[{ required: true, whitespace: true, max: 80 }]}>
|
||||
<Input maxLength={80} />
|
||||
</Form.Item>
|
||||
<Form.Item name="subtitle" label="副标题">
|
||||
<Input maxLength={160} />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortOrder" label="排序">
|
||||
<InputNumber min={0} max={1000000} />
|
||||
</Form.Item>
|
||||
<Form.Item name="enabled" label="用户端开放" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<Form.Item name="description" label="简短说明" rules={[{ required: true, whitespace: true, max: 500 }]}>
|
||||
<Input.TextArea rows={2} maxLength={500} />
|
||||
</Form.Item>
|
||||
<div className="admin-workbench-resource-grid">
|
||||
<ResourceUpload
|
||||
label="工具图标"
|
||||
value={{ id: draft.iconResourceId, url: draft.iconUrl }}
|
||||
disabled={editorBusy}
|
||||
upload={upload}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, iconResourceId: value.id, iconUrl: value.url }))}
|
||||
/>
|
||||
<ResourceUpload
|
||||
label="工具封面"
|
||||
value={{ id: draft.coverResourceId, url: draft.coverUrl }}
|
||||
disabled={editorBusy}
|
||||
upload={upload}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, coverResourceId: value.id, coverUrl: value.url }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-workbench-section-title">
|
||||
<strong>用户需要提供什么</strong>
|
||||
<small>配置模特、人脸、商品等输入,用户端按媒体类型和数量展示上传入口。</small>
|
||||
</div>
|
||||
<div className="admin-workbench-contract-toolbar">
|
||||
<label>
|
||||
提示词必填{" "}
|
||||
<Switch
|
||||
checked={draft.inputContract.promptRequired || draft.inputContract.minTextReferences > 0}
|
||||
onChange={(promptRequired) => setDraft((current) => ({ ...current, inputContract: { ...current.inputContract, promptRequired, minTextReferences: 0 } }))}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={draft.inputContract.slots.length >= 20}
|
||||
icon={<Plus className="size-3.5" />}
|
||||
onClick={() =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
inputContract: { ...current.inputContract, slots: [...current.inputContract.slots, { key: crypto.randomUUID(), name: "", mediaTypes: ["image"], resourceKind: "asset", minCount: 0, maxCount: 1, required: false }] },
|
||||
}))
|
||||
}
|
||||
>
|
||||
添加输入
|
||||
</Button>
|
||||
</div>
|
||||
<div className="admin-workbench-slots">
|
||||
{draft.inputContract.slots.map((slot, index) => (
|
||||
<SlotEditor
|
||||
key={slot.key}
|
||||
slot={slot}
|
||||
index={index}
|
||||
kind={draft.kind}
|
||||
disabled={editorBusy}
|
||||
upload={upload}
|
||||
onChange={(patch) => updateSlot(slot.key, patch)}
|
||||
onRemove={() => setDraft((current) => ({ ...current, inputContract: { ...current.inputContract, slots: current.inputContract.slots.filter((item) => item.key !== slot.key) } }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-workbench-section-title">
|
||||
<strong>使用说明与展示</strong>
|
||||
</div>
|
||||
<div className="admin-workbench-tool-grid">
|
||||
<Form.Item name="inputHint" label="输入提示">
|
||||
<Input.TextArea rows={2} maxLength={500} />
|
||||
</Form.Item>
|
||||
<Form.Item name="guideText" label="使用说明">
|
||||
<Input.TextArea rows={2} maxLength={5000} />
|
||||
</Form.Item>
|
||||
<Form.Item name="supportedModes" label="创作方式" rules={[{ required: true }]}>
|
||||
<Select mode="multiple" options={workbenchModeOptions(draft.kind)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="searchKeywords" label="搜索关键词">
|
||||
<Select mode="tags" tokenSeparators={[",", ","]} placeholder="输入关键词后按回车" />
|
||||
</Form.Item>
|
||||
<Form.Item name="surfaces" label="展示入口">
|
||||
<Select mode="multiple" options={[{ value: "workbench", label: "创作工作台" }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="recommendationOrder" label="推荐排序">
|
||||
<InputNumber min={0} max={1000000} />
|
||||
</Form.Item>
|
||||
<Form.Item name="recommended" label="推荐工具" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="admin-workbench-section-title">
|
||||
<strong>结果示例</strong>
|
||||
<small>上传实际效果图片或视频,帮助用户了解工具用途。</small>
|
||||
</div>
|
||||
<div className="admin-workbench-tool-grid">
|
||||
<Form.Item name="resultTitle" label="示例标题">
|
||||
<Input maxLength={120} />
|
||||
</Form.Item>
|
||||
<Form.Item name="resultDescription" label="示例说明">
|
||||
<Input.TextArea rows={2} maxLength={500} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="admin-workbench-example-previews">
|
||||
{draft.resultExample.imageResourceIds?.map((id, index) => (
|
||||
<ResourceUpload
|
||||
key={id}
|
||||
label={`图片示例 ${index + 1}`}
|
||||
value={{ id, url: draft.resultExample.imageUrls?.[index] }}
|
||||
disabled={editorBusy}
|
||||
upload={upload}
|
||||
onChange={(value) =>
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
resultExample: {
|
||||
...current.resultExample,
|
||||
imageResourceIds: current.resultExample.imageResourceIds?.flatMap((item, i) => (i === index ? (value.id ? [value.id] : []) : [item])),
|
||||
imageUrls: current.resultExample.imageResourceIds?.flatMap((_, i) => (i === index ? (value.id ? [value.url || ""] : []) : [current.resultExample.imageUrls?.[i] || ""])),
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-workbench-resource-grid">
|
||||
{(draft.resultExample.imageResourceIds?.length || 0) < 8 && (
|
||||
<ResourceUpload
|
||||
label="添加图片示例"
|
||||
value={{}}
|
||||
disabled={editorBusy}
|
||||
upload={upload}
|
||||
onChange={(value) =>
|
||||
value.id &&
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
resultExample: { ...current.resultExample, imageResourceIds: [...(current.resultExample.imageResourceIds || []), value.id!], imageUrls: [...(current.resultExample.imageUrls || []), value.url || ""] },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{draft.kind === "video" && (
|
||||
<ResourceUpload
|
||||
label="视频示例"
|
||||
kind="video"
|
||||
value={{ id: draft.resultExample.videoResourceId, url: draft.resultExample.videoUrl }}
|
||||
disabled={editorBusy}
|
||||
upload={upload}
|
||||
onChange={(value) => setDraft((current) => ({ ...current, resultExample: { ...current.resultExample, videoResourceId: value.id, videoUrl: value.url } }))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<section className="admin-workbench-presets">
|
||||
<header>
|
||||
<div>
|
||||
<strong>预设提示词</strong>
|
||||
<small>与工具一起保存;已保存的预设可停用,历史记录保留。</small>
|
||||
</div>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={draft.presets.length >= 50}
|
||||
icon={<Plus className="size-3.5" />}
|
||||
onClick={() => setDraft((current) => ({ ...current, presets: [...current.presets, { id: crypto.randomUUID(), name: "", promptTemplate: "", summary: "", enabled: true }] }))}
|
||||
>
|
||||
添加预设
|
||||
</Button>
|
||||
</header>
|
||||
{draft.presets.map((preset, index) => (
|
||||
<div className="admin-workbench-preset-row" key={preset.id}>
|
||||
<Input aria-label={`预设 ${index + 1} 名称`} placeholder="预设名称" value={preset.name} onChange={(event) => updatePreset(preset.id, { name: event.target.value })} />
|
||||
<Input aria-label={`预设 ${index + 1} 简介`} placeholder="一句话说明" value={preset.summary} onChange={(event) => updatePreset(preset.id, { summary: event.target.value })} />
|
||||
<Input.TextArea aria-label={`预设 ${index + 1} 提示词`} rows={3} placeholder="提示词模板" value={preset.promptTemplate} onChange={(event) => updatePreset(preset.id, { promptTemplate: event.target.value })} />
|
||||
<Switch aria-label={`启用预设 ${index + 1}`} checked={preset.enabled} onChange={(enabled) => updatePreset(preset.id, { enabled })} />
|
||||
{!draft.savedPresetIds.includes(preset.id) && (
|
||||
<Button danger size="small" onClick={() => setDraft((current) => ({ ...current, presets: current.presets.filter((item) => item.id !== preset.id) }))}>
|
||||
移除
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</Form>
|
||||
</Modal>
|
||||
</AdminPageFrame>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1,256 @@
|
||||
.admin-workbench-tools{display:flex;min-height:0;flex-direction:column;gap:14px}.admin-workbench-tools-toolbar{display:flex;align-items:center;gap:10px}.admin-workbench-tools-toolbar .ant-select{width:150px}.admin-workbench-tools-toolbar .ant-input-affix-wrapper{max-width:360px}.admin-workbench-tool-main{display:flex;align-items:center;gap:10px;text-align:left}.admin-workbench-tool-icon{display:grid;width:38px;height:38px;flex:none;place-items:center;overflow:hidden;border-radius:10px;background:var(--admin-layer-2);color:var(--admin-text-muted)}.admin-workbench-tool-icon img{width:100%;height:100%;object-fit:cover}.admin-workbench-tool-main strong,.admin-workbench-tool-main small{display:block}.admin-workbench-tool-main small{max-width:420px;overflow:hidden;color:var(--admin-text-muted);text-overflow:ellipsis;white-space:nowrap}.admin-workbench-tool-status{display:flex;align-items:center;justify-content:center;gap:10px}.admin-workbench-tools-empty{padding:48px 16px;text-align:center;color:var(--admin-text-muted)}.admin-workbench-tool-editor .ant-modal-body{max-height:min(76vh,860px);overflow:auto}.admin-workbench-tool-form{padding:4px 2px}.admin-workbench-tool-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:0 16px}.admin-workbench-resource-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:20px}.admin-workbench-resource-upload{display:flex;align-items:center;gap:12px;padding:12px;border:1px solid var(--admin-border-subtle);border-radius:12px;background:var(--admin-layer-1)}.admin-workbench-resource-preview{display:grid;width:72px;height:72px;flex:none;place-items:center;overflow:hidden;border-radius:10px;background:var(--admin-layer-2);color:var(--admin-text-muted)}.admin-workbench-resource-preview img,.admin-workbench-resource-preview video{width:100%;height:100%;object-fit:cover}.admin-workbench-resource-actions{display:flex;min-width:0;flex:1;flex-direction:column;gap:4px}.admin-workbench-resource-actions strong,.admin-workbench-resource-actions small{display:block}.admin-workbench-resource-actions small{color:var(--admin-text-muted)}.admin-workbench-resource-actions>div{display:flex;gap:8px;margin-top:4px}.admin-workbench-section-title{display:flex;flex-direction:column;gap:3px;margin:20px 0 12px;padding-top:16px;border-top:1px solid var(--admin-border-subtle)}.admin-workbench-section-title small{color:var(--admin-text-muted)}.admin-workbench-contract-toolbar{display:flex;align-items:center;gap:10px;margin-bottom:10px}.admin-workbench-contract-toolbar .ant-input-number,.admin-workbench-contract-toolbar .ant-input{max-width:180px}.admin-workbench-slots{display:flex;flex-direction:column;gap:10px}.admin-workbench-slot-row{display:flex;flex-direction:column;gap:10px;padding:12px;border-radius:12px;background:var(--admin-layer-2)}.admin-workbench-slot-fields{display:grid;grid-template-columns:1fr 1fr 1.25fr 130px 130px auto;align-items:center;gap:8px}.admin-workbench-slot-assets{display:flex;align-items:center;gap:8px}.admin-workbench-slot-assets small{color:var(--admin-text-muted)}.admin-workbench-example-actions{display:flex;gap:8px;margin:4px 0 12px}.admin-workbench-example-previews{display:flex;flex-wrap:wrap;gap:10px;margin-bottom:12px}.admin-workbench-example-previews>div{position:relative;display:flex;flex-direction:column;gap:5px}.admin-workbench-example-previews img,.admin-workbench-example-previews video{width:120px;height:84px;object-fit:cover;border-radius:8px;background:var(--admin-layer-2)}.admin-workbench-presets{margin-top:22px;padding-top:16px;border-top:1px solid var(--admin-border-subtle)}.admin-workbench-presets>header{display:flex;align-items:center;justify-content:space-between;margin-bottom:12px}.admin-workbench-presets>header strong,.admin-workbench-presets>header small{display:block}.admin-workbench-presets>header small{margin-top:3px;color:var(--admin-text-muted)}.admin-workbench-preset-row{display:grid;grid-template-columns:minmax(120px,1fr) minmax(140px,1fr) minmax(240px,2fr) auto auto;align-items:start;gap:8px;margin-bottom:10px;padding:12px;border-radius:10px;background:var(--admin-layer-2)}@media(max-width:900px){.admin-workbench-slot-fields{grid-template-columns:1fr 1fr 1fr}.admin-workbench-contract-toolbar{flex-wrap:wrap}}@media(max-width:760px){.admin-workbench-tools-toolbar{flex-wrap:wrap}.admin-workbench-tools-toolbar .ant-input-affix-wrapper{max-width:none;flex:1}.admin-workbench-tool-grid,.admin-workbench-resource-grid{grid-template-columns:1fr}.admin-workbench-slot-fields{grid-template-columns:1fr 1fr}.admin-workbench-preset-row{grid-template-columns:1fr 1fr}.admin-workbench-preset-row textarea{grid-column:1/-1}}
|
||||
.admin-workbench-tools {
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
.admin-workbench-tools-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.admin-workbench-tools-toolbar .ant-select {
|
||||
width: 150px;
|
||||
}
|
||||
.admin-workbench-tools-toolbar .ant-input-affix-wrapper {
|
||||
max-width: 360px;
|
||||
}
|
||||
.admin-workbench-tool-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.admin-workbench-tool-icon {
|
||||
display: grid;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
background: var(--admin-layer-2);
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
.admin-workbench-tool-icon img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.admin-workbench-tool-main strong,
|
||||
.admin-workbench-tool-main small {
|
||||
display: block;
|
||||
}
|
||||
.admin-workbench-tool-main small {
|
||||
max-width: 420px;
|
||||
overflow: hidden;
|
||||
color: var(--admin-text-muted);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-workbench-tool-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.admin-workbench-tools-empty {
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
.admin-workbench-tool-editor .ant-modal-body {
|
||||
max-height: min(76vh, 860px);
|
||||
overflow: auto;
|
||||
}
|
||||
.admin-workbench-tool-form {
|
||||
padding: 4px 2px;
|
||||
}
|
||||
.admin-workbench-tool-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0 16px;
|
||||
}
|
||||
.admin-workbench-resource-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.admin-workbench-resource-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--admin-border-subtle);
|
||||
border-radius: 12px;
|
||||
background: var(--admin-layer-1);
|
||||
}
|
||||
.admin-workbench-resource-preview {
|
||||
display: grid;
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
overflow: hidden;
|
||||
border-radius: 10px;
|
||||
background: var(--admin-layer-2);
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
.admin-workbench-resource-preview img,
|
||||
.admin-workbench-resource-preview video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.admin-workbench-resource-actions {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
.admin-workbench-resource-actions strong,
|
||||
.admin-workbench-resource-actions small {
|
||||
display: block;
|
||||
}
|
||||
.admin-workbench-resource-actions small {
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
.admin-workbench-resource-actions > div {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.admin-workbench-section-title {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
margin: 20px 0 12px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--admin-border-subtle);
|
||||
}
|
||||
.admin-workbench-section-title small {
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
.admin-workbench-contract-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.admin-workbench-contract-toolbar .ant-input-number,
|
||||
.admin-workbench-contract-toolbar .ant-input {
|
||||
max-width: 180px;
|
||||
}
|
||||
.admin-workbench-slots {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.admin-workbench-slot-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 12px;
|
||||
background: var(--admin-layer-2);
|
||||
}
|
||||
.admin-workbench-slot-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 1.25fr 130px 130px auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-workbench-slot-assets {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.admin-workbench-slot-assets small {
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
.admin-workbench-example-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 4px 0 12px;
|
||||
}
|
||||
.admin-workbench-example-previews {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.admin-workbench-example-previews > div {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
.admin-workbench-example-previews img,
|
||||
.admin-workbench-example-previews video {
|
||||
width: 120px;
|
||||
height: 84px;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
background: var(--admin-layer-2);
|
||||
}
|
||||
.admin-workbench-presets {
|
||||
margin-top: 22px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--admin-border-subtle);
|
||||
}
|
||||
.admin-workbench-presets > header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.admin-workbench-presets > header strong,
|
||||
.admin-workbench-presets > header small {
|
||||
display: block;
|
||||
}
|
||||
.admin-workbench-presets > header small {
|
||||
margin-top: 3px;
|
||||
color: var(--admin-text-muted);
|
||||
}
|
||||
.admin-workbench-preset-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 1fr) minmax(140px, 1fr) minmax(240px, 2fr) auto auto;
|
||||
align-items: start;
|
||||
gap: 8px;
|
||||
margin-bottom: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 10px;
|
||||
background: var(--admin-layer-2);
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.admin-workbench-slot-fields {
|
||||
grid-template-columns: 1fr 1fr 1fr;
|
||||
}
|
||||
.admin-workbench-contract-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.admin-workbench-tools-toolbar {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.admin-workbench-tools-toolbar .ant-input-affix-wrapper {
|
||||
max-width: none;
|
||||
flex: 1;
|
||||
}
|
||||
.admin-workbench-tool-grid,
|
||||
.admin-workbench-resource-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.admin-workbench-slot-fields {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.admin-workbench-preset-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
.admin-workbench-preset-row textarea {
|
||||
grid-column: 1/-1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +45,11 @@ export type WorkbenchReference = {
|
||||
|
||||
export function filterWorkbenchTools(tools: WorkbenchTool[], category: string, query: string) {
|
||||
const normalized = query.trim().toLocaleLowerCase();
|
||||
return tools.filter((tool) => (category === "all" || (category === "recommended" ? tool.recommended || tool.categoryId === category : tool.categoryId === category)) && (!normalized || `${tool.name} ${tool.subtitle} ${tool.description} ${workbenchCategoryLabel(tool.categoryId)} ${(tool.searchKeywords || []).join(" ")}`.toLocaleLowerCase().includes(normalized)));
|
||||
return tools.filter(
|
||||
(tool) =>
|
||||
(category === "all" || (category === "recommended" ? tool.recommended || tool.categoryId === category : tool.categoryId === category)) &&
|
||||
(!normalized || `${tool.name} ${tool.subtitle} ${tool.description} ${workbenchCategoryLabel(tool.categoryId)} ${(tool.searchKeywords || []).join(" ")}`.toLocaleLowerCase().includes(normalized)),
|
||||
);
|
||||
}
|
||||
|
||||
function parseInput(inputJson?: string) {
|
||||
@@ -76,9 +80,9 @@ type WorkbenchTaskSnapshot = {
|
||||
export function workbenchTaskSnapshot(task: GenerationTask): WorkbenchTaskSnapshot | null {
|
||||
const input = parseInput(task.inputJson);
|
||||
if (!input) return null;
|
||||
const metadata = input.metadata && typeof input.metadata === "object" ? input.metadata as Record<string, any> : {};
|
||||
const metadataSnapshot = metadata.workbench && typeof metadata.workbench === "object" ? metadata.workbench as Record<string, any> : {};
|
||||
const snapshot = input.workbench && typeof input.workbench === "object" ? input.workbench as Record<string, any> : metadataSnapshot;
|
||||
const metadata = input.metadata && typeof input.metadata === "object" ? (input.metadata as Record<string, any>) : {};
|
||||
const metadataSnapshot = metadata.workbench && typeof metadata.workbench === "object" ? (metadata.workbench as Record<string, any>) : {};
|
||||
const snapshot = input.workbench && typeof input.workbench === "object" ? (input.workbench as Record<string, any>) : metadataSnapshot;
|
||||
const kind = snapshot.kind || metadata.workbenchKind;
|
||||
if (kind !== "image" && kind !== "video") return null;
|
||||
const rawReferences = Array.isArray(snapshot.references) ? snapshot.references : [];
|
||||
@@ -195,21 +199,22 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
|
||||
const activeTasks = useMemo(() => tasks.filter((task) => task.status === "queued" || task.status === "running"), [tasks]);
|
||||
const historyTasks = useMemo(() => tasks.filter((task) => task.status !== "queued" && task.status !== "running"), [tasks]);
|
||||
const selectedCount = Number(count) || 1;
|
||||
const promptReferences = useMemo<CanvasResourceReference[]>(() => references.map((reference) => ({
|
||||
id: reference.id,
|
||||
nodeId: reference.id,
|
||||
kind: reference.kind,
|
||||
label: reference.name,
|
||||
title: reference.name,
|
||||
previewUrl: reference.previewUrl,
|
||||
...(reference.resourceId ? { storageKey: resourceStorageKey(reference.resourceId) } : {}),
|
||||
active: true,
|
||||
})), [references]);
|
||||
const composerConfig = useMemo(
|
||||
const promptReferences = useMemo<CanvasResourceReference[]>(
|
||||
() =>
|
||||
kind === "video" && selectedModel
|
||||
? creationVideoConfig(config, selectedModel, { ratio, seconds, videoQuality })
|
||||
: { ...config, model: selectedModel, imageModel: selectedModel, size: ratio, quality, count },
|
||||
references.map((reference) => ({
|
||||
id: reference.id,
|
||||
nodeId: reference.id,
|
||||
kind: reference.kind,
|
||||
label: reference.name,
|
||||
title: reference.name,
|
||||
previewUrl: reference.previewUrl,
|
||||
...(reference.resourceId ? { storageKey: resourceStorageKey(reference.resourceId) } : {}),
|
||||
active: true,
|
||||
})),
|
||||
[references],
|
||||
);
|
||||
const composerConfig = useMemo(
|
||||
() => (kind === "video" && selectedModel ? creationVideoConfig(config, selectedModel, { ratio, seconds, videoQuality }) : { ...config, model: selectedModel, imageModel: selectedModel, size: ratio, quality, count }),
|
||||
[config, count, kind, quality, ratio, seconds, selectedModel, videoQuality],
|
||||
);
|
||||
|
||||
@@ -435,7 +440,7 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
|
||||
const slot = tool?.inputContract?.slots.find((item) => item.key === targetSlotKey);
|
||||
const allowedTypes = slot?.mediaTypes || (kind === "image" ? ["image"] : ["image", "video", "audio"]);
|
||||
const accepted = list.filter((file) => allowedTypes.some((type) => file.type.startsWith(`${type}/`)));
|
||||
if (accepted.length !== list.length) message.warning(`该输入槽位仅支持${allowedTypes.map((type) => type === "image" ? "图片" : type === "video" ? "视频" : "音频").join("、")}`);
|
||||
if (accepted.length !== list.length) message.warning(`该输入槽位仅支持${allowedTypes.map((type) => (type === "image" ? "图片" : type === "video" ? "视频" : "音频")).join("、")}`);
|
||||
const currentCount = references.filter((reference) => reference.slotKey === targetSlotKey).length;
|
||||
const maxCount = slot?.maxCount || (kind === "image" ? imageProfile?.references.maxImages || 0 : (videoProfile?.references.maxImages || 0) + (videoProfile?.references.maxVideos || 0) + (videoProfile?.references.maxAudios || 0));
|
||||
const picked = accepted.slice(0, Math.max(0, maxCount - currentCount));
|
||||
@@ -510,7 +515,13 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
|
||||
const videoRequest = kind === "video" && videoProfile ? normalizeVideoValue(videoProfile, { seconds, ratio, resolution: videoQuality }) : null;
|
||||
const parameters = imageRequest
|
||||
? { size: imageRequest.size, quality: imageRequest.quality, count: Number(imageRequest.count) }
|
||||
: { seconds: Number(videoRequest?.seconds || seconds), ratio: videoRequest?.ratio || ratio, resolution: videoRequest?.resolution || videoQuality, generateAudio: composerConfig.videoGenerateAudio === "true", watermark: composerConfig.videoWatermark === "true" };
|
||||
: {
|
||||
seconds: Number(videoRequest?.seconds || seconds),
|
||||
ratio: videoRequest?.ratio || ratio,
|
||||
resolution: videoRequest?.resolution || videoQuality,
|
||||
generateAudio: composerConfig.videoGenerateAudio === "true",
|
||||
watermark: composerConfig.videoWatermark === "true",
|
||||
};
|
||||
const generationConfig = {
|
||||
...composerConfig,
|
||||
model: selectedModel,
|
||||
@@ -519,32 +530,63 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
|
||||
};
|
||||
const readyReferences = references.filter((reference) => reference.resourceId);
|
||||
const referenceSnapshot = readyReferences.map((reference) => ({ resourceId: reference.resourceId!, kind: reference.kind, role: reference.role, ...(reference.slotKey ? { slotKey: reference.slotKey } : {}) }));
|
||||
const createdTask = logicalModelId || channel?.scope === "system"
|
||||
? await createWorkbenchTask({
|
||||
kind,
|
||||
...(tool ? { toolId: tool.id } : {}),
|
||||
...(tool && preset ? { presetId: preset.id } : {}),
|
||||
prompt: submittedPrompt,
|
||||
model: modelOptionName(selectedModel),
|
||||
...(logicalModelId ? { logicalModelId } : { channelId: channel.id }),
|
||||
parameters,
|
||||
references: readyReferences.map((reference) => ({ resourceId: reference.resourceId!, kind: reference.kind, role: reference.role, ...(reference.slotKey ? { slotKey: reference.slotKey } : {}) })),
|
||||
})
|
||||
: await submitBackendGenerationTask({
|
||||
mode: kind,
|
||||
prompt: submittedPrompt,
|
||||
config: generationConfig,
|
||||
referenceImages: readyReferences.filter((reference) => reference.kind === "image").map((reference) => ({ id: reference.id, name: reference.name, type: "image/*", dataUrl: "", storageKey: resourceStorageKey(reference.resourceId!), role: reference.role, ...(reference.slotKey ? { slotKey: reference.slotKey } : {}) })),
|
||||
referenceVideos: readyReferences.filter((reference) => reference.kind === "video").map((reference) => ({ id: reference.id, name: reference.name, type: "video/*", url: "", storageKey: resourceStorageKey(reference.resourceId!), role: reference.role, ...(reference.slotKey ? { slotKey: reference.slotKey } : {}) })),
|
||||
referenceAudios: readyReferences.filter((reference) => reference.kind === "audio").map((reference) => ({ id: reference.id, name: reference.name, type: "audio/*", url: "", storageKey: resourceStorageKey(reference.resourceId!), role: reference.role, ...(reference.slotKey ? { slotKey: reference.slotKey } : {}) })),
|
||||
metadata: {
|
||||
source: "workbench",
|
||||
workbenchKind: kind,
|
||||
...(tool ? { workbenchToolId: tool.id, workbenchToolName: tool.name } : {}),
|
||||
...(tool && preset ? { workbenchPresetId: preset.id } : {}),
|
||||
workbench: { kind, ...(tool ? { toolId: tool.id } : {}), ...(tool && preset ? { presetId: preset.id } : {}), parameters, references: referenceSnapshot },
|
||||
},
|
||||
});
|
||||
const createdTask =
|
||||
logicalModelId || channel?.scope === "system"
|
||||
? await createWorkbenchTask({
|
||||
kind,
|
||||
...(tool ? { toolId: tool.id } : {}),
|
||||
...(tool && preset ? { presetId: preset.id } : {}),
|
||||
prompt: submittedPrompt,
|
||||
model: modelOptionName(selectedModel),
|
||||
...(logicalModelId ? { logicalModelId } : { channelId: channel.id }),
|
||||
parameters,
|
||||
references: readyReferences.map((reference) => ({ resourceId: reference.resourceId!, kind: reference.kind, role: reference.role, ...(reference.slotKey ? { slotKey: reference.slotKey } : {}) })),
|
||||
})
|
||||
: await submitBackendGenerationTask({
|
||||
mode: kind,
|
||||
prompt: submittedPrompt,
|
||||
config: generationConfig,
|
||||
referenceImages: readyReferences
|
||||
.filter((reference) => reference.kind === "image")
|
||||
.map((reference) => ({
|
||||
id: reference.id,
|
||||
name: reference.name,
|
||||
type: "image/*",
|
||||
dataUrl: "",
|
||||
storageKey: resourceStorageKey(reference.resourceId!),
|
||||
role: reference.role,
|
||||
...(reference.slotKey ? { slotKey: reference.slotKey } : {}),
|
||||
})),
|
||||
referenceVideos: readyReferences
|
||||
.filter((reference) => reference.kind === "video")
|
||||
.map((reference) => ({
|
||||
id: reference.id,
|
||||
name: reference.name,
|
||||
type: "video/*",
|
||||
url: "",
|
||||
storageKey: resourceStorageKey(reference.resourceId!),
|
||||
role: reference.role,
|
||||
...(reference.slotKey ? { slotKey: reference.slotKey } : {}),
|
||||
})),
|
||||
referenceAudios: readyReferences
|
||||
.filter((reference) => reference.kind === "audio")
|
||||
.map((reference) => ({
|
||||
id: reference.id,
|
||||
name: reference.name,
|
||||
type: "audio/*",
|
||||
url: "",
|
||||
storageKey: resourceStorageKey(reference.resourceId!),
|
||||
role: reference.role,
|
||||
...(reference.slotKey ? { slotKey: reference.slotKey } : {}),
|
||||
})),
|
||||
metadata: {
|
||||
source: "workbench",
|
||||
workbenchKind: kind,
|
||||
...(tool ? { workbenchToolId: tool.id, workbenchToolName: tool.name } : {}),
|
||||
...(tool && preset ? { workbenchPresetId: preset.id } : {}),
|
||||
workbench: { kind, ...(tool ? { toolId: tool.id } : {}), ...(tool && preset ? { presetId: preset.id } : {}), parameters, references: referenceSnapshot },
|
||||
},
|
||||
});
|
||||
const task = await queryGenerationTask(createdTask.id);
|
||||
setActiveTask(task);
|
||||
setTasks((current) => [task, ...current.filter((item) => item.id !== task.id)]);
|
||||
@@ -609,17 +651,19 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
|
||||
setUploadSlotKey(undefined);
|
||||
referencesRef.current.forEach(revokeReferencePreview);
|
||||
setReferences([]);
|
||||
const restoredReferences = await Promise.all(snapshot.references.map(async (reference, index): Promise<WorkbenchReference> => ({
|
||||
id: crypto.randomUUID(),
|
||||
name: `${reference.kind === "image" ? "图片" : reference.kind === "video" ? "视频" : "音频"} ${index + 1}`,
|
||||
kind: reference.kind,
|
||||
resourceId: reference.resourceId,
|
||||
previewUrl: await resolveWorkbenchResourceURL(reference.resourceId, "original") || "",
|
||||
progress: 100,
|
||||
uploading: false,
|
||||
role: reference.role || "reference",
|
||||
...(reference.slotKey ? { slotKey: reference.slotKey } : {}),
|
||||
})));
|
||||
const restoredReferences = await Promise.all(
|
||||
snapshot.references.map(async (reference, index): Promise<WorkbenchReference> => ({
|
||||
id: crypto.randomUUID(),
|
||||
name: `${reference.kind === "image" ? "图片" : reference.kind === "video" ? "视频" : "音频"} ${index + 1}`,
|
||||
kind: reference.kind,
|
||||
resourceId: reference.resourceId,
|
||||
previewUrl: (await resolveWorkbenchResourceURL(reference.resourceId, "original")) || "",
|
||||
progress: 100,
|
||||
uploading: false,
|
||||
role: reference.role || "reference",
|
||||
...(reference.slotKey ? { slotKey: reference.slotKey } : {}),
|
||||
})),
|
||||
);
|
||||
setReferences(restoredReferences);
|
||||
const params = new URLSearchParams(searchParams);
|
||||
if (restoredTool) params.set("tool", restoredTool.id);
|
||||
@@ -683,30 +727,87 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
|
||||
: "报价暂不可用";
|
||||
const resultTask = activeTask || tasks.find((task) => task.status === "succeeded") || null;
|
||||
const resultUrl = resultTask ? taskPreview(resultTask) : "";
|
||||
const settingsPanel = kind === "image" && imageProfile ? (
|
||||
<div className="workbench-settings-content">
|
||||
<ImageSizePicker profile={imageProfile} size={ratio} quality={quality} onChange={updateImageSettings} />
|
||||
{imageProfile.quality.supported && !imageResolutionUsesQuality(imageProfile) ? (
|
||||
<label className="workbench-setting-row">
|
||||
<span>图片质量</span>
|
||||
<Select value={quality} onChange={(value) => { setQuality(value); rememberImageSettings({ quality: value }); }} options={imageProfile.quality.values.map((value) => ({ value, label: imageQualityLabel(value) }))} />
|
||||
</label>
|
||||
) : null}
|
||||
<CountPicker value={count} max={imageProfile.maxOutputs} onChange={(value) => { setCount(value); rememberImageSettings({ count: value }); }} />
|
||||
</div>
|
||||
) : videoProfile ? (
|
||||
<div className="workbench-settings-content">
|
||||
<ParameterChips label="视频时长" value={seconds} options={(videoProfile.duration.selection === "enum" ? videoProfile.duration.values || [] : Array.from({ length: Math.floor(((videoProfile.duration.max || 15) - (videoProfile.duration.min || 1)) / (videoProfile.duration.step || 1)) + 1 }, (_, index) => (videoProfile.duration.min || 1) + index * (videoProfile.duration.step || 1))).map((value) => ({ value: String(value), label: `${value} 秒` }))} onChange={(value) => { setSeconds(value); rememberVideoSettings({ seconds: value }); }} />
|
||||
<ParameterChips label="画面比例" value={ratio} options={videoProfile.ratios.map((value) => ({ value, label: value === "adaptive" ? "自适应" : value }))} onChange={(value) => { setRatio(value); rememberVideoSettings({ ratio: value }); }} />
|
||||
<ParameterChips label="清晰度" value={videoQuality} options={videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: value }))} onChange={(value) => { setVideoQuality(value); rememberVideoSettings({ videoQuality: value }); }} />
|
||||
{videoProfile.generateAudio.supported || videoProfile.watermark.supported ? (
|
||||
<div className="workbench-switch-row">
|
||||
{videoProfile.generateAudio.supported ? <label><span>生成音频</span><Switch checked={config.videoGenerateAudio === "true"} onChange={(value) => updateConfig("videoGenerateAudio", String(value))} /></label> : null}
|
||||
{videoProfile.watermark.supported ? <label><span>添加水印</span><Switch checked={config.videoWatermark === "true"} onChange={(value) => updateConfig("videoWatermark", String(value))} /></label> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : <p className="workbench-list-empty">选择模型后,这里会显示该模型真正支持的参数。</p>;
|
||||
const settingsPanel =
|
||||
kind === "image" && imageProfile ? (
|
||||
<div className="workbench-settings-content">
|
||||
<ImageSizePicker profile={imageProfile} size={ratio} quality={quality} onChange={updateImageSettings} />
|
||||
{imageProfile.quality.supported && !imageResolutionUsesQuality(imageProfile) ? (
|
||||
<label className="workbench-setting-row">
|
||||
<span>图片质量</span>
|
||||
<Select
|
||||
value={quality}
|
||||
onChange={(value) => {
|
||||
setQuality(value);
|
||||
rememberImageSettings({ quality: value });
|
||||
}}
|
||||
options={imageProfile.quality.values.map((value) => ({ value, label: imageQualityLabel(value) }))}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<CountPicker
|
||||
value={count}
|
||||
max={imageProfile.maxOutputs}
|
||||
onChange={(value) => {
|
||||
setCount(value);
|
||||
rememberImageSettings({ count: value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : videoProfile ? (
|
||||
<div className="workbench-settings-content">
|
||||
<ParameterChips
|
||||
label="视频时长"
|
||||
value={seconds}
|
||||
options={(videoProfile.duration.selection === "enum"
|
||||
? videoProfile.duration.values || []
|
||||
: Array.from(
|
||||
{ length: Math.floor(((videoProfile.duration.max || 15) - (videoProfile.duration.min || 1)) / (videoProfile.duration.step || 1)) + 1 },
|
||||
(_, index) => (videoProfile.duration.min || 1) + index * (videoProfile.duration.step || 1),
|
||||
)
|
||||
).map((value) => ({ value: String(value), label: `${value} 秒` }))}
|
||||
onChange={(value) => {
|
||||
setSeconds(value);
|
||||
rememberVideoSettings({ seconds: value });
|
||||
}}
|
||||
/>
|
||||
<ParameterChips
|
||||
label="画面比例"
|
||||
value={ratio}
|
||||
options={videoProfile.ratios.map((value) => ({ value, label: value === "adaptive" ? "自适应" : value }))}
|
||||
onChange={(value) => {
|
||||
setRatio(value);
|
||||
rememberVideoSettings({ ratio: value });
|
||||
}}
|
||||
/>
|
||||
<ParameterChips
|
||||
label="清晰度"
|
||||
value={videoQuality}
|
||||
options={videoProfile.resolutions.map((value) => ({ value: value.replace(/p$/i, ""), label: value }))}
|
||||
onChange={(value) => {
|
||||
setVideoQuality(value);
|
||||
rememberVideoSettings({ videoQuality: value });
|
||||
}}
|
||||
/>
|
||||
{videoProfile.generateAudio.supported || videoProfile.watermark.supported ? (
|
||||
<div className="workbench-switch-row">
|
||||
{videoProfile.generateAudio.supported ? (
|
||||
<label>
|
||||
<span>生成音频</span>
|
||||
<Switch checked={config.videoGenerateAudio === "true"} onChange={(value) => updateConfig("videoGenerateAudio", String(value))} />
|
||||
</label>
|
||||
) : null}
|
||||
{videoProfile.watermark.supported ? (
|
||||
<label>
|
||||
<span>添加水印</span>
|
||||
<Switch checked={config.videoWatermark === "true"} onChange={(value) => updateConfig("videoWatermark", String(value))} />
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="workbench-list-empty">选择模型后,这里会显示该模型真正支持的参数。</p>
|
||||
);
|
||||
const toolLibrary = (
|
||||
<div className="workbench-tool-library" id="workbench-tool-library">
|
||||
<div className="workbench-tool-library-heading">
|
||||
@@ -722,7 +823,11 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
|
||||
</div>
|
||||
<Input aria-label="搜索创作工具" prefix={<Search className="size-4" />} value={toolSearch} onChange={(event) => setToolSearch(event.target.value)} placeholder="搜索工具" allowClear />
|
||||
<div className="workbench-tool-categories" role="tablist" aria-label="工具分类">
|
||||
{[{ value: "all", label: "全部" }, ...workbenchCategories].map((category) => <button type="button" key={category.value} role="tab" aria-selected={toolCategory === category.value} className={toolCategory === category.value ? "is-active" : ""} onClick={() => setToolCategory(category.value)}>{category.label}</button>)}
|
||||
{[{ value: "all", label: "全部" }, ...workbenchCategories].map((category) => (
|
||||
<button type="button" key={category.value} role="tab" aria-selected={toolCategory === category.value} className={toolCategory === category.value ? "is-active" : ""} onClick={() => setToolCategory(category.value)}>
|
||||
{category.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{directoryLoading ? (
|
||||
<div className="workbench-picker-state">
|
||||
@@ -737,7 +842,11 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
|
||||
setDirectoryError("");
|
||||
setDirectoryLoading(true);
|
||||
listWorkbenchTools(kind)
|
||||
.then((directory) => setTools(directory.tools.filter((item) => item.kind === kind && item.enabled !== false && (!capabilitySupportedModes(item).length || capabilitySupportedModes(item).includes(kind))).sort((a, b) => a.sortOrder - b.sortOrder)))
|
||||
.then((directory) =>
|
||||
setTools(
|
||||
directory.tools.filter((item) => item.kind === kind && item.enabled !== false && (!capabilitySupportedModes(item).length || capabilitySupportedModes(item).includes(kind))).sort((a, b) => a.sortOrder - b.sortOrder),
|
||||
),
|
||||
)
|
||||
.catch((error) => setDirectoryError(error instanceof Error ? error.message : "工具目录加载失败"))
|
||||
.finally(() => setDirectoryLoading(false));
|
||||
}}
|
||||
@@ -776,7 +885,15 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
|
||||
<div className="workbench-composer">
|
||||
<div className="workbench-tool-bar">
|
||||
<span className="workbench-kind-label">{kind === "image" ? "图片生成" : "视频生成"}</span>
|
||||
<Popover open={toolLibraryOpen} onOpenChange={setToolLibraryOpen} trigger="click" placement="bottomLeft" arrow={false} classNames={{ root: "workbench-tool-popover", container: "workbench-tool-popover-surface", content: "workbench-tool-popover-content" }} content={toolLibrary}>
|
||||
<Popover
|
||||
open={toolLibraryOpen}
|
||||
onOpenChange={setToolLibraryOpen}
|
||||
trigger="click"
|
||||
placement="bottomLeft"
|
||||
arrow={false}
|
||||
classNames={{ root: "workbench-tool-popover", container: "workbench-tool-popover-surface", content: "workbench-tool-popover-content" }}
|
||||
content={toolLibrary}
|
||||
>
|
||||
<button type="button" className="workbench-tool-choice" aria-expanded={toolLibraryOpen} aria-haspopup="dialog" aria-controls="workbench-tool-library">
|
||||
<span className="workbench-tool-choice-mark">{tool?.iconUrl ? <img src={tool.iconUrl} alt="" /> : kind === "image" ? <FileImage /> : <Video />}</span>
|
||||
<span className="workbench-tool-choice-copy">
|
||||
@@ -788,106 +905,154 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
|
||||
</Popover>
|
||||
</div>
|
||||
<div className="workbench-studio">
|
||||
<div className="workbench-prompt-surface">
|
||||
<CanvasResourceMentionTextarea
|
||||
aria-label="生成提示词,可输入 @ 引用参考素材"
|
||||
className="workbench-prompt-input workbench-prompt-rich-editor"
|
||||
containerClassName="workbench-prompt-mention-container"
|
||||
value={prompt}
|
||||
references={promptReferences}
|
||||
onChange={setPrompt}
|
||||
onSubmit={() => void submit()}
|
||||
sendOnEnter={false}
|
||||
style={{ color: "var(--wb-text)" }}
|
||||
placeholder={kind === "image" ? "写下要生成的画面:主体、场景、构图、光线、风格" : "写下镜头里发生什么:主体、运动、光线、节奏、转场"}
|
||||
maxLength={kind === "image" ? imageProfile?.references.promptMaxChars || 32000 : videoProfile?.references.promptMaxChars || 8000}
|
||||
/>
|
||||
<div className="workbench-prompt-meta">
|
||||
<span>
|
||||
{prompt.length} / {kind === "image" ? imageProfile?.references.promptMaxChars || 32000 : videoProfile?.references.promptMaxChars || 8000}
|
||||
<div className="workbench-prompt-surface">
|
||||
<CanvasResourceMentionTextarea
|
||||
aria-label="生成提示词,可输入 @ 引用参考素材"
|
||||
className="workbench-prompt-input workbench-prompt-rich-editor"
|
||||
containerClassName="workbench-prompt-mention-container"
|
||||
value={prompt}
|
||||
references={promptReferences}
|
||||
onChange={setPrompt}
|
||||
onSubmit={() => void submit()}
|
||||
sendOnEnter={false}
|
||||
style={{ color: "var(--wb-text)" }}
|
||||
placeholder={kind === "image" ? "写下要生成的画面:主体、场景、构图、光线、风格" : "写下镜头里发生什么:主体、运动、光线、节奏、转场"}
|
||||
maxLength={kind === "image" ? imageProfile?.references.promptMaxChars || 32000 : videoProfile?.references.promptMaxChars || 8000}
|
||||
/>
|
||||
<div className="workbench-prompt-meta">
|
||||
<span>
|
||||
{prompt.length} / {kind === "image" ? imageProfile?.references.promptMaxChars || 32000 : videoProfile?.references.promptMaxChars || 8000}
|
||||
</span>
|
||||
<span>⌘ / Ctrl + Enter</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="workbench-composer-toolbar">
|
||||
<span className="workbench-ref-count">
|
||||
参考素材 {references.length} /{" "}
|
||||
{kind === "image" ? imageProfile?.references.maxImages || 0 : (videoProfile?.references.maxImages || 0) + (videoProfile?.references.maxVideos || 0) + (videoProfile?.references.maxAudios || 0)}
|
||||
</span>
|
||||
<span>⌘ / Ctrl + Enter</span>
|
||||
<div className="workbench-toolbar-actions">
|
||||
<Tooltip title="添加参考素材">
|
||||
<button type="button" aria-label="添加参考素材" onClick={() => fileRef.current?.click()}>
|
||||
<Upload />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="清空参考素材">
|
||||
<button type="button" aria-label="清空参考素材" onClick={() => references.forEach(removeReference)} disabled={!references.length}>
|
||||
<X />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="workbench-composer-toolbar">
|
||||
<span className="workbench-ref-count">
|
||||
参考素材 {references.length} / {kind === "image" ? imageProfile?.references.maxImages || 0 : (videoProfile?.references.maxImages || 0) + (videoProfile?.references.maxVideos || 0) + (videoProfile?.references.maxAudios || 0)}
|
||||
</span>
|
||||
<div className="workbench-toolbar-actions">
|
||||
<Tooltip title="添加参考素材">
|
||||
<button type="button" aria-label="添加参考素材" onClick={() => fileRef.current?.click()}>
|
||||
<Upload />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<Tooltip title="清空参考素材">
|
||||
<button type="button" aria-label="清空参考素材" onClick={() => references.forEach(removeReference)} disabled={!references.length}>
|
||||
<X />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{tool?.inputContract?.slots.length ? (
|
||||
<div className="workbench-reference-slots">
|
||||
{tool.inputContract.slots.map((slot) => {
|
||||
const slotReferences = references.filter((reference) => reference.slotKey === slot.key);
|
||||
const mediaLabel = slot.mediaTypes.map((type) => type === "image" ? "图片" : type === "video" ? "视频" : "音频").join(" / ");
|
||||
const openSlotPicker = () => { setUploadSlotKey(slot.key); fileRef.current?.click(); };
|
||||
return (
|
||||
<section className="workbench-reference-slot" key={slot.key}>
|
||||
<div className="workbench-reference-slot-heading">
|
||||
<div className="workbench-reference-slot-copy">
|
||||
<div className="workbench-reference-slot-title">
|
||||
<strong>{slot.name || "参考素材"}</strong>
|
||||
<span className={`workbench-reference-slot-requirement${slot.required ? " is-required" : ""}`}>{slot.required ? "必填" : "可选"}</span>
|
||||
</div>
|
||||
<div className="workbench-reference-slot-meta">
|
||||
<span>{slot.minCount}-{slot.maxCount} 个</span>
|
||||
<span>{mediaLabel}</span>
|
||||
{tool?.inputContract?.slots.length ? (
|
||||
<div className="workbench-reference-slots">
|
||||
{tool.inputContract.slots.map((slot) => {
|
||||
const slotReferences = references.filter((reference) => reference.slotKey === slot.key);
|
||||
const mediaLabel = slot.mediaTypes.map((type) => (type === "image" ? "图片" : type === "video" ? "视频" : "音频")).join(" / ");
|
||||
const openSlotPicker = () => {
|
||||
setUploadSlotKey(slot.key);
|
||||
fileRef.current?.click();
|
||||
};
|
||||
return (
|
||||
<section className="workbench-reference-slot" key={slot.key}>
|
||||
<div className="workbench-reference-slot-heading">
|
||||
<div className="workbench-reference-slot-copy">
|
||||
<div className="workbench-reference-slot-title">
|
||||
<strong>{slot.name || "参考素材"}</strong>
|
||||
<span className={`workbench-reference-slot-requirement${slot.required ? " is-required" : ""}`}>{slot.required ? "必填" : "可选"}</span>
|
||||
</div>
|
||||
<div className="workbench-reference-slot-meta">
|
||||
<span>
|
||||
{slot.minCount}-{slot.maxCount} 个
|
||||
</span>
|
||||
<span>{mediaLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
{slotReferences.length ? (
|
||||
<button type="button" className="workbench-slot-upload" onClick={openSlotPicker} aria-label={`为${slot.name}添加素材`} disabled={slotReferences.length >= slot.maxCount}>
|
||||
<ImagePlus /> <span>添加</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{slotReferences.length ? (
|
||||
<button type="button" className="workbench-slot-upload" onClick={openSlotPicker} aria-label={`为${slot.name}添加素材`} disabled={slotReferences.length >= slot.maxCount}>
|
||||
<ImagePlus /> <span>添加</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="workbench-reference-grid">
|
||||
{slotReferences.map((reference) => <ReferenceCard key={reference.id} reference={reference} kind={kind} onRemove={removeReference} onRoleChange={(role) => setReferences((current) => current.map((item) => (item.id === reference.id ? { ...item, role } : item)))} />)}
|
||||
{!slotReferences.length ? (
|
||||
<button type="button" className="workbench-reference-slot-empty" onClick={openSlotPicker} disabled={slotReferences.length >= slot.maxCount}>
|
||||
<span className="workbench-reference-slot-empty-icon"><ImagePlus /></span>
|
||||
<span className="workbench-reference-slot-empty-copy"><strong>上传{slot.name || "参考"}素材</strong><small>从设备中选择文件</small></span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="workbench-reference-grid">
|
||||
{references.map((reference) => <ReferenceCard key={reference.id} reference={reference} kind={kind} onRemove={removeReference} onRoleChange={(role) => setReferences((current) => current.map((item) => (item.id === reference.id ? { ...item, role } : item)))} />)}
|
||||
{!references.length || (kind === "video" && references.length < 5) ? <button type="button" className="workbench-reference-add" onClick={() => { setUploadSlotKey(undefined); fileRef.current?.click(); }}><ImagePlus /><span>添加{kind === "image" ? "参考图" : "参考素材"}</span></button> : null}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
className="sr-only"
|
||||
accept={uploadSlotKey ? tool?.inputContract?.slots.find((slot) => slot.key === uploadSlotKey)?.mediaTypes.map((type) => `${type}/*`).join(",") : kind === "image" ? "image/*" : "image/*,video/*,audio/*"}
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
if (event.target.files) void addFiles(event.target.files, uploadSlotKey);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
<section className="workbench-parameters" aria-label="生成参数">
|
||||
<header>
|
||||
<strong>{kind === "image" ? "图片参数" : "视频参数"}</strong>
|
||||
<span>{kind === "image" ? "尺寸、质量和数量" : "时长、画幅、清晰度和音频"}</span>
|
||||
</header>
|
||||
{selectedModel ? settingsPanel : <p className="workbench-list-empty">先选择模型,再调整尺寸、数量或时长。</p>}
|
||||
</section>
|
||||
<div className="workbench-reference-grid">
|
||||
{slotReferences.map((reference) => (
|
||||
<ReferenceCard
|
||||
key={reference.id}
|
||||
reference={reference}
|
||||
kind={kind}
|
||||
onRemove={removeReference}
|
||||
onRoleChange={(role) => setReferences((current) => current.map((item) => (item.id === reference.id ? { ...item, role } : item)))}
|
||||
/>
|
||||
))}
|
||||
{!slotReferences.length ? (
|
||||
<button type="button" className="workbench-reference-slot-empty" onClick={openSlotPicker} disabled={slotReferences.length >= slot.maxCount}>
|
||||
<span className="workbench-reference-slot-empty-icon">
|
||||
<ImagePlus />
|
||||
</span>
|
||||
<span className="workbench-reference-slot-empty-copy">
|
||||
<strong>上传{slot.name || "参考"}素材</strong>
|
||||
<small>从设备中选择文件</small>
|
||||
</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="workbench-reference-grid">
|
||||
{references.map((reference) => (
|
||||
<ReferenceCard
|
||||
key={reference.id}
|
||||
reference={reference}
|
||||
kind={kind}
|
||||
onRemove={removeReference}
|
||||
onRoleChange={(role) => setReferences((current) => current.map((item) => (item.id === reference.id ? { ...item, role } : item)))}
|
||||
/>
|
||||
))}
|
||||
{!references.length || (kind === "video" && references.length < 5) ? (
|
||||
<button
|
||||
type="button"
|
||||
className="workbench-reference-add"
|
||||
onClick={() => {
|
||||
setUploadSlotKey(undefined);
|
||||
fileRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<ImagePlus />
|
||||
<span>添加{kind === "image" ? "参考图" : "参考素材"}</span>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
className="sr-only"
|
||||
accept={
|
||||
uploadSlotKey
|
||||
? tool?.inputContract?.slots
|
||||
.find((slot) => slot.key === uploadSlotKey)
|
||||
?.mediaTypes.map((type) => `${type}/*`)
|
||||
.join(",")
|
||||
: kind === "image"
|
||||
? "image/*"
|
||||
: "image/*,video/*,audio/*"
|
||||
}
|
||||
multiple
|
||||
onChange={(event) => {
|
||||
if (event.target.files) void addFiles(event.target.files, uploadSlotKey);
|
||||
event.currentTarget.value = "";
|
||||
}}
|
||||
/>
|
||||
<section className="workbench-parameters" aria-label="生成参数">
|
||||
<header>
|
||||
<strong>{kind === "image" ? "图片参数" : "视频参数"}</strong>
|
||||
<span>{kind === "image" ? "尺寸、质量和数量" : "时长、画幅、清晰度和音频"}</span>
|
||||
</header>
|
||||
{selectedModel ? settingsPanel : <p className="workbench-list-empty">先选择模型,再调整尺寸、数量或时长。</p>}
|
||||
</section>
|
||||
</div>
|
||||
<div className="workbench-composer-dock" aria-label="生成设置">
|
||||
{models.length ? (
|
||||
@@ -895,7 +1060,17 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
|
||||
config={config}
|
||||
value={selectedModel}
|
||||
capability={capability}
|
||||
requirements={{ capability, input: { textCount: prompt.trim() ? 1 : 0, imageCount: references.filter((reference) => reference.kind === "image").length, videoCount: references.filter((reference) => reference.kind === "video").length, audioCount: references.filter((reference) => reference.kind === "audio").length, characterCount: 0 }, ...(kind === "image" ? { imageSize: ratio, options: { size: ratio, quality, count: selectedCount } } : { videoSeconds: seconds, options: { size: ratio, videoSeconds: Number(seconds), vquality: videoQuality } }) }}
|
||||
requirements={{
|
||||
capability,
|
||||
input: {
|
||||
textCount: prompt.trim() ? 1 : 0,
|
||||
imageCount: references.filter((reference) => reference.kind === "image").length,
|
||||
videoCount: references.filter((reference) => reference.kind === "video").length,
|
||||
audioCount: references.filter((reference) => reference.kind === "audio").length,
|
||||
characterCount: 0,
|
||||
},
|
||||
...(kind === "image" ? { imageSize: ratio, options: { size: ratio, quality, count: selectedCount } } : { videoSeconds: seconds, options: { size: ratio, videoSeconds: Number(seconds), vquality: videoQuality } }),
|
||||
}}
|
||||
variant="creation"
|
||||
fullWidth
|
||||
className="creation-model-picker workbench-model-picker"
|
||||
@@ -911,7 +1086,9 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
|
||||
) : (
|
||||
<div className="workbench-model-empty">
|
||||
<span>暂无可用的{kind === "image" ? "图片" : "视频"}模型</span>
|
||||
<a href="/settings?section=models" target="_blank" rel="noreferrer">模型设置</a>
|
||||
<a href="/settings?section=models" target="_blank" rel="noreferrer">
|
||||
模型设置
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
<div className="workbench-dock-actions">
|
||||
@@ -1002,12 +1179,29 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
|
||||
<div className="workbench-guide-empty">选择适合的模型和参考素材,写下你希望呈现的画面细节。</div>
|
||||
)}
|
||||
{tool.inputHint ? <p className="workbench-guide-input-hint">{tool.inputHint}</p> : null}
|
||||
{tool.inputContract?.slots.length ? <div className="workbench-guide-contract"><strong>输入素材</strong>{tool.inputContract.slots.map((slot) => <span key={slot.key}>{slot.name} · {slot.required ? "必填" : "可选"} · 最多 {slot.maxCount} 个</span>)}</div> : null}
|
||||
{tool.inputContract?.slots.length ? (
|
||||
<div className="workbench-guide-contract">
|
||||
<strong>输入素材</strong>
|
||||
{tool.inputContract.slots.map((slot) => (
|
||||
<span key={slot.key}>
|
||||
{slot.name} · {slot.required ? "必填" : "可选"} · 最多 {slot.maxCount} 个
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{tool.resultExample?.imageUrls?.length || tool.resultExample?.videoUrl ? (
|
||||
<div className="workbench-example-strip">
|
||||
<span>{tool.resultExample.title || "效果参考"}</span>
|
||||
{tool.resultExample.imageUrls?.slice(0, 3).map((url) => <span className="workbench-example-thumb" key={url}><img src={url} alt="工具效果参考" /></span>)}
|
||||
{tool.resultExample.videoUrl ? <span className="workbench-example-thumb"><video src={tool.resultExample.videoUrl} muted /></span> : null}
|
||||
{tool.resultExample.imageUrls?.slice(0, 3).map((url) => (
|
||||
<span className="workbench-example-thumb" key={url}>
|
||||
<img src={url} alt="工具效果参考" />
|
||||
</span>
|
||||
))}
|
||||
{tool.resultExample.videoUrl ? (
|
||||
<span className="workbench-example-thumb">
|
||||
<video src={tool.resultExample.videoUrl} muted />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
@@ -1109,8 +1303,14 @@ export function WorkbenchPage({ kind }: { kind: WorkbenchKind }) {
|
||||
</div>
|
||||
</dl>
|
||||
<div className="workbench-detail-actions">
|
||||
<Button disabled={detailLoading} onClick={() => void restoreTaskToComposer(detailTask)}>载入到编辑器</Button>
|
||||
{detailTask.status === "failed" || detailTask.status === "cancelled" ? <Button type="primary" loading={taskBusyId === detailTask.id} disabled={detailLoading} onClick={() => void retryTask(detailTask)}>重新生成</Button> : null}
|
||||
<Button disabled={detailLoading} onClick={() => void restoreTaskToComposer(detailTask)}>
|
||||
载入到编辑器
|
||||
</Button>
|
||||
{detailTask.status === "failed" || detailTask.status === "cancelled" ? (
|
||||
<Button type="primary" loading={taskBusyId === detailTask.id} disabled={detailLoading} onClick={() => void retryTask(detailTask)}>
|
||||
重新生成
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1159,7 +1359,12 @@ function CountPicker({ value, max, onChange }: { value: string; max: number; onC
|
||||
|
||||
function ParameterChips({ label, value, options, onChange }: { label: string; value: string; options: Array<{ value: string; label: string }>; onChange: (value: string) => void }) {
|
||||
if (options.length > 7) {
|
||||
return <label className="workbench-setting-row"><span>{label}</span><Select aria-label={label} value={value} onChange={onChange} options={options} /></label>;
|
||||
return (
|
||||
<label className="workbench-setting-row">
|
||||
<span>{label}</span>
|
||||
<Select aria-label={label} value={value} onChange={onChange} options={options} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="workbench-chip-field">
|
||||
@@ -1197,11 +1402,36 @@ function ToolCard({ tool, selected, onClick }: { tool: WorkbenchTool; selected:
|
||||
function ReferenceCard({ reference, kind, onRemove, onRoleChange }: { reference: WorkbenchReference; kind: WorkbenchKind; onRemove: (reference: WorkbenchReference) => void; onRoleChange: (role: WorkbenchReference["role"]) => void }) {
|
||||
return (
|
||||
<div className="workbench-reference">
|
||||
{reference.kind === "video" ? <video src={reference.previewUrl} muted /> : reference.kind === "audio" ? <span className="workbench-audio-reference"><Mic /></span> : <img src={reference.previewUrl} alt={reference.name} />}
|
||||
{reference.kind === "video" ? (
|
||||
<video src={reference.previewUrl} muted />
|
||||
) : reference.kind === "audio" ? (
|
||||
<span className="workbench-audio-reference">
|
||||
<Mic />
|
||||
</span>
|
||||
) : (
|
||||
<img src={reference.previewUrl} alt={reference.name} />
|
||||
)}
|
||||
{reference.uploading ? <Progress type="circle" percent={Math.round(reference.progress)} size={34} /> : null}
|
||||
{reference.error ? <span className="workbench-reference-error" title={reference.error}>上传失败</span> : null}
|
||||
<button type="button" aria-label={`移除${reference.name}`} onClick={() => onRemove(reference)}><X /></button>
|
||||
{kind === "video" && reference.kind === "image" && !reference.slotKey ? <Select size="small" value={reference.role} onChange={onRoleChange} options={[{ label: "参考图", value: "reference" }, { label: "首帧", value: "first_frame" }, { label: "尾帧", value: "last_frame" }]} /> : null}
|
||||
{reference.error ? (
|
||||
<span className="workbench-reference-error" title={reference.error}>
|
||||
上传失败
|
||||
</span>
|
||||
) : null}
|
||||
<button type="button" aria-label={`移除${reference.name}`} onClick={() => onRemove(reference)}>
|
||||
<X />
|
||||
</button>
|
||||
{kind === "video" && reference.kind === "image" && !reference.slotKey ? (
|
||||
<Select
|
||||
size="small"
|
||||
value={reference.role}
|
||||
onChange={onRoleChange}
|
||||
options={[
|
||||
{ label: "参考图", value: "reference" },
|
||||
{ label: "首帧", value: "first_frame" },
|
||||
{ label: "尾帧", value: "last_frame" },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,10 @@ export function workbenchCategoryLabel(id: string) {
|
||||
|
||||
export function workbenchModeOptions(kind: WorkbenchKind) {
|
||||
return kind === "image"
|
||||
? [{ value: "image", label: "图片生成" }, { value: "image_edit", label: "图片编辑" }]
|
||||
? [
|
||||
{ value: "image", label: "图片生成" },
|
||||
{ value: "image_edit", label: "图片编辑" },
|
||||
]
|
||||
: [{ value: "video", label: "视频生成" }];
|
||||
}
|
||||
|
||||
|
||||
@@ -430,7 +430,9 @@
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, border-color 140ms ease;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease;
|
||||
}
|
||||
.workbench-slot-upload svg {
|
||||
width: 15px;
|
||||
@@ -467,7 +469,9 @@
|
||||
color: var(--wb-text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background-color 140ms ease, border-color 140ms ease;
|
||||
transition:
|
||||
background-color 140ms ease,
|
||||
border-color 140ms ease;
|
||||
}
|
||||
.workbench-reference-slot-empty:hover:not(:disabled) {
|
||||
border-color: var(--user-primary, #5b5fc7);
|
||||
|
||||
@@ -168,7 +168,24 @@ export type SaveAdminWorkbenchToolInput = Pick<WorkbenchTool, "kind" | "category
|
||||
|
||||
export function saveAdminWorkbenchTool(input: SaveAdminWorkbenchToolInput) {
|
||||
const { id, capability = {}, guide = {}, presets, ...tool } = input;
|
||||
const payload = { ...tool, capability, guide, ...(presets ? { presets: presets.map((preset) => ({ id: preset.id || undefined, name: preset.name, summary: preset.summary || "", promptTemplate: preset.promptTemplate, enabled: preset.enabled !== false, sortOrder: preset.sortOrder || 0, defaultParameters: preset.defaultParameters || {} })) } : {}) };
|
||||
const payload = {
|
||||
...tool,
|
||||
capability,
|
||||
guide,
|
||||
...(presets
|
||||
? {
|
||||
presets: presets.map((preset) => ({
|
||||
id: preset.id || undefined,
|
||||
name: preset.name,
|
||||
summary: preset.summary || "",
|
||||
promptTemplate: preset.promptTemplate,
|
||||
enabled: preset.enabled !== false,
|
||||
sortOrder: preset.sortOrder || 0,
|
||||
defaultParameters: preset.defaultParameters || {},
|
||||
})),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return id ? http.patch<{ tool: WorkbenchTool }>(`/admin/workbench/tools/${encodeURIComponent(id)}`, payload).then(({ tool: saved }) => saved) : http.post<{ tool: WorkbenchTool }>("/admin/workbench/tools", payload).then(({ tool: saved }) => saved);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { defaultModelCapabilityConfig } from "../src/lib/model-capabilities";
|
||||
import type { ModelProtocolDefinition } from "../src/lib/model-protocols";
|
||||
import { changeChannelModelCapability, duplicateChannelModelValues, editorSectionForField, initialChannelModelValues, updateChannelModelUpstreamCapabilities, validateChannelModelPrices, validateChannelModelProtocol } from "../src/pages/admin/components/channel-model-editor-form";
|
||||
import {
|
||||
changeChannelModelCapability,
|
||||
duplicateChannelModelValues,
|
||||
editorSectionForField,
|
||||
initialChannelModelValues,
|
||||
updateChannelModelUpstreamCapabilities,
|
||||
validateChannelModelPrices,
|
||||
validateChannelModelProtocol,
|
||||
} from "../src/pages/admin/components/channel-model-editor-form";
|
||||
import { defaultPriceTier } from "../src/pages/admin/components/channel-model-price-tier-form";
|
||||
import type { ChannelModel, ChannelModelPriceTier } from "../src/services/api/wallet";
|
||||
|
||||
@@ -84,7 +92,10 @@ describe("channel model editor drafts", () => {
|
||||
capability: "video",
|
||||
protocol: "video",
|
||||
enabled: false,
|
||||
priceTiers: [{ providerModelKey: "", unitPrice: 5 }, { providerModelKey: "vendor-video-hq", unitPrice: 8 }],
|
||||
priceTiers: [
|
||||
{ providerModelKey: "", unitPrice: 5 },
|
||||
{ providerModelKey: "vendor-video-hq", unitPrice: 8 },
|
||||
],
|
||||
});
|
||||
expect(draft.tags).not.toBe(item.tags);
|
||||
});
|
||||
|
||||
@@ -59,7 +59,10 @@ test("candidate rows do not disguise missing prices as zero", () => {
|
||||
|
||||
test("model rows preserve multiple promotional labels and colors without altering prices", () => {
|
||||
const config = fixture();
|
||||
config.channels[0].modelCosts![0].tags = [{ text: "限时特价", color: "purple" }, { text: "官方1折", color: "gold" }];
|
||||
config.channels[0].modelCosts![0].tags = [
|
||||
{ text: "限时特价", color: "purple" },
|
||||
{ text: "官方1折", color: "gold" },
|
||||
];
|
||||
for (const theme of [canvasThemes.light, canvasThemes.dark]) {
|
||||
const markup = renderToStaticMarkup(<ModelLabel config={config} model={config.model} capability="video" theme={theme} creationVariant showConfiguredModelName={false} showPrice showDescription />);
|
||||
expect(markup).toContain('data-color="purple">限时特价');
|
||||
@@ -68,7 +71,8 @@ test("model rows preserve multiple promotional labels and colors without alterin
|
||||
}
|
||||
config.channels[0].modelCosts![0].tags = [];
|
||||
const markup = renderToStaticMarkup(<ModelLabel config={config} model={config.model} capability="video" theme={canvasThemes.light} showDescription />);
|
||||
expect(markup).not.toContain('aria-label="模型标签"');
|
||||
expect(markup).not.toContain("限时特价");
|
||||
expect(markup).not.toContain("官方1折");
|
||||
});
|
||||
|
||||
test("video capability tag is derived from limits and precedes editable model tags", () => {
|
||||
@@ -76,7 +80,10 @@ test("video capability tag is derived from limits and precedes editable model ta
|
||||
const cost = config.channels[0].modelCosts![0];
|
||||
cost.capabilityConfig = defaultModelCapabilityConfig();
|
||||
Object.assign(cost.capabilityConfig.video!.references, { maxImages: 30, maxVideos: 10, maxAudios: 10 });
|
||||
cost.tags = [{ text: "限时特价", color: "purple" }, { text: "按次收费", color: "blue" }];
|
||||
cost.tags = [
|
||||
{ text: "限时特价", color: "purple" },
|
||||
{ text: "按次收费", color: "blue" },
|
||||
];
|
||||
|
||||
const markup = renderToStaticMarkup(<ModelLabel config={config} model={config.model} capability="video" theme={canvasThemes.light} creationVariant showConfiguredModelName={false} showPrice showDescription />);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
import { defaultFeatureAvailability } from "./use-user-store";
|
||||
import { defaultFeatureAvailability } from "../src/stores/use-user-store";
|
||||
|
||||
test("new workbench entries stay closed until the backend explicitly opens them", () => {
|
||||
expect(defaultFeatureAvailability.imageWorkbenchEnabled).toBe(false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
|
||||
import { filterWorkbenchTools, workbenchTaskSnapshot } from "./image-workbench-page";
|
||||
import { filterWorkbenchTools, workbenchTaskSnapshot } from "../src/pages/workbench/image-workbench-page";
|
||||
import type { GenerationTask } from "@/services/api/task-center";
|
||||
import type { WorkbenchTool } from "@/services/api/workbench";
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ describe("workspace route loading", () => {
|
||||
expect(router).toContain('{ path: "/create", element: deferred(<CreatePage />) }');
|
||||
expect(router).toContain("<RequireAuth allowAnonymous={isGuestWorkspacePath(pathname)}>");
|
||||
expect(router).toContain('{ path: "/assets", element: <RequireAuth>{deferred(<AssetsPage />)}</RequireAuth> }');
|
||||
expect(router).toContain('{ path: "/canvas/:id", element: <RequireAuth><CanvasProjectPage /></RequireAuth> }');
|
||||
expect(router).toMatch(/path: "\/canvas\/:id",\s+element: \(\s+<RequireAuth>\s+<CanvasProjectPage \/>\s+<\/RequireAuth>\s+\),/);
|
||||
expect(router).not.toContain('path: "/home"');
|
||||
expect(router).not.toContain("HomePage");
|
||||
expect(navigation).toContain('{ ...toolItem("create", "/"), id: "home", title: "创作" }');
|
||||
@@ -77,7 +77,6 @@ describe("workspace route loading", () => {
|
||||
expect(detail).toContain("新建画布");
|
||||
});
|
||||
|
||||
|
||||
test("keeps project asset refresh scoped to the latest user and project", () => {
|
||||
const editor = source("../src/pages/projects/detail/editor.tsx");
|
||||
|
||||
@@ -108,7 +107,7 @@ describe("workspace route loading", () => {
|
||||
expect(projectDetail).toContain('import("@/services/user-data-sync")');
|
||||
expect(projectDetail).not.toContain('import { createCanvasProjectWithRemoteSync } from "@/services/user-data-sync"');
|
||||
expect(workflow).not.toContain('from "@/lib/video-poster"');
|
||||
expect(workflow).toContain('if (playing) return <video');
|
||||
expect(workflow).toContain("if (playing) return <video");
|
||||
});
|
||||
|
||||
test("uses a quiet workspace skeleton for initial hydration", () => {
|
||||
@@ -141,9 +140,9 @@ describe("workspace wallet entry", () => {
|
||||
expect(router).not.toContain("WalletPage");
|
||||
expect(router).not.toContain("loadWalletPage");
|
||||
expect(modules).not.toContain("pages/wallet");
|
||||
expect(host).toContain("pathname !== \"/wallet\"");
|
||||
expect(host).toContain('pathname !== "/wallet"');
|
||||
expect(host).toContain("openWorkspaceWallet");
|
||||
expect(palette).toContain('run: () => openWorkspaceWallet()');
|
||||
expect(palette).toContain("run: () => openWorkspaceWallet()");
|
||||
expect(palette).not.toContain('"/wallet"');
|
||||
expect(canvasTopBar).toContain("openWorkspaceWallet()");
|
||||
expect(canvasTopBar).not.toContain('to="/wallet"');
|
||||
|
||||
Reference in New Issue
Block a user