修改登录时淘宝ID的输入合法性,问答助手可批量导入问题生成回答,

This commit is contained in:
db_huang 2026-09-17 14:30:10 +08:00
parent 3f15dd0ea3
commit 3ab8b52593
9 changed files with 453 additions and 19 deletions

View File

@ -13,7 +13,7 @@ from PIL import Image, ImageOps
from fastapi import HTTPException, status from fastapi import HTTPException, status
from .config import settings from .config import settings
from .schemas import QAGenerateRequest from .schemas import QABulkAnswerRequest, QAGenerateRequest
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -119,6 +119,102 @@ def generate_qa(payload: QAGenerateRequest) -> list[dict]:
return items[:target] return items[:target]
def _bulk_answers_prompt(payload: QABulkAnswerRequest, questions: list[str]) -> str:
style = "带真实体验的轻度种草语气" if payload.seed_style else "客观自然,不刻意种草"
question_lines = "\n".join(f"{index}. {question}" for index, question in enumerate(questions, start=1))
return f"""你是电商问大家回答助手。请严格按照给定问题逐条生成参考回答,不要新增、删除、合并或改写问题。
商品名称:{payload.product_name or '未提供'}
商品品类:{payload.category or '未提供'}
商品功效:{payload.benefits or '未提供'}
商品卖点:{payload.selling_points or '未提供'}
语言风格:{payload.language_style or '客观自然'}
目标人群:{payload.target_group or '普通消费者'}
使用场景:{payload.usage_scene or '日常使用'}
购买渠道:{payload.purchase_channel or '电商平台'}
回答要求:{payload.answer_requirement or '直接回应问题,避免夸大功效'}
回答语气:{style}
问题列表:
{question_lines}
每个问题生成 {payload.answers_per_question} 条不同角度的回答。回答尽量不超过50字,不虚构成分、检测、认证和医疗功效。
只返回严格 JSON,不要 Markdown 或解释。必须按问题序号原顺序返回,格式:
{{"items":[{{"index":1,"answers":["回答1","回答2"]}}]}}"""
def _generate_bulk_answers_batch(payload: QABulkAnswerRequest, questions: list[str]) -> list[list[str]]:
if not settings.llm_api_key:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="尚未配置大模型 API Key,请先设置 LLM_API_KEY")
url = settings.llm_base_url.rstrip("/") + "/chat/completions"
request_body = {
"model": settings.llm_model,
"temperature": 0.75,
"messages": [
{"role": "system", "content": "你只输出合法 JSON,并严格保持输入问题的数量和顺序。"},
{"role": "user", "content": _bulk_answers_prompt(payload, questions)},
],
}
request = urllib.request.Request(
url,
data=json.dumps(request_body).encode("utf-8"),
headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.llm_api_key}"},
method="POST",
)
try:
with urllib.request.urlopen(request, timeout=settings.llm_timeout_seconds) as response:
result = json.loads(response.read().decode("utf-8"))
content = str(result["choices"][0]["message"]["content"]).strip()
cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", content, flags=re.I | re.S).strip()
parsed = json.loads(cleaned)
raw_items = parsed.get("items") if isinstance(parsed, dict) else parsed
if not isinstance(raw_items, list):
raise ValueError("items is not a list")
by_index: dict[int, list[str]] = {}
for raw in raw_items:
if not isinstance(raw, dict):
continue
try:
index = int(raw.get("index"))
except (TypeError, ValueError):
continue
answers = raw.get("answers") or []
if isinstance(answers, str):
answers = [answers]
clean_answers = [str(answer).strip() for answer in answers if str(answer).strip()]
if len(clean_answers) >= payload.answers_per_question:
by_index[index] = clean_answers[:payload.answers_per_question]
if any(index not in by_index for index in range(1, len(questions) + 1)):
raise ValueError("answers are incomplete")
return [by_index[index] for index in range(1, len(questions) + 1)]
except urllib.error.HTTPError as exc:
logger.error("LLM bulk-answer HTTP error: status=%s url=%s", exc.code, url)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"大模型服务返回错误(HTTP {exc.code})") from exc
except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError, TypeError, ValueError) as exc:
logger.exception("LLM bulk-answer generation failed")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="批量回答生成不完整,请重试") from exc
def generate_answers_for_questions(payload: QABulkAnswerRequest, questions: list[str]) -> list[dict]:
"""Generate answers for exact imported questions without rewriting them."""
items: list[dict] = []
# Keep each response small enough that the provider can return every
# requested answer without truncating the JSON payload.
batch_size = max(1, min(10, 30 // payload.answers_per_question))
for offset in range(0, len(questions), batch_size):
batch = questions[offset:offset + batch_size]
answer_groups = _generate_bulk_answers_batch(payload, batch)
for question, answers in zip(batch, answer_groups):
items.append({
"id": f"Q{len(items) + 1:03d}",
"question": question,
"answers": answers,
"source": "批量导入(AI回答)",
"status": "待采纳",
"accepted": False,
})
return items
def _generate_questions_batch(payload: dict, count: int, reference_qa: str = "") -> list[str]: def _generate_questions_batch(payload: dict, count: int, reference_qa: str = "") -> list[str]:
"""Generate only consumer questions for the ask-task workflow.""" """Generate only consumer questions for the ask-task workflow."""
if not settings.llm_api_key: if not settings.llm_api_key:

View File

@ -24,9 +24,9 @@ from sqlalchemy.orm import Session, defer, noload, selectinload
from .config import settings from .config import settings
from .database import Base, SessionLocal, engine, get_db from .database import Base, SessionLocal, engine, get_db
from .llm import extract_questions_from_images, generate_answer_for_question, generate_questions, generate_qa from .llm import extract_questions_from_images, generate_answer_for_question, generate_answers_for_questions, generate_questions, generate_qa
from .models import AdminLoginLog, AdminUser, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QALibraryEntry, RefundUploadSession from .models import AdminLoginLog, AdminUser, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QALibraryEntry, RefundUploadSession
from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QAGenerateRequest, RegisterRequest from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QABulkAnswerRequest, QAGenerateRequest, RegisterRequest
from .security import create_session_token, decode_session_token, hash_password, verify_password from .security import create_session_token, decode_session_token, hash_password, verify_password
@ -513,10 +513,10 @@ def _phone_login(payload: dict, db: Session) -> AnswerUser:
def _taobao_id(payload: dict) -> str: def _taobao_id(payload: dict) -> str:
value = str(payload.get("taobao_id", "")).strip() value = str(payload.get("taobao_id", "")).strip()
if not re.fullmatch(r"[A-Za-z0-9_.-]{3,64}", value): if not re.fullmatch(r"[\u3400-\u4dbf\u4e00-\u9fffA-Za-z0-9_.-]{2,64}", value):
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="请输入正确的淘宝ID(3-64位字母、数字、下划线、点或短横线)", detail="请输入正确的淘宝ID(2-64位中文、英文字母、数字、下划线、点或短横线)",
) )
return value return value
@ -3542,6 +3542,24 @@ def qa_generate(payload: QAGenerateRequest, _: AdminUser = Depends(current_user)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="问答生成服务异常,请检查大模型配置后重试") from exc raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="问答生成服务异常,请检查大模型配置后重试") from exc
@app.post("/api/admin/qa/generate-answers")
def qa_generate_answers(payload: QABulkAnswerRequest, _: AdminUser = Depends(current_user)):
intent_error = _product_intent_error([payload.product_name, payload.category, payload.benefits, payload.selling_points])
if intent_error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=intent_error)
questions = _dedupe_import_questions(payload.questions)
if not questions:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请至少导入一个有效问题")
try:
return {"success": True, "data": {"items": generate_answers_for_questions(payload, questions)}}
except HTTPException:
raise
except Exception as exc:
import logging
logging.getLogger(__name__).exception("Bulk answer generation failed")
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="批量回答生成服务异常,请稍后重试") from exc
def _qa_library_data(entry: QALibraryEntry) -> dict: def _qa_library_data(entry: QALibraryEntry) -> dict:
try: try:
answers = json.loads(entry.answers_json) answers = json.loads(entry.answers_json)

View File

@ -42,6 +42,21 @@ class QAGenerateRequest(BaseModel):
seed_style: bool = True seed_style: bool = True
class QABulkAnswerRequest(BaseModel):
product_name: str = Field(default="", max_length=200)
category: str = Field(default="", max_length=100)
benefits: str = Field(default="", max_length=1000)
selling_points: str = Field(default="", max_length=2000)
language_style: str = Field(default="口语(亲切随性)", max_length=40)
target_group: str = Field(default="女性", max_length=100)
usage_scene: str = Field(default="居家", max_length=100)
purchase_channel: str = Field(default="电商平台", max_length=100)
answers_per_question: int = Field(default=2, ge=1, le=20)
answer_requirement: str = Field(default="", max_length=1000)
seed_style: bool = True
questions: list[str] = Field(min_length=1, max_length=500)
class UserOut(BaseModel): class UserOut(BaseModel):
id: int id: int
username: str username: str

View File

@ -89,6 +89,48 @@ def test_qa_generation_requires_product_and_model_key():
assert client.post("/api/admin/qa/generate", json={"question_count": 2}).status_code == 400 assert client.post("/api/admin/qa/generate", json={"question_count": 2}).status_code == 400
def test_imported_questions_can_generate_configured_answer_count(monkeypatch):
captured = {}
def fake_bulk_answers(payload, questions):
captured["questions"] = questions
captured["answers_per_question"] = payload.answers_per_question
return [
{
"id": f"Q{index:03d}",
"question": question,
"answers": [f"回答{answer_index}" for answer_index in range(1, payload.answers_per_question + 1)],
"source": "批量导入(AI回答)",
"status": "待采纳",
"accepted": False,
}
for index, question in enumerate(questions, start=1)
]
monkeypatch.setattr(main_module, "generate_answers_for_questions", fake_bulk_answers)
payload = {
"product_name": "批量回答测试商品",
"questions": ["导入问题一?", "导入问题二?", "导入问题一?"],
"answers_per_question": 3,
}
with TestClient(app) as client:
assert client.post("/api/admin/qa/generate-answers", json=payload).status_code == 401
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
assert client.post(
"/api/admin/qa/generate-answers",
json={"questions": ["缺少商品信息?"], "answers_per_question": 2},
).status_code == 400
response = client.post("/api/admin/qa/generate-answers", json=payload)
assert response.status_code == 200
assert captured == {
"questions": ["导入问题一?", "导入问题二?"],
"answers_per_question": 3,
}
items = response.json()["data"]["items"]
assert [item["question"] for item in items] == captured["questions"]
assert all(len(item["answers"]) == 3 for item in items)
def test_accepted_qa_is_saved_to_library_without_duplicates(): def test_accepted_qa_is_saved_to_library_without_duplicates():
unique_product = f"问答库测试商品-{uuid4().hex[:8]}" unique_product = f"问答库测试商品-{uuid4().hex[:8]}"
payload = { payload = {
@ -179,6 +221,27 @@ def test_taobao_id_login_is_shared_by_both_user_portals():
) )
assert invalid.status_code == 400 assert invalid.status_code == 400
chinese_taobao_id = f"淘宝用户{uuid4().hex[:6]}"
chinese_login = client.post(
"/api/ask/auth/taobao-login",
json={"taobao_id": chinese_taobao_id},
)
assert chinese_login.status_code == 200
assert chinese_login.json()["data"]["taobao_id"] == chinese_taobao_id
answer_chinese_login = client.post(
"/api/answer/auth/taobao-login",
json={"taobao_id": chinese_taobao_id, "owner_token": owner_token},
)
assert answer_chinese_login.status_code == 200
assert answer_chinese_login.json()["data"]["token"] == chinese_login.json()["data"]["token"]
invalid_symbol = client.post(
"/api/ask/auth/taobao-login",
json={"taobao_id": "淘宝用户@测试"},
)
assert invalid_symbol.status_code == 400
def test_legacy_public_user_sessions_are_invalidated_and_rotated_on_taobao_login(): def test_legacy_public_user_sessions_are_invalidated_and_rotated_on_taobao_login():
taobao_id = f"tb{uuid4().hex[:14]}" taobao_id = f"tb{uuid4().hex[:14]}"

View File

@ -1,5 +1,6 @@
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'
import { isValidTaobaoId, normalizeTaobaoId, TAOBAO_ID_HINT } from './taobaoId.js'
const SESSION_KEY = 'answer_user_taobao_session_v2' const SESSION_KEY = 'answer_user_taobao_session_v2'
const LEGACY_SESSION_KEYS = ['answer_user_taobao_session', 'answer_user_session'] const LEGACY_SESSION_KEYS = ['answer_user_taobao_session', 'answer_user_session']
@ -25,12 +26,13 @@ function Login({ api, onLogin, linkToken }) {
const [taobaoId, setTaobaoId] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState('') const [taobaoId, setTaobaoId] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState('')
async function submit(event) { async function submit(event) {
event.preventDefault(); setError('') event.preventDefault(); setError('')
if (!/^[A-Za-z0-9_.-]{3,64}$/.test(taobaoId.trim())) return setError('请输入正确的淘宝ID') const account = normalizeTaobaoId(taobaoId)
if (!isValidTaobaoId(account)) return setError(TAOBAO_ID_HINT)
setBusy(true) setBusy(true)
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: account, 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/><small className="user-login-notice">为了运营更好地核对信息,请填写真实的淘宝ID</small></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.slice(0, 64))} maxLength={64} inputMode="text" lang="zh-CN" 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 }) {

View File

@ -11,6 +11,7 @@ import {
ShieldCheck, ShieldCheck,
Trash2, Trash2,
} from "lucide-react"; } from "lucide-react";
import { isValidTaobaoId, normalizeTaobaoId, TAOBAO_ID_HINT } from "./taobaoId.js";
const SESSION_KEY = "ask_user_taobao_session_v2"; const SESSION_KEY = "ask_user_taobao_session_v2";
const LEGACY_SESSION_KEYS = ["ask_user_taobao_session", "ask_user_session"]; const LEGACY_SESSION_KEYS = ["ask_user_taobao_session", "ask_user_session"];
@ -86,8 +87,9 @@ function TaobaoLogin({ api, onLogin }) {
async function submit(event) { async function submit(event) {
event.preventDefault(); event.preventDefault();
if (!/^[A-Za-z0-9_.-]{3,64}$/.test(taobaoId.trim())) { const account = normalizeTaobaoId(taobaoId);
setError("请输入正确的淘宝ID"); if (!isValidTaobaoId(account)) {
setError(TAOBAO_ID_HINT);
return; return;
} }
setBusy(true); setBusy(true);
@ -95,7 +97,7 @@ function TaobaoLogin({ api, onLogin }) {
try { try {
const result = await api("/api/ask/auth/taobao-login", { const result = await api("/api/ask/auth/taobao-login", {
method: "POST", method: "POST",
body: JSON.stringify({ taobao_id: taobaoId.trim() }), body: JSON.stringify({ taobao_id: account }),
}); });
localStorage.setItem(SESSION_KEY, JSON.stringify(result.data)); localStorage.setItem(SESSION_KEY, JSON.stringify(result.data));
onLogin(result.data); onLogin(result.data);
@ -128,12 +130,14 @@ function TaobaoLogin({ api, onLogin }) {
autoFocus autoFocus
maxLength="64" maxLength="64"
value={taobaoId} value={taobaoId}
onChange={(event) => setTaobaoId(event.target.value.replace(/\s/g, ""))} onChange={(event) => setTaobaoId(event.target.value.slice(0, 64))}
inputMode="text"
lang="zh-CN"
autoCapitalize="none" autoCapitalize="none"
autoCorrect="off" autoCorrect="off"
placeholder="例如:tb1008611" placeholder="例如:tb1008611 或 淘宝用户"
/> />
<small className="user-login-notice">为了运营更好地核对信息,请填写真实的淘宝ID</small> <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}>

View File

@ -526,6 +526,57 @@
padding-left: 3px; padding-left: 3px;
line-height: 1.5; line-height: 1.5;
} }
.assistant-import-options {
display: grid;
gap: 12px;
margin: 16px 0 0;
padding: 14px;
border: 1px solid #d8e4f2;
border-radius: 8px;
}
.assistant-import-options legend {
padding: 0 5px;
color: #405a7d;
font-size: 12px;
font-weight: 700;
}
.assistant-import-mode {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 4px;
padding: 4px;
border-radius: 8px;
background: #edf2f8;
}
.assistant-import-mode button {
padding: 9px;
border-radius: 6px;
background: transparent;
color: #617995;
}
.assistant-import-mode button.active {
background: #fff;
color: #1764e8;
font-weight: 700;
box-shadow: 0 2px 7px rgba(34, 65, 105, .1);
}
.assistant-import-options label {
display: grid;
gap: 7px;
color: #405a7d;
font-size: 12px;
}
.assistant-import-options small {
color: #8296ae;
font-size: 11px;
}
.qa-empty-answer {
color: #c07a00;
font-style: italic;
}
.result-toolbar .ghost-button svg {
vertical-align: -2px;
}
.ask-library-search { .ask-library-search {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto;

View File

@ -873,7 +873,10 @@ function InlineQAItem({ item, onToggle, onDelete, onSave }) {
</> </>
) : ( ) : (
<> <>
<button onClick={() => setEditing(true)}>编辑</button> <button onClick={() => {
if (!answers.length) setAnswers([""]);
setEditing(true);
}}>编辑</button>
<button className="accept-link" onClick={() => onToggle(item.id)}> <button className="accept-link" onClick={() => onToggle(item.id)}>
{item.accepted ? "✓ 已采纳" : "✓ 采纳"} {item.accepted ? "✓ 已采纳" : "✓ 采纳"}
</button> </button>
@ -930,12 +933,13 @@ function InlineQAItem({ item, onToggle, onDelete, onSave }) {
) : ( ) : (
<> <>
<h3>问题:{item.question}</h3> <h3>问题:{item.question}</h3>
{item.answers.map((answer, index) => ( {(item.answers || []).map((answer, index) => (
<p key={`${item.id}-${index}`}> <p key={`${item.id}-${index}`}>
<b>回答{index + 1}:</b> <b>回答{index + 1}:</b>
{answer} {answer}
</p> </p>
))} ))}
{!(item.answers || []).length && <p className="qa-empty-answer">尚未生成回答,请点击“编辑”补充</p>}
</> </>
)} )}
</article> </article>
@ -1029,6 +1033,107 @@ function ManualQAForm({ onClose, onSave }) {
); );
} }
function AssistantQuestionImportModal({ form, onClose, onImport }) {
const inputRef = useRef(null);
const [file, setFile] = useState(null);
const [questions, setQuestions] = useState([]);
const [generateAnswers, setGenerateAnswers] = useState(true);
const [answersPerQuestion, setAnswersPerQuestion] = useState(
String(Number(form.answers_per_question) || 2),
);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
async function selectFile(nextFile) {
setFile(nextFile || null);
setQuestions([]);
setError("");
if (!nextFile) return;
setBusy(true);
try {
const body = new FormData();
body.append("file", nextFile);
const result = await api("/api/admin/ask-questions/import", { method: "POST", body });
setQuestions(result.data.questions || []);
} catch (err) {
setError(err.message || "文件解析失败");
} finally {
setBusy(false);
}
}
async function submit(event) {
event.preventDefault();
if (!questions.length || busy) return;
const answerCount = Number(answersPerQuestion);
if (generateAnswers && (!Number.isInteger(answerCount) || answerCount < 1 || answerCount > 20)) {
setError("每个问题的回答条数必须是 1-20 的整数");
return;
}
setBusy(true);
setError("");
try {
await onImport(questions, generateAnswers, answerCount);
} catch (err) {
setError(err.message || "批量导入失败");
setBusy(false);
}
}
return (
<div className="manual-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && !busy && onClose()}>
<form className="manual-modal ask-import-dialog assistant-import-dialog" onSubmit={submit}>
<div className="manual-modal-header">
<h3>批量导入问题</h3>
<button type="button" disabled={busy} onClick={onClose}>×</button>
</div>
<p>导入本地问题文件,并选择是否为每个问题自动生成参考回答。</p>
<input
ref={inputRef}
hidden
type="file"
accept=".txt,.csv,.json,.md,.xlsx,text/plain,text/csv,application/json,text/markdown,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
onChange={(event) => selectFile(event.target.files?.[0] || null)}
/>
<button type="button" className="ask-import-picker" disabled={busy} onClick={() => inputRef.current?.click()}>
<FileText size={22} />
<span>{file ? file.name : "选择本地问题文件"}<small>支持 TXT、CSV、JSON、Markdown、XLSX,最大 5MB</small></span>
</button>
<small className="ask-import-rule">TXT、Markdown 每行一个问题;表格文件优先读取“问题”列。</small>
<fieldset className="assistant-import-options">
<legend>是否自动生成回答</legend>
<div className="assistant-import-mode" role="group" aria-label="是否自动生成回答">
<button type="button" className={!generateAnswers ? "active" : ""} onClick={() => setGenerateAnswers(false)}>不生成回答</button>
<button type="button" className={generateAnswers ? "active" : ""} onClick={() => setGenerateAnswers(true)}>自动生成回答</button>
</div>
{generateAnswers && (
<label>
每个问题生成回答条数
<input type="number" min="1" max="20" step="1" value={answersPerQuestion} onChange={(event) => setAnswersPerQuestion(event.target.value)} />
<small>可填写 1-20 条,默认 2 条</small>
</label>
)}
</fieldset>
{busy && !questions.length && <div className="ask-import-status">正在解析文件…</div>}
{error && <div className="form-error">{error}</div>}
{questions.length > 0 && (
<div className="ask-import-preview">
<header><strong>识别到 {questions.length} 条问题</strong><span>重复问题已自动排除</span></header>
<ol>{questions.slice(0, 8).map((question, index) => <li key={`${question}-${index}`}>{question}</li>)}</ol>
{questions.length > 8 && <small>还有 {questions.length - 8} 条未展开显示</small>}
</div>
)}
<div className="manual-modal-actions">
<button type="button" disabled={busy} onClick={onClose}>取消</button>
<button type="submit" className="primary-button" disabled={!questions.length || busy}>
{busy ? (generateAnswers ? "正在生成回答…" : "正在导入…") : `导入 ${questions.length || 0} 条问题`}
</button>
</div>
</form>
</div>
);
}
function AssistantPageV2() { function AssistantPageV2() {
const [form, setForm] = useState({ const [form, setForm] = useState({
product_name: "", product_name: "",
@ -1587,6 +1692,7 @@ function AssistantPageFinal() {
const [savedMessage, setSavedMessage] = useState(""); const [savedMessage, setSavedMessage] = useState("");
const [adoptedDialog, setAdoptedDialog] = useState(null); const [adoptedDialog, setAdoptedDialog] = useState(null);
const [manualOpen, setManualOpen] = useState(false); const [manualOpen, setManualOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [referenceFile, setReferenceFile] = useState(null); const [referenceFile, setReferenceFile] = useState(null);
const [referenceFileName, setReferenceFileName] = useState(cached.referenceFileName || ""); const [referenceFileName, setReferenceFileName] = useState(cached.referenceFileName || "");
const [referenceQa, setReferenceQa] = useState(cached.referenceQa || ""); const [referenceQa, setReferenceQa] = useState(cached.referenceQa || "");
@ -1657,6 +1763,13 @@ function AssistantPageFinal() {
} }
async function adopt(targetItems, showDialog = false) { async function adopt(targetItems, showDialog = false) {
if (!targetItems.length || saving) return; if (!targetItems.length || saving) return;
const readyItems = targetItems.filter((item) =>
(item.answers || []).some((answer) => String(answer || "").trim()),
);
if (!readyItems.length) {
setError("请先为问题补充回答,再采纳到问答库");
return;
}
setSaving(true); setSaving(true);
setSavedMessage(""); setSavedMessage("");
try { try {
@ -1667,14 +1780,14 @@ function AssistantPageFinal() {
category: form.category, category: form.category,
benefits: form.benefits, benefits: form.benefits,
selling_points: form.selling_points, selling_points: form.selling_points,
items: targetItems.map(({ question, answers, source }) => ({ items: readyItems.map(({ question, answers, source }) => ({
question, question,
answers, answers,
source, source,
})), })),
}), }),
}); });
const ids = new Set(targetItems.map((item) => item.id)); const ids = new Set(readyItems.map((item) => item.id));
setItems((current) => setItems((current) =>
current.map((item) => current.map((item) =>
ids.has(item.id) ids.has(item.id)
@ -1683,7 +1796,7 @@ function AssistantPageFinal() {
), ),
); );
const { added_count: added, skipped_count: skipped } = result.data; const { added_count: added, skipped_count: skipped } = result.data;
if (showDialog) setAdoptedDialog({ count: targetItems.length, added, skipped }); if (showDialog) setAdoptedDialog({ count: readyItems.length, added, skipped });
setSavedMessage( setSavedMessage(
added > 0 added > 0
? `已将 ${added} 条问答存入问答库${skipped ? `,${skipped} 条重复内容已跳过` : ""}` ? `已将 ${added} 条问答存入问答库${skipped ? `,${skipped} 条重复内容已跳过` : ""}`
@ -1700,6 +1813,10 @@ function AssistantPageFinal() {
const target = items.find((item) => item.id === id); const target = items.find((item) => item.id === id);
if (!target) return; if (!target) return;
if (target.accepted) return; if (target.accepted) return;
if (!(target.answers || []).some((answer) => String(answer || "").trim())) {
setError("请先为该问题补充回答,再采纳到问答库");
return;
}
adopt([target]); adopt([target]);
} }
const acceptAll = () => adopt(items, true); const acceptAll = () => adopt(items, true);
@ -1727,6 +1844,54 @@ function AssistantPageFinal() {
]); ]);
setManualOpen(false); setManualOpen(false);
}; };
async function addImportedQuestions(questions, generateAnswers, answersPerQuestion) {
const existing = new Set(items.map((item) => String(item.question || "").trim().toLocaleLowerCase()));
const newQuestions = questions.filter((question) => !existing.has(String(question || "").trim().toLocaleLowerCase()));
if (!newQuestions.length) {
setSavedMessage("导入的问题已全部存在于当前列表中");
setImportOpen(false);
return;
}
let importedItems;
if (generateAnswers) {
const intentError = analyzeProductIntent([
form.product_name,
form.category,
form.benefits,
form.selling_points,
]);
if (intentError) throw new Error(`自动生成回答前,${intentError}`);
const result = await api("/api/admin/qa/generate-answers", {
method: "POST",
body: JSON.stringify({
...form,
questions: newQuestions,
answers_per_question: answersPerQuestion,
}),
});
importedItems = result.data.items || [];
} else {
importedItems = newQuestions.map((question) => ({
question,
answers: [],
source: "批量导入",
status: "待补充回答",
accepted: false,
}));
}
const idPrefix = `IMP${Date.now()}`;
setItems((current) => [
...current,
...importedItems.map((item, index) => ({ ...item, id: `${idPrefix}-${index + 1}` })),
]);
setSavedMessage(
generateAnswers
? `已导入 ${importedItems.length} 条问题,并生成每题 ${answersPerQuestion} 条回答`
: `已导入 ${importedItems.length} 条问题,可点击“编辑”补充回答`,
);
setError("");
setImportOpen(false);
}
const selected = items.filter((item) => item.accepted).length; const selected = items.filter((item) => item.accepted).length;
return ( return (
<> <>
@ -1920,6 +2085,12 @@ function AssistantPageFinal() {
> >
+ 手动添加Q&A + 手动添加Q&A
</button> </button>
<button
className="ghost-button"
onClick={() => setImportOpen(true)}
>
<FileText size={14} /> 批量导入问题
</button>
<button <button
className="success-button" className="success-button"
onClick={acceptAll} onClick={acceptAll}
@ -1956,6 +2127,9 @@ function AssistantPageFinal() {
{manualOpen && ( {manualOpen && (
<ManualQAForm onClose={() => setManualOpen(false)} onSave={addManual} /> <ManualQAForm onClose={() => setManualOpen(false)} onSave={addManual} />
)} )}
{importOpen && (
<AssistantQuestionImportModal form={form} onClose={() => setImportOpen(false)} onImport={addImportedQuestions} />
)}
{adoptedDialog && ( {adoptedDialog && (
<div <div
className="adopt-success-backdrop" className="adopt-success-backdrop"

11
frontend/src/taobaoId.js Normal file
View File

@ -0,0 +1,11 @@
export const TAOBAO_ID_HINT = "请输入2-64位淘宝ID,支持中文、英文字母、数字、下划线、点和短横线";
const TAOBAO_ID_PATTERN = /^[\p{Script=Han}A-Za-z0-9_.-]{2,64}$/u;
export function normalizeTaobaoId(value) {
return String(value || "").trim();
}
export function isValidTaobaoId(value) {
return TAOBAO_ID_PATTERN.test(normalizeTaobaoId(value));
}