feat: add active pack image downloads
This commit is contained in:
144
src/app/api/packs/download/route.ts
Normal file
144
src/app/api/packs/download/route.ts
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { PACK_ORDER } from '@/lib/templates';
|
||||||
|
import { loadSession, readImageUrl } from '@/lib/storage';
|
||||||
|
import type { PackKind, ToyAsset } from '@/lib/types';
|
||||||
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
|
const CRC_TABLE = new Uint32Array(256).map((_, index) => {
|
||||||
|
let value = index;
|
||||||
|
for (let bit = 0; bit < 8; bit += 1) {
|
||||||
|
value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
|
||||||
|
}
|
||||||
|
return value >>> 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
function crc32(buffer: Buffer) {
|
||||||
|
let crc = 0xffffffff;
|
||||||
|
for (const byte of buffer) {
|
||||||
|
crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
|
||||||
|
}
|
||||||
|
return (crc ^ 0xffffffff) >>> 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeName(input: string) {
|
||||||
|
return input
|
||||||
|
.trim()
|
||||||
|
.replace(/[\\/:*?"<>|]+/g, '-')
|
||||||
|
.replace(/\s+/g, '_')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 80) || 'asset';
|
||||||
|
}
|
||||||
|
|
||||||
|
function extFromAsset(asset: ToyAsset, sourceFilename: string, mimeType: string) {
|
||||||
|
const fromName = sourceFilename.match(/\.([a-z0-9]+)$/i)?.[1];
|
||||||
|
if (fromName) return fromName.toLowerCase() === 'jpeg' ? 'jpg' : fromName.toLowerCase();
|
||||||
|
if (mimeType.includes('jpeg')) return 'jpg';
|
||||||
|
if (mimeType.includes('webp')) return 'webp';
|
||||||
|
if (mimeType.includes('svg')) return 'svg';
|
||||||
|
return asset.url.match(/\.([a-z0-9]+)(?:\?|$)/i)?.[1] ?? 'png';
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeZip(files: Array<{ name: string; data: Buffer }>) {
|
||||||
|
const localParts: Buffer[] = [];
|
||||||
|
const centralParts: Buffer[] = [];
|
||||||
|
let offset = 0;
|
||||||
|
const dosTime = 0;
|
||||||
|
const dosDate = 33;
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const name = Buffer.from(file.name, 'utf8');
|
||||||
|
const data = file.data;
|
||||||
|
const crc = crc32(data);
|
||||||
|
|
||||||
|
const local = Buffer.alloc(30);
|
||||||
|
local.writeUInt32LE(0x04034b50, 0);
|
||||||
|
local.writeUInt16LE(20, 4);
|
||||||
|
local.writeUInt16LE(0x0800, 6);
|
||||||
|
local.writeUInt16LE(0, 8);
|
||||||
|
local.writeUInt16LE(dosTime, 10);
|
||||||
|
local.writeUInt16LE(dosDate, 12);
|
||||||
|
local.writeUInt32LE(crc, 14);
|
||||||
|
local.writeUInt32LE(data.length, 18);
|
||||||
|
local.writeUInt32LE(data.length, 22);
|
||||||
|
local.writeUInt16LE(name.length, 26);
|
||||||
|
local.writeUInt16LE(0, 28);
|
||||||
|
localParts.push(local, name, data);
|
||||||
|
|
||||||
|
const central = Buffer.alloc(46);
|
||||||
|
central.writeUInt32LE(0x02014b50, 0);
|
||||||
|
central.writeUInt16LE(20, 4);
|
||||||
|
central.writeUInt16LE(20, 6);
|
||||||
|
central.writeUInt16LE(0x0800, 8);
|
||||||
|
central.writeUInt16LE(0, 10);
|
||||||
|
central.writeUInt16LE(dosTime, 12);
|
||||||
|
central.writeUInt16LE(dosDate, 14);
|
||||||
|
central.writeUInt32LE(crc, 16);
|
||||||
|
central.writeUInt32LE(data.length, 20);
|
||||||
|
central.writeUInt32LE(data.length, 24);
|
||||||
|
central.writeUInt16LE(name.length, 28);
|
||||||
|
central.writeUInt16LE(0, 30);
|
||||||
|
central.writeUInt16LE(0, 32);
|
||||||
|
central.writeUInt16LE(0, 34);
|
||||||
|
central.writeUInt16LE(0, 36);
|
||||||
|
central.writeUInt32LE(0, 38);
|
||||||
|
central.writeUInt32LE(offset, 42);
|
||||||
|
centralParts.push(central, name);
|
||||||
|
|
||||||
|
offset += local.length + name.length + data.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
const centralSize = centralParts.reduce((sum, part) => sum + part.length, 0);
|
||||||
|
const end = Buffer.alloc(22);
|
||||||
|
end.writeUInt32LE(0x06054b50, 0);
|
||||||
|
end.writeUInt16LE(0, 4);
|
||||||
|
end.writeUInt16LE(0, 6);
|
||||||
|
end.writeUInt16LE(files.length, 8);
|
||||||
|
end.writeUInt16LE(files.length, 10);
|
||||||
|
end.writeUInt32LE(centralSize, 12);
|
||||||
|
end.writeUInt32LE(offset, 16);
|
||||||
|
end.writeUInt16LE(0, 20);
|
||||||
|
|
||||||
|
return Buffer.concat([...localParts, ...centralParts, end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const sessionId = url.searchParams.get('sessionId')?.trim();
|
||||||
|
const kind = url.searchParams.get('kind')?.trim() as PackKind | null;
|
||||||
|
|
||||||
|
if (!sessionId || !kind || !PACK_ORDER.includes(kind)) {
|
||||||
|
return NextResponse.json({ error: 'bad request' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await loadSession(sessionId);
|
||||||
|
if (!session) return NextResponse.json({ error: 'session not found' }, { status: 404 });
|
||||||
|
|
||||||
|
const selectedImage = session.images.find(image => image.status === 'selected') ?? session.images[0];
|
||||||
|
const pack = session.packs?.find(item => item.kind === kind && (!selectedImage || item.sourceImageId === selectedImage.id))
|
||||||
|
?? session.packs?.find(item => item.kind === kind);
|
||||||
|
|
||||||
|
if (!pack?.assets.length) {
|
||||||
|
return NextResponse.json({ error: 'pack has no images' }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = await Promise.all(pack.assets.map(async (asset, index) => {
|
||||||
|
const image = await readImageUrl(asset.url);
|
||||||
|
const ext = extFromAsset(asset, image.filename, image.type);
|
||||||
|
const fileName = `${String(index + 1).padStart(2, '0')}_${safeName(asset.templateId)}.${ext}`;
|
||||||
|
return { name: fileName, data: image.buf };
|
||||||
|
}));
|
||||||
|
|
||||||
|
const zip = makeZip(files);
|
||||||
|
const filename = `${safeName(session.characterSpec?.name ?? session.prompt ?? session.id)}_${kind}_${pack.assets.length}张.zip`;
|
||||||
|
|
||||||
|
return new Response(zip, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/zip',
|
||||||
|
'Content-Length': String(zip.length),
|
||||||
|
'Content-Disposition': `attachment; filename*=UTF-8''${encodeURIComponent(filename)}`,
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -961,6 +961,32 @@ input, textarea {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gallery-download-button {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 38px;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(255, 255, 255, 0.07);
|
||||||
|
color: rgba(255, 255, 255, 0.72);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10);
|
||||||
|
transition: background 160ms ease, color 160ms ease, box-shadow 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gallery-download-button:hover,
|
||||||
|
.gallery-download-button:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
background: rgba(230, 245, 120, 0.14);
|
||||||
|
color: #e6f578;
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(230, 245, 120, 0.32);
|
||||||
|
}
|
||||||
|
|
||||||
.gallery-rail-thumb {
|
.gallery-rail-thumb {
|
||||||
box-shadow: 0 12px 32px -22px rgba(230, 245, 120, 0.65);
|
box-shadow: 0 12px 32px -22px rgba(230, 245, 120, 0.65);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import type {
|
|||||||
LockCharacterResponse,
|
LockCharacterResponse,
|
||||||
PackKind,
|
PackKind,
|
||||||
ProjectFromUploadResponse,
|
ProjectFromUploadResponse,
|
||||||
RegenerateAssetResponse,
|
|
||||||
UploadImageResponse,
|
UploadImageResponse,
|
||||||
UploadedImageRole,
|
UploadedImageRole,
|
||||||
VideoGenerationResponse,
|
VideoGenerationResponse,
|
||||||
@@ -138,11 +137,7 @@ function ProjectPackOverview({
|
|||||||
const progress = Math.round((count / total) * 100);
|
const progress = Math.round((count / total) * 100);
|
||||||
|
|
||||||
function handleGenerate() {
|
function handleGenerate() {
|
||||||
if (locked || running) return;
|
if (locked || running || pack) return;
|
||||||
if (pack) {
|
|
||||||
const ok = window.confirm(`${PACK_LABELS[kind]} 已有 ${count} 张图,重新生成会再次调用图片模型并产生费用。确认继续?`);
|
|
||||||
if (!ok) return;
|
|
||||||
}
|
|
||||||
onGeneratePack(sourceImage, kind);
|
onGeneratePack(sourceImage, kind);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,14 +160,16 @@ function ProjectPackOverview({
|
|||||||
<span className={`project-pack-status ${complete ? 'project-pack-status--done' : locked ? 'project-pack-status--locked' : 'project-pack-status--ready'}`}>
|
<span className={`project-pack-status ${complete ? 'project-pack-status--done' : locked ? 'project-pack-status--locked' : 'project-pack-status--ready'}`}>
|
||||||
{complete ? '完成' : locked ? '锁定' : '可生成'}
|
{complete ? '完成' : locked ? '锁定' : '可生成'}
|
||||||
</span>
|
</span>
|
||||||
<button
|
{!pack && (
|
||||||
onClick={handleGenerate}
|
<button
|
||||||
disabled={running || locked}
|
onClick={handleGenerate}
|
||||||
className="project-pack-action"
|
disabled={running || locked}
|
||||||
title={locked ? '先完成前置步骤' : pack ? '重新生成该包' : '生成该包'}
|
className="project-pack-action"
|
||||||
>
|
title={locked ? '先完成前置步骤' : '生成该包'}
|
||||||
{running ? '...' : pack ? '重做' : '生成'}
|
>
|
||||||
</button>
|
{running ? '...' : '生成'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="project-pack-jump"
|
className="project-pack-jump"
|
||||||
@@ -500,22 +497,6 @@ export default function Home() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRegenerateAsset(assetId: string, userRefinement?: string) {
|
|
||||||
if (!current) return;
|
|
||||||
const r = await fetch(`/api/assets/${encodeURIComponent(assetId)}/regenerate`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ sessionId: current.id, userRefinement, confirmCost: true }),
|
|
||||||
});
|
|
||||||
if (!r.ok) {
|
|
||||||
alert('单张重做失败:' + (await r.text()));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const d: RegenerateAssetResponse = await r.json();
|
|
||||||
setProvider(d.provider);
|
|
||||||
await reloadCurrent(current.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveVideoAnchor(image: GenImage) {
|
function resolveVideoAnchor(image: GenImage) {
|
||||||
const packs = current?.packs ?? [];
|
const packs = current?.packs ?? [];
|
||||||
const mktFront = packs.find(pack => pack.kind === 'marketing')?.assets.find(asset => asset.templateId === 'mkt_white_front');
|
const mktFront = packs.find(pack => pack.kind === 'marketing')?.assets.find(asset => asset.templateId === 'mkt_white_front');
|
||||||
@@ -649,7 +630,6 @@ export default function Home() {
|
|||||||
activeNav={activeAssetPanel}
|
activeNav={activeAssetPanel}
|
||||||
onActiveNavChange={setActiveAssetPanel}
|
onActiveNavChange={setActiveAssetPanel}
|
||||||
videoLoading={videoLoading}
|
videoLoading={videoLoading}
|
||||||
onRegenerateAsset={handleRegenerateAsset}
|
|
||||||
onGenerateVideo={handleGenerateVideo}
|
onGenerateVideo={handleGenerateVideo}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -93,14 +93,11 @@ function AssetTile({ template, asset, onOpen }: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function AssetDetailDrawer({ detail, onClose, onRegenerate }: {
|
function AssetDetailDrawer({ detail, onClose }: {
|
||||||
detail: AssetDetail | null;
|
detail: AssetDetail | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onRegenerate: (assetId: string, userRefinement?: string) => Promise<void>;
|
|
||||||
}) {
|
}) {
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [refinement, setRefinement] = useState('');
|
|
||||||
const [regenerating, setRegenerating] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -108,7 +105,6 @@ function AssetDetailDrawer({ detail, onClose, onRegenerate }: {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
setRefinement('');
|
|
||||||
function handleKey(event: KeyboardEvent) {
|
function handleKey(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') onClose();
|
if (event.key === 'Escape') onClose();
|
||||||
}
|
}
|
||||||
@@ -116,21 +112,6 @@ function AssetDetailDrawer({ detail, onClose, onRegenerate }: {
|
|||||||
return () => window.removeEventListener('keydown', handleKey);
|
return () => window.removeEventListener('keydown', handleKey);
|
||||||
}, [detail, onClose]);
|
}, [detail, onClose]);
|
||||||
|
|
||||||
async function handleRedo() {
|
|
||||||
const asset = detail?.asset;
|
|
||||||
if (!asset || !onRegenerate || regenerating) return;
|
|
||||||
const ok = window.confirm('重新生成这 1 张会再次调用图片模型并产生费用。确认继续?');
|
|
||||||
if (!ok) return;
|
|
||||||
setRegenerating(true);
|
|
||||||
try {
|
|
||||||
await onRegenerate(asset.id, refinement);
|
|
||||||
setRefinement('');
|
|
||||||
onClose();
|
|
||||||
} finally {
|
|
||||||
setRegenerating(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
const template = detail?.template;
|
const template = detail?.template;
|
||||||
@@ -206,28 +187,6 @@ function AssetDetailDrawer({ detail, onClose, onRegenerate }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{ready && (
|
|
||||||
<div className="asset-detail-block">
|
|
||||||
<div className="asset-detail-label">单张重做</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<input
|
|
||||||
value={refinement}
|
|
||||||
onChange={event => setRefinement(event.target.value)}
|
|
||||||
placeholder="补充重做要求"
|
|
||||||
className="min-w-0 flex-1 rounded-[8px] bg-black/30 px-3 py-2 text-[12px] text-white/82 outline-none ring-1 ring-white/[0.09] focus:ring-[#e6f578]/40"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleRedo}
|
|
||||||
disabled={regenerating}
|
|
||||||
className="rounded-[8px] bg-[#d6b36a]/18 px-4 py-2 text-[12px] font-semibold text-[#f2d38c] ring-1 ring-[#d6b36a]/24 transition-colors hover:bg-[#d6b36a]/26 disabled:opacity-40"
|
|
||||||
>
|
|
||||||
{regenerating ? '...' : '确认'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -248,13 +207,12 @@ function DetailItem({ label, value }: { label: string; value: string | number })
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ── Pack Section ─────────────────────────────── */
|
/* ── Pack Section ─────────────────────────────── */
|
||||||
function PackSection({ kind, pack, locked, lockReason, stepIndex, onRegenerateAsset }: {
|
function PackSection({ kind, pack, locked, lockReason, stepIndex }: {
|
||||||
kind: PackKind;
|
kind: PackKind;
|
||||||
pack: AssetPack | undefined;
|
pack: AssetPack | undefined;
|
||||||
locked: boolean;
|
locked: boolean;
|
||||||
lockReason?: string;
|
lockReason?: string;
|
||||||
stepIndex: number;
|
stepIndex: number;
|
||||||
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
|
|
||||||
}) {
|
}) {
|
||||||
const [detail, setDetail] = useState<AssetDetail | null>(null);
|
const [detail, setDetail] = useState<AssetDetail | null>(null);
|
||||||
const accent = PACK_ACCENT[kind];
|
const accent = PACK_ACCENT[kind];
|
||||||
@@ -301,7 +259,7 @@ function PackSection({ kind, pack, locked, lockReason, stepIndex, onRegenerateAs
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<AssetDetailDrawer detail={detail} onClose={() => setDetail(null)} onRegenerate={onRegenerateAsset} />
|
<AssetDetailDrawer detail={detail} onClose={() => setDetail(null)} />
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -516,14 +474,12 @@ export default function PackPanel({
|
|||||||
activeNav,
|
activeNav,
|
||||||
onActiveNavChange,
|
onActiveNavChange,
|
||||||
videoLoading,
|
videoLoading,
|
||||||
onRegenerateAsset,
|
|
||||||
onGenerateVideo,
|
onGenerateVideo,
|
||||||
}: {
|
}: {
|
||||||
session: GenSession;
|
session: GenSession;
|
||||||
activeNav: string;
|
activeNav: string;
|
||||||
onActiveNavChange: (id: string) => void;
|
onActiveNavChange: (id: string) => void;
|
||||||
videoLoading: boolean;
|
videoLoading: boolean;
|
||||||
onRegenerateAsset: (assetId: string, userRefinement?: string) => Promise<void>;
|
|
||||||
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void;
|
onGenerateVideo: (image: GenImage, template: typeof VIDEO_TEMPLATES[number]) => void;
|
||||||
}) {
|
}) {
|
||||||
const selectedImages = session.images.filter(image => image.status === 'selected');
|
const selectedImages = session.images.filter(image => image.status === 'selected');
|
||||||
@@ -564,7 +520,7 @@ export default function PackPanel({
|
|||||||
<span className="section-eyebrow">Asset Details</span>
|
<span className="section-eyebrow">Asset Details</span>
|
||||||
<h2 className="mt-1 text-sm font-semibold text-white">生成明细</h2>
|
<h2 className="mt-1 text-sm font-semibold text-white">生成明细</h2>
|
||||||
</div>
|
</div>
|
||||||
<span className="shrink-0 text-[10px] text-white/35">查看与单张重做</span>
|
<span className="shrink-0 text-[10px] text-white/35">查看明细</span>
|
||||||
</div>
|
</div>
|
||||||
<SectionNav active={activeNav} onChange={onActiveNavChange} />
|
<SectionNav active={activeNav} onChange={onActiveNavChange} />
|
||||||
</section>
|
</section>
|
||||||
@@ -579,7 +535,6 @@ export default function PackPanel({
|
|||||||
locked={locked}
|
locked={locked}
|
||||||
lockReason={lockReason}
|
lockReason={lockReason}
|
||||||
stepIndex={index + 1}
|
stepIndex={index + 1}
|
||||||
onRegenerateAsset={onRegenerateAsset}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})() : (
|
})() : (
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ type GalleryItem = {
|
|||||||
|
|
||||||
type GalleryPanel = {
|
type GalleryPanel = {
|
||||||
mode: 'pack' | 'empty' | 'project';
|
mode: 'pack' | 'empty' | 'project';
|
||||||
|
kind?: PackKind;
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -64,6 +65,7 @@ function galleryForPanel(session: GenSession, activeNav: string): GalleryPanel {
|
|||||||
})) ?? [];
|
})) ?? [];
|
||||||
return {
|
return {
|
||||||
mode: 'pack' as const,
|
mode: 'pack' as const,
|
||||||
|
kind,
|
||||||
label: PACK_LABELS[kind],
|
label: PACK_LABELS[kind],
|
||||||
description: `${PACK_LABELS[kind]}当前区块图片`,
|
description: `${PACK_LABELS[kind]}当前区块图片`,
|
||||||
total: PACK_TEMPLATES[kind].length,
|
total: PACK_TEMPLATES[kind].length,
|
||||||
@@ -99,6 +101,9 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
|
|||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [preview, setPreview] = useState<GalleryItem | null>(null);
|
const [preview, setPreview] = useState<GalleryItem | null>(null);
|
||||||
const gallery = galleryForPanel(session, activeNav);
|
const gallery = galleryForPanel(session, activeNav);
|
||||||
|
const downloadHref = gallery.kind && gallery.images.length
|
||||||
|
? `/api/packs/download?sessionId=${encodeURIComponent(session.id)}&kind=${encodeURIComponent(gallery.kind)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -191,6 +196,22 @@ export default function ProjectGalleryDrawer({ session, activeNav, onAction }: P
|
|||||||
<span className="mt-0.5 text-[9px] font-semibold">图库</span>
|
<span className="mt-0.5 text-[9px] font-semibold">图库</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{downloadHref && (
|
||||||
|
<a
|
||||||
|
href={downloadHref}
|
||||||
|
className="gallery-download-button"
|
||||||
|
title={`打包下载${gallery.label}图片`}
|
||||||
|
aria-label={`打包下载${gallery.label}图片`}
|
||||||
|
>
|
||||||
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" aria-hidden="true">
|
||||||
|
<path d="M12 4v10" strokeLinecap="round" />
|
||||||
|
<path d="m8 10 4 4 4-4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
<path d="M5 19h14" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
<span>下载</span>
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="w-full rounded-[8px] bg-black/22 px-1.5 py-2 text-center text-[10px] text-white/42">
|
<div className="w-full rounded-[8px] bg-black/22 px-1.5 py-2 text-center text-[10px] text-white/42">
|
||||||
<b className="block text-[13px] text-[#e6f578]">{gallery.images.length}</b>
|
<b className="block text-[13px] text-[#e6f578]">{gallery.images.length}</b>
|
||||||
<span>/ {gallery.total}</span>
|
<span>/ {gallery.total}</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user