Răsfoiți Sursa

feat: 可替换品牌素材默认展示缩略图+分割线区分填色素材

- 服务端 GET assets/:key 未上传时自动回退模板默认图
- 前端品牌素材始终展示缩略图(未上传=默认,已替换=新图)
- 文件列表分区:「填色素材」+ 分割线 +「品牌素材·点击可替换」
- 显示逻辑独立于是否上传过文件
guoziyun 3 săptămâni în urmă
părinte
comite
e6a2687d01

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

@@ -163,6 +163,20 @@
   color: var(--color-primary);
 }
 
+.divider {
+  border-top: 1px dashed var(--color-border);
+  margin: 8px 0;
+}
+
+.sectionLabel {
+  font-size: 11px;
+  font-weight: 600;
+  color: var(--color-text-secondary);
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+  margin-bottom: 4px;
+}
+
 .thumb {
   width: 36px;
   height: 36px;

+ 87 - 52
platform/client/src/components/AssetUploader.tsx

@@ -116,9 +116,11 @@ export default function AssetUploader({ creativeId, assets, assetDefs, onUpdated
 
   // 哪些 key 是可手动替换的(optional + 图片文件,排除 special)
   const EXCLUDE_MANUAL = new Set(["special"]);
-  const replaceableKeys = new Set(
-    assetDefs.optional.filter((d) => isImageFile(d.file) && !EXCLUDE_MANUAL.has(d.key)).map((d) => d.key)
-  );
+  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}>
@@ -191,32 +193,65 @@ export default function AssetUploader({ creativeId, assets, assetDefs, onUpdated
       {error && <p className={styles.error}>{error}</p>}
 
       {/* 文件列表 */}
-      {assets.length > 0 && (
-        <div className={styles.fileList}>
-          <div className={styles.fileListHeader}>
-            <span>
-              已上传文件 ({assets.filter((a) => a.isRequired).length}/{assetDefs.required.length} 必填)
-            </span>
+      <div className={styles.fileList}>
+        <div className={styles.fileListHeader}>
+          <span>素材文件</span>
+          {assets.length > 0 && (
             <button onClick={handleClear} className={styles.clearBtn}>
               清除素材
             </button>
-          </div>
-          {allDefs.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);
-            const canReplace = replaceableKeys.has(def.key);
-            const isUploading = singleUploading === def.key;
+          )}
+        </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 && "(选填)"}
+              </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} ${canReplace ? styles.fileItemClickable : ""}`}
-                onClick={canReplace ? () => handleSingleUploadClick(def.key) : undefined}
-                title={canReplace ? "点击上传替换" : undefined}
-              >
-                {/* 隐藏的文件选择器 */}
-                {canReplace && (
+              return (
+                <div
+                  key={def.key}
+                  className={`${styles.fileItem} ${styles.fileItemClickable}`}
+                  onClick={() => handleSingleUploadClick(def.key)}
+                  title="点击上传替换"
+                >
                   <input
                     type="file"
                     accept={def.accept || ".png,.jpg,.jpeg"}
@@ -224,35 +259,35 @@ export default function AssetUploader({ creativeId, assets, assetDefs, onUpdated
                     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}`}
-                    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 && "(选填)"}
-                  {canReplace && asset && <span className={styles.replaceHint}> 🔄</span>}
-                </span>
-                {asset && (
-                  <span className={styles.fileSize}>
-                    {(asset.fileSize / 1024).toFixed(0)} KB
+                  {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}
+                    {asset && <span className={styles.replaceHint}> 🔄</span>}
                   </span>
-                )}
-              </div>
-            );
-          })}
-        </div>
+                  {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 && (

+ 28 - 9
platform/server/src/routes/assets.ts

@@ -194,21 +194,40 @@ export function assetsRouter(db: Database.Database, storageDir: string): Router
     const creativeId = req.params.id as string;
     const fileKey = req.params.key as string;
 
+    // 1. 先查用户上传的素材
     const asset = db
       .prepare("SELECT file_path, file_name FROM creative_assets WHERE creative_id = ? AND file_key = ?")
       .get(creativeId, fileKey) as any;
 
-    if (!asset) {
-      res.status(404).json({ error: { message: "Asset not found" } });
-      return;
-    }
+    let filePath: string;
+    let fileName: string;
 
-    if (!fs.existsSync(asset.file_path)) {
-      res.status(404).json({ error: { message: "File not found on disk" } });
-      return;
+    if (asset && fs.existsSync(asset.file_path)) {
+      filePath = asset.file_path;
+      fileName = asset.file_name;
+    } else {
+      // 2. 未上传 → 尝试回退到模板默认图
+      const TEMPLATE_IMG_DIR = path.resolve(__dirname, "../../../../templates/coloring/assets/img");
+      const defaultMap: Record<string, string> = {
+        logo: "logo.png",
+        logoTxt: "logo-txt.png",
+        slogon: "slogon.png",
+        coloringPages: "coloring-pages.png",
+      };
+      const defaultFile = defaultMap[fileKey];
+      if (!defaultFile) {
+        res.status(404).json({ error: { message: "Asset not found" } });
+        return;
+      }
+      filePath = path.join(TEMPLATE_IMG_DIR, defaultFile);
+      fileName = defaultFile;
+      if (!fs.existsSync(filePath)) {
+        res.status(404).json({ error: { message: "Default asset not found" } });
+        return;
+      }
     }
 
-    const ext = path.extname(asset.file_name).toLowerCase();
+    const ext = path.extname(fileName).toLowerCase();
     const mimeTypes: Record<string, string> = {
       ".png": "image/png",
       ".jpg": "image/jpeg",
@@ -221,7 +240,7 @@ export function assetsRouter(db: Database.Database, storageDir: string): Router
 
     res.setHeader("Content-Type", contentType);
     res.setHeader("Cache-Control", "public, max-age=3600");
-    fs.createReadStream(asset.file_path).pipe(res);
+    fs.createReadStream(filePath).pipe(res);
   });
 
   // POST /api/v1/creatives/:id/assets/:key — 上传单个素材文件(手动替换)