diff --git a/web/src/pages/assets/index.tsx b/web/src/pages/assets/index.tsx index e754e87fa..3036d590a 100644 --- a/web/src/pages/assets/index.tsx +++ b/web/src/pages/assets/index.tsx @@ -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 { useEffect, useMemo, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { App, Button, Drawer, Dropdown, Form, Input, Modal, Select, Space, Tag, Typography } from "antd"; +import { App, Button, Drawer, Dropdown, Form, Input, Modal, Progress, Select, Space, Tag, Typography } from "antd"; import type { MenuProps } from "antd"; import { useNavigate } from "react-router"; @@ -13,7 +13,7 @@ import { saveAs } from "file-saver"; import { useCopyText } from "@/hooks/use-copy-text"; import { resourceStorageLabel, resourceStorageLocation, resourceStorageTitle } from "@/lib/canvas/resource-storage-status"; -import { formatBytes, readFileAsDataUrl } from "@/lib/image-utils"; +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"; @@ -93,6 +93,9 @@ export default function AssetsPage() { const [formKind, setFormKind] = useState("text"); const [imageDraft, setImageDraft] = useState(null); + const [imageFile, setImageFile] = useState(null); + const [imageUploading, setImageUploading] = useState(false); + const [imageUploadProgress, setImageUploadProgress] = useState<{ phase: "uploading" | "confirming"; percent?: number } | null>(null); const coverUrl = Form.useWatch("coverUrl", form) || ""; const title = Form.useWatch("title", form) || ""; const tags = Form.useWatch("tags", form) || []; @@ -133,6 +136,9 @@ export default function AssetsPage() { const openCreate = () => { setEditingAsset(null); setImageDraft(null); + setImageFile(null); + setImageUploading(false); + setImageUploadProgress(null); setFormKind("text"); form.setFieldsValue({ kind: "text", category: "other", title: "", coverUrl: "", tags: [], source: "手动添加", note: "", content: "" }); setIsAssetOpen(true); @@ -140,6 +146,9 @@ export default function AssetsPage() { const openEdit = (asset: LibraryAsset) => { setEditingAsset(asset); + setImageFile(null); + setImageUploading(false); + setImageUploadProgress(null); setFormKind(asset.kind); setImageDraft(asset.kind === "image" ? asset.data : null); form.setFieldsValue({ @@ -157,12 +166,32 @@ export default function AssetsPage() { const saveAsset = async () => { const values = await form.validateFields(); + let imageData = imageDraft; + if (values.kind === "image" && imageFile) { + setImageUploading(true); + setImageUploadProgress({ phase: "uploading", percent: 0 }); + try { + const image = await uploadImage(imageFile); + setImageUploadProgress({ phase: "confirming" }); + imageData = { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType }; + setImageDraft(imageData); + setImageFile(null); + void queryClient.invalidateQueries({ queryKey: assetStorageUsageQueryKey }); + } catch (error) { + message.error(error instanceof Error ? error.message : "图片上传失败,请重试"); + return; + } finally { + setImageUploading(false); + setImageUploadProgress(null); + } + } + const base = { title: values.title.trim(), category: values.category, status: editingAsset?.status || "confirmed" as const, primaryVersionId: editingAsset?.primaryVersionId, - coverUrl: values.coverUrl?.trim() || (values.kind === "image" && imageDraft ? imageDraft.dataUrl : ""), + coverUrl: values.coverUrl?.trim() || (values.kind === "image" && imageData ? imageData.dataUrl : ""), tags: values.tags || [], source: values.source?.trim(), note: values.note?.trim(), @@ -173,11 +202,11 @@ export default function AssetsPage() { const asset = { ...base, kind: "text" as const, data: { content: (values.content || "").trim() } }; editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset); } else { - if (!imageDraft) { + if (!imageData) { message.error("请选择图片文件"); return; } - const asset = { ...base, kind: "image" as const, data: imageDraft }; + const asset = { ...base, kind: "image" as const, data: imageData }; editingAsset ? updateAsset(editingAsset.id, asset) : addAsset(asset); } @@ -192,13 +221,18 @@ export default function AssetsPage() { }; const readImageFile = async (file?: File) => { - if (!file || !file.type.startsWith("image/")) return; - const image = await uploadImage(file); - void queryClient.invalidateQueries({ queryKey: assetStorageUsageQueryKey }); - const draft = { dataUrl: image.url, storageKey: image.storageKey, width: image.width, height: image.height, bytes: image.bytes, mimeType: image.mimeType }; - setImageDraft(draft); - if (!form.getFieldValue("coverUrl")) form.setFieldValue("coverUrl", draft.dataUrl); - if (!form.getFieldValue("title")) form.setFieldValue("title", file.name); + if (!file || !file.type.startsWith("image/") || imageUploading) return; + try { + const dataUrl = await readFileAsDataUrl(file); + const meta = await readImageMeta(dataUrl); + setImageFile(file); + const draft = { dataUrl, storageKey: "", width: meta.width, height: meta.height, bytes: file.size, mimeType: file.type || meta.mimeType }; + setImageDraft(draft); + if (!form.getFieldValue("coverUrl")) form.setFieldValue("coverUrl", dataUrl); + if (!form.getFieldValue("title")) form.setFieldValue("title", file.name); + } catch (error) { + message.error(error instanceof Error ? error.message : "读取图片失败,请重试"); + } }; const readModelFile = async (file?: File) => { @@ -340,7 +374,7 @@ export default function AssetsPage() { - setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden> + { if (!imageUploading) setIsAssetOpen(false); }} onOk={() => void saveAsset()} okText={imageUploading ? "正在上传" : "保存"} cancelText="取消" confirmLoading={imageUploading} cancelButtonProps={{ disabled: imageUploading }} closable={!imageUploading} destroyOnHidden>
@@ -384,9 +418,10 @@ export default function AssetsPage() { ) : (
- + {imageFile ? 待保存上传 : null} {imageDraft ? ( {imageDraft.width}x{imageDraft.height} · {formatBytes(imageDraft.bytes)} · {resourceStorageLabel(imageDraft.storageKey)} @@ -404,7 +439,18 @@ export default function AssetsPage() { 预览
{coverUrl || imageDraft?.dataUrl ? ( - +
+ + {imageUploading && imageUploadProgress ? ( +
+
+ {imageUploadProgress.phase === "confirming" ? "正在确认资源" : "正在上传到云端"} + {typeof imageUploadProgress.percent === "number" ? {imageUploadProgress.percent}% : null} +
+ +
+ ) : null} +
) : (
{content || "暂无封面"}
)} diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 2b6608e81..4f0b0f240 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -12997,6 +12997,13 @@ html:not(.dark) .creation-home .storyboard-workbench-bar-action:hover { backgrou .assets-empty-card strong { color: var(--foreground); font-size: var(--fs-body); font-weight: 620; } .assets-empty-card > span:last-child { color: color-mix(in srgb, var(--foreground) 48%, transparent); font-size: var(--fs-label); line-height: 1.55; } +.asset-preview-uploading { position: relative; overflow: hidden; } +.asset-preview-uploading.is-uploading::after { position: absolute; inset: 0; background: color-mix(in srgb, var(--foreground) 16%, transparent); content: ""; pointer-events: none; } +.asset-preview-uploading-panel { position: absolute; right: var(--space-3); bottom: var(--space-3); left: var(--space-3); z-index: 1; padding: var(--space-3); border-radius: var(--r-md); background: color-mix(in srgb, var(--background) 92%, transparent); color: var(--foreground); } +.asset-preview-uploading-copy { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); font-size: var(--fs-caption); } +.asset-preview-uploading-copy strong { font-variant-numeric: tabular-nums; } +.asset-preview-uploading-panel .ant-progress { margin: var(--space-2) 0 0; } + /* 资产档案抽屉 */ .asset-archive-header { display: flex; align-items: flex-start; gap: 14px; } .asset-archive-header-icon { display: grid; width: 46px; height: 46px; flex: 0 0 auto; place-items: center; border-radius: 14px; background: color-mix(in srgb, var(--workspace-accent) 10%, transparent); color: var(--workspace-accent); }