251 lines
28 KiB
JavaScript
251 lines
28 KiB
JavaScript
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { Check, ClipboardCheck, Clock3, Copy, Eye, FileCheck2, History, ImagePlus, LogOut, MessageCircle, ReceiptText, RefreshCw, ShieldCheck } from 'lucide-react'
|
||
import { isValidTaobaoId, normalizeTaobaoId, TAOBAO_ID_HINT } from './taobaoId.js'
|
||
import { formatDateTime } from './time.js'
|
||
|
||
const SESSION_KEY = 'answer_user_taobao_session_v2'
|
||
const LEGACY_SESSION_KEYS = ['answer_user_taobao_session', 'answer_user_session']
|
||
const STATUS_TEXT = { '待筛选': '截图审核中', '识别失败': '运营处理中', '待运营派发': '截图审核中', '已派发': '截图审核中', '待用户完成': '待完成回答', '待审核': '完成审核中', '已通过': '已完成', '已完成': '已返款', '未通过': '审核不通过', '已撤销': '任务已撤销' }
|
||
const accountName = session => session?.taobao_id || session?.account || session?.user_id || ''
|
||
const formatTime = formatDateTime
|
||
const fileKey = file => `${file.name}-${file.size}-${file.lastModified}`
|
||
|
||
function Top({ session, latest, logout }) {
|
||
return <>
|
||
<header className="answer-v2-top"><div><ShieldCheck size={14}/><strong>淘宝问答运营官方承接通道</strong><em>官方认证</em></div><span>ⓘ 规则说明</span></header>
|
||
<section className="answer-v2-user"><div><b>淘宝问答承接</b><span>● {latest ? STATUS_TEXT[latest.status] || latest.status : '待认领'}</span></div><p>淘宝ID:{accountName(session)}{latest?.status === '待用户完成' && <> 派发任务编号:<strong>{latest.submission_no}</strong></>}</p><button title="退出当前用户" onClick={logout}><LogOut size={15}/></button></section>
|
||
</>
|
||
}
|
||
|
||
function Progress({ status }) {
|
||
const active = !status ? 1 : ['待筛选', '识别失败', '待运营派发', '已派发'].includes(status) ? 2 : ['待用户完成', '未通过'].includes(status) ? 3 : 4
|
||
const labels = ['被邀截图', '任务派发', '完成任务', '终审确认']
|
||
return <div className="answer-v2-progress">{labels.map((label, index) => { const step = index + 1; const done = step < active || (active === 4 && ['已通过', '已完成'].includes(status)); return <div key={label} className={step === active ? 'active' : done ? 'done' : ''}><b>{done ? <Check size={15}/> : step}</b><span>{label}</span></div> })}</div>
|
||
}
|
||
|
||
function Login({ api, onLogin, linkToken }) {
|
||
const [taobaoId, setTaobaoId] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState('')
|
||
async function submit(event) {
|
||
event.preventDefault(); setError('')
|
||
const account = normalizeTaobaoId(taobaoId)
|
||
if (!isValidTaobaoId(account)) return setError(TAOBAO_ID_HINT)
|
||
setBusy(true)
|
||
try { const result = await api('/api/answer/auth/taobao-login', { method: 'POST', body: JSON.stringify({ taobao_id: account, owner_token: linkToken || undefined }) }); localStorage.setItem(SESSION_KEY, JSON.stringify(result.data)); onLogin(result.data) }
|
||
catch (err) { setError(err.message) } finally { setBusy(false) }
|
||
}
|
||
return <main className="answer-v2-shell"><section className="answer-v2-app login-only"><header className="answer-v2-top"><div><ShieldCheck size={14}/><strong>淘宝问答运营官方承接通道</strong><em>官方认证</em></div></header><form className="answer-v2-login" onSubmit={submit}><span><MessageCircle size={28}/></span><h1>淘宝账号登录</h1><p>输入淘宝ID即可进入任务中心</p><label>淘宝ID<input value={taobaoId} onChange={event => setTaobaoId(event.target.value.slice(0, 64))} maxLength={64} inputMode="text" lang="zh-CN" autoCapitalize="none" autoCorrect="off" placeholder="例如:tb1008611 或 淘宝用户" autoFocus/><small className="user-login-notice">为了运营更好地核对信息,请填写真实的淘宝ID;支持中文、字母和数字</small></label>{error && <div className="answer-v2-error">{error}</div>}<button disabled={busy}>{busy ? '登录中…' : '登录并进入任务中心'}</button></form></section></main>
|
||
}
|
||
|
||
function UploadStage({ api, session, onUploaded, setError, linkToken }) {
|
||
const [files, setFiles] = useState([]); const [busy, setBusy] = useState(false); const inputRef = useRef(null)
|
||
const previews = useMemo(() => files.map(file => ({ file, url: URL.createObjectURL(file) })), [files])
|
||
useEffect(() => () => previews.forEach(item => URL.revokeObjectURL(item.url)), [previews])
|
||
function addFiles(list) {
|
||
const images = Array.from(list).filter(file => /^image\/(jpeg|png|webp)$/.test(file.type))
|
||
if (images.some(file => file.size > 10 * 1024 * 1024)) setError('单张图片不能超过10MB')
|
||
const valid = images.filter(file => file.size <= 10 * 1024 * 1024)
|
||
setFiles(current => {
|
||
const next = [...current, ...valid].filter((file, index, all) => all.findIndex(row => fileKey(row) === fileKey(file)) === index)
|
||
if (next.length > 2) setError('一次最多上传2张截图')
|
||
return next.slice(0, 2)
|
||
})
|
||
}
|
||
async function submit() {
|
||
if (!files.length) return setError('请至少上传一张被邀请回答截图')
|
||
setBusy(true); setError(''); const body = new FormData(); body.append('token', session.token); files.forEach(file => body.append('files', file))
|
||
if (linkToken) body.append('owner_token', linkToken)
|
||
try { await api('/api/answer/me/submissions', { method: 'POST', body }); await onUploaded() } catch (err) { setError(err.message) } finally { setBusy(false) }
|
||
}
|
||
return <><section className="answer-v2-hero purple"><small>✨ 任务认领与核验</small><h1>上传被邀请回答截图</h1><p>提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。</p></section><section className="answer-v2-card upload-card"><header><div><h2>上传被邀请回答截图 <em>截图 · 必传</em></h2><p>一次最多上传2张。为了提高问题识别的准确率,请每张图片少于十个问题。</p></div><b>{files.length}/2</b></header><div className="answer-v2-upload-grid">{previews.map(({ file, url }) => <figure key={fileKey(file)}><img src={url} alt={file.name}/><figcaption>{file.name}</figcaption><button onClick={() => setFiles(current => current.filter(row => fileKey(row) !== fileKey(file)))}>删除</button></figure>)}{files.length < 2 && <button type="button" className="answer-v2-add" onClick={() => inputRef.current?.click()}><ImagePlus size={28}/><strong>+ 上传被邀截图</strong><small>支持 JPG、PNG、WEBP</small></button>}</div><input ref={inputRef} hidden type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => addFiles(event.target.files)}/><p className="answer-v2-photo-note">▧ 请确保凭证清晰、信息完整、无遮挡;单张不超过10MB</p></section><button className="answer-v2-primary wide" disabled={busy || !files.length} onClick={submit}>{busy ? '正在提交…' : '提交截图并进入审核'}</button></>
|
||
}
|
||
|
||
function MaterialCard({ latest, completion = false }) {
|
||
const images = completion ? latest.completion_screenshots : latest.invited_screenshots
|
||
return <section className="answer-v2-card material-card"><header><h2><FileCheck2 size={16}/>{completion ? '已提交任务凭证(只读)' : '已提交核验材料(只读)'}</h2><span>♙ 已锁定审核</span></header><div>{images.map((image, index) => { const src = image.url || image.data_url; return <figure key={image.id || src}><img src={src} alt={`${completion ? '完成凭证' : '被邀截图'}${index + 1}`}/><figcaption>◉ {completion ? '完成截图' : `被邀截图${index + 1}`}</figcaption></figure> })}</div></section>
|
||
}
|
||
|
||
function ReviewStage({ latest, reload, busy, startNew }) {
|
||
return <><section className="answer-v2-card status-card"><div className="status-title"><span><Clock3 size={24}/></span><div><h1>已进入任务派发审核 <em>截图审核中</em></h1><p>截图已提交,任务派发审核界面已创建。运营正在核验截图并整理可回答问题,识别完成后会自动显示在后台。</p></div></div><div className="status-meta"><span>提交时间<b>{formatTime(latest.created_at)}</b></span><span>当前状态<b>等待运营审核</b></span></div><footer><small>页面每 10 秒自动检查最新审核结果</small><button onClick={() => reload()} disabled={busy}><RefreshCw size={14} className={busy ? 'spin' : ''}/>{busy ? '刷新中…' : '刷新状态'}</button></footer></section><MaterialCard latest={latest}/><section className="answer-v2-wait"><ShieldCheck size={17}/><div><strong>等待运营确认并派发问题</strong><p>截图已进入审核队列,无需停留等待识别完成;问题整理好后会自动开放任务。</p></div></section><section className="answer-v2-new-task answer-v2-new-task-light"><strong>还可以继续提交新的任务</strong><p>当前任务会继续在“任务状态”中自动跟进,新的截图将生成独立任务。</p><button onClick={startNew}>+ 开始新的任务</button></section></>
|
||
}
|
||
|
||
function AssignmentStage({ api, session, latest, reload, setError, linkToken }) {
|
||
const selected = latest.questions.filter(question => question.selected); const progressKey = `answer_progress_${latest.id}`
|
||
const [done, setDone] = useState(() => { try { return JSON.parse(localStorage.getItem(progressKey) || '[]') } catch { return [] } })
|
||
const [tab, setTab] = useState('list'); const [files, setFiles] = useState([]); const [busy, setBusy] = useState(false)
|
||
const [alipayAccount, setAlipayAccount] = useState(''); const [payeeName, setPayeeName] = useState('')
|
||
const completed = selected.filter(question => done.includes(question.id)).length
|
||
useEffect(() => {
|
||
if (latest.status !== '未通过' || !latest.latest_review) return
|
||
const reason = [latest.latest_review.reason_code, latest.latest_review.reason_text].filter(Boolean).join(';')
|
||
if (reason) setError(`审核不通过原因:${reason}`)
|
||
}, [latest.id, latest.status, latest.latest_review, setError])
|
||
function saveDone(next) { setDone(next); localStorage.setItem(progressKey, JSON.stringify(next)) }
|
||
function mark(id) { saveDone(done.includes(id) ? done.filter(value => value !== id) : [...done, id]) }
|
||
async function copy(question) { try { await navigator.clipboard.writeText(question.answer || ''); if (!done.includes(question.id)) saveDone([...done, question.id]) } catch { setError('复制失败,请长按参考回答手动复制') } }
|
||
async function complete(event) {
|
||
event.preventDefault(); setError(''); if (!files.length) return setError('请至少上传一张完成回答截图')
|
||
if (!alipayAccount.trim() || !payeeName.trim()) return setError('请填写支付宝账号和收款人姓名')
|
||
const body = new FormData(); body.append('token', session.token); if (linkToken) body.append('owner_token', linkToken); files.forEach(file => body.append('files', file)); setBusy(true)
|
||
body.append('alipay_account', alipayAccount.trim()); body.append('payee_name', payeeName.trim())
|
||
try { const result = await api(`/api/answer/me/submissions/${latest.id}/completion`, { method: 'POST', body }); await reload(result.data) } catch (err) { setError(err.message) } finally { setBusy(false) }
|
||
}
|
||
return <><section className="answer-v2-hero green"><small>✓ 任务已派发 · 完成任务阶段</small><h1>完成问答并上传任务凭证</h1><p>请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传任务截图并填写收款信息。</p></section><div className="answer-v2-inner-tabs"><button className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>▣ 待回答清单 <small>{completed}/{selected.length}</small></button><button className={tab === 'upload' ? 'active' : ''} onClick={() => setTab('upload')}>✣ 任务上传</button></div>{tab === 'list' ? <section className="answer-v2-card assignment-list"><header><div><h2>待回答问题清单</h2><p>点击复制参考回答即可前往淘宝粘贴提交</p></div><span>回答进度 <b>{completed}/{selected.length}</b><i><u style={{width:`${selected.length ? completed / selected.length * 100 : 0}%`}}/></i></span></header><div className="answer-v2-list-tip">完成所有问题后,进入“任务上传” <button onClick={() => saveDone(selected.map(q => q.id))}>一键全部标记</button></div>{selected.map((question, index) => <article key={question.id} className={done.includes(question.id) ? 'complete' : ''}><header><b>{String(index + 1).padStart(2, '0')}</b><h3>{question.question}</h3><button aria-label="标记完成" onClick={() => mark(question.id)}>{done.includes(question.id) ? <Check size={15}/> : ''}</button></header><small>#{question.product_title || '商品问题'}</small>{question.operator_images?.length > 0 && <div className="answer-question-operator-image"><span>运营指定图片(请在回答本题时添加)</span><div>{question.operator_images.map((image, imageIndex) => <img key={image.id || image.data_url || image} src={image.data_url || image.url || image} alt={`运营指定回答图片${imageIndex + 1}`}/>)}</div></div>}<div><strong>▱ 参考回答:</strong><p>{question.answer}</p></div><footer><span>{done.includes(question.id) ? '已复制并标记完成' : '待复制进入淘宝回答'}</span><button onClick={() => copy(question)}><Copy size={15}/>复制回答</button></footer></article>)}<button className="answer-v2-primary wide" disabled={!selected.length} onClick={() => setTab('upload')}>完成回答,进入任务上传</button></section> : <form className="answer-v2-card completion-form" onSubmit={complete}><h2>上传任务完成凭证</h2><label>支付宝账号(必填)<input required maxLength={128} value={alipayAccount} onChange={event => setAlipayAccount(event.target.value)} placeholder="请输入支付宝账号(手机号或邮箱)" /></label><label>收款人姓名(必填)<input required maxLength={64} value={payeeName} onChange={event => setPayeeName(event.target.value)} placeholder="请输入支付宝实名姓名" /></label><p>请上传已发布回答的截图,并填写准确的支付宝账号及收款人姓名。</p><label>完成回答截图(必传)<input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => setFiles(Array.from(event.target.files).slice(0, 10))}/><small>已选择 {files.length} 张</small></label><button className="answer-v2-primary wide" disabled={busy || !files.length}>{busy ? '提交中…' : '提交任务凭证'}</button></form>}</>
|
||
}
|
||
|
||
function FinalStage({ latest, reload, busy, reopen, startNew }) {
|
||
const paid = latest.status === '已完成'
|
||
const refundProofs = latest.refund_screenshots || []
|
||
const passed = ['已通过', '已完成'].includes(latest.status)
|
||
const rejected = latest.status === '未通过'
|
||
const cancelled = latest.status === '已撤销'
|
||
const reason = latest.latest_review && !latest.latest_review.approved
|
||
? [latest.latest_review.reason_code, latest.latest_review.reason_text].filter(Boolean).join(';')
|
||
: ''
|
||
if (rejected) return <>
|
||
<section className="answer-v2-card answer-v2-rejected">
|
||
<div className="reject-title"><span>!</span><div><h1>审核未通过 · 需重做 <em>需修正</em></h1><p>请根据下方运营反馈的具体不通过原因重新核对问答,补齐截图后重新提交审核。</p></div></div>
|
||
<div className="reject-reason"><strong>▣ 审核不通过原因:</strong><p>{reason || '完成凭证不符合任务要求,请重新提交。'}</p></div>
|
||
<button onClick={reopen}>↻ 重新完成任务(开放修改)</button>
|
||
</section>
|
||
</>
|
||
if (latest.status === '待审核') return <>
|
||
<section className="answer-v2-card status-card">
|
||
<div className="status-title"><span><Clock3 size={24}/></span><div><h1>完成凭证审核中 <em>{STATUS_TEXT[latest.status]}</em></h1><p>您提交的回答完成截图正在审核,请耐心等待运营确认。</p></div></div>
|
||
<footer><small>提交编号:{latest.submission_no}</small><button onClick={() => reload()} disabled={busy}><RefreshCw size={14} className={busy ? 'spin' : ''}/>{busy ? '刷新中…' : '刷新状态'}</button></footer>
|
||
</section>
|
||
{latest.completion_screenshots?.length > 0 && <MaterialCard latest={latest} completion/>}
|
||
<section className="answer-v2-new-task answer-v2-new-task-light"><strong>还可以开始新的任务</strong><p>当前任务会继续在“任务状态”中自动跟进,新的截图将生成独立任务。</p><button onClick={startNew}>+ 开始新的任务</button></section>
|
||
</>
|
||
return <>
|
||
<section className={`answer-v2-card status-card ${passed ? 'passed' : cancelled ? 'rejected' : ''}`}>
|
||
<div className="status-title"><span>{passed ? <Check size={25}/> : cancelled ? '!' : <Clock3 size={24}/>}</span><div><h1>{paid ? '已返款' : passed ? '审核已完成' : cancelled ? '任务已撤销' : '完成凭证审核中'} <em>{STATUS_TEXT[latest.status]}</em></h1><p>{paid ? '运营已完成本次任务返款,请留意您的收款账户。' : passed ? '运营已审核通过您提交的任务凭证,当前进入打款流程,请耐心等待打款。' : cancelled ? '运营已撤销本次任务,您无需继续提交。' : '您提交的回答完成截图正在审核,请耐心等待运营确认。'}</p></div></div>
|
||
{passed && <div className={`answer-v2-payment-wait ${paid ? 'paid' : ''}`}><strong>{paid ? '返款已完成' : '审核已完成,等待打款'}</strong><span>{paid ? '款项已由运营确认发放,请留意收款账户。' : '款项将按运营安排发放,请留意收款账户。'}</span></div>}
|
||
{paid && refundProofs.length > 0 && <section className="answer-v2-card material-card refund-proof-user"><header><h2>返款凭证</h2><span>运营已上传</span></header><div>{refundProofs.map((image, index) => <figure key={image.id || image.data_url}><img src={image.data_url || image.url} alt={`返款凭证${index + 1}`} /><figcaption>返款支付截图</figcaption></figure>)}</div></section>}
|
||
<footer><small>提交编号:{latest.submission_no}</small><button onClick={() => reload()} disabled={busy}><RefreshCw size={14} className={busy ? 'spin' : ''}/>{busy ? '刷新中…' : '刷新状态'}</button></footer>
|
||
</section>
|
||
{latest.completion_screenshots?.length > 0 && <MaterialCard latest={latest} completion/>}
|
||
{(passed || cancelled) && <section className="answer-v2-new-task"><strong>{passed ? '本次任务已完成' : '本次任务已结束'}</strong><p>您可以返回第一步,上传新的被邀请回答截图并开始下一项任务。原任务仍保留在上传历史中。</p><button onClick={startNew}>+ 开始新的任务</button></section>}
|
||
</>
|
||
}
|
||
|
||
function HistoryPage({ records, close, onOpen, openingId }) {
|
||
const canContinue = status => ['待筛选', '识别失败', '待运营派发', '已派发', '待用户完成', '未通过'].includes(status)
|
||
const actionText = record => record.status === '已完成' ? '查看返款凭证' : canContinue(record.status) ? '继续此任务' : '查看任务详情'
|
||
return <section className="answer-v2-history"><header><button onClick={close}>← 返回任务</button><h2>任务状态 / 上传历史({records.length})</h2></header>{records.length === 0 ? <div className="answer-v2-card">暂无历史记录</div> : records.map(record => <article className="answer-v2-card" key={record.id}><header><strong>{record.submission_no}</strong><span>{STATUS_TEXT[record.status] || record.status}</span></header><p>{formatTime(record.created_at)} · 识别问题 {record.questions.length} 条 · 截图 {record.invited_screenshots.length} 张</p><div>{record.invited_screenshots.slice(0, 4).map(image => { const src = image.url || image.data_url; return <img key={image.id || src} src={src} alt="被邀请回答截图"/> })}</div><button className="answer-v2-history-continue" onClick={() => onOpen(record)} disabled={openingId === record.id}>{record.status === '已完成' ? <ReceiptText size={14}/> : <Eye size={14}/>} {openingId === record.id ? '正在读取…' : actionText(record)}</button></article>)}</section>
|
||
}
|
||
|
||
export default function AnswerUserPortalV2({ api, linkToken = '' }) {
|
||
const [session, setSession] = useState(() => { try { LEGACY_SESSION_KEYS.forEach(key => localStorage.removeItem(key)); return JSON.parse(localStorage.getItem(SESSION_KEY) || 'null') } catch { return null } })
|
||
const [records, setRecords] = useState([]); const [error, setError] = useState(''); const [busy, setBusy] = useState(false); const [loaded, setLoaded] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [retrying, setRetrying] = useState(false); const [newTaskMode, setNewTaskMode] = useState(false); const [activeRecordId, setActiveRecordId] = useState(null); const [optimisticStatuses, setOptimisticStatuses] = useState({}); const [openingId, setOpeningId] = useState(null)
|
||
const imageLoadPromises = useRef(new Map())
|
||
const loadVersion = useRef(0)
|
||
async function load(silent = false) {
|
||
if (!session) return
|
||
const version = ++loadVersion.current
|
||
if (!silent) setBusy(true)
|
||
try {
|
||
const ownerQuery = linkToken ? `&owner_token=${encodeURIComponent(linkToken)}` : ''
|
||
const result = await api(`/api/answer/me/submissions?token=${encodeURIComponent(session.token)}&include_images=false${ownerQuery}`)
|
||
if (version !== loadVersion.current) return
|
||
setRecords(current => result.data.map(record => {
|
||
const previous = current.find(item => item.id === record.id)
|
||
return previous ? {
|
||
...previous,
|
||
...record,
|
||
// The polling endpoint is intentionally lightweight and returns
|
||
// null image fields. Preserve images already loaded from the detail
|
||
// endpoint so polling/clicking never makes task instructions vanish.
|
||
questions: record.questions.map(question => {
|
||
const oldQuestion = previous.questions?.find(item => item.id === question.id)
|
||
return {
|
||
...oldQuestion,
|
||
...question,
|
||
product_image: question.product_image || oldQuestion?.product_image || null,
|
||
source_image: question.source_image || oldQuestion?.source_image || null,
|
||
operator_images: question.operator_images?.length ? question.operator_images : (oldQuestion?.operator_images || []),
|
||
}
|
||
}),
|
||
invited_screenshots: record.invited_screenshots?.length ? record.invited_screenshots : previous.invited_screenshots,
|
||
completion_screenshots: record.completion_screenshots?.length ? record.completion_screenshots : previous.completion_screenshots,
|
||
payment_screenshots: record.payment_screenshots?.length ? record.payment_screenshots : previous.payment_screenshots,
|
||
refund_screenshots: record.refund_screenshots?.length ? record.refund_screenshots : previous.refund_screenshots,
|
||
} : record
|
||
}))
|
||
setOptimisticStatuses(current => {
|
||
const next = { ...current }
|
||
result.data.forEach(record => {
|
||
if (next[record.id] === record.status) delete next[record.id]
|
||
})
|
||
return next
|
||
})
|
||
setError('')
|
||
} catch (err) {
|
||
if (err.message.includes('登录已失效')) { logout(); return }
|
||
if (version === loadVersion.current) setError(err.message)
|
||
} finally {
|
||
if (version === loadVersion.current) { setLoaded(true); if (!silent) setBusy(false) }
|
||
}
|
||
}
|
||
useEffect(() => { if (!session) return undefined; load(); const timer = window.setInterval(() => load(true), 10000); return () => window.clearInterval(timer) }, [session?.token])
|
||
useEffect(() => { if (historyOpen) setError('') }, [historyOpen])
|
||
async function loadSubmissionImages(record) {
|
||
if (!record?.id) return
|
||
const loadKey = `${record.id}:${record.status}`
|
||
if (record._imagesLoadedFor === record.status) return record
|
||
const pending = imageLoadPromises.current.get(loadKey)
|
||
if (pending) return pending
|
||
const request = (async () => {
|
||
try {
|
||
const ownerQuery = linkToken ? `&owner_token=${encodeURIComponent(linkToken)}` : ''
|
||
const result = await api(`/api/answer/me/submissions/${record.id}?token=${encodeURIComponent(session.token)}${ownerQuery}`)
|
||
const detailed = { ...result.data, _imagesLoadedFor: result.data.status }
|
||
setRecords(current => current.map(item => item.id === record.id ? { ...item, ...detailed } : item))
|
||
return detailed
|
||
} catch (err) {
|
||
setError(err.message)
|
||
return null
|
||
} finally {
|
||
imageLoadPromises.current.delete(loadKey)
|
||
}
|
||
})()
|
||
imageLoadPromises.current.set(loadKey, request)
|
||
return request
|
||
}
|
||
async function openHistoryRecord(record) {
|
||
if (openingId !== null) return
|
||
setOpeningId(record.id); setError('')
|
||
const detailed = await loadSubmissionImages(record)
|
||
setOpeningId(null)
|
||
if (!detailed) return
|
||
setActiveRecordId(record.id); setRetrying(record.status === '未通过'); setNewTaskMode(false); setHistoryOpen(false)
|
||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||
}
|
||
const latestRecord = records.find(record => record.id === activeRecordId) || records[0]
|
||
const latest = latestRecord && optimisticStatuses[latestRecord.id] ? { ...latestRecord, status: optimisticStatuses[latestRecord.id] } : latestRecord
|
||
const logout = () => { localStorage.removeItem(SESSION_KEY); LEGACY_SESSION_KEYS.forEach(key => localStorage.removeItem(key)); setSession(null); setRecords([]); setLoaded(false); setOptimisticStatuses({}) }
|
||
function applySubmittedRecord(submitted) {
|
||
if (!submitted?.id) return
|
||
setOptimisticStatuses(current => ({ ...current, [submitted.id]: submitted.status }))
|
||
setRecords(current => current.map(record => record.id === submitted.id ? {
|
||
...record,
|
||
...submitted,
|
||
invited_screenshots: submitted.invited_screenshots?.length ? submitted.invited_screenshots : record.invited_screenshots,
|
||
completion_screenshots: submitted.completion_screenshots || [],
|
||
payment_screenshots: submitted.payment_screenshots || [],
|
||
refund_screenshots: submitted.refund_screenshots || [],
|
||
} : record))
|
||
}
|
||
const visibleLatest = newTaskMode ? null : latest
|
||
useEffect(() => {
|
||
if (!visibleLatest || historyOpen || newTaskMode) return
|
||
loadSubmissionImages(visibleLatest)
|
||
}, [visibleLatest?.id, visibleLatest?.status, historyOpen, newTaskMode])
|
||
useEffect(() => {
|
||
if (!historyOpen) return
|
||
records.slice(0, 20).forEach(record => { loadSubmissionImages(record) })
|
||
}, [historyOpen, records.length])
|
||
if (!session) return <Login api={api} onLogin={setSession} linkToken={linkToken}/>
|
||
return <main className="answer-v2-shell"><section className="answer-v2-app"><Top session={session} latest={visibleLatest} logout={logout}/><div className="answer-v2-tabs"><button className={!historyOpen ? 'active' : ''} onClick={() => setHistoryOpen(false)}><ClipboardCheck size={15}/>任务认领</button><button className={historyOpen ? 'active' : ''} onClick={() => setHistoryOpen(true)}><History size={15}/>任务状态 <small>{visibleLatest ? STATUS_TEXT[visibleLatest.status] || visibleLatest.status : '待上传'}</small></button></div>{!historyOpen && <Progress status={visibleLatest?.status}/>}<div className="answer-v2-body">{error && <div className="answer-v2-error">{error}</div>}{!loaded ? <div className="answer-v2-card answer-v2-loading">正在读取任务状态…</div> : historyOpen ? <HistoryPage records={records} close={() => setHistoryOpen(false)} onOpen={openHistoryRecord} openingId={openingId}/> : !visibleLatest ? <UploadStage api={api} session={session} linkToken={linkToken} onUploaded={async () => { setNewTaskMode(false); setActiveRecordId(null); await load() }} setError={setError}/> : ['待筛选', '识别失败', '待运营派发', '已派发'].includes(visibleLatest.status) ? <ReviewStage latest={visibleLatest} reload={load} busy={busy} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/> : visibleLatest.status === '待用户完成' || (visibleLatest.status === '未通过' && retrying) ? <AssignmentStage api={api} session={session} latest={visibleLatest} linkToken={linkToken} reload={async submitted => { if (submitted) { applySubmittedRecord(submitted); setRetrying(false); load(true); return } await load(); setRetrying(false) }} setError={setError}/> : <FinalStage latest={visibleLatest} reload={load} busy={busy} reopen={() => setRetrying(true)} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/>}</div><footer className="answer-v2-footer"><button onClick={() => setHistoryOpen(true)}>上传历史({records.length})</button>{visibleLatest && <button className="primary" onClick={() => load()}><RefreshCw size={14}/>刷新状态</button>}</footer></section></main>
|
||
}
|
||
|