SimulationResultPage.tsx 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114
  1. import { useEffect, useState } from 'react'
  2. import { Link, useParams } from 'react-router-dom'
  3. import { Helmet } from 'react-helmet-async'
  4. import type { AiHairSimulationDetail } from '../../../services/api/contracts'
  5. import { getSimulationById } from '../../../services/api/simulations'
  6. import { usePageContext } from '../../../shared/pageContext'
  7. import { DEFAULT_USER_AVATAR_URL, LOADING_ANIMATION_URL } from '../../../shared/uiAssets'
  8. import { demoSimulationResults, resolveLocalizedTemplateName } from '../../../shared/demoContent'
  9. import { ImageLightbox } from '../../../components/common/ImageLightbox'
  10. import { DashboardLayout } from '../../../layouts/DashboardLayout'
  11. function formatDisplayDate(input: string) {
  12. const date = new Date(input)
  13. if (Number.isNaN(date.getTime())) return input
  14. return `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`
  15. }
  16. const demoDetail: AiHairSimulationDetail = {
  17. id: '10',
  18. createdAt: '2026-07-03T23:52:00',
  19. customer: { id: '1', name: '山田', avatar: DEFAULT_USER_AVATAR_URL, phone: '', expectContact: 'NONE' },
  20. originImage: '/images/mvp-simulation-result-10/source-photo.jpg',
  21. simulateRemark: null,
  22. totalTaskCount: 3,
  23. processingTaskCount: 0,
  24. successTaskCount: 3,
  25. failedTaskCount: 0,
  26. overallStatus: 'SUCCESSES',
  27. tasks: demoSimulationResults.map((item, index) => ({
  28. id: item.id,
  29. templateId: item.id,
  30. templateName: item.title,
  31. templateCoverUrl: item.image,
  32. templateCategory: 'MEDIUM',
  33. templateSource: 'SYSTEM',
  34. taskId: `demo-${index}`,
  35. taskStatus: 'SUCCESSES',
  36. resultImages: [item.image],
  37. errorMsg: null,
  38. taskStartTime: '2026-07-03T23:52:00',
  39. taskFinishTime: '2026-07-03T23:52:10',
  40. })),
  41. }
  42. export function SimulationResultPage() {
  43. const { simulationId = '' } = useParams()
  44. const { locale, path, copy } = usePageContext()
  45. const pageCopy = copy.simulationResultPage
  46. const [detail, setDetail] = useState<AiHairSimulationDetail | null>(simulationId === '10' ? demoDetail : null)
  47. const [loading, setLoading] = useState(simulationId !== '10')
  48. const [error, setError] = useState('')
  49. const [preview, setPreview] = useState<{ image: string; alt: string } | null>(null)
  50. useEffect(() => {
  51. if (simulationId === '10') {
  52. setDetail(demoDetail)
  53. setLoading(false)
  54. return undefined
  55. }
  56. let cancelled = false
  57. let timer: number | undefined
  58. const load = async () => {
  59. try {
  60. const data = await getSimulationById(simulationId)
  61. if (cancelled) return
  62. setDetail(data)
  63. setError('')
  64. setLoading(false)
  65. if (data.overallStatus === 'PROCESSING') timer = window.setTimeout(() => void load(), 5000)
  66. } catch (loadError) {
  67. if (cancelled) return
  68. setError(loadError instanceof Error ? loadError.message : pageCopy.loadFailed)
  69. setLoading(false)
  70. }
  71. }
  72. void load()
  73. return () => {
  74. cancelled = true
  75. if (timer) window.clearTimeout(timer)
  76. }
  77. }, [pageCopy.loadFailed, simulationId])
  78. const backTarget = detail?.customer ? path(`/customers/${detail.customer.id}`) : path('/simulation')
  79. const customerName = simulationId === '10' ? pageCopy.demoCustomerName || detail?.customer?.name : detail?.customer?.name
  80. return (
  81. <DashboardLayout>
  82. <Helmet><html lang={locale === 'ja' ? 'ja' : locale === 'en' ? 'en' : 'zh-CN'} /><title>{`Aitori Stylist | ${pageCopy.title}`}</title></Helmet>
  83. <section className="stylist-workbench-top ipad-top-space-margin mb-2"><div className="container"><div className="d-flex align-items-center justify-content-between flex-wrap gap-3 mb-4"><Link to={backTarget} className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"><i className="feather icon-feather-arrow-left me-6px"></i>{pageCopy.back}</Link></div></div></section>
  84. <section className="pt-0"><div className="container">
  85. {error ? <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px mb-3"><p role="alert" className="text-red mb-0">{error}</p></div> : null}
  86. {loading ? <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px"><div className="simulation-loading-state"><img src={LOADING_ANIMATION_URL} alt={pageCopy.card.processing} className="simulation-loading-gif" /><p className="mb-0 text-medium-gray">{pageCopy.loading}</p></div></div> : null}
  87. {detail ? <div className="simulation-result-layout gallery-box">
  88. <aside className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px">
  89. <h1 className="fw-700 text-dark-gray fs-24 mb-20px">{pageCopy.original.title}</h1>
  90. <button type="button" className="simulation-original-card aitori-image-button" onClick={() => setPreview({ image: detail.originImage || DEFAULT_USER_AVATAR_URL, alt: pageCopy.original.title })}><img className="simulation-result-image" src={detail.originImage || DEFAULT_USER_AVATAR_URL} alt={pageCopy.original.title} /></button>
  91. <div className="template-detail-summary mt-20px"><div><span>{pageCopy.original.customer}</span><strong>{customerName || pageCopy.anonymousTitle}</strong></div><div><span>{pageCopy.original.generatedAt}</span><strong>{formatDisplayDate(detail.createdAt)}</strong></div><div><span>{pageCopy.original.count}</span><strong>{pageCopy.original.countValue.replace('{{count}}', String(detail.totalTaskCount))}</strong></div></div>
  92. </aside>
  93. <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px">
  94. <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px"><h2 className="fw-700 text-dark-gray fs-26 mb-0">{pageCopy.results.title}</h2><span className="simulation-count-badge">{pageCopy.results.count.replace('{{count}}', String(detail.totalTaskCount))}</span></div>
  95. <div className="simulation-result-grid">{detail.tasks.map((task) => {
  96. const image = task.resultImages[0] || task.templateCoverUrl || '/images/aitori-simulation-result.png'
  97. const templateName = task.templateSource === 'SYSTEM'
  98. ? resolveLocalizedTemplateName(task.templateName, task.templateNameI18n, locale, copy.systemTemplateNames)
  99. : resolveLocalizedTemplateName(task.templateName, task.templateNameI18n, locale)
  100. return <article key={task.id} className="simulation-result-card"><button type="button" className="aitori-image-button" disabled={task.taskStatus === 'PROCESSING'} onClick={() => setPreview({ image, alt: templateName })}><img className="simulation-result-image" src={image} alt={templateName} /></button><div className="simulation-result-body"><h3>{templateName}</h3><span className="template-status-badge">{task.templateSource === 'SYSTEM' ? pageCopy.source.system : pageCopy.source.stylist}</span>{task.taskStatus === 'PROCESSING' ? <img src={LOADING_ANIMATION_URL} alt={pageCopy.card.loadingAlt || '生成中'} className="simulation-result-card-loading" /> : null}<p>{task.taskStatus === 'FAILED' ? task.errorMsg || pageCopy.card.failed : task.taskStatus === 'PROCESSING' ? pageCopy.card.processing : pageCopy.card.success}</p></div></article>
  101. })}</div>
  102. </div>
  103. </div> : null}
  104. </div></section>
  105. <ImageLightbox image={preview?.image || null} alt={preview?.alt} onClose={() => setPreview(null)} />
  106. </DashboardLayout>
  107. )
  108. }