From 93e8ece9dab5a0ef38babd567e6449273bcde8ce Mon Sep 17 00:00:00 2001 From: hh <2587203630@qq.com> Date: Thu, 9 Jul 2026 15:05:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=87=AA=E7=94=B1=E5=88=9B=E4=BD=9C?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=B4=A0=E6=9D=90=E7=BB=84=E7=BC=96=E8=BE=91?= =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../free-create/asset-library-modal.tsx | 241 +++++++++++++++--- core/frontend/src/free-create-page.css | 105 ++++++++ 2 files changed, 315 insertions(+), 31 deletions(-) diff --git a/core/frontend/src/components/free-create/asset-library-modal.tsx b/core/frontend/src/components/free-create/asset-library-modal.tsx index 5dcb6db..4dbb81f 100644 --- a/core/frontend/src/components/free-create/asset-library-modal.tsx +++ b/core/frontend/src/components/free-create/asset-library-modal.tsx @@ -3,7 +3,7 @@ // processing 素材每 8s 轮询火山刷新状态(与基础资产送审轮询同节奏)。 import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { ChevronLeft, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react"; +import { Check, ChevronLeft, FolderPlus, Pencil, Trash2, Upload, Users, X } from "lucide-react"; import { api } from "../../api"; import type { FreeAssetGroup, FreeAssetItem, FreeVideoRef } from "../../types"; import { useBodyScrollLock } from "../overlays"; @@ -25,9 +25,20 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: { const [assets, setAssets] = useState([]); const [loading, setLoading] = useState(false); const [creating, setCreating] = useState(false); + const [creatingGroup, setCreatingGroup] = useState(false); const [newName, setNewName] = useState(""); const [uploading, setUploading] = useState(false); const [quickUploading, setQuickUploading] = useState(false); + const [editingAssetId, setEditingAssetId] = useState(null); + const [editingName, setEditingName] = useState(""); + const [renamingAssetId, setRenamingAssetId] = useState(null); + const [confirmDeleteAssetId, setConfirmDeleteAssetId] = useState(null); + const [deletingAssetId, setDeletingAssetId] = useState(null); + const [editingGroupId, setEditingGroupId] = useState(null); + const [editingGroupName, setEditingGroupName] = useState(""); + const [renamingGroupId, setRenamingGroupId] = useState(null); + const [confirmDeleteGroupId, setConfirmDeleteGroupId] = useState(null); + const [deletingGroupId, setDeletingGroupId] = useState(null); const fileRef = useRef(null); const quickFileRef = useRef(null); useBodyScrollLock(open); @@ -61,6 +72,12 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: { if (!open) return; setActiveGroup(null); setAssets([]); + setEditingAssetId(null); + setEditingName(""); + setConfirmDeleteAssetId(null); + setEditingGroupId(null); + setEditingGroupName(""); + setConfirmDeleteGroupId(null); void loadGroups(); }, [open, loadGroups]); @@ -83,7 +100,8 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: { const createGroup = async () => { const name = newName.trim(); - if (!name) return; + if (!name || creatingGroup) return; + setCreatingGroup(true); try { const data = await api.createFreeAssetGroup({ name }); setGroups((prev) => [data.group, ...prev]); @@ -92,6 +110,8 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: { notify("success", `素材组「${name}」已创建`); } catch (error) { notify("error", error instanceof Error ? error.message : "创建失败"); + } finally { + setCreatingGroup(false); } }; @@ -128,48 +148,100 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: { } }; + const startRenameAsset = (item: FreeAssetItem) => { + setConfirmDeleteAssetId(null); + setConfirmDeleteGroupId(null); + setEditingAssetId(item.id); + setEditingName(item.name); + }; + + const cancelRenameAsset = () => { + setEditingAssetId(null); + setEditingName(""); + }; + const renameAsset = async (item: FreeAssetItem) => { - const name = window.prompt("素材名称(用于 @ 引用)", item.name); - if (!name || name.trim() === item.name) return; + const name = editingName.trim(); + if (!name) { + notify("info", "素材名称不能为空"); + return; + } + if (name === item.name) { + cancelRenameAsset(); + return; + } + setRenamingAssetId(item.id); try { - const data = await api.renameFreeAsset(item.id, name.trim()); + const data = await api.renameFreeAsset(item.id, name); setAssets((prev) => prev.map((a) => (a.id === item.id ? data.asset : a))); + cancelRenameAsset(); } catch (error) { notify("error", error instanceof Error ? error.message : "重命名失败"); + } finally { + setRenamingAssetId(null); } }; const removeAsset = async (item: FreeAssetItem) => { - if (!window.confirm(`删除素材「${item.name}」?`)) return; + setDeletingAssetId(item.id); try { await api.deleteFreeAsset(item.id); setAssets((prev) => prev.filter((a) => a.id !== item.id)); + setConfirmDeleteAssetId(null); } catch (error) { notify("error", error instanceof Error ? error.message : "删除失败"); + } finally { + setDeletingAssetId(null); } }; + const startRenameGroup = (group: FreeAssetGroup) => { + setConfirmDeleteAssetId(null); + setConfirmDeleteGroupId(null); + setEditingGroupId(group.id); + setEditingGroupName(group.name); + }; + + const cancelRenameGroup = () => { + setEditingGroupId(null); + setEditingGroupName(""); + }; + const renameGroup = async (group: FreeAssetGroup) => { - const name = window.prompt("素材组名称", group.name); - if (!name || name.trim() === group.name) return; + const name = editingGroupName.trim(); + if (!name) { + notify("info", "素材组名称不能为空"); + return; + } + if (name === group.name) { + cancelRenameGroup(); + return; + } + setRenamingGroupId(group.id); try { - const data = await api.updateFreeAssetGroup(group.id, { name: name.trim() }); + const data = await api.updateFreeAssetGroup(group.id, { name }); setGroups((prev) => prev.map((g) => (g.id === group.id ? data.group : g))); if (activeGroup?.id === group.id) setActiveGroup(data.group); + cancelRenameGroup(); } catch (error) { notify("error", error instanceof Error ? error.message : "重命名失败"); + } finally { + setRenamingGroupId(null); } }; const removeGroup = async (group: FreeAssetGroup) => { - if (!window.confirm(`删除素材组「${group.name}」及其全部素材?此操作不可恢复。`)) return; + setDeletingGroupId(group.id); try { await api.deleteFreeAssetGroup(group.id); setGroups((prev) => prev.filter((g) => g.id !== group.id)); if (activeGroup?.id === group.id) { setActiveGroup(null); setAssets([]); } + setConfirmDeleteGroupId(null); notify("success", "素材组已删除"); } catch (error) { notify("error", error instanceof Error ? error.message : "删除失败"); + } finally { + setDeletingGroupId(null); } }; @@ -206,7 +278,7 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: { -
+
{ setConfirmDeleteAssetId(null); setConfirmDeleteGroupId(null); }}> {!activeGroup ? ( <>
@@ -228,11 +300,17 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: { autoFocus placeholder="素材组名称(如:碧碧)" value={newName} + disabled={creatingGroup} onChange={(event) => setNewName(event.target.value)} - onKeyDown={(event) => { if (event.key === "Enter") void createGroup(); if (event.key === "Escape") setCreating(false); }} + onKeyDown={(event) => { + if (event.key === "Enter" && !creatingGroup) void createGroup(); + if (event.key === "Escape" && !creatingGroup) setCreating(false); + }} /> - - + +
) : ( <> @@ -252,19 +330,71 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
) : (
- {groups.map((group) => ( -
void loadGroupDetail(group)} onKeyDown={(event) => { if (event.key === "Enter") void loadGroupDetail(group); }}> -
- {group.thumbnail_url ? {group.name} : } + {groups.map((group) => { + const isEditing = editingGroupId === group.id; + const isDeleting = confirmDeleteGroupId === group.id; + return ( +
{ if (!editingGroupId) void loadGroupDetail(group); }} + onKeyDown={(event) => { if (!editingGroupId && event.key === "Enter") void loadGroupDetail(group); }} + > +
+ {group.thumbnail_url ? {group.name} : } +
+ {isEditing ? ( +
event.stopPropagation()}> + setEditingGroupName(event.target.value)} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") void renameGroup(group); + if (event.key === "Escape") cancelRenameGroup(); + }} + /> + + +
+ ) : ( +
{group.name}
+ )} +
// {group.asset_count} 个素材
+
+ + +
+ {isDeleting ? ( +
event.stopPropagation()}> +
删除素材组?
+
// 组内素材也会删除
+
+ + +
+
+ ) : null}
-
{group.name}
-
// {group.asset_count} 个素材
-
- - -
-
- ))} + ); + })}
)} @@ -297,19 +427,68 @@ export function AssetLibraryModal({ open, onClose, onPick, notify }: {
{assets.map((item) => { const pill = STATUS_PILL[item.status]; + const isEditing = editingAssetId === item.id; + const isDeleting = confirmDeleteAssetId === item.id; return ( -
pick(item)} onKeyDown={(event) => { if (event.key === "Enter") pick(item); }}> +
{ if (!editingAssetId) pick(item); }} + onKeyDown={(event) => { if (!editingAssetId && event.key === "Enter") pick(item); }} + >
{item.thumb_url ? {item.name} : {item.type === "audio" ? "♪" : item.type.toUpperCase()}} {item.duration ? {item.duration}s : null}
-
{item.name}
- {pill.label} + {isEditing ? ( +
event.stopPropagation()}> + setEditingName(event.target.value)} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") void renameAsset(item); + if (event.key === "Escape") cancelRenameAsset(); + }} + /> + + +
+ ) : ( +
{item.name}
+ )} + {pill.label} {item.status === "failed" && item.error_message &&
// {item.error_message}
}
- - + +
+ {isDeleting ? ( +
event.stopPropagation()}> +
删除素材?
+
+ + +
+
+ ) : null}
); })} diff --git a/core/frontend/src/free-create-page.css b/core/frontend/src/free-create-page.css index 670eff8..c14fbe0 100644 --- a/core/frontend/src/free-create-page.css +++ b/core/frontend/src/free-create-page.css @@ -555,8 +555,61 @@ } .fc-lib-thumb img { width: 100%; height: 100%; object-fit: cover; } .fc-lib-name { font-size: 12.5px; font-weight: 500; color: var(--accent-black); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.fc-lib-rename-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 24px 24px; + gap: 4px; + align-items: center; +} +.fc-lib-name-input { + height: 28px; + min-width: 0; + padding: 0 8px; + font-size: 12.5px; + font-weight: 500; +} +.fc-lib-rename-btn { + width: 24px; + height: 28px; + border: 1px solid var(--border-faint); + border-radius: var(--r-sm); + background: var(--surface); + color: var(--black-alpha-56); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.fc-lib-rename-btn:hover { color: var(--accent-black); border-color: var(--black-alpha-24); background: var(--black-alpha-4); } +.fc-lib-rename-btn.ok { color: var(--accent-forest); border-color: var(--forest-bd); background: var(--forest-bg); } +.fc-lib-rename-btn:disabled { opacity: .45; cursor: not-allowed; } .fc-lib-count { font-size: 10.5px; letter-spacing: 0.04em; color: var(--black-alpha-48); } .fc-lib-err { font-size: 10.5px; color: var(--accent-crimson); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.fc-lib-status { + width: 100%; + justify-content: flex-start; + font-size: 11px; +} +.fc-lib-status.pill-ok { + background: var(--forest-bg); + color: var(--accent-forest); + border-color: var(--forest-bd); +} +.fc-lib-status.pill-info { + background: var(--heat-12); + color: var(--heat); + border-color: var(--heat-20); +} +.fc-lib-status.pill-err { + background: var(--crimson-bg); + color: var(--accent-crimson); + border-color: var(--crimson-bd); +} +.fc-lib-status .dot { + width: 7px; + height: 7px; + background: currentColor; +} .fc-lib-ops { position: absolute; top: 14px; right: 14px; display: flex; gap: 4px; opacity: 0; transition: opacity 0.2s; } .fc-lib-group:hover .fc-lib-ops, .fc-lib-asset:hover .fc-lib-ops { opacity: 1; } .fc-lib-ops button { @@ -572,6 +625,58 @@ justify-content: center; } .fc-lib-ops button:hover { background: var(--accent-black); } +.fc-lib-delete-pop { + position: absolute; + top: 42px; + right: 10px; + z-index: 2; + min-width: 148px; + padding: 10px; + border: 1px solid var(--border-muted); + border-radius: var(--r-md); + background: var(--surface-raised); + box-shadow: 0 0 0 1px var(--black-alpha-7), var(--shadow-floating); +} +.fc-lib-delete-pop::before { + content: ""; + position: absolute; + top: -5px; + right: 16px; + width: 8px; + height: 8px; + border-left: 1px solid var(--border-muted); + border-top: 1px solid var(--border-muted); + background: var(--surface-raised); + transform: rotate(45deg); +} +.fc-lib-delete-title { + font-size: 12.5px; + font-weight: 500; + color: var(--accent-black); + margin-bottom: 8px; +} +.fc-lib-delete-note { + font-size: 10.5px; + letter-spacing: 0.04em; + color: var(--black-alpha-48); + margin: -2px 0 8px; +} +.fc-lib-delete-actions { display: flex; justify-content: flex-end; gap: 6px; } +.fc-lib-delete-actions .btn { + height: 26px; + padding: 0 10px; + font-size: 12px; +} +.fc-lib-delete-danger { + color: var(--accent-crimson); + border-color: var(--crimson-bd); + background: var(--crimson-bg); +} +.fc-lib-delete-danger:hover { + color: var(--accent-crimson); + border-color: var(--crimson-bd); + background: var(--crimson-bg); +} /* 移动端:输入条参数换行、任务流单列 */ @media (max-width: 720px) {