| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- import { useRef, useState } from "react";
- import { api } from "../api/client";
- import type { CreativeAsset, AssetDef } from "../types";
- import styles from "./AssetUploader.module.css";
- const BASE = `${import.meta.env.BASE_URL}api/v1`.replace(/\/+$/, "");
- // 可展示缩略图的图片后缀
- const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
- function isImageFile(fileName: string): boolean {
- const ext = fileName.substring(fileName.lastIndexOf(".")).toLowerCase();
- return IMAGE_EXTENSIONS.has(ext);
- }
- interface Props {
- creativeId: string;
- assets: CreativeAsset[];
- assetDefs: { required: AssetDef[]; optional: AssetDef[] };
- onUpdated: () => void;
- }
- 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;
- setUploading(true);
- setError("");
- setUploadResult(null);
- try {
- const res = await api.uploadAssets(creativeId, file);
- setUploadResult(res.data);
- onUpdated();
- } catch (err: any) {
- setError(err.message);
- } finally {
- setUploading(false);
- e.target.value = "";
- }
- }
- // === URL 导入 ===
- async function handleUrlImport() {
- if (!url.trim()) return;
- setUploading(true);
- setError("");
- setUploadResult(null);
- try {
- const res = await api.importFromUrl(creativeId, url.trim());
- setUploadResult(res.data);
- onUpdated();
- } catch (err: any) {
- setError(err.message);
- } finally {
- setUploading(false);
- }
- }
- // === 单个素材文件上传(点击替换) ===
- 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 {
- await api.clearAssets(creativeId);
- setUploadResult(null);
- setUrl("");
- onUpdated();
- } catch (err: any) {
- setError(err.message);
- }
- }
- const allDefs = [...assetDefs.required, ...assetDefs.optional];
- const hasAllRequired = assetDefs.required.every((def) =>
- assets.some((a) => a.key === def.key)
- );
- // 哪些 key 是可手动替换的(optional + 图片文件,排除 special)
- const EXCLUDE_MANUAL = new Set(["special"]);
- const replaceableDefs = assetDefs.optional.filter((d) => isImageFile(d.file) && !EXCLUDE_MANUAL.has(d.key));
- const replaceableKeys = new Set(replaceableDefs.map((d) => d.key));
- // 填色素材(ZIP 导入的)与可替换素材分开
- const zipAssetDefs = allDefs.filter((d) => !replaceableKeys.has(d.key));
- const brandAssetDefs = allDefs.filter((d) => replaceableKeys.has(d.key));
- return (
- <div className={styles.wrapper}>
- {/* 模式切换 */}
- <div className={styles.tabs}>
- <button
- className={`${styles.tab} ${mode === "file" ? styles.tabActive : ""}`}
- onClick={() => setMode("file")}
- >
- 上传 ZIP
- </button>
- <button
- className={`${styles.tab} ${mode === "url" ? styles.tabActive : ""}`}
- onClick={() => setMode("url")}
- >
- 素材 URL
- </button>
- </div>
- {/* 文件上传 */}
- {mode === "file" && (
- <label className={styles.dropZone}>
- <input
- type="file"
- accept=".zip"
- onChange={handleFileUpload}
- disabled={uploading}
- className={styles.fileInput}
- />
- <span className={styles.dropIcon}>📦</span>
- <span className={styles.dropText}>
- {uploading ? "解压中…" : "拖拽或点击上传素材 zip"}
- </span>
- <span className={styles.dropHint}>
- 包含 config.json + page.png + map.png ± special.jpeg
- </span>
- </label>
- )}
- {/* URL 导入 */}
- {mode === "url" && (
- <div className={styles.urlRow}>
- <input
- type="url"
- className={styles.urlInput}
- placeholder="https://color2.jccytech.cn/app/zh/pages/detail/6a15..."
- value={url}
- onChange={(e) => setUrl(e.target.value)}
- disabled={uploading}
- />
- <button
- onClick={handleUrlImport}
- disabled={!url.trim() || uploading}
- className={styles.urlBtn}
- >
- {uploading ? "获取中…" : "导入"}
- </button>
- </div>
- )}
- {/* 上传结果 */}
- {uploadResult && uploadResult.warnings.length > 0 && (
- <div className={styles.warnings}>
- {uploadResult.warnings.map((w, i) => (
- <p key={i}>⚠️ {w}</p>
- ))}
- </div>
- )}
- {error && <p className={styles.error}>{error}</p>}
- {/* 文件列表 */}
- <div className={styles.fileList}>
- <div className={styles.fileListHeader}>
- <span>素材文件</span>
- {assets.length > 0 && (
- <button onClick={handleClear} className={styles.clearBtn}>
- 清除素材
- </button>
- )}
- </div>
- {/* 填色素材 (ZIP 导入) */}
- <div className={styles.sectionLabel}>填色素材</div>
- {zipAssetDefs.map((def) => {
- 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);
- return (
- <div key={def.key} className={styles.fileItem}>
- {showThumb ? (
- <img
- className={styles.thumb}
- src={`${BASE}/creatives/${creativeId}/assets/${def.key}`}
- alt={def.label}
- />
- ) : (
- <span className={asset ? styles.fileOk : styles.fileMissing}>
- {asset ? "✅" : isRequired ? "❌" : "⬜"}
- </span>
- )}
- <span className={styles.fileName}>{def.file}</span>
- <span className={styles.fileLabel}>
- {def.label} {!isRequired && !asset ? "(无)" : !isRequired ? "(选填)" : ""}
- </span>
- {asset && (
- <span className={styles.fileSize}>
- {(asset.fileSize / 1024).toFixed(0)} KB
- </span>
- )}
- </div>
- );
- })}
- {/* 品牌素材 (可手动替换) */}
- {brandAssetDefs.length > 0 && (
- <>
- <div className={styles.divider} />
- <div className={styles.sectionLabel}>品牌素材 · 点击可替换</div>
- {brandAssetDefs.map((def) => {
- const asset = assets.find((a) => a.key === def.key);
- const isUploading = singleUploading === def.key;
- return (
- <div
- key={def.key}
- className={`${styles.fileItem} ${styles.fileItemClickable}`}
- onClick={() => handleSingleUploadClick(def.key)}
- title="点击上传替换"
- >
- <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>
- ) : (
- <img
- className={styles.thumb}
- src={`${BASE}/creatives/${creativeId}/assets/${def.key}`}
- alt={def.label}
- />
- )}
- <span className={styles.fileName}>{def.file}</span>
- <span className={styles.fileLabel}>
- {def.label}
- <span className={styles.dimensions}>{def.dimensions}</span>
- {asset && <span className={styles.replaceHint}> 🔄</span>}
- </span>
- {asset && (
- <span className={styles.fileSize}>
- {(asset.fileSize / 1024).toFixed(0)} KB
- </span>
- )}
- </div>
- );
- })}
- </>
- )}
- </div>
- {!hasAllRequired && (
- <p className={styles.hint}>请上传包含所有必填文件的 zip 包</p>
- )}
- {!hasAllRequired && assets.length > 0 && (
- <p className={styles.hint}>请上传包含所有必填文件的 zip 包</p>
- )}
- </div>
- );
- }
|