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 <>
淘宝问答承接● {latest ? STATUS_TEXT[latest.status] || latest.status : '待认领'}
淘宝ID:{accountName(session)}{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, 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
}
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 <>✨ 任务认领与核验上传被邀请回答截图
提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。
{previews.map(({ file, url }) =>
{file.name})}{files.length < 2 &&
}
addFiles(event.target.files)}/>▧ 请确保凭证清晰、信息完整、无遮挡;单张不超过10MB
>
}
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, 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 <>✓ 任务已派发 · 完成任务阶段完成问答并上传任务凭证
请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传任务截图并填写收款信息。
{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 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 <>
!审核未通过 · 需重做 需修正
请根据下方运营反馈的具体不通过原因重新核对问答,补齐截图后重新提交审核。
▣ 审核不通过原因:{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 ? '款项已由运营确认发放,请留意收款账户。' : '款项将按运营安排发放,请留意收款账户。'}
}
{paid && refundProofs.length > 0 && {refundProofs.map((image, index) =>
返款支付截图)}
}
{latest.completion_screenshots?.length > 0 && }
{(passed || cancelled) && {passed ? '本次任务已完成' : '本次任务已结束'}您可以返回第一步,上传新的被邀请回答截图并开始下一项任务。原任务仍保留在上传历史中。
}
>
}
function HistoryPage({ records, close, onOpen, openingId }) {
const canContinue = status => ['待筛选', '识别失败', '待运营派发', '已派发', '待用户完成', '未通过'].includes(status)
const actionText = record => record.status === '已完成' ? '查看返款凭证' : canContinue(record.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
![被邀请回答截图]()
})}
)}
}
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
return {!historyOpen &&
}