AssetUploader.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263
  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 replaceableKeys = new Set(
  102. assetDefs.optional.filter((d) => isImageFile(d.file) && !EXCLUDE_MANUAL.has(d.key)).map((d) => d.key)
  103. );
  104. return (
  105. <div className={styles.wrapper}>
  106. {/* 模式切换 */}
  107. <div className={styles.tabs}>
  108. <button
  109. className={`${styles.tab} ${mode === "file" ? styles.tabActive : ""}`}
  110. onClick={() => setMode("file")}
  111. >
  112. 上传 ZIP
  113. </button>
  114. <button
  115. className={`${styles.tab} ${mode === "url" ? styles.tabActive : ""}`}
  116. onClick={() => setMode("url")}
  117. >
  118. 素材 URL
  119. </button>
  120. </div>
  121. {/* 文件上传 */}
  122. {mode === "file" && (
  123. <label className={styles.dropZone}>
  124. <input
  125. type="file"
  126. accept=".zip"
  127. onChange={handleFileUpload}
  128. disabled={uploading}
  129. className={styles.fileInput}
  130. />
  131. <span className={styles.dropIcon}>📦</span>
  132. <span className={styles.dropText}>
  133. {uploading ? "解压中…" : "拖拽或点击上传素材 zip"}
  134. </span>
  135. <span className={styles.dropHint}>
  136. 包含 config.json + page.png + map.png ± special.jpeg
  137. </span>
  138. </label>
  139. )}
  140. {/* URL 导入 */}
  141. {mode === "url" && (
  142. <div className={styles.urlRow}>
  143. <input
  144. type="url"
  145. className={styles.urlInput}
  146. placeholder="https://color2.jccytech.cn/app/zh/pages/detail/6a15..."
  147. value={url}
  148. onChange={(e) => setUrl(e.target.value)}
  149. disabled={uploading}
  150. />
  151. <button
  152. onClick={handleUrlImport}
  153. disabled={!url.trim() || uploading}
  154. className={styles.urlBtn}
  155. >
  156. {uploading ? "获取中…" : "导入"}
  157. </button>
  158. </div>
  159. )}
  160. {/* 上传结果 */}
  161. {uploadResult && uploadResult.warnings.length > 0 && (
  162. <div className={styles.warnings}>
  163. {uploadResult.warnings.map((w, i) => (
  164. <p key={i}>⚠️ {w}</p>
  165. ))}
  166. </div>
  167. )}
  168. {error && <p className={styles.error}>{error}</p>}
  169. {/* 文件列表 */}
  170. {assets.length > 0 && (
  171. <div className={styles.fileList}>
  172. <div className={styles.fileListHeader}>
  173. <span>
  174. 已上传文件 ({assets.filter((a) => a.isRequired).length}/{assetDefs.required.length} 必填)
  175. </span>
  176. <button onClick={handleClear} className={styles.clearBtn}>
  177. 清除素材
  178. </button>
  179. </div>
  180. {allDefs.map((def) => {
  181. const asset = assets.find((a) => a.key === def.key);
  182. const isRequired = assetDefs.required.some((r) => r.key === def.key);
  183. const showThumb = asset && isImageFile(def.file);
  184. const canReplace = replaceableKeys.has(def.key);
  185. const isUploading = singleUploading === def.key;
  186. return (
  187. <div
  188. key={def.key}
  189. className={`${styles.fileItem} ${canReplace ? styles.fileItemClickable : ""}`}
  190. onClick={canReplace ? () => handleSingleUploadClick(def.key) : undefined}
  191. title={canReplace ? "点击上传替换" : undefined}
  192. >
  193. {/* 隐藏的文件选择器 */}
  194. {canReplace && (
  195. <input
  196. type="file"
  197. accept={def.accept || ".png,.jpg,.jpeg"}
  198. ref={(el) => { fileInputRefs.current[def.key] = el; }}
  199. onChange={(e) => handleSingleFileChange(def.key, e)}
  200. className={styles.fileInput}
  201. />
  202. )}
  203. {isUploading ? (
  204. <span className={styles.thumbLoading}>⏳</span>
  205. ) : showThumb ? (
  206. <img
  207. className={styles.thumb}
  208. src={`${BASE}/creatives/${creativeId}/assets/${def.key}`}
  209. alt={def.label}
  210. />
  211. ) : (
  212. <span className={asset ? styles.fileOk : styles.fileMissing}>
  213. {asset ? "✅" : isRequired ? "❌" : "⬜"}
  214. </span>
  215. )}
  216. <span className={styles.fileName}>{def.file}</span>
  217. <span className={styles.fileLabel}>
  218. {def.label} {!isRequired && "(选填)"}
  219. {canReplace && asset && <span className={styles.replaceHint}> 🔄</span>}
  220. </span>
  221. {asset && (
  222. <span className={styles.fileSize}>
  223. {(asset.fileSize / 1024).toFixed(0)} KB
  224. </span>
  225. )}
  226. </div>
  227. );
  228. })}
  229. </div>
  230. )}
  231. {!hasAllRequired && assets.length > 0 && (
  232. <p className={styles.hint}>请上传包含所有必填文件的 zip 包</p>
  233. )}
  234. </div>
  235. );
  236. }