审核页面图片改为预览,[200~1. 任务不领取直接释放不等待一分钟

This commit is contained in:
db_huang 2026-09-17 17:37:50 +08:00
parent 3ab8b52593
commit 68b59d2eab
5 changed files with 395 additions and 71 deletions

View File

@ -1943,10 +1943,16 @@ def select_public_ask_questions(task_no: str, payload: dict, db: Session = Depen
used_ids = {item.question_id for item in assignment.used_questions}
if selected_ids & used_ids:
raise HTTPException(status_code=400, detail="已认领的问题不能重复选择")
for item in assignment.items:
# The selection represents only the next batch. Questions in
# ``used_ids`` remain locked/read-only and are not resubmitted.
item.selected = item.question_id in selected_ids
for item in list(assignment.items):
# Keep historical submitted questions for batch details, retain only
# the questions confirmed for this batch, and immediately return all
# other provisional questions to the public pool.
if item.question_id in used_ids:
item.selected = False
elif item.question_id in selected_ids:
item.selected = True
else:
db.delete(item)
assignment.status = "进行中"
assignment.reservation_started_at = datetime.utcnow()
assignment.updated_at = assignment.reservation_started_at

View File

@ -1138,6 +1138,57 @@ def test_public_ask_questions_are_unique_across_users_and_eventually_exhausted()
assert exhausted.json()["detail"] == "本次任务已被领取完,请关注下一个任务!"
def test_unselected_ask_questions_are_released_immediately(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client:
assert client.post(
"/api/admin/auth/login",
json={"username": "operator", "password": "operator123456"},
).status_code == 200
generated = client.post(
"/api/admin/ask-tasks/generate",
json={
"shop_name": "未领取释放测试店铺",
"product_link": "TOKEN-RELEASE-UNSELECTED",
"product_name": "未领取释放测试商品",
"question_count": 8,
},
).json()["data"]
assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200
first_session = client.post(
"/api/ask/auth/taobao-login",
json={"taobao_id": f"tb{uuid4().hex[:14]}"},
).json()["data"]
second_session = client.post(
"/api/ask/auth/taobao-login",
json={"taobao_id": f"tb{uuid4().hex[:14]}"},
).json()["data"]
first_url = f"/api/ask/tasks/{generated['task_id']}?token={first_session['token']}"
first_assignment = client.get(first_url).json()["data"]
first_ids = {item["id"] for item in first_assignment["questions"]}
assert len(first_ids) == 5
selected_ids = set(list(first_ids)[:3])
confirmed = client.post(
f"/api/ask/tasks/{generated['task_id']}/selection",
json={"token": first_session["token"], "question_ids": list(selected_ids)},
)
assert confirmed.status_code == 200
confirmed_ids = {item["id"] for item in confirmed.json()["data"]["questions"]}
assert confirmed_ids == selected_ids
second_url = f"/api/ask/tasks/{generated['task_id']}?token={second_session['token']}"
second_assignment = client.get(second_url)
assert second_assignment.status_code == 200
second_ids = {item["id"] for item in second_assignment.json()["data"]["questions"]}
assert len(second_ids) == 5
assert second_ids == {
item["id"]
for item in generated["selected_questions"]
} - selected_ids
def test_answer_submission_requires_owner_link_and_allows_at_most_two_images():
with TestClient(app) as client:
missing_owner = client.post(

View File

@ -5485,48 +5485,97 @@ body img {
.global-image-viewer {
position: fixed;
z-index: 9999;
inset: 0;
top: 84px;
right: 18px;
bottom: 18px;
display: grid;
place-items: center;
padding: 32px;
background: rgba(4, 12, 28, .82);
cursor: zoom-out;
grid-template-rows: auto minmax(0, 1fr) auto;
width: min(480px, calc(100vw - 36px));
border: 1px solid #cbd8e8;
border-radius: 10px;
background: #fff;
box-shadow: 0 18px 55px rgba(8, 24, 50, .28);
overflow: hidden;
}
.global-image-viewer img {
.global-image-viewer.left {
right: auto;
left: 18px;
}
.global-image-viewer-header {
display: flex;
align-items: center;
gap: 8px;
min-height: 56px;
padding: 10px 12px 10px 15px;
border-bottom: 1px solid #dce5ef;
background: #fff;
cursor: grab;
touch-action: none;
user-select: none;
}
.global-image-viewer.dragging-window .global-image-viewer-header {
cursor: grabbing;
}
.global-image-viewer-header > div {
display: grid;
min-width: 0;
flex: 1;
gap: 3px;
}
.global-image-viewer-header strong {
color: #102744;
font-size: 14px;
}
.global-image-viewer-header span {
color: #7187a1;
font-size: 11px;
}
.global-image-viewer-canvas {
display: grid;
place-items: center;
min-height: 0;
padding: 14px;
overflow: hidden;
background: #eaf0f7;
overscroll-behavior: contain;
}
.global-image-viewer-canvas img {
display: block;
max-width: min(94vw, 1600px);
max-height: 92vh;
max-width: 100%;
max-height: 100%;
width: auto;
height: auto;
object-fit: contain;
border-radius: 10px;
box-shadow: 0 20px 80px rgba(0, 0, 0, .45);
border-radius: 6px;
box-shadow: 0 10px 32px rgba(8, 24, 50, .2);
cursor: grab;
touch-action: none;
transform-origin: center center;
user-select: none;
}
.global-image-viewer img.dragging {
.global-image-viewer-canvas img.dragging {
cursor: grabbing;
}
.global-image-viewer-tools {
position: fixed;
z-index: 2;
left: 50%;
bottom: 22px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 6px 8px;
border: 1px solid rgba(255, 255, 255, .2);
border-radius: 10px;
background: rgba(13, 28, 51, .88);
min-height: 50px;
padding: 8px 12px;
border-top: 1px solid rgba(255, 255, 255, .16);
background: #0d1c33;
color: #fff;
transform: translateX(-50%);
}
.global-image-viewer-tools button {
@ -5546,6 +5595,26 @@ body img {
background: rgba(255, 255, 255, .24);
}
.global-image-viewer-tools button:disabled {
opacity: .35;
cursor: not-allowed;
}
.global-image-viewer-tools button.gallery-nav {
min-width: 68px;
font-size: 11px;
}
.global-image-viewer-tools span.gallery-count {
min-width: 36px;
}
.global-image-viewer-tools i {
width: 1px;
height: 22px;
background: rgba(255, 255, 255, .22);
}
.global-image-viewer-tools button.reset {
font-size: 12px;
}
@ -5614,33 +5683,69 @@ body img {
}
.global-image-viewer-close {
position: fixed;
top: 18px;
right: 22px;
z-index: 1;
width: 40px;
height: 40px;
border: 1px solid rgba(255, 255, 255, .35);
border-radius: 50%;
background: rgba(255, 255, 255, .12);
color: #fff;
font-size: 28px;
display: grid;
place-items: center;
width: 34px;
height: 34px;
padding: 0;
border: 1px solid #d5e0ec;
border-radius: 7px;
background: #f4f7fa;
color: #536d8b;
font-size: 22px;
line-height: 1;
cursor: pointer;
}
@media (max-width: 640px) {
.global-image-viewer {
padding: 16px;
.global-image-viewer-dock {
display: grid;
place-items: center;
width: 34px;
height: 34px;
padding: 0;
border: 1px solid #d5e0ec;
border-radius: 7px;
background: #f4f7fa;
color: #1764e8;
font-size: 18px;
cursor: pointer;
}
@media (min-width: 1201px) {
body.image-preview-right .review-v2-backdrop {
padding-right: 516px;
justify-items: end;
}
.global-image-viewer-close {
top: 12px;
right: 12px;
body.image-preview-left .review-v2-backdrop {
padding-left: 516px;
justify-items: start;
}
}
@media (max-width: 1200px) {
.global-image-viewer,
.global-image-viewer.left {
top: auto;
right: 10px;
bottom: calc(10px + env(safe-area-inset-bottom));
left: 10px;
width: auto;
height: min(48dvh, 480px);
}
.global-image-viewer-dock {
display: none;
}
.global-image-viewer-tools {
bottom: 14px;
flex-wrap: wrap;
gap: 5px;
padding-inline: 8px;
}
.global-image-viewer-tools i {
display: none;
}
}

View File

@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import {
BookOpen,
ClipboardList,
Database,
FileText,
@ -326,6 +327,13 @@ const BASE_ITEMS = [
label: "\u5ba1\u6838\u4e2d\u5fc3",
group: "\u4efb\u52a1\u7ba1\u7406",
},
{
id: "guide",
icon: BookOpen,
label: "\u4f7f\u7528\u8bf4\u660e",
group: "\u4efb\u52a1\u7ba1\u7406",
href: "https://alidocs.dingtalk.com/i/nodes/amweZ92PV6w4kNGdcqEmwAMYJxEKBD6p?corpId=dinga78635fdab496f8735c2f4657eb6378f&doc_type=wiki_doc&editNotify=true&iframeQuery=utm_medium=im_card&utm_source=im&utm_medium=im_card&utm_scene=team_space&utm_source=im",
},
];
const ADMIN_ITEMS = [
{
@ -394,6 +402,21 @@ function Sidebar({ user, active, setActive, reviewBadge }) {
}
function NavItem({ item, active, setActive }) {
const Icon = item.icon;
if (item.href) {
return (
<a
className="nav-item"
href={item.href}
target="_blank"
rel="noopener noreferrer"
title="在新标签页打开使用说明"
>
<Icon size={17} />
<span>{item.label}</span>
<em aria-hidden="true">↗</em>
</a>
);
}
return (
<button
className={`nav-item ${active === item.id ? "active" : ""}`}
@ -3936,21 +3959,41 @@ function CompletionReviewPage() {
);
}
function GlobalImageViewer() {
const [src, setSrc] = useState("");
const [gallery, setGallery] = useState([]);
const [currentIndex, setCurrentIndex] = useState(-1);
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const [dockSide, setDockSide] = useState("right");
const [floatingPosition, setFloatingPosition] = useState(null);
const [panelDragging, setPanelDragging] = useState(false);
const panelRef = useRef(null);
const panelDragStart = useRef(null);
const dragStart = useRef(null);
const src = gallery[currentIndex]?.src || "";
useEffect(() => {
const handleClick = (event) => {
const image = event.target?.closest?.("img");
if (!image || !image.src) return;
setSrc(image.src);
if (!image || !image.src || image.closest(".global-image-viewer")) return;
const scope = image.closest(".review-v2-modal") || document;
const imageNodes = Array.from(scope.querySelectorAll("img")).filter(
(node) => node.src && !node.closest(".global-image-viewer"),
);
const items = imageNodes.map((node) => ({
src: node.currentSrc || node.src,
alt: node.alt || "审核图片",
}));
setGallery(items);
setCurrentIndex(Math.max(0, imageNodes.indexOf(image)));
setScale(1);
setPosition({ x: 0, y: 0 });
};
const handleKeyDown = (event) => {
if (event.key === "Escape") setSrc("");
if (event.key === "Escape") {
setGallery([]);
setCurrentIndex(-1);
setFloatingPosition(null);
}
};
document.addEventListener("click", handleClick);
document.addEventListener("keydown", handleKeyDown);
@ -3959,6 +4002,31 @@ function GlobalImageViewer() {
document.removeEventListener("keydown", handleKeyDown);
};
}, []);
useEffect(() => {
if (!src) return undefined;
const handleGalleryKeyDown = (event) => {
if (event.key === "ArrowLeft") {
setCurrentIndex((current) => Math.max(0, current - 1));
}
if (event.key === "ArrowRight") {
setCurrentIndex((current) => Math.min(gallery.length - 1, current + 1));
}
};
document.addEventListener("keydown", handleGalleryKeyDown);
return () => document.removeEventListener("keydown", handleGalleryKeyDown);
}, [src, gallery.length]);
useEffect(() => {
if (!src) return;
setScale(1);
setPosition({ x: 0, y: 0 });
}, [currentIndex, src]);
useEffect(() => {
document.body.classList.remove("image-preview-left", "image-preview-right");
if (!src) return undefined;
const className = `image-preview-${dockSide}`;
document.body.classList.add(className);
return () => document.body.classList.remove(className);
}, [src, dockSide]);
if (!src) return null;
const changeScale = (delta) => {
setScale((current) => {
@ -3991,38 +4059,131 @@ function GlobalImageViewer() {
dragStart.current = null;
setDragging(false);
};
const closeViewer = () => {
setGallery([]);
setCurrentIndex(-1);
setFloatingPosition(null);
};
const showImage = (nextIndex) => {
if (nextIndex < 0 || nextIndex >= gallery.length) return;
setCurrentIndex(nextIndex);
};
const beginPanelDrag = (event) => {
if (event.button !== 0 || event.target.closest("button") || !panelRef.current) return;
const rect = panelRef.current.getBoundingClientRect();
event.currentTarget.setPointerCapture?.(event.pointerId);
panelDragStart.current = {
pointerX: event.clientX,
pointerY: event.clientY,
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
setPanelDragging(true);
};
const movePanelDrag = (event) => {
if (!panelDragStart.current) return;
const start = panelDragStart.current;
const margin = 8;
const left = Math.min(
window.innerWidth - start.width - margin,
Math.max(margin, start.left + event.clientX - start.pointerX),
);
const top = Math.min(
window.innerHeight - start.height - margin,
Math.max(margin, start.top + event.clientY - start.pointerY),
);
setFloatingPosition({ left, top, height: start.height });
};
const endPanelDrag = () => {
panelDragStart.current = null;
setPanelDragging(false);
};
const switchDockSide = () => {
setFloatingPosition(null);
setDockSide((current) => current === "right" ? "left" : "right");
};
return (
<div
className="global-image-viewer"
role="dialog"
aria-modal="true"
aria-label="图片大图预览"
onClick={() => setSrc("")}
onWheel={(event) => {
event.preventDefault();
changeScale(event.deltaY > 0 ? -0.15 : 0.15);
}}
<aside
ref={panelRef}
className={`global-image-viewer ${dockSide === "left" ? "left" : ""}${panelDragging ? " dragging-window" : ""}`}
role="complementary"
aria-label="图片预览"
style={floatingPosition ? {
left: `${floatingPosition.left}px`,
top: `${floatingPosition.top}px`,
right: "auto",
bottom: "auto",
height: `${floatingPosition.height}px`,
} : undefined}
>
<button type="button" className="global-image-viewer-close" aria-label="关闭大图" onClick={() => setSrc("")}>×</button>
<div className="global-image-viewer-tools" onClick={(event) => event.stopPropagation()}>
<header
className="global-image-viewer-header"
onPointerDown={beginPanelDrag}
onPointerMove={movePanelDrag}
onPointerUp={endPanelDrag}
onPointerCancel={endPanelDrag}
>
<div>
<strong>图片预览</strong>
<span>拖动标题栏可移动 · 页面仍可滚动</span>
</div>
<button
type="button"
className="global-image-viewer-dock"
aria-label={dockSide === "right" ? "将预览移到左侧" : "将预览移到右侧"}
title={dockSide === "right" ? "移到左侧" : "移到右侧"}
onClick={switchDockSide}
>
{dockSide === "right" ? "←" : "→"}
</button>
<button type="button" className="global-image-viewer-close" aria-label="关闭预览" onClick={closeViewer}>×</button>
</header>
<div
className="global-image-viewer-canvas"
onWheel={(event) => {
event.preventDefault();
changeScale(event.deltaY > 0 ? -0.15 : 0.15);
}}
>
<img
src={src}
alt="大图预览"
draggable={false}
className={dragging ? "dragging" : ""}
style={{ transform: `translate(${position.x}px, ${position.y}px) scale(${scale})` }}
onPointerDown={beginDrag}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
/>
</div>
<div className="global-image-viewer-tools">
<button
type="button"
className="gallery-nav"
disabled={currentIndex <= 0}
onClick={() => showImage(currentIndex - 1)}
>
← 上一张
</button>
<span className="gallery-count">{currentIndex + 1}/{gallery.length}</span>
<button
type="button"
className="gallery-nav"
disabled={currentIndex >= gallery.length - 1}
onClick={() => showImage(currentIndex + 1)}
>
下一张 →
</button>
<i aria-hidden="true" />
<button type="button" aria-label="缩小" onClick={() => changeScale(-0.25)}>−</button>
<span>{Math.round(scale * 100)}%</span>
<button type="button" aria-label="放大" onClick={() => changeScale(0.25)}>+</button>
<button type="button" className="reset" onClick={resetView}>重置</button>
</div>
<img
src={src}
alt="大图预览"
draggable={false}
className={dragging ? "dragging" : ""}
style={{ transform: `translate(${position.x}px, ${position.y}px) scale(${scale})` }}
onClick={(event) => event.stopPropagation()}
onPointerDown={beginDrag}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
/>
</div>
</aside>
);
}

View File

@ -313,6 +313,7 @@ button {
color: #c5d2e8;
background: transparent;
text-align: left;
text-decoration: none;
}
.nav-item:hover,
.nav-item.active {