修复新字段不同步任务详情的bug,新增回答任务消息图标
This commit is contained in:
parent
561a34f108
commit
bd7a578252
Binary file not shown.
@ -1439,6 +1439,8 @@ def _ask_assignment_data(
|
||||
"task_id": assignment.task.task_no,
|
||||
"public_token": assignment.task.public_token or assignment.task.task_no,
|
||||
"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_link": assignment.task.product_link,
|
||||
"product_qr": _product_qr_data(assignment.task),
|
||||
@ -2036,6 +2038,8 @@ def _ask_review_board_data(
|
||||
"task_id": assignment.task.task_no,
|
||||
"public_token": assignment.task.public_token or assignment.task.task_no,
|
||||
"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_link": assignment.task.product_link,
|
||||
"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,
|
||||
"status_label": "已完成" if refund_completed else "待返款",
|
||||
"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,
|
||||
"qa_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")
|
||||
def review_pending_count(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||
"""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(
|
||||
select(func.count(AnswerSubmission.id)).where(
|
||||
AnswerSubmission.status == "待审核",
|
||||
@ -2498,6 +2511,7 @@ def review_pending_count(user: AdminUser = Depends(current_user), db: Session =
|
||||
"ask": ask_count,
|
||||
"answer": answer_count,
|
||||
"total": ask_count + answer_count,
|
||||
"answer_drafts": answer_draft_count,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@ -437,6 +437,7 @@ def test_task_board_is_available_to_operator():
|
||||
assert counts.status_code == 200
|
||||
count_data = counts.json()["data"]
|
||||
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():
|
||||
|
||||
@ -52,7 +52,11 @@ def test_refund_snapshot_is_idempotent_when_callback_wins_race(setup):
|
||||
@pytest.mark.parametrize("batched", [True, False])
|
||||
def test_ask_confirmation_creates_record(setup, batched):
|
||||
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.flush()
|
||||
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"
|
||||
db.add(RefundRecipient(target_kind=kind, target_id=record.id, account="ask@example.com", payee_name="提问人"))
|
||||
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"
|
||||
payer_id = db.scalar(select(RefundPaymentAccount.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]
|
||||
assert row["amount_fen"] == 50
|
||||
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):
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
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 [expanded, setExpanded] = useState({})
|
||||
const [busy, setBusy] = useState('')
|
||||
@ -219,6 +219,7 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
|
||||
setPublishing(null)
|
||||
setPublishForm({ ecommerce_platform: ecommercePlatform, shop_name: shopName, product_id: '' })
|
||||
await load()
|
||||
window.dispatchEvent(new CustomEvent('answer-draft-count-changed'))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@ -234,6 +235,7 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
|
||||
await api(`/api/admin/answer-task-drafts/${record.id}/reject`, { method: 'POST' })
|
||||
setRecords(current => current.filter(item => item.id !== record.id))
|
||||
setRejecting(null)
|
||||
window.dispatchEvent(new CustomEvent('answer-draft-count-changed'))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
} finally {
|
||||
@ -277,7 +279,7 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
|
||||
return <>
|
||||
<div className="create-page-heading">
|
||||
<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 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>}
|
||||
|
||||
@ -65,7 +65,7 @@ function withoutTransientProductFields(value = {}) {
|
||||
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 [form, setForm] = useState(() => {
|
||||
// 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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 [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 [accountsBusy, setAccountsBusy] = useState(false);
|
||||
const [refundError, setRefundError] = useState("");
|
||||
useEffect(() => { setPayerAccount(""); setRefundError(""); }, [task.board_key]);
|
||||
useEffect(() => { setPayerAccount(rememberedPayerAccount()); setRefundError(""); }, [task.board_key, payerAccountStorageKey]);
|
||||
const [amountBusy, setAmountBusy] = useState(false);
|
||||
const [amountError, setAmountError] = useState("");
|
||||
useEffect(() => {
|
||||
@ -99,11 +107,23 @@ export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, o
|
||||
setRefundError("");
|
||||
setPaymentAccounts([]);
|
||||
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); })
|
||||
.finally(() => { if (!cancelled) setAccountsBusy(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [refundOpen, api]);
|
||||
}, [refundOpen, api, payerAccountStorageKey]);
|
||||
function selectPayerAccount(value) {
|
||||
setPayerAccount(value);
|
||||
setRefundError("");
|
||||
}
|
||||
async function confirmRefund() {
|
||||
if (!validAmount) return setRefundError("请输入大于0且最多两位小数的金额");
|
||||
if (!payerAccount) return setRefundError("请选择付款账户");
|
||||
@ -111,6 +131,7 @@ export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, o
|
||||
setRefundError("");
|
||||
try {
|
||||
await onConfirmRefund(task, amount, payerAccount);
|
||||
try { window.localStorage.setItem(payerAccountStorageKey, payerAccount); } catch { /* storage is optional */ }
|
||||
setRefundOpen(false);
|
||||
} catch (error) {
|
||||
setRefundError(error.message);
|
||||
@ -366,7 +387,7 @@ export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, o
|
||||
<small>默认按每条问答 0.50 元计算;支付宝单笔转账最低 0.10 元。</small>
|
||||
</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>
|
||||
{paymentAccounts.map((account) => <option key={account.id} value={account.id}>{account.name}({account.alipay_account})</option>)}
|
||||
</select>
|
||||
@ -869,6 +890,7 @@ export default function TaskBoardPage({ api, user }) {
|
||||
onCancel={cancel}
|
||||
actionBusy={busy === detail.id}
|
||||
api={api}
|
||||
user={user}
|
||||
|
||||
hasNextRefund={Boolean(nextRefundTask)}
|
||||
nextBusy={Boolean(detailLoading)}
|
||||
|
||||
@ -2039,11 +2039,29 @@
|
||||
background: #e7edf6;
|
||||
}
|
||||
.create-mode-tabs button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
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 {
|
||||
background: #fff;
|
||||
color: #173258;
|
||||
|
||||
@ -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) =>
|
||||
item.id === "review" ? { ...item, badge: reviewBadge } : item,
|
||||
item.id === "review" ? { ...item, badge: reviewBadge }
|
||||
: item.id === "create" ? { ...item, badge: createBadge }
|
||||
: item,
|
||||
);
|
||||
const items =
|
||||
user.role_code === "super_admin"
|
||||
@ -4613,6 +4615,7 @@ function Dashboard({ user, onLogout }) {
|
||||
const [active, setActive] = useState("assistant");
|
||||
const [createMode, setCreateMode] = useState("ask");
|
||||
const [reviewBadge, setReviewBadge] = useState(0);
|
||||
const [createBadge, setCreateBadge] = useState(0);
|
||||
const [librarySelection, setLibrarySelection] = useState([]);
|
||||
useEffect(() => {
|
||||
api("/api/admin/usage/enter", { method: "POST" }).catch(() => {});
|
||||
@ -4621,6 +4624,7 @@ function Dashboard({ user, onLogout }) {
|
||||
try {
|
||||
const result = await api("/api/admin/review-pending-count");
|
||||
setReviewBadge(result.data.total || 0);
|
||||
setCreateBadge(result.data.answer_drafts || 0);
|
||||
} catch {
|
||||
// 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-ask", handler);
|
||||
window.addEventListener("review-count-changed", loadReviewBadge);
|
||||
window.addEventListener("answer-draft-count-changed", loadReviewBadge);
|
||||
loadReviewBadge();
|
||||
const reviewCountTimer = window.setInterval(loadReviewBadge, 30000);
|
||||
return () => {
|
||||
@ -4670,6 +4675,7 @@ function Dashboard({ user, onLogout }) {
|
||||
window.removeEventListener("switch-create-answer", handler);
|
||||
window.removeEventListener("switch-create-ask", handler);
|
||||
window.removeEventListener("review-count-changed", loadReviewBadge);
|
||||
window.removeEventListener("answer-draft-count-changed", loadReviewBadge);
|
||||
window.clearInterval(reviewCountTimer);
|
||||
};
|
||||
}, []);
|
||||
@ -4689,6 +4695,7 @@ function Dashboard({ user, onLogout }) {
|
||||
active={active}
|
||||
setActive={selectPage}
|
||||
reviewBadge={reviewBadge}
|
||||
createBadge={createBadge}
|
||||
/>
|
||||
<section className="main-shell">
|
||||
<Topbar user={user} onLogout={onLogout} />
|
||||
@ -4697,9 +4704,9 @@ function Dashboard({ user, onLogout }) {
|
||||
>
|
||||
{active === "create" ? (
|
||||
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" ? (
|
||||
<TaskBoardPage api={api} user={user} />
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user