auto-save 2026-05-18 21:03 (+1, ~3)

This commit is contained in:
2026-05-18 21:03:11 +08:00
parent 32620af91d
commit 73e8ffecc6
4 changed files with 839 additions and 20 deletions

View File

@@ -1,19 +1,5 @@
{
"entries": [
{
"files_changed": 1,
"hash": "59687a5",
"message": "auto-save 2026-05-16 15:37 (~1)",
"ts": "2026-05-16T15:37:58+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "3ee9dc8",
"message": "auto-save 2026-05-16 15:43 (~1)",
"ts": "2026-05-16T15:43:44+08:00",
"type": "commit"
},
{
"files_changed": 1,
"hash": "258bc10",
@@ -3213,6 +3199,19 @@
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 2 项未提交变更 · 最近提交auto-save 2026-05-18 20:51 (~2)",
"files_changed": 2
},
{
"ts": "2026-05-18T20:57:23+08:00",
"type": "commit",
"message": "auto-save 2026-05-18 20:57 (~3)",
"hash": "32620af",
"files_changed": 3
},
{
"ts": "2026-05-18T13:02:20Z",
"type": "session-heartbeat",
"message": "Codex 会话活跃 · 最近命令codex · 分支 main · 3 项未提交变更 · 最近提交auto-save 2026-05-18 20:57 (~3)",
"files_changed": 3
}
]
}

View File

@@ -6056,6 +6056,49 @@ def save_subject_template(job_id: str, req: SaveSubjectTemplateReq) -> SubjectTe
)
items = [item] + [existing for existing in load_subject_template_items() if existing.id != item.id]
save_subject_template_items(items)
try:
library_id = f"lib_subjects_{uuid.uuid4().hex[:12]}"
library_dir = _asset_library_item_dir("subjects", library_id)
library_images: list[AssetLibraryImage] = []
for image in images:
src = SUBJECT_TEMPLATE_IMAGE_DIR / image.filename
if not src.exists():
continue
view = re.sub(r"[^a-zA-Z0-9_-]+", "_", image.view or image.id).strip("_") or image.id
dst = library_dir / "images" / f"{view}.jpg"
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
width, height = _library_media_size(dst)
library_images.append(AssetLibraryImage(
id=view,
view=image.view,
label=image.label or image.view,
filename=f"images/{view}.jpg",
width=width or image.width,
height=height or image.height,
created_at=image.created_at or now,
))
if library_images:
library_item = AssetLibraryItem(
id=library_id,
kind="subjects",
name=name,
name_zh=name,
note=req.note.strip(),
tags=["主体模板"],
source_job_id=job_id,
prompt_brief=prompt_brief,
prompt_brief_zh=prompt_brief_zh,
subject_style=req.subject_style,
images=library_images,
views=library_images,
created_at=now,
updated_at=now,
)
_hydrate_asset_library_urls(library_item)
_write_asset_item(library_item)
except Exception as e:
print(f"[asset library] subject template mirror failed: {e}", flush=True)
return item

View File

@@ -3,7 +3,7 @@
import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom"
import {
AlertTriangle, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2,
AlertTriangle, BookOpen, Check, ChevronDown, Circle, Film, FileText, Image as ImageIcon, Info, Link2, Loader2,
Mic, Moon, Package, PanelRight, Play, Plus, RefreshCw, Scissors, Sparkles, Sun, Trash2, Upload, Wand2,
} from "lucide-react"
import { toast } from "sonner"
@@ -13,6 +13,8 @@ import {
type FrameObject,
type GeneratedVideo,
type ImageRef,
type AssetLibraryItem,
type AssetLibraryKind,
type CharacterLibraryItem,
type SubjectTemplateItem,
type Job,
@@ -31,6 +33,8 @@ import {
analyzeProductViews,
apiAssetUrl,
characterLibraryImageUrl,
createAssetLibraryItem,
createPromptLibraryItem,
cutoutElement,
deleteSubjectAsset,
effectiveFrameUrl,
@@ -60,6 +64,7 @@ import {
import { type NodeData } from "@/components/nodes"
import { MediaAssetTile } from "@/components/media-asset-tile"
import { AnimatedLoginCharacters } from "@/components/login/animated-login-characters"
import { LibraryDrawer } from "@/components/resource-library/library-drawer"
const TARGETS: Array<{ value: FrameExtractTarget; label: string }> = [
{ value: "balanced", label: "综合" },
@@ -1705,6 +1710,7 @@ export function AdRecreationBoard({
const [generatingAll, setGeneratingAll] = useState(false)
const [runtimeModels, setRuntimeModels] = useState<RuntimeModels | undefined>()
const [boardTheme, setBoardTheme] = useState<BoardThemeMode>("dark")
const [libraryOpen, setLibraryOpen] = useState(false)
const fileRef = useRef<HTMLInputElement | null>(null)
const selectedFrames = job
? job.frames.filter((frame) => data.selectedFrames.has(frame.index)).sort((a, b) => a.timestamp - b.timestamp)
@@ -1806,6 +1812,26 @@ export function AdRecreationBoard({
}
}
const applyLibraryAsset = async (
kind: AssetLibraryKind,
ref: ImageRef,
target: "copy_only" | "product_pool",
item: AssetLibraryItem,
) => {
if (!job) return
if (target === "product_pool" && kind === "products") {
const existing = job.product_refs ?? []
const next = [
...existing,
createProductRefItem(ref, existing.length, "library", "front", item.note || item.name || "素材库产品图"),
]
const updated = await saveProductRefs(job.id, next)
data.onJobUpdate(updated)
return
}
toast.success("素材已复制到当前 job需要入产品池时请选择“应用到产品素材池”。")
}
const addDraftSegment = () => {
setDraftSegments((prev) => [
...prev,
@@ -1950,6 +1976,15 @@ export function AdRecreationBoard({
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => setLibraryOpen(true)}
className="skg-secondary-action inline-flex h-10 items-center gap-1.5 px-3 text-[11px] font-semibold transition"
title="打开全局资源中心"
>
<BookOpen className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={toggleBoardTheme}
@@ -2062,6 +2097,12 @@ export function AdRecreationBoard({
</section>
</div>
</div>
<LibraryDrawer
open={libraryOpen}
currentJobId={job?.id}
onClose={() => setLibraryOpen(false)}
onApplyAsset={applyLibraryAsset}
/>
</section>
)
}
@@ -4123,6 +4164,27 @@ function ProductReferenceCard({
const assetWarnings = item.assetMeta?.warnings ?? []
const assetActions = item.assetMeta?.actions ?? []
const orientationText = formatProductOrientation(item.orientation)
const saveProductToLibrary = async () => {
if (!src) return
try {
const response = await fetch(src)
if (!response.ok) throw new Error(`fetch ${response.status}`)
const blob = await response.blob()
const file = new File([blob], `${item.view || "product"}.jpg`, { type: blob.type || "image/jpeg" })
await createAssetLibraryItem("products", {
name: item.note || productViewLabel(item.view),
name_zh: item.note || productViewLabel(item.view),
note: item.note,
tags: ["产品素材池", productViewLabel(item.view)],
source_job_id: job.id,
product_type: "neck_and_shoulder_massager",
views: [item.view],
}, [file])
toast.success("产品图已保存到素材库")
} catch (error) {
toast.error("保存到素材库失败:" + (error instanceof Error ? error.message : String(error)))
}
}
const previewDetail = (
<>
{productViewLabel(item.view)} · {productBackgroundLabel(item.background)} · {tagLabels.join(" / ") || "用途待标注"}
@@ -4183,6 +4245,15 @@ function ProductReferenceCard({
>
<Trash2 className="mx-auto h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={() => void saveProductToLibrary()}
className="col-start-3 h-7 w-7 rounded-md border border-[#d6b36a]/22 text-[#f1d78e]/70 transition hover:border-[#d6b36a]/55 hover:text-[#f1d78e]"
aria-label="保存产品图到素材库"
title="保存到素材库"
>
<BookOpen className="mx-auto h-3.5 w-3.5" />
</button>
</div>
)
}
@@ -4292,15 +4363,43 @@ function EndpointFrameSlot({
const ref = endpointAssetRef(frame, role)
const src = ref ? resolveImageRefUrl(job.id, ref) : ""
const label = role === "first_frame" ? "首帧" : "尾帧"
const saveEndpointFrameToLibrary = async () => {
if (!src) return
try {
const response = await fetch(src)
if (!response.ok) throw new Error(`fetch ${response.status}`)
const blob = await response.blob()
const file = new File([blob], `${role}.jpg`, { type: blob.type || "image/jpeg" })
await createAssetLibraryItem("scenes", {
name: `${label} · ${shortId(job.id)}`,
name_zh: `${label} · ${shortId(job.id)}`,
note: subjectBrief || `${label}首尾帧资产`,
tags: [label, "首尾帧"],
source_job_id: job.id,
asset_role: role,
aspect_ratio: "9:16",
}, [file])
toast.success(`${label}已保存到素材库`)
} catch (error) {
toast.error("保存首尾帧到素材库失败:" + (error instanceof Error ? error.message : String(error)))
}
}
return (
<div className="overflow-hidden rounded border border-white/10 bg-black/32">
<div className="flex h-6 items-center justify-between gap-1 border-b border-white/10 px-1.5 text-[9.5px] text-white/42">
<span>{label}</span>
<span
title={subjectBrief?.trim() ? subjectBrief : "本条没有主体 brief生成时只按画面规划和产品参考执行。"}
className="inline-flex h-4 w-4 items-center justify-center rounded border border-white/10 bg-white/[0.045] text-white/45"
>
<Info className="h-3 w-3" />
<span className="flex items-center gap-1">
{src ? (
<button type="button" onClick={() => void saveEndpointFrameToLibrary()} className="inline-flex h-4 w-4 items-center justify-center rounded border border-[#d6b36a]/20 bg-[#d6b36a]/8 text-[#f1d78e]/72" title="保存到素材库">
<BookOpen className="h-3 w-3" />
</button>
) : null}
<span
title={subjectBrief?.trim() ? subjectBrief : "本条没有主体 brief生成时只按画面规划和产品参考执行。"}
className="inline-flex h-4 w-4 items-center justify-center rounded border border-white/10 bg-white/[0.045] text-white/45"
>
<Info className="h-3 w-3" />
</span>
</span>
</div>
<MediaAssetTile

View File

@@ -0,0 +1,678 @@
"use client"
import { type MouseEvent as ReactMouseEvent, type ReactNode, useEffect, useMemo, useRef, useState } from "react"
import { createPortal } from "react-dom"
import {
BookOpen, Check, Copy, Database, Download, Edit3, FileText, Image as ImageIcon, Loader2,
Package, Plus, Search, Sparkles, Trash2, Upload, Video, X,
} from "lucide-react"
import { toast } from "sonner"
import { MediaAssetTile } from "@/components/media-asset-tile"
import {
type AssetLibraryItem,
type AssetLibraryKind,
type ImageRef,
type PromptLibraryCategory,
type PromptLibraryItem,
type ResourceLibraryRecentItem,
apiAssetUrl,
copyAssetLibraryToJob,
createAssetLibraryItem,
createPromptLibraryItem,
deleteAssetLibraryItem,
deletePromptLibraryItem,
getAssetLibraryRefs,
getResourceLibraryRecent,
listAssetLibrary,
listPromptLibrary,
usePromptLibraryItem,
} from "@/lib/api"
type LibraryTab = "prompts" | "assets"
type LibraryApplyTarget = "copy_only" | "product_pool"
type LibraryDrawerProps = {
open: boolean
currentJobId?: string
onClose: () => void
onApplyAsset?: (kind: AssetLibraryKind, ref: ImageRef, target: LibraryApplyTarget, item: AssetLibraryItem) => Promise<void> | void
}
const DRAWER_STORAGE_KEY = "skg-resource-library-drawer"
const PROMPT_COLUMNS: Array<{ category: PromptLibraryCategory; label: string; desc: string }> = [
{ category: "scene_desc", label: "场景描述", desc: "首尾帧、场景图、环境描述" },
{ category: "video_desc", label: "视频描述", desc: "视频生成动作、镜头语言" },
{ category: "subject_desc", label: "主体描述", desc: "人物、透明骨架、角色 brief" },
{ category: "skg_script", label: "SKG 文案", desc: "口播、卖点、作者意图" },
{ category: "product_angle", label: "产品角度", desc: "视角、佩戴、结构约束" },
]
const ASSET_COLUMNS: Array<{ kind: AssetLibraryKind; label: string; icon: ReactNode }> = [
{ kind: "subjects", label: "主体", icon: <Sparkles className="h-3.5 w-3.5" /> },
{ kind: "products", label: "产品", icon: <Package className="h-3.5 w-3.5" /> },
{ kind: "scenes", label: "场景", icon: <ImageIcon className="h-3.5 w-3.5" /> },
{ kind: "videos", label: "视频", icon: <Video className="h-3.5 w-3.5" /> },
]
function cn(...items: Array<string | false | null | undefined>) {
return items.filter(Boolean).join(" ")
}
function formatAgo(ts?: number) {
if (!ts) return "-"
const diff = Math.max(0, Date.now() / 1000 - ts)
if (diff < 3600) return `${Math.max(1, Math.floor(diff / 60))} 分钟前`
if (diff < 86400) return `${Math.floor(diff / 3600)} 小时前`
return `${Math.floor(diff / 86400)} 天前`
}
function monthLabel(ts?: number) {
const date = ts ? new Date(ts * 1000) : new Date()
return `${date.getFullYear()}${date.getMonth() + 1}`
}
function matchesPrompt(item: PromptLibraryItem, q: string) {
const needle = q.trim().toLowerCase()
if (!needle) return true
return [item.name, item.prompt_en, item.prompt_zh, item.tags.join(" ")].join(" ").toLowerCase().includes(needle)
}
function matchesAsset(item: AssetLibraryItem, q: string) {
const needle = q.trim().toLowerCase()
if (!needle) return true
return [item.name, item.name_zh, item.note, item.prompt_brief, item.prompt_brief_zh, item.tags.join(" ")].join(" ").toLowerCase().includes(needle)
}
function groupByMonth<T extends { created_at?: number }>(items: T[]) {
const groups = new Map<string, T[]>()
for (const item of [...items].sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))) {
const label = monthLabel(item.created_at)
groups.set(label, [...(groups.get(label) ?? []), item])
}
return [...groups.entries()]
}
function assetThumb(item: AssetLibraryItem) {
const image = item.image || item.poster || item.views?.[0] || item.images?.[0]
if (image?.url) return apiAssetUrl(image.url)
if (item.video_url) return apiAssetUrl(item.video_url)
return ""
}
function assetMeta(item: AssetLibraryItem) {
if (item.kind === "subjects") return `${item.images?.length || item.views?.length || 0} 图 · ${item.subject_style === "source_actor" ? "真人" : "骨架"}`
if (item.kind === "products") return `${item.views?.length || 0} 视角 · ${item.product_type || "肩颈产品"}`
if (item.kind === "scenes") return item.asset_role || item.aspect_ratio || "场景图"
return item.duration ? `${item.duration.toFixed(1)}s` : "视频素材"
}
export function LibraryDrawer({ open, currentJobId, onClose, onApplyAsset }: LibraryDrawerProps) {
const [tab, setTab] = useState<LibraryTab>("prompts")
const [search, setSearch] = useState("")
const [prompts, setPrompts] = useState<PromptLibraryItem[]>([])
const [assets, setAssets] = useState<Record<AssetLibraryKind, AssetLibraryItem[]>>({ subjects: [], products: [], scenes: [], videos: [] })
const [recent, setRecent] = useState<ResourceLibraryRecentItem[]>([])
const [loading, setLoading] = useState(false)
const [newPromptOpen, setNewPromptOpen] = useState(false)
const [uploadOpen, setUploadOpen] = useState(false)
const [detail, setDetail] = useState<PromptLibraryItem | AssetLibraryItem | null>(null)
const [pulseId, setPulseId] = useState<string>("")
const [rect, setRect] = useState({ width: 1100, height: 700, left: 0, top: 0 })
const dragRef = useRef<{ mode: "move" | "resize"; x: number; y: number; rect: typeof rect } | null>(null)
const refresh = async () => {
setLoading(true)
try {
const [promptItems, subjects, products, scenes, videos, recentItems] = await Promise.all([
listPromptLibrary(),
listAssetLibrary("subjects"),
listAssetLibrary("products"),
listAssetLibrary("scenes"),
listAssetLibrary("videos"),
getResourceLibraryRecent(24),
])
setPrompts(promptItems)
setAssets({ subjects, products, scenes, videos })
setRecent(recentItems.items)
} catch (error) {
toast.error("资源库读取失败:" + (error instanceof Error ? error.message : String(error)))
} finally {
setLoading(false)
}
}
useEffect(() => {
if (!open) return
try {
const saved = window.localStorage.getItem(DRAWER_STORAGE_KEY)
if (saved) {
const parsed = JSON.parse(saved)
setTab(parsed.tab === "assets" ? "assets" : "prompts")
setRect({
width: Math.max(800, Math.min(Number(parsed.width) || 1100, window.innerWidth - 32)),
height: Math.max(540, Math.min(Number(parsed.height) || 700, window.innerHeight - 32)),
left: Math.max(16, Math.min(Number(parsed.left) || (window.innerWidth - 1100) / 2, window.innerWidth - 240)),
top: Math.max(16, Math.min(Number(parsed.top) || (window.innerHeight - 700) / 2, window.innerHeight - 120)),
})
} else {
setRect({
width: Math.min(1100, window.innerWidth - 32),
height: Math.min(700, window.innerHeight - 32),
left: Math.max(16, (window.innerWidth - Math.min(1100, window.innerWidth - 32)) / 2),
top: Math.max(16, (window.innerHeight - Math.min(700, window.innerHeight - 32)) / 2),
})
}
} catch {
// storage is optional
}
void refresh()
}, [open])
useEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [open, onClose])
useEffect(() => {
if (!open) return
try {
window.localStorage.setItem(DRAWER_STORAGE_KEY, JSON.stringify({ ...rect, tab }))
} catch {
// ignore
}
}, [open, rect, tab])
useEffect(() => {
const onMove = (event: MouseEvent) => {
const state = dragRef.current
if (!state) return
if (state.mode === "move") {
setRect((current) => ({
...current,
left: Math.max(8, Math.min(state.rect.left + event.clientX - state.x, window.innerWidth - 240)),
top: Math.max(8, Math.min(state.rect.top + event.clientY - state.y, window.innerHeight - 120)),
}))
} else {
setRect((current) => ({
...current,
width: Math.max(800, Math.min(state.rect.width + event.clientX - state.x, window.innerWidth - current.left - 8)),
height: Math.max(540, Math.min(state.rect.height + event.clientY - state.y, window.innerHeight - current.top - 8)),
}))
}
}
const onUp = () => { dragRef.current = null }
window.addEventListener("mousemove", onMove)
window.addEventListener("mouseup", onUp)
return () => {
window.removeEventListener("mousemove", onMove)
window.removeEventListener("mouseup", onUp)
}
}, [])
const copyPrompt = async (item: PromptLibraryItem, mode: "en" | "zh" | "both" = "en") => {
const text = mode === "zh" ? item.prompt_zh : mode === "both" ? `${item.prompt_en}\n\n中文${item.prompt_zh}` : item.prompt_en
await navigator.clipboard.writeText(text || item.prompt_en || item.prompt_zh)
const updated = await usePromptLibraryItem(item.id)
setPrompts((current) => current.map((candidate) => candidate.id === item.id ? updated : candidate))
toast.success("已复制 · 可粘贴到任意输入框")
}
const deletePrompt = async (item: PromptLibraryItem) => {
if (!window.confirm(`删除提示词「${item.name}」?`)) return
await deletePromptLibraryItem(item.id)
setPrompts((current) => current.filter((candidate) => candidate.id !== item.id))
toast.success("提示词已移入回收区")
}
const applyAsset = async (item: AssetLibraryItem) => {
if (!currentJobId) {
toast.warning("先选择一个 job再应用素材。")
return
}
const target = item.kind === "products" && window.confirm("应用到产品素材池?取消则仅复制到当前 job 素材目录。") ? "product_pool" : "copy_only"
const result = await copyAssetLibraryToJob(item.kind, item.id, currentJobId)
if ("kind" in result && result.kind === "video") {
await navigator.clipboard.writeText(result.url)
toast.success("视频已复制到当前 job链接已复制")
return
}
await onApplyAsset?.(item.kind, result as ImageRef, target, item)
await navigator.clipboard.writeText((result as ImageRef).element_id || "")
toast.success(target === "product_pool" ? "已应用到产品素材池" : "已复制到当前 job素材 ID 已复制")
void refresh()
}
const deleteAsset = async (item: AssetLibraryItem) => {
const refs = await getAssetLibraryRefs(item.kind, item.id)
if (refs.count && !window.confirm(`${refs.count} 个 job 仍在引用这个库素材,仍要删除?`)) return
if (!refs.count && !window.confirm(`删除素材「${item.name}」?`)) return
await deleteAssetLibraryItem(item.kind, item.id, refs.count > 0)
setAssets((current) => ({ ...current, [item.kind]: current[item.kind].filter((candidate) => candidate.id !== item.id) }))
toast.success("素材已移入回收区")
}
const recentNodes = recent.slice(0, 12)
if (!open || typeof document === "undefined") return null
return createPortal(
<div className="fixed inset-0 z-[9000] pointer-events-none">
<div
className="pointer-events-auto fixed flex min-w-[800px] flex-col overflow-hidden rounded-xl border border-white/12 bg-[#070707]/96 text-white shadow-[0_28px_90px_rgba(0,0,0,0.72)] backdrop-blur-xl"
style={{ width: rect.width, height: rect.height, left: rect.left, top: rect.top }}
>
<header
className="flex cursor-move items-center justify-between gap-3 border-b border-white/10 bg-white/[0.035] px-3 py-2"
onMouseDown={(event) => {
if ((event.target as HTMLElement).closest("button,input")) return
dragRef.current = { mode: "move", x: event.clientX, y: event.clientY, rect }
}}
>
<div className="flex items-center gap-2">
<span className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-[#f5efe3] text-black shadow-xl shadow-black/25"><BookOpen className="h-4 w-4" /></span>
<div>
<div className="text-[13px] font-semibold"></div>
<div className="text-[10px] text-white/42"> job </div>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex rounded-md border border-white/10 bg-black/35 p-0.5">
{[
["prompts", "提示词库"],
["assets", "素材库"],
].map(([value, label]) => (
<button
key={value}
type="button"
onClick={() => setTab(value as LibraryTab)}
className={cn("h-7 rounded px-2.5 text-[11px] font-semibold transition", tab === value ? "bg-[#f5efe3] text-black" : "text-white/52 hover:text-white")}
>
{label}
</button>
))}
</div>
<label className="flex h-8 min-w-[240px] items-center gap-1.5 rounded-md border border-white/10 bg-black/35 px-2 text-white/46">
<Search className="h-3.5 w-3.5" />
<input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="搜索当前库,不隐藏上下文" className="w-full bg-transparent text-[11px] text-white/78 outline-none placeholder:text-white/28" />
</label>
<button type="button" onClick={() => tab === "prompts" ? setNewPromptOpen(true) : setUploadOpen(true)} className="skg-primary-action inline-flex h-8 items-center gap-1.5 px-2.5 text-[11px] font-semibold">
<Plus className="h-3.5 w-3.5" />
</button>
<button type="button" onClick={onClose} className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-white/10 bg-white/[0.04] text-white/58 hover:text-white">
<X className="h-4 w-4" />
</button>
</div>
</header>
<LibraryRecentStrip items={recentNodes} onPick={(item) => { setPulseId(item.id); setDetail(item.item); setTimeout(() => setPulseId(""), 2000) }} />
<main className="grid min-h-0 flex-1 grid-cols-[minmax(0,1fr)_260px]">
<div className="min-w-0 overflow-x-auto p-3">
{loading ? (
<div className="flex h-full items-center justify-center text-white/45"><Loader2 className="mr-2 h-4 w-4 animate-spin" /></div>
) : tab === "prompts" ? (
<div className="grid h-full auto-cols-[260px] grid-flow-col gap-4">
{PROMPT_COLUMNS.map((column) => (
<PromptColumn
key={column.category}
label={column.label}
desc={column.desc}
query={search}
items={prompts.filter((item) => item.category === column.category)}
pulseId={pulseId}
onCopy={copyPrompt}
onDelete={deletePrompt}
onDetail={setDetail}
/>
))}
</div>
) : (
<div className="grid h-full auto-cols-[260px] grid-flow-col gap-4">
{ASSET_COLUMNS.map((column) => (
<AssetColumn
key={column.kind}
label={column.label}
icon={column.icon}
query={search}
items={assets[column.kind]}
pulseId={pulseId}
onApply={applyAsset}
onDelete={deleteAsset}
onDetail={setDetail}
/>
))}
</div>
)}
</div>
<LibraryDetailPanel item={detail} />
</main>
<button
type="button"
aria-label="调整资源库浮窗大小"
onMouseDown={(event) => {
event.preventDefault()
dragRef.current = { mode: "resize", x: event.clientX, y: event.clientY, rect }
}}
className="absolute bottom-1 right-1 h-5 w-5 cursor-nwse-resize rounded-sm border-b-2 border-r-2 border-[#d6b36a]/60"
/>
</div>
{newPromptOpen ? <LibraryNewPromptDialog currentJobId={currentJobId} onClose={() => setNewPromptOpen(false)} onSaved={(item) => { setPrompts((current) => [item, ...current]); setNewPromptOpen(false); toast.success("提示词已入库") }} /> : null}
{uploadOpen ? <LibraryUploadDialog currentJobId={currentJobId} onClose={() => setUploadOpen(false)} onSaved={(item) => { setAssets((current) => ({ ...current, [item.kind]: [item, ...current[item.kind]] })); setUploadOpen(false); void refresh(); toast.success("素材已入库") }} /> : null}
</div>,
document.body,
)
}
function LibraryRecentStrip({ items, onPick }: { items: ResourceLibraryRecentItem[]; onPick: (item: ResourceLibraryRecentItem) => void }) {
return (
<div className="h-[100px] shrink-0 border-b border-white/10 bg-black/24 px-3 py-2">
<div className="mb-1 flex items-center justify-between">
<div className="text-[10px] font-semibold uppercase tracking-[0.18em] text-[#d6b36a]"> 24 </div>
<div className="text-[10px] text-white/34">{items.length ? `${items.length} 个新增` : "暂无新增"}</div>
</div>
<div className="flex gap-2 overflow-x-auto">
{items.length ? items.map((item) => (
<button key={`${item.type}-${item.id}`} type="button" onClick={() => onPick(item)} className="relative h-[72px] w-[80px] shrink-0 overflow-hidden rounded-md border border-white/10 bg-white/[0.035] text-left hover:border-[#d6b36a]/55">
<span className="absolute left-1 top-1 z-10 rounded bg-black/72 px-1 text-[9px] text-white/75">{item.type === "asset" ? "素" : "词"}</span>
<div className="flex h-full items-end p-1 text-[10px] leading-tight text-white/72">
<span className="line-clamp-2">{item.name}</span>
</div>
</button>
)) : (
<div className="flex h-[72px] items-center text-[11px] text-white/34"></div>
)}
</div>
</div>
)
}
function PromptColumn({ label, desc, items, query, pulseId, onCopy, onDelete, onDetail }: {
label: string
desc: string
items: PromptLibraryItem[]
query: string
pulseId: string
onCopy: (item: PromptLibraryItem, mode?: "en" | "zh" | "both") => void
onDelete: (item: PromptLibraryItem) => void
onDetail: (item: PromptLibraryItem) => void
}) {
const common = [...items].sort((a, b) => b.use_count - a.use_count).slice(0, 5).filter((item) => item.use_count > 0)
return (
<section className="flex min-h-0 flex-col rounded-lg border border-white/10 bg-white/[0.028]">
<header className="shrink-0 border-b border-white/10 p-2">
<div className="text-[12px] font-semibold">{label}</div>
<div className="mt-0.5 text-[10px] text-white/35">{desc}</div>
</header>
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto p-2">
{common.length ? (
<>
<Divider label="常用" />
{common.map((item) => <PromptCard key={`common-${item.id}`} item={item} dim={!matchesPrompt(item, query)} pulse={pulseId === item.id} onCopy={onCopy} onDelete={onDelete} onDetail={onDetail} />)}
</>
) : null}
{groupByMonth(items).map(([month, monthItems]) => (
<div key={month}>
<Divider label={month} />
<div className="space-y-2">
{monthItems.map((item) => <PromptCard key={item.id} item={item} dim={!matchesPrompt(item, query)} pulse={pulseId === item.id} onCopy={onCopy} onDelete={onDelete} onDetail={onDetail} />)}
</div>
</div>
))}
</div>
</section>
)
}
function PromptCard({ item, dim, pulse, onCopy, onDelete, onDetail }: {
item: PromptLibraryItem
dim: boolean
pulse: boolean
onCopy: (item: PromptLibraryItem, mode?: "en" | "zh" | "both") => void
onDelete: (item: PromptLibraryItem) => void
onDetail: (item: PromptLibraryItem) => void
}) {
const isNew = Date.now() / 1000 - item.created_at < 86400
return (
<article className={cn("group h-[132px] rounded-md border border-white/10 bg-black/34 p-2 transition", dim && "opacity-25", pulse && "ring-2 ring-[#d6b36a]/80")}>
<div className="flex items-start justify-between gap-2">
<button type="button" onClick={() => onDetail(item)} className="min-w-0 text-left">
<div className="truncate text-[11px] font-semibold text-white/86">{item.name}</div>
<div className="mt-1 flex flex-wrap gap-1">{item.tags.slice(0, 2).map((tag) => <span key={tag} className="rounded border border-[#d6b36a]/20 bg-[#d6b36a]/8 px-1 text-[9px] text-[#f1d78e]">{tag}</span>)}{isNew ? <span className="rounded bg-[#d6b36a]/18 px-1 text-[9px] text-[#f1d78e]"> </span> : null}</div>
</button>
<div className="flex opacity-0 transition group-hover:opacity-100">
<button type="button" title="编辑" onClick={() => onDetail(item)} className="h-5 w-5 text-white/42 hover:text-white"><Edit3 className="h-3.5 w-3.5" /></button>
<button type="button" title="删除" onClick={() => onDelete(item)} className="h-5 w-5 text-rose-200/58 hover:text-rose-100"><Trash2 className="h-3.5 w-3.5" /></button>
</div>
</div>
<button type="button" onClick={() => onDetail(item)} className="mt-2 line-clamp-2 min-h-[34px] w-full text-left text-[10.5px] leading-snug text-white/48">{item.prompt_en || item.prompt_zh}</button>
<div className="mt-2 flex items-center justify-between gap-2 text-[10px] text-white/34">
<CopyButton onCopy={(mode) => onCopy(item, mode)} />
<span>{item.use_count} 使 · {formatAgo(item.created_at)}</span>
</div>
</article>
)
}
function CopyButton({ onCopy }: { onCopy: (mode: "en" | "zh" | "both") => void }) {
const [open, setOpen] = useState(false)
return (
<span className="relative" onMouseEnter={() => setOpen(true)} onMouseLeave={() => setOpen(false)}>
<button type="button" onClick={() => onCopy("en")} className="inline-flex h-6 items-center gap-1 rounded bg-[#f5efe3] px-2 text-[10px] font-semibold text-black">
<Copy className="h-3 w-3" />
</button>
{open ? (
<span className="absolute bottom-7 left-0 z-20 flex rounded-md border border-white/12 bg-black/94 p-1 shadow-xl">
{[
["en", "英文"],
["zh", "中文"],
["both", "双语"],
].map(([mode, label]) => (
<button key={mode} type="button" onClick={() => onCopy(mode as "en" | "zh" | "both")} className="whitespace-nowrap rounded px-2 py-1 text-[10px] text-white/68 hover:bg-white/10 hover:text-white">{label}</button>
))}
</span>
) : null}
</span>
)
}
function AssetColumn({ label, icon, items, query, pulseId, onApply, onDelete, onDetail }: {
label: string
icon: ReactNode
items: AssetLibraryItem[]
query: string
pulseId: string
onApply: (item: AssetLibraryItem) => void
onDelete: (item: AssetLibraryItem) => void
onDetail: (item: AssetLibraryItem) => void
}) {
return (
<section className="flex min-h-0 flex-col rounded-lg border border-white/10 bg-white/[0.028]">
<header className="flex shrink-0 items-center gap-1.5 border-b border-white/10 p-2 text-[12px] font-semibold">{icon}{label}</header>
<div className="min-h-0 flex-1 space-y-3 overflow-y-auto p-2">
{groupByMonth(items).map(([month, monthItems]) => (
<div key={month}>
<Divider label={month} />
<div className="grid grid-cols-2 gap-2">
{monthItems.map((item) => <AssetCard key={item.id} item={item} dim={!matchesAsset(item, query)} pulse={pulseId === item.id} onApply={onApply} onDelete={onDelete} onDetail={onDetail} />)}
</div>
</div>
))}
</div>
</section>
)
}
function AssetCard({ item, dim, pulse, onApply, onDelete, onDetail }: {
item: AssetLibraryItem
dim: boolean
pulse: boolean
onApply: (item: AssetLibraryItem) => void
onDelete: (item: AssetLibraryItem) => void
onDetail: (item: AssetLibraryItem) => void
}) {
const horizontal = item.kind === "videos"
return (
<div className={cn("group rounded-md border border-white/10 bg-black/34 p-1.5 transition", dim && "opacity-25", pulse && "ring-2 ring-[#d6b36a]/80", horizontal && "col-span-2")}>
<MediaAssetTile
kind={item.kind === "videos" ? "video" : "image"}
src={item.kind === "videos" ? apiAssetUrl(item.video_url) : assetThumb(item)}
poster={item.kind === "videos" ? assetThumb(item) : undefined}
label={item.name}
meta={assetMeta(item)}
className={horizontal ? "aspect-video w-full bg-white" : "aspect-[120/156] w-full bg-white"}
objectFit="cover"
onClick={() => onDetail(item)}
actions={[
{ key: "copy", label: "复制 ID", icon: <Copy className="h-3 w-3" />, onClick: () => { void navigator.clipboard.writeText(item.id); toast.success("素材 ID 已复制") } },
{ key: "apply", label: "应用到当前 job", icon: <Download className="h-3 w-3" />, onClick: () => void onApply(item), tone: "cyan" },
{ key: "edit", label: "编辑", icon: <Edit3 className="h-3 w-3" />, onClick: () => onDetail(item) },
]}
onDelete={() => void onDelete(item)}
/>
<div className="mt-1 min-w-0">
<div className="truncate text-[10.5px] font-semibold text-white/78">{item.name}</div>
<div className="truncate text-[9.5px] text-white/34">{assetMeta(item)} · {item.use_count} </div>
</div>
</div>
)
}
function Divider({ label }: { label: string }) {
return <div className="my-2 flex items-center gap-2 text-[9px] uppercase tracking-[0.16em] text-white/28"><span className="h-px flex-1 bg-white/10" />{label}<span className="h-px flex-1 bg-white/10" /></div>
}
function LibraryDetailPanel({ item }: { item: PromptLibraryItem | AssetLibraryItem | null }) {
if (!item) {
return <aside className="border-l border-white/10 p-3 text-[11px] text-white/36"></aside>
}
const isPrompt = "prompt_en" in item
return (
<aside className="min-h-0 overflow-y-auto border-l border-white/10 p-3">
<div className="mb-2 flex items-center gap-2 text-[12px] font-semibold">
{isPrompt ? <FileText className="h-4 w-4 text-[#d6b36a]" /> : <Database className="h-4 w-4 text-[#d6b36a]" />}
{item.name}
</div>
<div className="space-y-2 text-[11px] leading-relaxed text-white/58">
<p>ID<span className="font-mono text-white/42">{item.id}</span></p>
<p>使{item.use_count} </p>
<p>{formatAgo(item.created_at)}</p>
<p>{item.tags?.join(" / ") || "-"}</p>
{isPrompt ? (
<>
<div><div className="mb-1 text-white/34"></div><pre className="whitespace-pre-wrap rounded border border-white/10 bg-black/36 p-2 text-[10.5px]">{(item as PromptLibraryItem).prompt_en}</pre></div>
<div><div className="mb-1 text-white/34"></div><pre className="whitespace-pre-wrap rounded border border-white/10 bg-black/36 p-2 text-[10.5px]">{(item as PromptLibraryItem).prompt_zh || "-"}</pre></div>
</>
) : (
<>
<p>{(item as AssetLibraryItem).note || "-"}</p>
<p> job{(item as AssetLibraryItem).source_job_id || "-"}</p>
<p>brief{(item as AssetLibraryItem).prompt_brief || "-"}</p>
</>
)}
</div>
</aside>
)
}
function LibraryNewPromptDialog({ currentJobId, onClose, onSaved }: { currentJobId?: string; onClose: () => void; onSaved: (item: PromptLibraryItem) => void }) {
const [category, setCategory] = useState<PromptLibraryCategory>("scene_desc")
const [name, setName] = useState("")
const [tags, setTags] = useState("")
const [promptEn, setPromptEn] = useState("")
const [promptZh, setPromptZh] = useState("")
const [busy, setBusy] = useState(false)
const save = async () => {
setBusy(true)
try {
const item = await createPromptLibraryItem({
category,
name,
tags: tags.split(/[,\s]+/).map((tag) => tag.trim()).filter(Boolean),
prompt_en: promptEn,
prompt_zh: promptZh,
source_job_id: currentJobId || "",
})
onSaved(item)
} catch (error) {
toast.error("提示词入库失败:" + (error instanceof Error ? error.message : String(error)))
} finally {
setBusy(false)
}
}
return <DialogFrame title="新建提示词" onClose={onClose}>
<div className="grid gap-2">
<select value={category} onChange={(event) => setCategory(event.target.value as PromptLibraryCategory)} className="h-9 rounded border border-white/10 bg-black px-2 text-[12px]">
{PROMPT_COLUMNS.map((column) => <option key={column.category} value={column.category}>{column.label}</option>)}
</select>
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="标题" className="h-9 rounded border border-white/10 bg-black px-2 text-[12px] outline-none focus:border-[#d6b36a]/55" />
<input value={tags} onChange={(event) => setTags(event.target.value)} placeholder="标签,用空格或逗号分隔" className="h-9 rounded border border-white/10 bg-black px-2 text-[12px] outline-none focus:border-[#d6b36a]/55" />
<textarea value={promptEn} onChange={(event) => setPromptEn(event.target.value)} placeholder="英文内容,实际发给模型" className="min-h-[112px] resize-y rounded border border-white/10 bg-black px-2 py-2 text-[12px] outline-none focus:border-[#d6b36a]/55" />
<textarea value={promptZh} onChange={(event) => setPromptZh(event.target.value)} placeholder="中文翻译,给团队看" className="min-h-[72px] resize-y rounded border border-white/10 bg-black px-2 py-2 text-[12px] outline-none focus:border-[#d6b36a]/55" />
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="skg-secondary-action h-9 px-3 text-[12px]"></button>
<button type="button" onClick={() => void save()} disabled={busy || !name.trim() || (!promptEn.trim() && !promptZh.trim())} className="skg-primary-action h-9 px-3 text-[12px] disabled:opacity-40">{busy ? "保存中" : "保存"}</button>
</div>
</div>
</DialogFrame>
}
function LibraryUploadDialog({ currentJobId, onClose, onSaved }: { currentJobId?: string; onClose: () => void; onSaved: (item: AssetLibraryItem) => void }) {
const [kind, setKind] = useState<AssetLibraryKind>("subjects")
const [name, setName] = useState("")
const [note, setNote] = useState("")
const [tags, setTags] = useState("")
const [files, setFiles] = useState<File[]>([])
const [busy, setBusy] = useState(false)
const save = async () => {
setBusy(true)
try {
const item = await createAssetLibraryItem(kind, {
name,
note,
tags: tags.split(/[,\s]+/).map((tag) => tag.trim()).filter(Boolean),
source_job_id: currentJobId || "",
}, files)
onSaved(item)
} catch (error) {
toast.error("素材入库失败:" + (error instanceof Error ? error.message : String(error)))
} finally {
setBusy(false)
}
}
return <DialogFrame title="上传素材" onClose={onClose}>
<div className="grid gap-2">
<select value={kind} onChange={(event) => setKind(event.target.value as AssetLibraryKind)} className="h-9 rounded border border-white/10 bg-black px-2 text-[12px]">
{ASSET_COLUMNS.map((column) => <option key={column.kind} value={column.kind}>{column.label}</option>)}
</select>
<input type="file" multiple onChange={(event) => setFiles(Array.from(event.currentTarget.files ?? []))} className="rounded border border-dashed border-white/12 bg-black/35 p-3 text-[12px] text-white/56 file:mr-3 file:rounded file:border-0 file:bg-[#f5efe3] file:px-2 file:py-1 file:text-black" />
<input value={name} onChange={(event) => setName(event.target.value)} placeholder="名称" className="h-9 rounded border border-white/10 bg-black px-2 text-[12px] outline-none focus:border-[#d6b36a]/55" />
<input value={tags} onChange={(event) => setTags(event.target.value)} placeholder="标签,用空格或逗号分隔" className="h-9 rounded border border-white/10 bg-black px-2 text-[12px] outline-none focus:border-[#d6b36a]/55" />
<textarea value={note} onChange={(event) => setNote(event.target.value)} placeholder="备注 / 视角 / brief" className="min-h-[80px] resize-y rounded border border-white/10 bg-black px-2 py-2 text-[12px] outline-none focus:border-[#d6b36a]/55" />
<div className="flex justify-end gap-2">
<button type="button" onClick={onClose} className="skg-secondary-action h-9 px-3 text-[12px]"></button>
<button type="button" onClick={() => void save()} disabled={busy || !name.trim() || !files.length} className="skg-primary-action h-9 px-3 text-[12px] disabled:opacity-40">{busy ? "保存中" : "保存"}</button>
</div>
</div>
</DialogFrame>
}
function DialogFrame({ title, children, onClose }: { title: string; children: ReactNode; onClose: () => void }) {
return (
<div className="pointer-events-auto fixed inset-0 z-[9100] flex items-center justify-center bg-black/45">
<div className="w-[520px] rounded-xl border border-white/12 bg-[#080808] p-4 text-white shadow-2xl">
<div className="mb-3 flex items-center justify-between">
<div className="text-[14px] font-semibold">{title}</div>
<button type="button" onClick={onClose} className="h-7 w-7 rounded border border-white/10 text-white/55 hover:text-white"><X className="mx-auto h-4 w-4" /></button>
</div>
{children}
</div>
</div>
)
}