fix(platform): 用户工作台 - 统一资源卡片并修正项目列表与预览交互

This commit is contained in:
ddCat
2026-08-06 23:01:08 +08:00
parent ad4c738e74
commit be99957273
12 changed files with 529 additions and 202 deletions

View File

@@ -0,0 +1,12 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
/** 资产库卡片的共同外壳;页面只负责业务动作和卡片内的信息层级。 */
export function AssetLibraryCard({ children, className, selected = false }: { children: ReactNode; className?: string; selected?: boolean }) {
return <article className={cn("library-card library-card-surface asset-library-card group", selected && "is-selected", className)}>{children}</article>;
}
export function AssetLibraryCardMedia({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn("library-card-media", className)}>{children}</div>;
}

View File

@@ -1,4 +1,5 @@
import { Check, Clapperboard, Download, FileText, Frame, Image as ImageIcon, MoreHorizontal, Music2, Pencil, Plus, Settings2, Sparkles, Trash2, Video, X } from "lucide-react";
import type { ReactNode } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { Dropdown, Input } from "antd";
@@ -18,7 +19,7 @@ export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; on
</button>;
}
export function CanvasProjectCard({ project, projectName, variant = "library" }: { project: CanvasProject; projectName?: string; variant?: "library" | "recent" }) {
export function CanvasProjectCard({ project, projectName, variant = "library", readOnly = false, footer }: { project: CanvasProject; projectName?: string; variant?: "library" | "recent"; readOnly?: boolean; footer?: ReactNode }) {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const renameProject = useCanvasStore((state) => state.renameProject);
@@ -40,7 +41,7 @@ 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", 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 || readOnly) && open()}>
<div className="app-canvas-project-preview relative">
<button
type="button"
@@ -52,13 +53,13 @@ export function CanvasProjectCard({ project, projectName, variant = "library" }:
>
<ProjectPreview project={project} />
</button>
{!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}
{!compact && !readOnly ? <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 ? "is-compact" : "")}>
<div className="canvas-project-heading-row">
{editing ? (
{editing && !readOnly ? (
<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
@@ -72,12 +73,12 @@ export function CanvasProjectCard({ project, projectName, variant = "library" }:
<h2>{project.title}</h2>
</button>
)}
{editing ? (
{editing && !readOnly ? (
<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>
) : (
) : !readOnly ? (
<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
@@ -94,9 +95,10 @@ export function CanvasProjectCard({ project, projectName, variant = "library" }:
<button type="button" aria-label={`${project.title} 画布操作`} title="更多操作"><MoreHorizontal className="size-4" /></button>
</Dropdown>
</div>
)}
) : null}
</div>
<div className="canvas-project-stats"><span>{projectName || "自由画布"}</span><span aria-hidden="true">·</span><time dateTime={project.updatedAt}>{formatProjectTime(project.updatedAt)}</time></div>
{footer ? <div className="canvas-project-card-footer" onClick={(event) => event.stopPropagation()}>{footer}</div> : null}
</div>
</article>
);

View File

@@ -5,6 +5,7 @@ import { App, Button, Drawer, Dropdown, Form, Input, Modal, Select, Space, Tag,
import { CollectionGrid, ListToolbar, PageHeader, PaginationBar, WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
import { saveAs } from "file-saver";
import { useCopyText } from "@/hooks/use-copy-text";
@@ -432,8 +433,8 @@ 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={`library-card asset-library-card group ${selected ? "is-selected" : ""}`}>
<div className="relative overflow-hidden bg-foreground/[.045]">
<AssetLibraryCard selected={selected}>
<AssetLibraryCardMedia 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>} />
</button>
@@ -456,7 +457,7 @@ function AssetCard({ asset, selected, onSelect, onOpen, onEdit, onCopy, onDownlo
<span className="rounded-[var(--r-xs)] bg-black/60 px-1.5 py-0.5 text-[var(--fs-tiny)] font-medium text-white/90 backdrop-blur">{assetKindLabel(asset.kind)}</span>
<span className="rounded-[var(--r-xs)] bg-black/60 px-1.5 py-0.5 text-[var(--fs-tiny)] text-white/75 backdrop-blur">{assetCategoryLabel(asset.category)}</span>
</div>
</div>
</AssetLibraryCardMedia>
<button type="button" className="block w-full px-2.5 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]" onClick={onOpen}>
<div className="flex min-w-0 items-center justify-between gap-2">
<h2 className="truncate text-[var(--fs-body)] font-semibold text-foreground" title={asset.title}>{asset.title}</h2>
@@ -469,7 +470,7 @@ function AssetCard({ asset, selected, onSelect, onOpen, onEdit, onCopy, onDownlo
<span className="truncate">{assetProjectLabel(asset)}</span>
</div>
</button>
</article>
</AssetLibraryCard>
);
}

View File

@@ -1,10 +1,11 @@
import { useMemo, useState } from "react";
import { useMutation, useMutationState, useQuery, useQueryClient } from "@tanstack/react-query";
import { App, Button, Dropdown, Form, Input, Modal, Popconfirm, Select, Tabs, type FormInstance } from "antd";
import { Box, Check, ChevronDown, FileText, Image as ImageIcon, Link2, Music2, Plus, RefreshCw, Sparkles, Trash2, UserRound, Video, VolumeX } from "lucide-react";
import { Box, Check, ChevronDown, Download, FileText, Image as ImageIcon, Link2, Music2, Plus, RefreshCw, Sparkles, Trash2, UserRound, Video, VolumeX } from "lucide-react";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
import { WorkspaceSignalIcon } from "@/components/ui/aceternity/workspace-signal-icon";
import { resolveCanvasStylePreset } from "@/components/canvas/canvas-style-picker-modal";
import { resourceFileUrl, resourceIdFromStorageKey } from "@/services/api/resources";
@@ -27,10 +28,11 @@ import {
import { saveRemoteUserDataNow } from "@/services/user-data-sync";
import { useAssetStore, type Asset, type AssetCategory, type AssetStatus, type EntityAsset, type ImageAsset } from "@/stores/use-asset-store";
import { useConfigStore, useEffectiveConfig } from "@/stores/use-config-store";
import { saveAs } from "file-saver";
import { ProjectCharacterCard } from "./project-character-card";
import { generateCharacterTurnaround } from "./project-character-media";
import { categoryLabels, categoryLabel, mediaLabel, StatusPill, formatTime, type ProjectDetailViewProps } from "./shared";
import { categoryLabels, categoryLabel, mediaLabel, StatusPill, formatTime, textValue, type ProjectDetailViewProps } from "./shared";
const categories = ["all", "character", "environment", "wardrobe", "prop", "weapon", "style", "other"];
const characterFields = [
@@ -56,6 +58,7 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
const [editorAsset, setEditorAsset] = useState<ProjectAsset | "new" | null>(null);
const [imageAsset, setImageAsset] = useState<ProjectAsset | null>(null);
const [voiceAsset, setVoiceAsset] = useState<ProjectAsset | null>(null);
const [previewAsset, setPreviewAsset] = useState<ProjectAsset | null>(null);
const [imageSelection, setImageSelection] = useState("");
const [voiceProfileId, setVoiceProfileId] = useState("");
const [voiceInstructions, setVoiceInstructions] = useState("");
@@ -149,6 +152,18 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
};
const openImages = (asset: ProjectAsset) => { setImageAsset(asset); setImageSelection(""); };
const openVoice = (asset: ProjectAsset) => { setVoiceAsset(asset); setVoiceProfileId(asset.character?.voice?.profile.id || ""); setVoiceInstructions(asset.character?.voice?.instructions || ""); };
const downloadPreviewAsset = (asset: ProjectAsset) => {
const personal = personalAssets.find((item) => item.id === asset.id);
if (personal && (personal.kind === "image" || personal.kind === "video" || personal.kind === "audio" || personal.kind === "model")) {
const url = personal.kind === "image" ? personal.data.dataUrl : personal.data.url;
const extension = personal.kind === "model" ? personal.data.fileName.split(".").pop() || "glb" : personal.data.mimeType.split("/")[1] || "bin";
saveAs(url, `${asset.title || "asset"}.${extension}`);
return;
}
const cover = asset.character?.representations.find((item) => item.role === "turnaround_sheet") || asset.character?.representations.find((item) => item.role === "primary") || asset.character?.representations[0];
if (cover) saveAs(resourceFileUrl(cover.resourceId), `${asset.title || "character"}.png`);
else message.warning("当前资产没有可下载的媒体文件");
};
return (
<div>
<header className="flex min-h-[72px] flex-col gap-4 border-b border-border/70 pb-4 sm:flex-row sm:items-center sm:justify-between">
@@ -201,11 +216,12 @@ export default function ProjectAssetsView({ detail, refreshProject }: ProjectDet
</section>
) : null}
<div className="mb-2 flex items-center justify-between text-xs text-foreground/45"><span>{category === "all" ? "全部资产" : categoryLabel(category)}</span><span>{visibleAssets.length} 项已确认</span></div>
{visibleAssets.length ? <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} generating={generatingAssetIds.has(asset.id)} removing={unlinkMutation.isPending && unlinkMutation.variables === asset.id} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => item.id === asset.id)} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}</div> : (category === "all" || category === "character") && pendingCandidates.length ? null : <WorkspaceState icon="assets" compact title="这个分类还没有资产" description={category === "character" ? "新建角色,或先从剧情章节提取角色信息。" : "可从个人素材库引用,或切换到其他分类查看。"} />}
{visibleAssets.length ? <div className="project-assets-grid assets-library-grid">{visibleAssets.map((asset) => asset.category === "character" ? <ProjectCharacterCard key={asset.id} asset={asset} generating={generatingAssetIds.has(asset.id)} removing={unlinkMutation.isPending && unlinkMutation.variables === asset.id} onOpen={() => setPreviewAsset(asset)} onEdit={() => openCharacterEditor(asset)} onGenerate={() => generateMutation.mutate(asset)} onBindImages={() => openImages(asset)} onBindVoice={() => openVoice(asset)} onRemove={() => unlinkMutation.mutate(asset.id)} /> : <MediaAssetCard key={asset.id} asset={asset} personalAsset={personalAssets.find((item) => item.id === asset.id)} onOpen={() => setPreviewAsset(asset)} onCategoryChange={(next) => categoryMutation.mutate({ id: asset.id, next })} onVersion={() => versionMutation.mutate(asset.id)} onRemove={() => unlinkMutation.mutate(asset.id)} loading={(categoryMutation.isPending && categoryMutation.variables?.id === asset.id) || (versionMutation.isPending && versionMutation.variables === asset.id) || (unlinkMutation.isPending && unlinkMutation.variables === asset.id)} />)}</div> : (category === "all" || category === "character") && pendingCandidates.length ? null : <WorkspaceState icon="assets" compact title="这个分类还没有资产" description={category === "character" ? "新建角色,或先从剧情章节提取角色信息。" : "可从个人素材库引用,或切换到其他分类查看。"} />}
</div>
</div>
<Modal title="引用个人素材" open={addOpen} okText="加入项目" cancelText="取消" okButtonProps={{ disabled: !assetId, loading: addMutation.isPending }} onCancel={() => setAddOpen(false)} onOk={() => addMutation.mutate()} width={480}><div className="grid gap-3"><Select showSearch optionFilterProp="label" value={assetId || undefined} placeholder="选择未加入项目的素材" options={availableAssets.map((asset) => ({ label: `${asset.title} · ${mediaLabel(asset.kind)}`, value: asset.id }))} onChange={(value) => { setAssetId(value); const next = personalAssets.find((asset) => asset.id === value); setAssetCategory(next?.kind === "entity" ? "character" : next?.category || "other"); }} /><Select value={assetCategory} disabled={selectedPersonalAsset?.kind === "entity"} options={selectedPersonalAsset?.kind === "entity" ? [{ value: "character", label: "角色" }] : Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ value, label }))} onChange={setAssetCategory} /></div></Modal>
<ProjectAssetPreviewModal asset={previewAsset} personalAsset={previewAsset ? personalAssets.find((item) => item.id === previewAsset.id) : undefined} onClose={() => setPreviewAsset(null)} onDownload={() => previewAsset && downloadPreviewAsset(previewAsset)} onReplaceImage={() => { if (!previewAsset || previewAsset.category !== "character") return; setPreviewAsset(null); openImages(previewAsset); }} />
<CharacterEditorModal open={Boolean(editorAsset)} editing={editorAsset !== "new"} form={form} loading={saveCharacter.isPending} onClose={() => setEditorAsset(null)} onSave={() => form.validateFields().then((values) => saveCharacter.mutate(values))} />
<Modal title={`绑定单张三视图 · ${imageAsset?.title || ""}`} open={Boolean(imageAsset)} okText="绑定并生成新版本" cancelText="取消" width={720} okButtonProps={{ loading: bindImagesMutation.isPending, disabled: !imageSelection }} onCancel={() => setImageAsset(null)} onOk={() => bindImagesMutation.mutate()}>{imageAssets.length ? <div className="grid max-h-[420px] gap-3 overflow-y-auto sm:grid-cols-3">{imageAssets.map((asset) => <button type="button" key={asset.id} onClick={() => setImageSelection(asset.id)} className={`overflow-hidden rounded-lg border text-left transition ${imageSelection === asset.id ? "border-[var(--workspace-accent)] ring-2 ring-[var(--workspace-accent)]/20" : "border-border/70 hover:border-foreground/35"}`}><div className="relative aspect-[4/3] bg-foreground/[.045]"><img src={asset.data.dataUrl} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-cover" />{imageSelection === asset.id ? <span className="absolute right-2 top-2 grid size-6 place-items-center rounded-full bg-[var(--workspace-accent)] text-white"><Check className="size-4" /></span> : null}</div><div className="truncate px-2 py-2 text-[var(--fs-label)] font-medium">{asset.title}</div></button>)}</div> : <WorkspaceState icon="assets" compact title="个人图片素材为空" description="需要一张包含正面、侧面、背面的角色设定图。" />}</Modal>
<Modal title={`选择声音素材 · ${voiceAsset?.title || ""}`} open={Boolean(voiceAsset)} okText="绑定并生成新版本" cancelText="取消" okButtonProps={{ loading: bindVoiceMutation.isPending, disabled: !voiceProfileId }} onCancel={() => setVoiceAsset(null)} onOk={() => bindVoiceMutation.mutate()}><div className="grid gap-3"><Select loading={voices.isLoading} showSearch optionFilterProp="label" value={voiceProfileId || undefined} placeholder="选择声音" options={(voices.data?.profiles || []).map((voice) => ({ label: `${voice.name} · ${voice.language}`, value: voice.id }))} onChange={setVoiceProfileId} /><Input.TextArea rows={3} value={voiceInstructions} placeholder="表演指令,例如:克制、温暖、语速稍慢" onChange={(event) => setVoiceInstructions(event.target.value)} />{voiceAsset?.character && voiceAsset.character.voiceStatus !== "missing" ? <div className="flex items-center justify-between border-t border-border/70 pt-2"><span className="text-[var(--fs-label)] text-foreground/45">当前绑定:{voiceAsset.character.voice?.profile.name || "声音素材不可用"}</span><Popconfirm title="解除当前声音绑定?" description="该操作会保留历史版本,并创建一个未绑定声音的新版本。" okText="解除" cancelText="取消" onConfirm={() => unbindVoiceMutation.mutate()}><Button type="text" danger size="small" loading={unbindVoiceMutation.isPending} icon={<VolumeX className="size-3.5" />}>解除声音</Button></Popconfirm></div> : null}</div></Modal>
@@ -225,8 +241,29 @@ function CharacterEditorModal({ open, editing, form, loading, onClose, onSave }:
type CharacterFormKey = (typeof characterFields)[number][0];
function MediaAssetCard({ asset, personalAsset, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; onCategoryChange: (category: string) => void; onVersion: () => void; onRemove: () => void; loading: boolean }) {
return <article className="overflow-hidden rounded-lg border border-border/80 bg-background"><div className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><AssetMediaPreview asset={personalAsset} alt={asset.title} className="h-full w-full bg-black object-cover" fallback={<div className="grid h-full place-items-center text-foreground/25"><MediaIcon kind={asset.mediaType} /></div>} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{mediaLabel(asset.mediaType)}</span></div></div><div className="p-2.5"><div className="flex items-start justify-between gap-2"><div className="min-w-0"><h3 className="truncate text-xs font-medium">{asset.title}</h3><div className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[var(--fs-tiny)] text-foreground/50 hover:bg-foreground/[.055]"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span></div></div><Link2 className="mt-0.5 size-3.5 shrink-0 text-[var(--workspace-accent)]" /></div><div className="mt-2 flex items-center justify-between gap-2 border-t border-border/60 pt-1.5"><span className="text-[var(--fs-micro)] text-foreground/38">{formatTime(asset.updatedAt)}</span><div className="flex items-center"><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} /></Popconfirm></div></div></div></article>;
function MediaAssetCard({ asset, personalAsset, onOpen, onCategoryChange, onVersion, onRemove, loading }: { asset: ProjectAsset; personalAsset?: Asset; onOpen: () => void; onCategoryChange: (category: string) => void; onVersion: () => void; onRemove: () => void; loading: boolean }) {
return <AssetLibraryCard className="project-asset-library-card"><AssetLibraryCardMedia className="relative aspect-[4/3] overflow-hidden bg-foreground/[.05]"><button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看资产:${asset.title}`}><AssetMediaPreview asset={personalAsset} alt={asset.title} className="h-full w-full bg-black object-cover" fallback={<div className="grid h-full place-items-center text-foreground/25"><MediaIcon kind={asset.mediaType} /></div>} /><div className="absolute inset-x-2 top-2 flex items-center justify-between"><StatusPill status={asset.status} /><span className="rounded bg-black/50 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{mediaLabel(asset.mediaType)}</span></div></button></AssetLibraryCardMedia><div className="p-2.5"><button type="button" className="project-asset-title-button" onClick={onOpen}><span className="min-w-0 truncate text-xs font-medium">{asset.title}</span><span className="shrink-0 text-[var(--fs-micro)] text-foreground/38">{formatTime(asset.updatedAt)}</span></button><div className="mt-1 flex items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/42"><Dropdown trigger={["click"]} menu={{ selectedKeys: [asset.category], items: Object.entries(categoryLabels).filter(([value]) => value !== "character").map(([value, label]) => ({ key: value, label })), onClick: ({ key }) => onCategoryChange(key) }}><button type="button" disabled={loading} className="inline-flex h-6 items-center gap-1 rounded px-1.5 text-[var(--fs-tiny)] text-foreground/50 hover:bg-foreground/[.055]"><span>{categoryLabel(asset.category)}</span><ChevronDown className="size-3" /></button></Dropdown><span>·</span><span>v{Math.max(1, asset.versionCount)}</span><Link2 className="ml-auto size-3.5 shrink-0 text-[var(--workspace-accent)]" /></div><div className="mt-2 flex items-center justify-between gap-2 border-t border-border/60 pt-1.5"><span className="text-[var(--fs-micro)] text-foreground/38">{mediaLabel(asset.mediaType)}</span><div className="flex items-center"><Button type="text" size="small" icon={<RefreshCw className="size-3.5" />} loading={loading} onClick={onVersion} /><Popconfirm title="移出项目资产?" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" icon={<Trash2 className="size-3.5" />} loading={loading} /></Popconfirm></div></div></div></AssetLibraryCard>;
}
function ProjectAssetPreviewModal({ asset, personalAsset, onClose, onDownload, onReplaceImage }: { asset: ProjectAsset | null; personalAsset?: Asset; onClose: () => void; onDownload: () => void; onReplaceImage: () => void }) {
const characterCover = asset?.character?.representations.find((item) => item.role === "turnaround_sheet") || asset?.character?.representations.find((item) => item.role === "primary") || asset?.character?.representations[0];
const canDownload = Boolean(personalAsset && ["image", "video", "audio", "model"].includes(personalAsset.kind)) || Boolean(characterCover);
const previewClass = asset?.category === "character" ? "is-character" : personalAsset?.kind === "video" ? "is-video" : personalAsset?.kind === "audio" ? "is-audio" : personalAsset?.kind === "text" ? "is-text" : "is-image";
return (
<Modal className="library-modal project-asset-preview-modal" title={asset?.category === "character" ? "角色卡预览" : "资产预览"} open={Boolean(asset)} width={840} onCancel={onClose} footer={<div className="flex justify-end gap-2"><Button onClick={onClose}>关闭</Button>{asset?.category === "character" ? <Button onClick={onReplaceImage}>替换图片</Button> : null}{canDownload ? <Button type="primary" icon={<Download className="size-3.5" />} onClick={onDownload}>下载</Button> : null}</div>}>
{asset ? <div className="project-asset-preview-layout">
<div className={`project-asset-preview-stage ${previewClass}`}>
{asset.category === "character" ? characterCover ? <img src={resourceFileUrl(characterCover.resourceId)} alt={asset.title} className="project-asset-preview-media" /> : <div className="grid min-h-48 place-items-center text-foreground/35"><UserRound className="size-12" /></div> : personalAsset?.kind === "video" ? <video src={personalAsset.data.url} controls className="project-asset-preview-media" /> : personalAsset?.kind === "audio" ? <audio src={personalAsset.data.url} controls className="project-asset-preview-audio" /> : personalAsset?.kind === "image" ? <img src={personalAsset.data.dataUrl || personalAsset.coverUrl} alt={asset.title} className="project-asset-preview-media" /> : personalAsset?.kind === "text" ? <p className="project-asset-preview-text">{personalAsset.data.content}</p> : <div className="grid min-h-48 place-items-center text-foreground/35"><MediaIcon kind={asset.mediaType} /></div>}
</div>
<aside className="project-asset-preview-details">
<div className="project-asset-preview-eyebrow">{asset.category === "character" ? "角色卡" : mediaLabel(asset.mediaType)}</div>
<h3 className="project-asset-preview-title">{asset.title}</h3>
<p className="project-asset-preview-meta">更新于 {formatTime(asset.updatedAt)}</p>
{asset.character ? <div className="project-asset-preview-sections"><section><span>剧情定位</span><p>{textValue(asset.character.definition.role) || "未填写"}</p></section><section><span>外观设定</span><p>{textValue(asset.character.definition.appearance) || textValue(asset.character.definition.consistencyPrompt) || "未填写"}</p></section><div className="project-asset-preview-status">形象:{asset.character.visualStatus === "ready" ? "已绑定" : "待完善"} · 声音:{asset.character.voiceStatus === "ready" ? "已绑定" : "未绑定"}</div></div> : <div className="project-asset-preview-facts"><span>版本 <strong>v{Math.max(1, asset.versionCount)}</strong></span><span>{asset.usages.length} 处引用</span></div>}
</aside>
</div> : null}
</Modal>
);
}
function characterDefinition(values: CharacterForm) {

View File

@@ -1,14 +1,14 @@
import { useMemo, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { App, Button, Popconfirm, Select, Tooltip } from "antd";
import { ArrowUpRight, Film, Link2, Unlink, X } from "lucide-react";
import { Link } from "react-router";
import { Link2, Unlink, X } from "lucide-react";
import { CanvasProjectCard } from "@/components/canvas/canvas-project-card";
import { WorkspaceState } from "@/components/layout/workspace-state";
import { linkCanvasUnit, unlinkCanvasProject, unlinkCanvasUnit } from "@/services/api/projects";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useCanvasStore, type CanvasProject } from "@/stores/canvas/use-canvas-store";
import { formatTime, type ProjectDetailViewProps } from "./shared";
import { type ProjectDetailViewProps } from "./shared";
export default function ProjectCanvasesView({ detail, refreshProject }: ProjectDetailViewProps) {
const { message } = App.useApp();
@@ -44,32 +44,35 @@ export default function ProjectCanvasesView({ detail, refreshProject }: ProjectD
return (
<div>
{canvases.length ? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(280px,360px))] justify-start gap-3">
{canvases.map((canvas, index) => {
<div className="project-library-grid library-grid">
{canvases.map((canvas) => {
const links = linksByCanvas[canvas.id] || [];
const linkedUnits = links.map((link) => detail.units.find((unit) => unit.id === link.unitId)).filter(Boolean);
const unlinkedUnits = detail.units.filter((unit) => !links.some((link) => link.unitId === unit.id));
const project = toCanvasProject(canvas, detail.project.id, localCanvases.find((item) => item.id === canvas.id));
return (
<article key={canvas.id} className="min-w-0 overflow-hidden rounded-lg border border-border/80 bg-background">
<Link to={`/canvas/${canvas.id}`} className="group relative block h-24 overflow-hidden border-b border-border/70 bg-[#111827] p-3 text-white">
<div className="absolute inset-0 opacity-40" style={{ backgroundImage: "linear-gradient(rgba(255,255,255,.08) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.08) 1px, transparent 1px)", backgroundSize: "20px 20px" }} />
<div className="relative flex h-full flex-col justify-between"><div className="flex items-center justify-between"><span className="flex items-center gap-1.5 text-[var(--fs-tiny)] text-white/55"><Film className="size-3" />画布 {String(index + 1).padStart(2, "0")}</span><ArrowUpRight className="size-3.5 text-white/45 group-hover:text-[var(--workspace-accent)]" /></div><div><div className="truncate text-[var(--fs-body)] font-semibold">{canvas.title}</div><div className="mt-0.5 text-[var(--fs-micro)] text-white/48">更新于 {formatTime(canvas.updatedAt)}</div></div></div>
</Link>
<div className="p-2.5">
<div className="flex items-center justify-between"><span className="text-[var(--fs-tiny)] font-medium text-foreground/48">关联章节</span><span className="text-[var(--fs-micro)] tabular-nums text-foreground/38">{linkedUnits.length} 个</span></div>
<div className="mt-1.5 flex min-h-6 max-h-12 flex-wrap gap-1 overflow-y-auto">
{linkedUnits.length ? linkedUnits.map((unit) => (
<span key={unit!.id} className="inline-flex h-5 max-w-full items-center gap-1 rounded bg-[var(--workspace-accent-soft)] pl-1.5 pr-0.5 text-[var(--fs-micro)] text-[var(--workspace-accent)]"><span className="truncate">{String(unit!.position + 1).padStart(2, "0")} · {unit!.title}</span><Tooltip title="解除章节关联"><button type="button" className="grid size-4 shrink-0 place-items-center rounded hover:bg-foreground/[.07]" aria-label={`解除${unit!.title}关联`} onClick={() => unlinkUnitMutation.mutate({ canvasId: canvas.id, unitId: unit!.id })}><X className="size-3" /></button></Tooltip></span>
)) : <span className="py-0.5 text-[var(--fs-tiny)] text-foreground/38">尚未关联章节</span>}
<CanvasProjectCard
key={canvas.id}
project={project}
projectName={detail.project.name}
readOnly
footer={
<div className="border-t border-border/60 pt-2.5">
<div className="flex items-center justify-between"><span className="text-[var(--fs-tiny)] font-medium text-foreground/48">关联章节</span><span className="text-[var(--fs-micro)] tabular-nums text-foreground/38">{linkedUnits.length} 个</span></div>
<div className="mt-1.5 flex min-h-6 max-h-12 flex-wrap gap-1 overflow-y-auto">
{linkedUnits.length ? linkedUnits.map((unit) => (
<span key={unit!.id} className="inline-flex h-5 max-w-full items-center gap-1 rounded bg-[var(--workspace-accent-soft)] pl-1.5 pr-0.5 text-[var(--fs-micro)] text-[var(--workspace-accent)]"><span className="truncate">{String(unit!.position + 1).padStart(2, "0")} · {unit!.title}</span><Tooltip title="解除章节关联"><button type="button" className="grid size-4 shrink-0 place-items-center rounded hover:bg-foreground/[.07]" aria-label={`解除${unit!.title}关联`} onClick={() => unlinkUnitMutation.mutate({ canvasId: canvas.id, unitId: unit!.id })}><X className="size-3" /></button></Tooltip></span>
)) : <span className="py-0.5 text-[var(--fs-tiny)] text-foreground/38">尚未关联章节</span>}
</div>
<div className="mt-1.5 flex items-center gap-1.5">
<Select size="small" className="min-w-0 flex-1" placeholder={unlinkedUnits.length ? "关联更多章节" : "全部章节已关联"} disabled={!unlinkedUnits.length} options={unlinkedUnits.map((unit) => ({ label: `${String(unit.position + 1).padStart(2, "0")} · ${unit.title}`, value: unit.id }))} onChange={(unitId) => { setLinkingCanvasId(canvas.id); linkMutation.mutate({ canvasId: canvas.id, unitId }); }} loading={linkMutation.isPending && linkingCanvasId === canvas.id} suffixIcon={<Link2 className="size-3.5" />} />
<Popconfirm title="解除画布与项目的关系?" description="画布文档不会删除,之后仍可在“画布”中打开。" okText="解除关系" cancelText="取消" okButtonProps={{ danger: true, loading: unlinkProjectMutation.isPending }} onConfirm={() => unlinkProjectMutation.mutate(canvas.id)}>
<Tooltip title="解除项目关系"><Button size="small" type="text" danger icon={<Unlink className="size-3.5" />} aria-label="解除项目关系" /></Tooltip>
</Popconfirm>
</div>
</div>
<div className="mt-1.5 flex items-center gap-1.5 border-t border-border/60 pt-2">
<Select size="small" className="min-w-0 flex-1" placeholder={unlinkedUnits.length ? "关联更多章节" : "全部章节已关联"} disabled={!unlinkedUnits.length} options={unlinkedUnits.map((unit) => ({ label: `${String(unit.position + 1).padStart(2, "0")} · ${unit.title}`, value: unit.id }))} onChange={(unitId) => { setLinkingCanvasId(canvas.id); linkMutation.mutate({ canvasId: canvas.id, unitId }); }} loading={linkMutation.isPending && linkingCanvasId === canvas.id} suffixIcon={<Link2 className="size-3.5" />} />
<Popconfirm title="解除画布与项目的关系?" description="画布文档不会删除,之后仍可在“画布”中打开。" okText="解除关系" cancelText="取消" okButtonProps={{ danger: true, loading: unlinkProjectMutation.isPending }} onConfirm={() => unlinkProjectMutation.mutate(canvas.id)}>
<Tooltip title="解除项目关系"><Button size="small" type="text" danger icon={<Unlink className="size-3.5" />} aria-label="解除项目关系" /></Tooltip>
</Popconfirm>
</div>
</div>
</article>
}
/>
);
})}
</div>
@@ -77,3 +80,22 @@ export default function ProjectCanvasesView({ detail, refreshProject }: ProjectD
</div>
);
}
function toCanvasProject(canvas: { id: string; title: string; createdAt: string; updatedAt: string }, projectId: string, local?: CanvasProject): CanvasProject {
if (local) return { ...local, title: canvas.title || local.title, updatedAt: canvas.updatedAt || local.updatedAt };
return {
id: canvas.id,
projectId,
title: canvas.title,
createdAt: canvas.createdAt,
updatedAt: canvas.updatedAt,
nodes: [],
connections: [],
chatSessions: [],
activeChatId: null,
backgroundMode: "dots",
showImageInfo: true,
viewport: { x: 0, y: 0, k: 1 },
directorScenes: [],
};
}

View File

@@ -19,42 +19,47 @@ export default function ProjectOverviewView({ detail }: ProjectDetailViewProps)
const unitStages = projectUnitStages(detail);
return (
<div className="space-y-7">
<section className="project-overview-focus overflow-hidden rounded-lg border border-border/75 bg-background/80 shadow-sm">
<div className="grid lg:grid-cols-[minmax(0,1fr)_300px]">
<div className="min-w-0 p-5 sm:p-6 lg:p-7">
<div className="flex flex-wrap items-center gap-2 text-[var(--fs-label)]">
<span className="font-medium text-[var(--workspace-accent)]">当前任务</span>
<span className="text-foreground/20" aria-hidden>/</span>
<span className="text-foreground/45">{stage.label}</span>
{attentionCount ? <span className="rounded bg-foreground/[.07] px-1.5 py-0.5 text-[var(--fs-tiny)] font-medium text-foreground/70">{attentionCount} 项待处理</span> : null}
<div className="space-y-8">
<section className="project-overview-focus">
<div className="grid lg:grid-cols-[minmax(0,1fr)_308px]">
<div className="project-overview-primary">
<div className="project-overview-eyebrow">
<span>当前任务</span>
<span className="project-overview-eyebrow-divider" aria-hidden>/</span>
<span className="project-overview-eyebrow-stage">{stage.label}</span>
{attentionCount ? <span className="project-overview-eyebrow-badge">{attentionCount} 项待处理</span> : null}
</div>
<h2 className="mt-3 max-w-[680px] text-2xl font-semibold leading-8 text-balance">{primaryAction.title}</h2>
<p className="mt-2 max-w-[680px] text-sm leading-6 text-foreground/52 text-pretty">{primaryAction.description}</p>
<div className="mt-5 flex flex-wrap items-center gap-3">
<Link to={primaryAction.href} className="inline-flex h-10 max-w-full items-center gap-2 rounded-md bg-[var(--workspace-accent)] px-4 text-sm font-medium text-white transition-[filter] hover:brightness-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<h2 className="project-overview-title">{primaryAction.title}</h2>
<p className="project-overview-description">{primaryAction.description}</p>
<div className="project-overview-cta">
{/* 主按钮走 --btn-solid-* 配对色:原先是 bg-[--workspace-accent] + text-white,
而暗色下该 accent 是 #f5f5f5,等于白底白字。 */}
<Link to={primaryAction.href} className="project-overview-cta-primary">
<span className="truncate">{primaryAction.actionLabel}</span><ArrowRight className="size-4 shrink-0" />
</Link>
{continueTarget.href !== primaryAction.href ? <Link to={continueTarget.href} className="inline-flex h-10 items-center gap-2 px-1 text-xs font-medium text-foreground/48 hover:text-foreground">继续最近工作<ArrowRight className="size-3.5" /></Link> : null}
{continueTarget.href !== primaryAction.href ? <Link to={continueTarget.href} className="project-overview-cta-secondary">继续最近工作<ArrowRight className="size-3.5" /></Link> : null}
</div>
</div>
<aside className="project-overview-status border-t border-border/70 bg-foreground/[.018] p-5 lg:border-l lg:border-t-0 lg:p-6" aria-label="项目进度">
<div className="flex items-end justify-between gap-3">
<div><div className="text-[var(--fs-tiny)] font-medium text-foreground/38">章节进度</div><div className="mt-1 text-lg font-semibold tabular-nums">{completedUnits}<span className="mx-1 text-sm font-normal text-foreground/28">/</span>{units.length}</div></div>
<span className="text-xs font-medium tabular-nums text-foreground/42">{completion}%</span>
<aside className="project-overview-status" aria-label="项目进度">
<div className="project-overview-progress">
<div className="project-overview-progress-head">
<span className="project-overview-status-label">章节进度</span>
<span className="project-overview-progress-percent">{completion}%</span>
</div>
<div className="project-overview-progress-count">{completedUnits}<span>/ {units.length}</span></div>
<div className="project-overview-progress-track" aria-label={`章节完成度 ${completion}%`}><div style={{ width: `${completion}%` }} /></div>
</div>
<div className="mt-3 h-1.5 overflow-hidden rounded-full bg-foreground/[.08]" aria-label={`章节完成度 ${completion}%`}><div className="h-full rounded-full bg-[var(--workspace-accent)] transition-[width]" style={{ width: `${completion}%` }} /></div>
<dl className="mt-5 grid grid-cols-2 gap-x-5 gap-y-3 text-xs">
<dl className="project-overview-facts">
<ProjectFact label="当前阶段" value={stage.label} />
<ProjectFact label="分镜镜头" value={`${shots.length} 个`} />
<ProjectFact label="项目画布" value={`${canvases.length} 张`} />
<ProjectFact label="需要处理" value={`${attentionCount} 项`} attention={attentionCount > 0} />
</dl>
{secondaryActions.length ? (
<div className="mt-5 border-t border-border/65 pt-4">
<div className="text-[var(--fs-tiny)] font-medium text-foreground/38">随后处理</div>
<div className="mt-2 space-y-1">{secondaryActions.map((action) => <SecondaryAction key={action.id} action={action} />)}</div>
<div className="project-overview-next">
<span className="project-overview-status-label">随后处理</span>
<div className="mt-2 space-y-0.5">{secondaryActions.map((action) => <SecondaryAction key={action.id} action={action} />)}</div>
</div>
) : null}
</aside>
@@ -62,61 +67,57 @@ export default function ProjectOverviewView({ detail }: ProjectDetailViewProps)
</section>
<section>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<div className="text-[var(--fs-tiny)] font-medium text-foreground/38">制作流水线</div>
<h2 className="mt-1 text-lg font-semibold">章节进度</h2>
<p className="mt-1 text-xs leading-5 text-foreground/46">从内容确认到项目画布,每章只显示当前真实状态。</p>
<div className="project-pipeline-head">
<div className="min-w-0">
<h2 className="project-pipeline-title">章节进度</h2>
<p className="project-pipeline-hint">从内容确认到项目画布,每章只显示当前真实状态。</p>
</div>
<Link to={`/projects/${project.id}/chapters`} className="inline-flex h-8 items-center gap-1.5 text-xs font-medium text-foreground/48 hover:text-foreground">查看全部章节<ArrowRight className="size-3.5" /></Link>
<Link to={`/projects/${project.id}/chapters`} className="project-pipeline-more">查看全部章节<ArrowRight className="size-3.5" /></Link>
</div>
{unitStages.length ? (
<div className="project-pipeline-surface mt-4 overflow-hidden rounded-lg border border-border/75 bg-background/70">
<div className="divide-y divide-border/65">
{unitStages.map((item) => (
<Link key={item.unit.id} to={`/projects/${project.id}/chapters/${item.unit.id}`} className="group grid gap-4 p-4 transition-colors hover:bg-foreground/[.025] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-foreground/20 sm:p-5 lg:grid-cols-[minmax(220px,.75fr)_minmax(480px,1.35fr)_24px] lg:items-center">
<span className="flex min-w-0 items-center gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-md bg-foreground/[.055] text-[var(--fs-tiny)] font-semibold tabular-nums text-foreground/45">{String(item.unit.position + 1).padStart(2, "0")}</span>
<span className="min-w-0"><span className="block truncate text-sm font-medium">{item.unit.title}</span><span className="mt-1 block text-[var(--fs-tiny)] text-foreground/36">更新于 {formatTime(item.unit.updatedAt)}</span></span>
</span>
<StagePipeline content={item.content} assets={item.assets} storyboard={item.storyboard} canvas={item.canvas} />
<ArrowRight className="hidden size-4 text-foreground/22 transition group-hover:translate-x-0.5 group-hover:text-foreground/55 lg:block" />
</Link>
))}
</div>
<div className="project-pipeline-surface">
{unitStages.map((item) => (
<Link key={item.unit.id} to={`/projects/${project.id}/chapters/${item.unit.id}`} className="project-pipeline-row group">
<span className="project-pipeline-chapter">
<span className="project-pipeline-index">{String(item.unit.position + 1).padStart(2, "0")}</span>
<span className="min-w-0"><span className="project-pipeline-chapter-title">{item.unit.title}</span><span className="project-pipeline-chapter-time">更新于 {formatTime(item.unit.updatedAt)}</span></span>
</span>
<StagePipeline content={item.content} assets={item.assets} storyboard={item.storyboard} canvas={item.canvas} />
<ArrowRight className="project-pipeline-arrow size-4" />
</Link>
))}
</div>
) : <div className="mt-4 overflow-hidden rounded-lg border border-border/75 bg-background/70 p-2"><WorkspaceState icon="projects" compact title="还没有剧情章节" description="添加章节后,这里会显示内容、资产、分镜和画布的制作进度。" /></div>}
) : <div className="project-pipeline-surface p-2"><WorkspaceState icon="projects" compact title="还没有剧情章节" description="添加章节后,这里会显示内容、资产、分镜和画布的制作进度。" /></div>}
</section>
</div>
);
}
function ProjectFact({ label, value, attention = false }: { label: string; value: string; attention?: boolean }) {
return <div className="min-w-0"><dt className="text-[var(--fs-tiny)] text-foreground/36">{label}</dt><dd className={`mt-1 truncate font-medium ${attention ? "text-foreground/85" : "text-foreground/72"}`}>{value}</dd></div>;
return <div className="min-w-0"><dt>{label}</dt><dd className={attention ? "is-attention" : ""}>{value}</dd></div>;
}
function SecondaryAction({ action }: { action: ProjectWorkbenchAction }) {
const Icon = action.tone === "danger" ? CircleAlert : action.tone === "attention" ? Clock3 : CheckCircle2;
return <Link to={action.href} className="group flex min-w-0 items-center gap-2 rounded px-1 py-1.5 text-[var(--fs-label)] text-foreground/52 hover:bg-foreground/[.04] hover:text-foreground"><Icon className={`size-3.5 shrink-0 ${action.tone === "danger" ? "text-foreground/80" : action.tone === "attention" ? "text-foreground/60" : "text-foreground/30"}`} /><span className="min-w-0 flex-1 truncate">{action.title}</span><ArrowRight className="size-3 shrink-0 text-foreground/25 transition group-hover:text-foreground/55" /></Link>;
return <Link to={action.href} className="project-overview-next-item group"><Icon className={`size-3.5 shrink-0 ${action.tone === "danger" ? "text-foreground/80" : action.tone === "attention" ? "text-foreground/60" : "text-foreground/30"}`} /><span className="min-w-0 flex-1 truncate">{action.title}</span><ArrowRight className="size-3 shrink-0 text-foreground/25 transition group-hover:text-foreground/55" /></Link>;
}
function StagePipeline({ content, assets, storyboard, canvas }: { content: ProjectStageCell; assets: ProjectStageCell; storyboard: ProjectStageCell; canvas: ProjectStageCell }) {
const stages = [{ label: "内容", cell: content }, { label: "资产", cell: assets }, { label: "分镜", cell: storyboard }, { label: "画布", cell: canvas }];
return (
<span className="grid min-w-0 grid-cols-2 gap-3 sm:grid-cols-4">
<span className="project-pipeline-stages">
{stages.map(({ label, cell }) => <StageStep key={label} label={label} cell={cell} />)}
</span>
);
}
function StageStep({ label, cell }: { label: string; cell: ProjectStageCell }) {
const bar = cell.state === "completed" ? "bg-foreground/65" : cell.state === "attention" ? "bg-foreground/40" : cell.state === "active" ? "bg-[var(--workspace-accent)]" : "bg-foreground/10";
return (
<span className="min-w-0">
<span className="block text-[var(--fs-tiny)] font-medium text-foreground/34">{label}</span>
<span className={`mt-2 block h-1 rounded-full ${bar}`} />
<span className="mt-1.5 block truncate text-[var(--fs-tiny)] text-foreground/48">{cell.label}</span>
<span className={`project-pipeline-stage is-${cell.state}`}>
<span className="project-pipeline-stage-label">{label}</span>
<span className="project-pipeline-stage-track" />
<span className="project-pipeline-stage-value">{cell.label}</span>
</span>
);
}

View File

@@ -4,11 +4,15 @@ import type { ReactNode } from "react";
import { resourceFileUrl } from "@/services/api/resources";
import type { ProjectAsset } from "@/services/api/projects";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
export function ProjectCharacterCard({ asset, generating, removing, onEdit, onGenerate, onBindImages, onBindVoice, onRemove }: {
import { textValue } from "./shared";
export function ProjectCharacterCard({ asset, generating, removing, onOpen, onEdit, onGenerate, onBindImages, onBindVoice, onRemove }: {
asset: ProjectAsset;
generating: boolean;
removing: boolean;
onOpen: () => void;
onEdit: () => void;
onGenerate: () => void;
onBindImages: () => void;
@@ -23,16 +27,18 @@ export function ProjectCharacterCard({ asset, generating, removing, onEdit, onGe
const voiceStatus = character?.voiceStatus === "ready" ? `声音已绑定 · ${character.voice?.profile.name}` : character?.voiceStatus === "unavailable" ? "声音需处理" : "声音未绑定";
const readinessLabel = character?.visualStatus === "ready" ? character.voiceStatus === "ready" ? "可直接用于生成" : "形象已就绪" : "设定进行中";
return (
<article className="group overflow-hidden rounded-lg border border-border/80 bg-background shadow-[0_10px_30px_rgba(0,0,0,.04)]">
<div className="relative aspect-[3/2] overflow-hidden bg-foreground/[.045]">
{cover ? <img src={resourceFileUrl(cover.resourceId)} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-contain p-1" /> : <div className="grid h-full place-items-center"><span className="grid size-14 place-items-center rounded-lg border border-border/70 bg-background/75 text-foreground/24"><UserRound className="size-7" /></span></div>}
<AssetLibraryCard className="project-character-card">
<AssetLibraryCardMedia className="relative aspect-[3/2] overflow-hidden bg-foreground/[.045]">
<button type="button" className="project-asset-media-button" onClick={onOpen} aria-label={`查看角色卡:${asset.title}`}>
{cover ? <img src={resourceFileUrl(cover.resourceId)} alt={asset.title} loading="lazy" decoding="async" className="h-full w-full object-contain p-1" /> : <div className="grid h-full place-items-center"><span className="grid size-14 place-items-center rounded-lg border border-border/70 bg-background/75 text-foreground/24"><UserRound className="size-7" /></span></div>}
</button>
<div className="absolute inset-x-2 top-2 flex items-center justify-between gap-2">
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[var(--fs-micro)] font-medium text-white">角色卡 · v{character?.version || 1}</span>
<span className="rounded bg-black/60 px-1.5 py-0.5 text-[var(--fs-micro)] text-white">{readinessLabel}</span>
</div>
</div>
</AssetLibraryCardMedia>
<div className="p-3">
<div className="flex items-start justify-between gap-3"><div className="min-w-0"><h3 className="truncate text-sm font-semibold">{asset.title}</h3><p className="mt-0.5 truncate text-[var(--fs-label)] text-foreground/48">{role}</p></div><Button type="text" size="small" className="!h-7 !px-1.5" icon={<Pencil className="size-3.5" />} onClick={onEdit} aria-label={`编辑 ${asset.title}`} /></div>
<div className="flex items-start justify-between gap-3"><button type="button" className="project-character-title-button" onClick={onOpen}><h3 className="truncate text-sm font-semibold">{asset.title}</h3><p className="mt-0.5 truncate text-[var(--fs-label)] text-foreground/48">{role}</p></button><Button type="text" size="small" className="!h-7 !px-1.5" icon={<Pencil className="size-3.5" />} onClick={onEdit} aria-label={`编辑 ${asset.title}`} /></div>
<p className="mt-2 line-clamp-2 min-h-9 text-[var(--fs-label)] leading-[18px] text-foreground/55">{appearance}</p>
<div className="mt-2 grid gap-1.5">
<StatusLine icon={<ImageIcon className="size-3.5" />} ready={character?.visualStatus === "ready"} label={imageStatus} action={character?.visualStatus === "ready" ? "更换" : "初始化"} onClick={character?.visualStatus === "ready" ? onBindImages : onGenerate} />
@@ -42,14 +48,10 @@ export function ProjectCharacterCard({ asset, generating, removing, onEdit, onGe
<Button size="small" className="min-w-0 flex-1" icon={<Sparkles className="size-3.5" />} loading={generating} disabled={removing} onClick={onGenerate}>{character?.visualStatus === "missing" ? "初始化三视图" : "重新生成三视图"}</Button><Popconfirm title="移出项目角色?" description="已有画布或镜头引用时将无法移出。" okText="移出" cancelText="取消" onConfirm={onRemove}><Button type="text" danger size="small" loading={removing} disabled={generating} icon={<Trash2 className="size-3.5" />} aria-label={`移出 ${asset.title}`} /></Popconfirm>
</div>
</div>
</article>
</AssetLibraryCard>
);
}
function StatusLine({ icon, ready, label, action, onClick }: { icon: ReactNode; ready: boolean; label: string; action: string; onClick: () => void }) {
return <div className={`flex min-w-0 items-center gap-2 rounded-md border px-2 py-1.5 text-[var(--fs-tiny)] ${ready ? "border-emerald-500/20 bg-emerald-500/[.06] text-emerald-700 dark:text-emerald-300" : "border-border/70 bg-foreground/[.025] text-foreground/48"}`}><span className="shrink-0">{icon}</span><span className="min-w-0 flex-1 truncate">{label}</span><button type="button" className="shrink-0 font-medium text-[var(--workspace-accent)] hover:underline" onClick={onClick}>{action}</button></div>;
}
function textValue(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}

View File

@@ -119,3 +119,7 @@ export function formatTime(value?: string) {
export function formatCount(value: number) {
return new Intl.NumberFormat("zh-CN").format(value);
}
export function textValue(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}

View File

@@ -121,5 +121,5 @@ 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>;
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>{label}</span></span>;
}

View File

@@ -239,10 +239,10 @@ 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="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>
<article className={`library-card skill-library-card group${skill.is_added ? " is-added" : ""}`}>
<div className="skill-card-top">
<button type="button" className="skill-card-title-button" onClick={onOpen}>
<h3>{skill.skill_name}</h3>
</button>
{skill.is_owner ? (
<Dropdown
@@ -255,33 +255,39 @@ function SkillCard({ skill, loading, onOpen, onAdd, onLike, onEdit, onDelete }:
onClick: ({ key }) => key === "edit" ? onEdit() : onDelete(),
}}
>
<button type="button" aria-label="技能操作" className="-mr-1 inline-flex size-8 shrink-0 items-center justify-center rounded-md text-foreground/42 transition-colors hover:bg-foreground/[.06] hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring">
<button type="button" aria-label="技能操作" className="skill-card-more">
<MoreHorizontal className="size-4" />
</button>
</Dropdown>
) : (
<Tooltip title={`${skill.is_added ? "从我的技能移除" : "加入我的技能"} · ${formatSkillCount(skill.added_count)} 人已加入`}>
<button type="button" disabled={loading} aria-label={skill.is_added ? "从我的技能移除" : "加入我的技能"} className={`-mr-1 inline-flex size-8 shrink-0 items-center justify-center rounded-md transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-wait ${skill.is_added ? "bg-foreground/[.08] text-foreground" : "text-foreground/70 hover:bg-foreground/[.07] hover:text-foreground"}`} onClick={onAdd}>
{loading ? <LoaderCircle className="size-4 animate-spin" /> : skill.is_added ? <Check className="size-4" /> : <Plus className="size-4" />}
</button>
</Tooltip>
)}
) : null}
</div>
<button type="button" className="mt-1 min-h-0 flex-1 rounded-sm text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" onClick={onOpen}>
<p className="line-clamp-3 text-xs leading-5 text-foreground/52">{skill.description || "暂无技能简介"}</p>
<button type="button" className="skill-card-description" onClick={onOpen}>
<p>{skill.description || "暂无技能简介"}</p>
</button>
<div className="mt-3 flex min-w-0 items-center gap-2 border-t border-border/45 pt-3 text-[var(--fs-label)] text-foreground/42">
<button type="button" disabled={loading} className="inline-flex shrink-0 items-center gap-1 rounded-sm transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-wait" aria-label={skill.is_like ? "取消收藏" : "收藏"} onClick={onLike}>
<div className="skill-card-footer">
<button type="button" disabled={loading} className="skill-card-like" aria-label={skill.is_like ? "取消收藏" : "收藏"} onClick={onLike}>
<Heart className={`size-3.5 ${skill.is_like ? "fill-current text-rose-500" : ""}`} />
<span>{formatSkillCount(skill.like_count)}</span>
</button>
<span className="truncate">来自 · {skill.effective_user.name || "未知用户"}</span>
{skill.is_private ? <span className="ml-auto shrink-0 text-foreground/55">仅自己</span> : null}
<span className="skill-card-author">{skill.effective_user.name || "未知用户"}</span>
{skill.is_private ? <span className="skill-card-flag">仅自己</span> : null}
</div>
{/* 加入是这个页面的主行为,给它完整的按钮 + 文案 + 已加入人数,不再藏在角落的加号里。 */}
{skill.is_owner
? <div className="skill-card-action"><span className="skill-card-owner-flag">我创建的</span><span className="skill-card-added-count">{formatSkillCount(skill.added_count)} 人已加入</span></div>
: (
<div className="skill-card-action">
<button type="button" disabled={loading} className={`skill-card-join${skill.is_added ? " is-added" : ""}`} onClick={onAdd}>
{loading ? <LoaderCircle className="size-3.5 animate-spin" /> : skill.is_added ? <Check className="size-3.5" /> : <Plus className="size-3.5" />}
<span>{skill.is_added ? "已加入" : "加入我的技能库"}</span>
</button>
<Tooltip title={`${formatSkillCount(skill.added_count)} 人已加入`}><span className="skill-card-added-count">{formatSkillCount(skill.added_count)}</span></Tooltip>
</div>
)}
</article>
);
}
function SkillSkeleton() {
return <div className="grid grid-cols-1 gap-3 py-6 md:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">{Array.from({ length: 8 }, (_, index) => <div key={index} className="h-[178px] animate-pulse rounded-md border border-border/45 bg-foreground/[.035]" />)}</div>;
return <div className="library-grid skill-library-grid py-6">{Array.from({ length: 8 }, (_, index) => <div key={index} className="h-[218px] animate-pulse rounded-[var(--r-xl)] bg-foreground/[.035]" />)}</div>;
}

View File

@@ -432,10 +432,10 @@ function TaskListRow({ task, canvasById, projectNameById, effectiveConfig, credi
<TaskPreviewThumbnail task={task} onOpen={onPreview} />
<div className="task-record-main">
<div className="task-record-heading">
<span className={`task-record-status ${isFailed ? "is-failed" : isActive ? "is-active" : "is-success"}`}><i className={statusDotClassName(task.status)} />{statusLabel[task.status]}</span>
<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>
<div className="task-record-meta"><span>{formatTaskKind(task)}</span><span aria-hidden="true">·</span><span>{formatModelName(effectiveConfig, task)}</span><span className="task-record-meta-canvas"><FolderKanban className="size-3" />{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>
@@ -531,11 +531,11 @@ function taskEmptyState(status: TaskStatusFilter) {
}
function statusDotClassName(status: TaskStatus) {
if (status === "succeeded") return "bg-emerald-500";
if (status === "running") return "bg-amber-500";
if (status === "queued") return "bg-blue-500";
if (status === "failed") return "bg-red-500";
return "bg-foreground/30";
if (status === "succeeded") return "task-record-dot bg-emerald-500";
if (status === "running") return "task-record-dot is-pulsing bg-amber-500";
if (status === "queued") return "task-record-dot bg-blue-500";
if (status === "failed") return "task-record-dot bg-red-500";
return "task-record-dot bg-foreground/30";
}
function taskMediaKind(task: GenerationTask): Exclude<TaskKindFilter, "all"> {

View File

@@ -105,6 +105,31 @@
--shadow-glow-md: 0 0 24px rgba(125, 162, 255, 0.3);
--shadow-glow-lg: 0 0 40px rgba(125, 162, 255, 0.4);
/* --- 5b. 抬升(overlay / card)---
--shadow-* 在暗色下会翻成白影,浮层用会发光刺眼。以下 token 两个主题都用黑影,
靠 hairline(内描边)而不是发光来交代层级,浮层与卡片统一引用这里。 */
/* 亮色下卡片浮在白底上,hairline 要比暗色更实才看得见边界(暗色靠影,亮色靠线)。 */
--elevation-hairline: inset 0 0 0 1px rgba(17, 17, 17, 0.13);
--elevation-card: 0 1px 2px rgba(0, 0, 0, 0.05);
--elevation-card-hover: 0 10px 26px rgba(0, 0, 0, 0.1), 0 2px 5px rgba(0, 0, 0, 0.05);
--elevation-overlay: 0 24px 60px rgba(0, 0, 0, 0.2), 0 3px 10px rgba(0, 0, 0, 0.09);
--elevation-panel: -18px 0 52px rgba(0, 0, 0, 0.16);
--focus-ring-soft: 0 0 0 3px color-mix(in srgb, var(--ring) 28%, transparent);
/* 卡片表面:亮色需要一点灰底把卡片从白页面里托出来,暗色则是提亮。 */
--surface-card: rgba(17, 17, 17, 0.028);
--surface-card-hover: rgba(17, 17, 17, 0.055);
/* 项目列表卡是高频入口,不能沿用过轻的通用 surface。 */
--project-library-surface: #f5f5f5;
--project-library-cover-surface: #ececec;
--project-library-icon-surface: #ffffff;
--project-library-shadow: 0 8px 20px rgba(0, 0, 0, 0.06);
/* 实心(反相)按钮:不要写 background:var(--foreground) + color:var(--background),
两者在不同层级里解析结果不稳定,会出现同色不可读的按钮。用显式配对 token。 */
--btn-solid-bg: #171717;
--btn-solid-fg: #ffffff;
/* --- 6. Elevation(z-index 14 档,按视觉层划分,见 ADR-001)--- */
--z-canvas: 0; /* L0 画布底 */
--z-canvas-overlay: 5; /* L1 画布覆盖(网格、连线)*/
@@ -502,6 +527,23 @@
--shadow-lg: 0 8px 24px rgba(255, 255, 255, 0.07);
--shadow-xl: 0 16px 40px rgba(255, 255, 255, 0.08);
--shadow-2xl: 0 24px 60px rgba(255, 255, 255, 0.1);
/* 抬升 token:暗色下继续用黑影压深,只把 hairline 换成浅色描边。 */
--elevation-hairline: inset 0 0 0 1px rgba(255, 255, 255, 0.085);
--elevation-card: 0 1px 2px rgba(0, 0, 0, 0.4);
--elevation-card-hover: 0 12px 30px rgba(0, 0, 0, 0.5), 0 2px 6px rgba(0, 0, 0, 0.32);
--elevation-overlay: 0 28px 72px rgba(0, 0, 0, 0.62), 0 4px 14px rgba(0, 0, 0, 0.4);
--elevation-panel: -20px 0 60px rgba(0, 0, 0, 0.56);
--surface-card: rgba(255, 255, 255, 0.035);
--surface-card-hover: rgba(255, 255, 255, 0.062);
--project-library-surface: #151515;
--project-library-cover-surface: #1c1c1c;
--project-library-icon-surface: #0d0d0d;
--project-library-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
--btn-solid-bg: #f5f5f5;
--btn-solid-fg: #131313;
}
/* -------------------------------------------------------------------------
@@ -3230,18 +3272,18 @@
.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-preview { display: grid; aspect-ratio: 16 / 9; place-items: center; overflow: hidden; border-radius: var(--r-2xl); background: var(--surface-card); box-shadow: var(--elevation-card), var(--elevation-hairline); 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 { overflow: hidden; border-radius: var(--r-2xl); background: var(--surface-card); box-shadow: var(--elevation-card), var(--elevation-hairline); 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) var(--r-2xl) 0 0; background: var(--surface-card); }
.app-canvas-project-card.is-library:hover, .app-canvas-project-card.is-library:focus-within { background: var(--surface-card-hover); box-shadow: var(--elevation-card-hover), var(--elevation-hairline); 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 .app-canvas-project-preview { overflow: hidden; border-radius: var(--r-2xl) var(--r-2xl) 0 0; 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; }
@@ -3263,9 +3305,10 @@
.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 { min-height: 90px; border-radius: 0 0 var(--r-2xl) var(--r-2xl); background: var(--surface-card); 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); }
.app-canvas-project-card:hover .app-canvas-project-body { background: var(--surface-card-hover); }
.canvas-project-card-footer { margin-top: 12px; }
.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; }
@@ -3290,7 +3333,7 @@
.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-primary-action { border: 0 !important; border-radius: var(--r-md) !important; background: var(--btn-solid-bg) !important; color: var(--btn-solid-fg) !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,
@@ -3306,84 +3349,264 @@
.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-surface { background: var(--surface-card); box-shadow: var(--elevation-card), var(--elevation-hairline); }
.library-card-surface:hover, .library-card-surface:focus-within { background: var(--surface-card-hover); box-shadow: var(--elevation-card-hover), var(--elevation-hairline); }
.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 { display: grid; aspect-ratio: 16 / 10; place-items: center; overflow: hidden; border-radius: var(--r-2xl); background: var(--surface-card); color: color-mix(in srgb, var(--foreground) 35%, transparent); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 11%, transparent); transition: background-color 320ms ease-out, color 260ms ease-out, box-shadow 420ms var(--motion-ease-out); }
/* 创建卡与同网格里的内容卡共用封面比例,否则首格会矮一截、网格底线对不齐。 */
.project-library-grid .library-create-cover { aspect-ratio: 16 / 9; }
.library-create-card:hover .library-create-cover, .library-create-card:focus-visible .library-create-cover { background: var(--surface-card-hover); color: var(--foreground); box-shadow: var(--elevation-card-hover), var(--elevation-hairline); }
.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); }
.library-create-title { margin-top: 15px; font-size: var(--fs-heading-lg); font-weight: 620; line-height: 1.3; }
.library-create-meta { margin-top: 6px; color: color-mix(in srgb, var(--foreground) 38%, transparent); font-size: var(--fs-caption); }
/* 技能网格里的创建卡要和技能卡等高,不然首格会短一截。 */
.skill-library-grid .library-create-card { justify-content: center; min-height: 218px; border-radius: var(--r-xl); background: var(--surface-card); padding: 16px; box-shadow: var(--elevation-card), var(--elevation-hairline); }
.skill-library-grid .library-create-cover { aspect-ratio: auto; min-height: 96px; flex: 1 1 auto; border-radius: var(--r-lg); }
.skill-library-grid .library-create-title { margin-top: 13px; font-size: var(--fs-body-lg); }
.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; }
/* 列数按内容体量分开定:项目卡是这些页面的主角,6 列会把它挤成缩略图;
资产卡只承载一张图 + 标题,密一点反而好扫。 */
.assets-library-grid { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); }
.project-library-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 30px 22px; align-items: stretch; }
.skill-library-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 16px; }
/* 项目卡是一个完整实体表面;封面只是顶部内容区,避免透明主体和内嵌细线造成「镂空」感。 */
.project-library-card { display: block; border-radius: var(--r-lg); background: var(--project-library-surface) !important; box-shadow: var(--project-library-shadow); transition: none; }
.project-library-card:hover, .project-library-card:focus-within { background: var(--project-library-surface) !important; box-shadow: var(--project-library-shadow); }
.project-library-card:hover, .project-library-card:focus-within, .project-library-grid .library-create-card:hover, .project-library-grid .library-create-card:focus-visible { transform: none; }
.project-library-card:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.project-library-grid .library-create-cover, .project-library-grid .library-create-card:hover .library-create-cover, .project-library-grid .library-create-card:focus-visible .library-create-cover { border-radius: var(--r-lg); background: var(--project-library-cover-surface); box-shadow: none; }
.project-library-grid .library-create-card:hover .library-create-cover svg, .project-library-grid .library-create-card:focus-visible .library-create-cover svg { transform: none; }
.project-library-cover { position: relative; display: flex; aspect-ratio: 16 / 9; align-items: center; justify-content: center; overflow: hidden; border-radius: var(--r-lg) var(--r-lg) 0 0; background: var(--project-library-cover-surface) !important; }
.library-card:hover .project-library-cover { background: var(--project-library-cover-surface) !important; }
.project-library-cover-icon { position: relative; z-index: 1; display: grid; place-items: center; width: 56px; height: 56px; border-radius: var(--r-md); background: var(--project-library-icon-surface); color: color-mix(in srgb, var(--foreground) 58%, transparent); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18); }
.project-library-cover-icon svg { width: 26px; height: 26px; }
.project-library-cover-ratio, .project-library-cover-stage { position: absolute; z-index: 1; bottom: 14px; border-radius: var(--r-full); background: color-mix(in srgb, var(--background) 62%, transparent); padding: 5px 10px; color: color-mix(in srgb, var(--foreground) 74%, transparent); font-size: var(--fs-tiny); backdrop-filter: blur(8px); }
.project-library-cover-ratio { left: 14px; font-variant-numeric: tabular-nums; }
.project-library-cover-stage { right: 14px; }
.project-library-body { display: block; padding: 15px 14px 16px; }
.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 strong { min-width: 0; overflow: hidden; font-size: var(--fs-heading-lg); 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-subtitle { display: block; margin-top: 6px; 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: 13px; color: color-mix(in srgb, var(--foreground) 45%, transparent); font-size: var(--fs-label); }
.project-library-progress > span { display: flex; justify-content: space-between; gap: 8px; font-variant-numeric: tabular-nums; }
.project-library-progress i { display: block; height: 4px; margin-top: 7px; 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); }
.project-library-stats { display: flex; align-items: center; gap: 14px; margin-top: 12px; color: color-mix(in srgb, var(--foreground) 40%, transparent); font-size: var(--fs-label); }
.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; }
/* 任务列表:整屏几十条长 prompt 极易糊成一片,靠三件事拉开密度——
行距(min-height + gap)、标题只给一行且宽度封顶、次要信息降到 42% 前景色。 */
.task-record-list { display: grid; gap: 2px; margin-top: 10px; }
.task-record-row { position: relative; display: grid; min-width: 0; grid-template-columns: 84px minmax(0, 1fr) 120px 104px 104px; align-items: center; gap: 18px; min-height: 92px; border-radius: var(--r-lg); background: transparent; padding: 12px 14px; transition: background-color 180ms ease-out; }
.task-record-row::after { position: absolute; inset-inline: 14px; bottom: 0; height: 1px; background: color-mix(in srgb, var(--foreground) 6%, transparent); content: ""; }
.task-record-row:last-child::after { display: none; }
.task-record-row:hover, .task-record-row:focus-within { background: color-mix(in srgb, var(--foreground) 4%, transparent); }
.task-record-row:hover::after, .task-record-row:focus-within::after { opacity: 0; }
.task-record-row > .group { width: 84px; height: 60px; }
.task-record-row > span:not(.task-record-billing-empty) { width: 84px; height: 60px; border: 0; border-radius: var(--r-md); }
.task-record-row > .group img, .task-record-row > .group video { height: 60px; width: 84px; 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-heading { display: flex; min-width: 0; align-items: baseline; gap: 10px; overflow: hidden; }
/* 标题封顶 68ch:超宽屏下一行能塞 200+ 汉字,读者反而找不到落点。 */
.task-record-title { display: block; min-width: 0; max-width: 68ch; flex: 0 1 auto; overflow: hidden; border: 0; background: transparent; padding: 0; color: color-mix(in srgb, var(--foreground) 92%, transparent); text-align: left; font-size: var(--fs-body); font-weight: 560; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
.task-record-title:hover { color: var(--foreground); }
.task-record-status { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 6px; border-radius: var(--r-full); background: color-mix(in srgb, var(--foreground) 5%, transparent); padding: 3px 9px 3px 7px; color: color-mix(in srgb, var(--foreground) 52%, transparent); font-size: var(--fs-tiny); line-height: 1.4; white-space: nowrap; }
.task-record-dot { display: block; width: 5px; height: 5px; flex: 0 0 auto; border-radius: var(--r-full); }
.task-record-dot.is-pulsing { animation: task-record-dot-pulse 1.8s ease-in-out infinite; }
@keyframes task-record-dot-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
@media (prefers-reduced-motion: reduce) { .task-record-dot.is-pulsing { animation: none; } }
.task-record-status.is-failed { background: color-mix(in srgb, var(--palette-status-error) 12%, transparent); color: color-mix(in srgb, var(--palette-status-error) 74%, var(--foreground)); }
.task-record-status.is-active { background: color-mix(in srgb, var(--palette-status-warning) 12%, transparent); color: color-mix(in srgb, var(--palette-status-warning) 74%, var(--foreground)); }
.task-record-meta { display: flex; min-width: 0; align-items: center; gap: 8px; margin-top: 7px; overflow: hidden; color: color-mix(in srgb, var(--foreground) 40%, transparent); font-size: var(--fs-label); white-space: nowrap; }
.task-record-meta-canvas { display: inline-flex; min-width: 0; align-items: center; gap: 4px; overflow: hidden; border-left: 1px solid color-mix(in srgb, var(--foreground) 11%, transparent); padding-left: 8px; text-overflow: ellipsis; }
.task-record-meta-canvas svg { flex: 0 0 auto; opacity: .7; }
.task-record-progress { display: grid; max-width: 420px; grid-template-columns: minmax(80px, 1fr) 42px; align-items: center; gap: 10px; margin-top: 8px; color: color-mix(in srgb, var(--foreground) 45%, transparent); font-size: var(--fs-label); }
.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-error { display: -webkit-box; max-width: 68ch; margin: 7px 0 0; overflow: hidden; color: color-mix(in srgb, var(--palette-status-error) 72%, var(--foreground)); font-size: var(--fs-label); line-height: 1.5; -webkit-box-orient: vertical; -webkit-line-clamp: 1; }
.task-record-date { min-width: 0; color: color-mix(in srgb, var(--foreground) 38%, transparent); }
.task-record-date-value { display: block; overflow: hidden; font-size: var(--fs-label); 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); }
/* 积分列去掉图标 glow:一屏 20 行同时发光比正文还抢眼。 */
.task-record-billing { color: color-mix(in srgb, var(--palette-status-warning) 52%, var(--foreground)); }
.task-record-billing > svg { width: 14px; height: 14px; opacity: .72; }
.task-record-billing > span { display: flex; flex-direction: column; align-items: flex-end; gap: 1px; }
.task-record-billing strong { color: color-mix(in srgb, var(--foreground) 82%, transparent); font-size: var(--fs-caption); font-weight: 560; font-variant-numeric: tabular-nums; }
.task-record-billing small { color: color-mix(in srgb, var(--foreground) 36%, transparent); font-size: var(--fs-tiny); }
.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-actions .ant-btn { width: 32px; height: 32px; 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); }
/* 项目概览:原本是「有边框的浅色盒子 + 一堆等重小字」,层级读不出来。
改成:主区留白撑开、标题字阶拉高、右栏用背景色而非边框分隔、流水线行给足行高。 */
.project-overview-focus { overflow: hidden; border-radius: var(--r-xl); background: var(--surface-card); box-shadow: var(--elevation-card), var(--elevation-hairline); }
.project-overview-primary { min-width: 0; padding: 30px 32px 32px; }
.project-overview-eyebrow { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; color: color-mix(in srgb, var(--foreground) 44%, transparent); font-size: var(--fs-label); }
.project-overview-eyebrow > span:first-child { color: color-mix(in srgb, var(--foreground) 72%, transparent); font-weight: 560; letter-spacing: .02em; }
.project-overview-eyebrow-divider { color: color-mix(in srgb, var(--foreground) 18%, transparent); }
.project-overview-eyebrow-badge { border-radius: var(--r-full); background: color-mix(in srgb, var(--palette-status-warning) 14%, transparent); padding: 3px 9px; color: color-mix(in srgb, var(--palette-status-warning) 74%, var(--foreground)); font-size: var(--fs-tiny); font-weight: 560; }
.project-overview-title { max-width: 22ch; margin-top: 14px; color: var(--foreground); font-size: 30px; font-weight: 640; line-height: 1.24; letter-spacing: -.015em; text-wrap: balance; }
.project-overview-description { max-width: 62ch; margin-top: 10px; color: color-mix(in srgb, var(--foreground) 50%, transparent); font-size: var(--fs-body); line-height: 1.65; text-wrap: pretty; }
.project-overview-cta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 26px; }
.project-overview-cta-primary { display: inline-flex; max-width: 100%; height: 40px; align-items: center; gap: 8px; border-radius: var(--r-md); background: var(--btn-solid-bg); padding-inline: 18px; color: var(--btn-solid-fg); font-size: var(--fs-body); font-weight: 560; transition: opacity 160ms ease-out; }
.project-overview-cta-primary:hover { color: var(--btn-solid-fg); opacity: .86; }
.project-overview-cta-primary:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.project-overview-cta-secondary { display: inline-flex; height: 40px; align-items: center; gap: 6px; border-radius: var(--r-md); padding-inline: 12px; color: color-mix(in srgb, var(--foreground) 50%, transparent); font-size: var(--fs-caption); font-weight: 500; transition: background-color 160ms ease-out, color 160ms ease-out; }
.project-overview-cta-secondary:hover { background: color-mix(in srgb, var(--foreground) 5%, transparent); color: var(--foreground); }
.project-overview-status { border-top: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent); background: color-mix(in srgb, var(--foreground) 2.5%, transparent); padding: 26px 26px 28px; }
.project-overview-status-label { display: block; color: color-mix(in srgb, var(--foreground) 38%, transparent); font-size: var(--fs-label); font-weight: 500; }
.project-overview-progress-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.project-overview-progress-percent { color: color-mix(in srgb, var(--foreground) 46%, transparent); font-size: var(--fs-caption); font-variant-numeric: tabular-nums; }
.project-overview-progress-count { margin-top: 6px; color: var(--foreground); font-size: 26px; font-weight: 620; line-height: 1.15; font-variant-numeric: tabular-nums; letter-spacing: -.01em; }
.project-overview-progress-count span { margin-left: 5px; color: color-mix(in srgb, var(--foreground) 30%, transparent); font-size: var(--fs-body); font-weight: 400; }
.project-overview-progress-track { height: 5px; margin-top: 12px; overflow: hidden; border-radius: var(--r-full); background: color-mix(in srgb, var(--foreground) 9%, transparent); }
.project-overview-progress-track > div { height: 100%; border-radius: inherit; background: var(--workspace-accent); transition: width 420ms var(--motion-ease-out); }
.project-overview-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px 18px; margin-top: 24px; }
.project-overview-facts dt { color: color-mix(in srgb, var(--foreground) 36%, transparent); font-size: var(--fs-label); }
.project-overview-facts dd { margin-top: 4px; overflow: hidden; color: color-mix(in srgb, var(--foreground) 74%, transparent); font-size: var(--fs-caption); font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.project-overview-facts dd.is-attention { color: var(--foreground); }
.project-overview-next { margin-top: 24px; border-top: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent); padding-top: 18px; }
.project-overview-next-item { display: flex; min-width: 0; align-items: center; gap: 8px; border-radius: var(--r-sm); padding: 6px 6px 6px 2px; color: color-mix(in srgb, var(--foreground) 52%, transparent); font-size: var(--fs-caption); transition: background-color 160ms ease-out, color 160ms ease-out; }
.project-overview-next-item:hover { background: color-mix(in srgb, var(--foreground) 4%, transparent); color: var(--foreground); }
.project-pipeline-head { display: flex; flex-direction: column; gap: 10px; }
.project-pipeline-title { color: var(--foreground); font-size: var(--fs-heading-lg); font-weight: 620; }
.project-pipeline-hint { margin-top: 5px; color: color-mix(in srgb, var(--foreground) 44%, transparent); font-size: var(--fs-caption); line-height: 1.6; }
.project-pipeline-more { display: inline-flex; height: 32px; align-items: center; gap: 6px; align-self: flex-start; border-radius: var(--r-sm); color: color-mix(in srgb, var(--foreground) 48%, transparent); font-size: var(--fs-caption); font-weight: 500; transition: color 160ms ease-out; }
.project-pipeline-more:hover { color: var(--foreground); }
.project-pipeline-surface { margin-top: 16px; overflow: hidden; border-radius: var(--r-xl); background: var(--surface-card); box-shadow: var(--elevation-card), var(--elevation-hairline); }
.project-pipeline-row { position: relative; display: grid; gap: 16px; padding: 18px 22px; transition: background-color 160ms ease-out; }
.project-pipeline-row::after { position: absolute; inset-inline: 22px; bottom: 0; height: 1px; background: color-mix(in srgb, var(--foreground) 6%, transparent); content: ""; }
.project-pipeline-row:last-child::after { display: none; }
.project-pipeline-row:hover { background: color-mix(in srgb, var(--foreground) 3.5%, transparent); }
.project-pipeline-row:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
.project-pipeline-chapter { display: flex; min-width: 0; align-items: center; gap: 12px; }
.project-pipeline-index { display: grid; width: 34px; height: 34px; flex: 0 0 auto; place-items: center; border-radius: var(--r-md); background: color-mix(in srgb, var(--foreground) 6%, transparent); color: color-mix(in srgb, var(--foreground) 48%, transparent); font-size: var(--fs-label); font-weight: 620; font-variant-numeric: tabular-nums; }
.project-pipeline-chapter-title { display: block; overflow: hidden; color: color-mix(in srgb, var(--foreground) 88%, transparent); font-size: var(--fs-body); font-weight: 560; text-overflow: ellipsis; white-space: nowrap; }
.project-pipeline-chapter-time { display: block; margin-top: 3px; color: color-mix(in srgb, var(--foreground) 34%, transparent); font-size: var(--fs-label); }
.project-pipeline-arrow { flex: 0 0 auto; color: color-mix(in srgb, var(--foreground) 20%, transparent); transition: transform 220ms var(--motion-ease-out), color 160ms ease-out; }
.project-pipeline-row:hover .project-pipeline-arrow { color: color-mix(in srgb, var(--foreground) 55%, transparent); transform: translateX(2px); }
.project-pipeline-stages { display: grid; min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px 18px; }
.project-pipeline-stage { min-width: 0; }
.project-pipeline-stage-label { display: block; color: color-mix(in srgb, var(--foreground) 34%, transparent); font-size: var(--fs-label); font-weight: 500; }
.project-pipeline-stage-track { display: block; height: 3px; margin-top: 8px; border-radius: var(--r-full); background: color-mix(in srgb, var(--foreground) 9%, transparent); }
.project-pipeline-stage.is-completed .project-pipeline-stage-track { background: color-mix(in srgb, var(--foreground) 58%, transparent); }
.project-pipeline-stage.is-attention .project-pipeline-stage-track { background: color-mix(in srgb, var(--palette-status-warning) 62%, transparent); }
.project-pipeline-stage.is-active .project-pipeline-stage-track { background: var(--workspace-accent); }
.project-pipeline-stage-value { display: block; margin-top: 7px; overflow: hidden; color: color-mix(in srgb, var(--foreground) 48%, transparent); font-size: var(--fs-label); text-overflow: ellipsis; white-space: nowrap; }
.project-pipeline-stage.is-completed .project-pipeline-stage-value, .project-pipeline-stage.is-active .project-pipeline-stage-value { color: color-mix(in srgb, var(--foreground) 66%, transparent); }
@media (min-width: 640px) {
.project-pipeline-head { flex-direction: row; align-items: flex-end; justify-content: space-between; }
.project-pipeline-more { align-self: auto; }
.project-pipeline-stages { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (min-width: 1024px) {
.project-overview-status { border-top: 0; border-left: 1px solid color-mix(in srgb, var(--foreground) 7%, transparent); }
.project-pipeline-row { grid-template-columns: minmax(220px, .8fr) minmax(420px, 1.3fr) 20px; align-items: center; gap: 24px; }
}
@media (max-width: 639px) {
.project-overview-primary { padding: 22px 20px 24px; }
.project-overview-title { max-width: none; font-size: 24px; }
.project-overview-status { padding: 22px 20px 24px; }
.project-overview-cta-primary { width: 100%; justify-content: center; }
.project-pipeline-row { padding: 16px; }
.project-pipeline-row::after { inset-inline: 16px; }
.project-pipeline-arrow { display: none; }
}
/* 全局素材库需要等高;项目资产页按内容自然高度,角色设定再多也不拉伸媒体卡。 */
.assets-library-page .assets-library-grid { align-items: stretch; }
.assets-library-page .assets-library-grid > .asset-library-card, .assets-library-page .assets-library-grid > .library-create-card { height: 100%; }
.assets-library-page .assets-library-grid > .asset-library-card { display: flex; flex-direction: column; }
.assets-library-page .assets-library-grid > .asset-library-card > button:last-child { flex: 1 1 auto; }
.assets-library-page .asset-library-card > .library-card-media, .assets-library-page .library-create-cover { aspect-ratio: 4 / 3; flex: 0 0 auto; }
.assets-library-page .asset-library-card > .library-card-media > button:first-child { height: 100%; }
.assets-library-page .asset-library-card > .library-card-media > button:first-child > :where(img, video) { height: 100%; aspect-ratio: auto; }
.assets-library-page .library-create-card { padding-bottom: var(--space-4); }
.project-assets-grid { align-items: start; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 18px; }
.project-assets-grid > .asset-library-card { min-height: 0; height: auto; }
.asset-library-card { overflow: hidden; }
.asset-library-card > div:first-child { border-radius: var(--r-2xl); background: var(--workspace-surface-strong); }
.asset-library-card > div:first-child { border-radius: var(--r-2xl) var(--r-2xl) 0 0; 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; }
.project-asset-media-button { position: relative; display: block; width: 100%; height: 100%; cursor: pointer; padding: 0; text-align: left; }
.project-asset-media-button:focus-visible, .project-asset-title-button:focus-visible { outline: 2px solid var(--ring); outline-offset: -2px; }
.project-asset-title-button { display: flex; min-width: 0; align-items: center; justify-content: space-between; gap: 8px; width: 100%; cursor: pointer; border: 0; background: transparent; padding: 0; color: var(--foreground); text-align: left; }
.project-character-card { display: flex; flex-direction: column; height: auto; }
.project-character-title-button { min-width: 0; flex: 1 1 auto; cursor: pointer; border: 0; background: transparent; padding: 0; color: var(--foreground); text-align: left; }
.project-character-title-button:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.project-asset-library-card > div:last-child { display: block; }
.project-asset-preview-modal .ant-modal-body { padding-top: 8px; }
.project-asset-preview-layout { display: grid; grid-template-columns: minmax(0, 1.15fr) minmax(220px, .85fr); align-items: start; gap: 22px; }
.project-asset-preview-stage { display: grid; min-width: 0; min-height: 0; aspect-ratio: 4 / 3; place-items: center; overflow: hidden; border-radius: var(--r-lg); background: color-mix(in srgb, var(--foreground) 5%, transparent); }
.project-asset-preview-stage.is-video { aspect-ratio: 16 / 9; }
.project-asset-preview-stage.is-audio { min-height: 180px; aspect-ratio: auto; padding: 20px; }
.project-asset-preview-stage.is-text { min-height: 280px; aspect-ratio: auto; align-items: start; }
.project-asset-preview-media { display: block; width: 100%; height: 100%; min-height: 0; object-fit: contain; }
.project-asset-preview-audio { width: 100%; }
.project-asset-preview-text { width: 100%; max-height: 360px; overflow-y: auto; padding: 20px; color: color-mix(in srgb, var(--foreground) 72%, transparent); font-size: var(--fs-body); line-height: 1.7; white-space: pre-wrap; }
.project-asset-preview-details { min-width: 0; padding: 4px 4px 0 0; }
.project-asset-preview-eyebrow { color: var(--workspace-accent); font-size: var(--fs-label); font-weight: 620; }
.project-asset-preview-title { margin-top: 8px; overflow-wrap: anywhere; color: var(--foreground); font-size: var(--fs-heading-lg); font-weight: 620; line-height: 1.4; }
.project-asset-preview-meta { margin-top: 7px; color: color-mix(in srgb, var(--foreground) 46%, transparent); font-size: var(--fs-caption); }
.project-asset-preview-sections { display: grid; gap: 15px; margin-top: 24px; }
.project-asset-preview-sections section { min-width: 0; }
.project-asset-preview-sections section > span { color: color-mix(in srgb, var(--foreground) 42%, transparent); font-size: var(--fs-label); }
.project-asset-preview-sections section > p { margin-top: 5px; color: color-mix(in srgb, var(--foreground) 74%, transparent); font-size: var(--fs-body); line-height: 1.6; }
.project-asset-preview-status { border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent); padding-top: 13px; color: color-mix(in srgb, var(--foreground) 52%, transparent); font-size: var(--fs-caption); }
.project-asset-preview-facts { display: flex; flex-wrap: wrap; gap: 8px 16px; margin-top: 24px; color: color-mix(in srgb, var(--foreground) 52%, transparent); font-size: var(--fs-caption); }
.project-asset-preview-facts strong { color: color-mix(in srgb, var(--foreground) 78%, transparent); font-weight: 620; }
@media (max-width: 680px) {
.project-asset-preview-layout { grid-template-columns: 1fr; gap: 16px; }
.project-asset-preview-stage { max-height: 52vh; }
.project-asset-preview-details { padding-right: 0; }
}
.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); }
/* 技能卡:原本是裸文字块,边界只靠一条小色条暗示,扫视时读不出「这是一张卡」。
改成真实表面(背景 + hairline),并把加入按钮提成卡底的显性主操作。 */
.skill-library-card { display: flex; min-height: 218px; flex-direction: column; border-radius: var(--r-xl); background: var(--surface-card); padding: 16px 16px 14px; box-shadow: var(--elevation-card), var(--elevation-hairline); transition: transform 300ms var(--motion-ease-out), background-color 200ms ease-out, box-shadow 300ms ease-out; }
.skill-library-card:hover, .skill-library-card:focus-within { background: var(--surface-card-hover); box-shadow: var(--elevation-card-hover), var(--elevation-hairline); transform: translateY(-3px); }
.skill-library-card.is-added { box-shadow: var(--elevation-card), inset 0 0 0 1px color-mix(in srgb, var(--foreground) 26%, transparent); }
.skill-library-card.is-added:hover { box-shadow: var(--elevation-card-hover), inset 0 0 0 1px color-mix(in srgb, var(--foreground) 32%, transparent); }
.skill-card-top { display: flex; min-height: 0; align-items: flex-start; gap: 8px; }
.skill-card-title-button { min-width: 0; flex: 1 1 auto; border: 0; background: transparent; padding: 0; color: inherit; text-align: left; }
.skill-card-title-button:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; border-radius: var(--r-xs); }
.skill-library-card h3 { display: -webkit-box; overflow: hidden; color: var(--foreground); font-size: var(--fs-body-lg); font-weight: 620; line-height: 1.4; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.skill-card-more { display: inline-flex; width: 28px; height: 28px; flex: 0 0 auto; align-items: center; justify-content: center; margin: -3px -5px 0 0; border: 0; border-radius: var(--r-sm); background: transparent; color: color-mix(in srgb, var(--foreground) 40%, transparent); opacity: 0; transition: opacity 180ms ease-out, background-color 160ms ease-out; }
.skill-library-card:hover .skill-card-more, .skill-card-more:focus-visible, .skill-library-card:focus-within .skill-card-more { opacity: 1; }
.skill-card-more:hover { background: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); }
.skill-card-description { min-height: 0; flex: 1 1 auto; margin-top: 8px; border: 0; background: transparent; padding: 0; text-align: left; }
.skill-card-description:focus-visible { outline: 2px solid var(--ring); outline-offset: 3px; border-radius: var(--r-xs); }
.skill-card-description p { display: -webkit-box; overflow: hidden; color: color-mix(in srgb, var(--foreground) 50%, transparent); font-size: var(--fs-caption); line-height: 1.6; -webkit-box-orient: vertical; -webkit-line-clamp: 3; }
.skill-card-footer { display: flex; min-width: 0; align-items: center; gap: 10px; margin-top: 12px; color: color-mix(in srgb, var(--foreground) 40%, transparent); font-size: var(--fs-label); }
.skill-card-like { display: inline-flex; flex: 0 0 auto; align-items: center; gap: 4px; border: 0; background: transparent; padding: 0; color: inherit; font-variant-numeric: tabular-nums; transition: color 160ms ease-out; }
.skill-card-like:hover { color: var(--foreground); }
.skill-card-like:disabled { cursor: wait; }
.skill-card-author { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.skill-card-flag { margin-left: auto; flex: 0 0 auto; color: color-mix(in srgb, var(--foreground) 52%, transparent); }
.skill-card-action { display: flex; align-items: center; gap: 10px; margin-top: 12px; border-top: 1px solid color-mix(in srgb, var(--foreground) 8%, transparent); padding-top: 12px; }
.skill-card-join { display: inline-flex; min-width: 0; height: 32px; flex: 1 1 auto; align-items: center; justify-content: center; gap: 6px; overflow: hidden; border: 1px solid color-mix(in srgb, var(--foreground) 34%, transparent); border-radius: var(--r-md); background: transparent; padding-inline: 12px; color: var(--foreground); font-size: var(--fs-caption); font-weight: 560; white-space: nowrap; transition: border-color 160ms ease-out, background-color 160ms ease-out, color 160ms ease-out; }
.skill-card-join svg { color: currentColor; }
.skill-card-join:hover { border-color: color-mix(in srgb, var(--foreground) 58%, transparent); background: color-mix(in srgb, var(--foreground) 7%, transparent); color: var(--foreground); }
.skill-card-join:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.skill-card-join:disabled { cursor: wait; opacity: .6; }
.skill-card-join.is-added { border-color: color-mix(in srgb, var(--foreground) 18%, transparent); background: color-mix(in srgb, var(--foreground) 8%, transparent); color: color-mix(in srgb, var(--foreground) 66%, transparent); }
.skill-card-join.is-added:hover { border-color: color-mix(in srgb, var(--foreground) 30%, transparent); background: color-mix(in srgb, var(--foreground) 12%, transparent); color: var(--foreground); }
.skill-card-added-count { flex: 0 0 auto; color: color-mix(in srgb, var(--foreground) 36%, transparent); font-size: var(--fs-label); font-variant-numeric: tabular-nums; }
.skill-card-owner-flag { flex: 1 1 auto; color: color-mix(in srgb, var(--foreground) 46%, transparent); font-size: var(--fs-caption); }
.wallet-library-page .app-page-header { padding-bottom: 0; }
.wallet-library-page > div { max-width: 1520px; }
@@ -3394,8 +3617,8 @@
.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); }
.task-library-page .library-loading-grid { grid-template-columns: 1fr; gap: 2px; }
.task-library-page .library-loading-grid .library-skeleton { height: 92px; aspect-ratio: auto; border-radius: var(--r-lg); }
@keyframes library-pulse { 0%, 100% { opacity: .5; } 50% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { .library-skeleton { animation: none; } }
@@ -3406,20 +3629,35 @@
.canvas-library-filters { grid-column: 1; grid-row: 2; }
}
@media (min-width: 1921px) {
.project-library-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); }
}
@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)); }
.assets-library-grid, .library-loading-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); }
.project-assets-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.project-library-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.skill-library-grid { grid-template-columns: repeat(4, 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)); }
.assets-library-grid, .library-loading-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.project-assets-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.project-library-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.skill-library-grid { grid-template-columns: repeat(3, 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)); }
.assets-library-grid, .library-loading-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.project-assets-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.project-library-grid, .skill-library-grid { grid-template-columns: repeat(2, 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; }
.project-library-grid, .project-assets-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 { grid-template-columns: 60px minmax(0, 1fr) 64px 92px; align-items: start; gap: 12px; min-height: 0; padding: 12px 8px; }
.task-record-row > .group, .task-record-row > span:not(.task-record-billing-empty) { width: 60px; height: 44px; }
.task-record-row::after { inset-inline: 8px; }
.task-record-heading { flex-wrap: wrap; gap: 6px 8px; }
.task-record-title { max-width: none; flex: 1 1 100%; white-space: normal; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; }
.task-record-meta-canvas { border-left: 0; padding-left: 0; }
.task-record-date { display: none; }
.task-record-billing { min-width: 64px; }
.task-record-actions { opacity: 1; transform: none; }
@@ -6713,9 +6951,10 @@ body.app-spatial-overlays :where(.ant-modal-mask, .ant-drawer-mask) {
overflow: hidden;
border: 0 !important;
border-radius: var(--r-2xl) !important;
background: color-mix(in srgb, var(--popover) 94%, var(--workspace-accent) 6%) !important;
background: var(--popover) !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;
/* 浮层用黑影 + hairline 交代层级,不叠 var(--foreground) 发光(暗色下会刺眼)。 */
box-shadow: var(--elevation-overlay), var(--elevation-hairline) !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; }
@@ -6727,23 +6966,24 @@ body.app-spatial-overlays :where(.ant-modal-mask, .ant-drawer-mask) {
.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 :where(.ant-input:hover, .ant-input-affix-wrapper:hover, .ant-select-selector:hover) { border-color: color-mix(in srgb, var(--foreground) 26%, transparent) !important; }
/* 聚焦态:--workspace-accent 暗色下是 #f5f5f5,直接描边会形成刺眼白圈,降到半透明。 */
.library-modal :where(.ant-input:focus, .ant-input-affix-wrapper-focused, .ant-select-focused .ant-select-selector) { border-color: color-mix(in srgb, var(--workspace-accent) 46%, transparent) !important; box-shadow: 0 0 0 3px color-mix(in srgb, var(--workspace-accent) 10%, transparent) !important; }
.library-modal .ant-btn-primary { border: 0; border-radius: var(--r-md); background: var(--btn-solid-bg); color: var(--btn-solid-fg); 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-content { overflow: hidden; border: 0 !important; background: var(--popover); box-shadow: var(--elevation-panel) !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-drawer-extra .ant-btn { border: 0; border-radius: var(--r-md); background: var(--btn-solid-bg); color: var(--btn-solid-fg); 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 :where(.ant-input:hover, .ant-input-affix-wrapper:hover, .ant-select-selector:hover) { border-color: color-mix(in srgb, var(--foreground) 26%, transparent) !important; }
.library-drawer :where(.ant-input:focus, .ant-input-affix-wrapper-focused, .ant-select-focused .ant-select-selector) { border-color: color-mix(in srgb, var(--workspace-accent) 46%, transparent) !important; box-shadow: 0 0 0 3px color-mix(in srgb, var(--workspace-accent) 10%, 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); }