AssetUploader.tsx 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import { useRef, useState } from "react";
  2. import { api } from "../api/client";
  3. import type { CreativeAsset, AssetDef } from "../types";
  4. import styles from "./AssetUploader.module.css";
  5. const BASE = `${import.meta.env.BASE_URL}api/v1`.replace(/\/+$/, "");
  6. // 可展示缩略图的图片后缀
  7. const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
  8. function isImageFile(fileName: string): boolean {
  9. const ext = fileName.substring(fileName.lastIndexOf(".")).toLowerCase();
  10. return IMAGE_EXTENSIONS.has(ext);
  11. }
  12. interface Props {
  13. creativeId: string;
  14. assets: CreativeAsset[];
  15. assetDefs: { required: AssetDef[]; optional: AssetDef[] };
  16. onUpdated: () => void;
  17. }
  18. type ImportMode = "file" | "url";
  19. export default function AssetUploader({ creativeId, assets, assetDefs, onUpdated }: Props) {
  20. const [mode, setMode] = useState<ImportMode>("file");
  21. const [uploading, setUploading] = useState(false);
  22. const [singleUploading, setSingleUploading] = useState<string | null>(null);
  23. const [url, setUrl] = useState("");
  24. const [uploadResult, setUploadResult] = useState<{
  25. files: Array<{ key: string; fileName: string; fileSize: number; valid: boolean }>;
  26. warnings: string[];
  27. } | null>(null);
  28. const [error, setError] = useState("");
  29. const [imgDims, setImgDims] = useState<Record<string, string>>({});
  30. const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({});
  31. // 读取图片实际尺寸
  32. function handleImgLoad(key: string, e: React.SyntheticEvent<HTMLImageElement>) {
  33. const img = e.currentTarget;
  34. setImgDims((prev) => ({ ...prev, [key]: `${img.naturalWidth}×${img.naturalHeight}` }));
  35. }
  36. // === ZIP 上传 ===
  37. async function handleFileUpload(e: React.ChangeEvent<HTMLInputElement>) {
  38. const file = e.target.files?.[0];
  39. if (!file) return;
  40. setUploading(true);
  41. setError("");
  42. setUploadResult(null);
  43. try {
  44. const res = await api.uploadAssets(creativeId, file);
  45. setUploadResult(res.data);
  46. onUpdated();
  47. } catch (err: any) {
  48. setError(err.message);
  49. } finally {
  50. setUploading(false);
  51. e.target.value = "";
  52. }
  53. }
  54. // === URL 导入 ===
  55. async function handleUrlImport() {
  56. if (!url.trim()) return;
  57. setUploading(true);
  58. setError("");
  59. setUploadResult(null);
  60. try {
  61. const res = await api.importFromUrl(creativeId, url.trim());
  62. setUploadResult(res.data);
  63. onUpdated();
  64. } catch (err: any) {
  65. setError(err.message);
  66. } finally {
  67. setUploading(false);
  68. }
  69. }
  70. // === 单个素材文件上传(点击替换) ===
  71. function handleSingleUploadClick(key: string) {
  72. fileInputRefs.current[key]?.click();
  73. }
  74. async function handleSingleFileChange(key: string, e: React.ChangeEvent<HTMLInputElement>) {
  75. const file = e.target.files?.[0];
  76. if (!file) return;
  77. setSingleUploading(key);
  78. setError("");
  79. try {
  80. await api.uploadAssetFile(creativeId, key, file);
  81. onUpdated();
  82. } catch (err: any) {
  83. setError(err.message);
  84. } finally {
  85. setSingleUploading(null);
  86. e.target.value = "";
  87. }
  88. }
  89. // === 清除 ===
  90. async function handleClear() {
  91. if (!confirm("确认清除所有素材?")) return;
  92. try {
  93. await api.clearAssets(creativeId);
  94. setUploadResult(null);
  95. setUrl("");
  96. onUpdated();
  97. } catch (err: any) {
  98. setError(err.message);
  99. }
  100. }
  101. const allDefs = [...assetDefs.required, ...assetDefs.optional];
  102. const hasAllRequired = assetDefs.required.every((def) =>
  103. assets.some((a) => a.key === def.key)
  104. );
  105. // 哪些 key 是可手动替换的(optional + 图片文件,排除 special)
  106. const EXCLUDE_MANUAL = new Set(["special"]);
  107. const replaceableDefs = assetDefs.optional.filter((d) => isImageFile(d.file) && !EXCLUDE_MANUAL.has(d.key));
  108. const replaceableKeys = new Set(replaceableDefs.map((d) => d.key));
  109. // 填色素材(ZIP 导入的)与可替换素材分开
  110. const zipAssetDefs = allDefs.filter((d) => !replaceableKeys.has(d.key));
  111. const brandAssetDefs = allDefs.filter((d) => replaceableKeys.has(d.key));
  112. return (
  113. <div className={styles.wrapper}>
  114. {/* 模式切换 */}
  115. <div className={styles.tabs}>
  116. <button
  117. className={`${styles.tab} ${mode === "file" ? styles.tabActive : ""}`}
  118. onClick={() => setMode("file")}
  119. >
  120. 上传 ZIP
  121. </button>
  122. <button
  123. className={`${styles.tab} ${mode === "url" ? styles.tabActive : ""}`}
  124. onClick={() => setMode("url")}
  125. >
  126. 素材 URL
  127. </button>
  128. </div>
  129. {/* 文件上传 */}
  130. {mode === "file" && (
  131. <label className={styles.dropZone}>
  132. <input
  133. type="file"
  134. accept=".zip"
  135. onChange={handleFileUpload}
  136. disabled={uploading}
  137. className={styles.fileInput}
  138. />
  139. <span className={styles.dropIcon}>📦</span>
  140. <span className={styles.dropText}>
  141. {uploading ? "解压中…" : "拖拽或点击上传素材 zip"}
  142. </span>
  143. <span className={styles.dropHint}>
  144. 包含 config.json + page.png + map.png ± special.jpeg
  145. </span>
  146. </label>
  147. )}
  148. {/* URL 导入 */}
  149. {mode === "url" && (
  150. <div className={styles.urlRow}>
  151. <input
  152. type="url"
  153. className={styles.urlInput}
  154. placeholder="https://color2.jccytech.cn/app/zh/pages/detail/6a15..."
  155. value={url}
  156. onChange={(e) => setUrl(e.target.value)}
  157. disabled={uploading}
  158. />
  159. <button
  160. onClick={handleUrlImport}
  161. disabled={!url.trim() || uploading}
  162. className={styles.urlBtn}
  163. >
  164. {uploading ? "获取中…" : "导入"}
  165. </button>
  166. </div>
  167. )}
  168. {/* 上传结果 */}
  169. {uploadResult && uploadResult.warnings.length > 0 && (
  170. <div className={styles.warnings}>
  171. {uploadResult.warnings.map((w, i) => (
  172. <p key={i}>⚠️ {w}</p>
  173. ))}
  174. </div>
  175. )}
  176. {error && <p className={styles.error}>{error}</p>}
  177. {/* 文件列表 */}
  178. <div className={styles.fileList}>
  179. <div className={styles.fileListHeader}>
  180. <span>素材文件</span>
  181. {assets.length > 0 && (
  182. <button onClick={handleClear} className={styles.clearBtn}>
  183. 清除素材
  184. </button>
  185. )}
  186. </div>
  187. {/* 填色素材 (ZIP 导入) */}
  188. <div className={styles.sectionLabel}>填色素材</div>
  189. {zipAssetDefs.map((def) => {
  190. const asset = assets.find((a) => a.key === def.key);
  191. const isRequired = assetDefs.required.some((r) => r.key === def.key);
  192. const showThumb = asset && isImageFile(def.file);
  193. return (
  194. <div key={def.key} className={styles.fileItem}>
  195. {showThumb ? (
  196. <img
  197. className={styles.thumb}
  198. src={`${BASE}/creatives/${creativeId}/assets/${def.key}`}
  199. alt={def.label}
  200. onLoad={(e) => handleImgLoad(def.key, e)}
  201. />
  202. ) : (
  203. <span className={asset ? styles.fileOk : styles.fileMissing}>
  204. {asset ? "✅" : isRequired ? "❌" : "⬜"}
  205. </span>
  206. )}
  207. <span className={styles.fileName}>{def.file}</span>
  208. <span className={styles.fileLabel}>
  209. {def.label} {!isRequired && !asset ? "(无)" : !isRequired ? "(选填)" : ""}
  210. {imgDims[def.key] && <span className={styles.dimensions}>{imgDims[def.key]}</span>}
  211. </span>
  212. {asset && (
  213. <span className={styles.fileSize}>
  214. {(asset.fileSize / 1024).toFixed(0)} KB
  215. </span>
  216. )}
  217. </div>
  218. );
  219. })}
  220. {/* 品牌素材 (可手动替换) */}
  221. {brandAssetDefs.length > 0 && (
  222. <>
  223. <div className={styles.divider} />
  224. <div className={styles.sectionLabel}>品牌素材 · 点击可替换</div>
  225. {brandAssetDefs.map((def) => {
  226. const asset = assets.find((a) => a.key === def.key);
  227. const isUploading = singleUploading === def.key;
  228. return (
  229. <div
  230. key={def.key}
  231. className={`${styles.fileItem} ${styles.fileItemClickable}`}
  232. onClick={() => handleSingleUploadClick(def.key)}
  233. title="点击上传替换"
  234. >
  235. <input
  236. type="file"
  237. accept={def.accept || ".png,.jpg,.jpeg"}
  238. ref={(el) => { fileInputRefs.current[def.key] = el; }}
  239. onChange={(e) => handleSingleFileChange(def.key, e)}
  240. className={styles.fileInput}
  241. />
  242. {isUploading ? (
  243. <span className={styles.thumbLoading}>⏳</span>
  244. ) : (
  245. <img
  246. className={styles.thumb}
  247. src={`${BASE}/creatives/${creativeId}/assets/${def.key}`}
  248. alt={def.label}
  249. onLoad={(e) => handleImgLoad(def.key, e)}
  250. />
  251. )}
  252. <span className={styles.fileName}>{def.file}</span>
  253. <span className={styles.fileLabel}>
  254. {def.label}
  255. {imgDims[def.key] && <span className={styles.dimensions}>{imgDims[def.key]}</span>}
  256. {def.dimensions && (
  257. <span className={styles.dimensionsHint}>建议 {def.dimensions}</span>
  258. )}
  259. {asset && <span className={styles.replaceHint}> 🔄</span>}
  260. </span>
  261. <span className={styles.fileSize}>
  262. {asset
  263. ? `${(asset.fileSize / 1024).toFixed(0)} KB`
  264. : def.defaultFileSize
  265. ? `${(def.defaultFileSize / 1024).toFixed(0)} KB`
  266. : ""}
  267. </span>
  268. </div>
  269. );
  270. })}
  271. </>
  272. )}
  273. </div>
  274. {!hasAllRequired && (
  275. <p className={styles.hint}>请上传包含所有必填文件的 zip 包</p>
  276. )}
  277. {!hasAllRequired && assets.length > 0 && (
  278. <p className={styles.hint}>请上传包含所有必填文件的 zip 包</p>
  279. )}
  280. </div>
  281. );
  282. }