Browse Source

feat: 单素材手动上传替换 + 点击素材卡片即可上传

- 新增 POST /api/v1/creatives/:id/assets/:key 单文件上传接口
- 客户端 api.ts 新增 uploadAssetFile 方法
- AssetUploader: optional 图片素材卡片可点击上传替换
- hover 高亮提示、🔄 图标表示可替换
guoziyun 3 tuần trước cách đây
mục cha
commit
b25a166dc3

+ 11 - 0
platform/client/src/api/client.ts

@@ -53,6 +53,17 @@ export const api = {
       method: "POST",
       body: JSON.stringify({ url }),
     }),
+  uploadAssetFile: (creativeId: string, key: string, file: File) => {
+    const formData = new FormData();
+    formData.append("file", file);
+    return fetch(`${BASE}/creatives/${creativeId}/assets/${key}`, {
+      method: "POST",
+      body: formData,
+    }).then((res) => {
+      if (!res.ok) return res.json().then((b) => { throw new Error(b.error?.message); });
+      return res.json();
+    });
+  },
   clearAssets: (creativeId: string) =>
     request<{ data: any }>(`/creatives/${creativeId}/assets`, { method: "DELETE" }),
 

+ 26 - 0
platform/client/src/components/AssetUploader.module.css

@@ -137,6 +137,32 @@
   font-size: 13px;
 }
 
+.fileItemClickable {
+  cursor: pointer;
+  border-radius: 6px;
+  padding: 4px 6px;
+  transition: background 0.15s;
+}
+
+.fileItemClickable:hover {
+  background: rgba(0, 113, 227, 0.06);
+}
+
+.thumbLoading {
+  width: 36px;
+  height: 36px;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  font-size: 20px;
+  flex-shrink: 0;
+}
+
+.replaceHint {
+  font-size: 11px;
+  color: var(--color-primary);
+}
+
 .thumb {
   width: 36px;
   height: 36px;

+ 56 - 4
platform/client/src/components/AssetUploader.tsx

@@ -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}>

+ 43 - 0
platform/server/src/routes/assets.ts

@@ -211,6 +211,49 @@ export function assetsRouter(db: Database.Database, storageDir: string): Router
     fs.createReadStream(asset.file_path).pipe(res);
   });
 
+  // POST /api/v1/creatives/:id/assets/:key — 上传单个素材文件(手动替换)
+  router.post("/creatives/:id/assets/:key", upload.single("file"), (req, res) => {
+    try {
+      const creativeId = req.params.id as string;
+      const fileKey = req.params.key as string;
+
+      const creative = db
+        .prepare("SELECT c.*, t.manifest FROM creatives c JOIN templates t ON c.template_id = t.id WHERE c.id = ?")
+        .get(creativeId) as any;
+      if (!creative) { res.status(404).json({ error: { message: "Creative not found" } }); return; }
+
+      const manifest = JSON.parse(creative.manifest);
+      const requiredAssets: Array<{ key: string; file: string; accept?: string }> = manifest.assets?.required ?? [];
+      const optionalAssets: Array<{ key: string; file: string; accept?: string }> = manifest.assets?.optional ?? [];
+      const def = [...requiredAssets, ...optionalAssets].find((d: any) => d.key === fileKey);
+      if (!def) { res.status(400).json({ error: { message: `Unknown asset key: ${fileKey}` } }); return; }
+
+      if (!req.file) { res.status(400).json({ error: { message: "请选择文件" } }); return; }
+
+      const assetsDir = path.join(storageDir, "creatives", creativeId, "assets");
+      if (!fs.existsSync(assetsDir)) fs.mkdirSync(assetsDir, { recursive: true });
+
+      // 删除同 key 的旧文件
+      const oldAsset = db.prepare("SELECT file_path FROM creative_assets WHERE creative_id = ? AND file_key = ?").get(creativeId, fileKey) as any;
+      if (oldAsset && fs.existsSync(oldAsset.file_path)) fs.unlinkSync(oldAsset.file_path);
+
+      const filePath = path.join(assetsDir, def.file);
+      fs.writeFileSync(filePath, req.file.buffer);
+      const stat = fs.statSync(filePath);
+
+      db.prepare("DELETE FROM creative_assets WHERE creative_id = ? AND file_key = ?").run(creativeId, fileKey);
+      db.prepare(
+        "INSERT INTO creative_assets (creative_id, file_key, file_name, file_path, file_size, is_required) VALUES (?, ?, ?, ?, ?, ?)"
+      ).run(creativeId, fileKey, def.file, filePath, stat.size, requiredAssets.some((r) => r.key === fileKey) ? 1 : 0);
+
+      console.log(`[assets] Single upload: ${fileKey} -> ${filePath} (${stat.size} bytes)`);
+      res.json({ data: { key: fileKey, fileName: def.file, fileSize: stat.size, valid: true } });
+    } catch (err: any) {
+      console.error("[assets] Single upload error:", err.message);
+      res.status(500).json({ error: { message: err.message } });
+    }
+  });
+
   // DELETE /api/v1/creatives/:id/assets — 清除素材
   router.delete("/creatives/:id/assets", (req, res) => {
     const creativeId = req.params.id as string;