Selaa lähdekoodia

feat: thumbnail previews for image assets in creative detail

- Backend: added GET /api/v1/creatives/:id/assets/:key to serve asset files
- Frontend: AssetUploader shows 36x36 thumbnails for .png/.jpg/.jpeg/.gif/.webp/.svg files
- Non-image assets keep the existing icon marker (check/cross/optional)
guoziyun 3 viikkoa sitten
vanhempi
sitoutus
eb2f9b3f5d

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
platform/client/dist/assets/index-BRjdK5H3.css


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
platform/client/dist/assets/index-DhG4rUbp.css


Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 1 - 1
platform/client/dist/assets/index-DoTZItqJ.js


+ 2 - 2
platform/client/dist/index.html

@@ -4,8 +4,8 @@
     <meta charset="UTF-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <title>Playable Ads Platform</title>
-    <script type="module" crossorigin src="/ads/assets/index-KE5D-PwR.js"></script>
-    <link rel="stylesheet" crossorigin href="/ads/assets/index-BRjdK5H3.css">
+    <script type="module" crossorigin src="/assets/index-DoTZItqJ.js"></script>
+    <link rel="stylesheet" crossorigin href="/assets/index-DhG4rUbp.css">
   </head>
   <body>
     <div id="root"></div>

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

@@ -137,6 +137,15 @@
   font-size: 13px;
 }
 
+.thumb {
+  width: 36px;
+  height: 36px;
+  object-fit: cover;
+  border-radius: 4px;
+  border: 1px solid var(--color-border);
+  flex-shrink: 0;
+}
+
 .fileOk { color: var(--color-success); }
 .fileMissing { color: var(--color-text-secondary); }
 

+ 22 - 3
platform/client/src/components/AssetUploader.tsx

@@ -3,6 +3,16 @@ import { api } from "../api/client";
 import type { CreativeAsset, AssetDef } from "../types";
 import styles from "./AssetUploader.module.css";
 
+const BASE = `${import.meta.env.BASE_URL}api/v1`.replace(/\/+$/, "");
+
+// 可展示缩略图的图片后缀
+const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg"]);
+
+function isImageFile(fileName: string): boolean {
+  const ext = fileName.substring(fileName.lastIndexOf(".")).toLowerCase();
+  return IMAGE_EXTENSIONS.has(ext);
+}
+
 interface Props {
   creativeId: string;
   assets: CreativeAsset[];
@@ -163,11 +173,20 @@ export default function AssetUploader({ creativeId, assets, assetDefs, onUpdated
           {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);
             return (
               <div key={def.key} className={styles.fileItem}>
-                <span className={asset ? styles.fileOk : styles.fileMissing}>
-                  {asset ? "✅" : isRequired ? "❌" : "⬜"}
-                </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 && "(选填)"}

+ 1 - 1
platform/server/dist/routes/assets.d.ts.map

@@ -1 +1 @@
-{"version":3,"file":"assets.d.ts","sourceRoot":"","sources":["../../src/routes/assets.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAqGtC,wBAAgB,YAAY,CAAC,EAAE,EAAE,QAAQ,CAAC,QAAQ,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CA8F9E"}
+{"version":3,"file":"assets.d.ts","sourceRoot":"","sources":["../../src/routes/assets.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,SAAS,CAAC;AACjC,OAAO,QAAQ,MAAM,gBAAgB,CAAC;AAqGtC,wBAAgB,YAAY,CAAC,EAAE,EAAE,QAAQ,CAAC,QAAQ,EAAE,UAAU,EAAE,MAAM,GAAG,MAAM,CAiI9E"}

+ 29 - 0
platform/server/dist/routes/assets.js

@@ -132,6 +132,35 @@ function assetsRouter(db, storageDir) {
             res.status(500).json({ error: { message: err.message } });
         }
     });
+    // GET /api/v1/creatives/:id/assets/:key — 获取单个素材文件(用于缩略图展示)
+    router.get("/creatives/:id/assets/:key", (req, res) => {
+        const creativeId = req.params.id;
+        const fileKey = req.params.key;
+        const asset = db
+            .prepare("SELECT file_path, file_name FROM creative_assets WHERE creative_id = ? AND file_key = ?")
+            .get(creativeId, fileKey);
+        if (!asset) {
+            res.status(404).json({ error: { message: "Asset not found" } });
+            return;
+        }
+        if (!fs_1.default.existsSync(asset.file_path)) {
+            res.status(404).json({ error: { message: "File not found on disk" } });
+            return;
+        }
+        const ext = path_1.default.extname(asset.file_name).toLowerCase();
+        const mimeTypes = {
+            ".png": "image/png",
+            ".jpg": "image/jpeg",
+            ".jpeg": "image/jpeg",
+            ".gif": "image/gif",
+            ".webp": "image/webp",
+            ".svg": "image/svg+xml",
+        };
+        const contentType = mimeTypes[ext] || "application/octet-stream";
+        res.setHeader("Content-Type", contentType);
+        res.setHeader("Cache-Control", "public, max-age=3600");
+        fs_1.default.createReadStream(asset.file_path).pipe(res);
+    });
     // DELETE /api/v1/creatives/:id/assets — 清除素材
     router.delete("/creatives/:id/assets", (req, res) => {
         const creativeId = req.params.id;

Tiedoston diff-näkymää rajattu, sillä se on liian suuri
+ 0 - 0
platform/server/dist/routes/assets.js.map


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

@@ -176,6 +176,41 @@ export function assetsRouter(db: Database.Database, storageDir: string): Router
     }
   });
 
+  // GET /api/v1/creatives/:id/assets/:key — 获取单个素材文件(用于缩略图展示)
+  router.get("/creatives/:id/assets/:key", (req, res) => {
+    const creativeId = req.params.id as string;
+    const fileKey = req.params.key as string;
+
+    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;
+    }
+
+    if (!fs.existsSync(asset.file_path)) {
+      res.status(404).json({ error: { message: "File not found on disk" } });
+      return;
+    }
+
+    const ext = path.extname(asset.file_name).toLowerCase();
+    const mimeTypes: Record<string, string> = {
+      ".png": "image/png",
+      ".jpg": "image/jpeg",
+      ".jpeg": "image/jpeg",
+      ".gif": "image/gif",
+      ".webp": "image/webp",
+      ".svg": "image/svg+xml",
+    };
+    const contentType = mimeTypes[ext] || "application/octet-stream";
+
+    res.setHeader("Content-Type", contentType);
+    res.setHeader("Cache-Control", "public, max-age=3600");
+    fs.createReadStream(asset.file_path).pipe(res);
+  });
+
   // DELETE /api/v1/creatives/:id/assets — 清除素材
   router.delete("/creatives/:id/assets", (req, res) => {
     const creativeId = req.params.id as string;

Kaikkia tiedostoja ei voida näyttää, sillä liian monta tiedostoa muuttui tässä diffissä