修改登录时淘宝ID的输入合法性,问答助手可批量导入问题生成回答,
This commit is contained in:
parent
3f15dd0ea3
commit
3ab8b52593
@ -13,7 +13,7 @@ from PIL import Image, ImageOps
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
from .config import settings
|
||||
from .schemas import QAGenerateRequest
|
||||
from .schemas import QABulkAnswerRequest, QAGenerateRequest
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@ -119,6 +119,102 @@ def generate_qa(payload: QAGenerateRequest) -> list[dict]:
|
||||
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]:
|
||||
"""Generate only consumer questions for the ask-task workflow."""
|
||||
if not settings.llm_api_key:
|
||||
|
||||
@ -24,9 +24,9 @@ from sqlalchemy.orm import Session, defer, noload, selectinload
|
||||
|
||||
from .config import settings
|
||||
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 .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
|
||||
|
||||
|
||||
@ -513,10 +513,10 @@ def _phone_login(payload: dict, db: Session) -> AnswerUser:
|
||||
|
||||
def _taobao_id(payload: dict) -> str:
|
||||
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(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="请输入正确的淘宝ID(3-64位字母、数字、下划线、点或短横线)",
|
||||
detail="请输入正确的淘宝ID(2-64位中文、英文字母、数字、下划线、点或短横线)",
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@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:
|
||||
try:
|
||||
answers = json.loads(entry.answers_json)
|
||||
|
||||
@ -42,6 +42,21 @@ class QAGenerateRequest(BaseModel):
|
||||
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):
|
||||
id: int
|
||||
username: str
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
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():
|
||||
unique_product = f"问答库测试商品-{uuid4().hex[:8]}"
|
||||
payload = {
|
||||
@ -179,6 +221,27 @@ def test_taobao_id_login_is_shared_by_both_user_portals():
|
||||
)
|
||||
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():
|
||||
taobao_id = f"tb{uuid4().hex[:14]}"
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
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 { isValidTaobaoId, normalizeTaobaoId, TAOBAO_ID_HINT } from './taobaoId.js'
|
||||
|
||||
const SESSION_KEY = 'answer_user_taobao_session_v2'
|
||||
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('')
|
||||
async function submit(event) {
|
||||
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)
|
||||
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) }
|
||||
}
|
||||
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 }) {
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { isValidTaobaoId, normalizeTaobaoId, TAOBAO_ID_HINT } from "./taobaoId.js";
|
||||
|
||||
const SESSION_KEY = "ask_user_taobao_session_v2";
|
||||
const LEGACY_SESSION_KEYS = ["ask_user_taobao_session", "ask_user_session"];
|
||||
@ -86,8 +87,9 @@ function TaobaoLogin({ api, onLogin }) {
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
if (!/^[A-Za-z0-9_.-]{3,64}$/.test(taobaoId.trim())) {
|
||||
setError("请输入正确的淘宝ID");
|
||||
const account = normalizeTaobaoId(taobaoId);
|
||||
if (!isValidTaobaoId(account)) {
|
||||
setError(TAOBAO_ID_HINT);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
@ -95,7 +97,7 @@ function TaobaoLogin({ api, onLogin }) {
|
||||
try {
|
||||
const result = await api("/api/ask/auth/taobao-login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ taobao_id: taobaoId.trim() }),
|
||||
body: JSON.stringify({ taobao_id: account }),
|
||||
});
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(result.data));
|
||||
onLogin(result.data);
|
||||
@ -128,12 +130,14 @@ function TaobaoLogin({ api, onLogin }) {
|
||||
autoFocus
|
||||
maxLength="64"
|
||||
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"
|
||||
autoCorrect="off"
|
||||
placeholder="例如:tb1008611"
|
||||
placeholder="例如:tb1008611 或 淘宝用户"
|
||||
/>
|
||||
<small className="user-login-notice">为了运营更好地核对信息,请填写真实的淘宝ID</small>
|
||||
<small className="user-login-notice">为了运营更好地核对信息,请填写真实的淘宝ID;支持中文、字母和数字</small>
|
||||
</label>
|
||||
{error && <div className="ask-user-error">{error}</div>}
|
||||
<button disabled={busy}>
|
||||
|
||||
@ -526,6 +526,57 @@
|
||||
padding-left: 3px;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
|
||||
@ -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)}>
|
||||
{item.accepted ? "✓ 已采纳" : "✓ 采纳"}
|
||||
</button>
|
||||
@ -930,12 +933,13 @@ function InlineQAItem({ item, onToggle, onDelete, onSave }) {
|
||||
) : (
|
||||
<>
|
||||
<h3>问题:{item.question}</h3>
|
||||
{item.answers.map((answer, index) => (
|
||||
{(item.answers || []).map((answer, index) => (
|
||||
<p key={`${item.id}-${index}`}>
|
||||
<b>回答{index + 1}:</b>
|
||||
{answer}
|
||||
</p>
|
||||
))}
|
||||
{!(item.answers || []).length && <p className="qa-empty-answer">尚未生成回答,请点击“编辑”补充</p>}
|
||||
</>
|
||||
)}
|
||||
</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() {
|
||||
const [form, setForm] = useState({
|
||||
product_name: "",
|
||||
@ -1587,6 +1692,7 @@ function AssistantPageFinal() {
|
||||
const [savedMessage, setSavedMessage] = useState("");
|
||||
const [adoptedDialog, setAdoptedDialog] = useState(null);
|
||||
const [manualOpen, setManualOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [referenceFile, setReferenceFile] = useState(null);
|
||||
const [referenceFileName, setReferenceFileName] = useState(cached.referenceFileName || "");
|
||||
const [referenceQa, setReferenceQa] = useState(cached.referenceQa || "");
|
||||
@ -1657,6 +1763,13 @@ function AssistantPageFinal() {
|
||||
}
|
||||
async function adopt(targetItems, showDialog = false) {
|
||||
if (!targetItems.length || saving) return;
|
||||
const readyItems = targetItems.filter((item) =>
|
||||
(item.answers || []).some((answer) => String(answer || "").trim()),
|
||||
);
|
||||
if (!readyItems.length) {
|
||||
setError("请先为问题补充回答,再采纳到问答库");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
setSavedMessage("");
|
||||
try {
|
||||
@ -1667,14 +1780,14 @@ function AssistantPageFinal() {
|
||||
category: form.category,
|
||||
benefits: form.benefits,
|
||||
selling_points: form.selling_points,
|
||||
items: targetItems.map(({ question, answers, source }) => ({
|
||||
items: readyItems.map(({ question, answers, source }) => ({
|
||||
question,
|
||||
answers,
|
||||
source,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
const ids = new Set(targetItems.map((item) => item.id));
|
||||
const ids = new Set(readyItems.map((item) => item.id));
|
||||
setItems((current) =>
|
||||
current.map((item) =>
|
||||
ids.has(item.id)
|
||||
@ -1683,7 +1796,7 @@ function AssistantPageFinal() {
|
||||
),
|
||||
);
|
||||
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(
|
||||
added > 0
|
||||
? `已将 ${added} 条问答存入问答库${skipped ? `,${skipped} 条重复内容已跳过` : ""}`
|
||||
@ -1700,6 +1813,10 @@ function AssistantPageFinal() {
|
||||
const target = items.find((item) => item.id === id);
|
||||
if (!target) return;
|
||||
if (target.accepted) return;
|
||||
if (!(target.answers || []).some((answer) => String(answer || "").trim())) {
|
||||
setError("请先为该问题补充回答,再采纳到问答库");
|
||||
return;
|
||||
}
|
||||
adopt([target]);
|
||||
}
|
||||
const acceptAll = () => adopt(items, true);
|
||||
@ -1727,6 +1844,54 @@ function AssistantPageFinal() {
|
||||
]);
|
||||
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;
|
||||
return (
|
||||
<>
|
||||
@ -1920,6 +2085,12 @@ function AssistantPageFinal() {
|
||||
>
|
||||
+ 手动添加Q&A
|
||||
</button>
|
||||
<button
|
||||
className="ghost-button"
|
||||
onClick={() => setImportOpen(true)}
|
||||
>
|
||||
<FileText size={14} /> 批量导入问题
|
||||
</button>
|
||||
<button
|
||||
className="success-button"
|
||||
onClick={acceptAll}
|
||||
@ -1956,6 +2127,9 @@ function AssistantPageFinal() {
|
||||
{manualOpen && (
|
||||
<ManualQAForm onClose={() => setManualOpen(false)} onSave={addManual} />
|
||||
)}
|
||||
{importOpen && (
|
||||
<AssistantQuestionImportModal form={form} onClose={() => setImportOpen(false)} onImport={addImportedQuestions} />
|
||||
)}
|
||||
{adoptedDialog && (
|
||||
<div
|
||||
className="adopt-success-backdrop"
|
||||
|
||||
11
frontend/src/taobaoId.js
Normal file
11
frontend/src/taobaoId.js
Normal 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));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user