feat(*): 生图与视频工作台 - 新增独立生成流程并整合平台配置

This commit is contained in:
ddcat666
2026-10-13 08:54:53 +08:00
parent 4c1d2b4012
commit 1f3f2c6020
69 changed files with 7108 additions and 87 deletions

View File

@@ -7,7 +7,7 @@ import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/co
import { refreshFeatureAvailability } from "@/lib/user-session";
import { useUserStore } from "@/stores/use-user-store";
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled";
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled" | "imageWorkbenchEnabled" | "videoWorkbenchEnabled";
const featureNames: Record<FeatureKey, string> = {
shortDramaEnabled: "短剧创作",
@@ -15,6 +15,8 @@ const featureNames: Record<FeatureKey, string> = {
creditsEnabled: "积分中心",
frontendModelsEnabled: "前台模型",
pluginCenterEnabled: "插件中心",
imageWorkbenchEnabled: "生图",
videoWorkbenchEnabled: "视频",
};
export function RequireFeature({ feature, children }: { feature: FeatureKey; children: ReactNode }) {
@@ -39,8 +41,18 @@ export function RequireFeature({ feature, children }: { feature: FeatureKey; chi
};
}, [feature, user?.id]);
if (checking) return <WorkspacePage><WorkspaceLoadingState label="正在确认功能状态" detail={featureNames[feature]} rows={3} /></WorkspacePage>;
if (error) return <WorkspacePage><WorkspaceErrorState title="无法确认功能状态" description={error} actionLabel="返回创作台" onRetry={() => navigate("/", { replace: true })} /></WorkspacePage>;
if (checking)
return (
<WorkspacePage>
<WorkspaceLoadingState label="正在确认功能状态" detail={featureNames[feature]} rows={3} />
</WorkspacePage>
);
if (error)
return (
<WorkspacePage>
<WorkspaceErrorState title="无法确认功能状态" description={error} actionLabel="返回创作台" onRetry={() => navigate("/", { replace: true })} />
</WorkspacePage>
);
if (!features[feature]) {
// 管理员页面返回到管理后台首页,用户页面返回到创作台
const isAdminFeature = feature === "frontendModelsEnabled";
@@ -49,7 +61,16 @@ export function RequireFeature({ feature, children }: { feature: FeatureKey; chi
return (
<WorkspacePage>
<WorkspaceState icon="empty" title={`${featureNames[feature]}暂未开放`} description="当前功能已由平台管理员关闭。" action={<Button type="primary" onClick={() => navigate(backPath, { replace: true })}>{backLabel}</Button>} />
<WorkspaceState
icon="empty"
title={`${featureNames[feature]}暂未开放`}
description="当前功能已由平台管理员关闭。"
action={
<Button type="primary" onClick={() => navigate(backPath, { replace: true })}>
{backLabel}
</Button>
}
/>
</WorkspacePage>
);
}

View File

@@ -1,5 +1,5 @@
import { Popover } from "antd";
import { ChevronDown, ChevronRight, CircleUserRound, History as HistoryIcon, Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen, Plus } from "lucide-react";
import { ChevronDown, ChevronRight, CircleUserRound, History as HistoryIcon, Image as ImageIcon, Infinity as InfinityIcon, PanelLeftClose, PanelLeftOpen, Plus, Video } from "lucide-react";
import { LayoutGroup, motion, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from "react";
import { Link, useLocation, useNavigate, useSearchParams } from "react-router";
@@ -46,15 +46,26 @@ function buildNav(features: FeatureAvailability): { groups: WorkspaceNavGroup[];
items: [
{ ...toolItem("create", "/"), id: "home", title: "创作" },
{ ...toolItem("inspirations", "/inspirations"), title: "灵感" },
{ ...toolItem("projects", "/projects"), title: "短剧 Agent" },
{ ...toolItem("projects", "/projects"), title: "短剧制作" },
{ ...toolItem("canvas", "/canvas"), title: "自由画布" },
],
},
{
heading: "资源与工具",
items: [{ ...toolItem("assets", "/assets"), title: "资产" }, { ...toolItem("skills", "/skills"), title: "技能" }, ...(features.pluginCenterEnabled ? [{ ...toolItem("plugins", "/plugins"), title: "插件" }] : [])],
items: [
...(features.imageWorkbenchEnabled ? [{ id: "toolbox-image", title: "图片生成", icon: ImageIcon, to: "/toolbox/image" }] : []),
...(features.videoWorkbenchEnabled ? [{ id: "toolbox-video", title: "视频生成", icon: Video, to: "/toolbox/video" }] : []),
{ ...toolItem("skills", "/skills"), title: "技能" },
...(features.pluginCenterEnabled ? [{ ...toolItem("plugins", "/plugins"), title: "插件" }] : []),
],
},
{
heading: "资产与历史",
items: [
{ ...toolItem("assets", "/assets"), title: "资产" },
...(features.taskCenterEnabled ? [{ id: "tasks", title: "创作历史", icon: ImageIcon, to: "/tasks" }] : []),
],
},
...(features.taskCenterEnabled ? [{ items: [{ ...toolItem("tasks", "/tasks"), title: "创作历史", icon: HistoryIcon }] }] : []),
];
// 管理、设置和退出登录不再占据参考站式侧栏底部,而是通过用户卡片菜单进入。
@@ -67,10 +78,25 @@ function WorkspaceSidebarProfile({ collapsed, user, onExpand, onCollapse }: { co
const profileName = user?.displayName || user?.username || "未登录";
if (!user) {
return <Link to="/login" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="登录" title="登录"><span className="app-workspace-sidebar-profile-avatar"><CircleUserRound aria-hidden /></span>{!collapsed ? <span>游客模式</span> : null}</Link>;
return (
<Link to="/login" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="登录" title="登录">
<span className="app-workspace-sidebar-profile-avatar">
<CircleUserRound aria-hidden />
</span>
{!collapsed ? <span>游客模式</span> : null}
</Link>
);
}
const content = <WorkspaceAccountCard onNavigate={() => setMenuOpen(false)} onWallet={() => { setMenuOpen(false); openWorkspaceWallet(); }} />;
const content = (
<WorkspaceAccountCard
onNavigate={() => setMenuOpen(false)}
onWallet={() => {
setMenuOpen(false);
openWorkspaceWallet();
}}
/>
);
return (
<div className={cn("app-workspace-sidebar-account", collapsed && "is-collapsed")}>
@@ -80,16 +106,25 @@ function WorkspaceSidebarProfile({ collapsed, user, onExpand, onCollapse }: { co
<Popover open={menuOpen} onOpenChange={setMenuOpen} trigger="click" placement="topLeft" arrow={false} rootClassName="workspace-account-popover" content={content}>
<button type="button" className={cn("app-workspace-sidebar-profile", collapsed && "is-collapsed")} aria-label="打开账户菜单" title={profileName}>
<UserAvatar user={user} className="app-workspace-sidebar-profile-avatar" />
{!collapsed ? <span className="app-workspace-sidebar-profile-copy"><strong>{profileName}</strong><span>创作工作台</span></span> : null}
{!collapsed ? (
<span className="app-workspace-sidebar-profile-copy">
<strong>{profileName}</strong>
<span>创作工作台</span>
</span>
) : null}
</button>
</Popover>
{!collapsed ? <button type="button" className="app-workspace-sidebar-collapse-toggle" aria-label="收起侧栏" title="收起侧栏" onClick={onCollapse}>
<PanelLeftClose className="size-4" strokeWidth={1.7} />
</button> : null}
{!collapsed ? (
<button type="button" className="app-workspace-sidebar-collapse-toggle" aria-label="收起侧栏" title="收起侧栏" onClick={onCollapse}>
<PanelLeftClose className="size-4" strokeWidth={1.7} />
</button>
) : null}
</div>
{collapsed ? <button type="button" className="app-workspace-sidebar-collapse-toggle is-collapsed" aria-label="展开侧栏菜单" title="展开侧栏菜单" onClick={onExpand}>
<PanelLeftOpen className="size-4" strokeWidth={1.7} />
</button> : null}
{collapsed ? (
<button type="button" className="app-workspace-sidebar-collapse-toggle is-collapsed" aria-label="展开侧栏菜单" title="展开侧栏菜单" onClick={onExpand}>
<PanelLeftOpen className="size-4" strokeWidth={1.7} />
</button>
) : null}
</div>
);
}
@@ -101,7 +136,12 @@ function WorkspaceSwitcher({ collapsed, onNavigate }: { collapsed: boolean; onNa
return (
<div className="app-workspace-sidebar-brand-row relative shrink-0 flex items-center justify-center px-3 pt-3">
<Link to="/" onClick={onNavigate} className="app-workspace-sidebar-brand-button group" aria-label={`${appearance.brandName}首页`}>
<BrandLogoFrame className="app-workspace-brand-mark grid size-8 shrink-0 place-items-center rounded-[var(--r-sm)] shadow-sm" logoClassName="size-5 object-contain" alt="" fallback={<InfinityIcon className="size-4" strokeWidth={2.2} />} />
<BrandLogoFrame
className="app-workspace-brand-mark grid size-8 shrink-0 place-items-center rounded-[var(--r-sm)] shadow-sm"
logoClassName="size-5 object-contain"
alt=""
fallback={<InfinityIcon className="size-4" strokeWidth={2.2} />}
/>
</Link>
</div>
);
@@ -111,7 +151,12 @@ function WorkspaceSwitcher({ collapsed, onNavigate }: { collapsed: boolean; onNa
<div className="app-workspace-sidebar-brand-row relative shrink-0 px-3 pt-3">
<Link to="/" onClick={onNavigate} className="app-workspace-sidebar-brand-button group" aria-label={`${appearance.brandName}首页`}>
<span className="flex min-w-0 items-center gap-2">
<BrandLogoFrame className="app-workspace-brand-mark grid size-8 shrink-0 place-items-center rounded-[var(--r-sm)] shadow-sm" logoClassName="size-5 object-contain" alt="" fallback={<InfinityIcon className="size-4" strokeWidth={2.2} />} />
<BrandLogoFrame
className="app-workspace-brand-mark grid size-8 shrink-0 place-items-center rounded-[var(--r-sm)] shadow-sm"
logoClassName="size-5 object-contain"
alt=""
fallback={<InfinityIcon className="size-4" strokeWidth={2.2} />}
/>
<span className="flex min-w-0 flex-col">
<span className="app-workspace-brand-wordmark truncate text-[var(--fs-body)] leading-none font-semibold">{appearance.brandName}</span>
<span className="mt-1 truncate text-[var(--fs-label)] leading-none text-foreground/60">创作工作台</span>
@@ -152,7 +197,8 @@ function NavItem({
const Icon = item.icon;
const rowStyle = collapsed ? undefined : ({ paddingLeft: `${level * 12 + 10}px` } as CSSProperties);
const collapsedTitle = item.id === "home" ? "创作" : item.id === "projects" ? "短剧" : item.id === "canvas" ? "画布" : item.id === "assets" ? "资产" : item.id === "skills" ? "技能" : item.id === "plugins" ? "插件" : item.id === "tasks" ? "历史" : item.title.slice(0, 2);
const collapsedTitle =
item.id === "home" ? "创作" : item.id === "projects" ? "短剧" : item.id === "canvas" ? "画布" : item.id === "assets" ? "资产" : item.id === "skills" ? "技能" : item.id === "plugins" ? "插件" : item.id === "tasks" ? "历史" : item.title.slice(0, 2);
const rowContent = (
<>
<span className="app-workspace-nav-main flex min-w-0 items-center gap-2.5">
@@ -166,9 +212,7 @@ function NavItem({
<span className="app-workspace-nav-title truncate">{collapsed ? collapsedTitle : item.title}</span>
</span>
<span className="app-workspace-nav-meta flex shrink-0 items-center gap-2">
{item.shortcut ? (
<Kbd className="hidden group-hover:inline-flex">{item.shortcut}</Kbd>
) : null}
{item.shortcut ? <Kbd className="hidden group-hover:inline-flex">{item.shortcut}</Kbd> : null}
{item.badge !== undefined ? <span className="flex min-w-5 items-center justify-center rounded-full bg-primary/10 px-1.5 py-0.5 text-[var(--fs-tiny)] font-medium tabular-nums text-primary">{item.badge}</span> : null}
{hasChildren ? <ChevronRight className={cn("size-3.5 shrink-0 text-foreground/40 transition-transform duration-200", isOpen && "rotate-90")} strokeWidth={2} /> : null}
</span>
@@ -180,14 +224,7 @@ function NavItem({
collapsed && "is-collapsed",
isActive ? "is-active font-medium" : "text-foreground/62 hover:bg-surface-hover hover:text-foreground",
);
const activePill = isActive ? (
<motion.span
layoutId="workspace-nav-active-pill"
className="app-workspace-nav-active-pill"
aria-hidden
transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.dock}
/>
) : null;
const activePill = isActive ? <motion.span layoutId="workspace-nav-active-pill" className="app-workspace-nav-active-pill" aria-hidden transition={reducedMotion ? { duration: 0 } : aceternityMotion.spring.dock} /> : null;
const handleClick = () => {
if (item.action === "search") {
@@ -227,7 +264,16 @@ function NavItem({
{rowContent}
</Link>
) : (
<button type="button" className={rowClassName} data-nav-id={item.id} style={rowStyle} aria-label={collapsed ? item.title : undefined} title={collapsed ? item.title : undefined} onClick={handleClick} aria-expanded={hasChildren ? isOpen : undefined}>
<button
type="button"
className={rowClassName}
data-nav-id={item.id}
style={rowStyle}
aria-label={collapsed ? item.title : undefined}
title={collapsed ? item.title : undefined}
onClick={handleClick}
aria-expanded={hasChildren ? isOpen : undefined}
>
{activePill}
{rowContent}
</button>
@@ -266,7 +312,11 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collaps
// 无标题分组(核心导航入口)常驻展示,不做折叠。
if (!group.heading || collapsed) {
return <div className="app-workspace-nav-group flex shrink-0 flex-col" data-nav-group-heading={group.heading || ""}>{content}</div>;
return (
<div className="app-workspace-nav-group flex shrink-0 flex-col" data-nav-group-heading={group.heading || ""}>
{content}
</div>
);
}
return (
@@ -282,6 +332,15 @@ function NavGroup({ group, activeId, onNavigate, onOpenSearch, onLogout, collaps
);
}
// 菜单项 id 与实际路由不一定同名:/toolbox/image 和 /toolbox/video 共用 toolbox 前缀,
// 只取首段会让两者都匹配不到任何菜单项,侧栏因此没有高亮。
export function workspaceNavActiveId(pathname: string, section?: string | null) {
const segments = pathname.split("/").filter(Boolean);
if (segments[0] === "toolbox" && segments[1]) return `toolbox-${segments[1]}`;
const slug = segments[0] || "home";
return slug === "settings" && section ? `settings:${section}` : slug;
}
export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExpand, onCollapse }: { collapsed: boolean; onNavigate: () => void; onOpenSearch: () => void; onExpand: () => void; onCollapse: () => void }) {
const { pathname } = useLocation();
const [searchParams] = useSearchParams();
@@ -290,9 +349,7 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp
const { handleLogout } = useWorkspaceLogout();
const { groups, footer } = useMemo(() => buildNav(features), [features]);
const slug = pathname.split("/").filter(Boolean)[0] || "home";
const section = searchParams.get("section");
const activeId = slug === "settings" && section ? `settings:${section}` : slug;
const activeId = workspaceNavActiveId(pathname, searchParams.get("section"));
const scrollRef = useRef<HTMLDivElement>(null);
const [scrollState, setScrollState] = useState({ hasTopFade: false, hasBottomFade: false });
@@ -314,24 +371,31 @@ export function WorkspaceSidebarNav({ collapsed, onNavigate, onOpenSearch, onExp
<WorkspaceSwitcher collapsed={collapsed} onNavigate={onNavigate} onExpand={onExpand} onCollapse={onCollapse} />
<LayoutGroup id="workspace-sidebar-nav">
<div
ref={scrollRef}
onScroll={handleScroll}
className={cn("app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-3 pb-3 pt-7", collapsed && "is-collapsed", scrollState.hasTopFade && "has-top-fade", scrollState.hasBottomFade && "has-bottom-fade")}
>
{groups.map((group, index) => (
<NavGroup key={index} group={group} activeId={activeId} onNavigate={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} collapsed={collapsed} />
))}
</div>
<div
ref={scrollRef}
onScroll={handleScroll}
className={cn(
"app-workspace-sidebar-scroll-area flex min-h-0 flex-1 flex-col gap-5 overflow-y-auto px-3 pb-3 pt-7",
collapsed && "is-collapsed",
scrollState.hasTopFade && "has-top-fade",
scrollState.hasBottomFade && "has-bottom-fade",
)}
>
{groups.map((group, index) => (
<NavGroup key={index} group={group} activeId={activeId} onNavigate={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} collapsed={collapsed} />
))}
</div>
</LayoutGroup>
<div className="app-workspace-sidebar-footer shrink-0 px-3 py-3">
<WorkspaceSidebarProfile collapsed={collapsed} user={user} onExpand={onExpand} onCollapse={onCollapse} />
{footer.length ? <div className="mt-2 flex flex-col gap-0.5">
{footer.map((item) => (
<NavItem key={item.id} item={item} activeId={activeId} onSelect={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} collapsed={collapsed} />
))}
</div> : null}
{footer.length ? (
<div className="mt-2 flex flex-col gap-0.5">
{footer.map((item) => (
<NavItem key={item.id} item={item} activeId={activeId} onSelect={onNavigate} onOpenSearch={onOpenSearch} onLogout={() => void handleLogout()} collapsed={collapsed} />
))}
</div>
) : null}
</div>
</div>
);

View File

@@ -16,7 +16,7 @@ const PAGE_TITLES: Record<string, string> = {
home: "创作",
create: "创作",
inspirations: "灵感",
projects: "短剧 Agent",
projects: "短剧制作",
canvas: "自由画布",
tasks: "创作历史",
assets: "资产",

View File

@@ -14,6 +14,7 @@ import { useActiveTheme } from "@/stores/canvas/use-canvas-theme-store";
import { useUserStore } from "@/stores/use-user-store";
import { ModelLogo } from "@/components/model-logo";
import { ModelTags } from "@/components/model-tags";
import { videoReferenceCapabilityTag } from "@/lib/model-tags";
import { quoteModel, type LogicalModelQuote, type PublicChannelModelAvailability } from "@/services/api/logical-models";
type ModelPickerProps = {
@@ -331,6 +332,8 @@ export function ModelLabel({
const logicalCost = channel.modelCosts?.find((item) => item.model === modelOptionName(model));
const logicalSpec = logicalCost?.logicalCapabilitySpec;
const videoProfile = capability === "video" ? modelCapabilityConfigFor(config, model).video : undefined;
const capabilityTag = videoReferenceCapabilityTag(videoProfile?.references);
const modelTags = capabilityTag ? [capabilityTag, ...(logicalCost?.tags || [])] : logicalCost?.tags;
const capabilitySummary =
disabledReason ||
logicalCost?.description?.trim() ||
@@ -354,7 +357,7 @@ export function ModelLabel({
<span className={cn("canvas-model-picker-description mt-1 block", showDescription && "is-visible")} style={{ color: theme.node.muted }}>
{capabilitySummary}
</span>
<ModelTags tags={logicalCost?.tags} />
<ModelTags tags={modelTags} />
</span>
{!creationVariant && meta.time ? (
<span className="shrink-0 rounded-full px-1.5 py-0.5 text-[var(--fs-tiny)] tabular-nums" style={{ background: theme.toolbar.itemHover, color: theme.node.muted }}>

View File

@@ -8,3 +8,17 @@ export const modelTagColors = [
] as const;
export type ModelTag = { text: string; color: typeof modelTagColors[number]["value"] };
export function videoReferenceCapabilityTag(references?: { maxImages?: number; maxVideos?: number; maxAudios?: number }): ModelTag | undefined {
if (!references) return undefined;
const counts = [
["图", references.maxImages],
["视", references.maxVideos],
["音", references.maxAudios],
] as const;
const text = counts
.filter(([, count]) => typeof count === "number" && Number.isFinite(count) && count > 0)
.map(([label, count]) => `${label}×${count}`)
.join(" · ");
return text ? { text, color: "green" } : undefined;
}

View File

@@ -35,6 +35,7 @@ import {
Sun,
TicketCheck,
ToggleLeft,
WandSparkles,
UsersRound,
} from "lucide-react";
import { Suspense, useEffect, useState, type ReactNode } from "react";
@@ -77,6 +78,7 @@ const adminNavigation: Array<{ label: string; items: AdminNavigationItem[] }> =
{ path: "/admin/channels", label: "系统渠道", description: "渠道、模型与售价", icon: <RadioTower className="size-4" /> },
{ path: "/admin/models", label: "前台模型", description: "展示、线路与用户价格", icon: <Layers3 className="size-4" />, requireFeature: "frontendModelsEnabled" },
{ path: "/admin/prompt-templates", label: "提示词模板", description: "平台创作策略版本", icon: <MessageSquareText className="size-4" /> },
{ path: "/admin/workbench-tools", label: "工作台工具", description: "生图/视频工具与预设", icon: <WandSparkles className="size-4" /> },
{ path: "/admin/resources", label: "存储资源", description: "资源列表、容量与预览", icon: <Database className="size-4" /> },
],
},

View File

@@ -4,7 +4,7 @@ import { imagePresetForRatio, imageSizePresets } from "@/lib/image-size-presets"
import { modelProtocolSupportsTokenBilling, type ModelProtocolDefinition } from "@/lib/model-protocols";
import type { ChannelModel } from "@/services/api/wallet";
import type { ModelTag } from "@/lib/model-tags";
import { defaultPriceTier, legacyPriceTierToForm, priceTierToForm, type PriceTierFormValues } from "./channel-model-price-tier-form";
import { defaultPriceTier, legacyPriceTierToForm, normalizeUpstreamModelKey, priceTierToForm, type PriceTierFormValues } from "./channel-model-price-tier-form";
export type ChannelModelFormValues = {
modelKey: string;
@@ -47,6 +47,17 @@ export function initialChannelModelValues(item: ChannelModel | null, protocols:
};
}
export function duplicateChannelModelValues(item: ChannelModel, protocols: ModelProtocolDefinition[]): ChannelModelFormValues {
const values = initialChannelModelValues(item, protocols);
const sourceUpstream = normalizeUpstreamModelKey(item.providerModelKey || item.modelKey);
return {
...values,
modelKey: "",
providerModelKey: "",
priceTiers: values.priceTiers.map((tier) => (normalizeUpstreamModelKey(tier.providerModelKey) === sourceUpstream ? { ...tier, providerModelKey: "" } : tier)),
};
}
// Clear incompatible selectors, but never silently convert prices or billing units.
export function changeChannelModelCapability(values: ChannelModelFormValues, protocols: ModelProtocolDefinition[]): ChannelModelFormValues {
const capability = values.capability;

View File

@@ -19,6 +19,7 @@ import { changeChannelModelCapability, editorSectionForField, initialChannelMode
export function ChannelModelEditor({
channel,
editing,
draftValues,
protocols,
protocolLoading,
protocolError,
@@ -28,6 +29,7 @@ export function ChannelModelEditor({
}: {
channel: ModelChannel;
editing: ChannelModel | null;
draftValues?: FormValues;
protocols: ModelProtocolDefinition[];
protocolLoading: boolean;
protocolError: string;
@@ -41,7 +43,7 @@ export function ChannelModelEditor({
.sort((a, b) => a.localeCompare(b, "zh-CN"))
.map((value) => ({ value }));
const [form] = Form.useForm<FormValues>();
const [initialValues] = useState(() => initialChannelModelValues(editing, protocols));
const [initialValues] = useState(() => draftValues ?? initialChannelModelValues(editing, protocols));
const [activeSection, setActiveSection] = useState<EditorSection>("identity");
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
@@ -177,9 +179,9 @@ export function ChannelModelEditor({
open
title={
<div className="admin-model-editor-title">
<span>{editing ? "编辑模型" : "新增模型"}</span>
<span>{draftValues ? "复制模型" : editing ? "编辑模型" : "新增模型"}</span>
<p>
{channel.name} · {editing?.displayName || editing?.modelKey || "配置调用方式与用户积分价格"}
{channel.name} · {editing?.displayName || editing?.modelKey || draftValues?.displayName || "配置调用方式与用户积分价格"}
</p>
</div>
}

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from "react";
import { App, Button, Checkbox, Input, Modal, Popconfirm, Space } from "antd";
import { App, Button, Checkbox, Input, Modal, Popconfirm, Space, Tooltip } from "antd";
import type { ColumnsType } from "antd/es/table";
import { Pencil, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
import { Copy, Pencil, Plus, RefreshCw, Search, Trash2 } from "lucide-react";
import { PaginationBar } from "@/pages/admin/components/admin-ui";
import { ModelIcon } from "@/components/model-picker";
@@ -15,6 +15,7 @@ import { ChannelOrderDialog } from "./channel-order-dialog";
import { ChannelModelCostSummary } from "./channel-model-cost-summary";
import { ChannelModelRepriceDialog } from "./channel-model-reprice-dialog";
import { deselectVisibleModels, filterImportModels, selectVisibleModels } from "./channel-model-import-selection";
import { duplicateChannelModelValues, type ChannelModelFormValues } from "./channel-model-editor-form";
import { Select } from "@/components/ui/base/select";
export function ChannelModelManager({ channel, onChanged }: { channel: ModelChannel; onChanged: () => void | Promise<void> }) {
@@ -24,6 +25,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
const [deletingSelected, setDeletingSelected] = useState(false);
const [repriceItems, setRepriceItems] = useState<ChannelModel[]>([]);
const [editing, setEditing] = useState<ChannelModel | null>(null);
const [copyDraft, setCopyDraft] = useState<ChannelModelFormValues | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState("");
const [fetching, setFetching] = useState(false);
@@ -73,6 +75,7 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
void reload();
void loadProtocols();
setEditing(null);
setCopyDraft(null);
setEditorOpen(false);
setSelectedModelIds([]);
setRepriceItems([]);
@@ -137,10 +140,17 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
const startCreate = () => {
setEditing(null);
setCopyDraft(null);
setEditorOpen(true);
};
const startEdit = (item: ChannelModel) => {
setEditing(item);
setCopyDraft(null);
setEditorOpen(true);
};
const startCopy = (item: ChannelModel) => {
setEditing(null);
setCopyDraft(duplicateChannelModelValues(item, availableProtocols));
setEditorOpen(true);
};
@@ -221,13 +231,16 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
{ title: "状态", dataIndex: "enabled", width: 85, render: (enabled) => <AdminStatusBadge label={enabled ? "启用" : "停用"} tone={enabled ? "success" : "neutral"} /> },
{
title: "操作",
width: 120,
width: 130,
fixed: "right",
render: (_, item) => (
<Space>
<Space size={4}>
<Button size="small" icon={<Pencil className="size-3.5" />} disabled={deletingSelected} onClick={() => startEdit(item)}>
编辑
</Button>
<Tooltip title="复制模型">
<Button size="small" aria-label={`复制模型 ${item.displayName || item.modelKey}`} disabled={deletingSelected} icon={<Copy className="size-3.5" />} onClick={() => startCopy(item)} />
</Tooltip>
<Popconfirm title="删除模型" description="已被前台供应线路或进行中任务使用的模型不能删除;删除后模型不再显示,且不能在页面恢复。" okText="删除" cancelText="取消" onConfirm={() => void remove(item)}>
<Button size="small" danger disabled={deletingSelected} title="删除模型" aria-label="删除模型" icon={<Trash2 className="size-3.5" />} />
</Popconfirm>
@@ -515,11 +528,16 @@ export function ChannelModelManager({ channel, onChanged }: { channel: ModelChan
<ChannelModelEditor
channel={channel}
editing={editing}
draftValues={copyDraft ?? undefined}
protocols={availableProtocols}
protocolLoading={protocolLoading}
protocolError={protocolError}
onRetryProtocols={() => void loadProtocols()}
onClose={() => setEditorOpen(false)}
onClose={() => {
setEditorOpen(false);
setCopyDraft(null);
setEditing(null);
}}
onSaved={async () => {
await reload();
await onChanged();

View File

@@ -8,7 +8,7 @@ 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";
type FeatureKey = "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "inspirationSourcesVisible" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers" | "imageWorkbenchEnabled" | "videoWorkbenchEnabled";
type FeatureRow = {
key: FeatureKey;
title: string;
@@ -17,7 +17,7 @@ type FeatureRow = {
dependsOn?: FeatureKey;
};
const editableFeatureKeys: FeatureKey[] = ["shortDramaEnabled", "taskCenterEnabled", "creditsEnabled", "customChannelsEnabled", "inspirationSourcesVisible", "frontendModelsEnabled", "pluginCenterEnabled", "systemPluginsVisibleToUsers"];
const editableFeatureKeys: FeatureKey[] = ["shortDramaEnabled", "taskCenterEnabled", "creditsEnabled", "customChannelsEnabled", "inspirationSourcesVisible", "imageWorkbenchEnabled", "videoWorkbenchEnabled", "frontendModelsEnabled", "pluginCenterEnabled", "systemPluginsVisibleToUsers"];
const workspaceFeatureRows: FeatureRow[] = [
{
@@ -50,6 +50,18 @@ const workspaceFeatureRows: FeatureRow[] = [
description: "显示创作首页的素材来源说明;关闭后隐藏该面板。",
icon: <Sparkles className="size-4" aria-hidden="true" />,
},
{
key: "imageWorkbenchEnabled",
title: "图片生成",
description: "开放生图入口并允许创建新的工作台图片任务;关闭不删除结果和历史。",
icon: <Sparkles className="size-4" aria-hidden="true" />,
},
{
key: "videoWorkbenchEnabled",
title: "视频生成",
description: "开放视频入口并允许创建新的工作台视频任务;关闭不删除结果和历史。",
icon: <Clapperboard className="size-4" aria-hidden="true" />,
},
];
const pluginFeatureRows: FeatureRow[] = [
@@ -251,7 +263,7 @@ export default function FeatureAvailabilityPanel() {
title="1. 用户工作台入口"
description="先决定普通用户能进入哪些核心工作区"
icon={<MonitorCog className="size-4" aria-hidden="true" />}
status={<AdminStatusBadge label={`${enabledWorkspaceFeatures}/5 开放`} tone={enabledWorkspaceFeatures === 5 ? "success" : "neutral"} />}
status={<AdminStatusBadge label={`${enabledWorkspaceFeatures}/7 开放`} tone={enabledWorkspaceFeatures === 5 ? "success" : "neutral"} />}
>
{workspaceFeatureRows.map((row) => (
<FeatureSettingRow key={row.key} row={row} saved={savedFeatures} draft={draftFeatures} saving={saving} onChange={requestFeatureChange} />
@@ -358,6 +370,8 @@ function toEditablePayload(features: FeatureAvailability) {
creditsEnabled: features.creditsEnabled,
customChannelsEnabled: features.customChannelsEnabled,
inspirationSourcesVisible: features.inspirationSourcesVisible,
imageWorkbenchEnabled: features.imageWorkbenchEnabled,
videoWorkbenchEnabled: features.videoWorkbenchEnabled,
frontendModelsEnabled: features.frontendModelsEnabled,
pluginCenterEnabled: features.pluginCenterEnabled,
systemPluginsVisibleToUsers: features.systemPluginsVisibleToUsers,
@@ -381,6 +395,8 @@ function parseFeatureAvailability(value: unknown): FeatureAvailability {
creditsEnabled: record.creditsEnabled as boolean,
customChannelsEnabled: record.customChannelsEnabled as boolean,
inspirationSourcesVisible: record.inspirationSourcesVisible as boolean,
imageWorkbenchEnabled: record.imageWorkbenchEnabled as boolean,
videoWorkbenchEnabled: record.videoWorkbenchEnabled as boolean,
frontendModelsEnabled: record.frontendModelsEnabled as boolean,
pluginCenterEnabled: record.pluginCenterEnabled as boolean,
systemPluginsVisibleToUsers: record.systemPluginsVisibleToUsers as boolean,

View File

@@ -0,0 +1,244 @@
import { App, Button, Form, Input, InputNumber, Modal, Select, Switch, Upload } from "antd";
import type { ColumnsType } from "antd/es/table";
import { CloudUpload, Edit3, Plus, RefreshCw, Search, Trash2, WandSparkles } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
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 "./workbench-tools.css";
type ToolDraft = {
id?: string;
kind: WorkbenchKind;
categoryId: string;
name: string;
subtitle: string;
description: string;
guideText: string;
inputHint: string;
iconResourceId?: string;
iconUrl?: string;
coverResourceId?: string;
coverUrl?: string;
sortOrder: number;
enabled: boolean;
supportedModes: string[];
searchKeywords: string[];
surfaces: string[];
recommended: boolean;
recommendationOrder: number;
capability: Record<string, unknown>;
guideConfig: Record<string, unknown>;
inputContract: WorkbenchInputContract;
resultExample: WorkbenchResultExample;
presets: WorkbenchPromptPreset[];
savedPresetIds: string[];
};
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: [],
});
function record(value: unknown): Record<string, unknown> {
if (typeof value === "string") {
try { return record(JSON.parse(value)); } catch { return {}; }
}
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 || "",
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,
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),
};
}
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>
</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>
</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() {
const { message } = App.useApp();
const [kind, setKind] = useState<WorkbenchKind>("image");
const [tools, setTools] = useState<WorkbenchTool[]>([]);
const [loading, setLoading] = useState(true);
const [query, setQuery] = useState("");
const [editorOpen, setEditorOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [uploadCount, setUploadCount] = useState(0);
const [draft, setDraft] = useState<ToolDraft>(emptyDraft());
const [form] = Form.useForm();
const editorBusy = saving || uploadCount > 0;
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); }
}, [kind, message]);
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) => {
const next = tool ? makeDraft(tool) : emptyDraft(kind);
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.videoUrl = await resolveWorkbenchResourceURL(next.resultExample.videoResourceId);
}
setDraft(next);
form.resetFields();
form.setFieldsValue({ ...next, resultTitle: next.resultExample.title || "", resultDescription: next.resultExample.description || "" });
setEditorOpen(true);
};
const upload: UploadMedia = async (file, mediaKind) => {
if (!file.type.startsWith(`${mediaKind}/`)) {
message.error(`请选择${mediaKind === "image" ? "图片" : "视频"}文件`);
throw new Error("文件类型不匹配");
}
setUploadCount((count) => count + 1);
try {
const resource = await uploadResourceFile(file, mediaKind, { fileName: file.name });
const url = await resolveWorkbenchResourceURL(resource.id);
message.success("文件已上传");
return { id: resource.id, url };
} catch (error) {
message.error(error instanceof Error ? error.message : "文件上传失败");
throw error;
} 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; }
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; }
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 || "" } },
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); }
};
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 : "工具状态更新失败"); }
};
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: "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> },
];
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>
</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>;
}

View File

@@ -0,0 +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}}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
import { WorkbenchPage } from "./image-workbench-page";
export default function ImageWorkbenchRoute() {
return <WorkbenchPage kind="image" />;
}

View File

@@ -0,0 +1,5 @@
import { WorkbenchPage } from "./image-workbench-page";
export default function VideoWorkbenchPage() {
return <WorkbenchPage kind="video" />;
}

View File

@@ -0,0 +1,33 @@
import type { WorkbenchKind, WorkbenchReferenceSlot } from "@/services/api/workbench";
export const workbenchCategories = [
{ value: "recommended", label: "推荐" },
{ value: "people", label: "人物" },
{ value: "ecommerce", label: "电商" },
{ value: "product", label: "商品" },
{ value: "drama-character", label: "短剧角色" },
{ value: "drama-scene", label: "短剧场景" },
{ value: "storyboard", label: "短剧分镜" },
{ value: "short-video", label: "短视频" },
{ value: "scene", label: "场景" },
{ value: "enhancement", label: "增强" },
];
export function workbenchCategoryLabel(id: string) {
return workbenchCategories.find((category) => category.value === id)?.label || id;
}
export function workbenchModeOptions(kind: WorkbenchKind) {
return kind === "image"
? [{ value: "image", label: "图片生成" }, { value: "image_edit", label: "图片编辑" }]
: [{ value: "video", label: "视频生成" }];
}
export const workbenchMediaLabel = (kind: string) => (kind === "image" ? "图片" : kind === "video" ? "视频" : "音频");
/** 用户可读的输入约束文案,避免在下拉标签里堆叠过长说明。 */
export function workbenchSlotRequirement(slot: WorkbenchReferenceSlot) {
const media = slot.mediaTypes.map(workbenchMediaLabel).join(" / ");
if (slot.required || slot.minCount > 0) return `${media} · 必填 ${slot.minCount}-${slot.maxCount} 个`;
return `${media} · 可选,最多 ${slot.maxCount} 个`;
}

View File

@@ -0,0 +1,45 @@
.workbench-count-picker {
display: grid;
gap: 10px;
}
.workbench-count-picker > span {
font-size: 12px;
font-weight: 580;
color: var(--wb-text);
}
.workbench-count-picker > div {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.workbench-count-picker button {
min-width: 36px;
height: 32px;
padding: 0 10px;
border: 1px solid var(--wb-edge);
border-radius: 8px;
background: var(--wb-surface);
color: var(--wb-text);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 120ms ease;
}
.workbench-count-picker button:hover {
border-color: var(--wb-text-muted);
background: var(--wb-hover);
}
.workbench-count-picker button.is-active {
border-color: var(--user-primary, #5b5fc7);
background: var(--user-primary, #5b5fc7);
color: white;
}
.workbench-count-picker .ant-input-number {
width: 72px;
height: 32px;
}
.workbench-count-picker .ant-input-number input {
height: 30px;
text-align: center;
}

View File

@@ -0,0 +1,9 @@
import type { WorkbenchTool } from "@/services/api/workbench";
export function workbenchPromptRequired(tool: WorkbenchTool | null) {
return !tool || tool.inputContract?.promptRequired !== false || (tool.inputContract?.minTextReferences || 0) > 0;
}
export function missingWorkbenchInput(tool: WorkbenchTool | null, references: Array<{ slotKey?: string; resourceId?: string; uploading?: boolean; error?: string }>) {
return tool?.inputContract?.slots.find((slot) => references.filter((reference) => reference.slotKey === slot.key && reference.resourceId && !reference.uploading && !reference.error).length < Math.max(slot.minCount, slot.required ? 1 : 0));
}

File diff suppressed because it is too large Load Diff

View File

@@ -34,6 +34,7 @@ const StorageSettingsPage = lazy(() => import("@/pages/admin/settings/storage-se
const ArkPrivateAssetsSettingsPage = lazy(() => import("@/pages/admin/settings/ark-private-assets-settings-page"));
const ResponseInterceptionSettingsPage = lazy(() => import("@/pages/admin/settings/response-interception-settings-page"));
const ThirdPartySettingsPage = lazy(() => import("@/pages/admin/settings/libtv-settings-page"));
const WorkbenchToolsPage = lazy(() => import("@/pages/admin/workbench-tools/workbench-tools-page"));
const SystemUpdatePage = lazy(() => import("@/pages/admin/settings/system-update-page"));
const SystemPerformancePage = lazy(() => import("@/pages/admin/settings/system-performance-page"));
const StoryboardPromptsPage = lazy(() => import("@/pages/admin/storyboard-prompts/storyboard-prompts-page"));
@@ -46,6 +47,8 @@ const CanvasPage = lazy(loadCanvasPage);
const CanvasProjectPage = lazy(loadCanvasProjectPage);
const SharedCanvasPage = lazy(() => import("@/pages/canvas/shared"));
const CreatePage = lazy(loadCreatePage);
const ImageWorkbenchPage = lazy(() => import("@/pages/workbench/image-workbench-route"));
const VideoWorkbenchPage = lazy(() => import("@/pages/workbench/video-workbench-page"));
const InspirationsPage = lazy(loadInspirationsPage);
const NotFound = lazy(() => import("@/pages/not-found"));
const SkillsPage = lazy(() => import("@/pages/skills"));
@@ -71,7 +74,15 @@ function AuthenticatedWorkspaceLayout() {
const { pathname } = useLocation();
const isCanvasProjectRoute = pathname.startsWith("/canvas/");
const fallback = isCanvasProjectRoute ? <CanvasRefreshShell /> : <FullScreenLoader label="正在打开创作空间" detail="准备当前页面" />;
return <RequireAuth allowAnonymous={isGuestWorkspacePath(pathname)}><Suspense fallback={fallback}><UserLayout><Outlet /></UserLayout></Suspense></RequireAuth>;
return (
<RequireAuth allowAnonymous={isGuestWorkspacePath(pathname)}>
<Suspense fallback={fallback}>
<UserLayout>
<Outlet />
</UserLayout>
</Suspense>
</RequireAuth>
);
}
/**
@@ -109,6 +120,22 @@ export const router = createBrowserRouter([
children: [
{ path: "/", element: deferred(<CreatePage />) },
{ path: "/create", element: deferred(<CreatePage />) },
{
path: "/toolbox/image",
element: (
<RequireAuth>
<RequireFeature feature="imageWorkbenchEnabled">{deferred(<ImageWorkbenchPage />)}</RequireFeature>
</RequireAuth>
),
},
{
path: "/toolbox/video",
element: (
<RequireAuth>
<RequireFeature feature="videoWorkbenchEnabled">{deferred(<VideoWorkbenchPage />)}</RequireFeature>
</RequireAuth>
),
},
{ path: "/inspirations", element: deferred(<InspirationsPage />) },
{
path: "/tasks",
@@ -183,7 +210,14 @@ export const router = createBrowserRouter([
),
},
{ path: "/canvas", element: <RequireAuth>{deferred(<CanvasPage />)}</RequireAuth> },
{ path: "/canvas/:id", element: <RequireAuth><CanvasProjectPage /></RequireAuth> },
{
path: "/canvas/:id",
element: (
<RequireAuth>
<CanvasProjectPage />
</RequireAuth>
),
},
{
path: "/admin",
element: <RequireAuth>{deferred(<AdminPage />)}</RequireAuth>,
@@ -192,8 +226,16 @@ export const router = createBrowserRouter([
{ path: "users", element: <UsersPage /> },
{ path: "login-logs", element: <LoginLogsPage /> },
{ path: "channels", element: <ChannelsPage /> },
{ path: "models", element: <RequireFeature feature="frontendModelsEnabled"><LogicalModelsPage /></RequireFeature> },
{
path: "models",
element: (
<RequireFeature feature="frontendModelsEnabled">
<LogicalModelsPage />
</RequireFeature>
),
},
{ path: "plugins", element: <AdminPluginsPage /> },
{ path: "workbench-tools", element: <WorkbenchToolsPage /> },
{ path: "skill-curation", element: <SkillCurationPage /> },
{ path: "payments", element: <AdminPaymentsPage /> },
{ path: "prompt-templates", element: <StoryboardPromptsPage /> },

View File

@@ -443,7 +443,7 @@ export function getAdminFeatureAvailability() {
}
export function updateAdminFeatureAvailability(
features: Partial<Pick<FeatureAvailability, "welcomeEnabled" | "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers" | "inspirationSourcesVisible">>,
features: Partial<Pick<FeatureAvailability, "welcomeEnabled" | "shortDramaEnabled" | "taskCenterEnabled" | "creditsEnabled" | "customChannelsEnabled" | "frontendModelsEnabled" | "pluginCenterEnabled" | "systemPluginsVisibleToUsers" | "inspirationSourcesVisible" | "imageWorkbenchEnabled" | "videoWorkbenchEnabled">>,
) {
return http.patch<{ features: FeatureAvailability }>("/admin/settings/features", features);
}

View File

@@ -378,6 +378,8 @@ function backendImageReference(image: ReferenceImage, override: Partial<Referenc
...(image.bytes ? { bytes: image.bytes } : {}),
...(image.width ? { width: image.width } : {}),
...(image.height ? { height: image.height } : {}),
...(image.role ? { role: image.role } : {}),
...(image.slotKey ? { slotKey: image.slotKey } : {}),
};
}
@@ -388,10 +390,12 @@ function backendMediaReference<T extends ReferenceVideo | ReferenceAudio>(media:
type: override.type || media.type,
url: override.url || "",
storageKey: override.storageKey,
...("bytes" in media && media.bytes ? { bytes: media.bytes } : {}),
...(media.bytes ? { bytes: media.bytes } : {}),
...("width" in media && media.width ? { width: media.width } : {}),
...("height" in media && media.height ? { height: media.height } : {}),
...(media.durationMs ? { durationMs: media.durationMs } : {}),
...(media.role ? { role: media.role } : {}),
...(media.slotKey ? { slotKey: media.slotKey } : {}),
} as T;
}

View File

@@ -0,0 +1,216 @@
import { http } from "@/services/api/request";
import { getResourceAccess, resolveResourceAccessURL, resourceStorageKey } from "@/services/api/resources";
import type { GenerationTask } from "@/services/api/task-center";
export type WorkbenchKind = "image" | "video";
export type WorkbenchPromptPreset = {
id: string;
name: string;
summary?: string;
promptTemplate: string;
enabled?: boolean;
defaultParameters?: Record<string, unknown>;
requiredVariables?: string[];
sortOrder?: number;
};
export type WorkbenchReferenceSlot = {
key: string;
name: string;
mediaTypes: Array<"image" | "video" | "audio">;
resourceKind?: string;
maxCount: number;
minCount: number;
required: boolean;
coverResourceId?: string;
coverUrl?: string;
sampleResourceId?: string;
sampleUrl?: string;
};
export type WorkbenchInputContract = {
promptRequired: boolean;
minTextReferences: number;
slots: WorkbenchReferenceSlot[];
};
export type WorkbenchResultExample = {
title?: string;
description?: string;
imageResourceIds?: string[];
imageUrls?: string[];
videoResourceId?: string;
videoUrl?: string;
};
export type WorkbenchTool = {
id: string;
kind: WorkbenchKind;
categoryId: string;
name: string;
subtitle: string;
description: string;
iconUrl?: string;
coverUrl?: string;
iconResourceId?: string;
coverResourceId?: string;
sortOrder: number;
enabled: boolean;
supportedModes?: string[];
capability?: Record<string, unknown> | string;
capabilityRequirements?: Record<string, unknown>;
guide?: Record<string, unknown> | string;
inputHint?: string;
searchKeywords?: string[];
surfaces?: string[];
recommended?: boolean;
recommendationOrder?: number;
inputContract?: WorkbenchInputContract;
resultExample?: WorkbenchResultExample;
presets: WorkbenchPromptPreset[];
};
export type WorkbenchToolDirectory = {
tools: WorkbenchTool[];
};
export type CreateWorkbenchTaskInput = {
kind: WorkbenchKind;
toolId?: string;
presetId?: string;
workbench?: { kind: WorkbenchKind; toolId: string; presetId?: string };
prompt: string;
model: string;
logicalModelId?: string;
channelId?: string;
modelChoice?: { logicalModelId?: string; userChannelId?: string; modelName?: string };
parameters?: Record<string, unknown>;
references?: Array<{
resourceId: string;
kind: "image" | "video" | "audio";
role?: "reference" | "first_frame" | "last_frame";
slotKey?: string;
}>;
};
export async function listWorkbenchTasks(kind: WorkbenchKind, limit = 100, activeOnly = false, signal?: AbortSignal) {
const pageSize = Number.isFinite(limit) ? Math.max(1, Math.min(100, Math.trunc(limit))) : 100;
return http.get<GenerationTask[]>("/workbench/tasks", {
params: { kind, pageSize, activeOnly: activeOnly || undefined },
signal,
});
}
export async function listWorkbenchTools(kind: WorkbenchKind, signal?: AbortSignal, admin = false) {
const response = await http.get<{ tools: WorkbenchTool[] }>(admin ? "/admin/workbench/tools" : "/workbench/tools", { params: { kind }, signal });
if (admin) return response;
const tools = await Promise.all(
response.tools.map(async (tool) => ({
...tool,
iconUrl: tool.iconUrl || (tool.iconResourceId ? await resourceDisplayUrl(tool.iconResourceId) : undefined),
coverUrl: tool.coverUrl || (tool.coverResourceId ? await resourceDisplayUrl(tool.coverResourceId) : undefined),
presets: Array.isArray(tool.presets) ? tool.presets.filter((preset) => preset.enabled !== false) : [],
})),
);
return { tools };
}
async function resourceDisplayUrl(resourceId: string, variant: "thumbnail" | "original" = "thumbnail") {
const access = await getResourceAccess(resourceStorageKey(resourceId), "display", variant);
return resolveResourceAccessURL(access.url);
}
export async function resolveWorkbenchResourceURL(resourceId?: string, variant: "thumbnail" | "original" = "thumbnail") {
if (!resourceId?.trim()) return undefined;
try {
return await resourceDisplayUrl(resourceId.trim(), variant);
} catch {
return undefined;
}
}
export function capabilitySupportedModes(tool: WorkbenchTool) {
if (Array.isArray(tool.supportedModes)) return tool.supportedModes;
const capability = typeof tool.capability === "string" ? parseJSONRecord(tool.capability) : tool.capability;
const modes = capability && Array.isArray(capability.supportedModes) ? capability.supportedModes : [];
return modes.filter((item): item is string => typeof item === "string");
}
function parseJSONRecord(value: string): Record<string, unknown> | undefined {
try {
const parsed: unknown = JSON.parse(value);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : undefined;
} catch {
return undefined;
}
}
export function normalizeWorkbenchGuide(guide?: WorkbenchTool["guide"]) {
if (typeof guide === "string") return guide;
if (!guide) return "";
const sections = Array.isArray(guide.sections) ? guide.sections : [];
const lines = Array.isArray(guide.lines) ? guide.lines : sections.flatMap((section) => (section && typeof section === "object" && Array.isArray(section.items) ? section.items : [section]));
return lines
.map((line) => (typeof line === "string" ? line : line && typeof line === "object" && "text" in line ? String(line.text) : ""))
.filter(Boolean)
.join("\n");
}
export type SaveAdminWorkbenchToolInput = Pick<WorkbenchTool, "kind" | "categoryId" | "name" | "subtitle" | "description" | "sortOrder" | "enabled"> & {
id?: string;
iconResourceId?: string;
coverResourceId?: string;
capability?: Record<string, unknown>;
guide?: Record<string, unknown>;
presets?: WorkbenchPromptPreset[];
};
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 || {} })) } : {}) };
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);
}
export type SaveAdminWorkbenchPresetInput = {
toolId: string;
name: string;
summary?: string;
promptTemplate: string;
defaultParameters?: Record<string, unknown>;
sortOrder: number;
enabled: boolean;
};
export function createAdminWorkbenchPreset(input: SaveAdminWorkbenchPresetInput) {
return http.post<{ preset: WorkbenchPromptPreset }>(`/admin/workbench/tools/${encodeURIComponent(input.toolId)}/presets`, input).then(({ preset }) => preset);
}
export function updateAdminWorkbenchPreset(id: string, input: SaveAdminWorkbenchPresetInput) {
return http.patch<{ preset: WorkbenchPromptPreset }>(`/admin/workbench/tools/${encodeURIComponent(input.toolId)}/presets/${encodeURIComponent(id)}`, input).then(({ preset }) => preset);
}
export function setAdminWorkbenchPresetEnabled(id: string, enabled: boolean) {
return http.post<{ id: string; enabled: boolean }>(`/admin/workbench/tools/presets/${encodeURIComponent(id)}/${enabled ? "publish" : "unpublish"}`).then((result) => result.enabled);
}
export function setAdminWorkbenchToolEnabled(id: string, enabled: boolean) {
return http.post<{ id: string; enabled: boolean }>(`/admin/workbench/tools/${encodeURIComponent(id)}/${enabled ? "publish" : "unpublish"}`).then((result) => result.enabled);
}
export type WorkbenchTaskIntent = CreateWorkbenchTaskInput;
export async function createWorkbenchTask(input: WorkbenchTaskIntent) {
const result = await http.post<WorkbenchTaskReceipt | WorkbenchTaskReceipt["task"]>("/workbench/tasks", input);
const task = result && "task" in result ? result.task : result;
if (!task?.id) throw new Error("任务已提交,但没有返回任务编号");
return task;
}
export type WorkbenchTaskReceipt = {
task: {
id: string;
status: "queued" | "running" | "succeeded" | "failed" | "cancelled";
createdAt?: string;
};
};

View File

@@ -39,6 +39,8 @@ export type FeatureAvailability = {
pluginCenterEnabled: boolean;
systemPluginsVisibleToUsers: boolean;
inspirationSourcesVisible: boolean;
imageWorkbenchEnabled: boolean;
videoWorkbenchEnabled: boolean;
configured?: boolean;
updatedBy?: string;
updatedAt?: string;
@@ -54,6 +56,8 @@ export const defaultFeatureAvailability: FeatureAvailability = {
pluginCenterEnabled: true,
systemPluginsVisibleToUsers: true,
inspirationSourcesVisible: true,
imageWorkbenchEnabled: false,
videoWorkbenchEnabled: false,
};
type UserStore = {

View File

@@ -7,6 +7,8 @@ export type ReferenceImage = {
dataUrl: string;
url?: string;
storageKey?: string;
role?: "reference" | "first_frame" | "last_frame";
slotKey?: string;
bytes?: number;
width?: number;
height?: number;

View File

@@ -4,6 +4,8 @@ export type ReferenceVideo = {
type: string;
url: string;
storageKey?: string;
role?: "reference" | "first_frame" | "last_frame";
slotKey?: string;
bytes?: number;
width?: number;
height?: number;
@@ -16,6 +18,8 @@ export type ReferenceAudio = {
type: string;
url: string;
storageKey?: string;
role?: "reference" | "first_frame" | "last_frame";
slotKey?: string;
bytes?: number;
durationMs?: number;
};