213 lines
26 KiB
JavaScript
213 lines
26 KiB
JavaScript
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
||
import { Check, ClipboardCheck, Clock3, Copy, FileCheck2, History, ImagePlus, LogOut, MessageCircle, RefreshCw, ShieldCheck } from 'lucide-react'
|
||
|
||
const SESSION_KEY = 'answer_user_session'
|
||
const STATUS_TEXT = { '待筛选': '截图审核中', '待运营派发': '截图审核中', '已派发': '截图审核中', '待用户完成': '待完成回答', '待审核': '完成审核中', '已通过': '已完成', '已完成': '已返款', '未通过': '审核不通过', '已撤销': '任务已撤销' }
|
||
const maskPhone = (phone = '') => phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
|
||
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:{session.user_id} · {maskPhone(session.phone)}{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 }) {
|
||
const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); const [busy, setBusy] = useState(false); const [sending, setSending] = useState(false); const [cooldown, setCooldown] = useState(0); const [error, setError] = useState(''); const [devCode, setDevCode] = useState('')
|
||
useEffect(() => { if (!cooldown) return undefined; const timer = window.setInterval(() => setCooldown(value => Math.max(0, value - 1)), 1000); return () => window.clearInterval(timer) }, [cooldown])
|
||
async function sendCode() {
|
||
if (!/^1\d{10}$/.test(phone)) return setError('请输入正确的 11 位手机号码')
|
||
setSending(true); setError('')
|
||
try { const result = await api('/api/answer/auth/send-code', { method: 'POST', body: JSON.stringify({ phone }) }); setCooldown(result.data.retry_after || 60); setDevCode(result.data.dev_code || '') }
|
||
catch (err) { setError(err.message) } finally { setSending(false) }
|
||
}
|
||
async function submit(event) {
|
||
event.preventDefault(); setError('')
|
||
if (!/^1\d{10}$/.test(phone)) return setError('请输入正确的 11 位手机号码')
|
||
if (!/^\d{6}$/.test(code)) return setError('请输入6位验证码')
|
||
setBusy(true)
|
||
try { const result = await api('/api/answer/auth/phone-login', { method: 'POST', body: JSON.stringify({ phone, code }) }); 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>使用短信验证码安全登录</p><label>手机号码<input value={phone} onChange={event => setPhone(event.target.value.replace(/\D/g, '').slice(0, 11))} inputMode="numeric" placeholder="请输入 11 位手机号码" autoFocus/></label><label>短信验证码<div className="answer-v2-code-row"><input value={code} onChange={event => setCode(event.target.value.replace(/\D/g, '').slice(0, 6))} inputMode="numeric" placeholder="请输入6位验证码"/><button type="button" onClick={sendCode} disabled={sending || cooldown > 0}>{cooldown ? `${cooldown}s后重发` : sending ? '发送中…' : '获取验证码'}</button></div></label>{devCode && <small className="answer-v2-dev-code">开发环境验证码:{devCode}</small>}{error && <div className="answer-v2-error">{error}</div>}<button disabled={busy}>{busy ? '登录中…' : '登录并进入任务中心'}</button></form></section></main>
|
||
}
|
||
|
||
function UploadStage({ api, session, onUploaded, setError }) {
|
||
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)); setFiles(current => [...current, ...images].filter((file, index, all) => all.findIndex(row => fileKey(row) === fileKey(file)) === index).slice(0, 10)) }
|
||
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))
|
||
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>用于确认您的账号可回答指定问题(支持上传多张)。</p></div><b>{files.length}/10</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 < 10 && <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">▧ 请确保凭证清晰、信息完整、无遮挡</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 }) {
|
||
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 [payFiles, setPayFiles] = useState([]); const [busy, setBusy] = useState(false)
|
||
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 (!payFiles.length) return setError('请上传收款二维码后再提交')
|
||
const body = new FormData(); body.append('token', session.token); files.forEach(file => body.append('files', file)); payFiles.forEach(file => body.append('payment_files', file)); setBusy(true)
|
||
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><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><label>收款二维码(必传)<input type="file" accept="image/jpeg,image/png,image/webp" onChange={event => setPayFiles(Array.from(event.target.files).slice(0, 1))}/><small>已选择 {payFiles.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 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>}
|
||
<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, onContinue }) {
|
||
const canContinue = status => ['待筛选', '待运营派发', '已派发', '待用户完成', '未通过'].includes(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}/> })}</div>{canContinue(record.status) && <button className="answer-v2-history-continue" onClick={() => onContinue(record)}>↗ 继续此任务</button>}</article>)}</section>
|
||
}
|
||
|
||
export default function AnswerUserPortalV2({ api }) {
|
||
const [session, setSession] = useState(() => { try { 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 imageLoadIds = useRef(new Set())
|
||
const loadVersion = useRef(0)
|
||
async function load(silent = false) {
|
||
if (!session) return
|
||
const version = ++loadVersion.current
|
||
if (!silent) setBusy(true)
|
||
try {
|
||
const result = await api(`/api/answer/me/submissions?token=${encodeURIComponent(session.token)}&include_images=false`)
|
||
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,
|
||
} : 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 (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 (imageLoadIds.current.has(loadKey)) return
|
||
imageLoadIds.current.add(loadKey)
|
||
try {
|
||
const result = await api(`/api/answer/me/submissions/${record.id}?token=${encodeURIComponent(session.token)}`)
|
||
setRecords(current => current.map(item => item.id === record.id ? { ...item, ...result.data, _imagesLoaded: true } : item))
|
||
} catch (err) { imageLoadIds.current.delete(loadKey); setError(err.message) }
|
||
}
|
||
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); 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 || [],
|
||
} : 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}/>
|
||
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)} onContinue={record => { setActiveRecordId(record.id); setRetrying(record.status === '未通过'); setNewTaskMode(false); setHistoryOpen(false) }}/> : !visibleLatest ? <UploadStage api={api} session={session} 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} 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>
|
||
}
|
||
|