diff --git a/furniture-3d-system/src/app/api/diy/ai-renders/image/route.ts b/furniture-3d-system/src/app/api/diy/ai-renders/image/route.ts new file mode 100644 index 00000000..b53bd9ad --- /dev/null +++ b/furniture-3d-system/src/app/api/diy/ai-renders/image/route.ts @@ -0,0 +1,23 @@ +import { NextRequest, NextResponse } from 'next/server' +import { handleRequest } from '@/server/core/response' +import { readOwnedDiyAiRenderImage } from '@/server/services/diy-ai-render.image' + +export const dynamic = 'force-dynamic' +export const maxDuration = 60 + +export async function GET(request: NextRequest) { + return handleRequest(request, async ctx => { + const image = await readOwnedDiyAiRenderImage({ + recordId: request.nextUrl.searchParams.get('recordId'), + storageKey: request.nextUrl.searchParams.get('storageKey'), + }, ctx.user) + return new NextResponse(new Uint8Array(image.buffer), { + headers: { + 'Content-Type': image.contentType, + 'Content-Length': String(image.buffer.byteLength), + 'Cache-Control': 'private, no-store', + 'X-Content-Type-Options': 'nosniff', + }, + }) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/diy/ai-renders/layout-replace/route.ts b/furniture-3d-system/src/app/api/diy/ai-renders/layout-replace/route.ts new file mode 100644 index 00000000..a6038a53 --- /dev/null +++ b/furniture-3d-system/src/app/api/diy/ai-renders/layout-replace/route.ts @@ -0,0 +1,20 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { ErrorCode } from '@/server/core/errors' +import { createDiyLayoutReplace } from '@/server/services/diy-layout-replace.service' +import { diyAiRenderService } from '@/server/services/diy-ai-render.service' + +export const maxDuration = 300 +export async function GET(request: NextRequest) { + return handleRequest(request, async ctx => jsonSuccess(await diyAiRenderService.findForRequest(request.nextUrl.searchParams.get('requestId') || '', ctx.user), 200, ctx.requestId), { requireAuth: true }) +} +export async function POST(request: NextRequest) { + return handleRequest(request, async ctx => { + const text = await request.text() + if (Buffer.byteLength(text) > 12_000) return jsonError(ErrorCode.FILE_TOO_LARGE, '排版参数过大', 413, ctx.requestId) + let input: unknown + try { input = JSON.parse(text) } catch { return jsonError(ErrorCode.VALIDATION_FAILED, '排版参数格式无效', 422, ctx.requestId) } + const record = await createDiyLayoutReplace(input, ctx.user) + return jsonSuccess(record, record.status === 'PENDING' ? 202 : 200, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/api/diy/ai-renders/layout-template/route.ts b/furniture-3d-system/src/app/api/diy/ai-renders/layout-template/route.ts new file mode 100644 index 00000000..7a8079ec --- /dev/null +++ b/furniture-3d-system/src/app/api/diy/ai-renders/layout-template/route.ts @@ -0,0 +1,13 @@ +import { NextRequest } from 'next/server' +import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response' +import { ErrorCode } from '@/server/core/errors' +import { uploadDiyLayoutTemplate } from '@/server/services/diy-image-template.service' + +export async function POST(request: NextRequest) { + return handleRequest(request, async ctx => { + if (Number(request.headers.get('content-length')) > 21 * 1024 * 1024) return jsonError(ErrorCode.FILE_TOO_LARGE, '模板不能超过20MB', 413, ctx.requestId) + const form = await request.formData(), file = form.get('file') + if (!(file instanceof File)) return jsonError(ErrorCode.VALIDATION_FAILED, '请选择模板图片', 422, ctx.requestId) + return jsonSuccess(await uploadDiyLayoutTemplate(file, ctx.user), 201, ctx.requestId) + }, { requireAuth: true }) +} diff --git a/furniture-3d-system/src/app/diy/components/DiyAiResults.tsx b/furniture-3d-system/src/app/diy/components/DiyAiResults.tsx index 2d3239bd..37976839 100644 --- a/furniture-3d-system/src/app/diy/components/DiyAiResults.tsx +++ b/furniture-3d-system/src/app/diy/components/DiyAiResults.tsx @@ -1,15 +1,24 @@ 'use client' import { useEffect, useState } from 'react' -import { Download, Trash2 } from 'lucide-react' +import dynamic from 'next/dynamic' +import { Download, SlidersHorizontal, Trash2, Wand2 } from 'lucide-react' import Button from '@/components/ui/Button' import type { DiyAiRenderDTO } from '@/lib/diy/ai-render' import type { DiyAiRenderController } from '../hooks/useDiyAiRender' import { RenderRecordGalleryLightbox } from '@/app/ai-render-workbench/RenderRecordGalleryLightbox' import DiyAiDeleteConfirmation from './DiyAiDeleteConfirmation' +import type { DiyImageSelection } from '@/lib/diy/image-tools' +import { useDiyLayoutBatch } from '../hooks/useDiyLayoutBatch' + +const DiyImageEditor = dynamic(() => import('./image-tools/DiyImageEditor'), { ssr: false }) +const DiyLayoutReplaceDialog = dynamic(() => import('./image-tools/DiyLayoutReplaceDialog'), { ssr: false }) +const recordLabel = (record: DiyAiRenderDTO) => record.operation === 'layout-replace' ? '排版图' : record.mode === 'scene' ? '效果图' : '白底图' export default function DiyAiResults({ records, ai }: { records: DiyAiRenderDTO[]; ai: DiyAiRenderController }) { const [large,setLarge] = useState(null) + const [editing, setEditing] = useState(null), [layout, setLayout] = useState(null) + const batch = useDiyLayoutBatch(ai.refresh) useEffect(() => { if (!large) return const close = (event:KeyboardEvent) => { if(event.key==='Escape')setLarge(null) } @@ -17,10 +26,17 @@ export default function DiyAiResults({ records, ai }: { records: DiyAiRenderDTO[ },[large]) return
+ {editing && setEditing(null)}/>} + {layout && setLayout(null)}/>} + {batch.items.length > 0 &&
+

{batch.notice || '排版处理中…'}

+ {batch.items.filter(item => item.error).map(item =>

{item.name}:{item.error}

)} + {batch.items.some(item => item.status !== 'succeeded' && item.status !== 'failed') && !batch.running && } +
} {large && setLarge(null)}/>} {records.map(record =>
- {record.name} · {record.mode==='scene'?'效果图':'白底图'} + {record.name} · {recordLabel(record)} {new Date(record.createdAt).toLocaleString('zh-CN',{timeZone:'Asia/Shanghai'})}

{record.snapshot.items.length} 柜体 · {Object.keys(record.snapshot.doors).length} 柜门{record.schemeCode ? ' · 已保存方案' : ' · 生成时方案'}

@@ -31,18 +47,24 @@ export default function DiyAiResults({ records, ai }: { records: DiyAiRenderDTO[ 删除失败记录}
{record.images.map((image,index) => { - const filename = `${record.name}-${record.mode==='scene'?'效果图':'白底图'}-${index+1}.png` + const filename = `${record.name}-${recordLabel(record)}-${index+1}.png` + const selection = { recordId: record.id, storageKey: image.storageKey, url: image.originalUrl, name: `${record.name}-${recordLabel(record)}-${index + 1}`, width: image.width, height: image.height } return
- +
+ + {ai.access.canGenerate && record.mode === 'white-background' && !record.operation && record.status === 'SUCCEEDED' && + } +
{image.width} × {image.height}
{ai.access.canDownload && } {ai.access.canDelete && }
@@ -51,6 +73,7 @@ export default function DiyAiResults({ records, ai }: { records: DiyAiRenderDTO[ })}
{record.referenceImageUrl && } + {record.templateUrl && }
)} {!records.length &&

还没有{ai.mode==='scene'?'效果图':'白底图'}记录

}
diff --git a/furniture-3d-system/src/app/diy/components/image-tools/DiyImageAdjustControls.tsx b/furniture-3d-system/src/app/diy/components/image-tools/DiyImageAdjustControls.tsx new file mode 100644 index 00000000..7f771f52 --- /dev/null +++ b/furniture-3d-system/src/app/diy/components/image-tools/DiyImageAdjustControls.tsx @@ -0,0 +1,26 @@ +'use client' + +import { useState } from 'react' +import Button from '@/components/ui/Button' +import { DEFAULT_IMAGE_ADJUSTMENTS, IMAGE_ADJUST_SECTIONS, type ImageAdjustPanelTab } from '@/lib/diy/image-tools/adjustments' +import type { DiyImageEditorController } from '../../hooks/useDiyImageEditor' + +const tabs: { key: ImageAdjustPanelTab; label: string }[] = [ + { key: 'light', label: '光线' }, { key: 'color', label: '颜色' }, { key: 'detail', label: '细节' }, { key: 'effects', label: '效果' }, +] +export default function DiyImageAdjustControls({ editor }: { editor: DiyImageEditorController }) { + const [tab, setTab] = useState('light') + return
+
+ {tabs.map(item => )} +
+ {IMAGE_ADJUST_SECTIONS[tab].map(control => )} + +

调色仅用于实时预览,尺寸导出使用原图。

+
+} diff --git a/furniture-3d-system/src/app/diy/components/image-tools/DiyImageCanvasPreview.tsx b/furniture-3d-system/src/app/diy/components/image-tools/DiyImageCanvasPreview.tsx new file mode 100644 index 00000000..bfeb6b74 --- /dev/null +++ b/furniture-3d-system/src/app/diy/components/image-tools/DiyImageCanvasPreview.tsx @@ -0,0 +1,35 @@ +'use client' + +import { useRef } from 'react' +import { buildImageAdjustmentFilterStyle, isDefaultImageAdjustments } from '@/lib/diy/image-tools/adjustments' +import { ImageNodeAdjustOverlays } from '@/lib/diy/image-tools/AdjustmentOverlays' +import { resizeGeometry } from '@/lib/diy/image-tools/resize' +import type { DiyImageEditorController } from '../../hooks/useDiyImageEditor' + +export default function DiyImageCanvasPreview({ editor, draggable }: { editor: DiyImageEditorController; draggable: boolean }) { + const drag = useRef<{ x: number; y: number; offsetX: number; offsetY: number; factor: number } | null>(null) + const { image, options, adjustments, validation } = editor + if (!image) return
{editor.error || '正在加载原图…'}
+ if (validation) return
{validation}
+ const rect = resizeGeometry(image.naturalWidth, image.naturalHeight, options) + return
+
{ + if (!draggable || event.button !== 0) return + event.currentTarget.setPointerCapture(event.pointerId) + drag.current = { x: event.clientX, y: event.clientY, offsetX: options.offsetX || 0, offsetY: options.offsetY || 0, factor: options.width / event.currentTarget.getBoundingClientRect().width } + }} onPointerMove={event => { + const start = drag.current + if (!start) return + editor.setOptions(previous => ({ ...previous, offsetX: Math.round(start.offsetX + (event.clientX - start.x) * start.factor), offsetY: Math.round(start.offsetY + (event.clientY - start.y) * start.factor) })) + }} onPointerUp={() => { drag.current = null }} onPointerCancel={() => { drag.current = null }}> +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + 编辑图片预览 + +
+
+
+} diff --git a/furniture-3d-system/src/app/diy/components/image-tools/DiyImageEditor.tsx b/furniture-3d-system/src/app/diy/components/image-tools/DiyImageEditor.tsx new file mode 100644 index 00000000..0c38e12d --- /dev/null +++ b/furniture-3d-system/src/app/diy/components/image-tools/DiyImageEditor.tsx @@ -0,0 +1,32 @@ +'use client' + +import { useState } from 'react' +import { Download } from 'lucide-react' +import Modal from '@/components/ui/Modal' +import Button from '@/components/ui/Button' +import type { DiyImageSelection } from '@/lib/diy/image-tools' +import { useDiyImageEditor } from '../../hooks/useDiyImageEditor' +import DiyImageAdjustControls from './DiyImageAdjustControls' +import DiyImageResizeControls from './DiyImageResizeControls' +import DiyImageCanvasPreview from './DiyImageCanvasPreview' + +export default function DiyImageEditor({ source, canDownload, onClose }: { source: DiyImageSelection; canDownload: boolean; onClose: () => void }) { + const [tab, setTab] = useState<'adjust' | 'resize'>('adjust') + const editor = useDiyImageEditor(source) + return { if (!editor.exporting) onClose() }} title="编辑图片" size="xlarge" variant="compact" bodyClassName="pt-4!" + footer={
原图保留 · 导出为新 PNG +
}> +
+ +
+
+ + +
+ {tab === 'adjust' ? : } +
+
+ {editor.error &&

{editor.error}

} + {editor.notice &&

{editor.notice}

} +
+} diff --git a/furniture-3d-system/src/app/diy/components/image-tools/DiyImageResizeControls.tsx b/furniture-3d-system/src/app/diy/components/image-tools/DiyImageResizeControls.tsx new file mode 100644 index 00000000..fc67872c --- /dev/null +++ b/furniture-3d-system/src/app/diy/components/image-tools/DiyImageResizeControls.tsx @@ -0,0 +1,33 @@ +'use client' + +import Button from '@/components/ui/Button' +import Input from '@/components/ui/Input' +import type { DiyImageEditorController } from '../../hooks/useDiyImageEditor' + +export default function DiyImageResizeControls({ editor }: { editor: DiyImageEditorController }) { + const { options, setOptions } = editor + return
+
+ {(['width', 'height'] as const).map((key, index) => setOptions(previous => ({ ...previous, [key]: Number(event.target.value) }))}/>)} +
+
+ {[800, 1024, 1600, 2048].map(size => )} +
+ setOptions(previous => ({ ...previous, scale: Number(event.target.value) / 100 }))}/> +
+ {(['offsetX', 'offsetY'] as const).map((key, index) => setOptions(previous => ({ ...previous, [key]: Number(event.target.value) }))}/>)} +
+
+ + {options.background !== null && setOptions(previous => ({ ...previous, background: event.target.value }))}/>} +
+
+ + +
+

可拖动图片调整位置。透明画布保留原图背景,不自动去除白底。单边最大 8192 像素,总像素不超过 3200 万。

+
+} diff --git a/furniture-3d-system/src/app/diy/components/image-tools/DiyLayoutReplaceDialog.tsx b/furniture-3d-system/src/app/diy/components/image-tools/DiyLayoutReplaceDialog.tsx new file mode 100644 index 00000000..a7c5d837 --- /dev/null +++ b/furniture-3d-system/src/app/diy/components/image-tools/DiyLayoutReplaceDialog.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useState } from 'react' +import { Wand2 } from 'lucide-react' +import Modal from '@/components/ui/Modal' +import Button from '@/components/ui/Button' +import type { DiyAiRenderDTO } from '@/lib/diy/ai-render' +import type { DiyImageSelection, DiyLayoutTemplate } from '@/lib/diy/image-tools' +import type { DiyLayoutBatchController } from '../../hooks/useDiyLayoutBatch' +import DiyLayoutTemplateUpload from './DiyLayoutTemplateUpload' + +export default function DiyLayoutReplaceDialog({ source, records, batch, onClose }: { + source: DiyImageSelection; records: DiyAiRenderDTO[]; batch: DiyLayoutBatchController; onClose: () => void +}) { + const [selected, setSelected] = useState([source.storageKey]), [template, setTemplate] = useState(null) + const [prompt, setPrompt] = useState(''), [uploading, setUploading] = useState(false), [error, setError] = useState('') + const candidates = records.filter(record => record.mode === 'white-background' && !record.operation && record.status === 'SUCCEEDED') + .flatMap(record => record.images.map((image, index) => ({ recordId: record.id, storageKey: image.storageKey, url: image.thumbnailUrl, name: `${record.name}-${index + 1}` }))) + const blocked = batch.running || batch.items.some(item => ['queued', 'submitting', 'generating', 'needs-review'].includes(item.status)) + const start = () => { + if (!template || blocked || uploading || !selected.length) return + setError('') + try { + const sources = selected.map(key => candidates.find(item => item.storageKey === key)) + if (sources.some(item => !item)) throw new Error('所选原图已更新,请重新选择') + batch.start(sources.map(item => ({ name: item!.name, input: { requestId: crypto.randomUUID(), sourceRecordId: item!.recordId, + sourceStorageKey: item!.storageKey, templateStorageKey: template.storageKey, supplementalPrompt: prompt.trim() } }))) + } catch (err) { setError(err instanceof Error ? err.message : '提交失败') } + } + return { if (!uploading) onClose() }} bodyClassName="pt-4!" + footer={
原图保留 · 结果存入出图记录 +
}> +
+
+

商品白底图 {selected.length}/5

+
+ {candidates.map(item => )} +
+