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 <>
淘宝问答承接● {latest ? STATUS_TEXT[latest.status] || latest.status : '待认领'}
用户ID:{session.user_id} · {maskPhone(session.phone)}{latest?.status === '待用户完成' && <> 派发任务编号:{latest.submission_no}>}
>
}
function Progress({ status }) {
const active = !status ? 1 : ['待筛选', '待运营派发', '已派发'].includes(status) ? 2 : ['待用户完成', '未通过'].includes(status) ? 3 : 4
const labels = ['被邀截图', '任务派发', '完成任务', '终审确认']
return
{labels.map((label, index) => { const step = index + 1; const done = step < active || (active === 4 && ['已通过', '已完成'].includes(status)); return
{done ? : step}{label}
})}
}
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
}
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 <>✨ 任务认领与核验上传被邀请回答截图
提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。
上传被邀请回答截图 截图 · 必传
用于确认您的账号可回答指定问题(支持上传多张)。
{files.length}/10{previews.map(({ file, url }) =>
{file.name})}{files.length < 10 &&
}
addFiles(event.target.files)}/>▧ 请确保凭证清晰、信息完整、无遮挡
>
}
function MaterialCard({ latest, completion = false }) {
const images = completion ? latest.completion_screenshots : latest.invited_screenshots
return {completion ? '已提交任务凭证(只读)' : '已提交核验材料(只读)'}
♙ 已锁定审核{images.map((image, index) => { const src = image.url || image.data_url; return
◉ {completion ? '完成截图' : `被邀截图${index + 1}`} })}
}
function ReviewStage({ latest, reload, busy, startNew }) {
return <>已进入任务派发审核 截图审核中
截图已提交,任务派发审核界面已创建。运营正在核验截图并整理可回答问题,识别完成后会自动显示在后台。
提交时间{formatTime(latest.created_at)}当前状态等待运营审核
等待运营确认并派发问题截图已进入审核队列,无需停留等待识别完成;问题整理好后会自动开放任务。
还可以继续提交新的任务当前任务会继续在“任务状态”中自动跟进,新的截图将生成独立任务。
>
}
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 <>✓ 任务已派发 · 完成任务阶段完成问答并上传任务凭证
请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。
{tab === 'list' ? 待回答问题清单
点击复制参考回答即可前往淘宝粘贴提交
回答进度 {completed}/{selected.length}完成所有问题后,进入“任务上传”
{selected.map((question, index) => {String(index + 1).padStart(2, '0')}{question.question}
#{question.product_title || '商品问题'}{question.operator_images?.length > 0 && 运营指定图片(请在回答本题时添加){question.operator_images.map((image, imageIndex) =>

)}
})} : }>
}
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 <>
!审核未通过 · 需重做 需修正
请根据下方运营反馈的具体不通过原因重新核对问答,补齐截图后重新提交审核。
▣ 审核不通过原因:{reason || '完成凭证不符合任务要求,请重新提交。'}
>
if (latest.status === '待审核') return <>
完成凭证审核中 {STATUS_TEXT[latest.status]}
您提交的回答完成截图正在审核,请耐心等待运营确认。
{latest.completion_screenshots?.length > 0 && }
还可以开始新的任务当前任务会继续在“任务状态”中自动跟进,新的截图将生成独立任务。
>
return <>
{passed ? : cancelled ? '!' : }{paid ? '已返款' : passed ? '审核已完成' : cancelled ? '任务已撤销' : '完成凭证审核中'} {STATUS_TEXT[latest.status]}
{paid ? '运营已完成本次任务返款,请留意您的收款账户。' : passed ? '运营已审核通过您提交的任务凭证,当前进入打款流程,请耐心等待打款。' : cancelled ? '运营已撤销本次任务,您无需继续提交。' : '您提交的回答完成截图正在审核,请耐心等待运营确认。'}
{passed && {paid ? '返款已完成' : '审核已完成,等待打款'}{paid ? '款项已由运营确认发放,请留意收款账户。' : '款项将按运营安排发放,请留意收款账户。'}
}
{latest.completion_screenshots?.length > 0 && }
{(passed || cancelled) && {passed ? '本次任务已完成' : '本次任务已结束'}您可以返回第一步,上传新的被邀请回答截图并开始下一项任务。原任务仍保留在上传历史中。
}
>
}
function HistoryPage({ records, close, onContinue }) {
const canContinue = status => ['待筛选', '待运营派发', '已派发', '待用户完成', '未通过'].includes(status)
return 任务状态 / 上传历史({records.length})
{records.length === 0 ? 暂无历史记录
: records.map(record => {record.submission_no}{STATUS_TEXT[record.status] || record.status}{formatTime(record.created_at)} · 识别问题 {record.questions.length} 条 · 截图 {record.invited_screenshots.length} 张
{record.invited_screenshots.slice(0, 4).map(image => { const src = image.url || image.data_url; return
![]()
})}
{canContinue(record.status) && })}
}
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
return {!historyOpen &&
}