| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395 |
- import { useEffect, useState } from 'react'
- import { Link } from 'react-router-dom'
- import { Helmet } from 'react-helmet-async'
- import type { TemplateEntity } from '../../../services/api/contracts'
- import {
- deleteTemplate,
- deleteTemplates,
- getTemplateDashboardSummary,
- listTemplates,
- type TemplateDashboardSummary,
- } from '../../../services/api/templates'
- import { usePageContext } from '../../../shared/pageContext'
- import { DashboardLayout } from '../../../layouts/DashboardLayout'
- const initialSummary: TemplateDashboardSummary = {
- total: 0,
- published: 0,
- weeklyUsed: 0,
- draft: 0,
- }
- const FALLBACK_TEMPLATE_IMAGE = '/images/aitori-simulation-result.png'
- const PAGE_SIZE = 8
- export function TemplateListPage() {
- const { locale, path, copy } = usePageContext()
- const [items, setItems] = useState<TemplateEntity[]>([])
- const [summary, setSummary] = useState<TemplateDashboardSummary>(initialSummary)
- const [selectedIds, setSelectedIds] = useState<string[]>([])
- const [query, setQuery] = useState('')
- const [category, setCategory] = useState('')
- const [status, setStatus] = useState('')
- const [page, setPage] = useState(1)
- const [pageSize] = useState(PAGE_SIZE)
- const [total, setTotal] = useState(0)
- const [loading, setLoading] = useState(false)
- const pageCopy = copy.templateListPage
- useEffect(() => {
- getTemplateDashboardSummary().then(setSummary).catch(() => setSummary(initialSummary))
- }, [])
- useEffect(() => {
- let cancelled = false
- const load = async () => {
- setLoading(true)
- try {
- const result = await listTemplates({
- q: query.trim(),
- category,
- status,
- page,
- pageSize,
- })
- if (cancelled) return
- setItems(result.items)
- setTotal(result.total)
- setSelectedIds((current) => current.filter((id) => result.items.some((item) => item.id === id)))
- } catch {
- if (cancelled) return
- setItems([])
- setTotal(0)
- setSelectedIds([])
- } finally {
- if (!cancelled) {
- setLoading(false)
- }
- }
- }
- void load()
- return () => {
- cancelled = true
- }
- }, [category, page, pageSize, query, status])
- const toggleSelect = (templateId: string) => {
- setSelectedIds((current) =>
- current.includes(templateId)
- ? current.filter((id) => id !== templateId)
- : [...current, templateId],
- )
- }
- const allCurrentPageSelected =
- items.length > 0 && items.every((item) => selectedIds.includes(item.id))
- const handleToggleSelectAllCurrentPage = () => {
- const currentPageIds = items.map((item) => item.id)
- setSelectedIds((current) => {
- if (allCurrentPageSelected) {
- return current.filter((id) => !currentPageIds.includes(id))
- }
- return Array.from(new Set([...current, ...currentPageIds]))
- })
- }
- const handleDelete = async (templateId: string) => {
- const template = items.find((item) => item.id === templateId)
- if (!window.confirm(`确认删除模板「${template?.name || ''}」吗?`)) return
- await deleteTemplate(templateId)
- setItems((current) => current.filter((item) => item.id !== templateId))
- setSelectedIds((current) => current.filter((id) => id !== templateId))
- setTotal((current) => Math.max(0, current - 1))
- }
- const handleBulkDelete = async () => {
- if (selectedIds.length === 0) return
- if (!window.confirm(`确认删除已选中的 ${selectedIds.length} 个模板吗?`)) return
- const deletingIds = [...selectedIds]
- await deleteTemplates(deletingIds)
- setItems((current) => current.filter((item) => !deletingIds.includes(item.id)))
- setSelectedIds([])
- setTotal((current) => Math.max(0, current - deletingIds.length))
- }
- const getGenerationBadge = (template: TemplateEntity) => {
- if (template.makeTaskStatus === 'WAIT' || template.generationStatus === 'pending') {
- return pageCopy.statusBadgeMap?.pending || '排队中'
- }
- if (
- template.makeTaskStatus === 'PROCESSING' ||
- template.makeTaskStatus === 'STEP1_SUCCESS' ||
- template.generationStatus === 'processing'
- ) {
- return pageCopy.statusBadgeMap?.processing || '生成中'
- }
- if (template.makeTaskStatus === 'FAILED' || template.generationStatus === 'failed') {
- return pageCopy.statusBadgeMap?.failed || '生成失败'
- }
- return null
- }
- const getTemplateTarget = (template: TemplateEntity) => {
- if (template.makeTaskId) {
- return path(`/templates/tasks/${template.makeTaskId}`)
- }
- return null
- }
- const getCardImage = (template: TemplateEntity) => {
- const isProcessing =
- template.makeTaskStatus === 'WAIT'
- || template.makeTaskStatus === 'PROCESSING'
- || template.makeTaskStatus === 'STEP1_SUCCESS'
- || template.generationStatus === 'pending'
- || template.generationStatus === 'processing'
- if (isProcessing) {
- return template.referenceImage || template.image || FALLBACK_TEMPLATE_IMAGE
- }
- return template.image || FALLBACK_TEMPLATE_IMAGE
- }
- const totalPages = Math.max(1, Math.ceil(total / pageSize))
- const pages = Array.from({ length: totalPages }, (_, index) => index + 1)
- return (
- <DashboardLayout>
- <Helmet>
- <html lang={locale === 'ja' ? 'ja' : 'zh-CN'} />
- <title>{`Aitori Stylist | ${pageCopy.title}`}</title>
- <meta name="description" content={pageCopy.description} />
- </Helmet>
- <section className="stylist-workbench-top ipad-top-space-margin">
- <div className="container">
- <div className="row align-items-end justify-content-between mb-4">
- <div className="col-lg-7">
- <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>
- <h1 className="fw-700 text-dark-gray ls-minus-1px mb-10px">{pageCopy.title}</h1>
- <p className="fs-18 lh-30 mb-0">{pageCopy.lead}</p>
- </div>
- <div className="col-lg-4 text-lg-end mt-3 mt-lg-0">
- <Link to={path('/templates/new')} className="btn btn-extra-large btn-rounded btn-base-color btn-box-shadow fw-700">
- <i className="feather icon-feather-plus-circle me-8px"></i>{pageCopy.newTemplate}
- </Link>
- </div>
- </div>
- <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-25px mb-4 template-list-filter-panel">
- <div className="row g-3 align-items-end template-list-filter-grid">
- <div className="col-lg-5">
- <label htmlFor="template-search" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.searchLabel}</label>
- <div className="customer-search-field">
- <i className="feather icon-feather-search"></i>
- <input
- id="template-search"
- type="search"
- className="form-control"
- placeholder={pageCopy.filters.searchPlaceholder}
- value={query}
- onChange={(event) => {
- setQuery(event.target.value)
- setPage(1)
- }}
- />
- </div>
- </div>
- <div className="col-lg-3 col-md-6">
- <label htmlFor="category-filter" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.categoryLabel}</label>
- <select
- id="category-filter"
- className="form-select customer-filter-control"
- value={category}
- onChange={(event) => {
- setCategory(event.target.value)
- setPage(1)
- }}
- >
- {pageCopy.filters.categoryOptions.map((option: { value: string; label: string }) => (
- <option key={option.value} value={option.value}>{option.label}</option>
- ))}
- </select>
- </div>
- <div className="col-lg-3 col-md-6">
- <label htmlFor="status-filter" className="fw-700 text-dark-gray mb-10px">{pageCopy.filters.statusLabel}</label>
- <select
- id="status-filter"
- className="form-select customer-filter-control"
- value={status}
- onChange={(event) => {
- setStatus(event.target.value)
- setPage(1)
- }}
- >
- {pageCopy.filters.statusOptions.map((option: { value: string; label: string }) => (
- <option key={option.value} value={option.value}>{option.label}</option>
- ))}
- </select>
- </div>
- </div>
- </div>
- <div className="row row-cols-2 row-cols-lg-4 row-cols-md-4 g-3 mb-4">
- <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>
- <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>
- <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>
- <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>
- </div>
- </div>
- </section>
- <section className="pt-0">
- <div className="container">
- <div className="workbench-panel bg-white border-radius-8px box-shadow-extra-large p-30px template-list-results-panel">
- <div className="d-flex align-items-center justify-content-between flex-wrap gap-2 mb-25px template-list-results-header">
- <div>
- <span className="fs-14 fw-700 text-base-color text-uppercase">{pageCopy.resultBadge}</span>
- <h2 className="fw-700 text-medium-gray fs-26 mb-0">{pageCopy.resultTitle.replace('{{count}}', String(total))}</h2>
- </div>
- <div className="d-flex align-items-center gap-2 template-list-results-actions">
- <button
- type="button"
- onClick={handleToggleSelectAllCurrentPage}
- disabled={items.length === 0}
- className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700"
- >
- {allCurrentPageSelected
- ? pageCopy.deselectAll || '取消全选'
- : pageCopy.selectAll || '全选'}
- </button>
- <button
- type="button"
- onClick={handleBulkDelete}
- disabled={selectedIds.length === 0}
- className="btn btn-small btn-rounded btn-base-color fw-700"
- >
- {pageCopy.bulkDelete || '批量删除'}
- </button>
- <a href="#" className="btn btn-small btn-rounded btn-transparent-light-gray border border-1 border-color-dark-gray fw-700">{pageCopy.sortRecent}</a>
- </div>
- </div>
- <div className="template-grid">
- {items.map((template) => {
- const target = getTemplateTarget(template)
- const generationBadge = getGenerationBadge(template)
- const cardContent = (
- <>
- <div className="template-card-image">
- {target ? (
- <Link to={target} className="template-card-image-link" aria-label="查看模板封面">
- <img src={getCardImage(template)} alt={template.name} />
- </Link>
- ) : (
- <div className="template-card-image-link" aria-disabled="true">
- <img src={getCardImage(template)} alt={template.name} />
- </div>
- )}
- <div className="template-card-image-selection">
- <input
- type="checkbox"
- checked={selectedIds.includes(template.id)}
- aria-label={`${pageCopy.select || '选择'}${template.name}`}
- onChange={() => toggleSelect(template.id)}
- className="template-card-checkbox"
- />
- </div>
- {generationBadge ? (
- <div className="template-card-image-overlay">
- <span className="template-status-badge">{generationBadge}</span>
- </div>
- ) : null}
- </div>
- <div className="template-card-content">
- <div className="template-card-main">
- <div className="template-card-meta">
- <span>{pageCopy.categoryMap[template.category as keyof typeof pageCopy.categoryMap] || template.category}</span>
- <span>{pageCopy.statusMap[template.status as keyof typeof pageCopy.statusMap] || template.status}</span>
- </div>
- {target ? (
- <Link to={target} className="template-card-title-link" aria-label={template.name}>
- <h3>{template.name}</h3>
- </Link>
- ) : (
- <h3>{template.name}</h3>
- )}
- <p>{template.description}</p>
- </div>
- <div className="template-card-footer">
- <span>{pageCopy.usedCount.replace('{{count}}', String(template.usedCount))}</span>
- <div className="template-card-footer-actions">
- <button
- type="button"
- className="template-card-delete-action"
- aria-label={`${pageCopy.delete || '删除'}${template.name}`}
- onClick={() => void handleDelete(template.id)}
- >
- {pageCopy.delete || '删除'}
- </button>
- </div>
- </div>
- </div>
- </>
- )
- return (
- <article
- key={template.id}
- className={`template-card${target ? '' : ' is-static'}${template.status === 'DRAFT' || generationBadge ? ' is-muted' : ''}`}
- >
- <div className="template-card-shell">{cardContent}</div>
- </article>
- )
- })}
- </div>
- {!loading && items.length === 0 ? <p className="fs-15 text-medium-gray mb-0">{copy.common.empty}</p> : null}
- <div className="customer-pager">
- <button
- type="button"
- className={`customer-pager-link${page === 1 ? ' is-disabled' : ''}`}
- aria-label={pageCopy.pagination.prev}
- disabled={page === 1}
- onClick={() => setPage((current) => Math.max(1, current - 1))}
- >
- {pageCopy.pagination.prev}
- </button>
- {pages.map((pageNumber) => (
- <button
- key={pageNumber}
- type="button"
- className={`customer-pager-link${page === pageNumber ? ' is-active' : ''}`}
- aria-current={page === pageNumber ? 'page' : undefined}
- onClick={() => setPage(pageNumber)}
- >
- {pageNumber}
- </button>
- ))}
- <button
- type="button"
- className={`customer-pager-link${page === totalPages ? ' is-disabled' : ''}`}
- aria-label={pageCopy.pagination.next}
- disabled={page === totalPages}
- onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
- >
- {pageCopy.pagination.next}
- </button>
- </div>
- </div>
- </div>
- </section>
- </DashboardLayout>
- )
- }
|