interactive-demo.tsx 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972
  1. "use client"
  2. import type React from "react"
  3. import { useState, useRef, useEffect } from "react"
  4. import { Button } from "@/components/ui/button"
  5. import { Input } from "@/components/ui/input"
  6. import { Card, CardContent } from "@/components/ui/card"
  7. import { Badge } from "@/components/ui/badge"
  8. import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
  9. import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
  10. import { UploadIcon, SparklesIcon, LoaderIcon, DownloadIcon, AlertTriangleIcon, X, ImageIcon } from "lucide-react"
  11. import Image from "next/image"
  12. import { useTranslations } from "next-intl"
  13. import { useRouter } from "next/navigation"
  14. import { useToast } from "@/components/ui/use-toast"
  15. import { useAuth } from "./providers"
  16. interface InteractiveDemoProps {
  17. locale: string
  18. }
  19. interface User {
  20. id: string
  21. email: string
  22. username: string | null
  23. isEmailVerified: boolean
  24. credits: number
  25. subscriptionCredits?: number
  26. }
  27. // API响应类型
  28. interface ApiResponse {
  29. success: boolean;
  30. data?: {
  31. images?: Array<{
  32. url: string;
  33. width?: number;
  34. height?: number;
  35. content_type?: string;
  36. }>;
  37. };
  38. error?: string;
  39. message?: string;
  40. credits?: {
  41. deducted: number;
  42. remaining: number;
  43. };
  44. }
  45. // 多图编辑API响应类型
  46. interface MultiImageApiResponse {
  47. success: boolean;
  48. data?: {
  49. images?: Array<{
  50. url: string;
  51. width?: number;
  52. height?: number;
  53. }>;
  54. model_used?: string;
  55. input_count?: number;
  56. output_count?: number;
  57. message?: string;
  58. };
  59. credits?: {
  60. remaining: number;
  61. used: number;
  62. };
  63. error?: string;
  64. }
  65. // 纵横比选项
  66. const getAspectRatios = (t: any) => [
  67. { value: 'original', label: t('aspectRatios.original') },
  68. { value: '21:9', label: t('aspectRatios.ultrawide') },
  69. { value: '16:9', label: t('aspectRatios.widescreen') },
  70. { value: '4:3', label: t('aspectRatios.standard') },
  71. { value: '3:2', label: t('aspectRatios.classic') },
  72. { value: '1:1', label: t('aspectRatios.square') },
  73. { value: '2:3', label: t('aspectRatios.portraitClassic') },
  74. { value: '3:4', label: t('aspectRatios.portraitStandard') },
  75. { value: '9:16', label: t('aspectRatios.portraitWidescreen') },
  76. { value: '9:21', label: t('aspectRatios.portraitUltrawide') }
  77. ]
  78. // 支持的文件格式
  79. const SUPPORTED_FORMATS = [
  80. 'image/jpeg',
  81. 'image/jpg',
  82. 'image/png',
  83. 'image/webp',
  84. 'image/avif'
  85. ]
  86. const MAX_FILES = 10
  87. const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
  88. export default function InteractiveDemo({ locale }: InteractiveDemoProps) {
  89. const [editMode, setEditMode] = useState<'single' | 'multi'>('single')
  90. const [prompt, setPrompt] = useState("")
  91. const [isProcessing, setIsProcessing] = useState(false)
  92. const [uploadedImage, setUploadedImage] = useState<string | null>(null)
  93. const [uploadedFile, setUploadedFile] = useState<File | null>(null)
  94. const [uploadedFiles, setUploadedFiles] = useState<File[]>([])
  95. const [fileUrls, setFileUrls] = useState<Map<File, string>>(new Map()) // 缓存文件URL
  96. const [generatedImage, setGeneratedImage] = useState<string | null>(null)
  97. const [generatedImages, setGeneratedImages] = useState<string[]>([])
  98. const [showDownloadSuccess, setShowDownloadSuccess] = useState(false)
  99. const [error, setError] = useState<string | null>(null)
  100. const [aspectRatio, setAspectRatio] = useState<string>('original')
  101. const { user, isLoading, refreshUser } = useAuth()
  102. const fileInputRef = useRef<HTMLInputElement>(null)
  103. const multiFileInputRef = useRef<HTMLInputElement>(null)
  104. const t = useTranslations("demo")
  105. const tError = useTranslations("errors")
  106. const tLogs = useTranslations("logs")
  107. const router = useRouter()
  108. const { toast } = useToast()
  109. const aspectRatios = getAspectRatios(useTranslations())
  110. // 清理文件URL的函数
  111. const cleanupFileUrls = (filesToCleanup: File[]) => {
  112. setFileUrls(prevUrls => {
  113. const newUrls = new Map(prevUrls)
  114. filesToCleanup.forEach(file => {
  115. const url = newUrls.get(file)
  116. if (url) {
  117. URL.revokeObjectURL(url)
  118. newUrls.delete(file)
  119. }
  120. })
  121. return newUrls
  122. })
  123. }
  124. // 获取或创建文件URL
  125. const getFileUrl = (file: File): string => {
  126. if (fileUrls.has(file)) {
  127. return fileUrls.get(file)!
  128. }
  129. const url = URL.createObjectURL(file)
  130. setFileUrls(prev => new Map(prev).set(file, url))
  131. return url
  132. }
  133. // 组件卸载时清理所有URL
  134. useEffect(() => {
  135. return () => {
  136. fileUrls.forEach(url => URL.revokeObjectURL(url))
  137. }
  138. }, [fileUrls])
  139. // 检查用户积分(只在需要时调用)
  140. const checkUserCredits = async (): Promise<boolean> => {
  141. if (isLoading) {
  142. setError(tError('checkingAuth'))
  143. return false
  144. }
  145. if (!user) {
  146. setError(tError('loginRequired'))
  147. return false
  148. }
  149. const totalCredits = (user.credits || 0) + (user.subscriptionCredits || 0)
  150. if (totalCredits <= 0) {
  151. setError(tError('insufficientCredits'))
  152. return false
  153. }
  154. return true
  155. }
  156. const handleLoginRequired = (action: string) => {
  157. toast({
  158. title: tError("loginRequired"),
  159. description: tError("loginRequiredDesc"),
  160. variant: "destructive"
  161. })
  162. router.push(`/${locale}/auth/login`)
  163. }
  164. const presetKeywords = [
  165. { key: "retro", label: t("presetKeywords.retro"), prompt: t("presetPrompts.retro") },
  166. { key: "cyberpunk", label: t("presetKeywords.cyberpunk"), prompt: t("presetPrompts.cyberpunk") },
  167. { key: "anime", label: t("presetKeywords.anime"), prompt: t("presetPrompts.anime") },
  168. { key: "removeBackground", label: t("presetKeywords.removeBackground"), prompt: t("presetPrompts.removeBackground") },
  169. { key: "colorizeOldPhoto", label: t("presetKeywords.colorizeOldPhoto"), prompt: t("presetPrompts.colorizeOldPhoto") },
  170. ]
  171. const handleKeywordClick = (prompt: string) => {
  172. setPrompt(prompt)
  173. }
  174. // 处理单图上传区域点击
  175. const handleUploadClick = () => {
  176. // 检查用户是否已登录
  177. if (!user) {
  178. handleLoginRequired("upload")
  179. return
  180. }
  181. // 如果已登录,触发文件选择
  182. fileInputRef.current?.click()
  183. }
  184. // 处理多图上传区域点击
  185. const handleMultiUploadClick = () => {
  186. // 检查用户是否已登录
  187. if (!user) {
  188. handleLoginRequired("upload")
  189. return
  190. }
  191. // 如果已登录,触发文件选择
  192. multiFileInputRef.current?.click()
  193. }
  194. const handleImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
  195. const file = event.target.files?.[0]
  196. if (file) {
  197. // 检查文件大小 (限制为5MB)
  198. if (file.size > MAX_FILE_SIZE) {
  199. setError(tError("fileTooLarge"))
  200. return
  201. }
  202. // 检查文件类型
  203. if (!SUPPORTED_FORMATS.includes(file.type)) {
  204. setError(tError("unsupportedFormat"))
  205. return
  206. }
  207. setError(null)
  208. setUploadedFile(file)
  209. const reader = new FileReader()
  210. reader.onload = (e) => {
  211. setUploadedImage(e.target?.result as string)
  212. }
  213. reader.readAsDataURL(file)
  214. }
  215. }
  216. const handleMultiImageUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
  217. const files = Array.from(event.target.files || [])
  218. if (files.length === 0) return
  219. // 检查文件数量
  220. if (uploadedFiles.length + files.length > MAX_FILES) {
  221. setError(t("maxFilesError", { max: MAX_FILES }))
  222. return
  223. }
  224. // 检查每个文件
  225. const validFiles: File[] = []
  226. for (const file of files) {
  227. if (file.size > MAX_FILE_SIZE) {
  228. toast({
  229. title: t("fileTooLargeError"),
  230. description: t("fileSizeExceeded", { filename: file.name }),
  231. variant: "destructive",
  232. })
  233. continue
  234. }
  235. if (!SUPPORTED_FORMATS.includes(file.type)) {
  236. toast({
  237. title: t("formatNotSupported"),
  238. description: t("fileFormatNotSupported", { filename: file.name }),
  239. variant: "destructive",
  240. })
  241. continue
  242. }
  243. validFiles.push(file)
  244. }
  245. if (validFiles.length > 0) {
  246. setError(null)
  247. setUploadedFiles(prev => [...prev, ...validFiles])
  248. }
  249. }
  250. // 移除多图中的某张图片
  251. const removeMultiImage = (index: number) => {
  252. const fileToRemove = uploadedFiles[index]
  253. if (fileToRemove) {
  254. cleanupFileUrls([fileToRemove])
  255. }
  256. setUploadedFiles(prev => prev.filter((_, i) => i !== index))
  257. }
  258. // 清空多图
  259. const clearMultiImages = () => {
  260. cleanupFileUrls(uploadedFiles)
  261. setUploadedFiles([])
  262. setGeneratedImages([])
  263. }
  264. const handleProcess = async () => {
  265. // 检查用户是否已登录
  266. if (!user) {
  267. handleLoginRequired("generate")
  268. return
  269. }
  270. if (!prompt.trim()) {
  271. setError(tError("uploadAndPrompt"))
  272. return
  273. }
  274. // 根据编辑模式检查文件
  275. if (editMode === 'single' && !uploadedFile) {
  276. setError(t("uploadSingleImage"))
  277. return
  278. }
  279. if (editMode === 'multi' && uploadedFiles.length === 0) {
  280. setError(t("uploadAtLeastOneImage"))
  281. return
  282. }
  283. // 检查用户积分
  284. const hasCredits = await checkUserCredits()
  285. if (!hasCredits) {
  286. return
  287. }
  288. setIsProcessing(true)
  289. setError(null)
  290. if (editMode === 'single') {
  291. setGeneratedImage(null)
  292. } else {
  293. setGeneratedImages([])
  294. }
  295. try {
  296. // 静默翻译提示词为英文(用户不会感知到这个过程)
  297. let translatedPrompt = prompt.trim();
  298. try {
  299. const translateResponse = await fetch('/api/translate', {
  300. method: 'POST',
  301. headers: {
  302. 'Content-Type': 'application/json',
  303. },
  304. body: JSON.stringify({
  305. text: prompt.trim(),
  306. targetLanguage: 'en'
  307. })
  308. });
  309. if (translateResponse.ok) {
  310. const translateResult = await translateResponse.json();
  311. if (translateResult.success && translateResult.translatedText) {
  312. translatedPrompt = translateResult.translatedText;
  313. // 只在控制台记录翻译信息,用户不会看到
  314. console.log('原始提示词:', prompt.trim());
  315. console.log('翻译后提示词:', translatedPrompt);
  316. }
  317. } else {
  318. console.warn('翻译失败,使用原始提示词');
  319. }
  320. } catch (translateError) {
  321. console.warn('翻译服务错误,使用原始提示词:', translateError);
  322. }
  323. if (editMode === 'single') {
  324. // 单图处理逻辑
  325. const formData = new FormData()
  326. formData.append('image', uploadedFile!)
  327. formData.append('prompt', translatedPrompt)
  328. formData.append('locale', locale)
  329. // 添加纵横比参数(如果选择了且不是原图比例)
  330. if (aspectRatio && aspectRatio !== 'original') {
  331. formData.append('aspect_ratio', aspectRatio)
  332. }
  333. // 检查是否是背景移除操作
  334. const isRemoveBackground = translatedPrompt.toLowerCase().includes('remove background') ||
  335. translatedPrompt.toLowerCase().includes('移除背景')
  336. if (isRemoveBackground) {
  337. formData.append('action', 'remove_background')
  338. } else {
  339. formData.append('action', 'smart')
  340. }
  341. console.log(tLogs('sendingRequest'))
  342. const response = await fetch('/api/edit-image', {
  343. method: 'POST',
  344. body: formData,
  345. credentials: 'include',
  346. })
  347. console.log(tLogs('receivedResponse'), response.status)
  348. const result: ApiResponse = await response.json()
  349. console.log(tLogs('apiResponseData'), result)
  350. if (!response.ok) {
  351. throw new Error(result.error || `HTTP错误: ${response.status}`)
  352. }
  353. if (!result.success) {
  354. throw new Error(result.error || t("processingFailed"))
  355. }
  356. // 检查响应数据结构
  357. if (!result.data) {
  358. throw new Error(t("invalidResponseData"))
  359. }
  360. if (!result.data.images || !Array.isArray(result.data.images)) {
  361. throw new Error(t("noImageDataInResponse"))
  362. }
  363. if (result.data.images.length === 0) {
  364. throw new Error(t("noImagesGenerated"))
  365. }
  366. const firstImage = result.data.images[0]
  367. if (!firstImage || !firstImage.url) {
  368. throw new Error(tError('invalidImageUrl'))
  369. }
  370. console.log(tLogs('successImageUrl'), firstImage.url)
  371. setGeneratedImage(firstImage.url)
  372. // 更新用户积分(如果响应中包含积分信息)
  373. if (result.credits && result.credits.deducted && result.credits.remaining !== undefined) {
  374. // 重新获取用户信息以确保积分数据正确
  375. await refreshUser();
  376. // 显示积分扣除提示
  377. toast({
  378. title: t("generateSuccess"),
  379. description: `${t("creditsDeducted")} ${result.credits.deducted} 积分,${t("creditsRemaining")} ${result.credits.remaining} 积分`,
  380. variant: "default"
  381. })
  382. }
  383. } else {
  384. // 多图处理逻辑
  385. const formData = new FormData()
  386. // 添加所有图片文件
  387. uploadedFiles.forEach(file => {
  388. formData.append('images', file)
  389. })
  390. formData.append('prompt', translatedPrompt)
  391. formData.append('locale', locale)
  392. // 添加纵横比参数(如果选择了且不是原图比例)
  393. if (aspectRatio && aspectRatio !== 'original') {
  394. formData.append('aspect_ratio', aspectRatio)
  395. }
  396. console.log(tLogs('sendingRequest'))
  397. const response = await fetch('/api/edit-multi-images', {
  398. method: 'POST',
  399. body: formData,
  400. credentials: 'include',
  401. })
  402. console.log(tLogs('receivedResponse'), response.status)
  403. const result: MultiImageApiResponse = await response.json()
  404. console.log(tLogs('apiResponseData'), result)
  405. if (!response.ok) {
  406. throw new Error(result.error || `HTTP错误: ${response.status}`)
  407. }
  408. if (!result.success) {
  409. throw new Error(result.error || t("processingFailed"))
  410. }
  411. // 检查响应数据结构
  412. if (!result.data) {
  413. throw new Error(t("invalidResponseData"))
  414. }
  415. if (!result.data.images || !Array.isArray(result.data.images)) {
  416. throw new Error(t("noImageDataInResponse"))
  417. }
  418. if (result.data.images.length === 0) {
  419. throw new Error(t("noImagesGenerated"))
  420. }
  421. const imageUrls = result.data.images.map(img => img.url).filter(Boolean)
  422. console.log('生成的图片URLs:', imageUrls)
  423. setGeneratedImages(imageUrls)
  424. // 更新用户积分(如果响应中包含积分信息)
  425. if (result.credits && result.credits.used && result.credits.remaining !== undefined) {
  426. // 重新获取用户信息以确保积分数据正确
  427. await refreshUser();
  428. // 显示积分扣除提示
  429. toast({
  430. title: t("editSuccess"),
  431. description: t("creditsUsed", { used: result.credits.used, remaining: result.credits.remaining }),
  432. variant: "default"
  433. })
  434. }
  435. }
  436. } catch (error) {
  437. console.error(tError('processingError'), error)
  438. // 提供更详细的错误信息
  439. let errorMessage = tError('processingFailed')
  440. if (error instanceof Error) {
  441. if (error.message.includes('Failed to fetch')) {
  442. errorMessage = tError('networkFailed')
  443. } else if (error.message.includes('HTTP错误: 500')) {
  444. errorMessage = tError('serverError')
  445. } else if (error.message.includes('HTTP错误: 401')) {
  446. errorMessage = tError('invalidApiKey')
  447. } else if (error.message.includes('HTTP错误: 429')) {
  448. errorMessage = tError('rateLimited')
  449. } else {
  450. errorMessage = error.message
  451. }
  452. }
  453. setError(errorMessage)
  454. } finally {
  455. setIsProcessing(false)
  456. }
  457. }
  458. const handleDownload = async () => {
  459. if (!generatedImage) return
  460. try {
  461. const response = await fetch(generatedImage)
  462. const blob = await response.blob()
  463. const url = window.URL.createObjectURL(blob)
  464. const link = document.createElement('a')
  465. link.href = url
  466. link.download = `aiartools-generated-${Date.now()}.png`
  467. document.body.appendChild(link)
  468. link.click()
  469. document.body.removeChild(link)
  470. window.URL.revokeObjectURL(url)
  471. // 显示成功消息
  472. setShowDownloadSuccess(true)
  473. setTimeout(() => {
  474. setShowDownloadSuccess(false)
  475. }, 3000)
  476. } catch (error) {
  477. console.error('Download error:', error)
  478. setError(tError('downloadFailed'))
  479. }
  480. }
  481. // 下载多图中的单张图片
  482. const downloadMultiImage = async (imageUrl: string, index: number) => {
  483. try {
  484. const response = await fetch(imageUrl)
  485. const blob = await response.blob()
  486. const url = window.URL.createObjectURL(blob)
  487. const link = document.createElement('a')
  488. link.href = url
  489. link.download = `aiartools-multi-${index + 1}-${Date.now()}.png`
  490. document.body.appendChild(link)
  491. link.click()
  492. document.body.removeChild(link)
  493. window.URL.revokeObjectURL(url)
  494. toast({
  495. title: t("downloadSuccess"),
  496. description: t("downloadImageSuccess", { index: index + 1 }),
  497. })
  498. } catch (error) {
  499. toast({
  500. title: t("downloadFailed"),
  501. description: t("downloadRetryLater"),
  502. variant: "destructive",
  503. })
  504. }
  505. }
  506. // 批量下载所有多图
  507. const downloadAllMultiImages = async () => {
  508. for (let i = 0; i < generatedImages.length; i++) {
  509. await downloadMultiImage(generatedImages[i], i)
  510. // 添加延迟避免浏览器阻止多个下载
  511. await new Promise(resolve => setTimeout(resolve, 500))
  512. }
  513. }
  514. return (
  515. <section id="demo" className="py-20">
  516. <div className="container mx-auto px-4 sm:px-6 lg:px-8">
  517. <div className="text-center mb-16">
  518. <h2 className="text-3xl md:text-4xl font-bold mb-4">{t("title")}</h2>
  519. <p className="text-xl text-muted-foreground max-w-3xl mx-auto">{t("subtitle")}</p>
  520. </div>
  521. <div className="max-w-6xl mx-auto">
  522. {/* 编辑模式切换 */}
  523. <div className="mb-8">
  524. <Tabs value={editMode} onValueChange={(value) => setEditMode(value as 'single' | 'multi')} className="w-full">
  525. <TabsList className="grid w-full grid-cols-2 max-w-md mx-auto">
  526. <TabsTrigger value="single" className="flex items-center gap-2">
  527. <ImageIcon className="w-4 h-4" />
  528. {t("singleImageEdit")}
  529. </TabsTrigger>
  530. <TabsTrigger value="multi" className="flex items-center gap-2">
  531. <ImageIcon className="w-4 h-4" />
  532. {t("multiImageEdit")}
  533. </TabsTrigger>
  534. </TabsList>
  535. </Tabs>
  536. </div>
  537. <div className="grid grid-cols-1 lg:grid-cols-2 gap-12">
  538. {/* Left Side - Controls */}
  539. <div className="space-y-8">
  540. {/* 用户积分显示 */}
  541. {user && (
  542. <Card>
  543. <CardContent className="p-6">
  544. <div className="flex items-center justify-between">
  545. <div>
  546. <h3 className="text-lg font-semibold mb-1">{t("creditBalance")}</h3>
  547. <p className="text-sm text-muted-foreground">
  548. {editMode === 'single' ? t("creditCost") : t("multiImageCreditCost")}
  549. </p>
  550. </div>
  551. <div className="text-right">
  552. <div className="text-2xl font-bold text-primary">
  553. {(user.credits || 0) + (user.subscriptionCredits || 0)}
  554. </div>
  555. <div className="text-sm text-muted-foreground">{t("availableCredits")}</div>
  556. </div>
  557. </div>
  558. </CardContent>
  559. </Card>
  560. )}
  561. {/* Image Upload */}
  562. <Card>
  563. <CardContent className="p-6">
  564. <h3 className="text-lg font-semibold mb-4">
  565. {editMode === 'single' ? t("uploadImage") : t("maxImagesNote", { count: MAX_FILES })}
  566. </h3>
  567. {editMode === 'single' ? (
  568. // 单图上传
  569. <div
  570. onClick={handleUploadClick}
  571. className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-8 text-center hover:border-primary/50 transition-colors cursor-pointer min-h-[200px] flex items-center justify-center"
  572. >
  573. <input
  574. ref={fileInputRef}
  575. type="file"
  576. accept=".jpg,.jpeg,.png,.webp,.avif"
  577. onChange={handleImageUpload}
  578. className="hidden"
  579. id="image-upload"
  580. />
  581. {uploadedImage ? (
  582. // 显示已上传的图片
  583. <div className="relative w-full h-full flex items-center justify-center">
  584. <Image
  585. src={uploadedImage}
  586. alt="Uploaded image"
  587. width={0}
  588. height={0}
  589. sizes="100vw"
  590. className="rounded-lg w-auto h-auto max-w-full max-h-[300px] object-contain"
  591. />
  592. <div className="absolute inset-0 bg-black bg-opacity-0 hover:bg-opacity-10 transition-all duration-200 rounded-lg flex items-center justify-center">
  593. <p className="text-white text-sm opacity-0 hover:opacity-100 transition-opacity bg-black bg-opacity-75 px-3 py-1 rounded">
  594. {t("clickToChangeImage") || "点击更换图片"}
  595. </p>
  596. </div>
  597. </div>
  598. ) : (
  599. // 显示上传提示
  600. <div>
  601. <UploadIcon className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
  602. <p className="text-muted-foreground">{t("dragDropOrClick")}</p>
  603. <p className="text-sm text-muted-foreground/75 mt-2">{t("fileFormatsSupported")}</p>
  604. </div>
  605. )}
  606. </div>
  607. ) : (
  608. // 多图上传
  609. <div className="space-y-4">
  610. <div
  611. onClick={handleMultiUploadClick}
  612. className="border-2 border-dashed border-muted-foreground/25 rounded-lg p-8 text-center hover:border-primary/50 transition-colors cursor-pointer min-h-[150px] flex items-center justify-center"
  613. >
  614. <input
  615. ref={multiFileInputRef}
  616. type="file"
  617. accept=".jpg,.jpeg,.png,.webp,.avif"
  618. onChange={handleMultiImageUpload}
  619. className="hidden"
  620. multiple
  621. id="multi-image-upload"
  622. />
  623. <div>
  624. <UploadIcon className="w-12 h-12 text-muted-foreground mx-auto mb-4" />
  625. <p className="text-muted-foreground">{t("dragDropMultiImages")}</p>
  626. <p className="text-sm text-muted-foreground/75 mt-2">{t("multiImageFormatsSupported")}</p>
  627. </div>
  628. </div>
  629. {/* 已上传文件列表 */}
  630. {uploadedFiles.length > 0 && (
  631. <div className="space-y-2">
  632. <div className="flex items-center justify-between">
  633. <span className="text-sm font-medium">{t("uploadedImagesCount", { count: uploadedFiles.length })}</span>
  634. <Button variant="outline" size="sm" onClick={clearMultiImages}>
  635. {t("clearAllImages")}
  636. </Button>
  637. </div>
  638. <div className="grid grid-cols-2 md:grid-cols-3 gap-4">
  639. {uploadedFiles.map((file, index) => (
  640. <div key={`${file.name}-${file.size}-${file.lastModified}`} className="relative group">
  641. <div className="aspect-square bg-gray-100 rounded-lg overflow-hidden">
  642. <Image
  643. src={getFileUrl(file)}
  644. alt={`Upload ${index + 1}`}
  645. width={150}
  646. height={150}
  647. className="w-full h-full object-cover"
  648. />
  649. </div>
  650. <Button
  651. variant="destructive"
  652. size="sm"
  653. className="absolute -top-2 -right-2 h-6 w-6 rounded-full p-0 opacity-0 group-hover:opacity-100 transition-opacity"
  654. onClick={() => removeMultiImage(index)}
  655. >
  656. <X className="h-3 w-3" />
  657. </Button>
  658. <p className="text-xs text-center mt-1 truncate">
  659. {file.name}
  660. </p>
  661. </div>
  662. ))}
  663. </div>
  664. </div>
  665. )}
  666. </div>
  667. )}
  668. </CardContent>
  669. </Card>
  670. {/* Prompt Input */}
  671. <Card>
  672. <CardContent className="p-6">
  673. <h3 className="text-lg font-semibold mb-4">{t("describeChange")}</h3>
  674. <Input
  675. value={prompt}
  676. onChange={(e) => setPrompt(e.target.value)}
  677. placeholder={editMode === 'single' ? t("promptPlaceholder") : t("multiImagePromptPlaceholder")}
  678. className="mb-4"
  679. />
  680. {/* Quick Keywords - 只在单图编辑模式下显示 */}
  681. {editMode === 'single' && (
  682. <div className="mb-4">
  683. <p className="text-sm text-muted-foreground mb-2">{t("quickKeywords")}</p>
  684. <div className="flex flex-wrap gap-2">
  685. {presetKeywords.map((keyword) => (
  686. <Badge
  687. key={keyword.key}
  688. variant="secondary"
  689. className="cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
  690. onClick={() => handleKeywordClick(keyword.prompt)}
  691. >
  692. {keyword.label}
  693. </Badge>
  694. ))}
  695. </div>
  696. </div>
  697. )}
  698. {/* Aspect Ratio Selection */}
  699. <div className="mb-6">
  700. <label className="text-sm font-medium mb-2 block">{t("aspectRatioLabel")}</label>
  701. <Select value={aspectRatio} onValueChange={setAspectRatio}>
  702. <SelectTrigger>
  703. <SelectValue placeholder={t("aspectRatioPlaceholder")} />
  704. </SelectTrigger>
  705. <SelectContent>
  706. {aspectRatios.map((ratio) => (
  707. <SelectItem key={ratio.value} value={ratio.value}>
  708. {ratio.label}
  709. </SelectItem>
  710. ))}
  711. </SelectContent>
  712. </Select>
  713. </div>
  714. {/* Error Message */}
  715. {error && (
  716. <div className="mb-4 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
  717. <p className="text-sm text-red-800 dark:text-red-200">{error}</p>
  718. </div>
  719. )}
  720. {/* Generate Button */}
  721. <Button
  722. onClick={handleProcess}
  723. disabled={isProcessing ||
  724. (editMode === 'single' && (!uploadedFile || !prompt.trim())) ||
  725. (editMode === 'multi' && (uploadedFiles.length === 0 || !prompt.trim()))
  726. }
  727. className="w-full bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700"
  728. size="lg"
  729. >
  730. {isProcessing ? (
  731. <>
  732. <LoaderIcon className="w-5 h-5 mr-2 animate-spin" />
  733. {t("processing")}
  734. </>
  735. ) : (
  736. <>
  737. <SparklesIcon className="w-5 h-5 mr-2" />
  738. {editMode === 'single' ? t("generateImage") : t("generateImage")}
  739. </>
  740. )}
  741. </Button>
  742. </CardContent>
  743. </Card>
  744. </div>
  745. {/* Right Side - Result */}
  746. <div>
  747. <Card className="h-full">
  748. <CardContent className="p-6">
  749. <h3 className="text-lg font-semibold mb-4">{t("result")}</h3>
  750. {editMode === 'single' ? (
  751. // 单图结果显示
  752. <>
  753. {isProcessing ? (
  754. <div className="text-center py-12">
  755. <LoaderIcon className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
  756. <p className="text-muted-foreground">{t("processingImage")}</p>
  757. <p className="text-sm text-muted-foreground mt-2">{t("processingTime")}</p>
  758. </div>
  759. ) : generatedImage ? (
  760. <div className="space-y-4">
  761. <div className="flex items-center justify-between">
  762. <h4 className="text-md font-semibold">{t("editResult")}</h4>
  763. </div>
  764. <div className="aspect-square bg-gray-100 rounded-lg overflow-hidden">
  765. <Image
  766. src={generatedImage}
  767. alt={t("editResult")}
  768. width={400}
  769. height={400}
  770. className="w-full h-full object-cover"
  771. />
  772. </div>
  773. <Button
  774. onClick={handleDownload}
  775. className="w-full bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700"
  776. size="lg"
  777. >
  778. <DownloadIcon className="w-4 h-4 mr-2" />
  779. {t("downloadEditResult")}
  780. </Button>
  781. <div className="flex items-start space-x-2 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
  782. <AlertTriangleIcon className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
  783. <p className="text-sm text-amber-800 dark:text-amber-200">
  784. {t("downloadReminder")}
  785. </p>
  786. </div>
  787. {showDownloadSuccess && (
  788. <div className="p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
  789. <p className="text-sm text-green-800 dark:text-green-200 text-center">
  790. ✅ {t("downloadSuccess")}
  791. </p>
  792. </div>
  793. )}
  794. </div>
  795. ) : (
  796. <div className="text-center py-12">
  797. <ImageIcon className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
  798. <p className="text-muted-foreground">{t("resultWillAppearHere")}</p>
  799. <p className="text-sm text-muted-foreground mt-2">{t("uploadAndDescribe")}</p>
  800. </div>
  801. )}
  802. </>
  803. ) : (
  804. // 多图结果显示
  805. <>
  806. {isProcessing ? (
  807. <div className="text-center py-12">
  808. <LoaderIcon className="w-12 h-12 text-primary animate-spin mx-auto mb-4" />
  809. <p className="text-muted-foreground">{t("processingMultiImages", { count: uploadedFiles.length })}</p>
  810. <p className="text-sm text-muted-foreground mt-2">{t("processingTime")}</p>
  811. </div>
  812. ) : generatedImages.length > 0 ? (
  813. <div className="space-y-4">
  814. <div className="flex items-center justify-between">
  815. <h4 className="text-md font-semibold">{t("editResult")}</h4>
  816. </div>
  817. <div className="aspect-square bg-gray-100 rounded-lg overflow-hidden">
  818. <Image
  819. src={generatedImages[0]}
  820. alt={t("editResult")}
  821. width={400}
  822. height={400}
  823. className="w-full h-full object-cover"
  824. />
  825. </div>
  826. <Button
  827. onClick={() => downloadMultiImage(generatedImages[0], 0)}
  828. className="w-full bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700"
  829. size="lg"
  830. >
  831. <DownloadIcon className="w-4 h-4 mr-2" />
  832. {t("downloadEditResult")}
  833. </Button>
  834. <div className="flex items-start space-x-2 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
  835. <AlertTriangleIcon className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
  836. <p className="text-sm text-amber-800 dark:text-amber-200">
  837. {t("downloadReminder")}
  838. </p>
  839. </div>
  840. </div>
  841. ) : (
  842. <div className="text-center py-12">
  843. <ImageIcon className="w-16 h-16 text-muted-foreground mx-auto mb-4" />
  844. <p className="text-muted-foreground">{t("resultWillAppearHere")}</p>
  845. <p className="text-sm text-muted-foreground mt-2">{t("uploadMultiAndDescribe")}</p>
  846. </div>
  847. )}
  848. </>
  849. )}
  850. </CardContent>
  851. </Card>
  852. </div>
  853. </div>
  854. </div>
  855. </div>
  856. </section>
  857. )
  858. }