|
|
@@ -1,4 +1,4 @@
|
|
|
-import { useState } from "react";
|
|
|
+import { useRef, useState } from "react";
|
|
|
import { api } from "../api/client";
|
|
|
import type { CreativeAsset, AssetDef } from "../types";
|
|
|
import styles from "./AssetUploader.module.css";
|
|
|
@@ -25,14 +25,16 @@ type ImportMode = "file" | "url";
|
|
|
export default function AssetUploader({ creativeId, assets, assetDefs, onUpdated }: Props) {
|
|
|
const [mode, setMode] = useState<ImportMode>("file");
|
|
|
const [uploading, setUploading] = useState(false);
|
|
|
+ const [singleUploading, setSingleUploading] = useState<string | null>(null);
|
|
|
const [url, setUrl] = useState("");
|
|
|
const [uploadResult, setUploadResult] = useState<{
|
|
|
files: Array<{ key: string; fileName: string; fileSize: number; valid: boolean }>;
|
|
|
warnings: string[];
|
|
|
} | null>(null);
|
|
|
const [error, setError] = useState("");
|
|
|
+ const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
|
|
|
|
|
|
- // === 文件上传 ===
|
|
|
+ // === ZIP 上传 ===
|
|
|
async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
|
|
const file = e.target.files?.[0];
|
|
|
if (!file) return;
|
|
|
@@ -72,6 +74,29 @@ export default function AssetUploader({ creativeId, assets, assetDefs, onUpdated
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ // === 单个素材文件上传(点击替换) ===
|
|
|
+ function handleSingleUploadClick(key: string) {
|
|
|
+ fileInputRefs.current[key]?.click();
|
|
|
+ }
|
|
|
+
|
|
|
+ async function handleSingleFileChange(key: string, e: React.ChangeEvent<HTMLInputElement>) {
|
|
|
+ const file = e.target.files?.[0];
|
|
|
+ if (!file) return;
|
|
|
+
|
|
|
+ setSingleUploading(key);
|
|
|
+ setError("");
|
|
|
+ try {
|
|
|
+ await api.uploadAssetFile(creativeId, key, file);
|
|
|
+ onUpdated();
|
|
|
+ } catch (err: any) {
|
|
|
+ setError(err.message);
|
|
|
+ } finally {
|
|
|
+ setSingleUploading(null);
|
|
|
+ e.target.value = "";
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // === 清除 ===
|
|
|
async function handleClear() {
|
|
|
if (!confirm("确认清除所有素材?")) return;
|
|
|
try {
|
|
|
@@ -89,6 +114,11 @@ export default function AssetUploader({ creativeId, assets, assetDefs, onUpdated
|
|
|
assets.some((a) => a.key === def.key)
|
|
|
);
|
|
|
|
|
|
+ // 哪些 key 是可手动替换的(optional + 图片文件)
|
|
|
+ const replaceableKeys = new Set(
|
|
|
+ assetDefs.optional.filter((d) => isImageFile(d.file)).map((d) => d.key)
|
|
|
+ );
|
|
|
+
|
|
|
return (
|
|
|
<div className={styles.wrapper}>
|
|
|
{/* 模式切换 */}
|
|
|
@@ -174,9 +204,30 @@ export default function AssetUploader({ creativeId, assets, assetDefs, onUpdated
|
|
|
const asset = assets.find((a) => a.key === def.key);
|
|
|
const isRequired = assetDefs.required.some((r) => r.key === def.key);
|
|
|
const showThumb = asset && isImageFile(def.file);
|
|
|
+ const canReplace = replaceableKeys.has(def.key);
|
|
|
+ const isUploading = singleUploading === def.key;
|
|
|
+
|
|
|
return (
|
|
|
- <div key={def.key} className={styles.fileItem}>
|
|
|
- {showThumb ? (
|
|
|
+ <div
|
|
|
+ key={def.key}
|
|
|
+ className={`${styles.fileItem} ${canReplace ? styles.fileItemClickable : ""}`}
|
|
|
+ onClick={canReplace ? () => handleSingleUploadClick(def.key) : undefined}
|
|
|
+ title={canReplace ? "点击上传替换" : undefined}
|
|
|
+ >
|
|
|
+ {/* 隐藏的文件选择器 */}
|
|
|
+ {canReplace && (
|
|
|
+ <input
|
|
|
+ type="file"
|
|
|
+ accept={def.accept || ".png,.jpg,.jpeg"}
|
|
|
+ ref={(el) => { fileInputRefs.current[def.key] = el; }}
|
|
|
+ onChange={(e) => handleSingleFileChange(def.key, e)}
|
|
|
+ className={styles.fileInput}
|
|
|
+ />
|
|
|
+ )}
|
|
|
+
|
|
|
+ {isUploading ? (
|
|
|
+ <span className={styles.thumbLoading}>⏳</span>
|
|
|
+ ) : showThumb ? (
|
|
|
<img
|
|
|
className={styles.thumb}
|
|
|
src={`${BASE}/creatives/${creativeId}/assets/${def.key}`}
|
|
|
@@ -190,6 +241,7 @@ export default function AssetUploader({ creativeId, assets, assetDefs, onUpdated
|
|
|
<span className={styles.fileName}>{def.file}</span>
|
|
|
<span className={styles.fileLabel}>
|
|
|
{def.label} {!isRequired && "(选填)"}
|
|
|
+ {canReplace && asset && <span className={styles.replaceHint}> 🔄</span>}
|
|
|
</span>
|
|
|
{asset && (
|
|
|
<span className={styles.fileSize}>
|