refactor(platform): 用户工作台 - 重构画布、任务与资源页面的浏览体验

This commit is contained in:
ddCat
2026-08-06 20:23:31 +08:00
parent 66c68ccd51
commit 5c27879f86
14 changed files with 956 additions and 462 deletions

View File

@@ -1,4 +1,4 @@
import { Check, Clapperboard, Download, FileText, Frame, Image as ImageIcon, MoreHorizontal, Music2, Pencil, Settings2, Sparkles, Trash2, Video, X } from "lucide-react";
import { Check, Clapperboard, Download, FileText, Frame, Image as ImageIcon, MoreHorizontal, Music2, Pencil, Plus, Settings2, Sparkles, Trash2, Video, X } from "lucide-react";
import { useNavigate, useSearchParams } from "react-router";
import { Dropdown, Input } from "antd";
@@ -10,6 +10,14 @@ import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resour
import { resolveBackendApiUrl } from "@/stores/use-config-store";
import { cn } from "@/lib/utils";
export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; onClick: () => void }) {
return <button type="button" className="app-canvas-create-card" disabled={disabled} onClick={onClick}>
<span className="app-canvas-create-preview"><Plus className="app-canvas-create-icon" /></span>
<span className="app-canvas-create-title">新建画布</span>
<span className="app-canvas-create-meta">从空白开始</span>
</button>;
}
export function CanvasProjectCard({ project, projectName, variant = "library" }: { project: CanvasProject; projectName?: string; variant?: "library" | "recent" }) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
@@ -32,11 +40,11 @@ export function CanvasProjectCard({ project, projectName, variant = "library" }:
const compact = variant === "recent";
return (
<article className={cn("app-canvas-project-card group h-full cursor-pointer", compact ? "is-recent" : "is-library overflow-hidden rounded-lg border", selected && "is-selected")} onClick={() => !editing && open()}>
<article className={cn("app-canvas-project-card group h-full cursor-pointer", compact ? "is-recent" : "is-library", selected && "is-selected")} onClick={() => !editing && open()}>
<div className="app-canvas-project-preview relative">
<button
type="button"
className={cn("block w-full overflow-hidden text-left", compact ? "aspect-[16/10]" : "aspect-video")}
className={cn("canvas-project-preview-button block w-full overflow-hidden text-left", compact ? "aspect-[16/10]" : "aspect-video")}
onClick={(event) => {
event.stopPropagation();
open();
@@ -44,34 +52,34 @@ export function CanvasProjectCard({ project, projectName, variant = "library" }:
>
<ProjectPreview project={project} />
</button>
{!compact ? <span className={`absolute left-2.5 top-2.5 grid size-6 place-items-center rounded-md border border-black/10 bg-white/90 shadow-sm backdrop-blur transition-opacity dark:border-white/10 dark:bg-stone-900/90 ${selected ? "opacity-100" : "opacity-100 sm:opacity-0 sm:group-hover:opacity-100 sm:group-focus-within:opacity-100"}`} onClick={(event) => event.stopPropagation()}><input type="checkbox" checked={selected} onChange={(event) => toggleSelected(project.id, event.target.checked)} className="app-canvas-project-checkbox size-3.5" aria-label={`选择 ${project.title}`} /></span> : null}
<span className="absolute bottom-2 right-2 rounded-md border border-white/15 bg-stone-950/80 px-1.5 py-0.5 text-[var(--fs-tiny)] font-medium text-white backdrop-blur-xl">{project.nodes.length} 节点</span>
{!compact ? <span className={`canvas-project-select ${selected ? "is-visible" : ""}`} onClick={(event) => event.stopPropagation()}><input type="checkbox" checked={selected} onChange={(event) => toggleSelected(project.id, event.target.checked)} className="app-canvas-project-checkbox" aria-label={`选择 ${project.title}`} /></span> : null}
<div className="canvas-project-cover-meta" aria-hidden="true"><span className="canvas-project-node-count">{project.nodes.length} 节点</span></div>
</div>
<div className={cn("app-canvas-project-body", compact ? "px-1 pb-1 pt-2.5" : "px-3 py-2.5")}>
<div className="flex min-h-7 items-center justify-between gap-2">
{editing ? (
<Input className="min-w-0" value={editingTitle} onClick={(event) => event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus />
) : (
<button
type="button"
className="min-w-0 flex-1 cursor-pointer text-left focus-visible:outline-none"
onClick={(event) => {
event.stopPropagation();
open();
}}
>
<h2 className="truncate text-[var(--fs-body)] font-semibold leading-5 text-foreground">{project.title}</h2>
</button>
)}
<div className={cn("app-canvas-project-body", compact ? "is-compact" : "")}>
<div className="canvas-project-heading-row">
{editing ? (
<div className="flex shrink-0 items-center gap-1" onClick={(event) => event.stopPropagation()}>
<button type="button" className="grid size-7 place-items-center rounded-md hover:bg-black/5 dark:hover:bg-white/10" onClick={saveTitle} aria-label="保存名称"><Check className="size-3.5" /></button>
<button type="button" className="grid size-7 place-items-center rounded-md hover:bg-black/5 dark:hover:bg-white/10" onClick={stopEditing} aria-label="取消重命名"><X className="size-3.5" /></button>
<Input className="canvas-project-title-input" value={editingTitle} onClick={(event) => event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus />
) : (
<button
type="button"
className="canvas-project-title-button"
onClick={(event) => {
event.stopPropagation();
open();
}}
>
<h2>{project.title}</h2>
</button>
)}
{editing ? (
<div className="canvas-project-actions" onClick={(event) => event.stopPropagation()}>
<button type="button" onClick={saveTitle} aria-label="保存名称"><Check className="size-3.5" /></button>
<button type="button" onClick={stopEditing} aria-label="取消重命名"><X className="size-3.5" /></button>
</div>
) : (
<div className="flex shrink-0 items-center" onClick={(event) => event.stopPropagation()}>
<button type="button" className="grid size-7 place-items-center rounded-md text-foreground/38 transition-colors hover:bg-foreground/[.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-white/10" onClick={() => startEditing(project.id, project.title)} aria-label={`重命名 ${project.title}`} title="重命名"><Pencil className="size-3.5" /></button>
<div className="canvas-project-actions" onClick={(event) => event.stopPropagation()}>
<button type="button" onClick={() => startEditing(project.id, project.title)} aria-label={`重命名 ${project.title}`} title="重命名"><Pencil className="size-3.5" /></button>
<Dropdown
trigger={["click"]}
menu={{
@@ -83,17 +91,12 @@ export function CanvasProjectCard({ project, projectName, variant = "library" }:
],
}}
>
<button type="button" className="grid size-7 place-items-center rounded-md text-foreground/38 transition-colors hover:bg-foreground/[.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring dark:hover:bg-white/10" aria-label={`${project.title} 画布操作`} title="更多操作"><MoreHorizontal className="size-4" /></button>
<button type="button" aria-label={`${project.title} 画布操作`} title="更多操作"><MoreHorizontal className="size-4" /></button>
</Dropdown>
</div>
)}
</div>
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[var(--fs-label)] leading-4 text-foreground/52">
<span className="truncate">{projectName || "自由画布"}</span>
<span className="shrink-0 text-foreground/28" aria-hidden="true">·</span>
<span className="shrink-0 tabular-nums">{project.connections.length} 条连线</span>
</div>
<p className="mt-1 text-[var(--fs-tiny)] leading-4 tabular-nums text-foreground/38">更新于 {formatProjectTime(project.updatedAt)}</p>
<div className="canvas-project-stats"><span>{projectName || "自由画布"}</span><span aria-hidden="true">·</span><time dateTime={project.updatedAt}>{formatProjectTime(project.updatedAt)}</time></div>
</div>
</article>
);
@@ -110,25 +113,25 @@ function ProjectPreview({ project }: { project: CanvasProject }) {
if (media) {
const { node, url } = media;
return (
<div className="size-full bg-stone-900">
<div className="canvas-project-media size-full">
{node.type === CanvasNodeType.Video
? <div className="flex size-full items-center justify-center bg-stone-900 text-stone-300"><Video className="size-8" aria-label={node.title || "项目视频"} /></div>
? <div className="canvas-project-video size-full"><Video className="size-8" aria-label={node.title || "项目视频"} /></div>
: <img src={url} alt={node.title || "项目图片"} loading="lazy" decoding="async" className="size-full min-h-0 object-cover" />}
</div>
);
}
const nodes = project.nodes.slice(0, 8);
if (!nodes.length) return <div className="flex size-full items-center justify-center bg-stone-100 text-xs text-stone-400 dark:bg-stone-900 dark:text-stone-500">空白画布</div>;
if (!nodes.length) return <div className="canvas-project-empty size-full"><Plus className="canvas-project-empty-icon" /><span>空白画布</span><small>等待第一幕</small></div>;
const previewNodes = buildNodePreviewLayout(nodes);
return (
<div className="relative size-full overflow-hidden bg-stone-100/80 bg-[linear-gradient(rgba(17,24,39,.05)_1px,transparent_1px),linear-gradient(90deg,rgba(17,24,39,.05)_1px,transparent_1px)] bg-[size:20px_20px] dark:bg-stone-950/80 dark:bg-[linear-gradient(rgba(255,255,255,.05)_1px,transparent_1px),linear-gradient(90deg,rgba(255,255,255,.05)_1px,transparent_1px)]">
<div className="canvas-project-preview-canvas relative size-full overflow-hidden">
{previewNodes.map(({ node, style }) => {
const presentation = getNodePresentation(node);
return (
<span key={node.id} className="absolute flex min-w-0 items-center gap-1.5 overflow-hidden rounded-md border border-stone-300/90 bg-white/90 px-2 text-left shadow-sm backdrop-blur-sm dark:border-stone-700 dark:bg-stone-900/92" style={style}>
<span className="grid size-5 shrink-0 place-items-center text-stone-500 dark:text-stone-300">{presentation.icon}</span>
<span className="min-w-0 truncate text-[var(--fs-micro)] font-semibold text-stone-700 dark:text-stone-200">{node.title || presentation.label}</span>
<span key={node.id} className="canvas-project-preview-node absolute flex min-w-0 items-center gap-1.5 overflow-hidden" style={style}>
<span className="canvas-project-preview-node-icon">{presentation.icon}</span>
<span className="canvas-project-preview-node-label">{node.title || presentation.label}</span>
</span>
);
})}
@@ -186,5 +189,15 @@ function isPreviewUrl(value?: string) {
}
function formatProjectTime(value: string) {
return new Date(value).toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
const timestamp = new Date(value).getTime();
if (!Number.isFinite(timestamp)) return "刚刚修改";
const elapsed = Math.max(0, Date.now() - timestamp);
const minutes = Math.floor(elapsed / 60000);
if (minutes < 1) return "刚刚修改";
if (minutes < 60) return `${minutes} 分钟前修改`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours} 小时前修改`;
const days = Math.floor(hours / 24);
if (days < 7) return `${days} 天前修改`;
return new Date(value).toLocaleDateString("zh-CN", { month: "numeric", day: "numeric" }) + " 修改";
}

View File

@@ -11,6 +11,21 @@ export function ClientRootInit({ children }: { children: ReactNode }) {
const updateConfig = useConfigStore((state) => state.updateConfig);
const config = useConfigStore((state) => state.config);
useEffect(() => {
const interactiveSelector = 'button, [role="button"], a, [class*="card"], [class*="Card"]';
const blurPointerFocus = (event: PointerEvent) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
const target = event.target instanceof Element ? event.target.closest<HTMLElement>(interactiveSelector) : null;
if (!target || target.hasAttribute("disabled") || target.getAttribute("aria-disabled") === "true") return;
// 浏览器可能把鼠标点击误判为 :focus-visible;下一帧只清掉这次指针点击产生的焦点。
window.requestAnimationFrame(() => {
if (document.activeElement === target) target.blur();
});
};
document.addEventListener("pointerdown", blurPointerFocus, true);
return () => document.removeEventListener("pointerdown", blurPointerFocus, true);
}, []);
useEffect(() => {
if (handledConfigParams.current) return;
const searchParams = new URLSearchParams(window.location.search);

View File

@@ -83,6 +83,7 @@ export default function AssetsPage() {
const selectedAssets = useMemo(() => validAssets.filter((asset) => selectedIds.includes(asset.id)), [selectedIds, validAssets]);
const kindCounts = useMemo(() => new Map(kindOptions.map((option) => [option.value, option.value === "all" ? validAssets.length : validAssets.filter((asset) => asset.kind === option.value).length])), [validAssets]);
const categoryCounts = useMemo(() => new Map(categoryOptions.map((option) => [option.value, option.value === "all" ? validAssets.length : validAssets.filter((asset) => (asset.category || "other") === option.value).length])), [validAssets]);
const canCreateAsset = !keyword.trim() && kindFilter === "all" && categoryFilter === "all";
const filteredAssets = useMemo(() => {
const query = keyword.trim().toLowerCase();
@@ -255,7 +256,7 @@ export default function AssetsPage() {
return (
<>
<WorkspacePage grid>
<WorkspacePage grid className="library-page assets-library-page">
<PageHeader
icon="assets"
title="素材库"
@@ -265,11 +266,10 @@ export default function AssetsPage() {
<>
<Button icon={<Download className="size-4" />} onClick={() => void exportAllAssets()}>导出全部</Button>
<Dropdown trigger={["click"]} menu={{ items: [{ key: "package", icon: <FileUp className="size-4" />, label: "导入素材包", onClick: () => assetInputRef.current?.click() }, { key: "model", icon: <Upload className="size-4" />, label: "上传 3D 模型", onClick: () => modelInputRef.current?.click() }] }}><Button icon={<FileUp className="size-4" />}>导入</Button></Dropdown>
<Button type="primary" icon={<Plus className="size-4" />} onClick={openCreate}>新增素材</Button>
</>
)}
/>
<ListToolbar active={Boolean(keyword || kindFilter !== "all" || categoryFilter !== "all")} onReset={() => { setKeyword(""); setKindFilter("all"); setCategoryFilter("all"); setPage(1); }}>
<ListToolbar className="library-toolbar" active={Boolean(keyword || kindFilter !== "all" || categoryFilter !== "all")} onReset={() => { setKeyword(""); setKindFilter("all"); setCategoryFilter("all"); setPage(1); }}>
<Input allowClear className="w-full sm:w-80" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索标题、内容、标签或来源" onChange={(event) => { setPage(1); setKeyword(event.target.value); }} />
</ListToolbar>
@@ -287,20 +287,25 @@ export default function AssetsPage() {
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={() => setBatchDeleteOpen(true)}>删除</Button>
</div>
) : null}
<CollectionGrid>
<CollectionGrid className="library-grid assets-library-grid">
{canCreateAsset ? <button type="button" className="library-create-card" onClick={openCreate}>
<span className="library-create-cover"><Plus className="size-8" /></span>
<span className="library-create-title">新增素材</span>
<span className="library-create-meta">文本、图片、音视频或模型</span>
</button> : null}
{visibleAssets.map((asset) => (
<AssetCard key={asset.id} asset={asset} selected={selectedIds.includes(asset.id)} onSelect={(selected) => setSelectedIds((current) => selected ? [...new Set([...current, asset.id])] : current.filter((id) => id !== asset.id))} onOpen={() => setPreviewAsset(asset)} onEdit={() => openEdit(asset)} onCopy={copyAssetText} onDownload={downloadImage} onDelete={() => setDeletingAsset(asset)} />
))}
</CollectionGrid>
{!visibleAssets.length ? <WorkspaceState icon="assets" compact title={validAssets.length ? "没有匹配的素材" : "素材库还是空的"} description={validAssets.length ? "调整关键词或左侧分类后再试。" : "从画布保存结果,或导入已有素材包开始整理。"} action={!validAssets.length ? <Button type="primary" icon={<Plus className="size-4" />} onClick={openCreate}>新增素材</Button> : undefined} /> : null}
{!visibleAssets.length && (validAssets.length > 0 || !canCreateAsset) ? <WorkspaceState icon="assets" compact title={validAssets.length ? "没有匹配的素材" : "素材库还是空的"} description={validAssets.length ? "调整关键词或左侧分类后再试。" : "从画布保存结果,或导入已有素材包开始整理。"} action={!validAssets.length ? <Button type="primary" icon={<Plus className="size-4" />} onClick={openCreate}>新增素材</Button> : undefined} /> : null}
<PaginationBar current={page} pageSize={pageSize} total={filteredAssets.length} pageSizeOptions={[20, 40, 80]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
</section>
</div>
</WorkspacePage>
<Modal title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
<Modal className="library-modal" title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} width={980} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", category: "other", tags: [] }}>
<Form.Item name="kind" label="类型">
@@ -414,10 +419,10 @@ export default function AssetsPage() {
<input ref={assetInputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importAssetZip(event.target.files?.[0])} />
<input ref={modelInputRef} type="file" accept=".glb,.gltf,model/gltf-binary,model/gltf+json" className="hidden" onChange={(event) => { void readModelFile(event.target.files?.[0]); event.currentTarget.value = ""; }} />
<Modal title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={() => void confirmDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
<Modal className="library-modal library-confirm-modal" title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={() => void confirmDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
确定删除「{deletingAsset?.title}」吗?删除后会从我的素材中移除。
</Modal>
<Modal title="批量删除素材" open={batchDeleteOpen} onCancel={() => setBatchDeleteOpen(false)} onOk={() => void confirmBatchDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
<Modal className="library-modal library-confirm-modal" title="批量删除素材" open={batchDeleteOpen} onCancel={() => setBatchDeleteOpen(false)} onOk={() => void confirmBatchDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
确定删除已选择的 {selectedAssets.length} 个素材吗?此操作会同步移除远端记录。
</Modal>
</>
@@ -427,7 +432,7 @@ export default function AssetsPage() {
function AssetCard({ asset, selected, onSelect, onOpen, onEdit, onCopy, onDownload, onDelete }: { asset: LibraryAsset; selected: boolean; onSelect: (selected: boolean) => void; onOpen: () => void; onEdit: () => void; onCopy: (asset: LibraryAsset) => void; onDownload: (asset: LibraryAsset) => void; onDelete: () => void }) {
const summary = assetSummary(asset);
return (
<article className={`group overflow-hidden rounded-[var(--r-md)] border bg-background/55 transition-[border-color,background-color,box-shadow] duration-[var(--motion-dur-fast-calc)] ${selected ? "border-[var(--workspace-accent)] bg-[var(--workspace-accent-soft)] shadow-[0_0_0_var(--stroke-2)_var(--workspace-accent-soft)]" : "border-border/80 hover:border-foreground/25 hover:bg-background/75 focus-within:border-foreground/25"}`}>
<article className={`library-card asset-library-card group ${selected ? "is-selected" : ""}`}>
<div className="relative overflow-hidden bg-foreground/[.045]">
<button type="button" className="block w-full text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]" onClick={onOpen} aria-label={`查看素材:${asset.title}`}>
<AssetMediaPreview asset={asset} alt={asset.title} className="aspect-[4/3] w-full bg-black object-cover" fallback={<div className="flex aspect-[4/3] items-center justify-center bg-foreground/[.035] p-4 text-center text-xs leading-5 text-foreground/55">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>} />
@@ -485,7 +490,7 @@ function AssetFilterGroup({ title, options, value, counts, onChange, className =
function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAsset | null; onClose: () => void; onCopy: (asset: LibraryAsset) => void; onDownload: (asset: LibraryAsset) => void }) {
return (
<Drawer title="素材详情" open={Boolean(asset)} size="large" onClose={onClose}>
<Drawer className="library-drawer" title="素材详情" open={Boolean(asset)} size="large" onClose={onClose}>
{asset ? (
<div className="space-y-5">
<AssetMediaPreview asset={asset} alt={asset.title} className="max-h-[420px] w-full rounded-lg bg-black object-contain" fallback={<div className="rounded-lg border border-stone-200 bg-stone-50 p-5 text-sm leading-6 text-stone-600 dark:border-stone-800 dark:bg-stone-900 dark:text-stone-300">{asset.kind === "text" ? asset.data.content : "暂无封面"}</div>} />
@@ -502,7 +507,7 @@ function AssetDrawer({ asset, onClose, onCopy, onDownload }: { asset: LibraryAss
))}
</Space>
</div>
<div className="rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<div className="asset-drawer-content rounded-lg border border-stone-200 p-4 dark:border-stone-800">
<Typography.Text type="secondary" className="block text-xs">
内容
</Typography.Text>

View File

@@ -1,16 +1,16 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { App, Button, Dropdown, Input, Modal, Select } from "antd";
import { Download, FileUp, MoreHorizontal, Plus, Search, Trash2 } from "lucide-react";
import { App, Button, Dropdown, Modal, Select } from "antd";
import { ArrowDownAZ, Clock3, Download, FileUp, ListFilter, MoreHorizontal, Search, SlidersHorizontal, Trash2, X } from "lucide-react";
import { CollectionGrid, ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
import { CollectionGrid, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
import { readZip } from "@/lib/zip";
import { setMediaBlob } from "@/services/file-storage";
import { setImageBlob } from "@/services/image-storage";
import { CanvasProjectCard } from "@/components/canvas/canvas-project-card";
import { CanvasCreateCard, CanvasProjectCard } from "@/components/canvas/canvas-project-card";
import type { CanvasExportFile } from "@/types/canvas-export";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useCanvasUiStore } from "@/stores/canvas/use-canvas-ui-store";
@@ -61,7 +61,20 @@ export default function CanvasPage() {
}, [keyword, projectFilter, projects, sort]);
const projectNames = useMemo(() => new Map((projectQuery.data?.projects || []).map(({ project }) => [project.id, project.name])), [projectQuery.data]);
const visibleProjects = filteredProjects.slice((page - 1) * pageSize, page * pageSize);
const showCreateCard = !keyword.trim() && projectFilter === "all";
const selectedProjects = projects.filter((project) => selectedIds.includes(project.id));
const projectFilterLabel = projectFilter === "all" ? "全部画布" : projectFilter === "independent" ? "自由画布" : projectNames.get(projectFilter) || "项目画布";
const sortLabel = sort === "name" ? "按名称" : sort === "nodes" ? "按节点" : "最近更新";
const projectFilterItems = useMemo(() => [
{ key: "all", label: "全部画布" },
{ key: "independent", label: "自由画布" },
...(projectQuery.data?.projects || []).map(({ project }) => ({ key: project.id, label: project.name })),
], [projectQuery.data]);
const sortItems = [
{ key: "updated", label: "最近更新", icon: <Clock3 className="size-3.5" /> },
{ key: "name", label: "按名称", icon: <ArrowDownAZ className="size-3.5" /> },
{ key: "nodes", label: "按节点数量", icon: <ListFilter className="size-3.5" /> },
];
const associateSelected = async (nextProjectId = associationProjectId) => {
const projectId = nextProjectId || undefined;
selectedIds.forEach((id) => updateProject(id, { projectId }));
@@ -153,33 +166,36 @@ export default function CanvasPage() {
<PageHeader
icon="canvas"
title="画布"
description="管理自由创作空间和已加入项目的制作画布。"
meta={<span className="text-xs tabular-nums text-foreground/45">{hydrated ? `${filteredProjects.length} 个` : "载入中"}</span>}
description="把镜头、素材和想法留在同一张画布里。"
actions={(
<>
{projects.length ? (
<Dropdown menu={{ items: [{ key: "delete-all", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除全部画布", onClick: () => setDeleteIds(projects.map((project) => project.id)) }] }} trigger={["click"]}>
<Button className="!h-9 !w-9 !p-0" aria-label="更多画布操作" title="更多操作" icon={<MoreHorizontal className="size-4" />} />
</Dropdown>
) : null}
</Dropdown>
) : null}
<Button className="!h-9 !px-3.5" disabled={!hydrated} icon={<FileUp className="size-3.5" />} onClick={() => inputRef.current?.click()}>导入</Button>
<Button className="!h-9 !px-4" type="primary" disabled={!hydrated} icon={<Plus className="size-3.5" />} onClick={createAndEnter}>新建画布</Button>
</>
)}
/>
<ListToolbar
className="canvas-library-toolbar"
active={Boolean(keyword || projectFilter !== "all" || sort !== "updated")}
trailing={<span className="text-xs tabular-nums text-foreground/42">显示 {visibleProjects.length} / {filteredProjects.length}</span>}
onReset={() => { setKeyword(""); setProjectFilter("all"); setSort("updated"); setPage(1); }}
>
<div className="canvas-library-search min-w-[220px] flex-[1_1_420px]">
<Input allowClear className="w-full" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索画布" aria-label="搜索画布" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
<section className="canvas-library-discovery" aria-label="画布浏览工具">
<div className="canvas-library-search">
<Search aria-hidden="true" />
<input value={keyword} placeholder="搜索画布" aria-label="搜索画布" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
{keyword ? <button type="button" aria-label="清除搜索" onClick={() => { setKeyword(""); setPage(1); }}><X /></button> : null}
</div>
<Select aria-label="按所属项目筛选" className="w-[168px]" value={projectFilter} onChange={(value) => { setProjectFilter(value); setPage(1); }} options={[{ label: "全部项目", value: "all" }, { label: "自由画布", value: "independent" }, ...(projectQuery.data?.projects || []).map(({ project }) => ({ label: project.name, value: project.id }))]} />
<Select aria-label="画布排序" className="w-[136px]" value={sort} onChange={(value) => { setSort(value); setPage(1); }} options={[{ label: "最近更新", value: "updated" }, { label: "名称排序", value: "name" }, { label: "节点数量", value: "nodes" }]} />
</ListToolbar>
<div className="canvas-library-filters">
<Dropdown trigger={["click"]} placement="bottomLeft" menu={{ items: projectFilterItems, selectedKeys: [projectFilter], onClick: ({ key }) => { setProjectFilter(String(key)); setPage(1); } }}>
<button type="button" className={`canvas-library-filter${projectFilter !== "all" ? " is-active" : ""}`} aria-label="按所属项目筛选"><SlidersHorizontal /><span>{projectFilterLabel}</span></button>
</Dropdown>
<Dropdown trigger={["click"]} placement="bottomLeft" menu={{ items: sortItems, selectedKeys: [sort], onClick: ({ key }) => { setSort(key as typeof sort); setPage(1); } }}>
<button type="button" className={`canvas-library-filter${sort !== "updated" ? " is-active" : ""}`} aria-label="画布排序">{sort === "updated" ? <Clock3 /> : sort === "name" ? <ArrowDownAZ /> : <ListFilter />}<span>{sortLabel}</span></button>
</Dropdown>
{keyword || projectFilter !== "all" || sort !== "updated" ? <button type="button" className="canvas-library-reset" onClick={() => { setKeyword(""); setProjectFilter("all"); setSort("updated"); setPage(1); }}>重置</button> : null}
</div>
<span className="canvas-library-count"><strong>{String(filteredProjects.length).padStart(2, "0")}</strong><span>/ {String(projects.length).padStart(2, "0")} 画布</span></span>
</section>
{selectedIds.length ? (
<div className="app-canvas-selection-toolbar mt-2 flex min-h-10 flex-wrap items-center gap-2 rounded-md border px-3 py-1.5 text-xs">
@@ -193,14 +209,15 @@ export default function CanvasPage() {
{!hydrated ? (
<WorkspaceLoadingState label="正在恢复画布" detail="读取本地缓存与账号同步状态" />
) : visibleProjects.length ? (
) : showCreateCard || visibleProjects.length ? (
<CollectionGrid className="canvas-library-grid">
{showCreateCard ? <CanvasCreateCard disabled={!hydrated} onClick={createAndEnter} /> : null}
{visibleProjects.map((project) => (
<CanvasProjectCard key={project.id} project={project} projectName={project.projectId ? projectNames.get(project.projectId) || "未同步项目" : undefined} />
))}
</CollectionGrid>
) : (
<WorkspaceState icon="canvas" title={keyword ? "没有匹配的画布" : "创建一张可继续生长的画布"} description={keyword ? "换一个画布名称或重置筛选条件。" : "把文本、图片、视频和 Agent 操作连接成可追踪的创作流程。"} action={!keyword ? <Button type="primary" icon={<Plus className="size-3.5" />} onClick={createAndEnter}>新建画布</Button> : undefined} />
<WorkspaceState icon="canvas" title="没有匹配的画布" description="换一个画布名称或重置筛选条件。" />
)}
<PaginationBar current={page} pageSize={pageSize} total={filteredProjects.length} pageSizeOptions={[12, 24, 48]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />

View File

@@ -2,8 +2,9 @@ import type { UploadedFile } from "@/services/file-storage";
import type { UploadedImage } from "@/services/image-storage";
import type { Asset, ImageAsset, NewAsset } from "@/stores/use-asset-store";
import type { ReferenceImage } from "@/types/image";
import type { ReferenceVideo } from "@/types/media";
export type CreationAttachment = ReferenceImage & { previewUrl: string };
export type CreationAttachment = (ReferenceImage | ReferenceVideo) & { previewUrl: string };
export type CreationAssetIdentity = {
taskId?: string;
@@ -45,6 +46,21 @@ export function creationAttachmentFromImage(file: File, uploaded: UploadedImage)
};
}
export function creationAttachmentFromVideo(file: File, uploaded: UploadedFile): CreationAttachment {
return {
id: `upload:${file.name}:${uploaded.storageKey}`,
name: file.name,
type: uploaded.mimeType || file.type || "video/mp4",
url: uploaded.url,
storageKey: uploaded.storageKey,
bytes: uploaded.bytes,
width: uploaded.width,
height: uploaded.height,
durationMs: uploaded.durationMs,
previewUrl: uploaded.url,
};
}
export function creationAttachmentFromAsset(asset: ImageAsset): CreationAttachment {
const url = asset.data.dataUrl || asset.coverUrl;
return {
@@ -61,6 +77,21 @@ export function creationAttachmentFromAsset(asset: ImageAsset): CreationAttachme
};
}
export function creationAttachmentFromVideoAsset(asset: Extract<Asset, { kind: "video" }>): CreationAttachment {
return {
id: `asset:${asset.id}`,
name: asset.title || "素材视频",
type: asset.data.mimeType || "video/mp4",
url: asset.data.url,
storageKey: asset.data.storageKey,
bytes: asset.data.bytes,
width: asset.data.width,
height: asset.data.height,
durationMs: asset.data.durationMs,
previewUrl: asset.coverUrl || asset.data.url,
};
}
export function creationImageAsset({ title, uploaded, metadata }: { title: string; uploaded: UploadedImage; metadata?: Record<string, unknown> }): NewAsset {
return {
kind: "image",

View File

@@ -1,13 +1,13 @@
import { buildSkillMentionReferences, renderSkillPrompt } from "@/lib/canvas/canvas-skill-mentions";
import { canvasResourceMentionToken, type CanvasResourceReference } from "@/lib/canvas/canvas-resource-references";
import type { Skill } from "@/services/api/skills";
import type { ReferenceImage } from "@/types/image";
import type { CreationAttachment } from "./creation-assets";
export type CreationReference = CanvasResourceReference & {
attachmentId?: string;
};
export function buildCreationMentionReferences(skills: Skill[], attachments: ReferenceImage[] = [], snapshots: CreationReference[] = []) {
export function buildCreationMentionReferences(skills: Skill[], attachments: CreationAttachment[] = [], snapshots: CreationReference[] = []) {
const attachmentReferences = attachments.map(attachmentReference);
const skillReferences = buildSkillMentionReferences(skills) as CreationReference[];
const current = [...attachmentReferences, ...skillReferences];
@@ -24,7 +24,7 @@ export function displayCreationPrompt(prompt: string, references: CreationRefere
return references.reduce((value, reference) => value.split(canvasResourceMentionToken(reference)).join(`@${reference.label}`), prompt);
}
export function expandCreationPrompt(prompt: string, references: CreationReference[], attachments: ReferenceImage[] = []) {
export function expandCreationPrompt(prompt: string, references: CreationReference[], attachments: CreationAttachment[] = []) {
const visiblePrompt = displayCreationPrompt(prompt, references).trim();
if (!references.length) return visiblePrompt;
@@ -38,7 +38,7 @@ export function expandCreationPrompt(prompt: string, references: CreationReferen
}
if (reference.attachmentId) {
const position = attachmentPositions.get(reference.attachmentId);
mediaMappings.push(`- @${reference.label}:参考图片 ${position || 1}`);
mediaMappings.push(`- @${reference.label}:参考${reference.kind === "video" ? "视频" : "图片"} ${position || 1}`);
return;
}
});
@@ -53,14 +53,15 @@ export function creationReferenceMetadata(references: CreationReference[]) {
};
}
function attachmentReference(attachment: ReferenceImage, index: number): CreationReference {
function attachmentReference(attachment: CreationAttachment, index: number): CreationReference {
const isVideo = attachment.type.startsWith("video/");
return {
id: `upload:${attachment.id}`,
nodeId: `upload:${attachment.id}`,
kind: "image",
label: `图片${index + 1}`,
kind: isVideo ? "video" : "image",
label: `${isVideo ? "视频" : "图片"}${index + 1}`,
title: "当前参考内容",
previewUrl: attachment.dataUrl || attachment.url,
previewUrl: attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url),
storageKey: attachment.storageKey,
active: true,
attachmentId: attachment.id,

View File

@@ -1,10 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState, type ChangeEvent, type ReactNode, type RefObject } from "react";
import localforage from "localforage";
import { App, Drawer, Modal, Popover, Spin, Tooltip } from "antd";
import { ArrowUp, Check, ChevronDown, Clock3, Download, FileText, Film, History, Image as ImageIcon, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, SlidersHorizontal, Sparkles, Square, X } from "lucide-react";
import { ArrowUp, Check, ChevronDown, Clock3, Download, FileText, Film, FolderOpen, History, Image as ImageIcon, Maximize2, MessageSquareText, Music2, Plus, RefreshCw, Search, SlidersHorizontal, Sparkles, Square, Upload, X } from "lucide-react";
import ReactMarkdown from "react-markdown";
import { Link } from "react-router";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { CanvasResourceMentionTextarea } from "@/components/canvas/canvas-resource-mention-textarea";
import { ModelPicker } from "@/components/model-picker";
import { canvasResourceMentionToken } from "@/lib/canvas/canvas-resource-references";
@@ -17,11 +18,12 @@ import { requestImageQuestion } from "@/services/api/image";
import { listAddedSkills, type Skill } from "@/services/api/skills";
import { listGenerationTasks, queryGenerationTask, type GenerationTask } from "@/services/api/task-center";
import { storeGeneratedVideo } from "@/services/api/video";
import { uploadMediaFile } from "@/services/file-storage";
import { resolveImageUrl, uploadImage, type UploadedImage } from "@/services/image-storage";
import { modelDisplayName, modelOptionName, selectableModelsByCapability, useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { useAssetStore, type NewAsset } from "@/stores/use-asset-store";
import { useAssetStore, type Asset, type NewAsset } from "@/stores/use-asset-store";
import { buildCreationMentionReferences, creationReferenceMetadata, displayCreationPrompt, expandCreationPrompt, selectedCreationReferences, type CreationReference } from "./creation-references";
import { creationAssetKey, creationAttachmentFromImage, creationImageAsset, creationVideoAsset, isSameCreationAsset, type CreationAssetIdentity, type CreationAttachment } from "./creation-assets";
import { creationAssetKey, creationAttachmentFromAsset, creationAttachmentFromImage, creationAttachmentFromVideo, creationAttachmentFromVideoAsset, creationImageAsset, creationVideoAsset, isSameCreationAsset, type CreationAssetIdentity, type CreationAttachment } from "./creation-assets";
type CreationMode = "text" | "image" | "video";
type CreationStatus = "streaming" | "pending" | "done" | "error" | "cancelled";
@@ -116,6 +118,7 @@ export default function CreatePage() {
const [count, setCount] = useState(String(Math.max(1, Math.min(4, Number(config.count) || 1))));
const [busy, setBusy] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [libraryOpen, setLibraryOpen] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const threadScrollRef = useRef<HTMLElement>(null);
@@ -237,24 +240,30 @@ export default function CreatePage() {
}
};
const maxReferences = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : 6;
const addAttachments = (files: FileList | File[]) => {
const maxImages = mode === "video" ? videoProfile.operations.includes("image_to_video") ? videoProfile.references.maxImages : 0 : 6;
if (mode === "video" && maxImages === 0) {
if (mode === "video" && maxReferences === 0) {
toast.warning("当前模型不支持图生视频");
return;
}
const next = Array.from(files).filter((file) => file.type.startsWith("image/")).slice(0, Math.max(0, maxImages - attachments.length));
const next = Array.from(files)
.filter((file) => file.type.startsWith("image/") || (mode === "video" && file.type.startsWith("video/")))
.slice(0, Math.max(0, maxReferences - attachments.length));
if (!next.length) return;
void Promise.allSettled(next.map(async (file) => {
if (file.type.startsWith("video/")) {
const uploaded = await uploadMediaFile(file, "create-upload");
addAsset(creationVideoAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }));
return creationAttachmentFromVideo(file, uploaded);
}
const uploaded = await uploadImage(file);
const attachment = creationAttachmentFromImage(file, uploaded);
addAsset(creationImageAsset({ title: file.name, uploaded, metadata: { source: "create-upload", fileName: file.name } }));
return attachment;
return creationAttachmentFromImage(file, uploaded);
})).then((settled) => {
const items = settled.flatMap((entry) => entry.status === "fulfilled" ? [entry.value] : []);
const failed = settled.filter((entry) => entry.status === "rejected");
if (items.length) setAttachments((current) => [...current, ...items].slice(0, mode === "video" ? videoProfile.references.maxImages : 6));
if (failed.length) toast.error(`${failed.length} 张参考图片上传失败,请重试`);
if (items.length) setAttachments((current) => [...current, ...items].slice(0, maxReferences));
if (failed.length) toast.error(`${failed.length} 个参考素材上传失败,请重试`);
});
};
@@ -263,6 +272,17 @@ export default function CreatePage() {
event.target.value = "";
};
const handleLibrarySelect = (selected: Asset[]) => {
const next = selected.flatMap((asset): CreationAttachment[] => {
if (asset.kind === "image") return [creationAttachmentFromAsset(asset)];
if (asset.kind === "video" && mode === "video") return [creationAttachmentFromVideoAsset(asset)];
return [];
});
if (!next.length) return;
setAttachments((current) => [...current.filter((item) => !next.some((candidate) => candidate.id === item.id)), ...next].slice(0, maxReferences));
setLibraryOpen(false);
};
const removeAttachment = (id: string) => {
const reference = mentionReferences.find((item) => item.attachmentId === id);
setAttachments((current) => current.filter((item) => item.id !== id));
@@ -286,6 +306,9 @@ export default function CreatePage() {
}
const settings = { ratio, seconds, quality, videoQuality, count };
const references = selectedCreationReferences(text, mentionReferences);
// 后端对图片和视频使用不同的参考字段;这里先拆分,避免媒体类型在写入任务时被误判。
const referenceImages = attachments.filter(isImageAttachment);
const referenceVideos = attachments.filter(isVideoAttachment);
const expandedPrompt = expandCreationPrompt(text, references, attachments);
if (mode === "video" && Array.from(expandedPrompt).length > videoProfile.references.promptMaxChars) {
toast.error(`引用展开后提示词超过当前模型限制(最多 ${videoProfile.references.promptMaxChars} 字)`);
@@ -331,7 +354,7 @@ export default function CreatePage() {
mode: "image",
prompt: expandedPrompt,
config: { ...requestConfig, count: "1" },
referenceImages: attachments,
referenceImages,
signal: controller.signal,
metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, ...referenceMetadata },
onTaskUpdate: bindTask,
@@ -367,7 +390,8 @@ export default function CreatePage() {
mode: "video",
prompt: expandedPrompt,
config: requestConfig,
referenceImages: attachments,
referenceImages,
referenceVideos,
signal: controller.signal,
metadata: { source: "create-page", conversationId: activeConversation.id, messageId: assistantMessage.id, videoEditOperation: attachments.length ? "image_to_video" : "text_to_video", ...referenceMetadata },
onTaskUpdate: bindTask,
@@ -469,6 +493,7 @@ export default function CreatePage() {
attachments,
references: mentionReferences,
onRemoveAttachment: removeAttachment,
onOpenLibrary: () => setLibraryOpen(true),
fileInputRef,
onFileChange: handleFileChange,
onModeChange: selectMode,
@@ -498,6 +523,7 @@ export default function CreatePage() {
</div>
<main ref={threadScrollRef} onScroll={handleThreadScroll} className="creation-scrollbar flex h-full min-h-0 flex-col overflow-y-scroll overscroll-contain">
{isEmpty ? <section className="creation-empty-workspace">
<CreationEmptyArt />
<CreationIntro mode={mode} />
<div className="creation-empty-composer"><CreationComposer {...composerProps} variant="empty" /></div>
</section> : <>
@@ -509,25 +535,103 @@ export default function CreatePage() {
</main>
</div>
<CreationHistoryDrawer open={historyOpen} conversations={historyConversations} activeId={activeConversation.id} onClose={() => setHistoryOpen(false)} onSelect={selectConversation} />
<CreationAssetLibraryModal open={libraryOpen} assets={assets} mode={mode} selectedIds={new Set(attachments.filter((item) => item.id.startsWith("asset:")).map((item) => item.id.slice(6)))} onClose={() => setLibraryOpen(false)} onConfirm={handleLibrarySelect} onUpload={() => fileInputRef.current?.click()} />
</>;
}
const creationAssetCategoryLabels: Record<string, string> = { all: "全部素材", character: "角色", environment: "场景", wardrobe: "服饰", prop: "道具", weapon: "武器", style: "画风", other: "其他" };
function CreationAssetLibraryModal({ open, assets, mode, selectedIds, onClose, onConfirm, onUpload }: { open: boolean; assets: Asset[]; mode: CreationMode; selectedIds: Set<string>; onClose: () => void; onConfirm: (assets: Asset[]) => void; onUpload: () => void }) {
const [category, setCategory] = useState("all");
const [keyword, setKeyword] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
const mediaAssets = useMemo(() => assets.filter((asset): asset is Extract<Asset, { kind: "image" | "video" }> => asset.kind === "image" || asset.kind === "video"), [assets]);
const categories = useMemo(() => ["all", ...Array.from(new Set(mediaAssets.map((asset) => asset.category || "other")))], [mediaAssets]);
const visibleAssets = useMemo(() => {
const query = keyword.trim().toLowerCase();
return mediaAssets.filter((asset) => (category === "all" || (asset.category || "other") === category) && (!query || [asset.title, ...(asset.tags || [])].join(" ").toLowerCase().includes(query)));
}, [category, keyword, mediaAssets]);
useEffect(() => {
if (!open) return;
setCategory("all");
setKeyword("");
setSelected(new Set(selectedIds));
}, [open]);
const toggle = (asset: Asset) => {
if (mode !== "video" && asset.kind === "video") return;
setSelected((current) => {
const next = new Set(current);
if (next.has(asset.id)) next.delete(asset.id); else next.add(asset.id);
return next;
});
};
const selectedAssets = mediaAssets.filter((asset) => selected.has(asset.id) && (mode === "video" || asset.kind === "image"));
const count = category === "all" ? mediaAssets.length : mediaAssets.filter((asset) => (asset.category || "other") === category).length;
return <Modal open={open} footer={null} title={null} destroyOnHidden onCancel={onClose} width="min(980px, calc(100vw - 24px))" className="creation-asset-library-modal" styles={{ container: { padding: 0 }, body: { padding: 0 } }}>
<div className="creation-library-shell">
<div className="creation-library-toolbar"><div className="creation-library-toolbar-title"><span>参考内容</span><strong>素材库</strong></div><div className="creation-library-search"><Search /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索素材名称或标签" aria-label="搜索素材" /></div><span className="creation-library-toolbar-count">已选 {selectedAssets.length} · {count} 个素材</span></div>
<div className="creation-library-body">
<nav className="creation-library-categories" aria-label="素材分类">{categories.map((item) => <button key={item} type="button" className={category === item ? "is-active" : ""} onClick={() => setCategory(item)}><span>{creationAssetCategoryLabels[item] || "其他"}</span><em>{item === "all" ? mediaAssets.length : mediaAssets.filter((asset) => (asset.category || "other") === item).length}</em></button>)}</nav>
<div className="creation-library-grid-wrap"><div className="creation-library-grid">{visibleAssets.length ? visibleAssets.map((asset) => <CreationLibraryCard key={asset.id} asset={asset} selected={selected.has(asset.id)} disabled={mode !== "video" && asset.kind === "video"} onToggle={() => toggle(asset)} />) : <div className="creation-library-empty"><FolderOpen /><strong>这个分类还没有素材</strong><span>换个分类,或从底部上传一份新素材。</span></div>}</div></div>
</div>
<footer className="creation-library-footer"><button type="button" className="creation-library-upload" onClick={onUpload}><Upload /><span><strong>找不到素材,手动上传</strong><small>支持图片{mode === "video" ? "和视频" : ""},可一次选择多个文件</small></span></button><div className="creation-library-actions"><button type="button" onClick={onClose}>取消</button><button type="button" className="is-primary" disabled={!selectedAssets.length} onClick={() => onConfirm(selectedAssets)}><Check />使用已选素材{selectedAssets.length ? `(${selectedAssets.length})` : ""}</button></div></footer>
</div>
</Modal>;
}
function CreationLibraryCard({ asset, selected, disabled, onToggle }: { asset: Extract<Asset, { kind: "image" | "video" }>; selected: boolean; disabled: boolean; onToggle: () => void }) {
const isVideo = asset.kind === "video";
return <button type="button" className={`creation-library-card${selected ? " is-selected" : ""}${disabled ? " is-disabled" : ""}`} onClick={onToggle} disabled={disabled} aria-pressed={selected}>
<div className="creation-library-card-media"><AssetMediaPreview asset={asset} alt={asset.title} fallback={<div className="creation-library-card-fallback">{isVideo ? <Film /> : <ImageIcon />}</div>} /><span className="creation-library-card-check"><Check /></span><span className="creation-library-card-kind">{isVideo ? "视频" : "图片"}</span>{disabled ? <span className="creation-library-card-lock">视频仅支持视频创作</span> : null}</div>
<div className="creation-library-card-title">{asset.title || "未命名素材"}</div>
</button>;
}
function CreationHistoryDrawer({ open, conversations, activeId, onClose, onSelect }: { open: boolean; conversations: CreationConversation[]; activeId: string; onClose: () => void; onSelect: (conversation: CreationConversation) => void }) {
const [keyword, setKeyword] = useState("");
useEffect(() => {
if (open) setKeyword("");
}, [open]);
const visibleConversations = useMemo(() => {
const query = keyword.trim().toLowerCase();
if (!query) return conversations;
return conversations.filter((conversation) => {
const latest = conversationPreviewMessage(conversation);
const searchable = [
conversation.title,
...conversation.messages.flatMap((message) => [message.content, displayCreationPrompt(message.content, message.references || [])]),
latest?.mode ? modeLabels[latest.mode] : "创作",
formatConversationTime(conversation.updatedAt),
].filter(Boolean).join(" ").toLowerCase();
return searchable.includes(query);
});
}, [conversations, keyword]);
return <Drawer open={open} onClose={onClose} placement="right" size="min(440px, 100vw)" closeIcon={<X className="size-4" />} className="creation-history-drawer" rootClassName="creation-history-drawer-root" styles={{ body: { padding: 0 } }} title={<div className="creation-history-title"><span>历史对话</span><small>{conversations.length} 个对话</small></div>}>
<ol className="creation-history-timeline" aria-label="历史对话,按更新时间倒序排列">
{conversations.map((conversation) => {
const latest = conversationPreviewMessage(conversation);
const active = conversation.id === activeId;
return <li key={conversation.id} className={active ? "is-active" : ""}>
<span className="creation-history-dot" aria-hidden="true" />
<button type="button" aria-current={active ? "page" : undefined} onClick={() => onSelect(conversation)}>
<span className="creation-history-time"><time dateTime={conversation.updatedAt}>{formatConversationTime(conversation.updatedAt)}</time><em>{latest?.mode ? modeLabels[latest.mode] : "创作"}</em></span>
<strong className="creation-history-item-heading">{conversation.title.trim() || "新创作"}</strong>
<span className="creation-history-snippet">{latest ? displayCreationPrompt(latest.content, latest.references || []).trim() || "还没有开始创作" : "还没有开始创作"}</span>
</button>
</li>;
})}
</ol>
<div className="creation-history-content">
<label className="creation-history-search">
<Search aria-hidden="true" />
<input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索对话标题或内容" aria-label="搜索历史对话" />
</label>
{visibleConversations.length ? <ul className="creation-history-list" aria-label="历史对话,按更新时间倒序排列">
{visibleConversations.map((conversation) => {
const latest = conversationPreviewMessage(conversation);
const active = conversation.id === activeId;
return <li key={conversation.id} className={active ? "is-active" : undefined}>
<button type="button" aria-current={active ? "page" : undefined} onClick={() => onSelect(conversation)}>
<span className="creation-history-time"><time dateTime={conversation.updatedAt}>{formatConversationTime(conversation.updatedAt)}</time><em>{latest?.mode ? modeLabels[latest.mode] : "创作"}</em></span>
<strong className="creation-history-item-heading">{conversation.title.trim() || "新创作"}</strong>
<span className="creation-history-snippet">{latest ? displayCreationPrompt(latest.content, latest.references || []).trim() || "还没有开始创作" : "还没有开始创作"}</span>
</button>
</li>;
})}
</ul> : <div className="creation-history-empty">{keyword.trim() ? "没有找到匹配的对话" : "暂无历史对话"}</div>}
</div>
</Drawer>;
}
@@ -540,10 +644,11 @@ function CreationMessageView({ item, modelName, onRetryFailure, onCreateVariant
function CreationUserMessage({ item }: { item: CreationMessage }) {
const [previewUrl, setPreviewUrl] = useState("");
const [previewType, setPreviewType] = useState<"image" | "video">("image");
return <div className="creation-user-message"><div>{displayCreationPrompt(item.content, item.references || [])}</div>{item.references?.length ? <CreationMessageReferences references={item.references} /> : null}{item.attachments?.length ? <div className="creation-user-message-attachments">{item.attachments.map((attachment) => {
const url = attachment.previewUrl || attachment.dataUrl || attachment.url || "";
return <button key={attachment.id} type="button" onClick={() => setPreviewUrl(url)} aria-label={`预览 ${attachment.name}`} disabled={!url}><img src={url} alt={attachment.name} width={44} height={44} loading="lazy" /><span aria-hidden="true"><Maximize2 /></span></button>;
})}</div> : null}<CreationMediaPreviewModal url={previewUrl} type="image" onClose={() => setPreviewUrl("")} /></div>;
const url = attachment.previewUrl || ("dataUrl" in attachment ? attachment.dataUrl : attachment.url) || "";
return <button key={attachment.id} type="button" onClick={() => { const video = isVideoAttachment(attachment); setPreviewType(video ? "video" : "image"); setPreviewUrl(video ? attachment.url : url); }} aria-label={`预览 ${attachment.name}`} disabled={!url}>{isVideoAttachment(attachment) ? <video src={attachment.url} poster={url !== attachment.url ? url : undefined} muted playsInline preload="metadata" /> : <img src={url} alt={attachment.name} width={44} height={44} loading="lazy" />}<span aria-hidden="true"><Maximize2 /></span></button>;
})}</div> : null}<CreationMediaPreviewModal url={previewUrl} type={previewType} onClose={() => setPreviewUrl("")} /></div>;
}
function CreationMessageReferences({ references }: { references: CreationReference[] }) {
@@ -577,6 +682,7 @@ type ComposerProps = {
attachments: CreationAttachment[];
references: CreationReference[];
onRemoveAttachment: (id: string) => void;
onOpenLibrary: () => void;
fileInputRef: RefObject<HTMLInputElement | null>;
onFileChange: (event: ChangeEvent<HTMLInputElement>) => void;
onModeChange: (mode: CreationMode) => void;
@@ -608,11 +714,11 @@ function CreationComposer(props: ComposerProps) {
const emptyPlaceholder = "输入你的镜头、画面或故事。也可以添加参考图开始创作";
return <section className={`creation-chat-composer is-${props.variant}`}>
<div className="creation-chat-writing-surface">
<input ref={props.fileInputRef} type="file" hidden accept="image/*" multiple onChange={props.onFileChange} />
<Tooltip title={props.mode === "video" && !props.videoProfile.operations.includes("image_to_video") ? "当前模型不支持图生视频" : "添加参考图片"}><button type="button" className="creation-chat-reference is-paper" onClick={() => props.fileInputRef.current?.click()} disabled={props.busy || (props.mode === "video" && !props.videoProfile.operations.includes("image_to_video"))} aria-label="添加参考图片"><Plus /><span>参考内容</span></button></Tooltip>
<input ref={props.fileInputRef} type="file" hidden accept={props.mode === "video" ? "image/*,video/*" : "image/*"} multiple onChange={props.onFileChange} />
<Tooltip title={props.mode === "video" && !props.videoProfile.operations.includes("image_to_video") ? "当前模型不支持参考媒体" : "从素材库选择参考内容"}><button type="button" className="creation-chat-reference is-paper" onClick={props.onOpenLibrary} disabled={props.busy || (props.mode === "video" && !props.videoProfile.operations.includes("image_to_video"))} aria-label="打开素材库选择参考内容"><Plus /><span>参考内容</span></button></Tooltip>
<div className="creation-chat-editor">
<CanvasResourceMentionTextarea value={props.prompt} references={props.references} maxLength={props.mode === "video" ? props.videoProfile.references.promptMaxChars : undefined} mentionMenuWidth={400} sendOnEnter={false} onChange={props.setPrompt} onSubmit={props.onSubmit} containerClassName="creation-chat-mention-container" className="creation-chat-mention-editor creation-scrollbar" style={{ color: "var(--creation-text)" }} placeholder={props.variant === "empty" ? emptyPlaceholder : placeholder} aria-label="创作提示词,可使用 @ 引用当前参考内容或技能" spellCheck disabled={props.busy} />
{props.attachments.length ? <div className="creation-chat-attachment-strip">{props.attachments.map((item) => <div key={item.id} className="creation-chat-attachment"><img src={item.previewUrl} alt={item.name} /><button type="button" onClick={() => props.onRemoveAttachment(item.id)} aria-label={`移除 ${item.name}`}><X /></button></div>)}</div> : null}
{props.attachments.length ? <div className="creation-chat-attachment-strip">{props.attachments.map((item) => <div key={item.id} className="creation-chat-attachment">{isVideoAttachment(item) ? <video src={item.url} poster={item.previewUrl !== item.url ? item.previewUrl : undefined} muted playsInline preload="metadata" aria-label={item.name} /> : <img src={item.previewUrl} alt={item.name} /> }<button type="button" onClick={() => props.onRemoveAttachment(item.id)} aria-label={`移除 ${item.name}`}><X /></button></div>)}</div> : null}
</div>
</div>
<footer className="creation-chat-dock">
@@ -674,9 +780,42 @@ function DurationMenu({ profile, seconds, onChange }: { profile: VideoCapability
</Popover>;
}
const creationEmptyArtLibrary = [
"black-white-noir.jpg", "chinese-2d.jpg", "clay-stop-motion.jpg", "comic-pop.jpg", "cyberpunk-neon.jpg", "fantasy-3d.jpg",
"future-tech.jpg", "ink-narrative.jpg", "nature-healing.jpg", "period-live-action.jpg", "real-life.jpg", "retro-hong-kong.jpg",
"space-opera.jpg", "storybook-fantasy.jpg", "surreal-dream.jpg", "suspense-noir.jpg", "three-d-cartoon.jpg", "urban-live-action.jpg",
].map((file) => `/short-drama-styles/${file}`);
function CreationEmptyArt() {
const frames = useMemo(() => shuffleCreationArt(creationEmptyArtLibrary), []);
const [activeIndex, setActiveIndex] = useState(0);
useEffect(() => {
const timer = window.setInterval(() => setActiveIndex((current) => (current + 1) % frames.length), 4200);
return () => window.clearInterval(timer);
}, [frames.length]);
const imageAt = (offset: number) => frames[(activeIndex + offset) % frames.length];
return <div className="creation-empty-art" aria-label="随机轮播的创作风格参考图">
<div className="creation-empty-art-frame is-back"><img key={imageAt(0)} src={imageAt(0)} alt="" /></div>
<div className="creation-empty-art-frame is-main"><img key={imageAt(1)} src={imageAt(1)} alt="" /><span>你的下一帧,从这里开始</span></div>
<div className="creation-empty-art-frame is-front"><img key={imageAt(2)} src={imageAt(2)} alt="" /></div>
<div className="creation-empty-art-caption"><span>{String(activeIndex + 1).padStart(2, "0")}</span><span>镜头 · 氛围 · 故事</span></div>
</div>;
}
function shuffleCreationArt(items: string[]) {
const shuffled = [...items];
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const target = Math.floor(Math.random() * (index + 1));
[shuffled[index], shuffled[target]] = [shuffled[target], shuffled[index]];
}
return shuffled;
}
function CreationIntro({ mode }: { mode: CreationMode }) {
const copy = mode === "video" ? ["让", "想象", ",先在镜头里发生", "影策 · AI 叙事创作"] : mode === "image" ? ["让", "画面", ",从一个想法开始", "影策 · 视觉创作"] : ["把", "故事", ",写在第一句话里", "影策 · 叙事创作"];
return <header className="creation-chat-intro" aria-live="polite"><span className="creation-intro-signal" aria-hidden="true" /><h1>{copy[0]}<span>{copy[1]}</span>{copy[2]}</h1><p>{copy[3]}</p></header>;
return <header className="creation-chat-intro" aria-live="polite"><span className="creation-intro-signal" aria-hidden="true" /><h1>{copy[0]}<span className="creation-intro-emphasis"><span className="is-pink">{copy[1].slice(0, 1)}</span><span className="is-blue">{copy[1].slice(1)}</span></span>{copy[2]}</h1><p>{copy[3]}</p></header>;
}
function videoResolutionLabel(value: string | number) {
@@ -696,11 +835,19 @@ function conversationPreviewMessage(conversation: CreationConversation) {
function buildTextMessageContent(item: CreationMessage) {
const content = expandCreationPrompt(item.content, item.references || [], item.attachments || []);
const images = item.attachments || [];
const images = (item.attachments || []).filter(isImageAttachment);
if (!images.length) return content;
return [{ type: "text" as const, text: content }, ...images.map((image) => ({ type: "image_url" as const, image_url: { url: image.dataUrl || image.url || "" } }))];
}
function isVideoAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { url: string } {
return attachment.type.startsWith("video/");
}
function isImageAttachment(attachment: CreationAttachment): attachment is CreationAttachment & { dataUrl: string } {
return !isVideoAttachment(attachment);
}
function removeReferenceTokens(value: string, references: CreationReference[]) {
return references.reduce((current, reference) => current.split(canvasResourceMentionToken(reference)).join(""), value);
}

View File

@@ -4,7 +4,7 @@ import { App, Button, Form, Input, Modal, Select } from "antd";
import { ArrowRight, BookOpenText, FolderKanban, Images, LayoutGrid, Plus, Search } from "lucide-react";
import { Link, useNavigate, useSearchParams } from "react-router";
import { ListToolbar, PageHeader, TableSurface, WorkspacePage } from "@/components/layout/workspace-page";
import { CollectionGrid, ListToolbar, PageHeader, WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceErrorState, WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
import { projectSummaryCompletion, projectSummaryStage } from "@/lib/project-workbench";
@@ -50,17 +50,18 @@ export default function ProjectsPage() {
return right.project.updatedAt.localeCompare(left.project.updatedAt);
});
}, [keyword, query.data, sort, status]);
const canCreateProject = !keyword.trim() && status === "all";
return (
<WorkspacePage>
<WorkspacePage className="library-page" grid>
<PageHeader
icon="projects"
title="短剧创作"
description="按制作阶段查看故事项目,继续最近工作或处理未完成章节。"
description="按时间浏览故事项目,继续最近的章节、画布与镜头。"
meta={<span className="text-xs text-foreground/45">{rows.length} 个</span>}
actions={<Button type="primary" icon={<Plus className="size-3.5" />} onClick={() => setCreateOpen(true)}>创建项目</Button>}
actions={null}
/>
<ListToolbar active={Boolean(keyword || status !== "all" || sort !== "updated")} onReset={() => { setKeyword(""); setStatus("all"); setSort("updated"); }}>
<ListToolbar className="library-toolbar" active={Boolean(keyword || status !== "all" || sort !== "updated")} onReset={() => { setKeyword(""); setStatus("all"); setSort("updated"); }}>
<Input allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索项目、简介或画风" onChange={(event) => setKeyword(event.target.value)} />
<Select className="w-32" value={status} onChange={setStatus} options={[{ label: "全部状态", value: "all" }, { label: "进行中", value: "active" }, { label: "已归档", value: "archived" }]} />
<Select className="w-32" value={sort} onChange={setSort} options={[{ label: "最近更新", value: "updated" }, { label: "章节进度", value: "progress" }, { label: "项目名称", value: "name" }]} />
@@ -68,17 +69,17 @@ export default function ProjectsPage() {
{query.isError ? <WorkspaceErrorState description={query.error instanceof Error ? query.error.message : "项目列表加载失败"} onRetry={() => void query.refetch()} /> : null}
{query.isLoading ? <WorkspaceLoadingState label="正在整理项目" detail="读取章节、画布与资产进度" /> : null}
{!query.isLoading && rows.length ? (
<TableSurface>
<div className="hidden h-10 grid-cols-[minmax(240px,1.2fr)_minmax(150px,.75fr)_minmax(150px,.7fr)_minmax(180px,.8fr)_120px_28px] items-center gap-4 border-b border-border/70 bg-foreground/[.025] px-4 text-[var(--fs-label)] font-medium text-foreground/42 lg:grid">
<span>项目</span><span>当前阶段</span><span>章节进度</span><span>内容</span><span>最近更新</span><span />
</div>
<div className="divide-y divide-border/65">
{rows.map((row) => <ProjectRow key={row.project.id} row={row} />)}
</div>
</TableSurface>
{!query.isLoading && !query.isError && (rows.length || canCreateProject) ? (
<CollectionGrid className="library-grid project-library-grid">
{canCreateProject ? <button type="button" className="library-create-card" onClick={() => setCreateOpen(true)}>
<span className="library-create-cover"><Plus className="size-8" /></span>
<span className="library-create-title">创建短剧项目</span>
<span className="library-create-meta">从故事、小说或空白开始</span>
</button> : null}
{rows.map((row) => <ProjectRow key={row.project.id} row={row} />)}
</CollectionGrid>
) : null}
{!query.isLoading && !rows.length && !query.isError ? (
{!query.isLoading && !rows.length && !query.isError && (keyword || status !== "all") ? (
<WorkspaceState
icon="projects"
title={keyword || status !== "all" ? "没有匹配的项目" : "创建第一个故事项目"}
@@ -87,7 +88,7 @@ export default function ProjectsPage() {
/>
) : null}
<Modal title="创建短剧项目" open={createOpen} footer={null} destroyOnHidden onCancel={() => setCreateOpen(false)} width={500} styles={{ body: { paddingTop: 12 } }}>
<Modal className="library-modal" title="创建短剧项目" open={createOpen} footer={null} destroyOnHidden onCancel={() => setCreateOpen(false)} width={560} styles={{ body: { paddingTop: 12 } }}>
<Form<ProjectForm> layout="vertical" initialValues={{ aspectRatio: "9:16", sourceType: "blank" }} onFinish={(values) => mutation.mutate({ ...values, type: "short-drama" })}>
<Form.Item name="name" label="项目名称" rules={[{ required: true, whitespace: true, message: "请输入项目名称" }]}><Input autoFocus placeholder="例如:长安夜行" /></Form.Item>
<div className="grid grid-cols-2 gap-3">
@@ -107,29 +108,14 @@ function ProjectRow({ row }: { row: ProjectSummary }) {
const stage = projectSummaryStage(row);
const styleTitle = resolveCanvasStylePreset(row.project.stylePresetId)?.title || (row.project.stylePresetId ? "自定义画风" : "未设置画风");
return (
<Link to={`/projects/${row.project.id}/overview`} className="group block min-w-0 px-3 py-3 transition-colors hover:bg-foreground/[.025] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-foreground/20 sm:px-4 lg:grid lg:min-h-[76px] lg:grid-cols-[minmax(240px,1.2fr)_minmax(150px,.75fr)_minmax(150px,.7fr)_minmax(180px,.8fr)_120px_28px] lg:items-center lg:gap-4 lg:py-2.5">
<span className="flex min-w-0 items-start gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-foreground/[.06] text-foreground/50"><FolderKanban className="size-4" /></span>
<span className="min-w-0"><span className="flex min-w-0 items-center gap-2"><strong className="truncate text-sm font-semibold">{row.project.name}</strong>{row.project.status === "archived" ? <span className="shrink-0 rounded bg-foreground/[.07] px-1.5 py-0.5 text-[var(--fs-tiny)] text-foreground/45">已归档</span> : null}</span><span className="mt-1 block truncate text-[var(--fs-label)] text-foreground/42">{styleTitle} · {row.project.aspectRatio} · {sourceTypeLabel(row.project.sourceType)}</span></span>
<Link to={`/projects/${row.project.id}/overview`} className="library-card project-library-card group">
<span className="project-library-cover"><span className="project-library-cover-icon"><FolderKanban className="size-7" /></span><span className="project-library-cover-ratio">{row.project.aspectRatio}</span><span className="project-library-cover-stage">{stage.label}</span></span>
<span className="project-library-body">
<span className="project-library-heading"><strong title={row.project.name}>{row.project.name}</strong>{row.project.status === "archived" ? <em>已归档</em> : null}<ArrowRight className="project-library-arrow size-4" /></span>
<span className="project-library-subtitle">{styleTitle} · {sourceTypeLabel(row.project.sourceType)}</span>
<span className="project-library-progress"><span><span>{row.completedUnitCount}/{row.unitCount} 章</span><span>{completion}%</span></span><i><b style={{ width: `${completion}%` }} /></i></span>
<span className="project-library-stats"><ProjectCount icon={<BookOpenText className="size-3.5" />} label="章节" value={row.unitCount} /><ProjectCount icon={<LayoutGrid className="size-3.5" />} label="画布" value={row.canvasCount} /><ProjectCount icon={<Images className="size-3.5" />} label="资产" value={row.assetCount} /></span>
</span>
<span className="mt-3 grid grid-cols-[72px_minmax(0,1fr)] items-start gap-2 lg:mt-0 lg:block">
<span className="text-[var(--fs-tiny)] text-foreground/38 lg:hidden">当前阶段</span>
<span className="min-w-0"><span className="block text-xs font-medium text-foreground/78">{stage.label}</span><span className="mt-1 line-clamp-1 block text-[var(--fs-tiny)] text-foreground/40">{stage.detail}</span></span>
</span>
<span className="mt-3 grid grid-cols-[72px_minmax(0,1fr)] items-center gap-2 lg:mt-0 lg:block">
<span className="text-[var(--fs-tiny)] text-foreground/38 lg:hidden">章节进度</span>
<span className="min-w-0"><span className="flex items-center justify-between text-[var(--fs-tiny)] text-foreground/48"><span>{row.completedUnitCount}/{row.unitCount} 章</span><span>{completion}%</span></span><span className="mt-1.5 block h-1.5 overflow-hidden rounded-full bg-foreground/[.08]"><span className="block h-full rounded-full bg-[var(--workspace-accent)] transition-[width]" style={{ width: `${completion}%` }} /></span></span>
</span>
<span className="mt-3 flex items-center gap-4 text-[var(--fs-label)] text-foreground/50 lg:mt-0">
<ProjectCount icon={<BookOpenText className="size-3.5" />} label="章节" value={row.unitCount} />
<ProjectCount icon={<LayoutGrid className="size-3.5" />} label="画布" value={row.canvasCount} />
<ProjectCount icon={<Images className="size-3.5" />} label="资产" value={row.assetCount} />
</span>
<span className="mt-3 block text-[var(--fs-label)] tabular-nums text-foreground/42 lg:mt-0">{formatProjectTime(row.project.updatedAt)}</span>
<ArrowRight className="hidden size-4 text-foreground/25 transition-transform group-hover:translate-x-0.5 group-hover:text-foreground/60 lg:block" />
</Link>
);
}
@@ -137,7 +123,3 @@ function ProjectRow({ row }: { row: ProjectSummary }) {
function ProjectCount({ icon, label, value }: { icon: ReactNode; label: string; value: number }) {
return <span className="inline-flex items-center gap-1.5" title={`${value} ${label}`}><span className="text-foreground/32">{icon}</span><strong className="font-medium tabular-nums text-foreground/65">{value}</strong><span className="hidden 2xl:inline">{label}</span></span>;
}
function formatProjectTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(new Date(value));
}

View File

@@ -162,8 +162,8 @@ export default function SkillsPage() {
return (
<>
<WorkspacePage>
<PageHeader icon="skills" title="技能库" meta={<span className="text-xs text-foreground/45">{total} 个技能</span>} actions={<Button type="primary" icon={<Plus className="size-4" />} onClick={() => void openEditor()}>创建技能</Button>} />
<WorkspacePage className="library-page skills-library-page" grid>
<PageHeader icon="skills" title="技能库" description="把常用的提示词、角色设定和创作方法收进自己的工具架。" meta={<span className="text-xs text-foreground/45">{total} 个技能</span>} actions={null} />
<div className="mt-1 flex flex-col border-b border-border/75 xl:flex-row xl:items-end xl:justify-between">
<nav className="thin-scrollbar -mb-px flex min-w-0 overflow-x-auto" aria-label="技能库范围" role="tablist">
@@ -186,7 +186,7 @@ export default function SkillsPage() {
})}
</nav>
<div className="flex min-w-0 flex-wrap items-center gap-2 py-2.5 xl:flex-nowrap xl:justify-end">
<div className="library-toolbar-controls flex min-w-0 flex-wrap items-center gap-2 py-2.5 xl:flex-nowrap xl:justify-end">
<Input className="w-full sm:!w-72" prefix={<Search className="size-4 text-foreground/38" />} value={search} allowClear placeholder="搜索技能或作者" onChange={(event) => { setSearch(event.target.value); setPage(1); }} />
<Select className="w-[136px]" value={tag} options={[{ value: "all", label: "全部分类" }, ...categories]} onChange={(value) => { setTag(value); setPage(1); }} />
<Select className="w-[124px]" value={sort} options={sortOptions} onChange={(value) => { setSort(value); setPage(1); }} />
@@ -206,7 +206,8 @@ export default function SkillsPage() {
<h2 id={`skill-category-${group.value}`} className="text-sm font-medium text-foreground/62">{group.label}</h2>
<span className="text-[var(--fs-label)] text-foreground/32">{group.skills.length} 个</span>
</div>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
<div className="library-grid skill-library-grid">
{groupedSkills[0] === group ? <button type="button" className="library-create-card" onClick={() => void openEditor()}><span className="library-create-cover"><Plus className="size-8" /></span><span className="library-create-title">创建技能</span><span className="library-create-meta">把一套方法变成可复用能力</span></button> : null}
{group.skills.map((skill) => <SkillCard key={skill.skill_id} skill={skill} loading={mutatingID === skill.skill_id} onOpen={() => void openSkill(skill)} onAdd={() => void toggleAdded(skill)} onLike={() => void toggleLiked(skill)} onEdit={() => void openEditor(skill)} onDelete={() => confirmDelete(skill)} />)}
</div>
</section>
@@ -238,7 +239,7 @@ export default function SkillsPage() {
function SkillCard({ skill, loading, onOpen, onAdd, onLike, onEdit, onDelete }: { skill: Skill; loading: boolean; onOpen: () => void; onAdd: () => void; onLike: () => void; onEdit: () => void; onDelete: () => void }) {
return (
<article className="flex h-[178px] min-w-0 flex-col rounded-md border border-border/55 bg-[color:var(--workspace-surface-strong)] p-4 transition-[border-color,box-shadow,background-color] duration-200 hover:border-border hover:bg-[color:var(--workspace-surface)] hover:shadow-sm">
<article className="library-card skill-library-card group">
<div className="flex min-h-8 items-start gap-3">
<button type="button" className="min-w-0 flex-1 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={onOpen}>
<h3 className="line-clamp-1 text-[var(--fs-body-lg)] font-semibold leading-6">{skill.skill_name}</h3>

View File

@@ -6,7 +6,7 @@ import type { Skill, SkillCategory } from "@/services/api/skills";
export function SkillDetailDrawer({ skill, loading, mutating, categories, onClose, onAdd, onLike, onEdit }: { skill: Skill | null; loading: boolean; mutating: boolean; categories: SkillCategory[]; onClose: () => void; onAdd: (skill: Skill) => void; onLike: (skill: Skill) => void; onEdit: (skill: Skill) => void }) {
return (
<Drawer open={Boolean(skill)} size={760} destroyOnHidden title={skill?.skill_name || "技能详情"} onClose={onClose} extra={skill?.is_owner ? <Button icon={<Pencil className="size-4" />} onClick={() => onEdit(skill)}>编辑</Button> : undefined}>
<Drawer className="library-drawer" open={Boolean(skill)} size={760} destroyOnHidden title={skill?.skill_name || "技能详情"} onClose={onClose} extra={skill?.is_owner ? <Button icon={<Pencil className="size-4" />} onClick={() => onEdit(skill)}>编辑</Button> : undefined}>
{skill ? (
<div className="space-y-6">
<header>

View File

@@ -61,7 +61,7 @@ export function SkillEditorDrawer({ open, skill, onClose, onSaved }: { open: boo
};
return (
<Drawer open={open} size={720} destroyOnHidden maskClosable={!dirty} title={skill ? "编辑技能" : "创建技能"} onClose={requestClose} extra={<Button type="primary" loading={saving} icon={<Save className="size-4" />} onClick={() => form.submit()}>保存技能</Button>}>
<Drawer className="library-drawer" open={open} size={720} destroyOnHidden maskClosable={!dirty} title={skill ? "编辑技能" : "创建技能"} onClose={requestClose} extra={<Button type="primary" loading={saving} icon={<Save className="size-4" />} onClick={() => form.submit()}>保存技能</Button>}>
<Form form={form} layout="vertical" requiredMark="optional" onFinish={submit} onValuesChange={() => setDirty(true)}>
<div className="grid gap-x-4 sm:grid-cols-2">
<Form.Item name="skill_name" label="技能名称" rules={[{ required: true, message: "请填写技能名称" }, { max: 80, message: "最多 80 个字符" }]}>

View File

@@ -1,10 +1,9 @@
import { App, Button, Drawer, Form, Input, Modal, Progress, Segmented, Select, Space, Table, Typography } from "antd";
import type { ColumnsType } from "antd/es/table";
import { Eye, FileText, FolderKanban, Image as ImageIcon, Play, Plus, RefreshCw, RotateCcw, Search, Video, X } from "lucide-react";
import { App, Button, Drawer, Form, Input, Modal, Segmented, Select, Tooltip, Typography } from "antd";
import { Coins, Eye, FileText, FolderKanban, Image as ImageIcon, Play, Plus, RefreshCw, RotateCcw, Search, Video, X } from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { ListToolbar, PageHeader, TableSurface, WorkspacePage } from "@/components/layout/workspace-page";
import { ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { CONTENT_MODERATION_ERROR_CODE, generationErrorMessage, isContentModerationError } from "@/lib/generation-error";
import { formatTaskKind, operationOptions, statusLabel } from "@/lib/generation-task-display";
@@ -18,8 +17,8 @@ import { useUserStore } from "@/stores/use-user-store";
import { formatCredits } from "@/constant/credits";
import { listProjects, type ProjectSummary } from "@/services/api/projects";
const taskTableClassName = "app-data-table";
type TaskStatusFilter = "all" | "failed" | "active" | "succeeded";
type TaskKindFilter = "all" | "text" | "image" | "video";
function taskStatusFilter(value: string | null): TaskStatusFilter {
return value === "failed" || value === "active" || value === "succeeded" ? value : "all";
@@ -48,6 +47,8 @@ export default function TasksPage() {
};
const [keyword, setKeyword] = useState("");
const [projectFilter, setProjectFilter] = useState("all");
const [kindFilter, setKindFilter] = useState<TaskKindFilter>("all");
const [modelFilter, setModelFilter] = useState("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [detailTask, setDetailTask] = useState<GenerationTask | null>(null);
@@ -64,6 +65,7 @@ export default function TasksPage() {
const projectName = project.projectId ? domainProjectNameById.get(project.projectId) : "";
return { label: projectName ? `${project.title || "未命名画布"} · ${projectName}` : project.title || "未命名画布", value: project.id };
}), [domainProjectNameById, projects]);
const modelOptions = useMemo(() => Array.from(new Set(tasks.map((task) => formatModelName(effectiveConfig, task)).filter(Boolean))).sort((left, right) => left.localeCompare(right, "zh-CN")), [effectiveConfig, tasks]);
const filteredTasks = useMemo(() => tasks.filter((task) => {
if (statusFilter === "all") return true;
if (statusFilter === "active") return task.status === "queued" || task.status === "running";
@@ -72,10 +74,13 @@ export default function TasksPage() {
return false;
}).filter((task) => {
if (projectFilter !== "all" && task.projectId !== projectFilter) return false;
if (kindFilter !== "all" && taskMediaKind(task) !== kindFilter) return false;
if (modelFilter !== "all" && formatModelName(effectiveConfig, task) !== modelFilter) return false;
const query = keyword.trim().toLowerCase();
const context = getTaskCanvasContext(task, canvasById, domainProjectNameById);
return !query || `${task.prompt} ${task.model || ""} ${formatTaskKind(task)} ${context.canvasName} ${context.projectName}`.toLowerCase().includes(query);
}), [canvasById, domainProjectNameById, keyword, projectFilter, statusFilter, tasks]);
}), [canvasById, domainProjectNameById, effectiveConfig, keyword, kindFilter, modelFilter, projectFilter, statusFilter, tasks]);
const visibleTasks = useMemo(() => filteredTasks.slice((page - 1) * pageSize, page * pageSize), [filteredTasks, page, pageSize]);
useEffect(() => {
if (!shortDramaEnabled) {
@@ -276,162 +281,45 @@ export default function TasksPage() {
}
};
const columns = useMemo<ColumnsType<GenerationTask>>(
() => [
{
title: "画布(项目)",
width: 190,
responsive: ["lg"],
render: (_, task) => {
const context = getTaskCanvasContext(task, canvasById, domainProjectNameById);
return (
<div className="min-w-0">
<div className="truncate text-xs font-medium text-foreground/82" title={context.canvasName}>{context.canvasName}</div>
<div className="mt-1 flex min-w-0 items-center gap-1 text-[var(--fs-tiny)] text-foreground/42" title={context.projectName || "未加入项目"}>
<FolderKanban className="size-3 shrink-0" />
<span className="truncate">{context.projectName || "未加入项目"}</span>
</div>
</div>
);
},
},
{
title: "类型|模型",
width: 180,
responsive: ["md"],
render: (_, task) => <div className="min-w-0"><div className="truncate text-xs font-medium text-foreground/78">{formatTaskKind(task)}</div><div className="mt-1 truncate text-[var(--fs-tiny)] text-foreground/42" title={formatModelName(effectiveConfig, task)}>{formatModelName(effectiveConfig, task)}</div></div>,
},
{
title: "任务名称",
dataIndex: "prompt",
width: 340,
render: (prompt, task) => {
const context = getTaskCanvasContext(task, canvasById, domainProjectNameById);
return <div className="flex min-w-0 items-start gap-3 md:items-center">
<TaskPreviewThumbnail task={task} onOpen={() => task.previewUrl && setMediaPreview({ url: task.previewUrl, kind: task.previewKind === "video" ? "video" : "image", title: prompt || formatTaskKind(task) })} />
<div className="min-w-0 flex-1">
<div title={prompt} className="line-clamp-2 max-w-full break-words text-xs font-medium leading-5 text-foreground/88">{prompt || "未命名任务"}</div>
<div className={`mt-1 hidden truncate text-[var(--fs-tiny)] md:block ${task.status === "failed" ? "text-red-600 dark:text-red-400" : task.status === "cancelled" ? "text-amber-600 dark:text-amber-400" : "text-foreground/38"}`} title={task.error ? generationErrorMessage(task.error) : undefined}>
{task.status === "failed" || task.status === "cancelled" ? taskAttentionReason(task) : task.stage || statusLabel[task.status]}
</div>
<div className="mt-2 space-y-2 md:hidden">
<div className="truncate text-[var(--fs-tiny)] text-foreground/45">{formatTaskKind(task)} · {formatModelName(effectiveConfig, task)}</div>
<div className="truncate text-[var(--fs-tiny)] text-foreground/38">{context.canvasName}{context.projectName ? ` · ${context.projectName}` : ""}</div>
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2 text-[var(--fs-label)]"><span className={`size-1.5 shrink-0 rounded-full ${statusDotClassName(task.status)}`} /><span>{statusLabel[task.status]}</span>{creditsEnabled ? <TaskBilling billing={task.billing} /> : null}</div>
<Space size={0}>
<Button type="text" size="small" aria-label="查看详情" icon={<Eye className="size-3.5" />} onClick={() => openTaskDetail(task)} />
{task.status === "failed" || task.status === "cancelled" ? <Button type="text" size="small" aria-label="重新生成" icon={<RotateCcw className="size-3.5" />} loading={actingId === task.id} disabled={task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)} onClick={() => runAction(task.id, "retry")} /> : null}
{task.status === "queued" || task.status === "running" ? <Button type="text" size="small" aria-label="取消任务" danger icon={<X className="size-3.5" />} loading={actingId === task.id} onClick={() => runAction(task.id, "cancel")} /> : null}
</Space>
</div>
</div>
</div>
</div>;
},
},
{
title: "状态",
width: 135,
responsive: ["md"],
render: (_, task) => (
<div className="min-w-0">
<div className="flex items-center gap-2 text-xs font-medium">
<span className={`size-1.5 shrink-0 rounded-full ${statusDotClassName(task.status)}`} />
<span>{statusLabel[task.status]}</span>
{task.status === "queued" || task.status === "running" ? <span className="ml-auto text-[var(--fs-tiny)] tabular-nums text-foreground/42">{task.progress || 0}%</span> : null}
</div>
{task.status === "queued" || task.status === "running" ? <Progress className="!mb-0 !mt-1.5 block" percent={task.progress || 0} showInfo={false} size={[90, 3]} strokeColor="var(--workspace-accent)" /> : null}
</div>
),
},
{
title: "重试",
dataIndex: "attempts",
width: 75,
align: "center",
responsive: ["lg"],
render: (attempts: number) => <span className="text-xs tabular-nums text-foreground/55">{Math.max(0, (attempts || 1) - 1)} 次</span>,
},
{
title: "创建时间",
dataIndex: "createdAt",
width: 145,
responsive: ["lg"],
render: (value) => <TaskDate value={value} />,
},
...(creditsEnabled ? [{
title: "消耗积分",
width: 130,
align: "right",
responsive: ["md"],
render: (_, task) => <TaskBilling billing={task.billing} />,
}] as ColumnsType<GenerationTask> : []),
{
title: "操作",
width: 168,
align: "right",
responsive: ["md"],
render: (_, task) => (
<Space size={2}>
<Button type="text" size="small" icon={<Eye className="size-3.5" />} onClick={() => openTaskDetail(task)}>详情</Button>
{task.status === "failed" || task.status === "cancelled" ? <Button type="text" size="small" aria-label="重新生成" title={task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error) ? "内容审核未通过,请修改提示词后新建任务" : "重新生成"} icon={<RotateCcw className="size-3.5" />} loading={actingId === task.id} disabled={task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)} onClick={() => runAction(task.id, "retry")}>重试</Button> : null}
{task.status === "queued" || task.status === "running" ? <Button type="text" size="small" aria-label="取消任务" danger icon={<X className="size-3.5" />} loading={actingId === task.id} onClick={() => runAction(task.id, "cancel")}>取消</Button> : null}
</Space>
),
},
],
[actingId, canvasById, creditsEnabled, domainProjectNameById, effectiveConfig, openTaskDetail],
);
return (
<>
<WorkspacePage grid>
<WorkspacePage grid className="library-page task-library-page">
<PageHeader
icon="tasks"
title="任务中心"
description="先处理失败任务,再跟踪运行进度和检查生成结果。"
meta={<span className="text-xs text-foreground/45">{filteredTasks.length} 个任务{loading ? " · 正在同步" : ""}</span>}
actions={(
<>
actions={(
<>
<Button icon={<RefreshCw className={`size-3.5 ${loading ? "animate-spin" : ""}`} />} onClick={() => void loadTasks(true)}>刷新</Button>
<Button type="primary" icon={<Plus className="size-3.5" />} onClick={() => setCreateOpen(true)}>新建任务</Button>
</>
)}
<Button className="library-primary-action" type="primary" icon={<Plus className="size-3.5" />} onClick={() => setCreateOpen(true)}>新建任务</Button>
</>
)}
/>
<ListToolbar active={Boolean(keyword || projectFilter !== "all")} onReset={() => { setKeyword(""); setProjectFilter("all"); setPage(1); }}>
<Input id="task-search" name="taskSearch" allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索任务、模型、画布或项目" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
<ListToolbar className="library-toolbar task-library-toolbar" active={Boolean(keyword || projectFilter !== "all" || kindFilter !== "all" || modelFilter !== "all" || statusFilter !== "all")} onReset={() => { setKeyword(""); setProjectFilter("all"); setKindFilter("all"); setModelFilter("all"); setStatusFilter("all"); setPage(1); }}>
<Input id="task-search" name="taskSearch" allowClear className="app-list-search" prefix={<Search className="size-4 text-foreground/40" />} value={keyword} placeholder="搜索任务、模型或画布" onChange={(event) => { setKeyword(event.target.value); setPage(1); }} />
<Select className="w-full sm:w-48" value={projectFilter} onChange={(value) => { setProjectFilter(value); setPage(1); }} options={[{ label: "全部画布", value: "all" }, ...projectOptions]} />
<Select className="w-full sm:w-32" value={kindFilter} onChange={(value) => { setKindFilter(value as TaskKindFilter); setPage(1); }} options={[{ label: "全部类型", value: "all" }, { label: "文本", value: "text" }, { label: "图片", value: "image" }, { label: "视频", value: "video" }]} />
<Select className="w-full sm:w-44" value={modelFilter} onChange={(value) => { setModelFilter(value); setPage(1); }} options={[{ label: "全部模型", value: "all" }, ...modelOptions.map((model) => ({ label: model, value: model }))]} />
<Segmented
size="small"
value={statusFilter}
onChange={(value) => { setStatusFilter(value as typeof statusFilter); setPage(1); }}
options={[
{ label: `全部 ${tasks.length}`, value: "all" },
{ label: `需要处理 ${tasks.filter((task) => task.status === "failed" || task.status === "cancelled").length}`, value: "failed" },
{ label: `运行中 ${tasks.filter((task) => task.status === "queued" || task.status === "running").length}`, value: "active" },
{ label: `已完成 ${tasks.filter((task) => task.status === "succeeded").length}`, value: "succeeded" },
{ label: "全部", value: "all" },
{ label: "待处理", value: "failed" },
{ label: "运行中", value: "active" },
{ label: "已完成", value: "succeeded" },
]}
/>
</ListToolbar>
<TableSurface className="task-table-surface">
<Table
rowKey="id"
size="middle"
className={taskTableClassName}
columns={columns}
dataSource={filteredTasks}
loading={loading}
rowClassName={() => "task-table-row align-middle"}
tableLayout="fixed"
pagination={{ current: page, pageSize, total: filteredTasks.length, showSizeChanger: true, pageSizeOptions: [20, 50, 100], showTotal: (total, range) => `${range[0]}-${range[1]} / 共 ${total} 个任务`, onChange: (nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); } }}
scroll={{ x: 1280 }}
locale={{ emptyText: <WorkspaceState compact title={taskEmptyState(statusFilter).title} description={taskEmptyState(statusFilter).description} /> }}
/>
</TableSurface>
{loading && !tasks.length ? <div className="library-loading-grid" aria-label="正在加载任务">{Array.from({ length: 8 }, (_, index) => <div key={index} className="library-skeleton" />)}</div> : null}
{!loading || tasks.length ? (
visibleTasks.length ? <div className="task-record-list">{visibleTasks.map((task) => <TaskListRow key={task.id} task={task} canvasById={canvasById} projectNameById={domainProjectNameById} effectiveConfig={effectiveConfig} creditsEnabled={creditsEnabled} actingId={actingId} onOpen={() => void openTaskDetail(task)} onRetry={() => void runAction(task.id, "retry")} onCancel={() => void runAction(task.id, "cancel")} onPreview={() => task.previewUrl && setMediaPreview({ url: task.previewUrl, kind: task.previewKind === "video" ? "video" : "image", title: task.prompt || formatTaskKind(task) })} />)}</div> : <WorkspaceState compact title={taskEmptyState(statusFilter).title} description={taskEmptyState(statusFilter).description} />
) : null}
<PaginationBar current={page} pageSize={pageSize} total={filteredTasks.length} pageSizeOptions={[20, 50, 100]} onChange={(nextPage, nextPageSize) => { setPage(nextPageSize !== pageSize ? 1 : nextPage); setPageSize(nextPageSize); }} />
</WorkspacePage>
<Modal title="新建异步生成任务" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={submitTask} confirmLoading={creating} okText="创建任务">
<Modal className="library-modal" title="新建异步生成任务" open={createOpen} onCancel={() => setCreateOpen(false)} onOk={submitTask} confirmLoading={creating} okText="创建任务">
<Form form={form} layout="vertical" initialValues={{ operation: "agent_session" }}>
<Form.Item name="operation" label="任务类型" rules={[{ required: true, message: "请选择任务类型" }]}>
<Select options={operationOptions} />
@@ -447,7 +335,7 @@ export default function TasksPage() {
</Form.Item>
</Form>
</Modal>
<Drawer title="任务详情" open={Boolean(detailTask)} onClose={() => setDetailTask(null)} size="large" destroyOnHidden>
<Drawer className="library-drawer" title="任务详情" open={Boolean(detailTask)} onClose={() => setDetailTask(null)} size="large" destroyOnHidden>
{detailTask ? (
<div className="space-y-5">
<div className="grid border-y border-border text-sm sm:grid-cols-2">
@@ -482,7 +370,7 @@ export default function TasksPage() {
centered
width="min(1040px, calc(100vw - 32px))"
destroyOnHidden
className="task-media-preview-modal"
className="library-modal task-media-preview-modal"
>
{mediaPreview?.kind === "video"
? <video src={mediaPreview.url} className="max-h-[76vh] w-full bg-black object-contain" controls playsInline preload="metadata" />
@@ -524,6 +412,44 @@ function TaskResultMedia({ value, taskType }: { value?: string; taskType: string
);
}
function TaskListRow({ task, canvasById, projectNameById, effectiveConfig, creditsEnabled, actingId, onOpen, onRetry, onCancel, onPreview }: {
task: GenerationTask;
canvasById: Map<string, { title: string; projectId?: string }>;
projectNameById: Map<string, string>;
effectiveConfig: AiConfig;
creditsEnabled: boolean;
actingId: string;
onOpen: () => void;
onRetry: () => void;
onCancel: () => void;
onPreview: () => void;
}) {
const context = getTaskCanvasContext(task, canvasById, projectNameById);
const isActive = task.status === "queued" || task.status === "running";
const isFailed = task.status === "failed" || task.status === "cancelled";
return (
<article className={`task-record-row group${isFailed ? " is-attention" : ""}`}>
<TaskPreviewThumbnail task={task} onOpen={onPreview} />
<div className="task-record-main">
<div className="task-record-heading">
<button type="button" className="task-record-title" title={task.prompt} onClick={onOpen}>{task.prompt || "未命名任务"}</button>
<span className={`task-record-status ${isFailed ? "is-failed" : isActive ? "is-active" : "is-success"}`}><i className={`size-1.5 rounded-full ${statusDotClassName(task.status)}`} />{statusLabel[task.status]}</span>
</div>
<div className="task-record-meta"><span>{formatTaskKind(task)}</span><span aria-hidden="true">·</span><span>{formatModelName(effectiveConfig, task)}</span><span aria-hidden="true">·</span><FolderKanban className="size-3" /><span className="truncate">{context.canvasName}{context.projectName ? ` · ${context.projectName}` : ""}</span></div>
{isActive ? <div className="task-record-progress"><span>{task.stage || "正在生成"}</span><span>{task.progress || 0}%</span><i><b style={{ width: `${task.progress || 0}%` }} /></i></div> : null}
{isFailed ? <p className="task-record-error" title={task.error ? generationErrorMessage(task.error) : undefined}>{taskAttentionReason(task)}</p> : null}
</div>
<div className="task-record-date"><TaskDate value={task.createdAt} /></div>
{creditsEnabled ? <TaskBilling billing={task.billing} /> : <span className="task-record-billing-empty" aria-hidden="true" />}
<div className="task-record-actions">
<Tooltip title="查看详情"><Button type="text" size="small" icon={<Eye className="size-3.5" />} aria-label="查看详情" onClick={onOpen} /></Tooltip>
{isFailed ? <Tooltip title="重试任务"><Button type="text" size="small" icon={<RotateCcw className="size-3.5" />} aria-label="重试任务" loading={actingId === task.id} disabled={task.errorCode === CONTENT_MODERATION_ERROR_CODE || isContentModerationError(task.error)} onClick={onRetry} /></Tooltip> : null}
{isActive ? <Tooltip title="取消任务"><Button type="text" size="small" danger icon={<X className="size-3.5" />} aria-label="取消任务" loading={actingId === task.id} onClick={onCancel} /></Tooltip> : null}
</div>
</article>
);
}
function resultMediaUrls(value?: string) {
if (!value) return [];
let parsed: unknown;
@@ -612,18 +538,26 @@ function statusDotClassName(status: TaskStatus) {
return "bg-foreground/30";
}
function taskMediaKind(task: GenerationTask): Exclude<TaskKindFilter, "all"> {
const value = `${task.type} ${task.operation || ""}`.toLowerCase();
if (value.includes("video") || value.includes("视频")) return "video";
if (value.includes("image") || value.includes("图片") || value.includes("画面")) return "image";
return "text";
}
function TaskDate({ value }: { value?: string }) {
if (!value) return <span className="text-xs text-foreground/38">-</span>;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return <span className="text-xs text-foreground/38">-</span>;
return <div className="text-xs tabular-nums text-foreground/62"><div>{date.toLocaleDateString()}</div><div className="mt-1 text-[var(--fs-tiny)] text-foreground/38">{date.toLocaleTimeString()}</div></div>;
const compact = `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()} ${date.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" })}`;
return <time className="task-record-date-value" dateTime={date.toISOString()} title={date.toLocaleString()}>{compact}</time>;
}
function TaskBilling({ billing }: { billing?: GenerationTask["billing"] }) {
if (!billing) return <span className="text-xs text-foreground/30">-</span>;
if (!billing) return <span className="task-record-billing-empty text-xs text-foreground/30">-</span>;
const amount = formatCredits(billing.amountMicrocredits);
const note = billing.status === "settled" ? "已结算" : billing.status === "refunded" ? "已退回" : billing.status === "uncertain" ? "待核对" : "预计";
return <div className="text-right"><div className="text-xs font-medium tabular-nums text-foreground/78">{amount}</div><div className={`mt-1 text-[var(--fs-tiny)] ${billing.status === "uncertain" ? "text-amber-600 dark:text-amber-300" : "text-foreground/38"}`}>{note}</div></div>;
return <div className={`task-record-billing ${billing.status === "uncertain" ? "is-uncertain" : ""}`} title={`积分${note}`}><Coins className="size-4" /><span><strong>{amount}</strong><small>{note}</small></span></div>;
}
function formatModelName(config: AiConfig, task: GenerationTask) {

View File

@@ -118,7 +118,7 @@ export default function WalletPage() {
];
return (
<main className="app-user-content app-workspace-scroll relative h-full overflow-y-auto text-foreground">
<main className="app-user-content app-workspace-scroll library-page wallet-library-page relative h-full overflow-y-auto text-foreground">
<div className="relative mx-auto max-w-[1440px] px-4 py-6 sm:px-6 lg:px-8">
<motion.header initial={reducedMotion ? false : { opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="app-page-header flex flex-wrap items-start justify-between gap-4 pb-6">
<div className="flex items-center gap-3">
@@ -138,7 +138,7 @@ export default function WalletPage() {
</div>
</motion.header>
<section className="mt-6 grid gap-4 lg:grid-cols-[minmax(0,1.35fr)_minmax(340px,0.65fr)]">
<section className="library-feature-grid mt-6 grid gap-4 lg:grid-cols-[minmax(0,1.35fr)_minmax(340px,0.65fr)]">
<CometCard rotateDepth={2.2} translateDepth={2} glare={!reducedMotion} className="credit-balance-card overflow-hidden rounded-lg border">
<div className="flex min-h-[210px] flex-col justify-between p-5 sm:p-6">
<div className="flex items-start justify-between gap-4">
@@ -162,7 +162,7 @@ export default function WalletPage() {
</div>
</CometCard>
<motion.div initial={reducedMotion ? false : { opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="app-workspace-surface flex flex-col rounded-lg border p-5 backdrop-blur-xl sm:p-6">
<motion.div initial={reducedMotion ? false : { opacity: 0, x: 12 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: aceternityMotion.duration.panel, ease: aceternityMotion.easing.enter }} className="wallet-redeem-panel app-workspace-surface flex flex-col rounded-lg border p-5 backdrop-blur-xl sm:p-6">
<div className="flex items-start gap-3">
<span className="grid size-9 shrink-0 place-items-center rounded-xl border border-amber-400/25 bg-amber-400/10 text-amber-600 dark:text-amber-300">
<TicketCheck className="size-4" />
@@ -186,7 +186,7 @@ export default function WalletPage() {
</motion.div>
</section>
<section className="app-workspace-surface mt-9 rounded-lg border p-4 backdrop-blur-xl sm:p-5">
<section className="wallet-ledger-panel app-workspace-surface mt-9 rounded-lg border p-4 backdrop-blur-xl sm:p-5">
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<h2 className="text-base font-semibold">积分流水</h2>

View File

@@ -2694,7 +2694,7 @@
.creation-chat-attachment-strip { display: flex; gap: 7px; padding-top: 6px; overflow-x: auto; }
.creation-chat-attachment { position: relative; width: 42px; height: 42px; flex: 0 0 auto; overflow: hidden; border: 1px solid var(--creation-border); border-radius: 8px; }
.creation-chat-attachment img { width: 100%; height: 100%; object-fit: cover; }
.creation-chat-attachment img, .creation-chat-attachment video { width: 100%; height: 100%; object-fit: cover; }
.creation-chat-attachment button { position: absolute; top: 2px; right: 2px; display: grid; width: 16px; height: 16px; place-items: center; border-radius: 50%; background: rgba(0, 0, 0, .7); color: white; opacity: 0; transition: opacity 140ms ease; }
.creation-chat-attachment:hover button, .creation-chat-attachment button:focus-visible { opacity: 1; }
.creation-chat-attachment button svg { width: 10px; height: 10px; }
@@ -2837,7 +2837,7 @@
.creation-user-message { width: fit-content; max-width: min(70%, 520px); margin: 0 0 34px auto; border-radius: 16px 16px 5px 16px; background: rgba(255, 255, 255, .075); padding: 10px 14px; font-size: 14px; line-height: 1.65; }
html:not(.dark) .creation-user-message { background: rgba(20, 26, 35, .065); }
.creation-user-message-attachments { display: flex; gap: 6px; margin-top: 8px; }
.creation-user-message-attachments img { width: 44px; height: 44px; border-radius: 7px; object-fit: cover; }
.creation-user-message-attachments img, .creation-user-message-attachments video { width: 44px; height: 44px; border-radius: 7px; object-fit: cover; }
.creation-assistant-message { max-width: 760px; margin: 0 0 42px; }
.creation-message-heading { display: flex; min-width: 0; flex-wrap: wrap; align-items: center; gap: 8px; color: var(--creation-muted); font-size: 12px; }
@@ -3171,128 +3171,280 @@
box-shadow: none;
}
.canvas-library-search {
max-width: 680px;
}
/* 画布库采用电影档案的浏览节奏:搜索轻、画面重、信息退后。 */
.canvas-library-page .app-page-header {
border-bottom: 0;
padding-bottom: 8px;
padding-bottom: 4px;
}
.canvas-library-toolbar {
min-height: 56px;
border: 0;
border-radius: 8px;
background: color-mix(in srgb, var(--workspace-surface-strong) 66%, transparent);
padding: 8px;
.canvas-library-discovery {
display: grid;
min-height: 58px;
grid-template-columns: minmax(240px, 1fr) auto auto;
align-items: center;
gap: 20px;
margin-top: 10px;
padding: 10px 0 14px;
}
.canvas-library-grid {
grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr));
align-items: start;
gap: 20px 16px;
}
.canvas-library-page .app-pagination-bar {
position: static;
margin-top: var(--space-4);
border-color: var(--workspace-border);
background: transparent;
padding: var(--space-1-half) var(--space-2);
backdrop-filter: none;
}
/* 画布文件是单个可点击对象,缩略图与元信息共用一个容器,避免视觉割裂。 */
.app-canvas-project-card {
.canvas-library-search {
display: flex;
min-width: 0;
color: var(--foreground);
height: 38px;
align-items: center;
gap: 10px;
border-radius: var(--r-lg);
background: color-mix(in srgb, var(--foreground) 5%, transparent);
padding: 0 14px;
color: color-mix(in srgb, var(--foreground) 48%, transparent);
transition: background-color 220ms ease-out, color 220ms ease-out, box-shadow 220ms ease-out;
}
.app-canvas-project-card.is-library {
border-color: var(--workspace-border);
background: color-mix(in srgb, var(--workspace-surface-strong) 76%, transparent);
content-visibility: auto;
contain-intrinsic-size: 0 260px;
transition:
border-color var(--motion-instant) ease-out,
background-color var(--motion-instant) ease-out,
box-shadow var(--motion-instant) ease-out,
transform var(--motion-instant) ease-out;
.canvas-library-search:focus-within {
background: color-mix(in srgb, var(--foreground) 9%, transparent);
color: var(--workspace-accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--workspace-accent) 10%, transparent);
}
.app-canvas-project-card.is-library .app-canvas-project-preview {
overflow: hidden;
border-bottom: 1px solid var(--workspace-border);
background: var(--workspace-surface-strong);
.canvas-library-search > svg { width: 17px; height: 17px; flex: 0 0 auto; }
.canvas-library-search input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--foreground); font-size: var(--fs-label); }
.canvas-library-search input::placeholder { color: color-mix(in srgb, var(--foreground) 42%, transparent); }
.canvas-library-search > button { display: grid; width: 26px; height: 26px; flex: 0 0 auto; place-items: center; border: 0; border-radius: 50%; background: transparent; color: color-mix(in srgb, var(--foreground) 46%, transparent); }
.canvas-library-search > button:hover { background: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); }
.canvas-library-search > button svg { width: 14px; height: 14px; }
.canvas-library-filters { display: flex; align-items: center; gap: 4px; }
.canvas-library-filter { display: inline-flex; min-height: 34px; align-items: center; gap: 8px; border: 1px solid transparent; border-radius: var(--r-sm); background: transparent; padding: 0 9px; color: color-mix(in srgb, var(--foreground) 58%, transparent); font-size: var(--fs-label); white-space: nowrap; transition: background-color var(--motion-instant) ease-out, color var(--motion-instant) ease-out; }
.canvas-library-filter > svg { width: 15px; height: 15px; color: color-mix(in srgb, var(--foreground) 44%, transparent); }
.canvas-library-filter:hover, .canvas-library-filter.is-active { background: color-mix(in srgb, var(--foreground) 7%, transparent); color: var(--foreground); }
.canvas-library-filter.is-active > svg { color: var(--workspace-accent); }
.canvas-library-reset { min-height: 30px; border: 0; background: transparent; padding: 0 8px; color: color-mix(in srgb, var(--foreground) 42%, transparent); font-size: var(--fs-micro); }
.canvas-library-reset:hover { color: var(--foreground); }
.canvas-library-count { display: flex; align-items: baseline; gap: 6px; justify-self: end; color: color-mix(in srgb, var(--foreground) 38%, transparent); font-size: var(--fs-micro); white-space: nowrap; }
.canvas-library-count strong { color: var(--foreground); font-size: var(--fs-heading-lg); font-variant-numeric: tabular-nums; font-weight: 500; letter-spacing: .02em; }
.canvas-library-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); align-items: start; gap: 28px 18px; }
.canvas-library-page .app-pagination-bar { position: static; margin-top: var(--space-4); border-color: var(--workspace-border); background: transparent; padding: var(--space-1-half) var(--space-2); backdrop-filter: none; }
.app-canvas-project-card { min-width: 0; cursor: pointer; color: var(--foreground); transition: transform 420ms var(--motion-ease-out); }
.app-canvas-create-card { display: flex; min-width: 0; cursor: pointer; flex-direction: column; align-items: stretch; border: 0; background: transparent; padding: 0; color: var(--foreground); text-align: left; transition: transform 420ms var(--motion-ease-out); }
.app-canvas-create-card:hover:not(:disabled), .app-canvas-create-card:focus-visible { transform: translateY(-7px); }
.app-canvas-create-card:focus-visible { outline: 0; }
.app-canvas-create-preview { display: grid; aspect-ratio: 16 / 9; place-items: center; overflow: hidden; border-radius: var(--r-2xl); background: color-mix(in srgb, var(--workspace-surface-strong) 66%, var(--background)); transition: background-color 320ms ease-out, box-shadow 420ms var(--motion-ease-out); }
.app-canvas-create-card:hover:not(:disabled) .app-canvas-create-preview, .app-canvas-create-card:focus-visible .app-canvas-create-preview { background: color-mix(in srgb, var(--workspace-accent) 12%, var(--workspace-surface-strong)); box-shadow: 0 18px 36px color-mix(in srgb, var(--foreground) 12%, transparent); }
.app-canvas-create-icon { width: 38px; height: 38px; color: color-mix(in srgb, var(--foreground) 36%, transparent); stroke-width: 1.2; transition: color 260ms ease-out, transform 420ms var(--motion-ease-out); }
.app-canvas-create-card:hover:not(:disabled) .app-canvas-create-icon { color: var(--workspace-accent); transform: scale(1.12) rotate(90deg); }
.app-canvas-create-title { margin-top: 14px; font-size: var(--fs-heading-lg); font-weight: 620; line-height: 1.25; }
.app-canvas-create-meta { margin-top: 6px; color: color-mix(in srgb, var(--foreground) 38%, transparent); font-size: var(--fs-caption); }
.app-canvas-create-card:disabled { cursor: not-allowed; opacity: .5; }
.app-canvas-project-card.is-library { overflow: hidden; border-radius: var(--r-2xl); background: transparent; content-visibility: auto; contain-intrinsic-size: 0 320px; }
.app-canvas-project-card.is-library .app-canvas-project-preview { overflow: hidden; border-radius: var(--r-2xl); background: var(--workspace-surface-strong); }
.app-canvas-project-card.is-library:hover, .app-canvas-project-card.is-library:focus-within { transform: translateY(-7px); }
.app-canvas-project-card.is-library.is-selected { box-shadow: 0 0 0 3px color-mix(in srgb, var(--workspace-accent) 22%, transparent); border-radius: var(--r-2xl); }
.app-canvas-project-card.is-recent .app-canvas-project-preview { overflow: hidden; border-radius: var(--r-2xl); background: var(--workspace-surface-strong); transition: box-shadow 420ms var(--motion-ease-out), transform 420ms var(--motion-ease-out); }
.app-canvas-project-card.is-recent:hover .app-canvas-project-preview, .app-canvas-project-card.is-recent:focus-within .app-canvas-project-preview { box-shadow: 0 18px 36px color-mix(in srgb, var(--foreground) 13%, transparent); transform: translateY(-4px); }
.canvas-project-preview-button { position: relative; aspect-ratio: 16 / 9; }
.app-canvas-project-card.is-recent .canvas-project-preview-button { aspect-ratio: 16 / 10; }
.canvas-project-media img { filter: saturate(.88) contrast(1.02); transition: transform 500ms var(--motion-ease-out), filter 300ms ease-out; }
.app-canvas-project-card:hover .canvas-project-media img { filter: saturate(1) contrast(1.04); transform: scale(1.025); }
.canvas-project-preview-canvas { transition: transform 520ms var(--motion-ease-out); }
.app-canvas-project-card:hover .canvas-project-preview-canvas { transform: scale(1.025); }
.canvas-project-preview-button::after { display: none; position: absolute; inset: 0; background: linear-gradient(180deg, transparent 54%, rgba(5, 6, 8, .6)); content: ""; pointer-events: none; }
.canvas-project-preview-button:has(.canvas-project-media)::after { display: block; }
.canvas-project-video { display: grid; place-items: center; background: color-mix(in srgb, var(--workspace-surface-strong) 74%, var(--workspace-accent) 26%); color: color-mix(in srgb, var(--workspace-accent) 72%, var(--foreground)); }
.canvas-project-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 5px; background: color-mix(in srgb, var(--workspace-surface-strong) 76%, var(--background)); color: color-mix(in srgb, var(--foreground) 58%, transparent); }
.canvas-project-empty-icon { width: 32px; height: 32px; margin-bottom: 4px; color: color-mix(in srgb, var(--foreground) 38%, transparent); stroke-width: 1.4; }
.canvas-project-empty span { font-size: var(--fs-label); }
.canvas-project-empty small { color: color-mix(in srgb, var(--foreground) 34%, transparent); font-size: var(--fs-micro); }
.canvas-project-cover-meta { position: absolute; z-index: 1; right: 14px; bottom: 13px; left: 14px; display: flex; align-items: end; justify-content: flex-end; color: rgba(255, 255, 255, .82); pointer-events: none; }
.canvas-project-node-count { border-radius: var(--r-full); background: rgba(5, 6, 8, .52); padding: 5px 9px; font-size: var(--fs-micro); backdrop-filter: blur(8px); }
.canvas-project-select { position: absolute; z-index: 2; top: 12px; left: 12px; display: grid; width: 24px; height: 24px; place-items: center; border: 1px solid rgba(255, 255, 255, .42); border-radius: 4px; background: rgba(5, 6, 8, .42); opacity: 0; transition: opacity var(--motion-instant) ease-out, background-color var(--motion-instant) ease-out; }
.canvas-project-select.is-visible, .app-canvas-project-card:hover .canvas-project-select, .app-canvas-project-card:focus-within .canvas-project-select { opacity: 1; }
.canvas-project-checkbox { width: 14px; height: 14px; margin: 0; accent-color: var(--workspace-accent); }
.app-canvas-project-body { min-height: 90px; border-radius: 0 0 var(--r-2xl) var(--r-2xl); padding: 14px 10px 8px; transition: background-color 320ms ease-out; }
.app-canvas-project-body.is-compact { min-height: 0; padding: 11px 1px 1px; }
.app-canvas-project-card:hover .app-canvas-project-body { background: color-mix(in srgb, var(--foreground) 4%, transparent); }
.canvas-project-heading-row { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 10px; }
.canvas-project-title-button { min-width: 0; flex: 1; cursor: pointer; border: 0; background: transparent; padding: 0; text-align: left; }
.canvas-project-title-button h2 { overflow: hidden; color: var(--foreground); font-size: var(--fs-heading); font-weight: 620; line-height: 1.3; text-overflow: ellipsis; white-space: nowrap; }
.canvas-project-title-input { min-width: 0; flex: 1; }
.canvas-project-actions { display: flex; flex: 0 0 auto; align-items: center; gap: 2px; opacity: 0; transform: translateY(4px); transition: opacity 220ms ease-out, transform 320ms var(--motion-ease-out); }
.app-canvas-project-card:hover .canvas-project-actions, .app-canvas-project-card:focus-within .canvas-project-actions, .canvas-project-actions:has(:focus-visible) { opacity: 1; transform: translateY(0); }
.canvas-project-actions button { display: grid; width: 28px; height: 28px; place-items: center; border: 0; border-radius: 4px; background: transparent; color: color-mix(in srgb, var(--foreground) 42%, transparent); }
.canvas-project-actions button:hover { background: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); }
.canvas-project-stats { display: flex; align-items: center; gap: 7px; margin-top: 6px; color: color-mix(in srgb, var(--foreground) 42%, transparent); font-size: var(--fs-caption); }
.canvas-project-stats span:nth-child(2) { color: color-mix(in srgb, var(--foreground) 24%, transparent); }
.canvas-project-stats time { color: color-mix(in srgb, var(--foreground) 34%, transparent); }
.canvas-project-preview-canvas { background: color-mix(in srgb, var(--workspace-surface-strong) 78%, var(--background)); }
.canvas-project-preview-node { border: 0; border-radius: var(--r-md); background: color-mix(in srgb, var(--workspace-surface-strong) 88%, var(--foreground) 12%); padding: 6px 9px; color: color-mix(in srgb, var(--foreground) 78%, transparent); box-shadow: 0 8px 18px color-mix(in srgb, var(--foreground) 10%, transparent); backdrop-filter: blur(8px); }
.canvas-project-preview-node-icon { display: grid; width: 15px; height: 15px; flex: 0 0 auto; place-items: center; color: color-mix(in srgb, var(--workspace-accent) 72%, var(--foreground)); }
.canvas-project-preview-node-icon svg { width: 13px; height: 13px; }
.canvas-project-preview-node-label { min-width: 0; overflow: hidden; font-size: var(--fs-micro); font-weight: 560; text-overflow: ellipsis; white-space: nowrap; }
.app-canvas-selection-toolbar { border-color: color-mix(in srgb, var(--workspace-accent) 25%, var(--workspace-border)); background: color-mix(in srgb, var(--workspace-accent) 7%, transparent); color: color-mix(in srgb, var(--workspace-accent) 48%, var(--foreground)); }
/* 作品库式页面:与画布列表共享浏览节奏,内容优先于边框和工具栏。 */
.library-page .app-page-header { border-bottom: 0; padding-bottom: 4px; }
.library-page .app-page-header h1 { font-size: var(--fs-heading-lg); font-weight: 620; }
.library-page .app-page-header p { max-width: 560px; }
.library-primary-action { border: 0 !important; border-radius: var(--r-md) !important; background: var(--foreground) !important; color: var(--background) !important; box-shadow: none !important; font-weight: 620; }
.library-toolbar { min-height: 50px; margin-top: 8px; border-bottom: 0; padding: 7px 0 10px; }
.library-toolbar .ant-input-affix-wrapper,
.library-toolbar .ant-select-selector,
.library-toolbar .ant-segmented { border: 0 !important; background: color-mix(in srgb, var(--foreground) 5%, transparent) !important; box-shadow: none !important; }
.library-toolbar .ant-input-affix-wrapper { border-radius: var(--r-lg); }
.library-toolbar .ant-select-selector { border-radius: var(--r-md) !important; }
.library-toolbar .ant-segmented { border-radius: var(--r-md); }
.library-toolbar .ant-btn { border: 0; background: transparent; color: color-mix(in srgb, var(--foreground) 52%, transparent); }
.library-toolbar .ant-btn:hover { background: color-mix(in srgb, var(--foreground) 7%, transparent); color: var(--foreground); }
.library-toolbar-controls .ant-input-affix-wrapper,
.library-toolbar-controls .ant-select-selector { border: 0 !important; background: color-mix(in srgb, var(--foreground) 5%, transparent) !important; box-shadow: none !important; }
.library-toolbar-controls .ant-input-affix-wrapper { border-radius: var(--r-lg); }
.library-toolbar-controls .ant-select-selector { border-radius: var(--r-md) !important; }
.library-grid { display: grid; align-items: start; gap: 26px 18px; }
.library-card { position: relative; min-width: 0; overflow: hidden; cursor: pointer; border: 0; border-radius: var(--r-2xl); background: transparent; color: var(--foreground); transition: transform 420ms var(--motion-ease-out), background-color 260ms ease-out, box-shadow 420ms var(--motion-ease-out); }
.library-card:hover, .library-card:focus-within { transform: translateY(-7px); }
.library-create-card { display: flex; min-width: 0; cursor: pointer; flex-direction: column; align-items: stretch; border: 0; background: transparent; padding: 0; color: var(--foreground); text-align: left; transition: transform 420ms var(--motion-ease-out); }
.library-create-card:hover, .library-create-card:focus-visible { transform: translateY(-7px); outline: 0; }
.library-create-cover { display: grid; aspect-ratio: 16 / 10; place-items: center; overflow: hidden; border-radius: var(--r-2xl); background: color-mix(in srgb, var(--workspace-surface-strong) 72%, var(--background)); color: color-mix(in srgb, var(--foreground) 35%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 5%, transparent); transition: background-color 320ms ease-out, color 260ms ease-out, box-shadow 420ms var(--motion-ease-out); }
.library-create-card:hover .library-create-cover, .library-create-card:focus-visible .library-create-cover { background: color-mix(in srgb, var(--workspace-accent) 13%, var(--workspace-surface-strong)); color: var(--workspace-accent); box-shadow: 0 18px 36px color-mix(in srgb, var(--foreground) 12%, transparent); }
.library-create-cover svg { transition: transform 420ms var(--motion-ease-out); stroke-width: 1.2; }
.library-create-card:hover .library-create-cover svg, .library-create-card:focus-visible .library-create-cover svg { transform: scale(1.12) rotate(90deg); }
.library-create-title { margin-top: 13px; font-size: var(--fs-heading); font-weight: 620; line-height: 1.3; }
.library-create-meta { margin-top: 5px; color: color-mix(in srgb, var(--foreground) 38%, transparent); font-size: var(--fs-caption); }
.project-library-grid, .assets-library-grid, .skill-library-grid { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.project-library-cover { position: relative; display: flex; aspect-ratio: 16 / 10; align-items: center; justify-content: center; overflow: hidden; border-radius: var(--r-2xl); background: color-mix(in srgb, var(--workspace-surface-strong) 94%, var(--workspace-accent) 6%); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 6%, transparent); transition: background-color 320ms ease-out, box-shadow 420ms var(--motion-ease-out); }
.project-library-cover::before { position: absolute; inset: 18px; border: 1px solid color-mix(in srgb, var(--foreground) 5%, transparent); border-radius: var(--r-xl); content: ""; transition: border-color 260ms ease-out, transform 420ms var(--motion-ease-out); }
.library-card:hover .project-library-cover { background: color-mix(in srgb, var(--workspace-surface-strong) 89%, var(--workspace-accent) 11%); box-shadow: 0 18px 36px color-mix(in srgb, var(--foreground) 11%, transparent), inset 0 0 0 1px color-mix(in srgb, var(--workspace-accent) 16%, transparent); }
.library-card:hover .project-library-cover::before { border-color: color-mix(in srgb, var(--workspace-accent) 20%, transparent); transform: scale(.98); }
.project-library-cover-icon { position: relative; z-index: 1; display: grid; place-items: center; width: 48px; height: 48px; border-radius: var(--r-lg); background: color-mix(in srgb, var(--background) 58%, transparent); color: color-mix(in srgb, var(--workspace-accent) 66%, var(--foreground)); box-shadow: 0 12px 24px color-mix(in srgb, var(--foreground) 10%, transparent); }
.project-library-cover-ratio, .project-library-cover-stage { position: absolute; z-index: 1; bottom: 12px; border-radius: var(--r-full); background: color-mix(in srgb, var(--background) 58%, transparent); padding: 5px 8px; color: color-mix(in srgb, var(--foreground) 74%, transparent); font-size: var(--fs-micro); backdrop-filter: blur(8px); }
.project-library-cover-ratio { left: 12px; }
.project-library-cover-stage { right: 12px; }
.project-library-body { display: block; padding: 13px 2px 1px; }
.project-library-heading { display: flex; min-width: 0; align-items: center; gap: 7px; }
.project-library-heading strong { min-width: 0; overflow: hidden; font-size: var(--fs-heading); font-weight: 620; text-overflow: ellipsis; white-space: nowrap; }
.project-library-heading em { flex: 0 0 auto; border-radius: var(--r-xs); background: color-mix(in srgb, var(--foreground) 7%, transparent); padding: 3px 5px; color: color-mix(in srgb, var(--foreground) 44%, transparent); font-size: var(--fs-micro); font-style: normal; }
.project-library-arrow { margin-left: auto; flex: 0 0 auto; color: color-mix(in srgb, var(--foreground) 25%, transparent); opacity: 0; transform: translateX(-4px); transition: opacity 220ms ease-out, transform 320ms var(--motion-ease-out); }
.library-card:hover .project-library-arrow, .library-card:focus-within .project-library-arrow { opacity: 1; transform: translateX(0); }
.project-library-subtitle { display: block; margin-top: 5px; overflow: hidden; color: color-mix(in srgb, var(--foreground) 42%, transparent); font-size: var(--fs-caption); text-overflow: ellipsis; white-space: nowrap; }
.project-library-progress { display: block; margin-top: 11px; color: color-mix(in srgb, var(--foreground) 45%, transparent); font-size: var(--fs-tiny); }
.project-library-progress > span { display: flex; justify-content: space-between; gap: 8px; }
.project-library-progress i { display: block; height: 4px; margin-top: 6px; overflow: hidden; border-radius: var(--r-full); background: color-mix(in srgb, var(--foreground) 9%, transparent); }
.project-library-progress b { display: block; height: 100%; border-radius: inherit; background: var(--workspace-accent); transition: width 420ms var(--motion-ease-out); }
.project-library-stats { display: flex; align-items: center; gap: 10px; margin-top: 10px; color: color-mix(in srgb, var(--foreground) 40%, transparent); font-size: var(--fs-tiny); }
.task-record-list { display: grid; gap: 6px; margin-top: 12px; }
.task-record-row { display: grid; min-width: 0; grid-template-columns: 76px minmax(0, 1fr) 116px 96px 112px; align-items: center; gap: 14px; min-height: 78px; border-radius: var(--r-xl); background: transparent; padding: 9px 12px; transition: background-color 260ms ease-out, transform 320ms var(--motion-ease-out), box-shadow 320ms ease-out; }
.task-record-row:hover, .task-record-row:focus-within { background: color-mix(in srgb, var(--foreground) 5%, transparent); box-shadow: 0 12px 28px color-mix(in srgb, var(--foreground) 7%, transparent); transform: translateY(-2px); }
.task-record-row > .group { width: 76px; height: 54px; }
.task-record-row > span:not(.task-record-billing-empty) { width: 76px; height: 54px; border: 0; border-radius: var(--r-md); }
.task-record-row > .group img, .task-record-row > .group video { height: 54px; width: 76px; object-fit: cover; }
.task-record-row > .group > span:first-of-type { border-radius: var(--r-md); }
.task-record-main { min-width: 0; }
.task-record-heading { display: flex; min-width: 0; align-items: center; gap: 9px; overflow: hidden; }
.task-record-title { display: block; min-width: 0; flex: 1 1 auto; overflow: hidden; border: 0; background: transparent; padding: 0; color: var(--foreground); text-align: left; font-size: var(--fs-body); font-weight: 620; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.task-record-status { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 5px; color: color-mix(in srgb, var(--foreground) 48%, transparent); font-size: var(--fs-tiny); }
.task-record-status.is-failed { color: color-mix(in srgb, var(--palette-status-error) 78%, var(--foreground)); }
.task-record-status.is-active { color: color-mix(in srgb, var(--palette-status-warning) 78%, var(--foreground)); }
.task-record-meta { display: flex; min-width: 0; align-items: center; gap: 6px; margin-top: 5px; overflow: hidden; color: color-mix(in srgb, var(--foreground) 42%, transparent); font-size: var(--fs-tiny); white-space: nowrap; }
.task-record-progress { display: grid; grid-template-columns: minmax(80px, 1fr) 42px; align-items: center; gap: 10px; margin-top: 7px; color: color-mix(in srgb, var(--foreground) 45%, transparent); font-size: var(--fs-tiny); }
.task-record-progress span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.task-record-progress span:nth-child(2) { text-align: right; }
.task-record-progress i { grid-column: 1 / -1; display: block; height: 3px; margin-top: -4px; overflow: hidden; border-radius: var(--r-full); background: color-mix(in srgb, var(--foreground) 9%, transparent); }
.task-record-progress b { display: block; height: 100%; border-radius: inherit; background: var(--workspace-accent); transition: width 420ms var(--motion-ease-out); }
.task-record-error { display: -webkit-box; margin: 6px 0 0; overflow: hidden; color: color-mix(in srgb, var(--palette-status-error) 76%, var(--foreground)); font-size: var(--fs-tiny); line-height: 1.45; -webkit-box-orient: vertical; -webkit-line-clamp: 1; }
.task-record-date { min-width: 0; color: color-mix(in srgb, var(--foreground) 42%, transparent); }
.task-record-date-value { display: block; overflow: hidden; font-size: var(--fs-tiny); font-variant-numeric: tabular-nums; text-overflow: ellipsis; white-space: nowrap; }
.task-record-billing, .task-record-billing-empty { display: inline-flex; min-width: 0; align-items: center; justify-content: flex-end; gap: 7px; }
.task-record-billing { color: var(--palette-status-warning); }
.task-record-billing > svg { filter: drop-shadow(0 0 8px color-mix(in srgb, var(--palette-status-warning) 36%, transparent)); }
.task-record-billing > span { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; }
.task-record-billing strong { color: color-mix(in srgb, var(--palette-status-warning) 84%, var(--foreground)); font-size: var(--fs-caption); font-weight: 620; }
.task-record-billing small { color: color-mix(in srgb, var(--palette-status-warning) 58%, var(--foreground)); font-size: var(--fs-micro); }
.task-record-billing.is-uncertain { color: color-mix(in srgb, var(--palette-status-warning) 84%, var(--foreground)); }
.task-record-billing.is-uncertain strong, .task-record-billing.is-uncertain small { color: inherit; }
.task-record-actions { display: flex; align-items: center; justify-content: flex-end; gap: 2px; opacity: 0; transform: translateX(4px); transition: opacity 220ms ease-out, transform 320ms var(--motion-ease-out); }
.task-record-actions .ant-btn { width: 30px; height: 30px; padding: 0; border-radius: var(--r-md); }
.task-record-row:hover .task-record-actions, .task-record-row:focus-within .task-record-actions { opacity: 1; transform: translateX(0); }
.asset-library-card { overflow: hidden; }
.asset-library-card > div:first-child { border-radius: var(--r-2xl); background: var(--workspace-surface-strong); }
.asset-library-card:hover { background: color-mix(in srgb, var(--foreground) 4%, transparent); }
.asset-library-card.is-selected { box-shadow: 0 0 0 3px color-mix(in srgb, var(--workspace-accent) 24%, transparent); }
.asset-library-card > button:last-child { padding-inline: 2px; }
.asset-library-card h2 { font-size: var(--fs-heading); font-weight: 620; }
.skill-library-card { display: flex; min-height: 178px; flex-direction: column; padding: 15px 2px 4px; }
.skill-library-card::before { display: block; width: 42px; height: 4px; margin-bottom: 12px; border-radius: var(--r-full); background: color-mix(in srgb, var(--workspace-accent) 70%, transparent); content: ""; transition: width 320ms var(--motion-ease-out); }
.skill-library-card:hover::before { width: 64px; }
.skill-library-card .border-t { border-color: color-mix(in srgb, var(--foreground) 10%, transparent); }
.skill-library-card h3 { font-size: var(--fs-heading); }
.wallet-library-page .app-page-header { padding-bottom: 0; }
.wallet-library-page > div { max-width: 1520px; }
.wallet-library-page .app-workspace-surface { border: 0; background: color-mix(in srgb, var(--workspace-surface-strong) 76%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 6%, transparent); }
.wallet-library-page .credit-balance-card { border: 0; box-shadow: 0 24px 60px color-mix(in srgb, var(--foreground) 14%, transparent), inset 0 1px 0 rgba(255, 228, 145, 0.16); }
.wallet-library-page .wallet-ledger-panel { margin-top: 28px; }
.wallet-library-page .wallet-ledger-panel .app-table-surface { box-shadow: none; }
.library-loading-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 26px 18px; margin-top: 20px; }
.library-skeleton { aspect-ratio: 16 / 10; border-radius: var(--r-2xl); background: color-mix(in srgb, var(--foreground) 6%, transparent); animation: library-pulse 1.6s ease-in-out infinite; }
.task-library-page .library-loading-grid { grid-template-columns: 1fr; gap: 6px; }
.task-library-page .library-loading-grid .library-skeleton { height: 82px; aspect-ratio: auto; border-radius: var(--r-xl); }
@keyframes library-pulse { 0%, 100% { opacity: .5; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .library-skeleton { animation: none; } }
@media (max-width: 760px) {
.canvas-library-discovery { grid-template-columns: 1fr auto; gap: 10px 14px; }
.canvas-library-search { grid-column: 1 / -1; }
.canvas-library-count { grid-column: 2; grid-row: 2; }
.canvas-library-filters { grid-column: 1; grid-row: 2; }
}
.app-canvas-project-card.is-library:hover,
.app-canvas-project-card.is-library:focus-within {
border-color: color-mix(in srgb, var(--foreground) 24%, var(--workspace-border));
background: color-mix(in srgb, var(--workspace-surface-strong) 90%, transparent);
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.07);
transform: translateY(-1px);
@media (max-width: 1600px) and (min-width: 1101px) {
.project-library-grid, .assets-library-grid, .skill-library-grid, .library-loading-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}
@media (max-width: 1100px) and (min-width: 900px) {
.project-library-grid, .assets-library-grid, .skill-library-grid, .library-loading-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (max-width: 899px) and (min-width: 640px) {
.project-library-grid, .assets-library-grid, .skill-library-grid, .library-loading-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 639px) {
.project-library-grid, .assets-library-grid, .skill-library-grid, .library-loading-grid { grid-template-columns: 1fr; gap: 22px; }
.library-create-title, .project-library-heading strong, .skill-library-card h3 { font-size: var(--fs-body); }
.task-record-row { grid-template-columns: 60px minmax(0, 1fr) 64px 96px; gap: 10px; padding: 9px 8px; }
.task-record-row > .group, .task-record-row > span:not(.task-record-billing-empty) { width: 60px; height: 44px; }
.task-record-date { display: none; }
.task-record-billing { min-width: 64px; }
.task-record-actions { opacity: 1; transform: none; }
}
.app-canvas-project-card.is-library.is-selected {
border-color: var(--workspace-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--workspace-accent) 22%, transparent);
@media (max-width: 1600px) and (min-width: 1101px) {
.canvas-library-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 30px 20px; }
}
.app-canvas-project-card.is-library .app-canvas-project-body {
min-height: 82px;
@media (max-width: 1100px) and (min-width: 900px) {
.canvas-library-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 28px 18px; }
}
.app-canvas-project-card.is-recent .app-canvas-project-preview {
overflow: hidden;
border: 1px solid var(--workspace-border);
border-radius: 8px;
background: var(--workspace-surface-strong);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
transition:
border-color var(--motion-instant) ease-out,
box-shadow var(--motion-instant) ease-out;
}
.app-canvas-project-card.is-recent:hover .app-canvas-project-preview,
.app-canvas-project-card.is-recent:focus-within .app-canvas-project-preview {
border-color: color-mix(in srgb, var(--foreground) 24%, var(--workspace-border));
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
}
.app-canvas-project-checkbox {
accent-color: var(--workspace-accent);
}
.app-canvas-selection-toolbar {
border-color: color-mix(in srgb, var(--workspace-accent) 24%, var(--workspace-border));
background: var(--workspace-accent-soft);
color: color-mix(in srgb, var(--workspace-accent) 48%, var(--foreground));
}
.dark .app-canvas-project-card.is-library:hover,
.dark .app-canvas-project-card.is-library:focus-within {
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.32);
}
.dark .app-canvas-project-card.is-recent .app-canvas-project-preview {
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.28);
}
.dark .app-canvas-project-card.is-recent:hover .app-canvas-project-preview,
.dark .app-canvas-project-card.is-recent:focus-within .app-canvas-project-preview {
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.34);
@media (max-width: 899px) and (min-width: 640px) {
.canvas-library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px 16px; }
}
@media (max-width: 639px) {
.canvas-library-search {
max-width: none;
}
.canvas-library-grid {
grid-template-columns: 1fr;
}
.canvas-library-grid { grid-template-columns: 1fr; gap: 18px; }
.canvas-library-discovery { padding-inline: 0; }
.canvas-library-filters { min-width: 0; }
.canvas-library-filter { max-width: 150px; padding-inline: 6px; }
.canvas-library-filter span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.canvas-project-actions { opacity: 1; transform: none; }
.canvas-project-select { opacity: 1; }
}
.app-data-table .ant-table-pagination {
@@ -5753,15 +5905,16 @@ html:not(.dark) .creation-home .creation-chat-control:hover,
html:not(.dark) .creation-home .creation-chat-control[aria-expanded="true"],
html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.canvas-composer-model-picker:hover { background: rgba(28, 32, 37, .06) !important; }
.creation-chat-intro h1 span {
.creation-chat-intro h1 > .creation-intro-emphasis {
display: inline-block;
margin: 0 4px;
color: #83d6ec;
font-size: 1.25em;
font-weight: 760;
text-shadow: 0 0 24px rgba(89, 202, 232, .24);
animation: creation-intro-emphasis 2.6s var(--motion-ease-out) infinite;
}
.creation-intro-emphasis > span { display: inline; }
.creation-intro-emphasis .is-pink { color: var(--palette-node-character); text-shadow: 0 0 24px color-mix(in srgb, var(--palette-node-character) 24%, transparent); }
.creation-intro-emphasis .is-blue { color: #83d6ec; text-shadow: 0 0 24px rgba(89, 202, 232, .24); }
.creation-thread-stage,
.creation-thread-composer { width: min(1068px, calc(100% - 56px)); }
@@ -5783,7 +5936,7 @@ html:not(.dark) .creation-home .creation-chat-composer .creation-model-picker.ca
.creation-user-message-references { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 8px; }
.creation-user-message-reference { display: inline-flex; min-width: 0; max-width: 180px; height: 25px; align-items: center; gap: 5px; border-radius: 6px; background: rgba(255, 255, 255, .075); padding: 0 7px; color: var(--creation-muted); font-size: 10px; line-height: 1; }
.creation-user-message-reference img { width: 17px; height: 17px; flex: 0 0 auto; border-radius: 4px; object-fit: cover; }
.creation-user-message-reference img, .creation-user-message-reference video { width: 17px; height: 17px; flex: 0 0 auto; border-radius: 4px; object-fit: cover; }
.creation-user-message-reference svg { width: 12px; height: 12px; flex: 0 0 auto; }
.creation-user-message-reference span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
html:not(.dark) .creation-user-message-reference { background: rgba(28, 32, 37, .07); }
@@ -6219,7 +6372,7 @@ html:not(.dark) .creation-choice-grid button.is-selected,
html:not(.dark) .creation-duration-choices button.is-selected { background: rgba(28, 32, 37, .09) !important; }
html:not(.dark) .creation-custom-value { background: rgba(28, 32, 37, .055) !important; }
/* 历史对话使用右侧时间轴,避免重新引入占据主舞台的常驻会话栏。 */
/* 历史对话是按更新时间倒序排列的普通记录列表,不使用时间轴视觉。 */
.creation-top-actions {
position: absolute;
z-index: 30;
@@ -6277,56 +6430,45 @@ html:not(.dark) .creation-history-drawer-root {
.creation-history-drawer.ant-drawer-content,
.creation-history-drawer .ant-drawer-content,
.creation-history-drawer-root .ant-drawer-content { background: var(--creation-history-bg); color: var(--creation-history-text); }
.creation-history-drawer .ant-drawer-header { min-height: 58px; border-bottom: 1px solid var(--creation-history-line); padding: 10px 14px 10px 18px; }
.creation-history-drawer .ant-drawer-header { min-height: 68px; border-bottom: 1px solid var(--creation-history-line); padding: 16px 18px 14px 20px; }
.creation-history-drawer .ant-drawer-title { color: var(--creation-history-text); }
.creation-history-drawer .ant-drawer-close { display: grid; width: 36px; height: 36px; place-items: center; margin-inline-end: 8px; border-radius: 8px; color: var(--creation-history-muted); }
.creation-history-drawer .ant-drawer-close { display: grid; width: 32px; height: 32px; place-items: center; margin-inline-end: 10px; border-radius: var(--r-sm); color: var(--creation-history-muted); }
.creation-history-drawer .ant-drawer-close:hover { background: var(--creation-history-hover); color: var(--creation-history-text); }
.creation-history-drawer .ant-drawer-close:focus-visible { outline: 2px solid color-mix(in srgb, var(--creation-history-accent) 66%, transparent); outline-offset: 1px; }
.creation-history-drawer .ant-drawer-body { overscroll-behavior: contain; }
.creation-history-title { display: flex; min-width: 0; flex-direction: column; gap: 2px; }
.creation-history-title > span { font-size: 14px; font-weight: 650; letter-spacing: 0; }
.creation-history-title small { color: var(--creation-history-muted); font-size: 10px; font-weight: 500; }
.creation-history-title { display: flex; min-width: 0; align-items: baseline; gap: 9px; }
.creation-history-title > span { font-size: var(--fs-title); font-weight: 680; letter-spacing: 0; }
.creation-history-title small { color: var(--creation-history-muted); font-size: var(--fs-label); font-weight: 500; }
.creation-history-timeline { margin: 0; padding: 8px 12px 18px 18px; list-style: none; }
.creation-history-timeline > li { position: relative; min-width: 0; padding-left: 14px; content-visibility: auto; contain-intrinsic-size: 0 68px; }
.creation-history-timeline > li::before { position: absolute; top: 0; bottom: 0; left: 2px; width: 1px; background: var(--creation-history-line); content: ""; }
.creation-history-timeline > li:first-child::before { top: 15px; }
.creation-history-timeline > li:last-child::before { bottom: calc(100% - 16px); }
.creation-history-dot { position: absolute; z-index: 1; top: 13px; left: 0; width: 5px; height: 5px; border: 1px solid var(--creation-history-bg); border-radius: 50%; background: var(--creation-history-faint); box-sizing: content-box; }
.creation-history-timeline > li.is-active .creation-history-dot { background: var(--creation-history-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--creation-history-accent) 12%, transparent); }
.creation-history-content { min-height: 100%; padding: 14px 14px 20px; }
.creation-history-search { display: flex; height: 38px; align-items: center; gap: 9px; margin: 0 2px 12px; border: 1px solid var(--creation-history-line); border-radius: var(--r-sm); background: var(--creation-history-surface); padding: 0 11px; color: var(--creation-history-muted); transition: border-color 150ms ease, background-color 150ms ease; }
.creation-history-search:focus-within { border-color: color-mix(in srgb, var(--creation-history-accent) 58%, var(--creation-history-line)); background: var(--creation-history-hover); }
.creation-history-search svg { width: 15px; height: 15px; flex: 0 0 auto; }
.creation-history-search input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--creation-history-text); font: inherit; font-size: var(--fs-label); }
.creation-history-search input::placeholder { color: var(--creation-history-muted); }
.creation-history-timeline button {
display: flex;
width: 100%;
min-height: 64px;
flex-direction: column;
align-items: flex-start;
justify-content: center;
gap: 2px;
border: 0;
border-radius: 7px;
background: transparent;
padding: 7px 9px;
color: var(--creation-history-text);
text-align: left;
transition: background-color 150ms ease;
}
.creation-history-timeline button:hover { background: var(--creation-history-hover); }
.creation-history-timeline button:focus-visible { outline: 2px solid color-mix(in srgb, var(--creation-history-accent) 68%, transparent); outline-offset: -2px; }
.creation-history-timeline > li.is-active button { background: color-mix(in srgb, var(--creation-history-accent) 5%, var(--creation-history-surface)); }
.creation-history-time { display: flex; width: 100%; align-items: center; justify-content: space-between; color: var(--creation-history-muted); font-size: 9px; font-variant-numeric: tabular-nums; line-height: 1.3; }
.creation-history-time em { color: var(--creation-history-faint); font-size: 9px; font-style: normal; }
.creation-history-item-heading { width: 100%; min-width: 0; overflow: hidden; font-size: 12px; font-weight: 630; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.creation-history-snippet { display: block; width: 100%; overflow: hidden; color: var(--creation-history-muted); font-size: 10px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.creation-history-list { margin: 0; padding: 0; list-style: none; }
.creation-history-list > li { min-width: 0; border-bottom: 1px solid var(--creation-history-line); }
.creation-history-list > li:last-child { border-bottom: 0; }
.creation-history-list button { display: flex; width: 100%; min-height: 78px; flex-direction: column; align-items: flex-start; justify-content: center; gap: 5px; border: 1px solid transparent; border-radius: var(--r-sm); background: transparent; padding: 12px 10px 13px; color: var(--creation-history-text); text-align: left; transition: background-color 150ms ease, border-color 150ms ease; }
.creation-history-list button:hover { background: var(--creation-history-hover); }
.creation-history-list button:focus-visible { outline: 2px solid color-mix(in srgb, var(--creation-history-accent) 68%, transparent); outline-offset: -2px; }
.creation-history-list > li.is-active button { border-color: color-mix(in srgb, var(--creation-history-accent) 28%, transparent); background: color-mix(in srgb, var(--creation-history-accent) 7%, var(--creation-history-surface)); }
.creation-history-time { display: flex; width: 100%; align-items: center; justify-content: space-between; color: var(--creation-history-muted); font-size: var(--fs-micro); font-variant-numeric: tabular-nums; line-height: 1.3; }
.creation-history-time em { border: 1px solid var(--creation-history-line); border-radius: var(--r-xs); padding: 2px 5px; color: var(--creation-history-muted); font-size: var(--fs-micro); font-style: normal; }
.creation-history-item-heading { width: 100%; min-width: 0; overflow: hidden; font-size: var(--fs-label); font-weight: 650; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.creation-history-snippet { display: block; width: 100%; overflow: hidden; color: color-mix(in srgb, var(--creation-history-muted) 86%, transparent); font-size: var(--fs-micro); line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; }
.creation-history-empty { display: grid; min-height: 180px; place-items: center; color: var(--creation-history-muted); font-size: var(--fs-label); }
@media (max-width: 640px) {
.creation-top-actions { top: 14px; right: 14px; }
.creation-top-action { width: 40px; height: 40px; }
.creation-history-drawer { width: 100vw !important; max-width: 100vw; }
.creation-history-drawer-root .ant-drawer-content-wrapper { width: 100vw !important; max-width: 100vw; }
.creation-history-timeline { padding-right: 12px; padding-left: 18px; }
.creation-history-drawer .ant-drawer-header { min-height: 62px; padding: 14px 14px 12px 16px; }
.creation-history-title > span { font-size: var(--fs-heading-lg); }
.creation-history-content { padding: 12px 12px 18px; }
}
/* -------------------------------------------------------------------------
@@ -6400,9 +6542,215 @@ body.app-spatial-overlays :where(.ant-modal-mask, .ant-drawer-mask) {
backdrop-filter: blur(3px) !important;
}
/* 鼠标点击不留下“选中框”;键盘导航仍通过 :focus-visible 保留可发现的焦点提示。 */
:where(button, [role="button"], a, .ant-btn):focus:not(:focus-visible) {
outline: none !important;
box-shadow: none !important;
}
:where([class*="card"], [class*="Card"]):focus:not(:focus-visible),
:where([class*="card"], [class*="Card"]):has(:focus:not(:focus-visible)) {
outline: none !important;
box-shadow: none !important;
}
.app-workspace-shell.is-spatial .app-workspace-sidebar {
background: var(--workspace-navigation) !important;
background-image: none !important;
box-shadow: none !important;
backdrop-filter: none !important;
}
/* 空态把视觉锚点放在输入框上方,保留足够留白让首个动作自然下沉。 */
.creation-empty-workspace {
justify-content: flex-start;
padding-top: clamp(28px, 7vh, 84px);
padding-bottom: clamp(28px, 6vh, 72px);
}
.creation-empty-art {
position: relative;
width: min(440px, 78vw);
height: 172px;
margin: 0 auto 18px;
isolation: isolate;
}
.creation-empty-art-frame {
position: absolute;
overflow: hidden;
border: 1px solid color-mix(in srgb, var(--creation-text) 12%, transparent);
border-radius: var(--r-md);
background: var(--creation-surface);
box-shadow: var(--shadow-lg);
}
.creation-empty-art-frame img { display: block; width: 100%; height: 100%; object-fit: cover; filter: saturate(.86) contrast(.96); }
.creation-empty-art-frame.is-back { top: 15px; left: 3%; width: 29%; height: 122px; transform: rotate(-8deg); opacity: .42; }
.creation-empty-art-frame.is-main { z-index: 2; top: 0; left: 25%; width: 50%; height: 156px; transform: rotate(1deg); }
.creation-empty-art-frame.is-front { top: 22px; right: 3%; width: 29%; height: 122px; transform: rotate(7deg); opacity: .48; }
.creation-empty-art-frame.is-main::after { position: absolute; inset: 0; background: linear-gradient(180deg, transparent 38%, color-mix(in srgb, #111 44%, transparent)); content: ""; }
.creation-empty-art-frame span { position: absolute; z-index: 1; right: 13px; bottom: 12px; color: #fff; font-size: var(--fs-micro); letter-spacing: .08em; text-shadow: 0 1px 8px rgba(0, 0, 0, .45); }
.creation-empty-art-caption { position: absolute; right: 25%; bottom: -3px; display: flex; align-items: center; gap: 8px; color: var(--creation-faint); font-size: var(--fs-micro); letter-spacing: .12em; }
.creation-empty-art-caption span:first-child { color: var(--creation-accent, #83d6ec); font-variant-numeric: tabular-nums; }
.creation-empty-workspace .creation-chat-intro { margin-bottom: 24px; }
/* AntD 当前版本的实际内容外壳是 .ant-modal-container;只覆盖 .ant-modal-content 会留下默认内边距。 */
.creation-asset-library-modal { width: min(980px, calc(100vw - 24px)) !important; max-width: calc(100vw - 24px) !important; margin: 0 auto !important; }
.creation-asset-library-modal .ant-modal-container { overflow: hidden; border: 1px solid color-mix(in srgb, var(--border) 70%, transparent); border-radius: var(--r-lg); background: var(--popover); padding: 0 !important; }
.creation-asset-library-modal .ant-modal-body { padding: 0 !important; }
.creation-asset-library-modal .ant-modal-close { top: 10px; right: 12px; }
.creation-library-shell { display: flex; height: min(680px, calc(100vh - 64px)); min-height: 480px; flex-direction: column; color: var(--foreground); }
.creation-library-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 14px; padding: 13px 52px 10px 22px; color: color-mix(in srgb, var(--foreground) 46%, transparent); font-size: var(--fs-label); }
.creation-library-search { display: flex; height: 34px; max-width: 320px; flex: 1; align-items: center; gap: 8px; border: 1px solid color-mix(in srgb, var(--border) 82%, transparent); border-radius: var(--r-sm); background: color-mix(in srgb, var(--foreground) 4%, transparent); padding: 0 10px; }
.creation-library-search svg { width: 15px; height: 15px; color: color-mix(in srgb, var(--foreground) 45%, transparent); }
.creation-library-search input { width: 100%; min-width: 0; border: 0; outline: 0; background: transparent; color: var(--foreground); font-size: var(--fs-label); }
.creation-library-body { display: grid; min-height: 0; flex: 1; grid-template-columns: 148px minmax(0, 1fr); border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent); }
.creation-library-categories { overflow-y: auto; border-right: 1px solid var(--border); padding: 10px; }
.creation-library-categories button { display: flex; width: 100%; height: 40px; align-items: center; justify-content: space-between; gap: 8px; border: 0; border-radius: var(--r-sm); background: transparent; padding: 0 10px; color: color-mix(in srgb, var(--foreground) 56%, transparent); font-size: var(--fs-label); text-align: left; transition: background-color 140ms ease, color 140ms ease; }
.creation-library-categories button:hover { background: color-mix(in srgb, var(--foreground) 5%, transparent); color: var(--foreground); }
.creation-library-categories button.is-active { background: color-mix(in srgb, var(--workspace-accent) 13%, transparent); color: var(--foreground); font-weight: 650; }
.creation-library-categories em { min-width: 22px; border-radius: var(--r-xs); background: color-mix(in srgb, var(--foreground) 6%, transparent); padding: 2px 5px; color: color-mix(in srgb, var(--foreground) 46%, transparent); font-size: var(--fs-micro); font-style: normal; text-align: center; }
.creation-library-grid-wrap { min-width: 0; overflow-y: auto; padding: 16px; }
.creation-library-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(148px, 1fr)); gap: 12px; align-content: start; }
.creation-library-card { min-width: 0; overflow: hidden; border: 1px solid color-mix(in srgb, var(--border) 86%, transparent); border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 2.5%, transparent); color: var(--foreground); padding: 0; text-align: left; transition: border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; }
.creation-library-card:hover:not(:disabled) { border-color: color-mix(in srgb, var(--workspace-accent) 55%, var(--border)); box-shadow: var(--shadow-md); transform: translateY(-1px); }
.creation-library-card.is-selected { border-color: var(--workspace-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--workspace-accent) 18%, transparent); }
.creation-library-card.is-disabled { cursor: not-allowed; opacity: .48; }
.creation-library-card-media { position: relative; aspect-ratio: 4 / 3; overflow: hidden; background: color-mix(in srgb, var(--foreground) 7%, transparent); }
.creation-library-card-media img, .creation-library-card-media video { display: block; width: 100%; height: 100%; object-fit: cover; }
.creation-library-card-check { position: absolute; top: 9px; right: 9px; display: grid; width: 22px; height: 22px; place-items: center; border: 1px solid rgba(255, 255, 255, .7); border-radius: var(--r-xs); background: rgba(0, 0, 0, .28); color: transparent; backdrop-filter: blur(5px); }
.creation-library-card-check svg { width: 13px; height: 13px; }
.creation-library-card.is-selected .creation-library-card-check { border-color: var(--workspace-accent); background: var(--workspace-accent); color: #fff; }
.creation-library-card-kind { position: absolute; bottom: 8px; left: 8px; border-radius: var(--r-xs); background: rgba(0, 0, 0, .58); padding: 3px 6px; color: #fff; font-size: var(--fs-micro); }
.creation-library-card-lock { position: absolute; right: 8px; bottom: 8px; max-width: 112px; color: rgba(255, 255, 255, .78); font-size: var(--fs-micro); text-align: right; }
.creation-library-card-title { overflow: hidden; padding: 9px 10px 11px; font-size: var(--fs-label); font-weight: 560; text-overflow: ellipsis; white-space: nowrap; }
.creation-library-card-fallback, .creation-library-empty { display: grid; place-items: center; color: color-mix(in srgb, var(--foreground) 38%, transparent); }
.creation-library-card-fallback { height: 100%; }
.creation-library-card-fallback svg { width: 26px; height: 26px; }
.creation-library-empty { grid-column: 1 / -1; min-height: 240px; gap: 8px; text-align: center; }
.creation-library-empty svg { width: 30px; height: 30px; color: color-mix(in srgb, var(--workspace-accent) 70%, var(--foreground)); }
.creation-library-empty strong { color: color-mix(in srgb, var(--foreground) 70%, transparent); font-size: var(--fs-label); }
.creation-library-empty span { font-size: var(--fs-micro); }
.creation-library-footer { display: flex; align-items: center; gap: 16px; border-top: 1px solid var(--border); padding: 14px 18px; }
.creation-library-upload { display: flex; min-height: 58px; flex: 1; align-items: center; gap: 12px; border: 1px dashed color-mix(in srgb, var(--workspace-accent) 55%, var(--border)); border-radius: var(--r-md); background: color-mix(in srgb, var(--workspace-accent) 7%, transparent); padding: 0 16px; color: var(--foreground); text-align: left; transition: background-color 140ms ease, border-color 140ms ease; }
.creation-library-upload:hover { border-color: var(--workspace-accent); background: color-mix(in srgb, var(--workspace-accent) 12%, transparent); }
.creation-library-upload > svg { width: 21px; height: 21px; color: var(--workspace-accent); }
.creation-library-upload span { display: flex; flex-direction: column; gap: 3px; }
.creation-library-upload strong { font-size: var(--fs-label); font-weight: 650; }
.creation-library-upload small { color: color-mix(in srgb, var(--foreground) 48%, transparent); font-size: var(--fs-micro); }
.creation-library-actions { display: flex; flex-shrink: 0; align-items: center; gap: 8px; }
.creation-library-actions button { display: inline-flex; height: 36px; align-items: center; gap: 6px; border: 0; border-radius: var(--r-sm); background: transparent; padding: 0 11px; color: color-mix(in srgb, var(--foreground) 58%, transparent); font-size: var(--fs-label); }
.creation-library-actions button:hover:not(:disabled) { background: color-mix(in srgb, var(--foreground) 6%, transparent); color: var(--foreground); }
.creation-library-actions button.is-primary { background: var(--foreground); color: var(--background); font-weight: 650; }
.creation-library-actions button.is-primary:hover:not(:disabled) { background: color-mix(in srgb, var(--foreground) 86%, transparent); }
.creation-library-actions button:disabled { cursor: not-allowed; opacity: .4; }
.creation-library-actions svg { width: 14px; height: 14px; }
@media (max-width: 680px) {
.creation-empty-workspace { width: calc(100% - 28px); padding-top: 28px; }
.creation-empty-art { width: min(390px, 90vw); height: 142px; }
.creation-empty-art-frame.is-main { height: 130px; }
.creation-empty-art-frame.is-back, .creation-empty-art-frame.is-front { height: 102px; }
.creation-library-shell { min-height: 0; height: min(720px, calc(100vh - 32px)); }
.creation-library-toolbar { padding: 10px 16px; }
.creation-library-body { grid-template-columns: 1fr; grid-template-rows: auto minmax(0, 1fr); }
.creation-library-categories { display: flex; gap: 6px; overflow-x: auto; border-right: 0; border-bottom: 1px solid var(--border); padding: 8px 10px; }
.creation-library-categories button { width: auto; min-width: 92px; }
.creation-library-grid-wrap { padding: 12px; }
.creation-library-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 9px; }
.creation-library-footer { flex-direction: column; align-items: stretch; gap: 10px; padding: 12px; }
.creation-library-actions { justify-content: flex-end; }
}
/* 轮播承担首屏视觉主角,工作区只保留很窄的安全边距。 */
.creation-empty-workspace { width: min(1320px, calc(100% - 24px)); padding-top: clamp(18px, 3vh, 40px); }
.creation-empty-art { width: min(1060px, calc(100% - 72px)); height: 300px; margin-bottom: 22px; }
.creation-empty-art-frame.is-back { top: 28px; left: 2%; width: 23%; height: 220px; }
.creation-empty-art-frame.is-main { top: 0; left: 19%; width: 62%; height: 270px; }
.creation-empty-art-frame.is-front { top: 34px; right: 2%; width: 23%; height: 220px; }
.creation-empty-art-frame img { animation: creation-empty-art-image-in 500ms var(--motion-ease-out) both; }
.creation-empty-art-caption { right: 19%; bottom: -4px; }
.creation-empty-workspace .creation-chat-intro { margin-top: 42px; }
.creation-empty-composer { width: min(1120px, calc(100% - 48px)); }
@keyframes creation-empty-art-image-in {
from { opacity: .35; transform: scale(1.035); }
to { opacity: 1; transform: scale(1); }
}
.creation-library-shell { height: min(640px, calc(100vh - 48px)); }
.creation-library-toolbar { min-height: 58px; border-bottom: 1px solid var(--border); padding: 10px 52px 10px 18px; }
.creation-library-toolbar-title { display: flex; flex-shrink: 0; align-items: baseline; gap: 8px; }
.creation-library-toolbar-title span { color: var(--workspace-accent); font-size: var(--fs-micro); font-weight: 650; letter-spacing: .08em; }
.creation-library-toolbar-title strong { font-size: var(--fs-label); font-weight: 650; }
.creation-library-toolbar-count { flex-shrink: 0; color: color-mix(in srgb, var(--foreground) 48%, transparent); font-size: var(--fs-micro); }
.creation-library-body { border-top: 0; }
.creation-library-actions button.is-primary { min-width: 148px; border: 1px solid var(--workspace-accent); background: var(--workspace-accent); color: var(--background); opacity: 1; }
.creation-library-actions button.is-primary:disabled { border-color: color-mix(in srgb, var(--foreground) 16%, transparent); background: color-mix(in srgb, var(--foreground) 7%, transparent); color: color-mix(in srgb, var(--foreground) 42%, transparent); opacity: 1; }
@media (prefers-reduced-motion: reduce) {
.creation-empty-art-frame img { animation: none; }
}
@media (max-width: 680px) {
.creation-empty-workspace { width: calc(100% - 16px); padding-top: 18px; }
.creation-empty-art { width: calc(100% - 10px); height: 210px; margin-bottom: 20px; }
.creation-empty-art-frame.is-back { top: 18px; height: 150px; }
.creation-empty-art-frame.is-main { left: 14%; width: 72%; height: 190px; }
.creation-empty-art-frame.is-front { top: 23px; height: 150px; }
.creation-empty-art-caption { right: 14%; }
.creation-empty-workspace .creation-chat-intro { margin-top: 28px; }
.creation-empty-composer { width: 100%; }
.creation-library-shell { height: min(680px, calc(100vh - 32px)); }
.creation-library-toolbar { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 8px 12px; padding: 10px 42px 10px 12px; }
.creation-library-toolbar-count { grid-column: 1 / -1; }
.creation-library-search { max-width: none; }
}
/* 用户端浮层:更像创作工作室的工作面,不保留默认后台表单的硬边和密集分隔。 */
.library-modal .ant-modal-container {
overflow: hidden;
border: 0 !important;
border-radius: var(--r-2xl) !important;
background: color-mix(in srgb, var(--popover) 94%, var(--workspace-accent) 6%) !important;
padding: 0 !important;
box-shadow: 0 30px 90px color-mix(in srgb, var(--foreground) 25%, transparent), inset 0 1px 0 color-mix(in srgb, var(--foreground) 8%, transparent) !important;
}
.library-modal .ant-modal-content { border: 0 !important; background: transparent !important; box-shadow: none !important; }
.library-modal .ant-modal-header { border-bottom: 0; background: transparent; padding: 28px 32px 8px; }
.library-modal .ant-modal-title { color: var(--foreground); font-size: var(--fs-title); font-weight: 620; line-height: 1.25; }
.library-modal .ant-modal-body { padding: 12px 32px 26px; }
.library-modal .ant-modal-footer { border-top: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); background: color-mix(in srgb, var(--foreground) 2%, transparent); padding: 14px 32px 18px; }
.library-modal .ant-modal-close { top: 23px; right: 24px; width: 34px; height: 34px; border-radius: var(--r-full); color: color-mix(in srgb, var(--foreground) 42%, transparent); }
.library-modal .ant-modal-close:hover { background: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); }
.library-modal .ant-form-item-label > label { color: color-mix(in srgb, var(--foreground) 68%, transparent); font-size: var(--fs-caption); font-weight: 560; }
.library-modal :where(.ant-input, .ant-input-affix-wrapper, .ant-select-selector, .ant-input-number, .ant-picker) { min-height: 42px; border-color: color-mix(in srgb, var(--foreground) 15%, transparent) !important; border-radius: var(--r-md) !important; background: color-mix(in srgb, var(--foreground) 3%, transparent) !important; box-shadow: none !important; }
.library-modal .ant-input-affix-wrapper .ant-input { min-height: 34px; background: transparent !important; }
.library-modal :where(.ant-input:hover, .ant-input-affix-wrapper:hover, .ant-select-selector:hover) { border-color: color-mix(in srgb, var(--workspace-accent) 48%, var(--foreground)) !important; }
.library-modal :where(.ant-input:focus, .ant-input-affix-wrapper-focused, .ant-select-focused .ant-select-selector) { border-color: var(--workspace-accent) !important; box-shadow: 0 0 0 3px color-mix(in srgb, var(--workspace-accent) 12%, transparent) !important; }
.library-modal .ant-btn-primary { border: 0; border-radius: var(--r-md); background: var(--foreground); color: var(--background); box-shadow: none; font-weight: 620; }
.library-modal .ant-btn-default { border-color: color-mix(in srgb, var(--foreground) 15%, transparent); border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 4%, transparent); }
.library-confirm-modal .ant-modal-body { padding-top: 4px; padding-bottom: 12px; }
.library-drawer .ant-drawer-content { overflow: hidden; border: 0 !important; background: color-mix(in srgb, var(--popover) 95%, var(--workspace-accent) 5%); box-shadow: -24px 0 80px color-mix(in srgb, var(--foreground) 18%, transparent) !important; }
.library-drawer .ant-drawer-header { min-height: 70px; border-bottom: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); background: transparent; padding: 22px 28px 15px; }
.library-drawer .ant-drawer-title { color: var(--foreground); font-size: var(--fs-heading-lg); font-weight: 620; }
.library-drawer .ant-drawer-close { width: 34px; height: 34px; margin-inline-end: 6px; border-radius: var(--r-full); color: color-mix(in srgb, var(--foreground) 42%, transparent); }
.library-drawer .ant-drawer-close:hover { background: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); }
.library-drawer .ant-drawer-body { padding: 22px 28px 30px; }
.library-drawer .ant-drawer-extra .ant-btn { border: 0; border-radius: var(--r-md); background: var(--foreground); color: var(--background); font-weight: 620; }
.library-drawer .ant-form-item-label > label { color: color-mix(in srgb, var(--foreground) 68%, transparent); font-size: var(--fs-caption); font-weight: 560; }
.library-drawer :where(.ant-input, .ant-input-affix-wrapper, .ant-select-selector) { border-color: color-mix(in srgb, var(--foreground) 15%, transparent) !important; border-radius: var(--r-md) !important; background: color-mix(in srgb, var(--foreground) 3%, transparent) !important; box-shadow: none !important; }
.library-drawer :where(.ant-input:hover, .ant-input-affix-wrapper:hover, .ant-select-selector:hover) { border-color: color-mix(in srgb, var(--workspace-accent) 48%, var(--foreground)) !important; }
.library-drawer :where(.ant-input:focus, .ant-input-affix-wrapper-focused, .ant-select-focused .ant-select-selector) { border-color: var(--workspace-accent) !important; box-shadow: 0 0 0 3px color-mix(in srgb, var(--workspace-accent) 12%, transparent) !important; }
.library-drawer .border-border\/70 { border-color: color-mix(in srgb, var(--foreground) 10%, transparent); }
.library-drawer .asset-drawer-content { border-color: color-mix(in srgb, var(--foreground) 10%, transparent) !important; background: color-mix(in srgb, var(--foreground) 3%, transparent); }
@media (max-width: 680px) {
.library-modal .ant-modal-header { padding: 22px 20px 8px; }
.library-modal .ant-modal-body { padding: 10px 20px 20px; }
.library-modal .ant-modal-footer { padding-inline: 20px; }
.library-drawer .ant-drawer-header { padding: 18px 18px 13px; }
.library-drawer .ant-drawer-body { padding: 18px; }
}