feat: add visual style picker and contextual previews
1
RULES.md
@@ -34,6 +34,7 @@
|
|||||||
- VPS 数据持久化在 `/opt/ai-toy-patent-workflow/data`
|
- VPS 数据持久化在 `/opt/ai-toy-patent-workflow/data`
|
||||||
- VPS 生产环境变量在 `/opt/ai-toy-patent-workflow/deploy/.env.production`,不入库
|
- 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 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 最高规格配置
|
- `OPENAI_API_KEY` — GPT API Key;文本/结构化/图片生成统一走 GPT 最高规格配置
|
||||||
|
|||||||
@@ -10,7 +10,8 @@
|
|||||||
"docker:up": "docker compose up -d --build",
|
"docker:up": "docker compose up -d --build",
|
||||||
"docker:down": "docker compose down",
|
"docker:down": "docker compose down",
|
||||||
"docker:logs": "docker compose logs -f web",
|
"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": {
|
"dependencies": {
|
||||||
"next": "^15.5.18",
|
"next": "^15.5.18",
|
||||||
|
|||||||
BIN
public/style-previews/blueprint.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
public/style-previews/cyber.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
public/style-previews/kawaii.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/style-previews/mecha.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
public/style-previews/minimal.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
public/style-previews/none.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
public/style-previews/plush.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
108
scripts/generate-style-previews.mjs
Normal file
@@ -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);
|
||||||
|
});
|
||||||
@@ -171,13 +171,35 @@ function renderPage(opts: {
|
|||||||
.card { position: relative; background: #15161b; border: 1px solid #2d2f36; border-radius: 12px; overflow: visible; }
|
.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; }
|
.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; }
|
.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 { z-index: 20; border-color: #565b68; }
|
||||||
.card:hover .zoom { display: block; }
|
.card:hover .zoom { display: block; }
|
||||||
.meta { padding: 10px; display: grid; gap: 5px; font-size: 11px; color: #9ca3af; }
|
.meta { padding: 10px; display: grid; gap: 5px; font-size: 11px; color: #9ca3af; }
|
||||||
.meta strong { color: #f5f5f5; font-size: 12px; line-height: 1.25; }
|
.meta strong { color: #f5f5f5; font-size: 12px; line-height: 1.25; }
|
||||||
.empty { color: #9ca3af; }
|
.empty { color: #9ca3af; }
|
||||||
</style>
|
</style>
|
||||||
|
<script>
|
||||||
|
window.addEventListener('pointermove', function (event) {
|
||||||
|
if (event.pointerType === 'touch') return;
|
||||||
|
var card = event.target && event.target.closest ? event.target.closest('.card') : null;
|
||||||
|
if (!card) return;
|
||||||
|
var zoom = card.querySelector('.zoom');
|
||||||
|
if (!zoom) return;
|
||||||
|
var gap = 18;
|
||||||
|
var margin = 12;
|
||||||
|
var thumb = card.querySelector('.thumb');
|
||||||
|
var rect = thumb ? thumb.getBoundingClientRect() : { width: 1, height: 1 };
|
||||||
|
var ratio = rect.width && rect.height ? rect.width / rect.height : 1;
|
||||||
|
var width = Math.min(620, Math.max(280, window.innerWidth * 0.42));
|
||||||
|
var height = Math.min(window.innerHeight * 0.82, width / ratio);
|
||||||
|
var left = event.clientX + gap;
|
||||||
|
var 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;
|
||||||
|
zoom.style.setProperty('--zoom-left', Math.max(margin, left) + 'px');
|
||||||
|
zoom.style.setProperty('--zoom-top', Math.max(margin, top) + 'px');
|
||||||
|
}, { passive: true });
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header>
|
<header>
|
||||||
|
|||||||
75
src/components/HoverImagePreview.tsx
Normal file
@@ -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<HTMLElement>, 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<PreviewState | null>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={alt}
|
||||||
|
className={imageClassName}
|
||||||
|
onPointerMove={event => {
|
||||||
|
if (event.pointerType === 'touch') return;
|
||||||
|
setPreview(nextPreviewState(event, aspectRatio));
|
||||||
|
}}
|
||||||
|
onPointerLeave={() => setPreview(null)}
|
||||||
|
/>
|
||||||
|
{preview && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none fixed z-[90] rounded-[8px] bg-white p-2 shadow-2xl ring-1 ring-white/20"
|
||||||
|
style={{ left: preview.left, top: preview.top, width: preview.width }}
|
||||||
|
>
|
||||||
|
<img src={src} alt="" className="max-h-[82vh] w-full object-contain" style={{ aspectRatio: parseRatio(aspectRatio) }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import type { AssetPack, AssetTemplate, GenImage, GenSession, PackKind, ToyAsset } from '@/lib/types';
|
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 { PACK_LABELS, PACK_ORDER, PACK_TEMPLATES, TEXT_TEMPLATES, VIDEO_TEMPLATES } from '@/lib/templates';
|
||||||
|
import { HoverImagePreview } from './HoverImagePreview';
|
||||||
|
|
||||||
const PACK_DESCRIPTIONS: Record<PackKind, string> = {
|
const PACK_DESCRIPTIONS: Record<PackKind, string> = {
|
||||||
patent: '六面视图 · 45° 立体图 · 局部放大',
|
patent: '六面视图 · 45° 立体图 · 局部放大',
|
||||||
@@ -63,12 +64,12 @@ function AssetRow({ template, asset, accent, onRegenerate }: {
|
|||||||
{/* thumbnail */}
|
{/* thumbnail */}
|
||||||
<div className="group/thumb relative w-[72px] h-[72px] rounded-[8px] overflow-visible bg-white/[0.04] ring-1 ring-white/[0.07] flex items-center justify-center">
|
<div className="group/thumb relative w-[72px] h-[72px] rounded-[8px] overflow-visible bg-white/[0.04] ring-1 ring-white/[0.07] flex items-center justify-center">
|
||||||
{ready ? (
|
{ready ? (
|
||||||
<>
|
<HoverImagePreview
|
||||||
<img src={asset!.url} alt={template.title} className="max-w-full max-h-full object-contain rounded-lg bg-white" />
|
src={asset!.url}
|
||||||
<div className="pointer-events-none fixed left-1/2 top-1/2 z-[80] hidden -translate-x-1/2 -translate-y-1/2 rounded-[8px] bg-white p-2 shadow-2xl ring-1 ring-white/20 group-hover/thumb:block" style={{ width: 'min(640px, 86vw)' }}>
|
alt={template.title}
|
||||||
<img src={asset!.url} alt="" className="max-h-[82vh] w-full object-contain" style={{ aspectRatio: aspectCss(asset!.aspectRatio) }} />
|
aspectRatio={asset!.aspectRatio}
|
||||||
</div>
|
imageClassName="max-w-full max-h-full object-contain rounded-lg bg-white"
|
||||||
</>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-1 rounded-lg bg-black/25 px-2 py-1" style={{ aspectRatio: aspectCss(template.aspectRatio) }}>
|
<div className="flex flex-col items-center gap-1 rounded-lg bg-black/25 px-2 py-1" style={{ aspectRatio: aspectCss(template.aspectRatio) }}>
|
||||||
<span className="text-[9px] text-white/30 font-mono">{template.aspectRatio}</span>
|
<span className="text-[9px] text-white/30 font-mono">{template.aspectRatio}</span>
|
||||||
|
|||||||
@@ -3,13 +3,14 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import type { GenSession, ProjectInputMode, UploadedImage } from '@/lib/types';
|
import type { GenSession, ProjectInputMode, UploadedImage } from '@/lib/types';
|
||||||
|
|
||||||
const PRESET_STYLES = [
|
const STYLE_OPTIONS = [
|
||||||
{ id: 'plush', label: '毛绒玩偶' },
|
{ id: 'none', label: '无', value: '', preview: '/style-previews/none.png' },
|
||||||
{ id: 'mecha', label: '机甲风' },
|
{ id: 'plush', label: '毛绒玩偶', value: '毛绒玩偶', preview: '/style-previews/plush.png' },
|
||||||
{ id: 'kawaii', label: '可爱萌系' },
|
{ id: 'mecha', label: '机甲风', value: '机甲风', preview: '/style-previews/mecha.png' },
|
||||||
{ id: 'blueprint', label: '专利蓝图' },
|
{ id: 'kawaii', label: '可爱萌系', value: '可爱萌系', preview: '/style-previews/kawaii.png' },
|
||||||
{ id: 'cyber', label: '赛博朋克' },
|
{ id: 'blueprint', label: '专利蓝图', value: '专利蓝图', preview: '/style-previews/blueprint.png' },
|
||||||
{ id: 'minimal', label: '极简' },
|
{ id: 'cyber', label: '赛博朋克', value: '赛博朋克', preview: '/style-previews/cyber.png' },
|
||||||
|
{ id: 'minimal', label: '极简', value: '极简', preview: '/style-previews/minimal.png' },
|
||||||
];
|
];
|
||||||
|
|
||||||
type UploadProjectFile = {
|
type UploadProjectFile = {
|
||||||
@@ -173,37 +174,98 @@ export default function PromptPanel({ session, onGenerate, onUploadProject, load
|
|||||||
const replicatePreview = filePreview(replicateFile);
|
const replicatePreview = filePreview(replicateFile);
|
||||||
const locked = Boolean(session);
|
const locked = Boolean(session);
|
||||||
const activeTab = session ? tabFromMode(session.inputMode) : tab;
|
const activeTab = session ? tabFromMode(session.inputMode) : tab;
|
||||||
|
const canChooseStyle = !session && (activeTab === 'idea' || activeTab === 'remix');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="card p-7 space-y-6">
|
<section className="card overflow-hidden">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="grid lg:grid-cols-[268px_minmax(0,1fr)]">
|
||||||
<div className="flex items-center gap-2">
|
<aside className="border-b border-[#8cb478]/12 bg-black/16 p-5 lg:border-b-0 lg:border-r">
|
||||||
<span className="section-eyebrow">Step · 01 · Input</span>
|
<div className="space-y-1.5">
|
||||||
<span className="text-[10px] text-white/30">· 想法 / 二创 / 复刻</span>
|
<span className="section-eyebrow">Step · 01 · Input</span>
|
||||||
</div>
|
<div className="text-[10px] text-white/34">入口、风格和数量都在这里选</div>
|
||||||
<div className="seg">
|
</div>
|
||||||
{[
|
|
||||||
['idea', '想法'],
|
|
||||||
['remix', '二创'],
|
|
||||||
['replicate', '复刻'],
|
|
||||||
].map(([id, label]) => (
|
|
||||||
<button
|
|
||||||
key={id}
|
|
||||||
onClick={() => { if (!locked) setTab(id as Tab); }}
|
|
||||||
disabled={locked}
|
|
||||||
title={locked ? '本会话入口已锁定;从左侧新建会话后可重新选择' : undefined}
|
|
||||||
className={`seg-item ${activeTab === id ? 'seg-item-active' : ''} ${locked ? 'cursor-not-allowed opacity-70' : ''}`}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{session ? (
|
<div className="mt-5 space-y-2">
|
||||||
<LockedSessionInput session={session} />
|
<div className="text-[10px] uppercase tracking-[0.14em] text-white/38">模式</div>
|
||||||
) : activeTab === 'idea' && (
|
<div className="grid gap-2">
|
||||||
<>
|
{[
|
||||||
|
['idea', '想法', 'Prompt 批量出意向'],
|
||||||
|
['remix', '二创', '上传图做风格变体'],
|
||||||
|
['replicate', '复刻', '上传主体直接锁定'],
|
||||||
|
].map(([id, label, desc]) => (
|
||||||
|
<button
|
||||||
|
key={id}
|
||||||
|
onClick={() => { if (!locked) setTab(id as Tab); }}
|
||||||
|
disabled={locked}
|
||||||
|
title={locked ? '本会话入口已锁定;从左侧新建会话后可重新选择' : undefined}
|
||||||
|
className={`rounded-[8px] border p-3 text-left transition-all ${
|
||||||
|
activeTab === id
|
||||||
|
? 'border-[#e6f578]/55 bg-[#e6f578]/14 text-white shadow-[0_12px_38px_-22px_rgba(230,245,120,0.65)]'
|
||||||
|
: 'border-[#8cb478]/14 bg-black/20 text-white/58 hover:border-[#e6f578]/28 hover:text-white/82'
|
||||||
|
} ${locked ? 'cursor-not-allowed opacity-70' : ''}`}
|
||||||
|
>
|
||||||
|
<span className="block text-sm font-semibold">{label}</span>
|
||||||
|
<span className="mt-1 block text-[10px] leading-relaxed text-white/38">{desc}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canChooseStyle && (
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.14em] text-white/38">风格示意</div>
|
||||||
|
<div className="text-[10px] text-[#e6f578]/55">GPT 预览</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{STYLE_OPTIONS.map(option => {
|
||||||
|
const active = style === option.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
onClick={() => setStyle(option.value)}
|
||||||
|
className={`group overflow-hidden rounded-[8px] border text-left transition-all ${
|
||||||
|
active
|
||||||
|
? 'border-[#e6f578]/70 bg-[#e6f578]/14 shadow-[0_14px_34px_-22px_rgba(230,245,120,0.9)]'
|
||||||
|
: 'border-[#8cb478]/16 bg-black/24 hover:border-[#e6f578]/38'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<img src={option.preview} alt={option.label} className="h-16 w-full bg-[#101a0d] object-cover transition-transform duration-300 group-hover:scale-[1.04]" />
|
||||||
|
<span className={`block px-2 py-1.5 text-[11px] font-medium ${active ? 'text-[#f2f6a8]' : 'text-white/72'}`}>
|
||||||
|
{option.label}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canChooseStyle && (
|
||||||
|
<div className="mt-6 space-y-2">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.14em] text-white/38">数量</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{[4, 8, 12].map(n => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
onClick={() => setCount(n)}
|
||||||
|
className={`rounded-[8px] border px-2 py-2 text-xs transition-all ${
|
||||||
|
count === n
|
||||||
|
? 'border-[#e6f578]/70 bg-[#e6f578] text-[#081006]'
|
||||||
|
: 'border-[#8cb478]/16 bg-black/24 text-white/55 hover:border-[#e6f578]/38 hover:text-white'
|
||||||
|
}`}
|
||||||
|
>{n} 张</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div className="space-y-6 p-7">
|
||||||
|
{session ? (
|
||||||
|
<LockedSessionInput session={session} />
|
||||||
|
) : activeTab === 'idea' && (
|
||||||
|
<>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[11px] font-medium text-white/55 uppercase tracking-[0.14em] mb-2.5">
|
<label className="block text-[11px] font-medium text-white/55 uppercase tracking-[0.14em] mb-2.5">
|
||||||
Prompt
|
Prompt
|
||||||
@@ -251,11 +313,11 @@ export default function PromptPanel({ session, onGenerate, onUploadProject, load
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!session && activeTab === 'remix' && (
|
{!session && activeTab === 'remix' && (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[11px] font-medium text-white/55 uppercase tracking-[0.14em] mb-2.5">
|
<label className="block text-[11px] font-medium text-white/55 uppercase tracking-[0.14em] mb-2.5">
|
||||||
参考图 <span className="text-white/35 normal-case tracking-normal">· 1-4 张</span>
|
参考图 <span className="text-white/35 normal-case tracking-normal">· 1-4 张</span>
|
||||||
@@ -300,11 +362,11 @@ export default function PromptPanel({ session, onGenerate, onUploadProject, load
|
|||||||
className="field text-[15px] leading-relaxed"
|
className="field text-[15px] leading-relaxed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!session && activeTab === 'replicate' && (
|
{!session && activeTab === 'replicate' && (
|
||||||
<div className="grid md:grid-cols-[220px_1fr] gap-5 items-start">
|
<div className="grid md:grid-cols-[220px_1fr] gap-5 items-start">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-[11px] font-medium text-white/55 uppercase tracking-[0.14em] mb-2.5">
|
<label className="block text-[11px] font-medium text-white/55 uppercase tracking-[0.14em] mb-2.5">
|
||||||
主体图
|
主体图
|
||||||
@@ -344,74 +406,41 @@ export default function PromptPanel({ session, onGenerate, onUploadProject, load
|
|||||||
上传图必须为你拥有或有合法授权使用的素材。请勿上传迪士尼、三丽鸥、泡泡玛特等已注册 IP 图像;用于专利申请前需自行确认不与他人在先权利冲突。
|
上传图必须为你拥有或有合法授权使用的素材。请勿上传迪士尼、三丽鸥、泡泡玛特等已注册 IP 图像;用于专利申请前需自行确认不与他人在先权利冲突。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!session && (activeTab === 'idea' || activeTab === 'remix') && (
|
|
||||||
<div>
|
|
||||||
<label className="block text-[11px] font-medium text-white/55 uppercase tracking-[0.14em] mb-2.5">
|
|
||||||
风格
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
<button
|
|
||||||
onClick={() => setStyle('')}
|
|
||||||
className={style === '' ? 'btn btn-primary text-xs px-3 py-1.5' : 'btn btn-outline text-xs px-3 py-1.5'}
|
|
||||||
>无</button>
|
|
||||||
{PRESET_STYLES.map(s => (
|
|
||||||
<button
|
|
||||||
key={s.id}
|
|
||||||
onClick={() => setStyle(s.label)}
|
|
||||||
className={style === s.label ? 'btn btn-primary text-xs px-3 py-1.5' : 'btn btn-outline text-xs px-3 py-1.5'}
|
|
||||||
>{s.label}</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!session && <div className="flex items-end justify-between gap-4 pt-2">
|
|
||||||
{(activeTab === 'idea' || activeTab === 'remix') ? (
|
|
||||||
<div>
|
|
||||||
<label className="block text-[11px] font-medium text-white/55 uppercase tracking-[0.14em] mb-2.5">
|
|
||||||
数量
|
|
||||||
</label>
|
|
||||||
<div className="seg">
|
|
||||||
{[4, 8, 12].map(n => (
|
|
||||||
<button
|
|
||||||
key={n}
|
|
||||||
onClick={() => setCount(n)}
|
|
||||||
className={`seg-item ${count === n ? 'seg-item-active' : ''}`}
|
|
||||||
>{n} 张</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
) : <div />}
|
|
||||||
<button
|
|
||||||
onClick={activeTab === 'idea' ? submitIdea : activeTab === 'remix' ? submitRemix : submitReplicate}
|
|
||||||
disabled={
|
|
||||||
busy
|
|
||||||
|| (activeTab === 'idea' && !prompt.trim())
|
|
||||||
|| (activeTab === 'remix' && remixFiles.length === 0)
|
|
||||||
|| (activeTab === 'replicate' && !replicateFile)
|
|
||||||
}
|
|
||||||
className="btn btn-primary px-5 py-2.5 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
{busy ? (
|
|
||||||
<>
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" className="animate-spin" fill="none" stroke="currentColor" strokeWidth="2">
|
|
||||||
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
|
|
||||||
</svg>
|
|
||||||
处理中
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{activeTab === 'idea' ? '批量生成' : activeTab === 'remix' ? '生成变体' : '复刻并锁定'}
|
|
||||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
|
||||||
<path d="M5 12h14M13 5l7 7-7 7" strokeLinecap="round" strokeLinejoin="round" />
|
|
||||||
</svg>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</button>
|
|
||||||
</div>}
|
{!session && (
|
||||||
|
<div className="flex justify-end gap-4 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={activeTab === 'idea' ? submitIdea : activeTab === 'remix' ? submitRemix : submitReplicate}
|
||||||
|
disabled={
|
||||||
|
busy
|
||||||
|
|| (activeTab === 'idea' && !prompt.trim())
|
||||||
|
|| (activeTab === 'remix' && remixFiles.length === 0)
|
||||||
|
|| (activeTab === 'replicate' && !replicateFile)
|
||||||
|
}
|
||||||
|
className="btn btn-primary px-5 py-2.5 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{busy ? (
|
||||||
|
<>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" className="animate-spin" fill="none" stroke="currentColor" strokeWidth="2">
|
||||||
|
<path d="M12 2a10 10 0 0 1 10 10" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
处理中
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{activeTab === 'idea' ? '批量生成' : activeTab === 'remix' ? '生成变体' : '复刻并锁定'}
|
||||||
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||||
|
<path d="M5 12h14M13 5l7 7-7 7" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import type { GenImage } from '@/lib/types';
|
import type { GenImage } from '@/lib/types';
|
||||||
|
import { HoverImagePreview } from './HoverImagePreview';
|
||||||
|
|
||||||
export type ResultGridProps = {
|
export type ResultGridProps = {
|
||||||
images: GenImage[];
|
images: GenImage[];
|
||||||
@@ -56,10 +57,7 @@ export default function ResultGrid({ images, onAction }: ResultGridProps) {
|
|||||||
key={img.id}
|
key={img.id}
|
||||||
className={`tile group ${img.status === 'selected' ? 'tile-selected' : ''} ${img.status === 'rejected' ? 'tile-rejected' : ''}`}
|
className={`tile group ${img.status === 'selected' ? 'tile-selected' : ''} ${img.status === 'rejected' ? 'tile-rejected' : ''}`}
|
||||||
>
|
>
|
||||||
<img src={img.url} alt={`gen ${i + 1}`} className="w-full h-full object-contain bg-white" />
|
<HoverImagePreview src={img.url} alt={`gen ${i + 1}`} imageClassName="w-full h-full object-contain bg-white" />
|
||||||
<div className="pointer-events-none fixed left-1/2 top-1/2 z-[80] hidden -translate-x-1/2 -translate-y-1/2 rounded-[8px] bg-white p-2 shadow-2xl ring-1 ring-white/20 group-hover:block" style={{ width: 'min(640px, 86vw)' }}>
|
|
||||||
<img src={img.url} alt="" className="max-h-[82vh] w-full object-contain" />
|
|
||||||
</div>
|
|
||||||
<div className="tile-keynum">{i + 1}</div>
|
<div className="tile-keynum">{i + 1}</div>
|
||||||
|
|
||||||
{img.status === 'selected' && (
|
{img.status === 'selected' && (
|
||||||
|
|||||||