auto-save 2026-05-18 21:03 (+1, ~3)
This commit is contained in:
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
43
api/main.py
43
api/main.py
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
678
web/components/resource-library/library-drawer.tsx
Normal file
678
web/components/resource-library/library-drawer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user