AssetUploader.tsx 9.6 KB

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