TemplateListPage.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395
  1. import { useEffect, useState } from 'react'
  2. import { Link } from 'react-router-dom'
  3. import { Helmet } from 'react-helmet-async'
  4. import type { TemplateEntity } from '../../../services/api/contracts'
  5. import {
  6. deleteTemplate,
  7. deleteTemplates,
  8. getTemplateDashboardSummary,
  9. listTemplates,
  10. type TemplateDashboardSummary,
  11. } from '../../../services/api/templates'
  12. import { usePageContext } from '../../../shared/pageContext'
  13. import { DashboardLayout } from '../../../layouts/DashboardLayout'
  14. const initialSummary: TemplateDashboardSummary = {
  15. total: 0,
  16. published: 0,
  17. weeklyUsed: 0,
  18. draft: 0,
  19. }
  20. const FALLBACK_TEMPLATE_IMAGE = '/images/aitori-simulation-result.png'
  21. const PAGE_SIZE = 8
  22. export function TemplateListPage() {
  23. const { locale, path, copy } = usePageContext()
  24. const [items, setItems] = useState<TemplateEntity[]>([])
  25. const [summary, setSummary] = useState<TemplateDashboardSummary>(initialSummary)
  26. const [selectedIds, setSelectedIds] = useState<string[]>([])
  27. const [query, setQuery] = useState('')
  28. const [category, setCategory] = useState('')
  29. const [status, setStatus] = useState('')
  30. const [page, setPage] = useState(1)
  31. const [pageSize] = useState(PAGE_SIZE)
  32. const [total, setTotal] = useState(0)
  33. const [loading, setLoading] = useState(false)
  34. const pageCopy = copy.templateListPage
  35. useEffect(() => {
  36. getTemplateDashboardSummary().then(setSummary).catch(() => setSummary(initialSummary))
  37. }, [])
  38. useEffect(() => {
  39. let cancelled = false
  40. const load = async () => {
  41. setLoading(true)
  42. try {
  43. const result = await listTemplates({
  44. q: query.trim(),
  45. category,
  46. status,
  47. page,
  48. pageSize,
  49. })
  50. if (cancelled) return
  51. setItems(result.items)
  52. setTotal(result.total)
  53. setSelectedIds((current) => current.filter((id) => result.items.some((item) => item.id === id)))
  54. } catch {
  55. if (cancelled) return
  56. setItems([])
  57. setTotal(0)
  58. setSelectedIds([])
  59. } finally {
  60. if (!cancelled) {
  61. setLoading(false)
  62. }
  63. }
  64. }
  65. void load()
  66. return () => {
  67. cancelled = true
  68. }
  69. }, [category, page, pageSize, query, status])
  70. const toggleSelect = (templateId: string) => {
  71. setSelectedIds((current) =>
  72. current.includes(templateId)
  73. ? current.filter((id) => id !== templateId)
  74. : [...current, templateId],
  75. )
  76. }
  77. const allCurrentPageSelected =
  78. items.length > 0 && items.every((item) => selectedIds.includes(item.id))
  79. const handleToggleSelectAllCurrentPage = () => {
  80. const currentPageIds = items.map((item) => item.id)
  81. setSelectedIds((current) => {
  82. if (allCurrentPageSelected) {
  83. return current.filter((id) => !currentPageIds.includes(id))
  84. }
  85. return Array.from(new Set([...current, ...currentPageIds]))
  86. })
  87. }
  88. const handleDelete = async (templateId: string) => {
  89. const template = items.find((item) => item.id === templateId)
  90. if (!window.confirm(`确认删除模板「${template?.name || ''}」吗?`)) return
  91. await deleteTemplate(templateId)
  92. setItems((current) => current.filter((item) => item.id !== templateId))
  93. setSelectedIds((current) => current.filter((id) => id !== templateId))
  94. setTotal((current) => Math.max(0, current - 1))
  95. }
  96. const handleBulkDelete = async () => {
  97. if (selectedIds.length === 0) return
  98. if (!window.confirm(`确认删除已选中的 ${selectedIds.length} 个模板吗?`)) return
  99. const deletingIds = [...selectedIds]
  100. await deleteTemplates(deletingIds)
  101. setItems((current) => current.filter((item) => !deletingIds.includes(item.id)))
  102. setSelectedIds([])
  103. setTotal((current) => Math.max(0, current - deletingIds.length))
  104. }
  105. const getGenerationBadge = (template: TemplateEntity) => {
  106. if (template.makeTaskStatus === 'WAIT' || template.generationStatus === 'pending') {
  107. return pageCopy.statusBadgeMap?.pending || '排队中'
  108. }
  109. if (
  110. template.makeTaskStatus === 'PROCESSING' ||
  111. template.makeTaskStatus === 'STEP1_SUCCESS' ||
  112. template.generationStatus === 'processing'
  113. ) {
  114. return pageCopy.statusBadgeMap?.processing || '生成中'
  115. }
  116. if (template.makeTaskStatus === 'FAILED' || template.generationStatus === 'failed') {
  117. return pageCopy.statusBadgeMap?.failed || '生成失败'
  118. }
  119. return null
  120. }
  121. const getTemplateTarget = (template: TemplateEntity) => {
  122. if (template.makeTaskId) {
  123. return path(`/templates/tasks/${template.makeTaskId}`)
  124. }
  125. return null
  126. }
  127. const getCardImage = (template: TemplateEntity) => {
  128. const isProcessing =
  129. template.makeTaskStatus === 'WAIT'
  130. || template.makeTaskStatus === 'PROCESSING'
  131. || template.makeTaskStatus === 'STEP1_SUCCESS'
  132. || template.generationStatus === 'pending'
  133. || template.generationStatus === 'processing'
  134. if (isProcessing) {
  135. return template.referenceImage || template.image || FALLBACK_TEMPLATE_IMAGE
  136. }
  137. return template.image || FALLBACK_TEMPLATE_IMAGE
  138. }
  139. const totalPages = Math.max(1, Math.ceil(total / pageSize))
  140. const pages = Array.from({ length: totalPages }, (_, index) => index + 1)
  141. return (
  142. <DashboardLayout>
  143. <Helmet>
  144. <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
  145. <title>{`Aitori Stylist | ${pageCopy.title}`}</title>
  146. <meta name="description" content={pageCopy.description} />
  147. </Helmet>
  148. <section className="stylist-workbench-top ipad-top-space-margin">
  149. <div className="container">
  150. <div className="row align-items-end justify-content-between mb-4">
  151. <div className="col-lg-7">
  152. <span className="bg-base-color d-inline-block mb-15px fw-600 text-white text-uppercase border-radius-30px ps-20px pe-20px fs-12">{pageCopy.badge}</span>
  153. <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{pageCopy.title}</h1>
  154. <p className="fs-18 lh-30 mb-0">{pageCopy.lead}</p>
  155. </div>
  156. <div className="col-lg-4 text-lg-end mt-3 mt-lg-0">
  157. <Link to={path('/templates/new')} className="btn btn-extra-large btn-rounded btn-base-color btn-box-shadow fw-700">
  158. <i className="feather icon-feather-plus-circle me-8px"></i>{pageCopy.newTemplate}
  159. </Link>
  160. </div>
  161. </div>
  162. <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px mb-4 template-list-filter-panel">
  163. <div className="row g-3 align-items-end template-list-filter-grid">
  164. <div className="col-lg-6">
  165. <label htmlFor="template-search" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.searchLabel}</label>
  166. <div className="customer-search-field">
  167. <i className="feather icon-feather-search"></i>
  168. <input
  169. id="template-search"
  170. type="search"
  171. className="form-control"
  172. placeholder={pageCopy.filters.searchPlaceholder}
  173. value={query}
  174. onChange={(event) => {
  175. setQuery(event.target.value)
  176. setPage(1)
  177. }}
  178. />
  179. </div>
  180. </div>
  181. <div className="col-lg-3 col-md-4">
  182. <label htmlFor="category-filter" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.categoryLabel}</label>
  183. <select
  184. id="category-filter"
  185. className="form-select customer-filter-control"
  186. value={category}
  187. onChange={(event) => {
  188. setCategory(event.target.value)
  189. setPage(1)
  190. }}
  191. >
  192. {pageCopy.filters.categoryOptions.map((option: { value: string; label: string }) => (
  193. <option key={option.value} value={option.value}>{option.label}</option>
  194. ))}
  195. </select>
  196. </div>
  197. <div className="col-lg-3 col-md-4">
  198. <label htmlFor="status-filter" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.statusLabel}</label>
  199. <select
  200. id="status-filter"
  201. className="form-select customer-filter-control"
  202. value={status}
  203. onChange={(event) => {
  204. setStatus(event.target.value)
  205. setPage(1)
  206. }}
  207. >
  208. {pageCopy.filters.statusOptions.map((option: { value: string; label: string }) => (
  209. <option key={option.value} value={option.value}>{option.label}</option>
  210. ))}
  211. </select>
  212. </div>
  213. </div>
  214. </div>
  215. <div className="row row-cols-2 row-cols-lg-4 row-cols-md-4 g-3 mb-4">
  216. <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{summary.total}</span><span className="fs-14">{pageCopy.stats.total}</span></div></div>
  217. <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{summary.published}</span><span className="fs-14">{pageCopy.stats.published}</span></div></div>
  218. <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{summary.weeklyUsed}</span><span className="fs-14">{pageCopy.stats.weeklyUsed}</span></div></div>
  219. <div className="col"><div className="workbench-stat bg-white border-radius-8px p-20px"><span className="d-block fs-26 fw-800 text-dark-gray">{summary.draft}</span><span className="fs-14">{pageCopy.stats.draft}</span></div></div>
  220. </div>
  221. </div>
  222. </section>
  223. <section className="pt-0">
  224. <div className="container">
  225. <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px template-list-results-panel">
  226. <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px template-list-results-header">
  227. <div>
  228. <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.resultBadge}</span>
  229. <h2 className="fw-700 text-medium-gray fs-26 mb-0">{pageCopy.resultTitle.replace('{{count}}', String(total))}</h2>
  230. </div>
  231. <div className="d-flex flex-wrap gap-2 template-list-results-actions">
  232. <button
  233. type="button"
  234. onClick={handleToggleSelectAllCurrentPage}
  235. disabled={items.length === 0}
  236. className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
  237. >
  238. {allCurrentPageSelected
  239. ? pageCopy.deselectAll || '取消全选'
  240. : pageCopy.selectAll || '全选'}
  241. </button>
  242. <button
  243. type="button"
  244. onClick={handleBulkDelete}
  245. disabled={selectedIds.length === 0}
  246. className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
  247. >
  248. {pageCopy.bulkDelete || '批量删除'}
  249. </button>
  250. <a href="#" className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.sortRecent}</a>
  251. </div>
  252. </div>
  253. <div className="template-grid">
  254. {items.map((template) => {
  255. const target = getTemplateTarget(template)
  256. const generationBadge = getGenerationBadge(template)
  257. const cardContent = (
  258. <>
  259. <div className="template-card-image">
  260. {target ? (
  261. <Link to={target} className="template-card-image-link" aria-label="查看模板封面">
  262. <img src={getCardImage(template)} alt={template.name} />
  263. </Link>
  264. ) : (
  265. <div className="template-card-image-link" aria-disabled="true">
  266. <img src={getCardImage(template)} alt={template.name} />
  267. </div>
  268. )}
  269. <div className="template-card-image-selection">
  270. <input
  271. type="checkbox"
  272. checked={selectedIds.includes(template.id)}
  273. aria-label={`${pageCopy.select || '选择'}${template.name}`}
  274. onChange={() => toggleSelect(template.id)}
  275. className="template-card-checkbox"
  276. />
  277. </div>
  278. {generationBadge ? (
  279. <div className="template-card-image-overlay">
  280. <span className="template-status-badge">{generationBadge}</span>
  281. </div>
  282. ) : null}
  283. </div>
  284. <div className="template-card-content">
  285. <div className="template-card-main">
  286. <div className="template-card-meta">
  287. <span>{pageCopy.categoryMap[template.category as keyof typeof pageCopy.categoryMap] || template.category}</span>
  288. <span>{pageCopy.statusMap[template.status as keyof typeof pageCopy.statusMap] || template.status}</span>
  289. </div>
  290. {target ? (
  291. <Link to={target} className="template-card-title-link" aria-label={template.name}>
  292. <h3>{template.name}</h3>
  293. </Link>
  294. ) : (
  295. <h3>{template.name}</h3>
  296. )}
  297. <p>{template.description}</p>
  298. </div>
  299. <div className="template-card-footer">
  300. <span>{pageCopy.usedCount.replace('{{count}}', String(template.usedCount))}</span>
  301. <div className="template-card-footer-actions">
  302. <button
  303. type="button"
  304. className="template-card-delete-action"
  305. aria-label={`${pageCopy.delete || '删除'}${template.name}`}
  306. onClick={() => void handleDelete(template.id)}
  307. >
  308. {pageCopy.delete || '删除'}
  309. </button>
  310. </div>
  311. </div>
  312. </div>
  313. </>
  314. )
  315. return (
  316. <article
  317. key={template.id}
  318. className={`template-card${target ? '' : ' is-static'}${template.status === 'DRAFT' || generationBadge ? ' is-muted' : ''}`}
  319. >
  320. <div className="template-card-shell">{cardContent}</div>
  321. </article>
  322. )
  323. })}
  324. </div>
  325. {!loading && items.length === 0 ? <p className="fs-15 text-medium-gray mb-0">{copy.common.empty}</p> : null}
  326. <div className="customer-pager">
  327. <button
  328. type="button"
  329. className={`customer-pager-link${page === 1 ? ' is-disabled' : ''}`}
  330. aria-label={pageCopy.pagination.prev}
  331. disabled={page === 1}
  332. onClick={() => setPage((current) => Math.max(1, current - 1))}
  333. >
  334. {pageCopy.pagination.prev}
  335. </button>
  336. {pages.map((pageNumber) => (
  337. <button
  338. key={pageNumber}
  339. type="button"
  340. className={`customer-pager-link${page === pageNumber ? ' is-active' : ''}`}
  341. aria-current={page === pageNumber ? 'page' : undefined}
  342. onClick={() => setPage(pageNumber)}
  343. >
  344. {pageNumber}
  345. </button>
  346. ))}
  347. <button
  348. type="button"
  349. className={`customer-pager-link${page === totalPages ? ' is-disabled' : ''}`}
  350. aria-label={pageCopy.pagination.next}
  351. disabled={page === totalPages}
  352. onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
  353. >
  354. {pageCopy.pagination.next}
  355. </button>
  356. </div>
  357. </div>
  358. </div>
  359. </section>
  360. </DashboardLayout>
  361. )
  362. }