diff --git a/backend/app/main.py b/backend/app/main.py index f5e3e08..80dfd63 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1480,6 +1480,11 @@ def _sync_ask_task_status(task: AskTask) -> None: def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict: questions = sorted(task.questions, key=lambda value: value.sequence_no) selected_questions = [q for q in questions if q.selected] + question_claimants = { + item.question_id: assignment.user + for assignment in task.assignments + for item in assignment.items + } # Keep every submission batch as an independent child row. Falling back # to the assignment row preserves compatibility with legacy assignments # created before batch submissions were introduced. @@ -1542,7 +1547,24 @@ def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict: # the ambiguous placeholder "--". Claimed child rows still expose # the actual answer user's public ID. "user_id": task.owner.display_name if task.owner else "运营后台", "phone": "", "questions": [], - "selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": "", "product_title": task.product_name, "source": q.source or "AI生成"} for q in selected_questions] if include_questions else []), + "selected_questions": ([{ + "id": q.id, + "sequence_no": q.sequence_no, + "question": q.question_text, + "answer": "", + "product_title": task.product_name, + "source": q.source or "AI生成", + "user_id": ( + _public_user_id(question_claimants[q.id]) + if q.id in question_claimants + else "" + ), + "phone": ( + _public_user_account(question_claimants[q.id]) + if q.id in question_claimants + else "" + ), + } for q in selected_questions] if include_questions else []), # One user can contribute several submission batches. Keep the # parent detail summary as a unique-user list; the child rows below # still expose every batch independently. @@ -1774,6 +1796,20 @@ def _start_ask_reservation(db: Session, task: AskTask, assignment: AskAssignment @app.get("/api/ask/tasks/{task_no}") def public_ask_task(task_no: str, token: str, refresh: bool = False, db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) + completed_task = db.scalar(select(AskTask).where( + (AskTask.public_token == task_no) | (AskTask.task_no == task_no), + AskTask.status == "已完成", + )) + if completed_task: + completed_assignment = db.scalar(select(AskAssignment).where( + AskAssignment.task_id == completed_task.id, + AskAssignment.user_id == user.id, + )) + if completed_assignment: + raise HTTPException( + status_code=409, + detail="本次提问任务已全部完成,可查看历史状态", + ) task = _published_ask_task(task_no, db, lock=True) current_assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) current_expired = _ask_assignment_expired(current_assignment) diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index c69170c..b00775d 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -946,6 +946,12 @@ def test_approved_answer_appears_as_pending_refund_with_detail(monkeypatch): assert refunded.status_code == 200 assert refunded.json()["data"]["status_label"] == "已完成" assert refunded.json()["data"]["refund_endpoint"] is None + user_detail = client.get( + f"/api/answer/me/submissions/{created['id']}?token={token}&owner_token={owner_token}" + ) + assert user_detail.status_code == 200 + assert user_detail.json()["data"]["status"] == "已完成" + assert len(user_detail.json()["data"]["refund_screenshots"]) == 1 board_after_refund = task_board_items(client, page_size=100) completed_payout = next( item @@ -1128,3 +1134,31 @@ def test_ask_batches_survive_reservation_timeout_and_parent_completes_only_after assert completed_parent["status_label"] == "已完成" assert completed_parent["completed_count"] == 2 assert len(completed_parent["claimed_tasks"]) == 2 + parent_detail = client.get(completed_parent["detail_endpoint"]) + assert parent_detail.status_code == 200 + assert { + question["user_id"] + for question in parent_detail.json()["data"]["selected_questions"] + } == {session["user_id"]} + + completed_link = client.get(task_url) + assert completed_link.status_code == 409 + assert completed_link.json()["detail"] == "本次提问任务已全部完成,可查看历史状态" + history = client.get( + f"/api/ask/me/assignments?token={session['token']}" + ) + assert history.status_code == 200 + completed_batches = [ + item for item in history.json()["data"] + if item["task_id"] == generated["task_id"] and item["status"] == "已完成" + ] + assert len(completed_batches) == 2 + + other_session = client.post( + "/api/ask/auth/taobao-login", + json={"taobao_id": f"tb{uuid4().hex[:14]}"}, + ).json()["data"] + unrelated_user = client.get( + f"/api/ask/tasks/{generated['task_id']}?token={other_session['token']}" + ) + assert unrelated_user.status_code == 404 diff --git a/frontend/src/AnswerUserPortal.jsx b/frontend/src/AnswerUserPortal.jsx index 7e28b51..1bc9168 100644 --- a/frontend/src/AnswerUserPortal.jsx +++ b/frontend/src/AnswerUserPortal.jsx @@ -1,5 +1,5 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' -import { Check, ClipboardCheck, Clock3, Copy, FileCheck2, History, ImagePlus, LogOut, MessageCircle, RefreshCw, ShieldCheck } from 'lucide-react' +import { Check, ClipboardCheck, Clock3, Copy, Eye, FileCheck2, History, ImagePlus, LogOut, MessageCircle, ReceiptText, RefreshCw, ShieldCheck } from 'lucide-react' const SESSION_KEY = 'answer_user_taobao_session' const STATUS_TEXT = { '待筛选': '截图审核中', '识别失败': '运营处理中', '待运营派发': '截图审核中', '已派发': '截图审核中', '待用户完成': '待完成回答', '待审核': '完成审核中', '已通过': '已完成', '已完成': '已返款', '未通过': '审核不通过', '已撤销': '任务已撤销' } @@ -121,15 +121,16 @@ function FinalStage({ latest, reload, busy, reopen, startNew }) { } -function HistoryPage({ records, close, onContinue }) { +function HistoryPage({ records, close, onOpen, openingId }) { 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) && }
)}
+ 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 { 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 [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 @@ -182,13 +183,34 @@ export default function AnswerUserPortalV2({ api, linkToken = '' }) { 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 ownerQuery = linkToken ? `&owner_token=${encodeURIComponent(linkToken)}` : '' - const result = await api(`/api/answer/me/submissions/${record.id}?token=${encodeURIComponent(session.token)}${ownerQuery}`) - setRecords(current => current.map(item => item.id === record.id ? { ...item, ...result.data, _imagesLoaded: true } : item)) - } catch (err) { imageLoadIds.current.delete(loadKey); setError(err.message) } + 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 @@ -212,9 +234,9 @@ export default function AnswerUserPortalV2({ api, linkToken = '' }) { }, [visibleLatest?.id, visibleLatest?.status, historyOpen, newTaskMode]) useEffect(() => { if (!historyOpen) return - records.slice(0, 20).forEach(record => loadSubmissionImages(record)) + records.slice(0, 20).forEach(record => { loadSubmissionImages(record) }) }, [historyOpen, records.length]) if (!session) return - return
{!historyOpen && }
{error &&
{error}
}{!loaded ?
正在读取任务状态…
: historyOpen ? setHistoryOpen(false)} onContinue={record => { setActiveRecordId(record.id); setRetrying(record.status === '未通过'); setNewTaskMode(false); setHistoryOpen(false) }}/> : !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' }) }}/>}
{visibleLatest && }
+ 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' }) }}/>}
{visibleLatest && }
} diff --git a/frontend/src/AskUserPortal.jsx b/frontend/src/AskUserPortal.jsx index 6616ad9..123911c 100644 --- a/frontend/src/AskUserPortal.jsx +++ b/frontend/src/AskUserPortal.jsx @@ -14,6 +14,7 @@ import { const SESSION_KEY = "ask_user_taobao_session"; const EXHAUSTED_MESSAGE = "本次任务已被领取完,请关注下一个任务!"; +const COMPLETED_TASK_MESSAGE = "本次提问任务已全部完成,可查看历史状态"; const INVALID_TASK_MESSAGE = "提问任务链接无效、未发布或已结束"; const fileKey = (file) => `${file.name}-${file.size}-${file.lastModified}`; const accountName = (session) => @@ -192,6 +193,30 @@ function InvalidTask({ message, onReload }) { ); } +function CompletedTask({ session, onOpenHistory, busy, error }) { + return ( +
+
+
+ 淘宝问答运营官方承接通道 + 官方认证 +
+
+ + 任务已完成 +

本次提问任务已全部完成

+

当前任务的所有问题均已完成,您可以前往历史状态查看自己提交过的任务和审核进度。

+ 当前淘宝ID:{accountName(session)} + {error &&
{error}
} + +
+
+
+ ); +} + function ExpiredAssignment({ session, onReload }) { return (
@@ -762,14 +787,14 @@ function TaskStatusPage({ history, onView }) { ["待选择", "进行中", "待审核"].includes(item.status), ).length; const completed = history.filter((item) => - ["已通过", "未通过"].includes(item.status), + ["已通过", "已完成", "未通过"].includes(item.status), ).length; const filtered = history.filter( (item) => filter === "all" || (filter === "progress" ? ["待选择", "进行中", "待审核"].includes(item.status) - : ["已通过", "未通过"].includes(item.status)), + : ["已通过", "已完成", "未通过"].includes(item.status)), ); return (
@@ -801,7 +826,7 @@ function TaskStatusPage({ history, onView }) { ) : ( filtered.map((item) => (
@@ -829,8 +854,8 @@ function TaskStatusPage({ history, onView }) { {item.status === "待审核" ? "凭证已锁定,专员核验中" - : item.status === "已通过" - ? "审核已通过" + : ["已通过", "已完成"].includes(item.status) + ? item.status === "已完成" ? "任务已完成" : "审核已通过" : item.status === "未通过" ? "需要重新提交" : "等待选择问题"} @@ -863,6 +888,7 @@ export default function AskUserPortal({ api }) { const [claimEdit, setClaimEdit] = useState(false); const [exhausted, setExhausted] = useState(false); const [invalidTask, setInvalidTask] = useState(""); + const [completedTask, setCompletedTask] = useState(false); const [expiredAssignment, setExpiredAssignment] = useState(false); const [selectionExpired, setSelectionExpired] = useState(false); @@ -877,6 +903,7 @@ export default function AskUserPortal({ api }) { setAssignment(result.data); setExhausted(false); setInvalidTask(""); + setCompletedTask(false); setExpiredAssignment(false); setSelectionExpired(false); // Keep the current view. In particular, polling a pending review must @@ -901,6 +928,11 @@ export default function AskUserPortal({ api }) { setExhausted(true); setInvalidTask(""); setError(""); + } else if (requestError.message === COMPLETED_TASK_MESSAGE) { + setAssignment(null); + setCompletedTask(true); + setInvalidTask(""); + setError(""); } else if (isInvalidTaskError(requestError.message)) { setAssignment(null); setInvalidTask(requestError.message); @@ -918,14 +950,18 @@ export default function AskUserPortal({ api }) { } } async function loadHistory() { + setBusy(true); try { const result = await api( `/api/ask/me/assignments?token=${encodeURIComponent(session.token)}`, ); setHistory(result.data); setView("status"); + setError(""); } catch (requestError) { setError(requestError.message); + } finally { + setBusy(false); } } useEffect(() => { @@ -944,12 +980,15 @@ export default function AskUserPortal({ api }) { setAssignment(null); setExhausted(false); setInvalidTask(""); + setCompletedTask(false); setExpiredAssignment(false); setSelectionExpired(false); }; if (!session) return ; if (exhausted) return ; + if (completedTask && view !== "status" && !assignment) + return ; if (invalidTask) return loadAssignment()} />; if (expiredAssignment) diff --git a/frontend/src/TaskBoardPage.jsx b/frontend/src/TaskBoardPage.jsx index 4b9f55b..f71c495 100644 --- a/frontend/src/TaskBoardPage.jsx +++ b/frontend/src/TaskBoardPage.jsx @@ -281,8 +281,8 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll className={`task-detail-cell${index ? " row-border" : ""}`} style={{ gridColumn: 3, gridRow: index + 1 }} > - {task.user_id} - {task.phone && task.phone !== task.user_id && {maskPhone(task.phone)}} + {question.user_id || "--"} + {question.phone && question.phone !== question.user_id && {maskPhone(question.phone)}}