修改批量上传后不能发布的BUG,优化发布时填写信息,认领上限改成20
This commit is contained in:
parent
7f7311a47c
commit
3f15dd0ea3
@ -44,6 +44,13 @@ app.add_middleware(
|
|||||||
# confirms a selection, the upload screen has a separate thirty-minute window.
|
# confirms a selection, the upload screen has a separate thirty-minute window.
|
||||||
ASK_SELECTION_TIMEOUT = timedelta(minutes=1)
|
ASK_SELECTION_TIMEOUT = timedelta(minutes=1)
|
||||||
ASK_UPLOAD_TIMEOUT = timedelta(minutes=30)
|
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:
|
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:
|
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))
|
user = db.scalar(select(AnswerUser).where(AnswerUser.auth_token == token))
|
||||||
if not user:
|
if not user or not user.taobao_id:
|
||||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="回答用户登录已失效")
|
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户登录已失效,请使用真实淘宝ID重新登录")
|
||||||
db.expunge(user)
|
db.expunge(user)
|
||||||
db.rollback()
|
db.rollback()
|
||||||
return user
|
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}",
|
public_id=f"U{uuid.uuid4().int % 10_000_000:07d}",
|
||||||
phone=legacy_phone_key,
|
phone=legacy_phone_key,
|
||||||
taobao_id=taobao_id,
|
taobao_id=taobao_id,
|
||||||
auth_token=uuid.uuid4().hex,
|
auth_token=_new_public_user_token(),
|
||||||
)
|
)
|
||||||
db.add(user)
|
db.add(user)
|
||||||
else:
|
else:
|
||||||
user.last_login_at = datetime.utcnow()
|
user.last_login_at = datetime.utcnow()
|
||||||
if not user.auth_token:
|
if not user.auth_token.startswith(PUBLIC_USER_SESSION_PREFIX):
|
||||||
user.auth_token = uuid.uuid4().hex
|
user.auth_token = _new_public_user_token()
|
||||||
db.commit(); db.refresh(user)
|
db.commit(); db.refresh(user)
|
||||||
return 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")
|
payload.get("product_qr_image"), payload.get("product_qr_image_name")
|
||||||
)
|
)
|
||||||
product_name = str(payload.get("product_name", "")).strip()
|
product_name = str(payload.get("product_name", "")).strip()
|
||||||
if not shop_name:
|
|
||||||
raise HTTPException(status_code=400, detail="店铺名称为必填项")
|
|
||||||
if len(shop_name) > 255:
|
if len(shop_name) > 255:
|
||||||
raise HTTPException(status_code=400, detail="店铺名称不能超过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")])
|
intent_error = _product_intent_error([product_name, payload.get("category"), payload.get("benefits"), payload.get("selling_points")])
|
||||||
if intent_error:
|
if intent_error:
|
||||||
raise HTTPException(status_code=400, detail=intent_error)
|
raise HTTPException(status_code=400, detail=intent_error)
|
||||||
@ -2427,15 +2431,9 @@ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db
|
|||||||
raise HTTPException(status_code=400, detail="提问生成条数必须是正整数")
|
raise HTTPException(status_code=400, detail="提问生成条数必须是正整数")
|
||||||
if count < 1:
|
if count < 1:
|
||||||
raise HTTPException(status_code=400, detail="提问生成条数必须大于 0")
|
raise HTTPException(status_code=400, detail="提问生成条数必须大于 0")
|
||||||
raw_claim_limit = payload.get("claim_questions_per_user", "")
|
# Claim limits belong to the publish workflow and must never prevent AI
|
||||||
if str(raw_claim_limit).strip():
|
# generation. Drafts keep the neutral default until the publish dialog is
|
||||||
try:
|
# confirmed.
|
||||||
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_limit = 5
|
||||||
existing_task_id = payload.get("task_id")
|
existing_task_id = payload.get("task_id")
|
||||||
try:
|
try:
|
||||||
@ -2601,15 +2599,149 @@ def delete_ask_question(task_id: int, question_id: int, user: AdminUser = Depend
|
|||||||
return {"success": True, "data": {"id": question_id}}
|
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")
|
@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)
|
task = db.get(AskTask, task_id)
|
||||||
_require_ask_task_access(task, user)
|
_require_ask_task_access(task, user)
|
||||||
if task.status != "草稿": raise HTTPException(status_code=409, detail="任务已经发布")
|
if task.status != "草稿": raise HTTPException(status_code=409, detail="任务已经发布")
|
||||||
_dedupe_draft_ask_questions(task, db)
|
_dedupe_draft_ask_questions(task, db)
|
||||||
|
if payload:
|
||||||
|
_apply_ask_publish_fields(task, payload)
|
||||||
|
else:
|
||||||
product_token_error = _product_token_error(task.product_link, task.product_qr_image_data)
|
product_token_error = _product_token_error(task.product_link, task.product_qr_image_data)
|
||||||
if product_token_error:
|
if product_token_error:
|
||||||
raise HTTPException(status_code=400, detail=f"无法发布任务:{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 any(q.selected for q in task.questions): raise HTTPException(status_code=400, detail="请至少选择一个提问")
|
||||||
if not task.public_token:
|
if not task.public_token:
|
||||||
task.public_token = _new_ask_public_token(db)
|
task.public_token = _new_ask_public_token(db)
|
||||||
|
|||||||
@ -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["taobao_id"] == taobao_id
|
||||||
assert answer_session["account"] == taobao_id
|
assert answer_session["account"] == taobao_id
|
||||||
assert answer_session["phone"] == ""
|
assert answer_session["phone"] == ""
|
||||||
|
assert answer_session["token"].startswith("tb2_")
|
||||||
|
|
||||||
ask_login = client.post(
|
ask_login = client.post(
|
||||||
"/api/ask/auth/taobao-login",
|
"/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
|
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():
|
def test_answer_task_publish_remembers_operator_shop_name():
|
||||||
unique = uuid4().hex[:10]
|
unique = uuid4().hex[:10]
|
||||||
remembered_shop = f"记忆店铺-{unique}"
|
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)
|
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
|
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": "测试商品"})
|
generation_without_publish_fields = client.post(
|
||||||
assert missing_link.status_code == 400
|
|
||||||
missing_product_access = client.post(
|
|
||||||
"/api/admin/ask-tasks/generate",
|
"/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
|
assert generation_without_publish_fields.status_code == 200
|
||||||
missing_shop = client.post("/api/admin/ask-tasks/generate", json={"product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3})
|
assert generation_without_publish_fields.json()["data"]["status"] == "草稿"
|
||||||
assert missing_shop.status_code == 400
|
|
||||||
generated = client.post("/api/admin/ask-tasks/generate", json={"shop_name": "测试店铺", "product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3})
|
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
|
assert generated.status_code == 200
|
||||||
task = generated.json()["data"]
|
task = generated.json()["data"]
|
||||||
@ -540,6 +564,49 @@ def test_ask_task_publish_appears_on_task_board(monkeypatch):
|
|||||||
assert detail.json()["data"]["shop_name"] == "测试店铺"
|
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):
|
def test_ask_task_generation_honors_question_count(monkeypatch):
|
||||||
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
|
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
|
||||||
with TestClient(app) as client:
|
with TestClient(app) as client:
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react'
|
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'
|
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 STATUS_TEXT = { '待筛选': '截图审核中', '识别失败': '运营处理中', '待运营派发': '截图审核中', '已派发': '截图审核中', '待用户完成': '待完成回答', '待审核': '完成审核中', '已通过': '已完成', '已完成': '已返款', '未通过': '审核不通过', '已撤销': '任务已撤销' }
|
||||||
const accountName = session => session?.taobao_id || session?.account || session?.user_id || ''
|
const accountName = session => session?.taobao_id || session?.account || session?.user_id || ''
|
||||||
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
|
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) }
|
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) }
|
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 }) {
|
function UploadStage({ api, session, onUploaded, setError, linkToken }) {
|
||||||
@ -128,7 +129,7 @@ function HistoryPage({ records, close, onOpen, openingId }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function AnswerUserPortalV2({ api, linkToken = '' }) {
|
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 [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 imageLoadPromises = useRef(new Map())
|
||||||
const loadVersion = useRef(0)
|
const loadVersion = useRef(0)
|
||||||
@ -173,6 +174,7 @@ export default function AnswerUserPortalV2({ api, linkToken = '' }) {
|
|||||||
})
|
})
|
||||||
setError('')
|
setError('')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err.message.includes('登录已失效')) { logout(); return }
|
||||||
if (version === loadVersion.current) setError(err.message)
|
if (version === loadVersion.current) setError(err.message)
|
||||||
} finally {
|
} finally {
|
||||||
if (version === loadVersion.current) { setLoaded(true); if (!silent) setBusy(false) }
|
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 latestRecord = records.find(record => record.id === activeRecordId) || records[0]
|
||||||
const latest = latestRecord && optimisticStatuses[latestRecord.id] ? { ...latestRecord, status: optimisticStatuses[latestRecord.id] } : latestRecord
|
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) {
|
function applySubmittedRecord(submitted) {
|
||||||
if (!submitted?.id) return
|
if (!submitted?.id) return
|
||||||
setOptimisticStatuses(current => ({ ...current, [submitted.id]: submitted.status }))
|
setOptimisticStatuses(current => ({ ...current, [submitted.id]: submitted.status }))
|
||||||
|
|||||||
@ -12,7 +12,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} 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 EXHAUSTED_MESSAGE = "本次任务已被领取完,请关注下一个任务!";
|
||||||
const COMPLETED_TASK_MESSAGE = "本次提问任务已全部完成,可查看历史状态";
|
const COMPLETED_TASK_MESSAGE = "本次提问任务已全部完成,可查看历史状态";
|
||||||
const INVALID_TASK_MESSAGE = "提问任务链接无效、未发布或已结束";
|
const INVALID_TASK_MESSAGE = "提问任务链接无效、未发布或已结束";
|
||||||
@ -132,6 +133,7 @@ function TaobaoLogin({ api, onLogin }) {
|
|||||||
autoCorrect="off"
|
autoCorrect="off"
|
||||||
placeholder="例如:tb1008611"
|
placeholder="例如:tb1008611"
|
||||||
/>
|
/>
|
||||||
|
<small className="user-login-notice">为了运营更好地核对信息,请填写真实的淘宝ID</small>
|
||||||
</label>
|
</label>
|
||||||
{error && <div className="ask-user-error">{error}</div>}
|
{error && <div className="ask-user-error">{error}</div>}
|
||||||
<button disabled={busy}>
|
<button disabled={busy}>
|
||||||
@ -873,6 +875,7 @@ export default function AskUserPortal({ api }) {
|
|||||||
const taskNo = getTaskNo();
|
const taskNo = getTaskNo();
|
||||||
const [session, setSession] = useState(() => {
|
const [session, setSession] = useState(() => {
|
||||||
try {
|
try {
|
||||||
|
LEGACY_SESSION_KEYS.forEach((key) => localStorage.removeItem(key));
|
||||||
return JSON.parse(localStorage.getItem(SESSION_KEY) || "null");
|
return JSON.parse(localStorage.getItem(SESSION_KEY) || "null");
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@ -942,6 +945,8 @@ export default function AskUserPortal({ api }) {
|
|||||||
setSelected([]);
|
setSelected([]);
|
||||||
setExpiredAssignment(true);
|
setExpiredAssignment(true);
|
||||||
setError("");
|
setError("");
|
||||||
|
} else if (requestError.message.includes("登录已失效")) {
|
||||||
|
logout();
|
||||||
} else {
|
} else {
|
||||||
setError(requestError.message);
|
setError(requestError.message);
|
||||||
}
|
}
|
||||||
@ -976,6 +981,7 @@ export default function AskUserPortal({ api }) {
|
|||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
localStorage.removeItem(SESSION_KEY);
|
localStorage.removeItem(SESSION_KEY);
|
||||||
|
LEGACY_SESSION_KEYS.forEach((key) => localStorage.removeItem(key));
|
||||||
setSession(null);
|
setSession(null);
|
||||||
setAssignment(null);
|
setAssignment(null);
|
||||||
setExhausted(false);
|
setExhausted(false);
|
||||||
|
|||||||
@ -17,7 +17,7 @@ const blankForm = {
|
|||||||
usage_scene: "",
|
usage_scene: "",
|
||||||
purchase_channel: "",
|
purchase_channel: "",
|
||||||
question_count: "",
|
question_count: "",
|
||||||
claim_questions_per_user: "",
|
claim_questions_per_user: "5",
|
||||||
};
|
};
|
||||||
|
|
||||||
const ADDED_QUESTION_SOURCES = new Set(["自主添加", "问答库", "批量导入"]);
|
const ADDED_QUESTION_SOURCES = new Set(["自主添加", "问答库", "批量导入"]);
|
||||||
@ -53,7 +53,13 @@ function isAddedQuestion(item) {
|
|||||||
|
|
||||||
export default function CreateAskTasksPage({ api, librarySelection = [], onLibrarySelectionConsumed }) {
|
export default function CreateAskTasksPage({ api, librarySelection = [], onLibrarySelectionConsumed }) {
|
||||||
const cached = readPageCache("create-ask-task", {});
|
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
|
// Only a task explicitly marked as a draft can be resumed. Older cached
|
||||||
// payloads did not include `status`, so treating them as editable could
|
// payloads did not include `status`, so treating them as editable could
|
||||||
// resend a published task id and leave the operator stuck on this page.
|
// 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 [referenceFileName, setReferenceFileName] = useState(cached.referenceFileName || "");
|
||||||
const [referenceQa, setReferenceQa] = useState(cached.referenceQa || "");
|
const [referenceQa, setReferenceQa] = useState(cached.referenceQa || "");
|
||||||
const [addDialog, setAddDialog] = useState(null);
|
const [addDialog, setAddDialog] = useState(null);
|
||||||
|
const [publishOpen, setPublishOpen] = useState(false);
|
||||||
const [linkCopied, setLinkCopied] = useState(false);
|
const [linkCopied, setLinkCopied] = useState(false);
|
||||||
const processingLibrarySelection = useRef(false);
|
const processingLibrarySelection = useRef(false);
|
||||||
const consumedLibrarySelectionKey = useRef("");
|
const consumedLibrarySelectionKey = useRef("");
|
||||||
@ -165,19 +172,12 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
|
|||||||
setReferenceFileName("");
|
setReferenceFileName("");
|
||||||
setReferenceQa("");
|
setReferenceQa("");
|
||||||
setAddDialog(null);
|
setAddDialog(null);
|
||||||
|
setPublishOpen(false);
|
||||||
if (referenceInputRef.current) referenceInputRef.current.value = "";
|
if (referenceInputRef.current) referenceInputRef.current.value = "";
|
||||||
if (productQrInputRef.current) productQrInputRef.current.value = "";
|
if (productQrInputRef.current) productQrInputRef.current.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
async function generate() {
|
async function generate() {
|
||||||
if (!form.shop_name.trim()) {
|
|
||||||
setError("请填写店铺名称");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!form.product_link.trim() && !form.product_qr_image) {
|
|
||||||
setError("请填写商品淘口令或上传商品二维码");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const intentError = analyzeProductIntent([
|
const intentError = analyzeProductIntent([
|
||||||
form.product_name,
|
form.product_name,
|
||||||
form.category,
|
form.category,
|
||||||
@ -188,7 +188,7 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
|
|||||||
setError(intentError);
|
setError(intentError);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const optionsError = validateGenerationOptions(form, false);
|
const optionsError = validateGenerationOptions({ ...form, claim_questions_per_user: "" }, false);
|
||||||
if (optionsError) {
|
if (optionsError) {
|
||||||
setError(optionsError);
|
setError(optionsError);
|
||||||
return;
|
return;
|
||||||
@ -411,18 +411,35 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function publish() {
|
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);
|
setBusy(true);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const result = await api(`/api/admin/ask-tasks/${task.id}/publish`, {
|
const result = await api("/api/admin/ask-tasks/publish", {
|
||||||
method: "POST",
|
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
|
// A successful response from this endpoint is authoritative even when
|
||||||
// an older backend build omits the raw workflow state from its payload.
|
// 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
|
// Keep the state explicit so the success/link view renders on the first
|
||||||
// click instead of waiting for a second request to return 409.
|
// click instead of waiting for a second request to return 409.
|
||||||
setTask({ ...result.data, status: result.data.status || "已发布" });
|
setTask({ ...result.data, status: result.data.status || "已发布" });
|
||||||
|
setPublishOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} finally {
|
||||||
@ -544,53 +561,10 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
|
|||||||
<section className="ask-config">
|
<section className="ask-config">
|
||||||
<div className="panel-title">
|
<div className="panel-title">
|
||||||
<Sparkles size={17} />
|
<Sparkles size={17} />
|
||||||
生成条件配置 <small>* 店铺名称必填,淘口令和二维码至少填写一种</small>
|
生成条件配置 <small>以下内容仅用于 AI 生成提问,发布信息将在发布时填写</small>
|
||||||
<button type="button" className="ask-clear-all" disabled={busy} onClick={clearAll}>一键清空</button>
|
<button type="button" className="ask-clear-all" disabled={busy} onClick={clearAll}>一键清空</button>
|
||||||
</div>
|
</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>
|
<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>
|
<label>
|
||||||
商品名称
|
商品名称
|
||||||
<input
|
<input
|
||||||
@ -599,7 +573,6 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
|
|||||||
placeholder="请输入商品名称"
|
placeholder="请输入商品名称"
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
<div className="two-col">
|
<div className="two-col">
|
||||||
<label>
|
<label>
|
||||||
商品品类
|
商品品类
|
||||||
@ -633,7 +606,6 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
|
|||||||
{referenceFileName && <button type="button" className="reference-qa-clear" onClick={clearReferenceFile}>清除已选文件</button>}
|
{referenceFileName && <button type="button" className="reference-qa-clear" onClick={clearReferenceFile}>清除已选文件</button>}
|
||||||
</label>
|
</label>
|
||||||
<h4>生成偏好与数量</h4>
|
<h4>生成偏好与数量</h4>
|
||||||
<div className="two-col">
|
|
||||||
<label>
|
<label>
|
||||||
提问生成条数 *
|
提问生成条数 *
|
||||||
<input
|
<input
|
||||||
@ -644,18 +616,6 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
|
|||||||
onChange={(event) => update("question_count", event.target.value)}
|
onChange={(event) => update("question_count", event.target.value)}
|
||||||
/>
|
/>
|
||||||
</label>
|
</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>
|
|
||||||
<div className="two-col">
|
<div className="two-col">
|
||||||
<label>
|
<label>
|
||||||
语言风格
|
语言风格
|
||||||
@ -761,12 +721,10 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="primary-button"
|
className="primary-button"
|
||||||
disabled={
|
disabled={!questions.length || busy}
|
||||||
!questions.some((item) => item.selected) || !task || busy
|
onClick={() => { setError(""); setPublishOpen(true); }}
|
||||||
}
|
|
||||||
onClick={publish}
|
|
||||||
>
|
>
|
||||||
{busy ? "发布中…" : "发布任务"}
|
发布任务
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -836,6 +794,43 @@ export default function CreateAskTasksPage({ api, librarySelection = [], onLibra
|
|||||||
)}
|
)}
|
||||||
{addDialog === "manual" && <ManualAskQuestionModal onClose={() => setAddDialog(null)} onAdd={addManualQuestion} />}
|
{addDialog === "manual" && <ManualAskQuestionModal onClose={() => setAddDialog(null)} onAdd={addManualQuestion} />}
|
||||||
{addDialog === "import" && <ImportAskQuestionsModal api={api} onClose={() => setAddDialog(null)} onImport={addImportedQuestions} />}
|
{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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3871,6 +3871,12 @@
|
|||||||
color: #466181;
|
color: #466181;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
.user-login-notice {
|
||||||
|
color: #d47a00;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
.answer-v2-login input {
|
.answer-v2-login input {
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid #c5d5e8;
|
border: 1px solid #c5d5e8;
|
||||||
@ -5133,6 +5139,27 @@
|
|||||||
padding: 3px 7px;
|
padding: 3px 7px;
|
||||||
font-size: 10px;
|
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 {
|
.ask-results {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3854,7 +3854,7 @@ function GlobalImageViewer() {
|
|||||||
|
|
||||||
function Dashboard({ user, onLogout }) {
|
function Dashboard({ user, onLogout }) {
|
||||||
const [active, setActive] = useState("assistant");
|
const [active, setActive] = useState("assistant");
|
||||||
const [createMode, setCreateMode] = useState("answer");
|
const [createMode, setCreateMode] = useState("ask");
|
||||||
const [reviewBadge, setReviewBadge] = useState(0);
|
const [reviewBadge, setReviewBadge] = useState(0);
|
||||||
const [librarySelection, setLibrarySelection] = useState([]);
|
const [librarySelection, setLibrarySelection] = useState([]);
|
||||||
async function loadReviewBadge() {
|
async function loadReviewBadge() {
|
||||||
@ -3917,7 +3917,7 @@ function Dashboard({ user, onLogout }) {
|
|||||||
const selectPage = (next) => {
|
const selectPage = (next) => {
|
||||||
if (user.permissions.includes(next)) {
|
if (user.permissions.includes(next)) {
|
||||||
setActive(next);
|
setActive(next);
|
||||||
if (next === "create") setCreateMode("answer");
|
if (next === "create") setCreateMode("ask");
|
||||||
if (next === "review") loadReviewBadge();
|
if (next === "review") loadReviewBadge();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@ -38,8 +38,8 @@ export function validateGenerationOptions(form, includeAnswers = true) {
|
|||||||
}
|
}
|
||||||
if (String(form.claim_questions_per_user ?? "").trim()) {
|
if (String(form.claim_questions_per_user ?? "").trim()) {
|
||||||
const claimCount = Number(form.claim_questions_per_user);
|
const claimCount = Number(form.claim_questions_per_user);
|
||||||
if (!Number.isInteger(claimCount) || claimCount < 2 || claimCount > 5) {
|
if (!Number.isInteger(claimCount) || claimCount < 2 || claimCount > 20) {
|
||||||
return "每个用户可认领的问题数量必须在 2-5 条之间";
|
return "每个用户可认领的问题数量必须在 2-20 条之间";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "";
|
return "";
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user