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
淘宝问答运营官方承接通道官方认证

淘宝账号登录

输入淘宝ID即可进入任务中心

{error &&
{error}
}
} 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 <>
✨ 任务认领与核验

上传被邀请回答截图

提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。

上传被邀请回答截图 截图 · 必传

一次最多上传2张。为了提高问题识别的准确率,请每张图片少于十个问题。

{files.length}/2
{previews.map(({ file, url }) =>
{file.name}/
{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
◉ {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) => {`运营指定回答图片${imageIndex)}
}
▱ 参考回答:

{question.answer}

{done.includes(question.id) ? '已复制并标记完成' : '待复制进入淘宝回答'}
)}
:

上传任务完成凭证

请上传已发布回答的截图,并填写准确的支付宝账号及收款人姓名。

} } 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.submission_no}
{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) =>
{`返款凭证${index
返款支付截图
)}
}
提交编号:{latest.submission_no}
{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 && }
{error &&
{error}
}{!loaded ?
正在读取任务状态…
: historyOpen ? setHistoryOpen(false)} onOpen={openHistoryRecord} openingId={openingId}/> : !visibleLatest ? { setNewTaskMode(false); setActiveRecordId(null); await load() }} setError={setError}/> : ['待筛选', '识别失败', '待运营派发', '已派发'].includes(visibleLatest.status) ? { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/> : visibleLatest.status === '待用户完成' || (visibleLatest.status === '未通过' && retrying) ? { if (submitted) { applySubmittedRecord(submitted); setRetrying(false); load(true); return } await load(); setRetrying(false) }} setError={setError}/> : setRetrying(true)} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/>}
}