679 lines
34 KiB
TypeScript
679 lines
34 KiB
TypeScript
"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>
|
||
)
|
||
}
|