修改批量上传后不能发布的BUG,优化发布时填写信息,认领上限改成20

This commit is contained in:
db_huang 2026-09-17 10:51:00 +08:00
parent 7f7311a47c
commit 3f15dd0ea3
8 changed files with 363 additions and 134 deletions

View File

@ -44,6 +44,13 @@ app.add_middleware(
# confirms a selection, the upload screen has a separate thirty-minute window.
ASK_SELECTION_TIMEOUT = timedelta(minutes=1)
ASK_UPLOAD_TIMEOUT = timedelta(minutes=30)
# Bumping this prefix invalidates every previously issued public-user token.
# Only Taobao-ID login can issue a token accepted by the current user portals.
PUBLIC_USER_SESSION_PREFIX = "tb2_"
def _new_public_user_token() -> str:
return f"{PUBLIC_USER_SESSION_PREFIX}{uuid.uuid4().hex}"
def _release_expired_ask_assignments(db: Session, task: AskTask | None = None) -> int:
@ -444,9 +451,11 @@ def _public_answer_task(task: dict) -> dict:
def _answer_user_from_token(token: str, db: Session) -> AnswerUser:
if not str(token or "").startswith(PUBLIC_USER_SESSION_PREFIX):
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户登录已失效,请使用真实淘宝ID重新登录")
user = db.scalar(select(AnswerUser).where(AnswerUser.auth_token == token))
if not user:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="回答用户登录已失效")
if not user or not user.taobao_id:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户登录已失效,请使用真实淘宝ID重新登录")
db.expunge(user)
db.rollback()
return user
@ -523,13 +532,13 @@ def _taobao_login(payload: dict, db: Session) -> AnswerUser:
public_id=f"U{uuid.uuid4().int % 10_000_000:07d}",
phone=legacy_phone_key,
taobao_id=taobao_id,
auth_token=uuid.uuid4().hex,
auth_token=_new_public_user_token(),
)
db.add(user)
else:
user.last_login_at = datetime.utcnow()
if not user.auth_token:
user.auth_token = uuid.uuid4().hex
if not user.auth_token.startswith(PUBLIC_USER_SESSION_PREFIX):
user.auth_token = _new_public_user_token()
db.commit(); db.refresh(user)
return user
@ -2408,13 +2417,8 @@ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db
payload.get("product_qr_image"), payload.get("product_qr_image_name")
)
product_name = str(payload.get("product_name", "")).strip()
if not shop_name:
raise HTTPException(status_code=400, detail="店铺名称为必填项")
if len(shop_name) > 255:
raise HTTPException(status_code=400, detail="店铺名称不能超过255个字符")
product_token_error = _product_token_error(product_link, product_qr_data)
if product_token_error:
raise HTTPException(status_code=400, detail=product_token_error)
intent_error = _product_intent_error([product_name, payload.get("category"), payload.get("benefits"), payload.get("selling_points")])
if intent_error:
raise HTTPException(status_code=400, detail=intent_error)
@ -2427,16 +2431,10 @@ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db
raise HTTPException(status_code=400, detail="提问生成条数必须是正整数")
if count < 1:
raise HTTPException(status_code=400, detail="提问生成条数必须大于 0")
raw_claim_limit = payload.get("claim_questions_per_user", "")
if str(raw_claim_limit).strip():
try:
claim_limit = int(raw_claim_limit)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="每个用户可认领的问题数量必须是 2-5 的整数")
if claim_limit < 2 or claim_limit > 5:
raise HTTPException(status_code=400, detail="每个用户可认领的问题数量必须在 2-5 条之间")
else:
claim_limit = 5
# Claim limits belong to the publish workflow and must never prevent AI
# generation. Drafts keep the neutral default until the publish dialog is
# confirmed.
claim_limit = 5
existing_task_id = payload.get("task_id")
try:
task = db.get(AskTask, int(existing_task_id)) if existing_task_id else None
@ -2601,15 +2599,149 @@ def delete_ask_question(task_id: int, question_id: int, user: AdminUser = Depend
return {"success": True, "data": {"id": question_id}}
def _ask_publish_fields(payload: dict) -> tuple[str, str, bytes | None, str | None, str | None, int]:
shop_name = str(payload.get("shop_name", "")).strip()
if not shop_name:
raise HTTPException(status_code=400, detail="请填写店铺名称")
if len(shop_name) > 255:
raise HTTPException(status_code=400, detail="店铺名称不能超过255个字符")
product_link = str(payload.get("product_link", "")).strip()
qr_data, qr_content_type, qr_name = _decode_product_qr_image(
payload.get("product_qr_image"), payload.get("product_qr_image_name")
)
product_token_error = _product_token_error(product_link, qr_data)
if product_token_error:
raise HTTPException(status_code=400, detail=product_token_error)
raw_claim_limit = payload.get("claim_questions_per_user", "")
if not str(raw_claim_limit).strip():
raise HTTPException(status_code=400, detail="请填写每个用户可领取的问题数量")
try:
claim_limit = int(raw_claim_limit)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail="每个用户可领取的问题数量必须是 2-20 的整数")
if claim_limit < 2 or claim_limit > 20:
raise HTTPException(status_code=400, detail="每个用户可领取的问题数量必须在 2-20 条之间")
return shop_name, product_link, qr_data, qr_content_type, qr_name, claim_limit
def _apply_ask_publish_fields(task: AskTask, payload: dict) -> None:
shop_name, product_link, qr_data, qr_content_type, qr_name, claim_limit = _ask_publish_fields(payload)
task.shop_name = shop_name
task.product_link = product_link
task.product_qr_image_data = qr_data
task.product_qr_image_content_type = qr_content_type
task.product_qr_image_name = qr_name
task.claim_questions_per_user = claim_limit
@app.post("/api/admin/ask-tasks/publish")
def create_and_publish_ask_task(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
"""Publish the questions currently visible in the editor, with or without an AI draft."""
raw_questions = payload.get("questions")
if not isinstance(raw_questions, list):
raise HTTPException(status_code=400, detail="请至少选择一个提问")
question_rows = []
seen = set()
for raw in raw_questions:
item = raw if isinstance(raw, dict) else {"question": raw}
question_text = re.sub(r"\s+", " ", str(item.get("question", "")).strip())
if not question_text or len(question_text) > 500 or question_text in seen:
continue
seen.add(question_text)
source = str(item.get("source", "自主添加")).strip()
if source not in {"自主添加", "问答库", "批量导入", "AI生成"}:
source = "自主添加"
question_rows.append((item, question_text, source))
if not question_rows:
raise HTTPException(status_code=400, detail="请至少选择一个提问")
if len(question_rows) > 500:
raise HTTPException(status_code=400, detail="单个任务最多发布 500 条问题")
raw_task_id = payload.get("task_id")
try:
task = db.get(AskTask, int(raw_task_id)) if raw_task_id else None
except (TypeError, ValueError):
task = None
if raw_task_id and not task:
raise HTTPException(status_code=404, detail="提问任务草稿不存在")
if task:
_require_ask_task_access(task, user)
if task.status != "草稿":
raise HTTPException(status_code=409, detail="任务已经发布")
else:
task = AskTask(
task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}",
owner_id=user.id,
shop_name="",
product_link="",
product_name=str(payload.get("product_name", "")).strip() or "待补充商品",
category=str(payload.get("category", "")),
benefits=str(payload.get("benefits", "")),
selling_points=str(payload.get("selling_points", "")),
language_style=str(payload.get("language_style", "")),
target_group=str(payload.get("target_group", "")),
usage_scene=str(payload.get("usage_scene", "")),
purchase_channel=str(payload.get("purchase_channel", "")),
claim_questions_per_user=5,
status="草稿",
)
db.add(task)
db.flush()
_apply_ask_publish_fields(task, payload)
if task.questions:
by_id = {question.id: question for question in task.questions}
selected_ids = set()
for item, question_text, _source in question_rows:
try:
question_id = int(item.get("id"))
except (TypeError, ValueError):
continue
question = by_id.get(question_id)
if question:
question.question_text = question_text
question.selected = True
selected_ids.add(question.id)
for question in task.questions:
question.selected = question.id in selected_ids
existing_by_text = {question.question_text: question for question in task.questions}
next_sequence = max((question.sequence_no for question in task.questions), default=0) + 1
for _item, question_text, source in question_rows:
existing_question = existing_by_text.get(question_text)
if existing_question:
existing_question.selected = True
continue
question = AskQuestion(task_id=task.id, sequence_no=next_sequence, question_text=question_text, source=source, selected=True)
db.add(question)
existing_by_text[question_text] = question
next_sequence += 1
else:
db.add_all([
AskQuestion(task_id=task.id, sequence_no=index, question_text=question_text, source=source, selected=True)
for index, (_item, question_text, source) in enumerate(question_rows, start=1)
])
if not task.public_token:
task.public_token = _new_ask_public_token(db)
task.status = "已发布"
db.commit()
db.refresh(task)
return {"success": True, "data": _ask_task_data(task)}
@app.post("/api/admin/ask-tasks/{task_id}/publish")
def publish_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
def publish_ask_task(task_id: int, payload: dict = Body(default={}), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
task = db.get(AskTask, task_id)
_require_ask_task_access(task, user)
if task.status != "草稿": raise HTTPException(status_code=409, detail="任务已经发布")
_dedupe_draft_ask_questions(task, db)
product_token_error = _product_token_error(task.product_link, task.product_qr_image_data)
if product_token_error:
raise HTTPException(status_code=400, detail=f"无法发布任务:{product_token_error}")
if payload:
_apply_ask_publish_fields(task, payload)
else:
product_token_error = _product_token_error(task.product_link, task.product_qr_image_data)
if product_token_error:
raise HTTPException(status_code=400, detail=f"无法发布任务:{product_token_error}")
if not task.shop_name:
raise HTTPException(status_code=400, detail="请填写店铺名称")
if not any(q.selected for q in task.questions): raise HTTPException(status_code=400, detail="请至少选择一个提问")
if not task.public_token:
task.public_token = _new_ask_public_token(db)

View File

@ -164,6 +164,7 @@ def test_taobao_id_login_is_shared_by_both_user_portals():
assert answer_session["taobao_id"] == taobao_id
assert answer_session["account"] == taobao_id
assert answer_session["phone"] == ""
assert answer_session["token"].startswith("tb2_")
ask_login = client.post(
"/api/ask/auth/taobao-login",
@ -179,6 +180,32 @@ def test_taobao_id_login_is_shared_by_both_user_portals():
assert invalid.status_code == 400
def test_legacy_public_user_sessions_are_invalidated_and_rotated_on_taobao_login():
taobao_id = f"tb{uuid4().hex[:14]}"
legacy_token = uuid4().hex
with main_module.SessionLocal() as db:
user = main_module.AnswerUser(
public_id=f"U{uuid4().int % 10_000_000:07d}",
phone=f"tb_{uuid4().hex[:16]}",
taobao_id=taobao_id,
auth_token=legacy_token,
)
db.add(user)
db.commit()
with TestClient(app) as client:
expired = client.get(f"/api/ask/me/assignments?token={legacy_token}")
assert expired.status_code == 401
assert "淘宝ID重新登录" in expired.json()["detail"]
renewed = client.post("/api/ask/auth/taobao-login", json={"taobao_id": taobao_id})
assert renewed.status_code == 200
renewed_token = renewed.json()["data"]["token"]
assert renewed_token.startswith("tb2_")
assert renewed_token != legacy_token
assert client.get(f"/api/ask/me/assignments?token={renewed_token}").status_code == 200
def test_answer_task_publish_remembers_operator_shop_name():
unique = uuid4().hex[:10]
remembered_shop = f"记忆店铺-{unique}"
@ -508,15 +535,12 @@ def test_ask_task_publish_appears_on_task_board(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
missing_link = client.post("/api/admin/ask-tasks/generate", json={"product_name": "测试商品"})
assert missing_link.status_code == 400
missing_product_access = client.post(
generation_without_publish_fields = client.post(
"/api/admin/ask-tasks/generate",
json={"shop_name": "测试店铺", "product_name": "测试商品", "question_count": 3},
json={"product_name": "测试商品", "question_count": 3},
)
assert missing_product_access.status_code == 400
missing_shop = client.post("/api/admin/ask-tasks/generate", json={"product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3})
assert missing_shop.status_code == 400
assert generation_without_publish_fields.status_code == 200
assert generation_without_publish_fields.json()["data"]["status"] == "草稿"
generated = client.post("/api/admin/ask-tasks/generate", json={"shop_name": "测试店铺", "product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3})
assert generated.status_code == 200
task = generated.json()["data"]
@ -540,6 +564,49 @@ def test_ask_task_publish_appears_on_task_board(monkeypatch):
assert detail.json()["data"]["shop_name"] == "测试店铺"
def test_imported_questions_can_publish_without_ai_generation():
with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
published = client.post(
"/api/admin/ask-tasks/publish",
json={
"shop_name": "直接发布测试店铺",
"product_link": "TOKEN-DIRECT-PUBLISH",
"product_name": "直接发布测试商品",
"claim_questions_per_user": 20,
"questions": [
{"id": "new-import-1", "question": "批量导入问题一?", "source": "批量导入"},
{"id": "new-manual-2", "question": "自主添加问题二?", "source": "自主添加"},
],
},
)
assert published.status_code == 200
data = published.json()["data"]
assert data["status"] == "已发布"
assert data["shop_name"] == "直接发布测试店铺"
assert data["claim_questions_per_user"] == 20
assert [item["question"] for item in data["selected_questions"]] == ["批量导入问题一?", "自主添加问题二?"]
assert [item["source"] for item in data["selected_questions"]] == ["批量导入", "自主添加"]
missing_fields = client.post(
"/api/admin/ask-tasks/publish",
json={"questions": [{"question": "不能缺少发布信息?", "source": "批量导入"}]},
)
assert missing_fields.status_code == 400
over_limit = client.post(
"/api/admin/ask-tasks/publish",
json={
"shop_name": "领取上限测试店铺",
"product_link": "TOKEN-CLAIM-LIMIT",
"claim_questions_per_user": 21,
"questions": [{"question": "超过领取上限?", "source": "自主添加"}],
},
)
assert over_limit.status_code == 400
assert over_limit.json()["detail"] == "每个用户可领取的问题数量必须在 2-20 条之间"
def test_ask_task_generation_honors_question_count(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client:

View File

@ -1,7 +1,8 @@
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'
const SESSION_KEY = 'answer_user_taobao_session'
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 = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
@ -29,7 +30,7 @@ function Login({ api, onLogin, linkToken }) {
try { const result = await api('/api/answer/auth/taobao-login', { method: 'POST', body: JSON.stringify({ taobao_id: taobaoId.trim(), owner_token: linkToken || undefined }) }); localStorage.setItem(SESSION_KEY, JSON.stringify(result.data)); onLogin(result.data) }
catch (err) { setError(err.message) } finally { setBusy(false) }
}
return <main className="answer-v2-shell"><section className="answer-v2-app login-only"><header className="answer-v2-top"><div><ShieldCheck size={14}/><strong>淘宝问答运营官方承接通道</strong><em>官方认证</em></div></header><form className="answer-v2-login" onSubmit={submit}><span><MessageCircle size={28}/></span><h1>淘宝账号登录</h1><p>输入淘宝ID即可进入任务中心</p><label>淘宝ID<input value={taobaoId} onChange={event => setTaobaoId(event.target.value.replace(/\s/g, '').slice(0, 64))} autoCapitalize="none" autoCorrect="off" placeholder="例如:tb1008611" autoFocus/></label>{error && <div className="answer-v2-error">{error}</div>}<button disabled={busy}>{busy ? '登录中…' : '登录并进入任务中心'}</button></form></section></main>
return <main className="answer-v2-shell"><section className="answer-v2-app login-only"><header className="answer-v2-top"><div><ShieldCheck size={14}/><strong>淘宝问答运营官方承接通道</strong><em>官方认证</em></div></header><form className="answer-v2-login" onSubmit={submit}><span><MessageCircle size={28}/></span><h1>淘宝账号登录</h1><p>输入淘宝ID即可进入任务中心</p><label>淘宝ID<input value={taobaoId} onChange={event => setTaobaoId(event.target.value.replace(/\s/g, '').slice(0, 64))} autoCapitalize="none" autoCorrect="off" placeholder="例如:tb1008611" autoFocus/><small className="user-login-notice">为了运营更好地核对信息,请填写真实的淘宝ID</small></label>{error && <div className="answer-v2-error">{error}</div>}<button disabled={busy}>{busy ? '登录中…' : '登录并进入任务中心'}</button></form></section></main>
}
function UploadStage({ api, session, onUploaded, setError, linkToken }) {
@ -128,7 +129,7 @@ function HistoryPage({ records, close, onOpen, openingId }) {
}
export default function AnswerUserPortalV2({ api, linkToken = '' }) {
const [session, setSession] = useState(() => { try { return JSON.parse(localStorage.getItem(SESSION_KEY) || 'null') } catch { return null } })
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)
@ -173,6 +174,7 @@ export default function AnswerUserPortalV2({ api, linkToken = '' }) {
})
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) }
@ -214,7 +216,7 @@ export default function AnswerUserPortalV2({ api, linkToken = '' }) {
}
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); setSession(null); setRecords([]); setLoaded(false); setOptimisticStatuses({}) }
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 }))

View File

@ -12,7 +12,8 @@ import {
Trash2,
} from "lucide-react";
const SESSION_KEY = "ask_user_taobao_session";
const SESSION_KEY = "ask_user_taobao_session_v2";
const LEGACY_SESSION_KEYS = ["ask_user_taobao_session", "ask_user_session"];
const EXHAUSTED_MESSAGE = "本次任务已被领取完,请关注下一个任务!";
const COMPLETED_TASK_MESSAGE = "本次提问任务已全部完成,可查看历史状态";
const INVALID_TASK_MESSAGE = "提问任务链接无效、未发布或已结束";
@ -132,6 +133,7 @@ function TaobaoLogin({ api, onLogin }) {
autoCorrect="off"
placeholder="例如:tb1008611"
/>
<small className="user-login-notice">为了运营更好地核对信息,请填写真实的淘宝ID</small>
</label>
{error && <div className="ask-user-error">{error}</div>}
<button disabled={busy}>
@ -873,6 +875,7 @@ export default function AskUserPortal({ api }) {
const taskNo = getTaskNo();
const [session, setSession] = useState(() => {
try {
LEGACY_SESSION_KEYS.forEach((key) => localStorage.removeItem(key));
return JSON.parse(localStorage.getItem(SESSION_KEY) || "null");
} catch {
return null;
@ -942,6 +945,8 @@ export default function AskUserPortal({ api }) {
setSelected([]);
setExpiredAssignment(true);
setError("");
} else if (requestError.message.includes("登录已失效")) {
logout();
} else {
setError(requestError.message);
}
@ -976,6 +981,7 @@ export default function AskUserPortal({ api }) {
const logout = () => {
localStorage.removeItem(SESSION_KEY);
LEGACY_SESSION_KEYS.forEach((key) => localStorage.removeItem(key));
setSession(null);
setAssignment(null);
setExhausted(false);

View File

@ -17,7 +17,7 @@ const blankForm = {
usage_scene: "",
purchase_channel: "",
question_count: "",
claim_questions_per_user: "",
claim_questions_per_user: "5",
};
const ADDED_QUESTION_SOURCES = new Set(["自主添加", "问答库", "批量导入"]);
@ -53,7 +53,13 @@ function isAddedQuestion(item) {
export default function CreateAskTasksPage({ api, librarySelection = [], onLibrarySelectionConsumed }) {
const cached = readPageCache("create-ask-task", {});
const [form, setForm] = useState({ ...blankForm, ...(cached.form || {}) });
const [form, setForm] = useState(() => {
const restored = { ...blankForm, ...(cached.form || {}) };
if (!String(restored.claim_questions_per_user ?? "").trim()) {
restored.claim_questions_per_user = "5";
}
return restored;
});
// Only a task explicitly marked as a draft can be resumed. Older cached
// payloads did not include `status`, so treating them as editable could
// resend a published task id and leave the operator stuck on this page.
@ -71,6 +77,7 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
const [referenceFileName, setReferenceFileName] = useState(cached.referenceFileName || "");
const [referenceQa, setReferenceQa] = useState(cached.referenceQa || "");
const [addDialog, setAddDialog] = useState(null);
const [publishOpen, setPublishOpen] = useState(false);
const [linkCopied, setLinkCopied] = useState(false);
const processingLibrarySelection = useRef(false);
const consumedLibrarySelectionKey = useRef("");
@ -165,19 +172,12 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
setReferenceFileName("");
setReferenceQa("");
setAddDialog(null);
setPublishOpen(false);
if (referenceInputRef.current) referenceInputRef.current.value = "";
if (productQrInputRef.current) productQrInputRef.current.value = "";
}
async function generate() {
if (!form.shop_name.trim()) {
setError("请填写店铺名称");
return;
}
if (!form.product_link.trim() && !form.product_qr_image) {
setError("请填写商品淘口令或上传商品二维码");
return;
}
const intentError = analyzeProductIntent([
form.product_name,
form.category,
@ -188,7 +188,7 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
setError(intentError);
return;
}
const optionsError = validateGenerationOptions(form, false);
const optionsError = validateGenerationOptions({ ...form, claim_questions_per_user: "" }, false);
if (optionsError) {
setError(optionsError);
return;
@ -411,18 +411,35 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
}
}
async function publish() {
if (!task) return setError("请先生成提问");
const checkedQuestions = questions.filter((item) => item.selected && item.question.trim());
const selectedQuestions = checkedQuestions.length ? checkedQuestions : questions.filter((item) => item.question.trim());
if (!selectedQuestions.length) return setError("请至少添加一个提问");
if (!form.shop_name.trim()) return setError("请填写店铺名称");
if (!form.product_link.trim() && !form.product_qr_image) return setError("请填写商品淘口令或上传商品二维码");
const claimLimit = Number(form.claim_questions_per_user);
if (!Number.isInteger(claimLimit) || claimLimit < 2 || claimLimit > 20) return setError("每个用户可领取的问题数量必须填写 2-20 的整数");
setBusy(true);
setError("");
try {
const result = await api(`/api/admin/ask-tasks/${task.id}/publish`, {
const result = await api("/api/admin/ask-tasks/publish", {
method: "POST",
body: JSON.stringify({
...form,
claim_questions_per_user: claimLimit,
...(task?.status === "草稿" ? { task_id: task.id } : {}),
questions: selectedQuestions.map((item) => ({
id: item.id,
question: item.question.trim(),
source: item.source || "自主添加",
})),
}),
});
// A successful response from this endpoint is authoritative even when
// an older backend build omits the raw workflow state from its payload.
// Keep the state explicit so the success/link view renders on the first
// click instead of waiting for a second request to return 409.
setTask({ ...result.data, status: result.data.status || "已发布" });
setPublishOpen(false);
} catch (err) {
setError(err.message);
} finally {
@ -544,62 +561,18 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
<section className="ask-config">
<div className="panel-title">
<Sparkles size={17} />
生成条件配置 <small>* 店铺名称必填,淘口令和二维码至少填写一种</small>
生成条件配置 <small>以下内容仅用于 AI 生成提问,发布信息将在发布时填写</small>
<button type="button" className="ask-clear-all" disabled={busy} onClick={clearAll}>一键清空</button>
</div>
<div className="ask-product-token-field">
<label>
商品淘口令/二维码
<input
value={form.product_link}
onChange={(event) => update("product_link", event.target.value)}
placeholder="请输入商品淘口令(可与二维码同时填写)"
/>
</label>
<input
ref={productQrInputRef}
hidden
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(event) => selectProductQr(event.target.files?.[0] || null)}
/>
{form.product_qr_image ? (
<div className="ask-product-qr-preview">
<img src={form.product_qr_image} alt="商品二维码预览" />
<div>
<strong>{form.product_qr_image_name || "商品二维码"}</strong>
<small>已上传商品二维码,可与淘口令同时使用</small>
<button type="button" className="ghost-button" onClick={clearProductQr}>移除二维码</button>
</div>
</div>
) : (
<button type="button" className="ask-product-qr-upload" onClick={() => productQrInputRef.current?.click()}>
<span>+ 上传商品二维码</span>
<small>支持 JPG、PNG、WEBP,单张不超过 5MB</small>
</button>
)}
<small className="ask-product-token-hint">淘口令和二维码至少填写一种,也可以两者都填写</small>
</div>
<h4>商品信息</h4>
<div className="two-col">
<label>
店铺名称 *
<input
value={form.shop_name}
onChange={(event) => update("shop_name", event.target.value)}
placeholder="请输入店铺名称"
maxLength={255}
/>
</label>
<label>
商品名称
<input
value={form.product_name}
onChange={(event) => update("product_name", event.target.value)}
placeholder="请输入商品名称"
/>
</label>
</div>
<label>
商品名称
<input
value={form.product_name}
onChange={(event) => update("product_name", event.target.value)}
placeholder="请输入商品名称"
/>
</label>
<div className="two-col">
<label>
商品品类
@ -633,29 +606,16 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
{referenceFileName && <button type="button" className="reference-qa-clear" onClick={clearReferenceFile}>清除已选文件</button>}
</label>
<h4>生成偏好与数量</h4>
<div className="two-col">
<label>
提问生成条数 *
<input
type="number"
min="1"
required
value={form.question_count}
onChange={(event) => update("question_count", event.target.value)}
/>
</label>
<label>
每个用户可认领的问题数量(填写后仅允许 2~5 条)
<input
type="number"
min="2"
max="5"
value={form.claim_questions_per_user}
onChange={(event) => update("claim_questions_per_user", event.target.value)}
placeholder="不填写默认每人 5 条"
/>
</label>
</div>
<label>
提问生成条数 *
<input
type="number"
min="1"
required
value={form.question_count}
onChange={(event) => update("question_count", event.target.value)}
/>
</label>
<div className="two-col">
<label>
语言风格
@ -761,12 +721,10 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
<button
type="button"
className="primary-button"
disabled={
!questions.some((item) => item.selected) || !task || busy
}
onClick={publish}
disabled={!questions.length || busy}
onClick={() => { setError(""); setPublishOpen(true); }}
>
{busy ? "发布中…" : "发布任务"}
发布任务
</button>
</div>
</div>
@ -836,6 +794,43 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
)}
{addDialog === "manual" && <ManualAskQuestionModal onClose={() => setAddDialog(null)} onAdd={addManualQuestion} />}
{addDialog === "import" && <ImportAskQuestionsModal api={api} onClose={() => setAddDialog(null)} onImport={addImportedQuestions} />}
{publishOpen && (
<div className="manual-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && !busy && setPublishOpen(false)}>
<form className="manual-modal ask-publish-dialog" onSubmit={(event) => { event.preventDefault(); publish(); }}>
<div className="manual-modal-header">
<div><h3>发布提问任务</h3><p>本次将发布 {questions.some((item) => item.selected) ? questions.filter((item) => item.selected).length : questions.length} 条问题</p></div>
<button type="button" disabled={busy} onClick={() => setPublishOpen(false)}><X size={18} /></button>
</div>
<label>
店铺名称 *
<input autoFocus value={form.shop_name} onChange={(event) => update("shop_name", event.target.value)} placeholder="请输入店铺名称" maxLength={255} />
</label>
<div className="ask-product-token-field">
<label>
商品淘口令/二维码 *
<input value={form.product_link} onChange={(event) => update("product_link", event.target.value)} placeholder="请输入商品淘口令(可与二维码同时填写)" />
</label>
<input ref={productQrInputRef} hidden type="file" accept="image/jpeg,image/png,image/webp" onChange={(event) => selectProductQr(event.target.files?.[0] || null)} />
{form.product_qr_image ? (
<div className="ask-product-qr-preview">
<img src={form.product_qr_image} alt="商品二维码预览" />
<div><strong>{form.product_qr_image_name || "商品二维码"}</strong><small>已上传商品二维码</small><button type="button" className="ghost-button" onClick={clearProductQr}>移除二维码</button></div>
</div>
) : (
<button type="button" className="ask-product-qr-upload" onClick={() => productQrInputRef.current?.click()}><span>+ 上传商品二维码</span><small>支持 JPG、PNG、WEBP,单张不超过 5MB</small></button>
)}
<small className="ask-product-token-hint">淘口令和二维码至少填写一种,也可以两者都填写</small>
</div>
<label>
每个用户可领取问题数 *
<input type="number" min="2" max="20" step="1" value={form.claim_questions_per_user} onChange={(event) => update("claim_questions_per_user", event.target.value)} placeholder="请输入 2-20" />
<small>每名用户在该链接中最多可领取 2-20 条问题,默认 5 条</small>
</label>
{error && <div className="form-error">{error}</div>}
<div className="manual-modal-actions"><button type="button" disabled={busy} onClick={() => setPublishOpen(false)}>取消</button><button type="submit" className="primary-button" disabled={busy}>{busy ? "发布中…" : "确认发布"}</button></div>
</form>
</div>
)}
</>
);
}

View File

@ -3871,6 +3871,12 @@
color: #466181;
font-size: 12px;
}
.user-login-notice {
color: #d47a00;
font-size: 10px;
font-weight: 500;
line-height: 1.5;
}
.answer-v2-login input {
padding: 12px;
border: 1px solid #c5d5e8;
@ -5133,6 +5139,27 @@
padding: 3px 7px;
font-size: 10px;
}
.ask-publish-dialog {
width: min(520px, calc(100vw - 28px));
}
.ask-publish-dialog .manual-modal-header > div {
min-width: 0;
}
.ask-publish-dialog .manual-modal-header p {
margin: 4px 0 0;
color: #8194aa;
font-size: 11px;
}
.ask-publish-dialog > label > small {
color: #8296ad;
font-size: 10px;
}
.ask-publish-dialog .ask-product-token-field {
margin-top: 12px;
}
.ask-publish-dialog .form-error {
margin-top: 14px;
}
.ask-results {
min-width: 0;
}

View File

@ -3854,7 +3854,7 @@ function GlobalImageViewer() {
function Dashboard({ user, onLogout }) {
const [active, setActive] = useState("assistant");
const [createMode, setCreateMode] = useState("answer");
const [createMode, setCreateMode] = useState("ask");
const [reviewBadge, setReviewBadge] = useState(0);
const [librarySelection, setLibrarySelection] = useState([]);
async function loadReviewBadge() {
@ -3917,7 +3917,7 @@ function Dashboard({ user, onLogout }) {
const selectPage = (next) => {
if (user.permissions.includes(next)) {
setActive(next);
if (next === "create") setCreateMode("answer");
if (next === "create") setCreateMode("ask");
if (next === "review") loadReviewBadge();
}
};

View File

@ -38,8 +38,8 @@ export function validateGenerationOptions(form, includeAnswers = true) {
}
if (String(form.claim_questions_per_user ?? "").trim()) {
const claimCount = Number(form.claim_questions_per_user);
if (!Number.isInteger(claimCount) || claimCount < 2 || claimCount > 5) {
return "每个用户可认领的问题数量必须在 2-5 条之间";
if (!Number.isInteger(claimCount) || claimCount < 2 || claimCount > 20) {
return "每个用户可认领的问题数量必须在 2-20 条之间";
}
}
return "";