feat(assets): 素材库 - 保存时上传图片并显示进度
合并 PR #308。 原作者:陈泽 <749959948@qq.com> 已知后续:浏览器上传进度目前显示为初始值/确认中状态,后续可补充真实百分比回调。
This commit is contained in:
@@ -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<AssetKind>("text");
|
||||
const [imageDraft, setImageDraft] = useState<ImageDraft>(null);
|
||||
const [imageFile, setImageFile] = useState<File | null>(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() {
|
||||
</div>
|
||||
</WorkspacePage>
|
||||
|
||||
<Modal className="workspace-modal workspace-modal-wide library-modal" title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} onCancel={() => setIsAssetOpen(false)} onOk={() => void saveAsset()} okText="保存" cancelText="取消" destroyOnHidden>
|
||||
<Modal className="workspace-modal workspace-modal-wide library-modal" title={editingAsset ? "编辑素材" : "新增素材"} open={isAssetOpen} onCancel={() => { if (!imageUploading) setIsAssetOpen(false); }} onOk={() => void saveAsset()} okText={imageUploading ? "正在上传" : "保存"} cancelText="取消" confirmLoading={imageUploading} cancelButtonProps={{ disabled: imageUploading }} closable={!imageUploading} destroyOnHidden>
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<Form form={form} layout="vertical" requiredMark={false} initialValues={{ kind: "text", category: "other", tags: [] }}>
|
||||
<Form.Item name="kind" label="类型">
|
||||
@@ -384,9 +418,10 @@ export default function AssetsPage() {
|
||||
) : (
|
||||
<Form.Item label="图片内容" required>
|
||||
<div className="rounded-lg border border-dashed border-stone-300 p-4 dark:border-stone-700">
|
||||
<Button icon={<Upload className="size-4" />} onClick={() => imageInputRef.current?.click()}>
|
||||
选择图片文件
|
||||
<Button disabled={imageUploading} icon={<Upload className="size-4" />} onClick={() => imageInputRef.current?.click()}>
|
||||
{imageUploading ? "正在上传图片" : "选择图片文件"}
|
||||
</Button>
|
||||
{imageFile ? <Tag color="gold" className="ml-3">待保存上传</Tag> : null}
|
||||
{imageDraft ? (
|
||||
<Typography.Text type="secondary" className="ml-3 text-xs" title={resourceStorageTitle(imageDraft.storageKey)}>
|
||||
{imageDraft.width}x{imageDraft.height} · {formatBytes(imageDraft.bytes)} · {resourceStorageLabel(imageDraft.storageKey)}
|
||||
@@ -404,7 +439,18 @@ export default function AssetsPage() {
|
||||
<Typography.Text strong className="text-xs">预览</Typography.Text>
|
||||
<div className="mt-2 overflow-hidden rounded-md bg-stone-100 dark:bg-stone-900">
|
||||
{coverUrl || imageDraft?.dataUrl ? (
|
||||
<img src={coverUrl || imageDraft?.dataUrl} alt="" loading="lazy" decoding="async" className="aspect-[4/3] w-full object-cover" />
|
||||
<div className={`asset-preview-uploading ${imageUploading ? "is-uploading" : ""}`}>
|
||||
<img src={coverUrl || imageDraft?.dataUrl} alt="" loading="lazy" decoding="async" className="aspect-[4/3] w-full object-cover" />
|
||||
{imageUploading && imageUploadProgress ? (
|
||||
<div className="asset-preview-uploading-panel">
|
||||
<div className="asset-preview-uploading-copy">
|
||||
<span>{imageUploadProgress.phase === "confirming" ? "正在确认资源" : "正在上传到云端"}</span>
|
||||
{typeof imageUploadProgress.percent === "number" ? <strong>{imageUploadProgress.percent}%</strong> : null}
|
||||
</div>
|
||||
<Progress percent={imageUploadProgress.percent} showInfo={false} size="small" status="active" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex aspect-[4/3] items-center justify-center bg-stone-100 p-5 text-center text-sm text-stone-500 dark:bg-stone-900">{content || "暂无封面"}</div>
|
||||
)}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user