修复新字段不同步任务详情的bug,新增回答任务消息图标

This commit is contained in:
db_huang 2026-11-05 13:59:09 +08:00
parent 561a34f108
commit bd7a578252
11 changed files with 91 additions and 14 deletions

View File

@ -1439,6 +1439,8 @@ def _ask_assignment_data(
"task_id": assignment.task.task_no, "task_id": assignment.task.task_no,
"public_token": assignment.task.public_token or assignment.task.task_no, "public_token": assignment.task.public_token or assignment.task.task_no,
"shop_name": assignment.task.shop_name, "shop_name": assignment.task.shop_name,
"ecommerce_platform": assignment.task.ecommerce_platform or "",
"product_id": assignment.task.product_id or "",
"product_name": assignment.task.product_name, "product_name": assignment.task.product_name,
"product_link": assignment.task.product_link, "product_link": assignment.task.product_link,
"product_qr": _product_qr_data(assignment.task), "product_qr": _product_qr_data(assignment.task),
@ -2036,6 +2038,8 @@ def _ask_review_board_data(
"task_id": assignment.task.task_no, "task_id": assignment.task.task_no,
"public_token": assignment.task.public_token or assignment.task.task_no, "public_token": assignment.task.public_token or assignment.task.task_no,
"shop_name": assignment.task.shop_name, "shop_name": assignment.task.shop_name,
"ecommerce_platform": assignment.task.ecommerce_platform or "",
"product_id": assignment.task.product_id or "",
"product_name": assignment.task.product_name, "product_name": assignment.task.product_name,
"product_link": assignment.task.product_link, "product_link": assignment.task.product_link,
"product_qr": _product_qr_data(assignment.task), "product_qr": _product_qr_data(assignment.task),
@ -2066,6 +2070,8 @@ def _ask_review_board_data(
"public_token": assignment.task.public_token or assignment.task.task_no, "public_token": assignment.task.public_token or assignment.task.task_no,
"status_label": "已完成" if refund_completed else "待返款", "status_label": "已完成" if refund_completed else "待返款",
"shop_name": assignment.task.shop_name, "shop_name": assignment.task.shop_name,
"ecommerce_platform": assignment.task.ecommerce_platform or "",
"product_id": assignment.task.product_id or "",
"product_name": assignment.task.product_name, "product_name": assignment.task.product_name,
"qa_count": selected_count, "qa_count": selected_count,
"claimed_count": selected_count, "claimed_count": selected_count,
@ -2474,6 +2480,13 @@ def ask_completion_reviews(user: AdminUser = Depends(current_user), db: Session
@app.get("/api/admin/review-pending-count") @app.get("/api/admin/review-pending-count")
def review_pending_count(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): def review_pending_count(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
"""Return lightweight sidebar counts without loading review rows or images.""" """Return lightweight sidebar counts without loading review rows or images."""
answer_draft_count = db.scalar(
select(func.count(AnswerSubmission.id)).where(
AnswerSubmission.status.in_(["待筛选", "待运营派发"]),
AnswerSubmission.questions.any(),
_answer_owner_clause(user),
)
) or 0
answer_count = db.scalar( answer_count = db.scalar(
select(func.count(AnswerSubmission.id)).where( select(func.count(AnswerSubmission.id)).where(
AnswerSubmission.status == "待审核", AnswerSubmission.status == "待审核",
@ -2498,6 +2511,7 @@ def review_pending_count(user: AdminUser = Depends(current_user), db: Session =
"ask": ask_count, "ask": ask_count,
"answer": answer_count, "answer": answer_count,
"total": ask_count + answer_count, "total": ask_count + answer_count,
"answer_drafts": answer_draft_count,
}, },
} }

View File

@ -437,6 +437,7 @@ def test_task_board_is_available_to_operator():
assert counts.status_code == 200 assert counts.status_code == 200
count_data = counts.json()["data"] count_data = counts.json()["data"]
assert count_data["total"] == count_data["ask"] + count_data["answer"] assert count_data["total"] == count_data["ask"] + count_data["answer"]
assert "answer_drafts" in count_data
def test_answer_task_expires_after_24_hours_and_can_be_reactivated(): def test_answer_task_expires_after_24_hours_and_can_be_reactivated():

View File

@ -52,7 +52,11 @@ def test_refund_snapshot_is_idempotent_when_callback_wins_race(setup):
@pytest.mark.parametrize("batched", [True, False]) @pytest.mark.parametrize("batched", [True, False])
def test_ask_confirmation_creates_record(setup, batched): def test_ask_confirmation_creates_record(setup, batched):
db, client, owner, person, _ = setup db, client, owner, person, _ = setup
task = AskTask(task_no="TASK-refund", product_link="https://example.com", product_name="商品", shop_name="提问店铺", owner_id=owner.id, status="已发布") task = AskTask(
task_no="TASK-refund", product_link="https://example.com", product_name="商品",
shop_name="提问店铺", ecommerce_platform="京东", product_id="687154817423861300",
owner_id=owner.id, status="已发布",
)
db.add(task) db.add(task)
db.flush() db.flush()
assignment = AskAssignment(task_id=task.id, user_id=person.id, status="已通过") assignment = AskAssignment(task_id=task.id, user_id=person.id, status="已通过")
@ -70,6 +74,10 @@ def test_ask_confirmation_creates_record(setup, batched):
kind, route = "ask_assignment", "ask-assignments" kind, route = "ask_assignment", "ask-assignments"
db.add(RefundRecipient(target_kind=kind, target_id=record.id, account="ask@example.com", payee_name="提问人")) db.add(RefundRecipient(target_kind=kind, target_id=record.id, account="ask@example.com", payee_name="提问人"))
db.commit() db.commit()
detail = client.get(f"/api/admin/task-board/{route}/{record.id}")
assert detail.status_code == 200, detail.text
assert detail.json()["data"]["ecommerce_platform"] == "京东"
assert detail.json()["data"]["product_id"] == "687154817423861300"
url = f"/api/admin/task-board/{route}/{record.id}/confirm-refund" url = f"/api/admin/task-board/{route}/{record.id}/confirm-refund"
payer_id = db.scalar(select(RefundPaymentAccount.id)) payer_id = db.scalar(select(RefundPaymentAccount.id))
owner = db.get(AdminUser, task.owner_id) owner = db.get(AdminUser, task.owner_id)
@ -84,6 +92,8 @@ def test_ask_confirmation_creates_record(setup, batched):
row = client.get("/api/admin/refund-records").json()["data"]["items"][0] row = client.get("/api/admin/refund-records").json()["data"]["items"][0]
assert row["amount_fen"] == 50 assert row["amount_fen"] == 50
assert row["payee_name"] == "提问人" and row["order_no"] == "TASK-refund" assert row["payee_name"] == "提问人" and row["order_no"] == "TASK-refund"
assert row["ecommerce_platform"] == "京东"
assert row["product_id"] == "687154817423861300"
def test_available_accounts_and_disabled_account_rejection(setup): def test_available_accounts_and_disabled_account_rejection(setup):

View File

@ -1,6 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from 'react' import React, { useEffect, useMemo, useRef, useState } from 'react'
export default function CreateAnswerTasksPageV2({ api, user }) { export default function CreateAnswerTasksPageV2({ api, user, answerDraftCount = 0 }) {
const [records, setRecords] = useState([]) const [records, setRecords] = useState([])
const [expanded, setExpanded] = useState({}) const [expanded, setExpanded] = useState({})
const [busy, setBusy] = useState('') const [busy, setBusy] = useState('')
@ -219,6 +219,7 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
setPublishing(null) setPublishing(null)
setPublishForm({ ecommerce_platform: ecommercePlatform, shop_name: shopName, product_id: '' }) setPublishForm({ ecommerce_platform: ecommercePlatform, shop_name: shopName, product_id: '' })
await load() await load()
window.dispatchEvent(new CustomEvent('answer-draft-count-changed'))
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -234,6 +235,7 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
await api(`/api/admin/answer-task-drafts/${record.id}/reject`, { method: 'POST' }) await api(`/api/admin/answer-task-drafts/${record.id}/reject`, { method: 'POST' })
setRecords(current => current.filter(item => item.id !== record.id)) setRecords(current => current.filter(item => item.id !== record.id))
setRejecting(null) setRejecting(null)
window.dispatchEvent(new CustomEvent('answer-draft-count-changed'))
} catch (err) { } catch (err) {
setError(err.message) setError(err.message)
} finally { } finally {
@ -277,7 +279,7 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
return <> return <>
<div className="create-page-heading"> <div className="create-page-heading">
<div><h2>创建任务</h2><p>通过顶部切换创建「提问任务」或「回答任务」。</p></div> <div><h2>创建任务</h2><p>通过顶部切换创建「提问任务」或「回答任务」。</p></div>
<div className="create-mode-tabs"><button onClick={() => window.dispatchEvent(new CustomEvent('switch-create-ask'))}>创建提问任务</button><button className="active">创建回答任务</button></div> <div className="create-mode-tabs"><button onClick={() => window.dispatchEvent(new CustomEvent('switch-create-ask'))}>创建提问任务</button><button className="active">创建回答任务{answerDraftCount > 0 && <span className="create-mode-badge">{answerDraftCount > 99 ? '99+' : answerDraftCount}</span>}</button></div>
</div> </div>
<div className="create-guide"><b>ⓘ 回答截图审核</b><span>用户提交截图后会立即进入此审核队列。按「用户ID」分组核对识别的问题,勾选需要派发的问题,回答可重新生成或直接编辑,确认后生成任务。</span></div> <div className="create-guide"><b>ⓘ 回答截图审核</b><span>用户提交截图后会立即进入此审核队列。按「用户ID」分组核对识别的问题,勾选需要派发的问题,回答可重新生成或直接编辑,确认后生成任务。</span></div>
{answerLink && <div className="answer-link-bar"><div><strong>我的回答任务专属链接</strong><span>长期固定,用户通过此链接提交的截图只会进入您的审核队列</span></div><input readOnly value={answerLink} onFocus={event => event.target.select()} /><button type="button" onClick={copyAnswerLink}>{linkCopied ? '已复制' : '复制链接'}</button></div>} {answerLink && <div className="answer-link-bar"><div><strong>我的回答任务专属链接</strong><span>长期固定,用户通过此链接提交的截图只会进入您的审核队列</span></div><input readOnly value={answerLink} onFocus={event => event.target.select()} /><button type="button" onClick={copyAnswerLink}>{linkCopied ? '已复制' : '复制链接'}</button></div>}

View File

@ -65,7 +65,7 @@ function withoutTransientProductFields(value = {}) {
return (await file.text()).slice(0, 20000); return (await file.text()).slice(0, 20000);
} }
export default function CreateAskTasksPage({ api, librarySelection = [], onLibrarySelectionConsumed }) { export default function CreateAskTasksPage({ api, librarySelection = [], onLibrarySelectionConsumed, answerDraftCount = 0 }) {
const cached = readPageCache("create-ask-task", {}); const cached = readPageCache("create-ask-task", {});
const [form, setForm] = useState(() => { const [form, setForm] = useState(() => {
// Product identifiers and QR/token data are one-time publish inputs. Never // Product identifiers and QR/token data are one-time publish inputs. Never
@ -662,6 +662,9 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
} }
> >
创建回答任务 创建回答任务
{answerDraftCount > 0 && (
<span className="create-mode-badge">{answerDraftCount > 99 ? "99+" : answerDraftCount}</span>
)}
</button> </button>
</div> </div>
</div> </div>

View File

@ -48,13 +48,21 @@ function TaskDetailLoading() {
); );
} }
export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, onReactivate, onCancel, actionBusy, api, hasNextRefund, onNextRefund, nextBusy, readOnly = false }) { export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, onReactivate, onCancel, actionBusy, api, user, hasNextRefund, onNextRefund, nextBusy, readOnly = false }) {
const [amount, setAmount] = useState(""); const [amount, setAmount] = useState("");
const [payerAccount, setPayerAccount] = useState(""); const payerAccountStorageKey = `task_board_last_payer_account_${user?.id || user?.username || "current"}`;
const rememberedPayerAccount = () => {
try {
return window.localStorage.getItem(payerAccountStorageKey) || "";
} catch {
return "";
}
};
const [payerAccount, setPayerAccount] = useState(rememberedPayerAccount);
const [paymentAccounts, setPaymentAccounts] = useState([]); const [paymentAccounts, setPaymentAccounts] = useState([]);
const [accountsBusy, setAccountsBusy] = useState(false); const [accountsBusy, setAccountsBusy] = useState(false);
const [refundError, setRefundError] = useState(""); const [refundError, setRefundError] = useState("");
useEffect(() => { setPayerAccount(""); setRefundError(""); }, [task.board_key]); useEffect(() => { setPayerAccount(rememberedPayerAccount()); setRefundError(""); }, [task.board_key, payerAccountStorageKey]);
const [amountBusy, setAmountBusy] = useState(false); const [amountBusy, setAmountBusy] = useState(false);
const [amountError, setAmountError] = useState(""); const [amountError, setAmountError] = useState("");
useEffect(() => { useEffect(() => {
@ -99,11 +107,23 @@ export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, o
setRefundError(""); setRefundError("");
setPaymentAccounts([]); setPaymentAccounts([]);
api("/api/admin/refund-payment-accounts/available").then((result) => { api("/api/admin/refund-payment-accounts/available").then((result) => {
if (!cancelled) setPaymentAccounts(result.data); if (cancelled) return;
const accounts = result.data || [];
const remembered = rememberedPayerAccount();
const isAvailable = accounts.some((account) => String(account.id) === remembered);
setPaymentAccounts(accounts);
setPayerAccount(isAvailable ? remembered : "");
if (remembered && !isAvailable) {
try { window.localStorage.removeItem(payerAccountStorageKey); } catch { /* localStorage may be unavailable */ }
}
}).catch((error) => { if (!cancelled) setRefundError(error.message); }) }).catch((error) => { if (!cancelled) setRefundError(error.message); })
.finally(() => { if (!cancelled) setAccountsBusy(false); }); .finally(() => { if (!cancelled) setAccountsBusy(false); });
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [refundOpen, api]); }, [refundOpen, api, payerAccountStorageKey]);
function selectPayerAccount(value) {
setPayerAccount(value);
setRefundError("");
}
async function confirmRefund() { async function confirmRefund() {
if (!validAmount) return setRefundError("请输入大于0且最多两位小数的金额"); if (!validAmount) return setRefundError("请输入大于0且最多两位小数的金额");
if (!payerAccount) return setRefundError("请选择付款账户"); if (!payerAccount) return setRefundError("请选择付款账户");
@ -111,6 +131,7 @@ export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, o
setRefundError(""); setRefundError("");
try { try {
await onConfirmRefund(task, amount, payerAccount); await onConfirmRefund(task, amount, payerAccount);
try { window.localStorage.setItem(payerAccountStorageKey, payerAccount); } catch { /* storage is optional */ }
setRefundOpen(false); setRefundOpen(false);
} catch (error) { } catch (error) {
setRefundError(error.message); setRefundError(error.message);
@ -366,7 +387,7 @@ export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, o
<small>默认按每条问答 0.50 元计算;支付宝单笔转账最低 0.10 元。</small> <small>默认按每条问答 0.50 元计算;支付宝单笔转账最低 0.10 元。</small>
</label> </label>
<label>付款账户 <label>付款账户
<select value={payerAccount} disabled={accountsBusy} onChange={(event) => setPayerAccount(event.target.value)}> <select value={payerAccount} disabled={accountsBusy} onChange={(event) => selectPayerAccount(event.target.value)}>
<option value="">{accountsBusy ? "正在加载账户…" : "请选择付款账户"}</option> <option value="">{accountsBusy ? "正在加载账户…" : "请选择付款账户"}</option>
{paymentAccounts.map((account) => <option key={account.id} value={account.id}>{account.name}({account.alipay_account})</option>)} {paymentAccounts.map((account) => <option key={account.id} value={account.id}>{account.name}({account.alipay_account})</option>)}
</select> </select>
@ -869,6 +890,7 @@ export default function TaskBoardPage({ api, user }) {
onCancel={cancel} onCancel={cancel}
actionBusy={busy === detail.id} actionBusy={busy === detail.id}
api={api} api={api}
user={user}
hasNextRefund={Boolean(nextRefundTask)} hasNextRefund={Boolean(nextRefundTask)}
nextBusy={Boolean(detailLoading)} nextBusy={Boolean(detailLoading)}

View File

@ -2039,11 +2039,29 @@
background: #e7edf6; background: #e7edf6;
} }
.create-mode-tabs button { .create-mode-tabs button {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px; padding: 8px 14px;
border-radius: 9px; border-radius: 9px;
background: transparent; background: transparent;
color: #506783; color: #506783;
} }
.create-mode-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 10px;
background: #e53935;
color: #fff;
font-size: 11px;
font-weight: 700;
line-height: 1;
box-shadow: 0 1px 3px rgba(198, 40, 40, 0.28);
}
.create-mode-tabs button.active { .create-mode-tabs button.active {
background: #fff; background: #fff;
color: #173258; color: #173258;

View File

@ -379,9 +379,11 @@ const ADMIN_ITEMS = [
}, },
]; ];
function Sidebar({ user, active, setActive, reviewBadge }) { function Sidebar({ user, active, setActive, reviewBadge, createBadge }) {
const baseItems = BASE_ITEMS.map((item) => const baseItems = BASE_ITEMS.map((item) =>
item.id === "review" ? { ...item, badge: reviewBadge } : item, item.id === "review" ? { ...item, badge: reviewBadge }
: item.id === "create" ? { ...item, badge: createBadge }
: item,
); );
const items = const items =
user.role_code === "super_admin" user.role_code === "super_admin"
@ -4613,6 +4615,7 @@ function Dashboard({ user, onLogout }) {
const [active, setActive] = useState("assistant"); const [active, setActive] = useState("assistant");
const [createMode, setCreateMode] = useState("ask"); const [createMode, setCreateMode] = useState("ask");
const [reviewBadge, setReviewBadge] = useState(0); const [reviewBadge, setReviewBadge] = useState(0);
const [createBadge, setCreateBadge] = useState(0);
const [librarySelection, setLibrarySelection] = useState([]); const [librarySelection, setLibrarySelection] = useState([]);
useEffect(() => { useEffect(() => {
api("/api/admin/usage/enter", { method: "POST" }).catch(() => {}); api("/api/admin/usage/enter", { method: "POST" }).catch(() => {});
@ -4621,6 +4624,7 @@ function Dashboard({ user, onLogout }) {
try { try {
const result = await api("/api/admin/review-pending-count"); const result = await api("/api/admin/review-pending-count");
setReviewBadge(result.data.total || 0); setReviewBadge(result.data.total || 0);
setCreateBadge(result.data.answer_drafts || 0);
} catch { } catch {
// Keep navigation usable if the counter request temporarily fails. // Keep navigation usable if the counter request temporarily fails.
} }
@ -4660,6 +4664,7 @@ function Dashboard({ user, onLogout }) {
window.addEventListener("switch-create-answer", handler); window.addEventListener("switch-create-answer", handler);
window.addEventListener("switch-create-ask", handler); window.addEventListener("switch-create-ask", handler);
window.addEventListener("review-count-changed", loadReviewBadge); window.addEventListener("review-count-changed", loadReviewBadge);
window.addEventListener("answer-draft-count-changed", loadReviewBadge);
loadReviewBadge(); loadReviewBadge();
const reviewCountTimer = window.setInterval(loadReviewBadge, 30000); const reviewCountTimer = window.setInterval(loadReviewBadge, 30000);
return () => { return () => {
@ -4670,6 +4675,7 @@ function Dashboard({ user, onLogout }) {
window.removeEventListener("switch-create-answer", handler); window.removeEventListener("switch-create-answer", handler);
window.removeEventListener("switch-create-ask", handler); window.removeEventListener("switch-create-ask", handler);
window.removeEventListener("review-count-changed", loadReviewBadge); window.removeEventListener("review-count-changed", loadReviewBadge);
window.removeEventListener("answer-draft-count-changed", loadReviewBadge);
window.clearInterval(reviewCountTimer); window.clearInterval(reviewCountTimer);
}; };
}, []); }, []);
@ -4689,6 +4695,7 @@ function Dashboard({ user, onLogout }) {
active={active} active={active}
setActive={selectPage} setActive={selectPage}
reviewBadge={reviewBadge} reviewBadge={reviewBadge}
createBadge={createBadge}
/> />
<section className="main-shell"> <section className="main-shell">
<Topbar user={user} onLogout={onLogout} /> <Topbar user={user} onLogout={onLogout} />
@ -4697,9 +4704,9 @@ function Dashboard({ user, onLogout }) {
> >
{active === "create" ? ( {active === "create" ? (
createMode === "ask" ? ( createMode === "ask" ? (
<CreateAskTasksPage api={api} librarySelection={librarySelection} onLibrarySelectionConsumed={() => setLibrarySelection([])} /> <CreateAskTasksPage api={api} librarySelection={librarySelection} onLibrarySelectionConsumed={() => setLibrarySelection([])} answerDraftCount={createBadge} />
) : ( ) : (
<CreateAnswerTasksPageV2 api={api} user={user} /> <CreateAnswerTasksPageV2 api={api} user={user} answerDraftCount={createBadge} />
) )
) : active === "board" ? ( ) : active === "board" ? (
<TaskBoardPage api={api} user={user} /> <TaskBoardPage api={api} user={user} />