feat(assets&canvas): 实现素材回收站机制、自动定时清理策略与过期时间提示,增加画布创建与变更历史时间线

This commit is contained in:
Danborad
2026-09-01 14:37:26 +08:00
parent b1076b9aa6
commit 87622dc678
17 changed files with 1411 additions and 321 deletions

View File

@@ -1072,6 +1072,17 @@ func (r *Repository) DeleteAsset(userID string, id string) error {
return r.DeleteAssetAndResources(userID, id, nil, nil)
}
func (r *Repository) FindExpiredArchivedAssets(cutoff time.Time, limit int) ([]model.Asset, error) {
var assets []model.Asset
if limit <= 0 {
limit = 100
}
err := r.db.Where("status = ? AND updated_at <= ?", model.AssetVersionStatusArchived, cutoff).
Limit(limit).
Find(&assets).Error
return assets, err
}
func (r *Repository) ReplaceAssets(userID string, assets []model.Asset) error {
return r.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Delete(&model.Asset{}, "user_id = ?", userID).Error; err != nil {
@@ -1104,6 +1115,15 @@ func (r *Repository) CanvasProjectForUser(userID string, id string) (*model.Canv
return &project, nil
}
func (r *Repository) CanvasProjectTasks(userID string, projectIDs []string) ([]model.Task, error) {
var tasks []model.Task
if len(projectIDs) == 0 {
return tasks, nil
}
err := r.db.Where("user_id = ? AND project_id IN ?", userID, projectIDs).Find(&tasks).Error
return tasks, err
}
func (r *Repository) UpsertCanvasProject(project *model.CanvasProject) error {
result := r.db.Model(&model.CanvasProject{}).
Where("id = ? AND user_id = ?", project.ID, project.UserID).

View File

@@ -15,24 +15,51 @@ func (s *Service) startResourceDeletionWorker(ctx context.Context) {
s.runWorkerLoop(func(ctx context.Context) {
s.drainResourceDeletionJobs(32)
s.cleanupStaleAnnouncementImageDrafts()
s.cleanupExpiredArchivedAssets()
ticker := time.NewTicker(15 * time.Second)
defer ticker.Stop()
lastDraftCleanup := time.Now()
lastPeriodicCleanup := time.Now()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.drainResourceDeletionJobs(32)
if time.Since(lastDraftCleanup) >= time.Hour {
if time.Since(lastPeriodicCleanup) >= time.Hour {
s.cleanupStaleAnnouncementImageDrafts()
lastDraftCleanup = time.Now()
s.cleanupExpiredArchivedAssets()
lastPeriodicCleanup = time.Now()
}
}
}
})
}
func (s *Service) cleanupExpiredArchivedAssets() {
policy, err := s.RuntimePolicy()
if err != nil {
return
}
retentionDays := policy.Resource.RecycleBinRetentionDays
if retentionDays <= 0 {
return
}
cutoff := time.Now().Add(-time.Duration(retentionDays) * 24 * time.Hour)
expired, err := s.repo.FindExpiredArchivedAssets(cutoff, 100)
if err != nil {
log.Printf("expired archived assets query failed: %v", err)
return
}
for _, asset := range expired {
if err := s.repo.DeleteAsset(asset.UserID, asset.ID); err != nil {
log.Printf("expired archived asset delete failed for %s: %v", asset.ID, err)
}
}
if len(expired) > 0 {
log.Printf("recycle bin cleanup: deleted %d expired assets (retention: %d days)", len(expired), retentionDays)
}
}
func (s *Service) drainResourceDeletionJobs(limit int) {
owner := s.workerID
if owner == "" {

View File

@@ -25,18 +25,19 @@ const (
)
type RuntimeResourcePolicy struct {
ResourceUploadMB int64 `json:"resourceUploadMB"`
SessionUploadMB int64 `json:"sessionUploadMB"`
GeneratedFileMB int64 `json:"generatedFileMB"`
DailyUploadMB int64 `json:"dailyUploadMB"`
StoredFileGB int64 `json:"storedFileGB"`
StructuredDataMB int64 `json:"structuredDataMB"`
TaskDataGB int64 `json:"taskDataGB"`
AssetCount int64 `json:"assetCount"`
CanvasCount int64 `json:"canvasCount"`
SessionCount int64 `json:"sessionCount"`
TaskCount int64 `json:"taskCount"`
APICallLogCount int64 `json:"apiCallLogCount"`
ResourceUploadMB int64 `json:"resourceUploadMB"`
SessionUploadMB int64 `json:"sessionUploadMB"`
GeneratedFileMB int64 `json:"generatedFileMB"`
DailyUploadMB int64 `json:"dailyUploadMB"`
StoredFileGB int64 `json:"storedFileGB"`
StructuredDataMB int64 `json:"structuredDataMB"`
TaskDataGB int64 `json:"taskDataGB"`
AssetCount int64 `json:"assetCount"`
CanvasCount int64 `json:"canvasCount"`
SessionCount int64 `json:"sessionCount"`
TaskCount int64 `json:"taskCount"`
APICallLogCount int64 `json:"apiCallLogCount"`
RecycleBinRetentionDays int `json:"recycleBinRetentionDays"`
}
type RuntimeTaskPolicy struct {
@@ -90,26 +91,28 @@ type PublicRuntimePolicySetting struct {
}
type PublicRuntimeLimits struct {
ActiveTaskLimit int `json:"activeTaskLimit"`
ResourceUploadMB int64 `json:"resourceUploadMB"`
SessionUploadMB int64 `json:"sessionUploadMB"`
ActiveTaskLimit int `json:"activeTaskLimit"`
ResourceUploadMB int64 `json:"resourceUploadMB"`
SessionUploadMB int64 `json:"sessionUploadMB"`
RecycleBinRetentionDays int `json:"recycleBinRetentionDays"`
}
func defaultRuntimePolicy() RuntimePolicySetting {
return RuntimePolicySetting{
Resource: RuntimeResourcePolicy{
ResourceUploadMB: 50,
SessionUploadMB: 32,
GeneratedFileMB: 64,
DailyUploadMB: 200,
StoredFileGB: 2,
StructuredDataMB: 256,
TaskDataGB: 1,
AssetCount: 2_000,
CanvasCount: 1_000,
SessionCount: 1_000,
TaskCount: 20_000,
APICallLogCount: 100_000,
ResourceUploadMB: 50,
SessionUploadMB: 32,
GeneratedFileMB: 64,
DailyUploadMB: 200,
StoredFileGB: 2,
StructuredDataMB: 256,
TaskDataGB: 1,
AssetCount: 2_000,
CanvasCount: 1_000,
SessionCount: 1_000,
TaskCount: 20_000,
APICallLogCount: 100_000,
RecycleBinRetentionDays: 30,
},
Task: RuntimeTaskPolicy{
WorkerConcurrency: effectiveChannelConcurrencyLimit(envInt("CANVAS_WORKER_CONCURRENCY", taskWorkerConcurrency)),
@@ -155,6 +158,7 @@ func selfUseRuntimePolicy() RuntimePolicySetting {
DailyUploadMB: maxRuntimeDataMB, StoredFileGB: maxRuntimeStorageGB, StructuredDataMB: maxRuntimeDataMB,
TaskDataGB: maxRuntimeStorageGB, AssetCount: maxRuntimeCount, CanvasCount: maxRuntimeCount,
SessionCount: maxRuntimeCount, TaskCount: maxRuntimeCount, APICallLogCount: maxRuntimeCount,
RecycleBinRetentionDays: 365,
}
value.Task = RuntimeTaskPolicy{
WorkerConcurrency: maxRuntimeConcurrency, ChannelConcurrency: maxRuntimeConcurrency, ActiveTaskLimit: maxRuntimeConcurrency,
@@ -194,7 +198,8 @@ func (s *Service) PublicRuntimeLimits() (*PublicRuntimeLimits, error) {
}
return &PublicRuntimeLimits{
ActiveTaskLimit: policy.Task.ActiveTaskLimit, ResourceUploadMB: policy.Resource.ResourceUploadMB,
SessionUploadMB: policy.Resource.SessionUploadMB,
SessionUploadMB: policy.Resource.SessionUploadMB,
RecycleBinRetentionDays: policy.Resource.RecycleBinRetentionDays,
}, nil
}
@@ -271,7 +276,7 @@ func (s *Service) readRuntimePolicy() (*model.SystemSetting, RuntimePolicySettin
if err != nil {
return nil, RuntimePolicySetting{}, err
}
value := RuntimePolicySetting{}
value := defaultRuntimePolicy()
if strings.TrimSpace(setting.ValueJSON) == "" || json.Unmarshal([]byte(setting.ValueJSON), &value) != nil {
return nil, RuntimePolicySetting{}, errors.New("资源与请求策略配置格式无效")
}
@@ -312,6 +317,9 @@ func validateRuntimePolicy(value RuntimePolicySetting) error {
return BadAuthRequest(fmt.Sprintf("%s必须是 1-%d 的整数", label, maxRuntimeCount))
}
}
if resource.RecycleBinRetentionDays < 0 || resource.RecycleBinRetentionDays > 365 {
return BadAuthRequest("回收站保留天数必须是 0-365 的整数 (0 表示不自动清理)")
}
task := value.Task
for label, item := range map[string]int{
"Worker 并发数": task.WorkerConcurrency, "全局渠道并发数": task.ChannelConcurrency, "活动任务上限": task.ActiveTaskLimit,

View File

@@ -1,6 +1,6 @@
import { Button, Dropdown, Modal } from "antd";
import { App, Button, Dropdown, Modal, Popconfirm } from "antd";
import type { MenuProps } from "antd";
import { Check, ChevronDown, FileText, FolderOpen, HardDrive, Image as ImageIcon, LoaderCircle, Music2, Puzzle, Search, Upload, UserRound, Video } from "lucide-react";
import { Check, ChevronDown, FileText, FolderOpen, HardDrive, Image as ImageIcon, LoaderCircle, Music2, Puzzle, RotateCcw, Search, Trash2, Upload, UserRound, Video } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { AssetMediaPreview } from "@/components/asset-media-preview";
@@ -9,7 +9,8 @@ import { CachedResourceImage } from "@/components/cached-resource-image";
import { PaginationBar } from "@/components/layout/workspace-page";
import { cn } from "@/lib/utils";
import type { ExternalAssetPickerReference } from "@/lib/plugins/plugin-types";
import type { Asset } from "@/stores/use-asset-store";
import { flushAssetStorePersistence, useAssetStore, type Asset } from "@/stores/use-asset-store";
import { deleteAssetWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
export type AssetLibraryPickerItem = {
id: string;
@@ -91,6 +92,7 @@ export function AssetLibraryPickerModal({
onConfirm,
onFolderAction,
}: Props) {
const { message } = App.useApp();
const [category, setCategory] = useState(initialCategory);
const [folderId, setFolderId] = useState(initialFolderId);
const [source, setSource] = useState<"local" | "plugin">("local");
@@ -112,26 +114,34 @@ export function AssetLibraryPickerModal({
itemsRef.current = allItems;
const localItems = useMemo(() => allItems.filter((item) => !item.external), [allItems]);
const pluginItems = useMemo(() => allItems.filter((item) => Boolean(item.external)), [allItems]);
const hasPluginSource = useMemo(
() => Object.keys(categoryLabels).some((value) => value.startsWith("external:")) || pluginItems.some((item) => item.category.startsWith("external:")),
[categoryLabels, pluginItems],
);
const hasPluginSource = useMemo(() => Object.keys(categoryLabels).some((value) => value.startsWith("external:")) || pluginItems.some((item) => item.category.startsWith("external:")), [categoryLabels, pluginItems]);
const sourceItems = source === "plugin" ? pluginItems : localItems;
const sourceFolders = source === "plugin" ? folders : [];
const showCategories = source === "local" || !sourceFolders.length;
const categories = useMemo(() => ["all", ...Array.from(new Set(sourceItems.map((item) => item.category || "other")))], [sourceItems]);
const normalCategories = useMemo(() => ["all", ...Array.from(new Set(sourceItems.map((item) => item.category || "other"))).filter((c) => c !== "all" && c !== "archived")], [sourceItems]);
const archivedCount = useMemo(() => sourceItems.filter((item) => item.category === "archived").length, [sourceItems]);
const isRecycleBin = category === "archived";
const visibleItems = useMemo(() => {
const query = keyword.trim().toLowerCase();
return sourceItems.filter((item) => {
if (category !== "all" && item.category !== category) return false;
if (category === "all") {
if (item.category === "archived") return false;
} else if (item.category !== category) {
return false;
}
if (folderId !== "all" && (item.folderId || "") !== folderId) return false;
return !query || [item.title, item.searchText || "", item.description || ""].join(" ").toLowerCase().includes(query);
});
}, [category, folderId, keyword, sourceItems]);
const selectedIds = useMemo(() => Array.from(selected).filter((id) => {
const item = allItems.find((entry) => entry.id === id);
return !item?.disabledReason;
}), [allItems, selected]);
const selectedIds = useMemo(
() =>
Array.from(selected).filter((id) => {
const item = allItems.find((entry) => entry.id === id);
return !item?.disabledReason;
}),
[allItems, selected],
);
useEffect(() => {
if (!open) return;
@@ -149,9 +159,9 @@ export function AssetLibraryPickerModal({
}, [initialCategory, initialFolderId, open]);
useEffect(() => {
if (category === "all" || categories.includes(category)) return;
if (category === "all" || category === "archived" || normalCategories.includes(category)) return;
setCategory("all");
}, [categories, category]);
}, [normalCategories, category]);
useEffect(() => {
if (hasPluginSource || source === "local") return;
@@ -191,6 +201,65 @@ export function AssetLibraryPickerModal({
}
};
const handleRestoreSelected = async () => {
if (!selectedIds.length) return;
setWorking(true);
try {
for (const id of selectedIds) {
useAssetStore.getState().updateAsset(id, { status: "confirmed" });
}
await flushAssetStorePersistence();
await saveRemoteUserDataNow();
setSelected(new Set());
message.success(`已还原 ${selectedIds.length} 个素材至素材库`);
setCategory("all");
} catch {
message.warning("已在本地还原,稍后自动同步至云端");
} finally {
setWorking(false);
}
};
const handleDeleteSelected = async () => {
if (!selectedIds.length) return;
setWorking(true);
try {
for (const id of selectedIds) {
await useAssetStore.getState().removeAsset(id);
await deleteAssetWithRemoteSync(id);
}
await flushAssetStorePersistence();
await saveRemoteUserDataNow();
setSelected(new Set());
message.success(`已彻底删除 ${selectedIds.length} 个素材`);
} catch (err) {
message.error(err instanceof Error ? err.message : "删除失败");
} finally {
setWorking(false);
}
};
const handleEmptyRecycleBin = async () => {
const toDelete = sourceItems.filter((item) => item.category === "archived");
if (!toDelete.length) return;
setWorking(true);
try {
for (const item of toDelete) {
await useAssetStore.getState().removeAsset(item.id);
await deleteAssetWithRemoteSync(item.id);
}
await flushAssetStorePersistence();
await saveRemoteUserDataNow();
setSelected(new Set());
message.success(`已清空回收站 ${toDelete.length} 个素材`);
setCategory("all");
} catch (err) {
message.error(err instanceof Error ? err.message : "清空回收站失败");
} finally {
setWorking(false);
}
};
const handleUpload = async (files: FileList | null) => {
if (!files?.length || working || (source === "local" && !upload) || (source === "plugin" && !upload?.external)) return;
setWorking(true);
@@ -228,13 +297,35 @@ export function AssetLibraryPickerModal({
}
};
const countFor = (value: string) => value === "all" ? sourceItems.length : sourceItems.filter((item) => item.category === value).length;
const countFor = (value: string) => (value === "all" ? sourceItems.filter((item) => item.category !== "archived").length : sourceItems.filter((item) => item.category === value).length);
const sourceLabel = source === "plugin" ? "插件来源" : "本地素材";
const sourceMenuItems: MenuProps["items"] = [
{ key: "local", icon: <HardDrive aria-hidden="true" />, label: <span className="asset-picker-source-menu-label"><span>本地素材</span><em>{localItems.length}</em></span> },
...(hasPluginSource ? [{ key: "plugin", icon: <Puzzle aria-hidden="true" />, label: <span className="asset-picker-source-menu-label"><span>插件来源</span><em>{pluginItems.length}</em></span> }] : []),
{
key: "local",
icon: <HardDrive aria-hidden="true" />,
label: (
<span className="asset-picker-source-menu-label">
<span>本地素材</span>
<em>{localItems.filter((i) => i.category !== "archived").length}</em>
</span>
),
},
...(hasPluginSource
? [
{
key: "plugin",
icon: <Puzzle aria-hidden="true" />,
label: (
<span className="asset-picker-source-menu-label">
<span>插件来源</span>
<em>{pluginItems.length}</em>
</span>
),
},
]
: []),
];
const activeUpload = source === "plugin" ? upload?.external : upload;
const activeUpload = isRecycleBin ? undefined : source === "plugin" ? upload?.external : upload;
const uploading = uploadingCount > 0;
return (
@@ -271,31 +362,76 @@ export function AssetLibraryPickerModal({
}}
>
<button type="button" className="asset-picker-title-trigger" aria-haspopup="menu" aria-expanded={sourceMenuOpen} aria-label={"素材库来源:" + sourceLabel}>
<strong>{title}</strong><ChevronDown aria-hidden="true" />
<strong>{isRecycleBin ? "回收站" : title}</strong>
<ChevronDown aria-hidden="true" />
</button>
</Dropdown>
</div>
</div>
<label className="asset-picker-search"><Search aria-hidden /><input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索素材名称或标签" aria-label="搜索素材" /></label>
<span className="asset-picker-count">已选 {selectedIds.length} · {pagination ? pagination.total : visibleItems.length} 个素材</span>
<label className="asset-picker-search">
<Search aria-hidden />
<input value={keyword} onChange={(event) => setKeyword(event.target.value)} placeholder="搜索素材名称或标签" aria-label="搜索素材" />
</label>
<span className="asset-picker-count">
已选 {selectedIds.length} · {pagination ? pagination.total : visibleItems.length} 个素材
</span>
</header>
<div className="asset-picker-body">
<nav className="asset-picker-categories" aria-label="素材分类">
{sourceFolders.length ? <><span className="asset-picker-nav-label">文件夹</span><button type="button" className={cn("assets-filter-item", folderId === "all" && "is-active")} aria-pressed={folderId === "all"} onClick={() => setFolderId("all")}><span className="assets-filter-item-label">全部文件夹</span><span className="assets-filter-count">{sourceItems.length}</span></button>{renderPickerFolders(sourceFolders, sourceItems, folderId, setFolderId)}</> : null}
{showCategories ? <><span className="asset-picker-nav-label">分类</span>{categories.map((value) => (
<button key={value} type="button" className={cn("assets-filter-item", category === value && "is-active")} aria-pressed={category === value} onClick={() => setCategory(value)}>
<span className="assets-filter-item-label">{categoryLabels[value] || "其他"}</span><span className="assets-filter-count">{countFor(value)}</span>
</button>
))}</> : null}
{sourceFolders.length ? (
<>
<span className="asset-picker-nav-label">文件夹</span>
<button type="button" className={cn("assets-filter-item", folderId === "all" && "is-active")} aria-pressed={folderId === "all"} onClick={() => setFolderId("all")}>
<span className="assets-filter-item-label">全部文件夹</span>
<span className="assets-filter-count">{sourceItems.length}</span>
</button>
{renderPickerFolders(sourceFolders, sourceItems, folderId, setFolderId)}
</>
) : null}
{showCategories ? (
<>
<span className="asset-picker-nav-label">分类</span>
{normalCategories.map((value) => (
<button key={value} type="button" className={cn("assets-filter-item", category === value && "is-active")} aria-pressed={category === value} onClick={() => setCategory(value)}>
<span className="assets-filter-item-label">{categoryLabels[value] || (value === "all" ? "全部素材" : "其他")}</span>
<span className="assets-filter-count">{countFor(value)}</span>
</button>
))}
{archivedCount > 0 ? (
<div className="mt-3 border-t border-border/40 pt-2">
<button
type="button"
className={cn("assets-filter-item text-amber-500 hover:text-amber-400 dark:text-amber-400", category === "archived" && "is-active !bg-amber-500/10")}
aria-pressed={category === "archived"}
onClick={() => setCategory("archived")}
>
<span className="assets-filter-item-label flex items-center gap-1.5">
<Trash2 className="size-3.5" />
<span>回收站</span>
</span>
<span className="assets-filter-count">{archivedCount}</span>
</button>
</div>
) : null}
</>
) : null}
</nav>
<div className="asset-picker-grid-wrap">
<div className="asset-picker-grid">
{loading ? (
<div className="asset-picker-empty"><LoaderCircle className="animate-spin" /><strong>正在读取素材</strong><span>素材会按页加载,不会一次下载整个项目库。</span></div>
) : visibleItems.length ? visibleItems.map((item) => (
<PickerCard key={item.id} item={item} selected={selected.has(item.id)} onToggle={() => toggle(item)} />
)) : (
<div className="asset-picker-empty"><FolderOpen /><strong>{emptyTitle}</strong><span>{activeUpload ? "换个分类,或从底部上传一份新素材。" : emptyDescription}</span></div>
<div className="asset-picker-empty">
<LoaderCircle className="animate-spin" />
<strong>正在读取素材</strong>
<span>素材会按页加载,不会一次下载整个项目库。</span>
</div>
) : visibleItems.length ? (
visibleItems.map((item) => <PickerCard key={item.id} item={item} selected={selected.has(item.id)} onToggle={() => toggle(item)} />)
) : (
<div className="asset-picker-empty">
<FolderOpen />
<strong>{isRecycleBin ? "回收站是空的" : emptyTitle}</strong>
<span>{isRecycleBin ? "删除画布或手动归档的素材会暂存到这里,可在需要时还原。" : activeUpload ? "换个分类,或从底部上传一份新素材。" : emptyDescription}</span>
</div>
)}
</div>
{pagination ? <PaginationBar alwaysShow current={pagination.current} pageSize={pagination.pageSize} total={pagination.total} itemLabel="项" pageSizeOptions={[20, 40, 80]} onChange={pagination.onChange} /> : null}
@@ -307,17 +443,57 @@ export function AssetLibraryPickerModal({
<input ref={uploadInputRef} type="file" hidden accept={activeUpload.accept} multiple={multiple} onChange={(event) => void handleUpload(event.target.files)} />
<button type="button" className="asset-picker-upload" onClick={() => uploadInputRef.current?.click()} disabled={working} aria-busy={uploading}>
{uploading ? <LoaderCircle className="animate-spin" /> : <Upload />}
<span><strong>{uploading ? `正在上传 ${uploadingCount} 个素材` : "上传新素材"}</strong><small>{uploading ? "保存完成后会自动选中" : activeUpload.description}</small></span>
<span>
<strong>{uploading ? `正在上传 ${uploadingCount} 个素材` : "上传新素材"}</strong>
<small>{uploading ? "保存完成后会自动选中" : activeUpload.description}</small>
</span>
</button>
</>
) : footerNote ? <span className="asset-picker-footer-note">{footerNote}</span> : <span />}
{error ? <span className="asset-picker-footer-error" role="alert">{error}</span> : null}
) : footerNote ? (
<span className="asset-picker-footer-note">{footerNote}</span>
) : (
<span />
)}
{error ? (
<span className="asset-picker-footer-error" role="alert">
{error}
</span>
) : null}
<div className="asset-picker-actions">
{onFolderAction && folderId !== "all" && (folderActionSource !== "local" || source === "local") ? <Button type="text" icon={<FolderOpen />} disabled={working} onClick={() => void runFolderAction()}>{folderActionLabel}</Button> : null}
<Button type="text" onClick={onClose} disabled={working}>取消</Button>
<Button type="primary" icon={<Check />} disabled={working || !selectedIds.length} loading={working && !uploading} onClick={() => void confirm()}>
{confirmLabel(selectedIds.length)}
</Button>
{isRecycleBin ? (
<>
<Popconfirm title="确认清空回收站?" description="清空后所有回收站素材及其物理文件将被彻底删除,不可恢复。" onConfirm={handleEmptyRecycleBin} okText="清空" okButtonProps={{ danger: true }} cancelText="取消">
<Button type="text" danger disabled={working || !archivedCount}>
清空回收站
</Button>
</Popconfirm>
<Popconfirm title="确认彻底删除已选素材?" onConfirm={handleDeleteSelected} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
<Button type="text" danger disabled={working || !selectedIds.length}>
彻底删除
</Button>
</Popconfirm>
<Button type="text" onClick={onClose} disabled={working}>
关闭
</Button>
<Button type="primary" icon={<RotateCcw className="size-3.5" />} disabled={working || !selectedIds.length} loading={working} onClick={handleRestoreSelected}>
还原已选素材{selectedIds.length ? `(${selectedIds.length})` : ""}
</Button>
</>
) : (
<>
{onFolderAction && folderId !== "all" && (folderActionSource !== "local" || source === "local") ? (
<Button type="text" icon={<FolderOpen />} disabled={working} onClick={() => void runFolderAction()}>
{folderActionLabel}
</Button>
) : null}
<Button type="text" onClick={onClose} disabled={working}>
取消
</Button>
<Button type="primary" icon={<Check />} disabled={working || !selectedIds.length} loading={working && !uploading} onClick={() => void confirm()}>
{confirmLabel(selectedIds.length)}
</Button>
</>
)}
</div>
</footer>
</div>
@@ -331,14 +507,32 @@ function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem
<AssetLibraryCard selected={selected} className={cn("asset-picker-card", disabled && "is-disabled")}>
<button type="button" className="asset-picker-card-action" onClick={onToggle} disabled={disabled} aria-pressed={selected} title={item.disabledReason || item.title}>
<div className="assets-cover asset-picker-card-media">
{item.imageUrl || item.imageStorageKey ? <CachedResourceImage storageKey={item.imageStorageKey} src={item.imageUrl} alt={item.title} loading="lazy" decoding="async" className={item.imageFit === "contain" ? "is-contain" : undefined} fallback={<div className="assets-cover-fallback">{kindIcon(item.kindLabel)}</div>} /> : <AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="assets-cover-fallback">{kindIcon(item.kindLabel)}</div>} />}
{item.imageUrl || item.imageStorageKey ? (
<CachedResourceImage
storageKey={item.imageStorageKey}
src={item.imageUrl}
alt={item.title}
loading="lazy"
decoding="async"
className={item.imageFit === "contain" ? "is-contain" : undefined}
fallback={<div className="assets-cover-fallback">{kindIcon(item.kindLabel)}</div>}
/>
) : (
<AssetMediaPreview asset={item.asset} alt={item.title} fallback={<div className="assets-cover-fallback">{kindIcon(item.kindLabel)}</div>} />
)}
<span className="assets-cover-vignette" aria-hidden="true" />
<span className="assets-cover-badges" aria-hidden="true">
<span className="assets-cover-badge is-kind">{item.kindLabel}</span></span>
<span className="asset-picker-card-check" aria-hidden="true"><Check /></span>
<span className="assets-cover-badge is-kind">{item.kindLabel}</span>
</span>
<span className="asset-picker-card-check" aria-hidden="true">
<Check />
</span>
{item.disabledReason ? <span className="asset-picker-card-lock">{item.disabledReason}</span> : null}
</div>
<div className="asset-picker-card-copy"><strong>{item.title || "未命名素材"}</strong>{item.description ? <span>{item.description}</span> : null}</div>
<div className="asset-picker-card-copy">
<strong>{item.title || "未命名素材"}</strong>
{item.description ? <span>{item.description}</span> : null}
</div>
</button>
</AssetLibraryCard>
);
@@ -346,17 +540,28 @@ function PickerCard({ item, selected, onToggle }: { item: AssetLibraryPickerItem
function renderPickerFolders(folders: AssetLibraryPickerFolder[], items: AssetLibraryPickerItem[], selectedId: string, onSelect: (folderId: string) => void, parentId = "", depth = 0, visited: ReadonlySet<string> = new Set()): ReactNode {
if (depth >= 8) return null;
return folders.filter((folder) => (folder.parentId || "") === parentId && !visited.has(folder.id)).map((folder) => {
const nextVisited = new Set(visited).add(folder.id);
return (
<span key={folder.id} className="contents">
<button type="button" className={cn("assets-filter-item", selectedId === folder.id && "is-active")} aria-pressed={selectedId === folder.id} onClick={() => onSelect(folder.id)} style={{ paddingLeft: `calc(var(--space-3) + ${depth} * var(--space-3))` }}>
<span className="assets-filter-item-label" title={folder.name}>{folder.name}</span><span className="assets-filter-count">{items.filter((item) => item.folderId === folder.id).length}</span>
</button>
{renderPickerFolders(folders, items, selectedId, onSelect, folder.id, depth + 1, nextVisited)}
</span>
);
});
return folders
.filter((folder) => (folder.parentId || "") === parentId && !visited.has(folder.id))
.map((folder) => {
const nextVisited = new Set(visited).add(folder.id);
return (
<span key={folder.id} className="contents">
<button
type="button"
className={cn("assets-filter-item", selectedId === folder.id && "is-active")}
aria-pressed={selectedId === folder.id}
onClick={() => onSelect(folder.id)}
style={{ paddingLeft: `calc(var(--space-3) + ${depth} * var(--space-3))` }}
>
<span className="assets-filter-item-label" title={folder.name}>
{folder.name}
</span>
<span className="assets-filter-count">{items.filter((item) => item.folderId === folder.id).length}</span>
</button>
{renderPickerFolders(folders, items, selectedId, onSelect, folder.id, depth + 1, nextVisited)}
</span>
);
});
}
function kindIcon(label: string): ReactNode {

View File

@@ -33,12 +33,15 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, eag
setNearViewport(true);
return;
}
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setNearViewport(true);
observer.disconnect();
}
}, { rootMargin: "240px" });
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
setNearViewport(true);
observer.disconnect();
}
},
{ rootMargin: "240px" },
);
observer.observe(image);
return () => observer.disconnect();
}, [eager, remoteResource]);
@@ -50,47 +53,61 @@ export function CachedResourceImage({ storageKey, src = "", fallback = null, eag
if (remoteResource && storageKey) {
if (!nearViewport) {
setCachedSrc("");
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}
setCachedSrc("");
const resolve = cacheResourceObjectUrl(storageKey);
void resolve.then((url) => {
if (!cancelled) setCachedSrc(url || src);
}).catch(() => {
if (!cancelled) {
setCacheFailed(true);
setCachedSrc(src);
}
});
return () => { cancelled = true; };
void resolve
.then((url) => {
if (!cancelled) setCachedSrc(url || src);
})
.catch(() => {
if (!cancelled) {
setCacheFailed(true);
setCachedSrc(src);
}
});
return () => {
cancelled = true;
};
}
if (localImageResource && storageKey) {
void resolveImageUrl(storageKey, src).then((url) => {
if (!cancelled) setCachedSrc(url || src);
}).catch(() => {
if (!cancelled) setCachedSrc(src);
});
return () => { cancelled = true; };
void resolveImageUrl(storageKey, src)
.then((url) => {
if (!cancelled) setCachedSrc(url || src);
})
.catch(() => {
if (!cancelled) setCachedSrc(src);
});
return () => {
cancelled = true;
};
}
setCachedSrc(src);
return () => { cancelled = true; };
return () => {
cancelled = true;
};
}, [localImageResource, nearViewport, remoteResource, src, storageKey]);
const handleImgError = (e: React.SyntheticEvent<HTMLImageElement, Event>) => {
if (localImageResource && storageKey && cachedSrc.startsWith("blob:")) {
void resolveImageUrl(storageKey).then((url) => {
if (url && url !== cachedSrc) {
setCachedSrc(url);
return;
}
setCacheFailed(true);
onError?.(e);
}).catch(() => {
setCacheFailed(true);
onError?.(e);
});
void resolveImageUrl(storageKey)
.then((url) => {
if (url && url !== cachedSrc) {
setCachedSrc(url);
return;
}
setCacheFailed(true);
onError?.(e);
})
.catch(() => {
setCacheFailed(true);
onError?.(e);
});
return;
}
setCacheFailed(true);

View File

@@ -13,7 +13,21 @@ export type InsertAssetPayload =
| { kind: "image"; dataUrl: string; title: string; url?: string; storageKey?: string; width?: number; height?: number; bytes?: number; mimeType?: string; assetId?: string }
| { kind: "video"; url: string; title: string; storageKey?: string; width?: number; height?: number; durationMs?: number; hasAudio?: boolean; bytes?: number; mimeType?: string; assetId?: string }
| { kind: "audio"; url: string; title: string; storageKey?: string; durationMs?: number; bytes?: number; mimeType?: string; assetId?: string }
| { kind: "character"; title: string; assetId: string; versionId: string; prompt: string; aliases: string[]; definition: Record<string, unknown>; coverUrl?: string; visualStatus: string; voiceStatus: string; voiceName?: string; voiceProfile?: { name: string; provider: string; language: string; timbre: string }; voiceInstructions?: string };
| {
kind: "character";
title: string;
assetId: string;
versionId: string;
prompt: string;
aliases: string[];
definition: Record<string, unknown>;
coverUrl?: string;
visualStatus: string;
voiceStatus: string;
voiceName?: string;
voiceProfile?: { name: string; provider: string; language: string; timbre: string };
voiceInstructions?: string;
};
type Props = {
open: boolean;
@@ -22,23 +36,26 @@ type Props = {
onClose: () => void;
};
const categoryLabels: Record<string, string> = { all: "全部素材", ...ASSET_CATEGORY_LABELS };
const categoryLabels: Record<string, string> = { all: "全部素材", ...ASSET_CATEGORY_LABELS, archived: "回收站" };
export function AssetPickerModal({ open, multiple = true, onInsert, onClose }: Props) {
const assets = useAssetStore((state) => state.assets);
const externalAssetSources = useExternalAssetSources(open);
const insertableAssets = useMemo(() => assets.filter((asset): asset is InsertableAsset => asset.kind === "text" || asset.kind === "image" || asset.kind === "video" || asset.kind === "audio"), [assets]);
const items = useMemo<AssetLibraryPickerItem[]>(() => [
...insertableAssets.map((asset) => ({
id: asset.id,
title: asset.title,
category: normalizeAssetCategory(asset.category),
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
asset,
searchText: (asset.tags || []).join(" "),
})),
...externalAssetSources.items,
], [externalAssetSources.items, insertableAssets]);
const items = useMemo<AssetLibraryPickerItem[]>(
() => [
...insertableAssets.map((asset) => ({
id: asset.id,
title: asset.title,
category: asset.status === "archived" ? "archived" : normalizeAssetCategory(asset.category),
kindLabel: asset.kind === "image" ? "图片" : asset.kind === "video" ? "视频" : asset.kind === "audio" ? "音频" : "文本",
asset,
searchText: (asset.tags || []).join(" "),
})),
...externalAssetSources.items,
],
[externalAssetSources.items, insertableAssets],
);
return (
<AssetLibraryPickerModal
@@ -76,7 +93,20 @@ export function assetPickerItemsToInsertPayloads(ids: string[], items: AssetLibr
function localAssetToInsertPayload(asset: InsertableAsset): InsertAssetPayload {
if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id };
if (asset.kind === "audio") return { kind: "audio", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
if (asset.kind === "video") return { kind: "video", url: asset.data.url, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, hasAudio: asset.data.hasAudio, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
if (asset.kind === "video")
return {
kind: "video",
url: asset.data.url,
storageKey: asset.data.storageKey,
title: asset.title,
width: asset.data.width,
height: asset.data.height,
durationMs: asset.data.durationMs,
hasAudio: asset.data.hasAudio,
bytes: asset.data.bytes,
mimeType: asset.data.mimeType,
assetId: asset.id,
};
return { kind: "image", dataUrl: asset.data.dataUrl, storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
}

View File

@@ -0,0 +1,243 @@
import { useMemo, useState } from "react";
import { Drawer, Tag, Button, Input, Popconfirm, Empty, Tooltip } from "antd";
import { Clock, Clock3, ExternalLink, History, Search, Trash2, X } from "lucide-react";
import { useNavigate } from "react-router";
import { useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
import { cn } from "@/lib/utils";
type TimelineItem = {
id: string;
title: string;
isDeleted: boolean;
createdAt: string;
updatedAt: string;
deletedAt?: string;
nodeCount: number;
timelineTime: string;
};
export function CanvasHistoryDrawer({ open, onClose }: { open: boolean; onClose: () => void }) {
const navigate = useNavigate();
const activeProjects = useCanvasStore((state) => state.projects);
const deletedProjects = useCanvasHistoryStore((state) => state.deletedProjects);
const removeDeletedItem = useCanvasHistoryStore((state) => state.removeDeletedHistoryItem);
const clearDeletedHistory = useCanvasHistoryStore((state) => state.clearDeletedHistory);
const [keyword, setKeyword] = useState("");
const [filter, setFilter] = useState<"all" | "active" | "deleted">("all");
const timelineItems = useMemo<TimelineItem[]>(() => {
const activeList: TimelineItem[] = activeProjects.map((p) => ({
id: p.id,
title: p.title || "未命名画布",
isDeleted: false,
createdAt: p.createdAt || p.updatedAt || new Date().toISOString(),
updatedAt: p.updatedAt || new Date().toISOString(),
nodeCount: p.nodes?.length || 0,
timelineTime: p.createdAt || p.updatedAt || new Date().toISOString(),
}));
const deletedList: TimelineItem[] = deletedProjects.map((d) => ({
id: d.id,
title: d.title || "未命名画布",
isDeleted: true,
createdAt: d.createdAt,
updatedAt: d.updatedAt,
deletedAt: d.deletedAt,
nodeCount: d.nodeCount,
timelineTime: d.deletedAt || d.createdAt,
}));
const all = [...activeList, ...deletedList];
all.sort((a, b) => new Date(b.timelineTime).getTime() - new Date(a.timelineTime).getTime());
return all;
}, [activeProjects, deletedProjects]);
const filteredItems = useMemo(() => {
const q = keyword.trim().toLowerCase();
return timelineItems.filter((item) => {
if (filter === "active" && item.isDeleted) return false;
if (filter === "deleted" && !item.isDeleted) return false;
if (!q) return true;
return item.title.toLowerCase().includes(q);
});
}, [filter, keyword, timelineItems]);
const openProject = (id: string) => {
navigate(`/canvas/${id}`);
onClose();
};
return (
<Drawer
title={
<div className="flex items-center justify-between gap-2 pr-2">
<div className="flex items-center gap-2">
<History className="size-4 text-[var(--workspace-accent)]" />
<span className="text-sm font-semibold text-stone-900 dark:text-stone-100">画布创建与变更历史时间线</span>
</div>
{deletedProjects.length > 0 ? (
<Popconfirm title="确定清空已删除画布的历史记录?" onConfirm={clearDeletedHistory} okText="清空" okButtonProps={{ danger: true }} cancelText="取消">
<Button type="text" size="small" danger icon={<Trash2 className="size-3.5" />}>
清理删除记录
</Button>
</Popconfirm>
) : null}
</div>
}
placement="right"
width={460}
open={open}
onClose={onClose}
className="workspace-drawer canvas-history-drawer"
styles={{
header: { borderBottom: "1px solid var(--border-color, rgba(255,255,255,0.08))" },
body: { padding: "16px 20px" },
}}
>
<div className="space-y-4">
{/* 搜索与筛选 */}
<div className="space-y-3">
<Input allowClear prefix={<Search className="size-3.5 text-stone-400" />} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="搜索画布名称..." className="text-xs" />
<div className="flex items-center gap-2 text-xs">
<button
type="button"
className={cn(
"cursor-pointer rounded-lg px-3 py-1.5 font-medium transition-all",
filter === "all"
? "!bg-stone-900 !text-white shadow-sm dark:!bg-stone-100 dark:!text-stone-900 font-semibold"
: "bg-stone-100 text-stone-700 hover:bg-stone-200 dark:bg-stone-800 dark:text-stone-300 dark:hover:bg-stone-700",
)}
onClick={() => setFilter("all")}
>
全部 ({timelineItems.length})
</button>
<button
type="button"
className={cn(
"cursor-pointer rounded-lg px-3 py-1.5 font-medium transition-all",
filter === "active"
? "!bg-emerald-600 !text-white shadow-sm font-semibold"
: "bg-emerald-50 text-emerald-800 hover:bg-emerald-100 border border-emerald-200/60 dark:bg-emerald-950/40 dark:text-emerald-300 dark:border-emerald-800/50",
)}
onClick={() => setFilter("active")}
>
活跃中 ({activeProjects.length})
</button>
<button
type="button"
className={cn(
"cursor-pointer rounded-lg px-3 py-1.5 font-medium transition-all",
filter === "deleted" ? "!bg-rose-600 !text-white shadow-sm font-semibold" : "bg-rose-50 text-rose-800 hover:bg-rose-100 border border-rose-200/60 dark:bg-rose-950/40 dark:text-rose-300 dark:border-rose-800/50",
)}
onClick={() => setFilter("deleted")}
>
已删除 ({deletedProjects.length})
</button>
</div>
</div>
{/* 时间线列表 */}
{filteredItems.length === 0 ? (
<div className="py-12">
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无匹配的历史记录" />
</div>
) : (
<div className="relative border-l-2 border-stone-200 dark:border-stone-800 pl-4 space-y-4 pt-1">
{filteredItems.map((item) => {
const isDeleted = item.isDeleted;
return (
<div key={item.id + (item.deletedAt || "")} className="group relative">
{/* 时间轴圆点 */}
<span className={cn("absolute -left-[23px] top-3.5 size-3 rounded-full border-2 border-background", isDeleted ? "bg-rose-500 ring-2 ring-rose-500/20" : "bg-emerald-500 ring-2 ring-emerald-500/20")} />
<div
className={cn(
"rounded-xl border p-3.5 transition-all shadow-sm",
isDeleted
? "border-rose-200/90 bg-rose-50/50 dark:border-rose-900/40 dark:bg-rose-950/20 hover:border-rose-300"
: "border-stone-200 bg-white dark:border-stone-800 dark:bg-stone-900/70 hover:border-stone-300 dark:hover:border-stone-700",
)}
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1 space-y-1.5">
<div className="flex items-center gap-2">
{isDeleted ? (
<span className="truncate text-sm font-semibold text-stone-500 line-through decoration-rose-500/80 decoration-2 dark:text-stone-400" title={item.title}>
{item.title}
</span>
) : (
<button
type="button"
className="truncate text-left text-sm font-semibold text-stone-900 hover:text-blue-600 dark:text-stone-100 dark:hover:text-blue-400 transition-colors"
onClick={() => openProject(item.id)}
title={item.title}
>
{item.title}
</button>
)}
{isDeleted ? (
<Tag color="error" className="m-0 text-[11px] leading-tight px-1.5 py-0.5 font-medium">
已删除
</Tag>
) : (
<Tag color="success" className="m-0 text-[11px] leading-tight px-1.5 py-0.5 font-medium">
活跃中
</Tag>
)}
</div>
<div className="space-y-1 text-xs text-stone-600 dark:text-stone-300">
<div className="flex items-center gap-1.5">
<Clock3 className="size-3.5 text-stone-400 shrink-0" />
<span>创建时间:{formatTimelineDate(item.createdAt)}</span>
</div>
{isDeleted && item.deletedAt ? (
<div className="flex items-center gap-1.5 text-rose-600 dark:text-rose-400 font-medium">
<Trash2 className="size-3.5 text-rose-500 shrink-0" />
<span>删除时间:{formatTimelineDate(item.deletedAt)}</span>
</div>
) : (
<div className="flex items-center gap-1.5 text-stone-500 dark:text-stone-400">
<Clock className="size-3.5 text-stone-400 shrink-0" />
<span>最近更新:{formatTimelineDate(item.updatedAt)}</span>
</div>
)}
<div className="text-[11px] text-stone-500 dark:text-stone-400 pt-0.5">包含 {item.nodeCount} 个节点</div>
</div>
</div>
<div className="flex items-center gap-1 shrink-0 pt-0.5">
{!isDeleted ? (
<Button
type="text"
size="small"
icon={<ExternalLink className="size-4 text-stone-500 hover:text-stone-800 dark:text-stone-400 dark:hover:text-stone-100" />}
onClick={() => openProject(item.id)}
title="打开画布"
/>
) : (
<Tooltip title="从历史列表中移除此条记录">
<Button type="text" size="small" danger icon={<X className="size-4" />} onClick={() => removeDeletedItem(item.id)} />
</Tooltip>
)}
</div>
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</Drawer>
);
}
function formatTimelineDate(isoString: string) {
if (!isoString) return "--";
const date = new Date(isoString);
if (!Number.isFinite(date.getTime())) return "--";
return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, "0")}/${String(date.getDate()).padStart(2, "0")} ${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
}

View File

@@ -13,7 +13,23 @@ import { useAssetStore, type Asset } from "@/stores/use-asset-store";
const categoryLabels: Record<string, string> = { all: "全部资产", ...ASSET_CATEGORY_LABELS };
type ProjectPickerItem = { id: string; category: string; folderId?: string; project?: ProjectAsset; character?: ProjectAsset; media?: Asset };
export function CanvasProjectAssetModal({ open, detail, initialCategory = "all", initialFolderId = "all", onClose, onInsert, onInsertFolder }: { open: boolean; detail?: ProjectDetail; initialCategory?: string; initialFolderId?: string; onClose: () => void; onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void; onInsertFolder?: (folderId: string) => Promise<void> | void }) {
export function CanvasProjectAssetModal({
open,
detail,
initialCategory = "all",
initialFolderId = "all",
onClose,
onInsert,
onInsertFolder,
}: {
open: boolean;
detail?: ProjectDetail;
initialCategory?: string;
initialFolderId?: string;
onClose: () => void;
onInsert: (payloads: InsertAssetPayload[]) => Promise<void> | void;
onInsertFolder?: (folderId: string) => Promise<void> | void;
}) {
const mediaAssets = useAssetStore((state) => state.assets);
const externalAssetSources = useExternalAssetSources(open);
const items = useMemo<ProjectPickerItem[]>(() => {
@@ -25,34 +41,35 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
});
if (detail) return projectItems;
// 自由画布未关联项目时回退到个人素材库。
return mediaAssets
.filter((asset) => asset.kind !== "model" && asset.kind !== "entity")
.map((media): ProjectPickerItem => ({ id: media.id, category: normalizeAssetCategory(media.category), media }));
return mediaAssets.filter((asset) => asset.kind !== "model" && asset.kind !== "entity" && asset.status !== "archived").map((media): ProjectPickerItem => ({ id: media.id, category: normalizeAssetCategory(media.category), media }));
}, [detail?.assets, mediaAssets]);
const localPickerItems = useMemo<AssetLibraryPickerItem[]>(() => items.map((item) => {
const character = item.character;
const project = item.project;
const media = item.media;
const coverRepresentation = character?.character?.representations.find((representation) => representation.role === "turnaround_sheet") || character?.character?.representations.find((representation) => representation.role === "primary") || character?.character?.representations.find((representation) => representation.role === "front");
const remoteResourceId = resourceIdFromStorageKey(project?.storageKey);
return {
id: item.id,
title: character?.title || project?.title || media?.title || "未命名资产",
category: item.category,
folderId: item.folderId,
kindLabel: character ? "角色卡" : (media?.kind || project?.mediaType) === "video" ? "视频" : (media?.kind || project?.mediaType) === "audio" ? "音频" : (media?.kind || project?.mediaType) === "text" ? "文本" : "图片",
asset: media,
imageUrl: coverRepresentation
? resourceFileUrl(coverRepresentation.resourceId)
: project?.mediaType === "image" && remoteResourceId
? resourceFileUrl(remoteResourceId)
: undefined,
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
imageFit: character ? "contain" : "cover",
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText,
searchText: [media?.tags?.join(" ") || "", project?.previewText || ""].join(" "),
};
}), [items]);
const localPickerItems = useMemo<AssetLibraryPickerItem[]>(
() =>
items.map((item) => {
const character = item.character;
const project = item.project;
const media = item.media;
const coverRepresentation =
character?.character?.representations.find((representation) => representation.role === "turnaround_sheet") ||
character?.character?.representations.find((representation) => representation.role === "primary") ||
character?.character?.representations.find((representation) => representation.role === "front");
const remoteResourceId = resourceIdFromStorageKey(project?.storageKey);
return {
id: item.id,
title: character?.title || project?.title || media?.title || "未命名资产",
category: item.category,
folderId: item.folderId,
kindLabel: character ? "角色卡" : (media?.kind || project?.mediaType) === "video" ? "视频" : (media?.kind || project?.mediaType) === "audio" ? "音频" : (media?.kind || project?.mediaType) === "text" ? "文本" : "图片",
asset: media,
imageUrl: coverRepresentation ? resourceFileUrl(coverRepresentation.resourceId) : project?.mediaType === "image" && remoteResourceId ? resourceFileUrl(remoteResourceId) : undefined,
imageStorageKey: coverRepresentation ? `resource:${coverRepresentation.resourceId}` : undefined,
imageFit: character ? "contain" : "cover",
description: character ? `${character.character?.visualStatus === "ready" ? "形象就绪" : "形象待完善"} · ${character.character?.voiceStatus === "ready" ? "声音已绑定" : "声音未绑定"}` : project?.previewText,
searchText: [media?.tags?.join(" ") || "", project?.previewText || ""].join(" "),
};
}),
[items],
);
const pickerItems = useMemo<AssetLibraryPickerItem[]>(() => [...localPickerItems, ...externalAssetSources.items], [externalAssetSources.items, localPickerItems]);
return (
@@ -69,18 +86,27 @@ export function CanvasProjectAssetModal({ open, detail, initialCategory = "all",
emptyTitle="此分类没有可引用资产"
emptyDescription={detail ? "先在项目角色与资产中完成角色确认或素材关联。" : "当前为自由画布,可先在素材库添加内容。"}
footerNote={externalAssetSources.error || "角色引用会在生成时解析当前角色版本"}
onFolderAction={onInsertFolder ? async (folderId) => { await onInsertFolder(folderId); onClose(); } : undefined}
onFolderAction={
onInsertFolder
? async (folderId) => {
await onInsertFolder(folderId);
onClose();
}
: undefined
}
onClose={onClose}
onConfirm={async (ids) => {
const payloads = await Promise.all(ids.map(async (id) => {
const external = externalAssetSources.items.find((item) => item.id === id)?.external;
if (external) return externalAssetToInsertPayload(external);
const item = items.find((candidate) => candidate.id === id);
if (!item) throw new Error("所选资产已不存在,请重新选择");
if (item.media || item.character || !item.project) return toInsertPayload(item);
const { asset } = await getRemoteAsset(item.project.id);
return toInsertPayload({ ...item, media: asset });
}));
const payloads = await Promise.all(
ids.map(async (id) => {
const external = externalAssetSources.items.find((item) => item.id === id)?.external;
if (external) return externalAssetToInsertPayload(external);
const item = items.find((candidate) => candidate.id === id);
if (!item) throw new Error("所选资产已不存在,请重新选择");
if (item.media || item.character || !item.project) return toInsertPayload(item);
const { asset } = await getRemoteAsset(item.project.id);
return toInsertPayload({ ...item, media: asset });
}),
);
if (!payloads.length) return;
await onInsert(payloads);
onClose();
@@ -106,8 +132,30 @@ function toInsertPayload(item: ProjectPickerItem): InsertAssetPayload {
}
if (!asset) throw new Error("项目资产不可用");
if (asset.kind === "text") return { kind: "text", content: asset.data.content, title: asset.title, assetId: asset.id };
if (asset.kind === "video") return { kind: "video", url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url), storageKey: asset.data.storageKey, title: asset.title, width: asset.data.width, height: asset.data.height, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
if (asset.kind === "audio") return { kind: "audio", url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url), storageKey: asset.data.storageKey, title: asset.title, durationMs: asset.data.durationMs, bytes: asset.data.bytes, mimeType: asset.data.mimeType, assetId: asset.id };
if (asset.kind === "video")
return {
kind: "video",
url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url),
storageKey: asset.data.storageKey,
title: asset.title,
width: asset.data.width,
height: asset.data.height,
durationMs: asset.data.durationMs,
bytes: asset.data.bytes,
mimeType: asset.data.mimeType,
assetId: asset.id,
};
if (asset.kind === "audio")
return {
kind: "audio",
url: projectAssetMediaUrl(asset.data.storageKey, asset.data.url),
storageKey: asset.data.storageKey,
title: asset.title,
durationMs: asset.data.durationMs,
bytes: asset.data.bytes,
mimeType: asset.data.mimeType,
assetId: asset.id,
};
if (asset.kind === "image") return { kind: "image", dataUrl: projectAssetMediaUrl(asset.data.storageKey, asset.data.dataUrl), storageKey: asset.data.storageKey, title: asset.title, assetId: asset.id };
throw new Error("当前项目资产不能直接插入画布");
}
@@ -121,7 +169,10 @@ export function projectCharacterToInsertPayload(asset: ProjectAsset): InsertAsse
if (!asset.character) throw new Error("项目角色信息不完整");
const card = asset.character;
const definition = card.definition;
const cover = card.representations.find((representation) => representation.role === "turnaround_sheet") || card.representations.find((representation) => representation.role === "primary") || card.representations.find((representation) => representation.role === "front");
const cover =
card.representations.find((representation) => representation.role === "turnaround_sheet") ||
card.representations.find((representation) => representation.role === "primary") ||
card.representations.find((representation) => representation.role === "front");
return {
kind: "character",
title: asset.title,
@@ -134,12 +185,14 @@ export function projectCharacterToInsertPayload(asset: ProjectAsset): InsertAsse
visualStatus: card.visualStatus,
voiceStatus: card.voiceStatus,
voiceName: card.voice?.profile.name,
voiceProfile: card.voice ? {
name: card.voice.profile.name,
provider: card.voice.profile.provider,
language: card.voice.profile.language,
timbre: card.voice.profile.timbre,
} : undefined,
voiceProfile: card.voice
? {
name: card.voice.profile.name,
provider: card.voice.profile.provider,
language: card.voice.profile.language,
timbre: card.voice.profile.timbre,
}
: undefined,
voiceInstructions: card.voice?.instructions,
};
}

View File

@@ -14,11 +14,15 @@ import { cn } from "@/lib/utils";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
export function CanvasCreateCard({ disabled, onClick }: { disabled?: boolean; onClick: () => void }) {
return <button type="button" className="app-canvas-create-card" disabled={disabled} onClick={onClick}>
<span className="app-canvas-create-preview"><Plus className="app-canvas-create-icon" /></span>
<span className="app-canvas-create-title">新建画布</span>
<span className="app-canvas-create-meta">从空白开始</span>
</button>;
return (
<button type="button" className="app-canvas-create-card" disabled={disabled} onClick={onClick}>
<span className="app-canvas-create-preview">
<Plus className="app-canvas-create-icon" />
</span>
<span className="app-canvas-create-title">新建画布</span>
<span className="app-canvas-create-meta">从空白开始</span>
</button>
);
}
export function CanvasProjectCard({ project, projectName, variant = "library", readOnly = false, footer }: { project: CanvasProject; projectName?: string; variant?: "library" | "recent"; readOnly?: boolean; footer?: ReactNode }) {
@@ -55,14 +59,27 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r
>
<ProjectPreview project={project} />
</button>
{!compact && !readOnly ? <span className={`canvas-project-select ${selected ? "is-visible" : ""}`} onClick={(event) => event.stopPropagation()}><input type="checkbox" checked={selected} onChange={(event) => toggleSelected(project.id, event.target.checked)} className="app-canvas-project-checkbox" aria-label={`选择 ${project.title}`} /></span> : null}
<div className="canvas-project-cover-meta" aria-hidden="true"><span className="canvas-project-node-count">{project.nodes.length} 节点</span></div>
{!compact && !readOnly ? (
<span className={`canvas-project-select ${selected ? "is-visible" : ""}`} onClick={(event) => event.stopPropagation()}>
<input type="checkbox" checked={selected} onChange={(event) => toggleSelected(project.id, event.target.checked)} className="app-canvas-project-checkbox" aria-label={`选择 ${project.title}`} />
</span>
) : null}
<div className="canvas-project-cover-meta" aria-hidden="true">
<span className="canvas-project-node-count">{project.nodes.length} 节点</span>
</div>
</div>
<div className={cn("app-canvas-project-body", compact ? "is-compact" : "")}>
<div className="canvas-project-heading-row">
{editing && !readOnly ? (
<Input className="canvas-project-title-input" value={editingTitle} onClick={(event) => event.stopPropagation()} onChange={(event) => setEditingTitle(event.target.value)} onKeyDown={(event) => event.key === "Enter" && saveTitle()} autoFocus />
<Input
className="canvas-project-title-input"
value={editingTitle}
onClick={(event) => event.stopPropagation()}
onChange={(event) => setEditingTitle(event.target.value)}
onKeyDown={(event) => event.key === "Enter" && saveTitle()}
autoFocus
/>
) : (
<button
type="button"
@@ -77,12 +94,18 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r
)}
{editing && !readOnly ? (
<div className="canvas-project-actions" onClick={(event) => event.stopPropagation()}>
<button type="button" onClick={saveTitle} aria-label="保存名称"><Check className="size-3.5" /></button>
<button type="button" onClick={stopEditing} aria-label="取消重命名"><X className="size-3.5" /></button>
<button type="button" onClick={saveTitle} aria-label="保存名称">
<Check className="size-3.5" />
</button>
<button type="button" onClick={stopEditing} aria-label="取消重命名">
<X className="size-3.5" />
</button>
</div>
) : !readOnly ? (
<div className="canvas-project-actions" onClick={(event) => event.stopPropagation()}>
<button type="button" onClick={() => startEditing(project.id, project.title)} aria-label={`重命名 ${project.title}`} title="重命名"><Pencil className="size-3.5" /></button>
<button type="button" onClick={() => startEditing(project.id, project.title)} aria-label={`重命名 ${project.title}`} title="重命名">
<Pencil className="size-3.5" />
</button>
<Dropdown
trigger={["click"]}
menu={{
@@ -94,13 +117,23 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r
],
}}
>
<button type="button" aria-label={`${project.title} 画布操作`} title="更多操作"><MoreHorizontal className="size-4" /></button>
<button type="button" aria-label={`${project.title} 画布操作`} title="更多操作">
<MoreHorizontal className="size-4" />
</button>
</Dropdown>
</div>
) : null}
</div>
<div className="canvas-project-stats"><span>{projectName || "自由画布"}</span><span aria-hidden="true">·</span><time dateTime={project.updatedAt}>{formatProjectTime(project.updatedAt)}</time></div>
{footer ? <div className="canvas-project-card-footer" onClick={(event) => event.stopPropagation()}>{footer}</div> : null}
<div className="canvas-project-stats">
<span>{projectName || "自由画布"}</span>
<span aria-hidden="true">·</span>
<time dateTime={project.updatedAt}>{formatProjectTime(project.updatedAt)}</time>
</div>
{footer ? (
<div className="canvas-project-card-footer" onClick={(event) => event.stopPropagation()}>
{footer}
</div>
) : null}
</div>
</article>
);
@@ -109,26 +142,31 @@ export function CanvasProjectCard({ project, projectName, variant = "library", r
export function ProjectPreview({ project, preferLatestImage = false }: { project: CanvasProject; preferLatestImage?: boolean }) {
const syncProgress = useSyncProgressStore((state) => state.syncingProjects[project.id]);
const isSyncing = Boolean(syncProgress && (syncProgress.phase === "uploading" || syncProgress.phase === "saving"));
const mediaNodes = project.nodes
.flatMap((node) => {
if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return [];
const url = getNodeMediaUrl(node);
const storageKey = node.metadata?.storageKey;
return isPreviewUrl(url) || Boolean(storageKey) ? [{ node, url, storageKey }] : [];
});
const mediaNodes = project.nodes.flatMap((node) => {
if (node.type !== CanvasNodeType.Image && node.type !== CanvasNodeType.Video) return [];
const url = getNodeMediaUrl(node);
const storageKey = node.metadata?.storageKey;
return isPreviewUrl(url) || Boolean(storageKey) ? [{ node, url, storageKey }] : [];
});
const imageNodes = mediaNodes.filter(({ node }) => node.type === CanvasNodeType.Image);
const media = preferLatestImage
? imageNodes[imageNodes.length - 1] || mediaNodes[mediaNodes.length - 1]
: imageNodes[0] || mediaNodes[0];
const media = preferLatestImage ? imageNodes[imageNodes.length - 1] || mediaNodes[mediaNodes.length - 1] : imageNodes[0] || mediaNodes[0];
const content = media ? (
<div className="canvas-project-media size-full">
{media.node.type === CanvasNodeType.Video
? <div className="canvas-project-video size-full"><Video className="size-8" aria-label={media.node.title || "项目视频"} /></div>
: <CachedResourceImage storageKey={media.storageKey} src={media.url} alt={media.node.title || "项目图片"} loading="lazy" decoding="async" className="size-full min-h-0 object-cover" />}
{media.node.type === CanvasNodeType.Video ? (
<div className="canvas-project-video size-full">
<Video className="size-8" aria-label={media.node.title || "项目视频"} />
</div>
) : (
<CachedResourceImage storageKey={media.storageKey} src={media.url} alt={media.node.title || "项目图片"} loading="lazy" decoding="async" className="size-full min-h-0 object-cover" />
)}
</div>
) : !project.nodes.length ? (
<div className="canvas-project-empty size-full"><Plus className="canvas-project-empty-icon" /><span>空白画布</span><small>等待第一幕</small></div>
<div className="canvas-project-empty size-full">
<Plus className="canvas-project-empty-icon" />
<span>空白画布</span>
<small>等待第一幕</small>
</div>
) : (
<div className="canvas-project-preview-canvas relative size-full overflow-hidden">
{buildNodePreviewLayout(project.nodes.slice(0, 8)).map(({ node, style }) => {
@@ -147,10 +185,7 @@ export function ProjectPreview({ project, preferLatestImage = false }: { project
<div className="relative size-full overflow-hidden">
{content}
{isSyncing && syncProgress ? (
<div
className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-stone-950/75 p-3 text-center backdrop-blur-sm transition-all duration-300 pointer-events-none select-none"
onClick={(e) => e.stopPropagation()}
>
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-2 bg-stone-950/75 p-3 text-center backdrop-blur-sm transition-all duration-300 pointer-events-none select-none" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-1.5 text-amber-400">
<CloudUpload className="size-4 animate-bounce" />
<span className="text-xs font-medium tracking-wide">云端同步中</span>
@@ -230,7 +265,7 @@ function getNodePresentation(node: CanvasNodeData) {
}
function isPreviewUrl(value?: string) {
return Boolean(value && (/^(https?:|blob:|data:image\/|data:video\/|\/api\/)/.test(value)));
return Boolean(value && /^(https?:|blob:|data:image\/|data:video\/|\/api\/)/.test(value));
}
export function formatProjectTime(value: string) {

View File

@@ -11,7 +11,7 @@ import { AdminStatTile, AdminStatusBadge, SettingsSectionCard } from "../compone
type PolicyGroup = "resource" | "task" | "request";
type RuntimePolicyDraft = Pick<RuntimePolicySetting, "resource" | "task" | "request">;
type PolicyField = { group: PolicyGroup; name: string; label: string; extra: string; unit: string; max: number };
type PolicyField = { group: PolicyGroup; name: string; label: string; extra: string; unit: string; min?: number; max: number };
type PolicySectionDefinition = {
id: string;
icon: ReactNode;
@@ -35,6 +35,7 @@ const resourceFields: PolicyField[] = [
{ group: "resource", name: "sessionCount", label: "Agent 会话数量", extra: "单账号可保存的 Agent 会话数量。", unit: "个", max: 999_999_999 },
{ group: "resource", name: "taskCount", label: "任务历史数量", extra: "单账号保留的任务历史记录数。", unit: "条", max: 999_999_999 },
{ group: "resource", name: "apiCallLogCount", label: "请求日志数量", extra: "单账号保留的上游请求日志数。", unit: "条", max: 999_999_999 },
{ group: "resource", name: "recycleBinRetentionDays", label: "回收站自动清理时间", extra: "素材移入回收站后自动彻底删除的天数,0 表示不自动清理。", unit: "天", min: 0, max: 365 },
];
const concurrencyFields: PolicyField[] = [
@@ -472,6 +473,7 @@ function PolicySection({ icon, title, description, fields, status }: PolicySecti
<div className="admin-runtime-policy-field-grid">
{fields.map((field) => {
const inputId = `admin-runtime-policy-${field.group}-${field.name}`;
const min = field.min ?? 1;
return (
<Form.Item key={`${field.group}.${field.name}`} label={field.label} htmlFor={inputId} extra={field.extra}>
<div className="admin-runtime-policy-number-control">
@@ -480,10 +482,10 @@ function PolicySection({ icon, title, description, fields, status }: PolicySecti
name={[field.group, field.name]}
rules={[
{ required: true, message: `请填写${field.label}` },
{ type: "number", min: 1, max: field.max, message: `${field.label}必须是 1-${field.max} 的整数` },
{ type: "number", min, max: field.max, message: `${field.label}必须是 ${min}-${field.max} 的整数` },
]}
>
<InputNumber id={inputId} min={1} max={field.max} precision={0} aria-label={field.label} />
<InputNumber id={inputId} min={min} max={field.max} precision={0} aria-label={field.label} />
</Form.Item>
<span className="admin-runtime-policy-number-unit" aria-hidden="true">
{field.unit}
@@ -517,7 +519,8 @@ function parseRuntimePolicySetting(value: RuntimePolicySetting): RuntimePolicySe
if (!value || typeof value !== "object") throw new Error("服务端返回的资源与策略格式无效");
for (const field of allPolicyFields) {
const fieldValue = readPolicyValue(value, field);
if (!Number.isInteger(fieldValue) || fieldValue < 1 || fieldValue > field.max) throw new Error(`服务端返回的“${field.label}”无效`);
const min = field.min ?? 1;
if (!Number.isInteger(fieldValue) || fieldValue < min || fieldValue > field.max) throw new Error(`服务端返回的“${field.label}”无效`);
}
if (typeof value.configured !== "boolean") throw new Error("服务端未返回有效的配置来源状态");
return value;

View File

@@ -1,7 +1,7 @@
import { AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, FolderOpen, Image as ImageIcon, Link2, MoreHorizontal, PencilLine, Play, Plus, Search, Trash2, Upload, type LucideIcon } from "lucide-react";
import { AlertTriangle, AudioLines, Box, CheckCheck, Clapperboard, Copy, Download, FileText, FileUp, FolderOpen, Image as ImageIcon, Link2, MoreHorizontal, PencilLine, Play, Plus, RotateCcw, Search, Trash2, Upload, type LucideIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { App, Button, Drawer, Dropdown, Form, Input, Modal, Progress, Select, Space, Tag, Typography } from "antd";
import { App, Button, Drawer, Dropdown, Form, Input, Modal, Popconfirm, Progress, Select, Space, Tag, Typography } from "antd";
import type { MenuProps } from "antd";
import { useNavigate } from "react-router";
@@ -10,6 +10,7 @@ import { WorkspaceState } from "@/components/layout/workspace-state";
import { AssetMediaPreview } from "@/components/asset-media-preview";
import { AssetLibraryCard, AssetLibraryCardMedia } from "@/components/assets/asset-library-card";
import { saveAs } from "file-saver";
import { cn } from "@/lib/utils";
import { useCopyText } from "@/hooks/use-copy-text";
import { ASSET_CATEGORY_OPTIONS, assetCategoryLabel } from "@/lib/asset-category";
@@ -17,10 +18,11 @@ import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } f
import { formatBytes, readFileAsDataUrl, readImageMeta } from "@/lib/image-utils";
import { uploadImage } from "@/services/image-storage";
import { uploadMediaFile } from "@/services/file-storage";
import { useAssetStore, type Asset, type AssetCategory, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
import { flushAssetStorePersistence, useAssetStore, type Asset, type AssetCategory, type AssetKind, type ImageAsset } from "@/stores/use-asset-store";
import { exportAssets, readAssetPackage } from "./asset-transfer";
import { AssetStorageUsage, assetStorageUsageQueryKey } from "./asset-storage-usage";
import { deleteAssetWithRemoteSync } from "@/services/user-data-sync";
import { deleteAssetWithRemoteSync, saveRemoteUserDataNow } from "@/services/user-data-sync";
import { useUserStore } from "@/stores/use-user-store";
type LibraryAsset = Exclude<Asset, { kind: "entity" }>;
@@ -74,6 +76,8 @@ export default function AssetsPage() {
const addAsset = useAssetStore((state) => state.addAsset);
const updateAsset = useAssetStore((state) => state.updateAsset);
const retentionDays = useUserStore((state) => state.runtimeLimits.recycleBinRetentionDays ?? 30);
const [viewMode, setViewMode] = useState<"library" | "trash">("library");
const [keyword, setKeyword] = useState("");
const [kindFilter, setKindFilter] = useState<AssetKind | "all">("all");
const [categoryFilter, setCategoryFilter] = useState<AssetCategory | "all">("all");
@@ -83,8 +87,10 @@ export default function AssetsPage() {
const [isAssetOpen, setIsAssetOpen] = useState(false);
const [previewAsset, setPreviewAsset] = useState<LibraryAsset | null>(null);
const [deletingAsset, setDeletingAsset] = useState<LibraryAsset | null>(null);
const [archivingAsset, setArchivingAsset] = useState<LibraryAsset | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [batchDeleteOpen, setBatchDeleteOpen] = useState(false);
const [batchArchiveOpen, setBatchArchiveOpen] = useState(false);
const [formKind, setFormKind] = useState<AssetKind>("text");
const [imageDraft, setImageDraft] = useState<ImageDraft>(null);
@@ -95,11 +101,15 @@ export default function AssetsPage() {
const title = Form.useWatch("title", form) || "";
const tags = Form.useWatch("tags", form) || [];
const content = Form.useWatch("content", form) || "";
const validAssets = useMemo(() => assets.filter((asset): asset is LibraryAsset => asset.kind !== "entity"), [assets]);
const allLibraryAssets = useMemo(() => assets.filter((asset): asset is LibraryAsset => asset.kind !== "entity"), [assets]);
const activeAssets = useMemo(() => allLibraryAssets.filter((asset) => asset.status !== "archived"), [allLibraryAssets]);
const trashAssets = useMemo(() => allLibraryAssets.filter((asset) => asset.status === "archived"), [allLibraryAssets]);
const validAssets = viewMode === "trash" ? trashAssets : activeAssets;
const selectedAssets = useMemo(() => validAssets.filter((asset) => selectedIds.includes(asset.id)), [selectedIds, validAssets]);
const kindCounts = useMemo(() => new Map(kindOptions.map((option) => [option.value, option.value === "all" ? validAssets.length : validAssets.filter((asset) => asset.kind === option.value).length])), [validAssets]);
const categoryCounts = useMemo(() => new Map(categoryOptions.map((option) => [option.value, option.value === "all" ? validAssets.length : validAssets.filter((asset) => (asset.category || "other") === option.value).length])), [validAssets]);
const canCreateAsset = !keyword.trim() && kindFilter === "all" && categoryFilter === "all";
const kindCounts = useMemo(() => new Map(kindOptions.map((option) => [option.value, option.value === "all" ? activeAssets.length : activeAssets.filter((asset) => asset.kind === option.value).length])), [activeAssets]);
const categoryCounts = useMemo(() => new Map(categoryOptions.map((option) => [option.value, option.value === "all" ? activeAssets.length : activeAssets.filter((asset) => (asset.category || "other") === option.value).length])), [activeAssets]);
const canCreateAsset = viewMode === "library" && !keyword.trim() && kindFilter === "all" && categoryFilter === "all";
const filteredAssets = useMemo(() => {
const query = keyword.trim().toLowerCase();
@@ -277,11 +287,79 @@ export default function AssetsPage() {
}
};
const restoreAsset = async (asset: LibraryAsset) => {
updateAsset(asset.id, { status: "confirmed" });
await flushAssetStorePersistence();
try {
await saveRemoteUserDataNow();
message.success(`已还原素材「${asset.title}」`);
} catch {
message.warning("已在本地还原,稍后自动同步至云端");
}
};
const batchRestore = async () => {
if (!selectedIds.length) return;
for (const id of selectedIds) {
updateAsset(id, { status: "confirmed" });
}
const count = selectedIds.length;
setSelectedIds([]);
await flushAssetStorePersistence();
try {
await saveRemoteUserDataNow();
message.success(`已还原 ${count} 个素材`);
} catch {
message.warning("已在本地还原,稍后自动同步至云端");
}
};
const archiveAsset = async (asset: LibraryAsset) => {
updateAsset(asset.id, { status: "archived" });
await flushAssetStorePersistence();
try {
await saveRemoteUserDataNow();
message.success(`已将「${asset.title}」移入回收站`);
} catch {
message.warning("已移入回收站,稍后自动同步至云端");
}
};
const batchArchive = async () => {
if (!selectedIds.length) return;
for (const id of selectedIds) {
updateAsset(id, { status: "archived" });
}
const count = selectedIds.length;
setSelectedIds([]);
await flushAssetStorePersistence();
try {
await saveRemoteUserDataNow();
message.success(`已将 ${count} 个素材移入回收站`);
} catch {
message.warning("已移入回收站,稍后自动同步至云端");
}
};
const emptyTrash = async () => {
const count = trashAssets.length;
if (!count) return;
try {
for (const asset of trashAssets) {
await deleteAssetWithRemoteSync(asset.id);
}
setSelectedIds([]);
message.success(`已彻底清空回收站 ${count} 个素材`);
} catch (error) {
message.error(error instanceof Error ? error.message : "清空回收站失败");
}
};
const confirmDelete = async () => {
if (!deletingAsset) return;
try {
await deleteAssetWithRemoteSync(deletingAsset.id);
message.success("素材已删除");
message.success("素材已彻底删除");
setDeletingAsset(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "素材删除失败");
@@ -297,7 +375,7 @@ export default function AssetsPage() {
if (!selectedAssets.length) return;
try {
for (const asset of selectedAssets) await deleteAssetWithRemoteSync(asset.id);
message.success(`已删除 ${selectedAssets.length} 个素材`);
message.success(`已彻底删除 ${selectedAssets.length} 个素材`);
setSelectedIds([]);
setBatchDeleteOpen(false);
} catch (error) {
@@ -311,18 +389,31 @@ export default function AssetsPage() {
<WorkspacePage grid className="library-page assets-library-page canvas-library-page">
<div className="studio-band">
<PageHeader
title="素材库"
description="管理文本、图片、视频、音频和 3D 模型素材。"
title={viewMode === "trash" ? "素材库 / 回收站" : "素材库"}
description={viewMode === "trash" ? "已删除画布或手动归档的临时素材,可随时还原或彻底清理。" : "管理文本、图片、视频、音频和 3D 模型素材。"}
meta={<span className="app-projects-header-meta assets-header-meta">{validAssets.length} 个素材</span>}
actions={(
<div className="assets-header-actions">
<div className="assets-header-action-buttons">
<Button className="library-primary-action" type="primary" icon={<Plus className="size-3.5" />} onClick={openCreate}>新增素材</Button>
<Button icon={<FolderOpen className="size-3.5" />} onClick={() => navigate("/plugins/eagle")}>Eagle 素材库</Button>
<Button title="导出全部素材" aria-label="导出全部素材" icon={<Download className="size-4" />} onClick={() => void exportAllAssets()} />
<Dropdown trigger={["click"]} menu={{ items: [{ key: "package", icon: <FileUp className="size-4" />, label: "导入素材包", onClick: () => assetInputRef.current?.click() }, { key: "model", icon: <Upload className="size-4" />, label: "上传 3D 模型", onClick: () => modelInputRef.current?.click() }] }}>
<Button title="导入素材" aria-label="导入素材" icon={<FileUp className="size-4" />} />
</Dropdown>
{viewMode === "trash" ? (
<>
{trashAssets.length > 0 ? (
<Popconfirm title="确定清空回收站吗?" description="清空后所有回收站素材及其文件将被彻底永久删除,不可恢复。" onConfirm={() => void emptyTrash()} okText="清空" okButtonProps={{ danger: true }} cancelText="取消">
<Button danger icon={<Trash2 className="size-3.5" />}>清空回收站</Button>
</Popconfirm>
) : null}
<Button icon={<RotateCcw className="size-3.5" />} onClick={() => { setViewMode("library"); setPage(1); setSelectedIds([]); }}>返回素材库</Button>
</>
) : (
<>
<Button className="library-primary-action" type="primary" icon={<Plus className="size-3.5" />} onClick={openCreate}>新增素材</Button>
<Button icon={<FolderOpen className="size-3.5" />} onClick={() => navigate("/plugins/eagle")}>Eagle 素材库</Button>
<Button title="导出全部素材" aria-label="导出全部素材" icon={<Download className="size-4" />} onClick={() => void exportAllAssets()} />
<Dropdown trigger={["click"]} menu={{ items: [{ key: "package", icon: <FileUp className="size-4" />, label: "导入素材包", onClick: () => assetInputRef.current?.click() }, { key: "model", icon: <Upload className="size-4" />, label: "上传 3D 模型", onClick: () => modelInputRef.current?.click() }] }}>
<Button title="导入素材" aria-label="导入素材" icon={<FileUp className="size-4" />} />
</Dropdown>
</>
)}
</div>
<AssetStorageUsage />
</div>
@@ -336,15 +427,90 @@ export default function AssetsPage() {
<div className="canvas-library-frame assets-library-frame">
<div className="grid min-h-0 gap-4 lg:grid-cols-[176px_minmax(0,1fr)]">
<aside className="thin-scrollbar flex gap-2 overflow-x-auto py-3 lg:sticky lg:top-0 lg:block lg:max-h-[calc(100vh-150px)] lg:overflow-y-auto lg:pr-3">
<AssetFilterGroup title="素材类型" options={kindOptions} value={kindFilter} counts={kindCounts} onChange={(value) => { setKindFilter(value as AssetKind | "all"); setPage(1); }} />
<AssetFilterGroup title="业务分类" options={categoryOptions} value={categoryFilter} counts={categoryCounts} onChange={(value) => { setCategoryFilter(value as AssetCategory | "all"); setPage(1); }} className="lg:mt-5" />
<AssetFilterGroup
title="素材类型"
options={kindOptions}
value={viewMode === "library" ? kindFilter : ""}
counts={kindCounts}
onChange={(value) => {
setViewMode("library");
setKindFilter(value as AssetKind | "all");
setPage(1);
}}
/>
<AssetFilterGroup
title="业务分类"
options={categoryOptions}
value={viewMode === "library" ? categoryFilter : ""}
counts={categoryCounts}
onChange={(value) => {
setViewMode("library");
setCategoryFilter(value as AssetCategory | "all");
setPage(1);
}}
className="lg:mt-5"
/>
<div className="mt-6 border-t border-border/40 pt-3">
<div className="mb-1.5 px-1 text-[var(--fs-tiny)] font-semibold uppercase tracking-[0.08em] text-foreground/38">垃圾箱与归档</div>
<button
type="button"
aria-pressed={viewMode === "trash"}
className={cn(
"assets-filter-item w-full transition-colors",
viewMode === "trash" ? "is-active !bg-amber-500/15 !text-amber-600 dark:!text-amber-400 font-semibold shadow-sm" : "text-foreground/65 hover:text-foreground"
)}
onClick={() => {
if (viewMode === "trash") {
setViewMode("library");
} else {
setViewMode("trash");
setKindFilter("all");
setCategoryFilter("all");
}
setPage(1);
setSelectedIds([]);
}}
>
<span className="assets-filter-item-label flex items-center gap-1.5">
<Trash2 className="size-3.5" />
<span>回收站</span>
</span>
<span className="assets-filter-count">{trashAssets.length}</span>
</button>
</div>
</aside>
<section className="min-w-0">
{viewMode === "trash" ? (
<div className="mb-4 flex items-center justify-between gap-3 rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-xs text-amber-600 dark:text-amber-300">
<div className="flex items-center gap-2">
<AlertTriangle className="size-4 shrink-0 text-amber-500" />
<span>
{retentionDays > 0
? `回收站内的素材将在 ${retentionDays} 天后自动彻底清除。您可以随时还原素材,或手动彻底删除释放空间。`
: "回收站内的素材当前设置为永久保留,您可以随时还原素材或手动彻底清除。"}
</span>
</div>
</div>
) : null}
{selectedAssets.length ? (
<AssetsBatchBar count={selectedAssets.length} allSelected={allFilteredSelected} onSelectAll={() => setSelectedIds((current) => Array.from(new Set([...current, ...filteredAssetIds])))} onClear={() => setSelectedIds([])} onExport={() => void exportSelectedAssets()} onDelete={() => setBatchDeleteOpen(true)} />
<AssetsBatchBar
count={selectedAssets.length}
isTrash={viewMode === "trash"}
allSelected={allFilteredSelected}
onSelectAll={() => setSelectedIds((current) => Array.from(new Set([...current, ...filteredAssetIds])))}
onClear={() => setSelectedIds([])}
onExport={() => void exportSelectedAssets()}
onRestore={() => void batchRestore()}
onArchive={() => setBatchArchiveOpen(true)}
onDelete={() => setBatchDeleteOpen(true)}
/>
) : null}
{validAssets.length === 0 ? (
<AssetsEmptyState onNew={openCreate} onImport={() => assetInputRef.current?.click()} onGoCanvas={() => navigate("/canvas")} />
viewMode === "trash" ? (
<WorkspaceState icon="assets" compact title="回收站是空的" description="删除画布或手动移入回收站的素材会暂存到这里,可在需要时随时还原。" />
) : (
<AssetsEmptyState onNew={openCreate} onImport={() => assetInputRef.current?.click()} onGoCanvas={() => navigate("/canvas")} />
)
) : (
<>
{filteredAssets.length === 0 ? (
@@ -357,7 +523,21 @@ export default function AssetsPage() {
<span className="library-create-meta">文本、图片、音视频或模型</span>
</button> : null}
{visibleAssets.map((asset) => (
<AssetCard key={asset.id} asset={asset} selected={selectedIds.includes(asset.id)} onSelect={(selected) => setSelectedIds((current) => selected ? [...new Set([...current, asset.id])] : current.filter((id) => id !== asset.id))} onOpen={() => setPreviewAsset(asset)} onEdit={() => openEdit(asset)} onCopy={copyAssetText} onDownload={downloadImage} onDelete={() => setDeletingAsset(asset)} />
<AssetCard
key={asset.id}
asset={asset}
selected={selectedIds.includes(asset.id)}
isTrash={viewMode === "trash"}
retentionDays={retentionDays}
onSelect={(selected) => setSelectedIds((current) => selected ? [...new Set([...current, asset.id])] : current.filter((id) => id !== asset.id))}
onOpen={() => setPreviewAsset(asset)}
onEdit={() => openEdit(asset)}
onCopy={copyAssetText}
onDownload={downloadImage}
onRestore={() => void restoreAsset(asset)}
onArchive={() => setArchivingAsset(asset)}
onDelete={() => setDeletingAsset(asset)}
/>
))}
</CollectionGrid>
)}
@@ -495,34 +675,100 @@ export default function AssetsPage() {
<input ref={assetInputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importAssetZip(event.target.files?.[0])} />
<input ref={modelInputRef} type="file" accept=".glb,.gltf,model/gltf-binary,model/gltf+json" className="hidden" onChange={(event) => { void readModelFile(event.target.files?.[0]); event.currentTarget.value = ""; }} />
<Modal className="library-modal library-confirm-modal" title="删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={() => void confirmDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
确定删除「{deletingAsset?.title}」吗?未被其他内容引用的服务器本地或对象存储文件也会同步删除;若仍被画布、任务或其他素材占用,本次删除将被阻止。
<Modal className="library-modal library-confirm-modal" title="移入回收站" open={Boolean(archivingAsset)} onCancel={() => setArchivingAsset(null)} onOk={() => { if (archivingAsset) { void archiveAsset(archivingAsset); setArchivingAsset(null); } }} okText="移入回收站" cancelText="取消">
确定将「{archivingAsset?.title}」移入回收站吗?移入后不会出现在正常素材库中,可在回收站随时还原。
</Modal>
<Modal className="library-modal library-confirm-modal" title="批量删除素材" open={batchDeleteOpen} onCancel={() => setBatchDeleteOpen(false)} onOk={() => void confirmBatchDelete()} okText="删除" okButtonProps={{ danger: true }} cancelText="取消">
确定删除已选择的 {selectedAssets.length} 个素材吗?未被复用的服务器文件会同步删除;仍被画布、任务或其他素材占用的素材会保留并提示具体来源。
<Modal className="library-modal library-confirm-modal" title="批量移入回收站" open={batchArchiveOpen} onCancel={() => setBatchArchiveOpen(false)} onOk={() => { void batchArchive(); setBatchArchiveOpen(false); }} okText="移入回收站" cancelText="取消">
确定将已选择的 {selectedAssets.length} 个素材移入回收站吗?移入后可随时在回收站批量还原。
</Modal>
<Modal className="library-modal library-confirm-modal" title="彻底删除素材" open={Boolean(deletingAsset)} onCancel={() => setDeletingAsset(null)} onOk={() => void confirmDelete()} okText="彻底删除" okButtonProps={{ danger: true }} cancelText="取消">
确定彻底删除「{deletingAsset?.title}」吗?未被其他内容引用的服务器本地或对象存储文件也会同步删除,操作不可恢复。
</Modal>
<Modal className="library-modal library-confirm-modal" title="批量彻底删除素材" open={batchDeleteOpen} onCancel={() => setBatchDeleteOpen(false)} onOk={() => void confirmBatchDelete()} okText="彻底删除" okButtonProps={{ danger: true }} cancelText="取消">
确定彻底删除已选择的 {selectedAssets.length} 个素材吗?未被复用的服务器文件会同步删除,操作不可恢复。
</Modal>
</>
);
}
function AssetCard({ asset, selected, onSelect, onOpen, onEdit, onCopy, onDownload, onDelete }: { asset: LibraryAsset; selected: boolean; onSelect: (selected: boolean) => void; onOpen: () => void; onEdit: () => void; onCopy: (asset: LibraryAsset) => void; onDownload: (asset: LibraryAsset) => void; onDelete: () => void }) {
function formatExpirationHint(updatedAt: string, retentionDays: number) {
if (!retentionDays || retentionDays <= 0) return "永久保留";
const updatedTime = new Date(updatedAt).getTime();
if (!Number.isFinite(updatedTime)) return `保留 ${retentionDays} 天`;
const expireTime = updatedTime + retentionDays * 24 * 60 * 60 * 1000;
const remainingMs = expireTime - Date.now();
const remainingDays = Math.ceil(remainingMs / (24 * 60 * 60 * 1000));
if (remainingDays <= 0) return "即将彻底清除";
if (remainingDays === 1) return "剩余 1 天过期";
return `剩余 ${remainingDays} 天过期`;
}
function formatExpirationDate(updatedAt: string, retentionDays: number) {
if (!retentionDays || retentionDays <= 0) return "永久保留";
const updatedTime = new Date(updatedAt).getTime();
if (!Number.isFinite(updatedTime)) return "";
const expireDate = new Date(updatedTime + retentionDays * 24 * 60 * 60 * 1000);
return `预计于 ${expireDate.getFullYear()}-${String(expireDate.getMonth() + 1).padStart(2, "0")}-${String(expireDate.getDate()).padStart(2, "0")} 彻底清除`;
}
function AssetCard({
asset,
selected,
isTrash = false,
retentionDays = 30,
onSelect,
onOpen,
onEdit,
onCopy,
onDownload,
onRestore,
onArchive,
onDelete,
}: {
asset: LibraryAsset;
selected: boolean;
isTrash?: boolean;
retentionDays?: number;
onSelect: (selected: boolean) => void;
onOpen: () => void;
onEdit: () => void;
onCopy: (asset: LibraryAsset) => void;
onDownload: (asset: LibraryAsset) => void;
onRestore?: () => void;
onArchive?: () => void;
onDelete: () => void;
}) {
const summary = assetSummary(asset);
const menuItems: MenuProps["items"] = [
...(asset.kind === "text" || asset.kind === "image" ? [{ key: "edit", icon: <PencilLine className="size-3.5" />, label: "编辑", onClick: onEdit }] : []),
...(asset.kind === "text" ? [{ key: "copy", icon: <Copy className="size-3.5" />, label: "复制文本", onClick: () => void onCopy(asset) }] : []),
...(asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? [{ key: "download", icon: <Download className="size-3.5" />, label: "下载", onClick: () => onDownload(asset) }] : []),
{ type: "divider" as const },
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "删除", onClick: onDelete },
];
const menuItems: MenuProps["items"] = isTrash
? [
{ key: "restore", icon: <RotateCcw className="size-3.5" />, label: "还原到素材库", onClick: onRestore },
{ type: "divider" as const },
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "彻底删除", onClick: onDelete },
]
: [
...(asset.kind === "text" || asset.kind === "image" ? [{ key: "edit", icon: <PencilLine className="size-3.5" />, label: "编辑", onClick: onEdit }] : []),
...(asset.kind === "text" ? [{ key: "copy", icon: <Copy className="size-3.5" />, label: "复制文本", onClick: () => void onCopy(asset) }] : []),
...(asset.kind === "image" || asset.kind === "video" || asset.kind === "audio" || asset.kind === "model" ? [{ key: "download", icon: <Download className="size-3.5" />, label: "下载", onClick: () => onDownload(asset) }] : []),
{ type: "divider" as const },
{ key: "archive", icon: <Trash2 className="size-3.5 text-amber-500" />, label: "移入回收站", onClick: onArchive },
{ key: "delete", danger: true, icon: <Trash2 className="size-3.5" />, label: "彻底删除", onClick: onDelete },
];
return (
<AssetLibraryCard selected={selected}>
<AssetCover asset={asset} selected={selected} onSelect={onSelect} onOpen={onOpen} menuItems={menuItems} />
<AssetCover asset={asset} selected={selected} isTrash={isTrash} onSelect={onSelect} onOpen={onOpen} menuItems={menuItems} />
<button type="button" className="block w-full px-2.5 py-2 text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--workspace-accent)]" onClick={onOpen}>
<div className="flex min-w-0 items-center justify-between gap-2">
<h2 className="truncate text-[var(--fs-body)] font-semibold text-foreground" title={asset.title}>{asset.title}</h2>
<span className="shrink-0 text-[var(--fs-tiny)] tabular-nums text-foreground/38">{formatAssetTime(asset.updatedAt)}</span>
</div>
<div className="mt-1 truncate text-[var(--fs-label)] text-foreground/52" title={summary}>{summary}</div>
{isTrash ? (
<div className="mt-1 flex items-center gap-1 text-[var(--fs-tiny)] font-medium text-amber-600 dark:text-amber-400" title={formatExpirationDate(asset.updatedAt, retentionDays)}>
<AlertTriangle className="size-3 shrink-0" />
<span>{formatExpirationHint(asset.updatedAt, retentionDays)}</span>
</div>
) : (
<div className="mt-1 truncate text-[var(--fs-label)] text-foreground/52" title={summary}>{summary}</div>
)}
<div className="mt-1 flex min-w-0 items-center gap-1.5 text-[var(--fs-tiny)] text-foreground/38">
<span className="truncate">{asset.source || "未标注来源"}</span>
<span aria-hidden="true">·</span>
@@ -533,7 +779,7 @@ function AssetCard({ asset, selected, onSelect, onOpen, onEdit, onCopy, onDownlo
);
}
function AssetCover({ asset, selected, onSelect, onOpen, menuItems }: { asset: LibraryAsset; selected: boolean; onSelect: (selected: boolean) => void; onOpen: () => void; menuItems: MenuProps["items"] }) {
function AssetCover({ asset, selected, isTrash = false, onSelect, onOpen, menuItems }: { asset: LibraryAsset; selected: boolean; isTrash?: boolean; onSelect: (selected: boolean) => void; onOpen: () => void; menuItems: MenuProps["items"] }) {
const KindIcon = assetKindIcons[asset.kind];
const clock = asset.kind === "video" || asset.kind === "audio" ? formatAssetClock(asset.data.durationMs) : null;
const showPlay = asset.kind === "video";
@@ -555,7 +801,11 @@ function AssetCover({ asset, selected, onSelect, onOpen, menuItems }: { asset: L
</button>
<span className="assets-cover-badges">
<span className="assets-cover-badge is-kind"><KindIcon />{assetKindLabel(asset.kind)}</span>
<span className="assets-cover-badge is-category">{assetCategoryLabel(asset.category)}</span>
{isTrash ? (
<span className="assets-cover-badge is-category !bg-amber-500/85 !text-white">回收站</span>
) : (
<span className="assets-cover-badge is-category">{assetCategoryLabel(asset.category)}</span>
)}
</span>
{clock ? <span className="assets-cover-clock">{clock}</span> : null}
<input type="checkbox" checked={selected} onClick={(event) => event.stopPropagation()} onChange={(event) => onSelect(event.target.checked)} className="assets-select-check" aria-label={`选择 ${asset.title}`} />
@@ -598,15 +848,45 @@ function ModelCover({ asset }: { asset: LibraryAsset & { kind: "model" } }) {
);
}
function AssetsBatchBar({ count, allSelected, onSelectAll, onClear, onExport, onDelete }: { count: number; allSelected: boolean; onSelectAll: () => void; onClear: () => void; onExport: () => void; onDelete: () => void }) {
function AssetsBatchBar({
count,
isTrash = false,
allSelected,
onSelectAll,
onClear,
onExport,
onRestore,
onArchive,
onDelete,
}: {
count: number;
isTrash?: boolean;
allSelected: boolean;
onSelectAll: () => void;
onClear: () => void;
onExport: () => void;
onRestore?: () => void;
onArchive?: () => void;
onDelete: () => void;
}) {
return (
<div className="assets-batch-bar" role="toolbar" aria-label="批量操作">
<span className="assets-batch-count">已选择 <strong>{count}</strong> 个素材</span>
<div className="assets-batch-actions">
<Button size="small" icon={<CheckCheck className="size-3.5" />} disabled={allSelected} onClick={onSelectAll}>全选</Button>
<Button size="small" onClick={onClear}>取消选择</Button>
<Button size="small" icon={<Download className="size-3.5" />} onClick={onExport}>导出</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}>删除</Button>
{isTrash ? (
<>
<Button size="small" type="primary" icon={<RotateCcw className="size-3.5" />} onClick={onRestore}>还原已选</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}>彻底删除已选</Button>
</>
) : (
<>
<Button size="small" icon={<Download className="size-3.5" />} onClick={onExport}>导出</Button>
<Button size="small" icon={<Trash2 className="size-3.5 text-amber-500" />} onClick={onArchive}>移入回收站</Button>
<Button size="small" danger icon={<Trash2 className="size-3.5" />} onClick={onDelete}>彻底删除</Button>
</>
)}
</div>
</div>
);

View File

@@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useSearchParams } from "react-router";
import { useQuery } from "@tanstack/react-query";
import { App, Button, Dropdown, Modal, Select } from "antd";
import { ArrowDownAZ, Clock3, Download, FileUp, ListFilter, MoreHorizontal, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react";
import { ArrowDownAZ, Clock3, Download, FileUp, History, ListFilter, MoreHorizontal, Plus, Search, SlidersHorizontal, Trash2, X } from "lucide-react";
import { CollectionGrid, PageHeader, WorkspacePage } from "@/components/layout/workspace-page";
import { WorkspaceLoadingState, WorkspaceState } from "@/components/layout/workspace-state";
@@ -12,6 +12,7 @@ import { setMediaBlob } from "@/services/file-storage";
import { setImageBlob } from "@/services/image-storage";
import { CanvasCreateCard } from "@/components/canvas/canvas-project-card";
import { CanvasFolderCard } from "@/components/canvas/canvas-folder-card";
import { CanvasHistoryDrawer } from "@/components/canvas/canvas-history-drawer";
import type { CanvasExportFile } from "@/types/canvas-export";
import type { CanvasNodeData } from "@/types/canvas";
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
@@ -42,6 +43,7 @@ export default function CanvasPage() {
const selectedIds = useCanvasUiStore((state) => state.selectedProjectIds);
const setDeleteIds = useCanvasUiStore((state) => state.setDeleteProjectIds);
const updateProject = useCanvasStore((state) => state.updateProject);
const [historyOpen, setHistoryOpen] = useState(false);
const [associationOpen, setAssociationOpen] = useState(false);
const [associationProjectId, setAssociationProjectId] = useState("");
const projectQuery = useQuery({ queryKey: ["projects"], queryFn: () => listProjects() });
@@ -172,9 +174,9 @@ export default function CanvasPage() {
const oldKey = node.metadata?.storageKey;
const mapped = oldKey ? storageKeyMap.get(oldKey) : undefined;
const isDeadBlob = (val?: string) => typeof val === "string" && val.startsWith("blob:");
const nextStorageKey = mapped ? mapped.storageKey : (oldKey && !isDeadBlob(oldKey) ? oldKey : undefined);
const content = mapped ? mapped.url : (isDeadBlob(node.metadata?.content) ? "" : node.metadata?.content);
const previewContent = mapped ? mapped.url : (isDeadBlob(node.metadata?.previewContent) ? "" : node.metadata?.previewContent);
const nextStorageKey = mapped ? mapped.storageKey : oldKey && !isDeadBlob(oldKey) ? oldKey : undefined;
const content = mapped ? mapped.url : isDeadBlob(node.metadata?.content) ? "" : node.metadata?.content;
const previewContent = mapped ? mapped.url : isDeadBlob(node.metadata?.previewContent) ? "" : node.metadata?.previewContent;
return {
...node,
metadata: {
@@ -357,6 +359,10 @@ export default function CanvasPage() {
<span>{sortLabel}</span>
</button>
</Dropdown>
<button type="button" className="canvas-library-filter flex items-center gap-1.5" onClick={() => setHistoryOpen(true)} aria-label="查看画布创建与变更历史时间线" title="查看画布创建与变更历史时间线">
<History className="size-3.5 text-[var(--workspace-accent)]" />
<span>历史</span>
</button>
{keyword || projectFilter !== "all" || sort !== "updated" ? (
<button
type="button"
@@ -419,20 +425,17 @@ export default function CanvasPage() {
<CollectionGrid className="canvas-library-grid">
{showCreateCard ? <CanvasCreateCard disabled={!hydrated} onClick={createAndEnter} /> : null}
{visibleProjects.map((project) => (
<CanvasFolderCard
key={project.id}
project={project}
projectName={project.projectId ? projectNames.get(project.projectId) || "未同步项目" : undefined}
onClick={() => enterProject(project.id)}
/>
<CanvasFolderCard key={project.id} project={project} projectName={project.projectId ? projectNames.get(project.projectId) || "未同步项目" : undefined} onClick={() => enterProject(project.id)} />
))}
</CollectionGrid>
) : (
<WorkspaceState icon="canvas" title="没有匹配的画布" description="换一个画布名称或重置筛选条件。" />
)}
{hydrated && visibleProjects.length ? <div ref={loadMoreRef} className="library-load-more" aria-live="polite">
{visibleProjects.length < filteredProjects.length ? `继续下滑加载更多(每页 50 条)` : `已加载全部 ${filteredProjects.length} 个画布`}
</div> : null}
{hydrated && visibleProjects.length ? (
<div ref={loadMoreRef} className="library-load-more" aria-live="polite">
{visibleProjects.length < filteredProjects.length ? `继续下滑加载更多(每页 50 条)` : `已加载全部 ${filteredProjects.length} 个画布`}
</div>
) : null}
</div>
<input ref={inputRef} type="file" accept="application/zip,.zip" className="hidden" onChange={(event) => void importCanvas(event.target.files?.[0])} />
@@ -454,6 +457,7 @@ export default function CanvasPage() {
onChange={setAssociationProjectId}
/>
</Modal>
<CanvasHistoryDrawer open={historyOpen} onClose={() => setHistoryOpen(false)} />
</WorkspacePage>
);
}

View File

@@ -122,8 +122,11 @@ export function useCanvasRenderModel({
return [...frames, ...regular];
}, [dragPreview, nodes, renderBounds, renderHiddenNodeIds, selectedNodeIds]);
const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image"), [assets]);
const canvasImageNodes = useMemo(() => nodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(node.metadata?.content) && !collapsedBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed)), [collapsedBatchChildIds, nodeById, nodes]);
const imageAssets = useMemo(() => assets.filter((asset): asset is ImageAsset => asset.kind === "image" && asset.status !== "archived"), [assets]);
const canvasImageNodes = useMemo(
() => nodes.filter((node) => node.type === CanvasNodeType.Image && Boolean(node.metadata?.content) && !collapsedBatchChildIds.has(node.id) && !(node.parentId && nodeById.get(node.parentId)?.metadata?.frame?.collapsed)),
[collapsedBatchChildIds, nodeById, nodes],
);
const semanticNodesRef = useRef(nodes);
const semanticNodes = useMemo(() => {
const previous = semanticNodesRef.current;
@@ -164,13 +167,17 @@ export function useCanvasRenderModel({
const bottom = Math.max(...selectedNodes.map((node) => node.position.y + node.height));
return { left, top, width: right - left, height: bottom - top, count: selectedNodes.length };
}, [nodes, renderHiddenNodeIds, selectedNodeIds]);
const selectedVideoNodes = useMemo(() => nodes
.filter((node) => selectedNodeIds.has(node.id) && node.type === CanvasNodeType.Video && Boolean(node.metadata?.content) && !renderHiddenNodeIds.has(node.id))
.sort((a, b) => {
const shotA = a.metadata?.shotIndex ?? Number.MAX_SAFE_INTEGER;
const shotB = b.metadata?.shotIndex ?? Number.MAX_SAFE_INTEGER;
return shotA - shotB || a.position.y - b.position.y || a.position.x - b.position.x;
}), [nodes, renderHiddenNodeIds, selectedNodeIds]);
const selectedVideoNodes = useMemo(
() =>
nodes
.filter((node) => selectedNodeIds.has(node.id) && node.type === CanvasNodeType.Video && Boolean(node.metadata?.content) && !renderHiddenNodeIds.has(node.id))
.sort((a, b) => {
const shotA = a.metadata?.shotIndex ?? Number.MAX_SAFE_INTEGER;
const shotB = b.metadata?.shotIndex ?? Number.MAX_SAFE_INTEGER;
return shotA - shotB || a.position.y - b.position.y || a.position.x - b.position.x;
}),
[nodes, renderHiddenNodeIds, selectedNodeIds],
);
const batchChildCountById = useMemo(() => {
const map = new Map<string, number>();
nodes.forEach((node) => {
@@ -216,25 +223,29 @@ export function useCanvasRenderModel({
});
return { nodeIds, connectionIds };
}, [activeNodeId, connections]);
const displayConnections = useMemo(() => connections.flatMap((connection) => {
if (collapsedBatchChildIds.has(connection.fromNodeId) || collapsedBatchChildIds.has(connection.toNodeId)) return [];
const fromNode = nodeById.get(connection.fromNodeId);
const toNode = nodeById.get(connection.toNodeId);
if (!fromNode || !toNode) return [];
const fromParent = fromNode.parentId ? nodeById.get(fromNode.parentId) : null;
const toParent = toNode.parentId ? nodeById.get(toNode.parentId) : null;
const displayFrom = fromParent && isFrameNode(fromParent) && fromParent.metadata?.frame?.collapsed ? fromParent : fromNode;
const displayTo = toParent && isFrameNode(toParent) && toParent.metadata?.frame?.collapsed ? toParent : toNode;
if (displayFrom.id === displayTo.id) return [];
const from = dragPreview?.nodeIds.has(displayFrom.id) ? { ...displayFrom, position: { x: displayFrom.position.x + dragPreview.x, y: displayFrom.position.y + dragPreview.y } } : displayFrom;
const to = dragPreview?.nodeIds.has(displayTo.id) ? { ...displayTo, position: { x: displayTo.position.x + dragPreview.x, y: displayTo.position.y + dragPreview.y } } : displayTo;
const connectionLeft = Math.min(from.position.x, to.position.x);
const connectionTop = Math.min(from.position.y, to.position.y);
const connectionRight = Math.max(from.position.x + from.width, to.position.x + to.width);
const connectionBottom = Math.max(from.position.y + from.height, to.position.y + to.height);
if (connectionRight <= renderBounds.left || connectionLeft >= renderBounds.right || connectionBottom <= renderBounds.top || connectionTop >= renderBounds.bottom) return [];
return [{ connection, from, to }];
}), [collapsedBatchChildIds, connections, dragPreview, nodeById, renderBounds]);
const displayConnections = useMemo(
() =>
connections.flatMap((connection) => {
if (collapsedBatchChildIds.has(connection.fromNodeId) || collapsedBatchChildIds.has(connection.toNodeId)) return [];
const fromNode = nodeById.get(connection.fromNodeId);
const toNode = nodeById.get(connection.toNodeId);
if (!fromNode || !toNode) return [];
const fromParent = fromNode.parentId ? nodeById.get(fromNode.parentId) : null;
const toParent = toNode.parentId ? nodeById.get(toNode.parentId) : null;
const displayFrom = fromParent && isFrameNode(fromParent) && fromParent.metadata?.frame?.collapsed ? fromParent : fromNode;
const displayTo = toParent && isFrameNode(toParent) && toParent.metadata?.frame?.collapsed ? toParent : toNode;
if (displayFrom.id === displayTo.id) return [];
const from = dragPreview?.nodeIds.has(displayFrom.id) ? { ...displayFrom, position: { x: displayFrom.position.x + dragPreview.x, y: displayFrom.position.y + dragPreview.y } } : displayFrom;
const to = dragPreview?.nodeIds.has(displayTo.id) ? { ...displayTo, position: { x: displayTo.position.x + dragPreview.x, y: displayTo.position.y + dragPreview.y } } : displayTo;
const connectionLeft = Math.min(from.position.x, to.position.x);
const connectionTop = Math.min(from.position.y, to.position.y);
const connectionRight = Math.max(from.position.x + from.width, to.position.x + to.width);
const connectionBottom = Math.max(from.position.y + from.height, to.position.y + to.height);
if (connectionRight <= renderBounds.left || connectionLeft >= renderBounds.right || connectionBottom <= renderBounds.top || connectionTop >= renderBounds.bottom) return [];
return [{ connection, from, to }];
}),
[collapsedBatchChildIds, connections, dragPreview, nodeById, renderBounds],
);
const configInputsById = useMemo(() => {
const map = new Map<string, NodeGenerationInput[]>();

View File

@@ -318,6 +318,7 @@ export type RuntimeResourcePolicy = {
sessionCount: number;
taskCount: number;
apiCallLogCount: number;
recycleBinRetentionDays?: number;
};
export type RuntimeTaskPolicy = {

View File

@@ -7,6 +7,7 @@ import { flushAssetStorePersistence, useAssetStore } from "@/stores/use-asset-st
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
import { flushCanvasStorePersistence, useCanvasStore } from "@/stores/canvas/use-canvas-store";
import { useSyncProgressStore } from "@/stores/use-sync-progress-store";
import { useCanvasHistoryStore } from "@/stores/canvas/use-canvas-history-store";
let activeRemoteUserId = "";
type RemoteUserDataPhase = "inactive" | "hydrating" | "ready" | "failed";
@@ -128,6 +129,12 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
if (!projectIds.length) return;
await withRemoteUserDataSyncExclusive(async () => {
if (activeRemoteUserId) requireRemoteUserDataBaseline();
const currentProjects = useCanvasStore.getState().projects;
const deletedProjectObjects = currentProjects.filter((p) => projectIds.includes(p.id));
if (deletedProjectObjects.length > 0) {
useCanvasHistoryStore.getState().recordDeletedProjects(deletedProjectObjects);
}
for (const id of projectIds) {
if (activeRemoteUserId) {
await deleteRemoteCanvasProject(id);
@@ -137,7 +144,7 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
// 批量删除允许部分成功;每个已成功远端删除的实体都立即落实到本地 durable cache。
await flushCanvasStorePersistence();
}
// 清理仅属于被删除画布的自动生成素材
// 将属于被删除画布的所有媒体节点安全归档至素材库回收站 (status = "archived")
const currentAssets = useAssetStore.getState().assets;
const remainingProjects = useCanvasStore.getState().projects;
const activeAssetIds = new Set<string>();
@@ -146,24 +153,118 @@ export async function deleteCanvasProjectsWithRemoteSync(ids: string[]) {
if (node.metadata?.assetId) activeAssetIds.add(node.metadata.assetId);
}
}
const assetsToRemove = currentAssets.filter((asset) => {
let assetChanged = false;
// 1. 已存在的关联素材标记为 archived
const assetsToArchive = currentAssets.filter((asset) => {
const canvasId = asset.metadata?.canvasId as string | undefined;
const source = asset.metadata?.source as string | undefined;
return canvasId && projectIds.includes(canvasId) && source === "canvas-generation" && !activeAssetIds.has(asset.id);
return canvasId && projectIds.includes(canvasId) && !activeAssetIds.has(asset.id) && asset.status !== "archived";
});
for (const asset of assetsToRemove) {
if (activeRemoteUserId) {
try {
await deleteRemoteAsset(asset.id);
acknowledgedAssets.delete(asset.id);
} catch {
// 忽略远端删除失败,继续本地清理
for (const asset of assetsToArchive) {
useAssetStore.getState().updateAsset(asset.id, { status: "archived" });
assetChanged = true;
}
// 2. 对于画布中尚未入库的媒体节点,直接归档为回收站素材
for (const project of deletedProjectObjects) {
for (const node of project.nodes || []) {
const isMedia = node.type === "image" || node.type === "video" || node.type === "audio";
const content = typeof node.metadata?.content === "string" ? node.metadata.content : "";
const storageKey = typeof node.metadata?.storageKey === "string" ? node.metadata.storageKey : undefined;
if (!isMedia || (!content && !storageKey)) continue;
const existingAsset = node.metadata?.assetId ? currentAssets.find((a) => a.id === node.metadata?.assetId) : undefined;
if (existingAsset) {
if (existingAsset.status !== "archived") {
useAssetStore.getState().updateAsset(existingAsset.id, { status: "archived" });
assetChanged = true;
}
continue;
}
const title = node.title || `${project.title} - ${node.type === "image" ? "图片" : node.type === "video" ? "视频" : "音频"}`;
const prompt = typeof node.metadata?.prompt === "string" ? node.metadata.prompt : "";
if (node.type === "image") {
useAssetStore.getState().addAsset({
kind: "image",
title,
coverUrl: content || "",
tags: prompt ? [prompt.slice(0, 16)] : ["画布生成"],
category: "other",
status: "archived",
source: `已删除画布:${project.title}`,
data: {
dataUrl: content || "",
storageKey,
width: Number(node.width) || 1024,
height: Number(node.height) || 1024,
bytes: Number(node.metadata?.bytes) || 0,
mimeType: (node.metadata?.mimeType as string) || "image/png",
},
metadata: {
canvasId: project.id,
sourceNodeId: node.id,
},
});
assetChanged = true;
} else if (node.type === "video") {
useAssetStore.getState().addAsset({
kind: "video",
title,
coverUrl: content || "",
tags: prompt ? [prompt.slice(0, 16)] : ["画布视频"],
category: "other",
status: "archived",
source: `已删除画布:${project.title}`,
data: {
url: content || "",
storageKey,
width: Number(node.width) || 1280,
height: Number(node.height) || 720,
durationMs: Number(node.metadata?.durationMs) || 0,
bytes: Number(node.metadata?.bytes) || 0,
mimeType: (node.metadata?.mimeType as string) || "video/mp4",
},
metadata: {
canvasId: project.id,
sourceNodeId: node.id,
},
});
assetChanged = true;
} else if (node.type === "audio") {
useAssetStore.getState().addAsset({
kind: "audio",
title,
coverUrl: "",
tags: ["画布音频"],
category: "other",
status: "archived",
source: `已删除画布:${project.title}`,
data: {
url: content || "",
storageKey,
durationMs: Number(node.metadata?.durationMs) || 0,
bytes: Number(node.metadata?.bytes) || 0,
mimeType: (node.metadata?.mimeType as string) || "audio/mpeg",
},
metadata: {
canvasId: project.id,
sourceNodeId: node.id,
},
});
assetChanged = true;
}
}
await useAssetStore.getState().removeAsset(asset.id);
}
if (assetsToRemove.length > 0) {
if (assetChanged) {
await flushAssetStorePersistence();
if (activeRemoteUserId) {
try {
await drainRemoteUserDataChanges();
} catch (syncErr) {
console.warn("回收站素材云端同步警告:", syncErr);
}
}
}
});
}
@@ -272,11 +373,7 @@ function collectLocalMediaKeys(value: unknown, set = new Set<string>()): string[
return [...set];
}
async function ensureRemoteResourceReferences<T>(
value: T,
uploaded = new Map<string, string>(),
onUploaded?: () => void,
): Promise<T> {
async function ensureRemoteResourceReferences<T>(value: T, uploaded = new Map<string, string>(), onUploaded?: () => void): Promise<T> {
if (!value || typeof value !== "object") return value;
if (Array.isArray(value)) {
const result: unknown[] = [];

View File

@@ -0,0 +1,55 @@
import { create } from "zustand";
import { persist } from "zustand/middleware";
import type { CanvasProject } from "@/stores/canvas/use-canvas-store";
export type DeletedCanvasHistoryItem = {
id: string;
title: string;
createdAt: string;
updatedAt: string;
deletedAt: string;
nodeCount: number;
coverUrl?: string;
};
type CanvasHistoryStore = {
deletedProjects: DeletedCanvasHistoryItem[];
recordDeletedProjects: (projects: CanvasProject[]) => void;
removeDeletedHistoryItem: (id: string) => void;
clearDeletedHistory: () => void;
};
export const useCanvasHistoryStore = create<CanvasHistoryStore>()(
persist(
(set, get) => ({
deletedProjects: [],
recordDeletedProjects: (projects) => {
const now = new Date().toISOString();
const newItems: DeletedCanvasHistoryItem[] = projects.map((p) => ({
id: p.id,
title: p.title || "未命名画布",
createdAt: p.createdAt || p.updatedAt || now,
updatedAt: p.updatedAt || now,
deletedAt: now,
nodeCount: p.nodes?.length || 0,
coverUrl: p.nodes?.find((n) => n.type === "image" && n.metadata?.content)?.metadata?.content as string | undefined,
}));
set((state) => {
const existingIds = new Set(newItems.map((item) => item.id));
const filtered = state.deletedProjects.filter((item) => !existingIds.has(item.id));
return {
deletedProjects: [...newItems, ...filtered].slice(0, 200),
};
});
},
removeDeletedHistoryItem: (id) =>
set((state) => ({
deletedProjects: state.deletedProjects.filter((item) => item.id !== id),
})),
clearDeletedHistory: () => set({ deletedProjects: [] }),
}),
{
name: "infinite-canvas:deleted_history_store",
},
),
);

View File

@@ -22,6 +22,7 @@ export type RuntimeLimits = {
activeTaskLimit: number;
resourceUploadMB: number;
sessionUploadMB: number;
recycleBinRetentionDays?: number;
};
export type FeatureAvailability = {