diff --git a/RULES.md b/RULES.md
index 6673a56..d603160 100644
--- a/RULES.md
+++ b/RULES.md
@@ -34,6 +34,7 @@
- VPS 数据持久化在 `/opt/ai-toy-patent-workflow/data`
- VPS 生产环境变量在 `/opt/ai-toy-patent-workflow/deploy/.env.production`,不入库
- 资源索引:运行 `npm run resources:index` 生成 `data/resource-index.json`、`data/resource-index.md` 和 `data/named/` 人类可读软链接;原始资源文件名不能直接改,避免 session JSON / 图片 URL 断链
+- 风格示意图:运行 `npm run styles:previews -- --force` 用 GPT 图片模型生成 `public/style-previews/*.png`;UI 左侧风格卡片直接引用这些小图
## 环境变量
- `OPENAI_API_KEY` — GPT API Key;文本/结构化/图片生成统一走 GPT 最高规格配置
diff --git a/package.json b/package.json
index 1676dba..1fe65f1 100644
--- a/package.json
+++ b/package.json
@@ -10,7 +10,8 @@
"docker:up": "docker compose up -d --build",
"docker:down": "docker compose down",
"docker:logs": "docker compose logs -f web",
- "resources:index": "node scripts/build-resource-index.mjs data"
+ "resources:index": "node scripts/build-resource-index.mjs data",
+ "styles:previews": "node scripts/generate-style-previews.mjs"
},
"dependencies": {
"next": "^15.5.18",
diff --git a/public/style-previews/blueprint.png b/public/style-previews/blueprint.png
new file mode 100644
index 0000000..95c8164
Binary files /dev/null and b/public/style-previews/blueprint.png differ
diff --git a/public/style-previews/cyber.png b/public/style-previews/cyber.png
new file mode 100644
index 0000000..1ec507b
Binary files /dev/null and b/public/style-previews/cyber.png differ
diff --git a/public/style-previews/kawaii.png b/public/style-previews/kawaii.png
new file mode 100644
index 0000000..fd93a3f
Binary files /dev/null and b/public/style-previews/kawaii.png differ
diff --git a/public/style-previews/mecha.png b/public/style-previews/mecha.png
new file mode 100644
index 0000000..fc60a76
Binary files /dev/null and b/public/style-previews/mecha.png differ
diff --git a/public/style-previews/minimal.png b/public/style-previews/minimal.png
new file mode 100644
index 0000000..b8cb7cd
Binary files /dev/null and b/public/style-previews/minimal.png differ
diff --git a/public/style-previews/none.png b/public/style-previews/none.png
new file mode 100644
index 0000000..992f1bd
Binary files /dev/null and b/public/style-previews/none.png differ
diff --git a/public/style-previews/plush.png b/public/style-previews/plush.png
new file mode 100644
index 0000000..52adc24
Binary files /dev/null and b/public/style-previews/plush.png differ
diff --git a/scripts/generate-style-previews.mjs b/scripts/generate-style-previews.mjs
new file mode 100644
index 0000000..f53c663
--- /dev/null
+++ b/scripts/generate-style-previews.mjs
@@ -0,0 +1,108 @@
+#!/usr/bin/env node
+
+import fs from 'node:fs';
+import path from 'node:path';
+
+const root = process.cwd();
+const envPath = path.join(root, '.env.local');
+const outputDir = path.join(root, 'public', 'style-previews');
+
+function loadEnvFile(filePath) {
+ if (!fs.existsSync(filePath)) return;
+ for (const line of fs.readFileSync(filePath, 'utf8').split(/\r?\n/)) {
+ const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)=(.*)\s*$/);
+ if (!match) continue;
+ const [, key, rawValue] = match;
+ if (process.env[key]) continue;
+ process.env[key] = rawValue.replace(/^['"]|['"]$/g, '');
+ }
+}
+
+const styles = [
+ {
+ id: 'none',
+ label: 'neutral toy concept',
+ prompt: 'neutral original toy mascot concept preview, small rounded collectible character, clean studio light, no text, no logo, no watermark, square UI thumbnail',
+ },
+ {
+ id: 'plush',
+ label: 'plush toy',
+ prompt: 'plush toy style preview, soft fuzzy fabric original rounded mascot, stitched details, warm studio light, no text, no logo, no watermark, square UI thumbnail',
+ },
+ {
+ id: 'mecha',
+ label: 'mecha toy',
+ prompt: 'mecha toy style preview, original rounded robot collectible, polished armor panels, tiny mechanical joints, clean dramatic light, no text, no logo, no watermark, square UI thumbnail',
+ },
+ {
+ id: 'kawaii',
+ label: 'kawaii toy',
+ prompt: 'kawaii toy style preview, original cute rounded mascot, soft pastel colors, friendly expression, clean studio background, no text, no logo, no watermark, square UI thumbnail',
+ },
+ {
+ id: 'blueprint',
+ label: 'patent blueprint',
+ prompt: 'patent blueprint style preview, original toy mascot shown as clean white technical line art on deep blue blueprint background, no readable text, no logo, no watermark, square UI thumbnail',
+ },
+ {
+ id: 'cyber',
+ label: 'cyberpunk toy',
+ prompt: 'cyberpunk toy style preview, original rounded collectible mascot, neon rim light, glossy dark materials, futuristic display glow, no text, no logo, no watermark, square UI thumbnail',
+ },
+ {
+ id: 'minimal',
+ label: 'minimal toy',
+ prompt: 'minimal toy style preview, original rounded mascot, simple geometric silhouette, restrained colors, premium clean product render, no text, no logo, no watermark, square UI thumbnail',
+ },
+];
+
+async function imageToBuffer(payload, apiKey) {
+ const first = payload?.data?.[0];
+ if (!first) throw new Error('missing image data');
+ if (first.b64_json) return Buffer.from(first.b64_json, 'base64');
+ if (first.url) {
+ const res = await fetch(first.url, { headers: { Authorization: `Bearer ${apiKey}` } });
+ if (!res.ok) throw new Error(`image url fetch ${res.status}: ${await res.text()}`);
+ return Buffer.from(await res.arrayBuffer());
+ }
+ throw new Error('image payload has no b64_json or url');
+}
+
+async function main() {
+ loadEnvFile(envPath);
+ const apiKey = process.env.OPENAI_API_KEY;
+ if (!apiKey) throw new Error('OPENAI_API_KEY missing');
+
+ const apiBase = process.env.GPT_API_BASE || 'https://api.openai.com/v1';
+ const model = process.env.GPT_IMAGE_MODEL || 'gpt-image-2';
+ fs.mkdirSync(outputDir, { recursive: true });
+
+ for (const style of styles) {
+ const out = path.join(outputDir, `${style.id}.png`);
+ if (fs.existsSync(out) && !process.argv.includes('--force')) {
+ console.log(`skip ${style.id}`);
+ continue;
+ }
+ console.log(`generate ${style.id}`);
+ const res = await fetch(`${apiBase}/images/generations`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${apiKey}`,
+ },
+ body: JSON.stringify({
+ model,
+ prompt: style.prompt,
+ n: 1,
+ size: '1024x1024',
+ }),
+ });
+ if (!res.ok) throw new Error(`GPT image ${style.id} ${res.status}: ${await res.text()}`);
+ fs.writeFileSync(out, await imageToBuffer(await res.json(), apiKey));
+ }
+}
+
+main().catch(error => {
+ console.error(error instanceof Error ? error.message : String(error));
+ process.exit(1);
+});
diff --git a/src/app/api/gallery/[sessionId]/route.ts b/src/app/api/gallery/[sessionId]/route.ts
index 8b2c101..ba0efab 100644
--- a/src/app/api/gallery/[sessionId]/route.ts
+++ b/src/app/api/gallery/[sessionId]/route.ts
@@ -171,13 +171,35 @@ function renderPage(opts: {
.card { position: relative; background: #15161b; border: 1px solid #2d2f36; border-radius: 12px; overflow: visible; }
.card a { position: relative; display: block; border-radius: 12px 12px 0 0; background: #fff; overflow: hidden; }
.thumb { width: 100%; aspect-ratio: var(--ratio, 1 / 1); object-fit: contain; display: block; background: #fff; }
- .zoom { display: none; position: absolute; left: 50%; top: 10px; width: min(620px, 82vw); max-height: 82vh; object-fit: contain; transform: translate(-50%, -6%); z-index: 100; background: #fff; border: 1px solid #3b3e47; border-radius: 14px; box-shadow: 0 24px 90px rgba(0,0,0,.72); pointer-events: none; }
+ .zoom { display: none; position: fixed; left: var(--zoom-left, 20px); top: var(--zoom-top, 20px); width: min(620px, 82vw); max-height: 82vh; object-fit: contain; z-index: 100; background: #fff; border: 1px solid #3b3e47; border-radius: 14px; box-shadow: 0 24px 90px rgba(0,0,0,.72); pointer-events: none; }
.card:hover { z-index: 20; border-color: #565b68; }
.card:hover .zoom { display: block; }
.meta { padding: 10px; display: grid; gap: 5px; font-size: 11px; color: #9ca3af; }
.meta strong { color: #f5f5f5; font-size: 12px; line-height: 1.25; }
.empty { color: #9ca3af; }
+
diff --git a/src/components/HoverImagePreview.tsx b/src/components/HoverImagePreview.tsx
new file mode 100644
index 0000000..f2a2d43
--- /dev/null
+++ b/src/components/HoverImagePreview.tsx
@@ -0,0 +1,75 @@
+'use client';
+
+import { useState } from 'react';
+import type { PointerEvent } from 'react';
+
+type PreviewState = {
+ left: number;
+ top: number;
+ width: number;
+};
+
+function parseRatio(aspectRatio?: string) {
+ if (!aspectRatio || aspectRatio === 'long') return aspectRatio === 'long' ? 1 / 3 : 1;
+ const [w, h] = aspectRatio.split(':').map(Number);
+ return w && h ? w / h : 1;
+}
+
+function nextPreviewState(event: PointerEvent, aspectRatio?: string): PreviewState {
+ const gap = 18;
+ const margin = 12;
+ const ratio = parseRatio(aspectRatio);
+ const width = Math.min(620, Math.max(280, window.innerWidth * 0.42));
+ const height = Math.min(window.innerHeight * 0.82, width / ratio);
+ let left = event.clientX + gap;
+ let top = event.clientY + gap;
+
+ if (left + width > window.innerWidth - margin) {
+ left = event.clientX - width - gap;
+ }
+ if (top + height > window.innerHeight - margin) {
+ top = window.innerHeight - height - margin;
+ }
+ return {
+ left: Math.max(margin, left),
+ top: Math.max(margin, top),
+ width,
+ };
+}
+
+export function HoverImagePreview({
+ src,
+ alt,
+ imageClassName,
+ aspectRatio,
+}: {
+ src: string;
+ alt: string;
+ imageClassName?: string;
+ aspectRatio?: string;
+}) {
+ const [preview, setPreview] = useState(null);
+
+ return (
+ <>
+
{
+ if (event.pointerType === 'touch') return;
+ setPreview(nextPreviewState(event, aspectRatio));
+ }}
+ onPointerLeave={() => setPreview(null)}
+ />
+ {preview && (
+
+

+
+ )}
+ >
+ );
+}
diff --git a/src/components/PackPanel.tsx b/src/components/PackPanel.tsx
index 3efeec6..ce4b6c1 100644
--- a/src/components/PackPanel.tsx
+++ b/src/components/PackPanel.tsx
@@ -3,6 +3,7 @@
import { useRef, useState } from 'react';
import type { AssetPack, AssetTemplate, GenImage, GenSession, PackKind, ToyAsset } from '@/lib/types';
import { PACK_LABELS, PACK_ORDER, PACK_TEMPLATES, TEXT_TEMPLATES, VIDEO_TEMPLATES } from '@/lib/templates';
+import { HoverImagePreview } from './HoverImagePreview';
const PACK_DESCRIPTIONS: Record = {
patent: '六面视图 · 45° 立体图 · 局部放大',
@@ -63,12 +64,12 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
{/* thumbnail */}
{ready ? (
- <>
-

-
-

-
- >
+
) : (
{template.aspectRatio}
diff --git a/src/components/PromptPanel.tsx b/src/components/PromptPanel.tsx
index c3f37d0..6dbc790 100644
--- a/src/components/PromptPanel.tsx
+++ b/src/components/PromptPanel.tsx
@@ -3,13 +3,14 @@
import { useRef, useState } from 'react';
import type { GenSession, ProjectInputMode, UploadedImage } from '@/lib/types';
-const PRESET_STYLES = [
- { id: 'plush', label: '毛绒玩偶' },
- { id: 'mecha', label: '机甲风' },
- { id: 'kawaii', label: '可爱萌系' },
- { id: 'blueprint', label: '专利蓝图' },
- { id: 'cyber', label: '赛博朋克' },
- { id: 'minimal', label: '极简' },
+const STYLE_OPTIONS = [
+ { id: 'none', label: '无', value: '', preview: '/style-previews/none.png' },
+ { id: 'plush', label: '毛绒玩偶', value: '毛绒玩偶', preview: '/style-previews/plush.png' },
+ { id: 'mecha', label: '机甲风', value: '机甲风', preview: '/style-previews/mecha.png' },
+ { id: 'kawaii', label: '可爱萌系', value: '可爱萌系', preview: '/style-previews/kawaii.png' },
+ { id: 'blueprint', label: '专利蓝图', value: '专利蓝图', preview: '/style-previews/blueprint.png' },
+ { id: 'cyber', label: '赛博朋克', value: '赛博朋克', preview: '/style-previews/cyber.png' },
+ { id: 'minimal', label: '极简', value: '极简', preview: '/style-previews/minimal.png' },
];
type UploadProjectFile = {
@@ -173,37 +174,98 @@ export default function PromptPanel({ session, onGenerate, onUploadProject, load
const replicatePreview = filePreview(replicateFile);
const locked = Boolean(session);
const activeTab = session ? tabFromMode(session.inputMode) : tab;
+ const canChooseStyle = !session && (activeTab === 'idea' || activeTab === 'remix');
return (
-
-
-
- Step · 01 · Input
- · 想法 / 二创 / 复刻
-
-
- {[
- ['idea', '想法'],
- ['remix', '二创'],
- ['replicate', '复刻'],
- ].map(([id, label]) => (
-
- ))}
-
-
+
+
+
+
+
+ {session ? (
+
+ ) : activeTab === 'idea' && (
+ <>
- >
- )}
+ >
+ )}
- {!session && activeTab === 'remix' && (
-
+ {!session && activeTab === 'remix' && (
+
-
- )}
+
+ )}
- {!session && activeTab === 'replicate' && (
-
+ {!session && activeTab === 'replicate' && (
+
-
- )}
-
- {!session && (activeTab === 'idea' || activeTab === 'remix') && (
-
-
-
-
- {PRESET_STYLES.map(s => (
-
- ))}
-
-
- )}
-
- {!session &&
- {(activeTab === 'idea' || activeTab === 'remix') ? (
-
-
-
- {[4, 8, 12].map(n => (
-
- ))}
-
- ) :
}
-
-
}
+
+ {!session && (
+
+
+
+ )}
+
+
);
}
diff --git a/src/components/ResultGrid.tsx b/src/components/ResultGrid.tsx
index 1857d89..87fbdeb 100644
--- a/src/components/ResultGrid.tsx
+++ b/src/components/ResultGrid.tsx
@@ -2,6 +2,7 @@
import { useEffect } from 'react';
import type { GenImage } from '@/lib/types';
+import { HoverImagePreview } from './HoverImagePreview';
export type ResultGridProps = {
images: GenImage[];
@@ -56,10 +57,7 @@ export default function ResultGrid({ images, onAction }: ResultGridProps) {
key={img.id}
className={`tile group ${img.status === 'selected' ? 'tile-selected' : ''} ${img.status === 'rejected' ? 'tile-rejected' : ''}`}
>
-

-
-

-
+
{i + 1}
{img.status === 'selected' && (