wendajia/frontend/src/main.jsx

4464 lines
154 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import {
BookOpen,
ClipboardList,
Database,
BarChart3,
FileText,
LogOut,
MessageCircle,
Plus,
Search,
Settings,
ShieldCheck,
Sparkles,
Users,
} from "lucide-react";
import "./styles.css";
import "./admin.css";
import CreateAnswerTasksPageV2 from "./CreateAnswerTasksPage.jsx";
import AnswerUserPortalV2 from "./AnswerUserPortal.jsx";
import TaskBoardPage, { clearTaskBoardCache } from "./TaskBoardPage.jsx";
import ReviewCenterPage from "./ReviewCenterPage.jsx";
import CreateAskTasksPage from "./CreateAskTasksPage.jsx";
import AskUserPortal from "./AskUserPortal.jsx";
import RefundUploadPage from "./RefundUploadPage.jsx";
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
import { readPageCache, writePageCache } from "./pageCache.js";
// 兼容公共回答链接末尾有无斜杠,避免 /answer 被识别为运营后台路由。
if (window.location.pathname === "/answer")
window.history.replaceState(null, "", "/answer/");
const publicPortalUserAgent = navigator.userAgent || "";
const isDingTalkMobile =
(/DingTalk|AliApp\(DT/i.test(publicPortalUserAgent) &&
/Android|iPhone|iPad|iPod|Mobile/i.test(publicPortalUserAgent)) ||
(new URLSearchParams(window.location.search).get("from") === "dingtalk" &&
window.matchMedia("(max-width: 700px)").matches);
if (isDingTalkMobile) document.documentElement.classList.add("dingtalk-mobile");
const API = import.meta.env.VITE_API_BASE || "";
const Z = {
brand: "\u3010\u95ee\u5927\u5bb6\u3011",
backend: "\u8fd0\u8425\u7ba1\u7406\u540e\u53f0",
subtitle:
"\u667a\u80fd\u95ee\u7b54\u5e93\u4e0e\u4efb\u52a1\u7ba1\u7406\u7cfb\u7edf",
username: "\u7528\u6237\u540d",
password: "\u5bc6\u7801",
login: "\u767b\u5f55\u540e\u53f0",
register: "\u6ce8\u518c\u7528\u6237",
displayName: "\u663e\u793a\u540d\u79f0",
};
async function api(path, options = {}) {
const isForm = options.body instanceof FormData;
const response = await fetch(`${API}${path}`, {
credentials: "include",
headers: isForm
? { ...(options.headers || {}) }
: { "Content-Type": "application/json", ...(options.headers || {}) },
...options,
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const message = data.detail || `请求失败(HTTP ${response.status})`;
throw new Error(message);
}
return data;
}
async function readReferenceFile(file, requestApi = api) {
if (!file) return "";
if (/\.xlsx$/i.test(file.name)) {
const body = new FormData();
body.append("file", file);
const result = await requestApi("/api/admin/reference-qa/parse", { method: "POST", body });
return result.data.text || "";
}
const text = await file.text();
if (file.name.toLowerCase().endsWith(".json")) {
try {
const data = JSON.parse(text);
if (Array.isArray(data)) return data.map((item) => `问题:${item.question || item.q || ""}\n回答:${(item.answers || item.answer || item.a || []).toString()}`).join("\n");
if (Array.isArray(data.items)) return data.items.map((item) => `问题:${item.question || ""}\n回答:${(item.answers || []).join(";")}`).join("\n");
} catch {
// Keep the original text so the backend/model can still interpret it.
}
}
return text.slice(0, 20000);
}
function LoginPage({ onLogin }) {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [dingtalkLoading, setDingtalkLoading] = useState(false);
const [autoDingtalkLogin, setAutoDingtalkLogin] = useState(false);
const [dingtalkLoginFailed, setDingtalkLoginFailed] = useState(false);
const autoLoginStarted = useRef(false);
const query = new URLSearchParams(window.location.search);
const getDingTalkBridge = () => window.dd || window.DingTalkPC || window.dingtalk;
// The DingTalk SDK is loaded on every page, including a normal browser.
// Do not treat the mere presence of `window.dd` as proof that we are inside
// the DingTalk workbench, otherwise local/browser users lose password login.
const inDingtalkWorkbench = /DingTalk|AliApp\(DT/i.test(navigator.userAgent || "") || query.get("from") === "dingtalk";
function loadDingTalkSdk() {
if (getDingTalkBridge()) return Promise.resolve(getDingTalkBridge());
return new Promise((resolve, reject) => {
const existing = document.querySelector('script[data-dingtalk-sdk="true"]');
const urls = [
existing?.src || "https://g.alicdn.com/dingding/dingtalk-jsapi/2.10.3/dingtalk.open.js",
"https://g.alicdn.com/dingding/dingtalk-jsapi/2.15.15/dingtalk.open.js",
];
let urlIndex = 0;
let settled = false;
const finish = () => {
if (settled) return;
if (getDingTalkBridge()) resolve(getDingTalkBridge());
else if (urlIndex < urls.length - 1) {
urlIndex += 1;
const fallback = document.createElement("script");
fallback.src = urls[urlIndex];
fallback.async = true;
fallback.dataset.dingtalkSdk = "true";
fallback.onload = finish;
fallback.onerror = finish;
document.head.appendChild(fallback);
} else {
settled = true;
reject(new Error("钉钉JSAPI未加载,请确认应用类型为H5微应用,并将 ask.dieya.net.cn 加入应用安全域名"));
}
};
if (existing) {
existing.addEventListener("load", finish, { once: true });
existing.addEventListener("error", finish, { once: true });
window.setTimeout(finish, 5000);
return;
}
const script = document.createElement("script");
script.src = urls[0];
script.async = true;
script.dataset.dingtalkSdk = "true";
script.onload = finish;
script.onerror = finish;
document.head.appendChild(script);
window.setTimeout(finish, 8000);
});
}
async function requestDingTalkAuthCode(corpId) {
const ding = (await loadDingTalkSdk()) || getDingTalkBridge();
return new Promise((resolve, reject) => {
if (!ding) return reject(new Error("未检测到钉钉JSAPI,请将当前地址配置为钉钉H5微应用首页,并加入应用安全域名"));
let settled = false;
const finish = (callback) => (value) => {
if (settled) return;
settled = true;
callback(value);
};
const success = finish((result) => {
const code = result?.code || result?.authCode;
code ? resolve(code) : reject(new Error("钉钉未返回免登授权码"));
});
const failure = finish((error) => reject(new Error(error?.errorMessage || error?.message || "钉钉免登授权失败")));
const invoke = () => {
try {
const requestAuthCode = ding.runtime?.permission?.requestAuthCode || ding.permission?.requestAuthCode;
if (requestAuthCode) {
requestAuthCode.call(ding.runtime?.permission || ding.permission, { corpId, onSuccess: success, onFail: failure });
return;
}
if (typeof ding.getAuthCode === "function") {
ding.getAuthCode({ onSuccess: success, onFail: failure });
return;
}
throw new Error("当前钉钉版本不支持工作台免登JSAPI");
} catch (error) {
failure(error);
}
};
if (typeof ding.ready === "function") ding.ready(invoke);
else invoke();
window.setTimeout(() => failure(new Error("钉钉免登请求超时,请确认应用已发布并开通免登权限")), 10000);
});
}
async function loginWithDingTalkWorkbench() {
setDingtalkLoading(true);
setError("");
setDingtalkLoginFailed(false);
try {
const config = await api("/api/admin/auth/dingtalk/config");
if (!config.data?.enabled || !config.data?.corp_id) throw new Error("钉钉工作台免登尚未配置,请联系管理员");
const code = await requestDingTalkAuthCode(config.data.corp_id);
const result = await api("/api/admin/auth/dingtalk/workbench", { method: "POST", body: JSON.stringify({ code }) });
onLogin(result.data.user);
} catch (err) {
setError(err.message);
setDingtalkLoginFailed(true);
setAutoDingtalkLogin(false);
setDingtalkLoading(false);
}
}
async function submit(event) {
event.preventDefault();
setError("");
if (!username.trim() || !password)
return setError("\u8bf7\u5b8c\u6574\u586b\u5199\u4fe1\u606f");
setLoading(true);
try {
const result = await api("/api/admin/auth/login", {
method: "POST",
body: JSON.stringify({ username, password }),
});
onLogin(result.data.user);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => {
if (!inDingtalkWorkbench || autoLoginStarted.current) return;
autoLoginStarted.current = true;
setAutoDingtalkLogin(true);
loginWithDingTalkWorkbench();
}, []);
if (autoDingtalkLogin) {
return <div className="loading-screen">正在验证钉钉身份…</div>;
}
if (inDingtalkWorkbench && dingtalkLoginFailed) {
return (
<main className="login-shell">
<section className="login-card dingtalk-workbench-card">
<div className="brand-mark"><MessageCircle size={28} /></div>
<div className="brand-title">{Z.brand}</div>
<h1>钉钉工作台免登</h1>
<p className="login-subtitle">正在使用当前钉钉账号验证身份,不需要输入后台账号密码。</p>
<div className="form-error">{error || "钉钉免登失败,请稍后重试"}</div>
<button className="primary-button login-button" type="button" onClick={() => { setAutoDingtalkLogin(true); loginWithDingTalkWorkbench(); }} disabled={dingtalkLoading}>
{dingtalkLoading ? "正在验证钉钉身份…" : "重新验证"}
</button>
<div className="login-hint">请从钉钉工作台打开此应用,并确认应用已发布、配置安全域名和免登权限。</div>
</section>
</main>
);
}
return (
<main className="login-shell">
<div className="login-decoration">
<div className="orb orb-a" />
<div className="orb orb-b" />
<div className="grid-lines" />
</div>
<section className="login-card">
<div className="brand-mark">
<MessageCircle size={28} />
</div>
<div className="brand-title">{Z.brand}</div>
<h1>{Z.backend}</h1>
<p className="login-subtitle">从钉钉工作台进入,使用钉钉身份安全登录</p>
{!inDingtalkWorkbench && <div className="login-divider"><span>运营后台账号登录</span></div>}
<form onSubmit={submit} className="login-form">
<label>
{Z.username}
<input
autoFocus
value={username}
onChange={(event) => setUsername(event.target.value)}
placeholder={"\u8bf7\u8f93\u5165\u7528\u6237\u540d"}
/>
</label>
<label>
{Z.password}
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder={
"\u8bf7\u8f93\u5165\u5bc6\u7801"
}
/>
</label>
{error && <div className="form-error">{error}</div>}
<button className="primary-button login-button" disabled={loading}>
{loading ? "\u767b\u5f55\u4e2d\u2026" : Z.login}
</button>
</form>
<div className="login-hint">
{
"\u8fd0\u8425\u8d26\u53f7\u767b\u5f55 \u00b7 \u7528\u6237 A/B \u8bf7\u4f7f\u7528\u72ec\u7acb\u4efb\u52a1\u94fe\u63a5"
}
</div>
</section>
</main>
);
}
const BASE_ITEMS = [
{
id: "assistant",
icon: Sparkles,
label: "\u95ee\u7b54\u52a9\u624b",
group: "\u95ee\u7b54\u7ba1\u7406",
tag: "AI",
},
{
id: "library",
icon: Database,
label: "\u95ee\u7b54\u5e93",
group: "\u95ee\u7b54\u7ba1\u7406",
},
{
id: "create",
icon: Plus,
label: "\u521b\u5efa\u4efb\u52a1",
group: "\u4efb\u52a1\u7ba1\u7406",
},
{
id: "board",
icon: ClipboardList,
label: "\u4efb\u52a1\u770b\u677f",
group: "\u4efb\u52a1\u7ba1\u7406",
},
{
id: "review",
icon: ShieldCheck,
label: "\u5ba1\u6838\u4e2d\u5fc3",
group: "\u4efb\u52a1\u7ba1\u7406",
},
{
id: "guide",
icon: BookOpen,
label: "\u4f7f\u7528\u8bf4\u660e",
group: "\u4efb\u52a1\u7ba1\u7406",
href: "https://alidocs.dingtalk.com/i/nodes/amweZ92PV6w4kNGdcqEmwAMYJxEKBD6p?corpId=dinga78635fdab496f8735c2f4657eb6378f&doc_type=wiki_doc&editNotify=true&iframeQuery=utm_medium=im_card&utm_source=im&utm_medium=im_card&utm_scene=team_space&utm_source=im",
},
];
const ADMIN_ITEMS = [
{
id: "usage",
icon: BarChart3,
label: "使用统计",
group: "系统管理",
},
{
id: "users",
icon: Users,
label: "\u7528\u6237\u7ba1\u7406",
group: "\u7cfb\u7edf\u7ba1\u7406",
},
{
id: "logs",
icon: FileText,
label: "\u767b\u5f55\u65e5\u5fd7",
group: "\u7cfb\u7edf\u7ba1\u7406",
},
{
id: "settings",
icon: Settings,
label: "\u7cfb\u7edf\u914d\u7f6e",
group: "\u7cfb\u7edf\u7ba1\u7406",
},
];
function Sidebar({ user, active, setActive, reviewBadge }) {
const baseItems = BASE_ITEMS.map((item) =>
item.id === "review" ? { ...item, badge: reviewBadge } : item,
);
const items =
user.role_code === "super_admin"
? [...baseItems, ...ADMIN_ITEMS]
: baseItems;
const groups = [...new Set(items.map((item) => item.group))];
return (
<aside className="sidebar">
<div className="side-brand">
<div className="brand-icon">
<MessageCircle size={21} />
</div>
<div>
<strong>{Z.brand}</strong>
<span>{"\u8fd0\u8425\u667a\u80fd\u63a7\u5236\u53f0 v2.4"}</span>
</div>
</div>
<div className="nav-group">
{groups.map((group) => (
<React.Fragment key={group}>
<div className="nav-title">{group}</div>
{items
.filter((item) => item.group === group)
.map((item) => (
<NavItem
key={item.id}
item={item}
active={active}
setActive={setActive}
/>
))}
</React.Fragment>
))}
</div>
<div className="system-status">
<span className="status-dot" />
{"\u7cfb\u7edf\u72b6\u6001\uff1a\u6b63\u5e38\u8fd0\u884c"}
</div>
</aside>
);
}
function NavItem({ item, active, setActive }) {
const Icon = item.icon;
if (item.href) {
return (
<a
className="nav-item"
href={item.href}
target="_blank"
rel="noopener noreferrer"
title="在新标签页打开使用说明"
>
<Icon size={17} />
<span>{item.label}</span>
<em aria-hidden="true">↗</em>
</a>
);
}
return (
<button
className={`nav-item ${active === item.id ? "active" : ""}`}
onClick={() => setActive(item.id)}
>
<Icon size={17} />
<span>{item.label}</span>
{item.tag && <em>{item.tag}</em>}
{item.badge && <b>{item.badge}</b>}
</button>
);
}
function Topbar({ user, onLogout }) {
return (
<header className="topbar">
<div />
<div className="top-actions">
<div className="user-menu">
<div className="avatar">{user.display_name.slice(0, 1)}</div>
<div>
<strong>
{user.display_name}({user.role_label})
</strong>
</div>
<button
className="logout-btn"
onClick={onLogout}
title={"\u9000\u51fa\u767b\u5f55"}
>
<LogOut size={16} />
</button>
</div>
</div>
</header>
);
}
function PageHeading({ title, description, actions }) {
return (
<div className="page-heading">
<div>
<h2>{title}</h2>
<p>{description}</p>
</div>
{actions && <div className="heading-actions">{actions}</div>}
</div>
);
}
function AssistantPage() {
const [form, setForm] = useState({
product_name: "泊泉雅三色修护凝精华",
category: "",
benefits: "",
selling_points: "",
language_style: "",
target_group: "",
usage_scene: "",
purchase_channel: "",
question_count: "",
answers_per_question: "",
answer_requirement: "",
seed_style: false,
});
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [manualOpen, setManualOpen] = useState(false);
const [referenceFile, setReferenceFile] = useState(null);
const [referenceQa, setReferenceQa] = useState("");
const update = (key, value) =>
setForm((current) => ({ ...current, [key]: value }));
async function generate() {
const intentError = analyzeProductIntent([
form.product_name,
form.category,
form.benefits,
form.selling_points,
]);
if (intentError) {
setError(intentError);
setItems([]);
return;
}
const optionsError = validateGenerationOptions(form);
if (optionsError) {
setError(optionsError);
return;
}
setError("");
setLoading(true);
try {
const reference_qa = referenceFile ? await readReferenceFile(referenceFile, api) : referenceQa;
const result = await api("/api/admin/qa/generate", {
method: "POST",
body: JSON.stringify({
...form,
question_count: Number(form.question_count),
answers_per_question: Number(form.answers_per_question) || 2,
reference_qa,
}),
});
setItems(result.data.items);
} catch (err) {
setError(err.message);
setItems([]);
} finally {
setLoading(false);
}
}
function toggle(id) {
setItems((current) =>
current.map((item) =>
item.id === id ? { ...item, accepted: !item.accepted } : item,
),
);
}
function acceptAll() {
setItems((current) =>
current.map((item) => ({ ...item, accepted: true, status: "已采纳" })),
);
}
function edit(item) {
const question = window.prompt("编辑问题", item.question);
if (question === null) return;
const answer = window.prompt(
"编辑回答(多个回答用换行分隔)",
item.answers.join("\n"),
);
if (answer === null) return;
setItems((current) =>
current.map((row) =>
row.id === item.id
? {
...row,
question: question.trim() || row.question,
answers: answer
.split("\n")
.map((value) => value.trim())
.filter(Boolean),
status: "待采纳",
}
: row,
),
);
}
const addManual = (question, answers) => {
setItems((current) => [
...current,
{
id: `Q${String(current.length + 1).padStart(3, "0")}`,
question,
answers,
source: "手动添加",
status: "待采纳",
accepted: false,
},
]);
setManualOpen(false);
};
const selected = items.filter((item) => item.accepted).length;
return (
<>
<PageHeading
title="问答助手"
description="运营内容生产入口,输入商品要求即可快速生成 QA,并将采纳内容收录至问答库。"
/>
<div className="qa-workspace">
<section className="config-card panel">
<div className="panel-title">
<Settings size={18} />
生成条件配置 <small>* 商品信息至少填写一项</small>
</div>
<div className="rule" />
<h4>商品信息</h4>
<label>
商品名称
<input
value={form.product_name}
onChange={(event) => update("product_name", event.target.value)}
placeholder="请输入商品名称"
/>
</label>
<div className="two-col">
<label>
商品品类
<input
value={form.category}
onChange={(event) => update("category", event.target.value)}
placeholder="例如:面部精华"
/>
</label>
<label>
商品功效
<input
value={form.benefits}
onChange={(event) => update("benefits", event.target.value)}
placeholder="例如:舒缓泛红、修护屏障"
/>
</label>
</div>
<label>
商品卖点
<textarea
value={form.selling_points}
onChange={(event) => update("selling_points", event.target.value)}
placeholder="例如:油敏肌救星,植萃配方,修护泛红速效"
/>
</label>
<label className="reference-qa-upload">
真实问答参考(选填)
<input type="file" accept=".txt,.csv,.json,.md,.xlsx,text/plain,text/csv,application/json,text/markdown,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => setReferenceFile(event.target.files?.[0] || null)} />
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown、XLSX,模型将参考其中的问答语气与角度"}</small>
{referenceFile && <button type="button" className="reference-qa-clear" onClick={() => setReferenceFile(null)}>清除已选文件</button>}
</label>
<div className="rule" />
<h4>生成偏好与数量</h4>
<div className="two-col">
<label>
语言风格
<select
value={form.language_style}
onChange={(event) =>
update("language_style", event.target.value)
}
>
<option value="">请选择语言风格</option>
<option>口语(亲切随性)</option>
<option>幽默</option>
<option>专业</option>
</select>
</label>
<label>
目标人群
<select
value={form.target_group}
onChange={(event) => update("target_group", event.target.value)}
>
<option value="">请选择目标人群</option>
<option>女性</option>
<option>男性</option>
<option>学生</option>
<option>熟龄人群</option>
</select>
</label>
</div>
<div className="two-col">
<label>
提问生成条数 *
<input
type="number"
min="1"
required
value={form.question_count}
onChange={(event) =>
update("question_count", event.target.value)
}
/>
</label>
<label>
单题回答条数
<input
type="number"
min="1"
max="10"
value={form.answers_per_question}
onChange={(event) =>
update("answers_per_question", event.target.value)
}
/>
</label>
</div>
<div className="two-col">
<label>
使用场景
<select
value={form.usage_scene}
onChange={(event) => update("usage_scene", event.target.value)}
>
<option value="">请选择使用场景</option>
<option>居家</option>
<option>旅行</option>
<option>通勤</option>
<option>运动</option>
<option>换季</option>
</select>
</label>
<label>
购买渠道
<select
value={form.purchase_channel}
onChange={(event) =>
update("purchase_channel", event.target.value)
}
>
<option value="">请选择购买渠道</option>
<option>电商平台</option>
<option>大型商超</option>
<option>药房</option>
<option>美妆店</option>
</select>
</label>
</div>
<label>
回答要求(选填)
<input
value={form.answer_requirement}
onChange={(event) =>
update("answer_requirement", event.target.value)
}
placeholder="例如:回答需要包含吸收速度和使用感"
/>
</label>
<div className="switch-row">
<span>种草式回答</span>
<button
type="button"
className={`switch ${form.seed_style ? "on" : ""}`}
onClick={() => update("seed_style", !form.seed_style)}
>
<i />
</button>
</div>
{error && <div className="form-error">{error}</div>}
<button
className="primary-button generate-btn"
onClick={generate}
disabled={loading}
>
<Sparkles size={18} />
{loading ? "大模型生成中…" : "生成 Q&A"}
</button>
</section>
<section className="results-area">
<div className="result-toolbar">
<label className="check-label">
<input
type="checkbox"
checked={items.length > 0 && selected === items.length}
onChange={(event) =>
setItems((current) =>
current.map((item) => ({
...item,
accepted: event.target.checked,
})),
)
}
/>{" "}
全选 ({selected}/{items.length})
</label>
<div>
<button
className="ghost-button"
onClick={() =>
setItems((current) => [
...current,
{
id: `Q${String(current.length + 1).padStart(3, "0")}`,
question: "手动输入问题",
answers: ["手动输入参考回答"],
source: "手动添加",
status: "待采纳",
accepted: false,
},
])
}
>
+ 手动添加Q&A
</button>
<button
className="success-button"
onClick={acceptAll}
disabled={!items.length}
>
✓ 批量采纳
</button>
<button
className="ghost-button"
onClick={generate}
disabled={loading}
>
↻ 重新生成
</button>
</div>
</div>
{items.length === 0 && (
<div className="empty-results panel">
<Sparkles size={22} />
<span>填写左侧商品条件后,点击“生成 Q&A”</span>
</div>
)}
{items.map((item) => (
<article className="qa-card" key={item.id}>
<div className="qa-meta">
<input
type="checkbox"
checked={item.accepted}
onChange={() => toggle(item.id)}
/>
<code>{item.id}</code>
<span>来源:{item.source}</span>
<strong className={item.accepted ? "accepted" : ""}>
{item.accepted ? "已采纳" : "待采纳"}
</strong>
<div className="qa-actions">
<button onClick={() => edit(item)}>编辑</button>
<button
className="accept-link"
onClick={() => toggle(item.id)}
>
{item.accepted ? "✓ 已采纳" : "✓ 采纳"}
</button>
</div>
</div>
<h3>问题:{item.question}</h3>
{item.answers.map((answer, index) => (
<p key={`${item.id}-${index}`}>
<b>回答{index + 1}:</b>
{answer}
</p>
))}
</article>
))}
</section>
</div>
</>
);
}
function InlineQAItem({ item, onToggle, onDelete, onSave }) {
const [editing, setEditing] = useState(false);
const [question, setQuestion] = useState(item.question);
const [answers, setAnswers] = useState(item.answers);
useEffect(() => {
setQuestion(item.question);
setAnswers(item.answers);
}, [item.question, item.answers]);
function save() {
const cleanAnswers = answers.map((value) => value.trim()).filter(Boolean);
if (!question.trim() || !cleanAnswers.length) return;
onSave(item.id, question.trim(), cleanAnswers);
setEditing(false);
}
function addAnswer() {
setAnswers((current) => [...current, ""]);
}
function removeAnswer(index) {
if (answers.length <= 1) return;
setAnswers((current) =>
current.filter((_, answerIndex) => answerIndex !== index),
);
}
return (
<article className={`qa-card ${editing ? "editing" : ""}`}>
<div className="qa-meta">
<input
type="checkbox"
checked={item.accepted}
onChange={() => onToggle(item.id)}
/>
<code>{item.id}</code>
<span>来源:{item.source}</span>
<strong className={item.accepted ? "accepted" : ""}>
{item.accepted ? "已采纳" : "待采纳"}
</strong>
<div className="qa-actions">
{editing ? (
<>
<button onClick={save}>保存</button>
<button
onClick={() => {
setQuestion(item.question);
setAnswers(item.answers);
setEditing(false);
}}
>
取消
</button>
</>
) : (
<>
<button onClick={() => {
if (!answers.length) setAnswers([""]);
setEditing(true);
}}>编辑</button>
<button className="accept-link" onClick={() => onToggle(item.id)}>
{item.accepted ? "✓ 已采纳" : "✓ 采纳"}
</button>
<button className="delete-link" onClick={() => onDelete(item.id)}>
删除
</button>
</>
)}
</div>
</div>
{editing ? (
<div className="inline-edit-fields">
<label>
问题
<input
value={question}
onChange={(event) => setQuestion(event.target.value)}
/>
</label>
{answers.map((answer, index) => (
<div className="answer-edit-row" key={index}>
<label>
回答{index + 1}
<textarea
value={answer}
onChange={(event) =>
setAnswers((current) =>
current.map((value, answerIndex) =>
answerIndex === index ? event.target.value : value,
),
)
}
/>
</label>
{answers.length > 1 && (
<button
type="button"
className="remove-answer"
onClick={() => removeAnswer(index)}
>
删除回答
</button>
)}
</div>
))}
<button
type="button"
className="add-answer-button"
onClick={addAnswer}
>
+ 添加回答
</button>
</div>
) : (
<>
<h3>问题:{item.question}</h3>
{(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>
);
}
function ManualQAForm({ onClose, onSave }) {
const [question, setQuestion] = useState("");
const [answers, setAnswers] = useState([""]);
function addAnswer() {
setAnswers((current) => [...current, ""]);
}
function save(event) {
event.preventDefault();
const clean = answers.map((value) => value.trim()).filter(Boolean);
if (!question.trim() || !clean.length) return;
onSave(question.trim(), clean);
}
return (
<div className="manual-modal-backdrop">
<form className="manual-modal" onSubmit={save}>
<div className="manual-modal-header">
<h3>手动添加 Q&A 问答</h3>
<button type="button" onClick={onClose}>
×
</button>
</div>
<div className="manual-modal-rule" />
<label>
问题描述 *
<input
autoFocus
required
value={question}
onChange={(event) => setQuestion(event.target.value)}
placeholder="输入你提出的问题(例如:敏感肌用会刺激吗?)"
/>
</label>
{answers.map((answer, index) => (
<div className="manual-answer-row" key={index}>
<label>
回答 {index + 1}
{index > 0 && "(可选)"}
<textarea
required={index === 0}
value={answer}
onChange={(event) =>
setAnswers((current) =>
current.map((value, answerIndex) =>
answerIndex === index ? event.target.value : value,
),
)
}
placeholder={
index === 0
? "输入该问题的推荐回答内容"
: "输入更多回答(可选)"
}
/>
</label>
{index > 0 && (
<button
type="button"
className="remove-answer"
onClick={() =>
setAnswers((current) =>
current.filter((_, answerIndex) => answerIndex !== index),
)
}
>
删除
</button>
)}
</div>
))}
<button
type="button"
className="add-answer-button modal-add-answer"
onClick={addAnswer}
>
+ 添加回答
</button>
<div className="manual-modal-actions">
<button type="button" className="ghost-button" onClick={onClose}>
取消
</button>
<button className="primary-button">保存并添加</button>
</div>
</form>
</div>
);
}
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("");
const [jobProgress, setJobProgress] = useState(null);
const [retryJobId, setRetryJobId] = useState("");
async function selectFile(nextFile) {
setFile(nextFile || null);
setQuestions([]);
setError("");
setJobProgress(null);
setRetryJobId("");
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, {
jobId: retryJobId,
onProgress: setJobProgress,
});
} catch (err) {
setError(err.message || "批量导入失败");
if (err.bulkJob) {
setJobProgress(err.bulkJob);
setRetryJobId(err.bulkJob.id || "");
}
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" disabled={busy || !!retryJobId} className={!generateAnswers ? "active" : ""} onClick={() => setGenerateAnswers(false)}>不生成回答</button>
<button type="button" disabled={busy || !!retryJobId} className={generateAnswers ? "active" : ""} onClick={() => setGenerateAnswers(true)}>自动生成回答</button>
</div>
{generateAnswers && (
<label>
每个问题生成回答条数
<input type="number" disabled={busy || !!retryJobId} 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>}
{jobProgress && (
<div className={`assistant-import-progress ${jobProgress.failed ? "has-failures" : ""}`}>
<header><span>{jobProgress.status === "completed" ? "回答生成完成" : jobProgress.status === "partial_failed" || jobProgress.status === "failed" ? "部分问题生成失败" : "正在生成回答"}</span><b>{jobProgress.progress_percent || 0}%</b></header>
<div className="assistant-import-progress-track"><i style={{ width: `${jobProgress.progress_percent || 0}%` }} /></div>
<p><span>共 {jobProgress.total || 0} 条</span><span>已完成 <b>{jobProgress.completed || 0}</b></span><span>失败 <b>{jobProgress.failed || 0}</b></span><span>待处理 <b>{jobProgress.pending || 0}</b></span></p>
</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 ? (jobProgress ? `正在生成回答(${jobProgress.completed || 0}/${jobProgress.total || questions.length})…` : generateAnswers ? "正在生成回答…" : "正在导入…") : retryJobId ? `重试 ${jobProgress?.failed || 0} 条失败问题` : `导入 ${questions.length || 0} 条问题`}
</button>
</div>
</form>
</div>
);
}
function AssistantPageV2() {
const [form, setForm] = useState({
product_name: "",
category: "",
benefits: "",
selling_points: "",
language_style: "",
target_group: "",
usage_scene: "",
purchase_channel: "",
question_count: "",
answers_per_question: "",
answer_requirement: "",
seed_style: false,
});
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const update = (key, value) =>
setForm((current) => ({ ...current, [key]: value }));
async function generate() {
const intentError = analyzeProductIntent([
form.product_name,
form.category,
form.benefits,
form.selling_points,
]);
if (intentError) {
setError(intentError);
setItems([]);
return;
}
const optionsError = validateGenerationOptions(form);
if (optionsError) {
setError(optionsError);
return;
}
setError("");
setLoading(true);
try {
const result = await api("/api/admin/qa/generate", {
method: "POST",
body: JSON.stringify({
...form,
question_count: Number(form.question_count),
answers_per_question: Number(form.answers_per_question) || 2,
}),
});
setItems(result.data.items);
} catch (err) {
setError(err.message);
setItems([]);
} finally {
setLoading(false);
}
}
const toggle = (id) =>
setItems((current) =>
current.map((item) =>
item.id === id
? {
...item,
accepted: !item.accepted,
status: !item.accepted ? "已采纳" : "待采纳",
}
: item,
),
);
const save = (id, question, answers) =>
setItems((current) =>
current.map((item) =>
item.id === id
? { ...item, question, answers, accepted: false, status: "待采纳" }
: item,
),
);
const remove = (id) =>
setItems((current) => current.filter((item) => item.id !== id));
const addManual = () =>
setItems((current) => [
...current,
{
id: `Q${String(current.length + 1).padStart(3, "0")}`,
question: "手动输入问题",
answers: ["手动输入参考回答"],
source: "手动添加",
status: "待采纳",
accepted: false,
},
]);
const selected = items.filter((item) => item.accepted).length;
return (
<>
<PageHeading
title="问答助手"
description="运营内容生产入口,输入商品要求即可快速生成 QA,并将采纳内容收录至问答库。"
/>
<div className="qa-workspace">
<section className="config-card panel">
<div className="panel-title">
<Settings size={18} />
生成条件配置 <small>* 商品信息至少填写一项</small>
</div>
<div className="rule" />
<h4>商品信息</h4>
<label>
商品名称
<input
value={form.product_name}
onChange={(event) => update("product_name", event.target.value)}
placeholder="请输入商品名称"
/>
</label>
<div className="two-col">
<label>
商品品类
<input
value={form.category}
onChange={(event) => update("category", event.target.value)}
placeholder="例如:面部精华"
/>
</label>
<label>
商品功效
<input
value={form.benefits}
onChange={(event) => update("benefits", event.target.value)}
placeholder="例如:舒缓泛红、修护屏障"
/>
</label>
</div>
<label>
商品卖点
<textarea
value={form.selling_points}
onChange={(event) => update("selling_points", event.target.value)}
placeholder="例如:油敏肌救星,植萃配方,修护泛红速效"
/>
</label>
<div className="rule" />
<h4>生成偏好与数量</h4>
<div className="two-col">
<label>
语言风格
<select
value={form.language_style}
onChange={(event) =>
update("language_style", event.target.value)
}
>
<option value="">请选择语言风格</option>
<option>口语(亲切随性)</option>
<option>幽默</option>
<option>专业</option>
</select>
</label>
<label>
目标人群
<select
value={form.target_group}
onChange={(event) => update("target_group", event.target.value)}
>
<option value="">请选择目标人群</option>
<option>女性</option>
<option>男性</option>
<option>学生</option>
<option>熟龄人群</option>
</select>
</label>
</div>
<div className="two-col">
<label>
提问生成条数 *
<input
type="number"
min="1"
required
value={form.question_count}
onChange={(event) =>
update("question_count", event.target.value)
}
/>
</label>
<label>
单题回答条数
<input
type="number"
min="1"
max="10"
value={form.answers_per_question}
onChange={(event) =>
update("answers_per_question", event.target.value)
}
/>
</label>
</div>
<div className="two-col">
<label>
使用场景
<select
value={form.usage_scene}
onChange={(event) => update("usage_scene", event.target.value)}
>
<option value="">请选择使用场景</option>
<option>居家</option>
<option>旅行</option>
<option>通勤</option>
<option>运动</option>
<option>换季</option>
</select>
</label>
<label>
购买渠道
<select
value={form.purchase_channel}
onChange={(event) =>
update("purchase_channel", event.target.value)
}
>
<option value="">请选择购买渠道</option>
<option>电商平台</option>
<option>大型商超</option>
<option>药房</option>
<option>美妆店</option>
</select>
</label>
</div>
<label>
回答要求(选填)
<input
value={form.answer_requirement}
onChange={(event) =>
update("answer_requirement", event.target.value)
}
placeholder="例如:回答需要包含吸收速度和使用感"
/>
</label>
<div className="switch-row">
<span>种草式回答</span>
<button
type="button"
className={`switch ${form.seed_style ? "on" : ""}`}
onClick={() => update("seed_style", !form.seed_style)}
>
<i />
</button>
</div>
{error && <div className="form-error">{error}</div>}
<button
className="primary-button generate-btn"
onClick={generate}
disabled={loading}
>
<Sparkles size={18} />
{loading ? "大模型生成中…" : "生成 Q&A"}
</button>
</section>
<section className="results-area">
<div className="result-toolbar">
<label className="check-label">
<input
type="checkbox"
checked={items.length > 0 && selected === items.length}
onChange={(event) =>
setItems((current) =>
current.map((item) => ({
...item,
accepted: event.target.checked,
status: event.target.checked ? "已采纳" : "待采纳",
})),
)
}
/>{" "}
全选 ({selected}/{items.length})
</label>
<div>
<button className="ghost-button" onClick={addManual}>
+ 手动添加Q&A
</button>
<button
className="success-button"
onClick={() =>
setItems((current) =>
current.map((item) => ({
...item,
accepted: true,
status: "已采纳",
})),
)
}
disabled={!items.length}
>
✓ 批量采纳
</button>
<button
className="ghost-button"
onClick={generate}
disabled={loading}
>
↻ 重新生成
</button>
</div>
</div>
{items.length === 0 && (
<div className="empty-results panel">
<Sparkles size={22} />
<span>填写左侧商品条件后,点击“生成 Q&A”</span>
</div>
)}
{items.map((item) => (
<InlineQAItem
key={item.id}
item={item}
onToggle={toggle}
onDelete={remove}
onSave={save}
/>
))}
</section>
</div>
</>
);
}
function QALibraryPage({ selectionMode = false }) {
const [records, setRecords] = useState([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [editing, setEditing] = useState(null);
const [deleting, setDeleting] = useState(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [selectedIds, setSelectedIds] = useState([]);
async function load(keyword = search) {
setLoading(true);
try {
const result = await api(
`/api/admin/qa-library?search=${encodeURIComponent(keyword.trim())}`,
);
setRecords(result.data);
setError("");
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => {
load("");
const refresh = () => load(search);
window.addEventListener("qa-library-changed", refresh);
return () => window.removeEventListener("qa-library-changed", refresh);
}, []);
async function removeRecord() {
if (!deleting || deleteBusy) return;
setDeleteBusy(true);
try {
await api(`/api/admin/qa-library/${deleting.id}`, { method: "DELETE" });
setRecords((current) => current.filter((item) => item.id !== deleting.id));
setDeleting(null);
window.dispatchEvent(new Event("qa-library-changed"));
} catch (err) {
setError(err.message);
} finally {
setDeleteBusy(false);
}
}
return (
<>
<PageHeading
title={selectionMode ? "从问答库添加提问" : "问答库"}
description={selectionMode ? "搜索并勾选问题,添加后将返回当前提问任务。" : "集中沉淀运营已采纳的问答内容,供后续选题和回答参考。"}
/>
<section className={`qa-library-toolbar panel${selectionMode ? " selection-mode" : ""}`}>
<div className="qa-library-search-row">
<div className="qa-library-search-input">
<Search size={17} />
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
onKeyDown={(event) => event.key === "Enter" && load()}
placeholder="搜索商品、品类、问题或回答"
/>
</div>
<button type="button" className="primary-button" onClick={() => load()}>
搜索
</button>
</div>
<div className="qa-library-selection-actions">
{selectionMode && <label className="qa-library-select-all"><input type="checkbox" checked={records.length > 0 && records.every((item) => selectedIds.includes(item.id))} onChange={(event) => setSelectedIds(event.target.checked ? records.map((item) => item.id) : [])} /> 全选当前搜索结果</label>}
<span>共 {records.length} 条问答</span>
{selectionMode && <button type="button" className="ghost-button" onClick={() => window.dispatchEvent(new CustomEvent("switch-create-ask"))}>返回创建任务</button>}
{selectionMode && <button type="button" className="primary-button" disabled={!selectedIds.length} onClick={() => window.dispatchEvent(new CustomEvent("qa-library-selected", { detail: records.filter((item) => selectedIds.includes(item.id)) }))}>添加已选并返回({selectedIds.length})</button>}
</div>
</section>
{error && <div className="form-error">{error}</div>}
<section className="qa-library-list">
{loading ? (
<div className="empty-results panel">正在加载问答库…</div>
) : records.length === 0 ? (
<div className="empty-results panel">
暂无已采纳问答,请先在问答助手中采纳内容。
</div>
) : (
records.map((record) => (
<article className={`qa-library-card panel${selectionMode ? " selectable" : ""}`} key={record.id}>
<header>
<div>
{selectionMode && <input type="checkbox" checked={selectedIds.includes(record.id)} onChange={() => setSelectedIds((current) => current.includes(record.id) ? current.filter((id) => id !== record.id) : [...current, record.id])} aria-label={`选择问题 ${record.question}`} />}
<strong>{record.product_name || "未填写商品名称"}</strong>
{record.category && <span>{record.category}</span>}
</div>
<div className="qa-library-card-tools">
<small>
{record.source} · {record.created_by} ·{" "}
{new Date(record.created_at).toLocaleString("zh-CN", {
hour12: false,
})}
</small>
<button onClick={() => setEditing(record)}>编辑</button>
<button className="danger-button" onClick={() => setDeleting(record)}>删除</button>
</div>
</header>
<h3>Q:{record.question}</h3>
<div className="qa-library-answers">
{record.answers.map((answer, index) => (
<p key={`${record.id}-${index}`}>
<b>A{index + 1}</b>
{answer}
</p>
))}
</div>
{(record.benefits || record.selling_points) && (
<footer>
{record.benefits && <span>功效:{record.benefits}</span>}
{record.selling_points && (
<span>卖点:{record.selling_points}</span>
)}
</footer>
)}
</article>
))
)}
</section>
{editing && (
<QALibraryEditModal
record={editing}
onClose={() => setEditing(null)}
onSaved={(updated) => {
setRecords((current) =>
current.map((item) => (item.id === updated.id ? updated : item)),
);
setEditing(null);
}}
/>
)}
{deleting && (
<div className="manual-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && setDeleting(null)}>
<section className="manual-modal qa-library-delete-modal" role="dialog" aria-modal="true" aria-labelledby="qa-delete-title">
<div className="manual-modal-header">
<h3 id="qa-delete-title">确认删除</h3>
<button type="button" onClick={() => setDeleting(null)}>×</button>
</div>
<p>确定要删除这条问答吗?删除后将无法恢复。</p>
<div className="qa-delete-preview">Q:{deleting.question}</div>
<div className="manual-modal-actions">
<button type="button" onClick={() => setDeleting(null)}>取消</button>
<button type="button" className="danger-solid-button" disabled={deleteBusy} onClick={removeRecord}>{deleteBusy ? "删除中…" : "确认删除"}</button>
</div>
</section>
</div>
)}
</>
);
}
function QALibraryEditModal({ record, onClose, onSaved }) {
const [form, setForm] = useState({
product_name: record.product_name || "",
category: record.category || "",
benefits: record.benefits || "",
selling_points: record.selling_points || "",
question: record.question || "",
answers: (record.answers || []).join("\n"),
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState("");
const update = (key, value) => setForm((current) => ({ ...current, [key]: value }));
async function save(event) {
event.preventDefault();
const answers = form.answers.split("\n").map((value) => value.trim()).filter(Boolean);
if (!form.question.trim() || !answers.length) {
setError("问题和回答不能为空");
return;
}
setSaving(true);
setError("");
try {
const result = await api(`/api/admin/qa-library/${record.id}`, {
method: "PATCH",
body: JSON.stringify({ ...form, question: form.question.trim(), answers }),
});
onSaved(result.data);
window.dispatchEvent(new Event("qa-library-changed"));
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
}
return (
<div className="manual-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && onClose()}>
<form className="manual-modal qa-library-edit-modal" onSubmit={save}>
<div className="manual-modal-header">
<h3>编辑问答</h3>
<button type="button" onClick={onClose}>×</button>
</div>
<label>商品名称<input value={form.product_name} onChange={(event) => update("product_name", event.target.value)} /></label>
<div className="two-col">
<label>商品品类<input value={form.category} onChange={(event) => update("category", event.target.value)} /></label>
<label>商品功效<input value={form.benefits} onChange={(event) => update("benefits", event.target.value)} /></label>
</div>
<label>商品卖点<input value={form.selling_points} onChange={(event) => update("selling_points", event.target.value)} /></label>
<label>问题<textarea value={form.question} onChange={(event) => update("question", event.target.value)} /></label>
<label>参考回答(每行一条)<textarea rows="5" value={form.answers} onChange={(event) => update("answers", event.target.value)} /></label>
{error && <div className="form-error">{error}</div>}
<div className="manual-modal-actions">
<button type="button" onClick={onClose}>取消</button>
<button className="primary-button" disabled={saving}>{saving ? "保存中…" : "保存修改"}</button>
</div>
</form>
</div>
);
}
function AssistantPageFinal() {
const cached = readPageCache("assistant", {});
const [form, setForm] = useState(cached.form || {
product_name: "",
category: "",
benefits: "",
selling_points: "",
language_style: "",
target_group: "",
usage_scene: "",
purchase_channel: "",
question_count: "",
answers_per_question: "",
answer_requirement: "",
seed_style: false,
});
const [items, setItems] = useState(cached.items || []);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [saving, setSaving] = useState(false);
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 || "");
const referenceInputRef = useRef(null);
useEffect(() => {
writePageCache("assistant", { form, items, referenceQa, referenceFileName });
}, [form, items, referenceQa, referenceFileName]);
const update = (key, value) =>
setForm((current) => ({ ...current, [key]: value }));
async function selectReferenceFile(file) {
setReferenceFile(file);
setReferenceFileName(file?.name || "");
setError("");
if (!file) {
setReferenceQa("");
return;
}
try {
setReferenceQa(await readReferenceFile(file, api));
} catch (err) {
setError(err.message || "参考文件读取失败");
}
}
function clearReferenceFile() {
setReferenceFile(null);
setReferenceFileName("");
setReferenceQa("");
if (referenceInputRef.current) referenceInputRef.current.value = "";
}
async function generate() {
const intentError = analyzeProductIntent([
form.product_name,
form.category,
form.benefits,
form.selling_points,
]);
if (intentError) {
setError(intentError);
setItems([]);
return;
}
const optionsError = validateGenerationOptions(form);
if (optionsError) {
setError(optionsError);
return;
}
setError("");
setLoading(true);
try {
const reference_qa = referenceFile ? await readReferenceFile(referenceFile, api) : referenceQa;
setReferenceQa(reference_qa);
const result = await api("/api/admin/qa/generate", {
method: "POST",
body: JSON.stringify({
...form,
question_count: Number(form.question_count),
answers_per_question: Number(form.answers_per_question) || 2,
reference_qa,
}),
});
setItems(result.data.items);
} catch (err) {
setError(err.message);
setItems([]);
} finally {
setLoading(false);
}
}
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 {
const result = await api("/api/admin/qa-library", {
method: "POST",
body: JSON.stringify({
product_name: form.product_name,
category: form.category,
benefits: form.benefits,
selling_points: form.selling_points,
items: readyItems.map(({ question, answers, source }) => ({
question,
answers,
source,
})),
}),
});
const ids = new Set(readyItems.map((item) => item.id));
setItems((current) =>
current.map((item) =>
ids.has(item.id)
? { ...item, accepted: true, status: "已采纳" }
: item,
),
);
const { added_count: added, skipped_count: skipped } = result.data;
if (showDialog) setAdoptedDialog({ count: readyItems.length, added, skipped });
setSavedMessage(
added > 0
? `已将 ${added} 条问答存入问答库${skipped ? `,${skipped} 条重复内容已跳过` : ""}`
: "这些问答已经存在于问答库中",
);
window.dispatchEvent(new Event("qa-library-changed"));
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
}
function toggle(id) {
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);
const save = (id, question, answers) =>
setItems((current) =>
current.map((item) =>
item.id === id
? { ...item, question, answers, accepted: false, status: "待采纳" }
: item,
),
);
const remove = (id) =>
setItems((current) => current.filter((item) => item.id !== id));
const addManual = (question, answers) => {
setItems((current) => [
...current,
{
id: `Q${String(current.length + 1).padStart(3, "0")}`,
question,
answers,
source: "手动添加",
status: "待采纳",
accepted: false,
},
]);
setManualOpen(false);
};
async function addImportedQuestions(questions, generateAnswers, answersPerQuestion, jobOptions = {}) {
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 && !jobOptions.jobId) {
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}`);
let job;
if (jobOptions.jobId) {
const retryResult = await api(`/api/admin/qa/generate-answer-jobs/${jobOptions.jobId}/retry`, { method: "POST" });
job = retryResult.data;
} else {
const createResult = await api("/api/admin/qa/generate-answer-jobs", {
method: "POST",
body: JSON.stringify({
...form,
questions: newQuestions,
answers_per_question: answersPerQuestion,
}),
});
job = createResult.data;
}
jobOptions.onProgress?.(job);
while (["queued", "processing"].includes(job.status)) {
await new Promise((resolve) => window.setTimeout(resolve, 1200));
const progressResult = await api(`/api/admin/qa/generate-answer-jobs/${job.id}`);
job = progressResult.data;
jobOptions.onProgress?.(job);
}
if (job.status !== "completed") {
const failure = new Error(`已完成 ${job.completed} 条,${job.failed} 条生成失败。点击下方按钮仅重试失败问题。`);
failure.bulkJob = job;
throw failure;
}
const detailResult = await api(`/api/admin/qa/generate-answer-jobs/${job.id}?include_items=true`);
importedItems = detailResult.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 (
<>
<PageHeading
title="问答助手"
description="运营内容生产入口,输入商品要求即可快速生成 QA,并将采纳内容收录至问答库。"
/>
<div className="qa-workspace">
<section className="config-card panel">
<div className="panel-title">
<Settings size={18} />
生成条件配置 <small>* 商品信息至少填写一项</small>
</div>
<div className="rule" />
<h4>商品信息</h4>
<label>
商品名称
<input
value={form.product_name}
onChange={(event) => update("product_name", event.target.value)}
placeholder="请输入商品名称"
/>
</label>
<div className="two-col">
<label>
商品品类
<input
value={form.category}
onChange={(event) => update("category", event.target.value)}
placeholder="例如:面部精华"
/>
</label>
<label>
商品功效
<input
value={form.benefits}
onChange={(event) => update("benefits", event.target.value)}
placeholder="例如:舒缓泛红、修护屏障"
/>
</label>
</div>
<label>
商品卖点
<textarea
value={form.selling_points}
onChange={(event) => update("selling_points", event.target.value)}
placeholder="例如:油敏肌救星,植萃配方,修护泛红速效"
/>
</label>
<label className="reference-qa-upload">
真实问答参考(选填)
<input ref={referenceInputRef} type="file" accept=".txt,.csv,.json,.md,.xlsx,text/plain,text/csv,application/json,text/markdown,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" onChange={(event) => selectReferenceFile(event.target.files?.[0] || null)} />
<small>{referenceFileName ? `已选择:${referenceFileName}` : "支持 TXT、CSV、JSON、Markdown、XLSX,模型将参考其中的问答语气与角度"}</small>
{referenceFileName && <button type="button" className="reference-qa-clear" onClick={clearReferenceFile}>清除已选文件</button>}
</label>
<div className="rule" />
<h4>生成偏好与数量</h4>
<div className="two-col">
<label>
语言风格
<select
value={form.language_style}
onChange={(event) =>
update("language_style", event.target.value)
}
>
<option value="">请选择语言风格</option>
<option>口语(亲切随性)</option>
<option>幽默</option>
<option>专业</option>
</select>
</label>
<label>
目标人群
<select
value={form.target_group}
onChange={(event) => update("target_group", event.target.value)}
>
<option value="">请选择目标人群</option>
<option>女性</option>
<option>男性</option>
<option>学生</option>
<option>熟龄人群</option>
</select>
</label>
</div>
<div className="two-col">
<label>
提问生成条数 *
<input
type="number"
min="1"
required
value={form.question_count}
onChange={(event) =>
update("question_count", event.target.value)
}
/>
</label>
<label>
单题回答条数
<input
type="number"
min="1"
max="20"
value={form.answers_per_question}
onChange={(event) =>
update("answers_per_question", event.target.value)
}
/>
</label>
</div>
<div className="two-col">
<label>
使用场景
<select
value={form.usage_scene}
onChange={(event) => update("usage_scene", event.target.value)}
>
<option value="">请选择使用场景</option>
<option>居家</option>
<option>旅行</option>
<option>通勤</option>
<option>运动</option>
<option>换季</option>
</select>
</label>
<label>
购买渠道
<select
value={form.purchase_channel}
onChange={(event) =>
update("purchase_channel", event.target.value)
}
>
<option value="">请选择购买渠道</option>
<option>电商平台</option>
<option>大型商超</option>
<option>药房</option>
<option>美妆店</option>
</select>
</label>
</div>
<label>
回答要求(选填)
<input
value={form.answer_requirement}
onChange={(event) =>
update("answer_requirement", event.target.value)
}
placeholder="例如:回答需要包含吸收速度和使用感"
/>
</label>
<div className="switch-row">
<span>种草式回答</span>
<button
type="button"
className={`switch ${form.seed_style ? "on" : ""}`}
onClick={() => update("seed_style", !form.seed_style)}
>
<i />
</button>
</div>
{error && <div className="form-error">{error}</div>}
{savedMessage && <div className="form-success">{savedMessage}</div>}
<button
className="primary-button generate-btn"
onClick={generate}
disabled={loading}
>
<Sparkles size={18} />
{loading ? "大模型生成中…" : "生成 Q&A"}
</button>
</section>
<section className="results-area">
<div className="result-toolbar">
<label className="check-label">
<input
type="checkbox"
checked={items.length > 0 && selected === items.length}
onChange={(event) => {
if (event.target.checked) acceptAll();
}}
/>{" "}
全选 ({selected}/{items.length})
</label>
<div>
<button
className="ghost-button"
onClick={() => setManualOpen(true)}
>
+ 手动添加Q&A
</button>
<button
className="ghost-button"
onClick={() => setImportOpen(true)}
>
<FileText size={14} /> 批量导入问题
</button>
<button
className="success-button"
onClick={acceptAll}
disabled={!items.length || saving}
>
{saving ? "正在存入…" : "✓ 批量采纳"}
</button>
<button
className="ghost-button"
onClick={generate}
disabled={loading}
>
↻ 重新生成
</button>
</div>
</div>
{items.length === 0 && (
<div className="empty-results panel">
<Sparkles size={22} />
<span>填写左侧商品条件后,点击“生成 Q&A”</span>
</div>
)}
{items.map((item) => (
<InlineQAItem
key={item.id}
item={item}
onToggle={toggle}
onDelete={remove}
onSave={save}
/>
))}
</section>
</div>
{manualOpen && (
<ManualQAForm onClose={() => setManualOpen(false)} onSave={addManual} />
)}
{importOpen && (
<AssistantQuestionImportModal form={form} onClose={() => setImportOpen(false)} onImport={addImportedQuestions} />
)}
{adoptedDialog && (
<div
className="adopt-success-backdrop"
onMouseDown={(event) => event.target === event.currentTarget && setAdoptedDialog(null)}
>
<section className="adopt-success-modal" role="dialog" aria-modal="true" aria-labelledby="adopt-success-title">
<div className="adopt-success-icon">✓</div>
<h2 id="adopt-success-title">问答已采纳</h2>
<p>已采纳 {adoptedDialog.count} 条,采纳问答已收录至问答库。</p>
<button type="button" className="primary-button" onClick={() => setAdoptedDialog(null)}>知道了</button>
</section>
</div>
)}
</>
);
}
function UserTable({ users, onChanged }) {
const [editing, setEditing] = useState(null);
const [form, setForm] = useState({
username: "",
password: "",
display_name: "",
role_code: "operator",
is_active: true,
});
function open(user) {
setForm(
user
? {
username: user.username,
password: "",
display_name: user.display_name,
role_code: user.role_code,
is_active: user.is_active,
}
: {
username: "",
password: "",
display_name: "",
role_code: "operator",
is_active: true,
},
);
setEditing(user || "new");
}
async function save(event) {
event.preventDefault();
try {
if (editing === "new")
await api("/api/admin/users", {
method: "POST",
body: JSON.stringify(form),
});
else
await api(`/api/admin/users/${editing.id}`, {
method: "PATCH",
body: JSON.stringify({
password: form.password || undefined,
display_name: form.display_name,
role_code: form.role_code,
is_active: form.is_active,
}),
});
setEditing(null);
onChanged();
} catch (error) {
window.alert(error.message);
}
}
async function remove(user) {
if (
!window.confirm(
`\u786e\u8ba4\u5220\u9664\u7528\u6237 ${user.username}\uff1f`,
)
)
return;
try {
await api(`/api/admin/users/${user.id}`, { method: "DELETE" });
onChanged();
} catch (error) {
window.alert(error.message);
}
}
return (
<>
<div className="user-table-actions">
<button className="primary-button" onClick={() => open(null)}>
<Plus size={16} />
{"\u65b0\u589e\u7528\u6237"}
</button>
</div>
{editing && (
<div className="user-modal-backdrop">
<form className="user-modal panel" onSubmit={save}>
<h3>
{editing === "new"
? "\u65b0\u589e\u7528\u6237"
: "\u7f16\u8f91\u7528\u6237"}
</h3>
{editing === "new" && (
<label>
{"\u7528\u6237\u540d"}
<input
required
value={form.username}
onChange={(event) =>
setForm({ ...form, username: event.target.value })
}
/>
</label>
)}
<label>
{"\u663e\u793a\u540d\u79f0"}
<input
required
value={form.display_name}
onChange={(event) =>
setForm({ ...form, display_name: event.target.value })
}
/>
</label>
<label>
{editing === "new"
? "\u521d\u59cb\u5bc6\u7801"
: "\u91cd\u7f6e\u5bc6\u7801\uff08\u9009\u586b\uff09"}
<input
type="password"
minLength="8"
required={editing === "new"}
value={form.password}
onChange={(event) =>
setForm({ ...form, password: event.target.value })
}
/>
</label>
<label>
{"\u89d2\u8272"}
<select
value={form.role_code}
onChange={(event) =>
setForm({ ...form, role_code: event.target.value })
}
>
<option value="operator">{"\u666e\u901a\u8fd0\u8425"}</option>
<option value="super_admin">
{"\u8d85\u7ea7\u7ba1\u7406\u5458"}
</option>
</select>
</label>
{editing !== "new" && (
<label className="checkbox-row">
<input
type="checkbox"
checked={form.is_active}
onChange={(event) =>
setForm({ ...form, is_active: event.target.checked })
}
/>
{"\u542f\u7528\u8d26\u53f7"}
</label>
)}
<div className="modal-actions">
<button
type="button"
className="ghost-button"
onClick={() => setEditing(null)}
>
{"\u53d6\u6d88"}
</button>
<button className="primary-button">{"\u4fdd\u5b58"}</button>
</div>
</form>
</div>
)}
<table className="data-table">
<thead>
<tr>
<th>{"\u8d26\u53f7"}</th>
<th>{"\u663e\u793a\u540d\u79f0"}</th>
<th>{"\u89d2\u8272"}</th>
<th>{"\u72b6\u6001"}</th>
<th>{"\u6700\u8fd1\u767b\u5f55"}</th>
<th>{"\u64cd\u4f5c"}</th>
</tr>
</thead>
<tbody>
{users.map((user) => (
<tr key={user.id}>
<td>{user.username}</td>
<td>{user.display_name}</td>
<td>
<span className="role-pill">{user.role_label}</span>
</td>
<td>
<span
className={
user.is_active ? "status-pill success" : "status-pill"
}
>
{user.is_active ? "\u6b63\u5e38" : "\u505c\u7528"}
</span>
</td>
<td>
{user.last_login_at
? new Date(user.last_login_at).toLocaleString()
: "\u672a\u767b\u5f55"}
</td>
<td>
<button className="table-action" onClick={() => open(user)}>
{"\u7f16\u8f91"}
</button>
<button
className="table-action danger"
onClick={() => remove(user)}
>
{"\u5220\u9664"}
</button>
</td>
</tr>
))}
</tbody>
</table>
</>
);
}
function LogTable({ logs }) {
return (
<table className="data-table">
<thead>
<tr>
<th>{"\u8d26\u53f7"}</th>
<th>{"\u7ed3\u679c"}</th>
<th>IP \u5730\u5740</th>
<th>{"\u65f6\u95f4"}</th>
</tr>
</thead>
<tbody>
{logs.map((log, index) => (
<tr key={`${log.created_at}-${index}`}>
<td>{log.username}</td>
<td>
<span
className={log.success ? "status-pill success" : "status-pill"}
>
{log.success ? "\u6210\u529f" : "\u5931\u8d25"}
</span>
</td>
<td>{log.ip_address || "-"}</td>
<td>{new Date(log.created_at).toLocaleString()}</td>
</tr>
))}
</tbody>
</table>
);
}
function SettingsTable({ settings }) {
return (
<div className="settings-list">
{Object.entries(settings).map(([key, value]) => (
<div className="settings-row" key={key}>
<strong>{key}</strong>
<span>
{Array.isArray(value)
? value.map((item) => item.label).join("\u3001")
: String(value)}
</span>
</div>
))}
</div>
);
}
function ReviewTable({ records, onRefresh }) {
const [busy, setBusy] = useState("");
async function decide(record, approved) {
setBusy(record.task_id);
try {
await api(`/api/admin/reviews/${record.task_id}/decision`, {
method: "POST",
body: JSON.stringify({ approved }),
});
onRefresh();
} catch (error) {
window.alert(error.message);
} finally {
setBusy("");
}
}
return (
<div className="review-list">
{records.length === 0 && (
<div className="empty-state">暂无待审核记录</div>
)}
{records.map((record) => (
<article className="review-card panel" key={record.task_id}>
<div className="review-card-head">
<div>
<strong>{record.task_id}</strong>
<span>
{record.user_name} · {record.product_name}
</span>
</div>
<span className="status-pill">{record.status}</span>
</div>
<div className="review-columns">
<div>
<h4>邀答截图({record.invited_screenshots.length})</h4>
<div className="review-images">
{record.invited_screenshots.map((image) => (
<img key={image.name} src={image.data_url} alt={image.name} />
))}
</div>
</div>
<div>
<h4>识别问题({record.recognized_questions.length})</h4>
{record.recognized_questions.map((question) => (
<div className="review-question" key={question.id}>
<b>{question.id}</b>
{question.question}
</div>
))}
</div>
<div>
<h4>完成截图({record.final_screenshots.length})</h4>
<div className="review-images">
{record.final_screenshots.map((image) => (
<img key={image.name} src={image.data_url} alt={image.name} />
))}
</div>
</div>
</div>
<div className="review-actions">
<button
className="danger-button"
disabled={!!busy}
onClick={() => decide(record, false)}
>
驳回
</button>
<button
className="primary-button"
disabled={!!busy}
onClick={() => decide(record, true)}
>
审核通过
</button>
</div>
</article>
))}
</div>
);
}
function ReviewPanel({ records, onRefresh }) {
const [queue, setQueue] = useState("answer");
return (
<>
<div className="review-queues">
<button
className={queue === "ask" ? "active" : ""}
onClick={() => setQueue("ask")}
>
队列一:提问审核
</button>
<button
className={queue === "answer" ? "active" : ""}
onClick={() => setQueue("answer")}
>
队列二:回答审核
</button>
</div>
<div className="review-tip">
<strong>{queue === "answer" ? "回答审核说明" : "提问审核说明"}</strong>
<span>
{queue === "answer"
? "查看用户上传的邀答截图、识别问题及完成回答截图,确认问题与回答内容是否符合任务要求。点击截图可放大查看。"
: "对照已发布的问题文字与用户提交截图,审核提问是否合规、相关、无重复。"}
</span>
</div>
{queue === "answer" ? (
<ReviewTable records={records} onRefresh={onRefresh} />
) : (
<div className="empty-state">暂无提问审核记录</div>
)}
</>
);
}
function UsagePanel({ data }) {
const maxCount = Math.max(1, ...(data.trend || []).map((item) => item.count || 0));
return (
<div className="usage-statistics">
<div className="usage-summary-grid">
<div><span>近30天使用次数</span><strong>{data.total_count || 0}</strong></div>
<div><span>今日使用次数</span><strong>{data.today_count || 0}</strong></div>
<div><span>今日使用人数</span><strong>{data.today_users || 0}</strong></div>
</div>
<section className="usage-chart-section">
<div className="usage-section-heading"><h3>近30天使用趋势</h3><span>每次进入后台计 1 次(10 分钟内不重复)</span></div>
<div className="usage-chart" aria-label="近30天使用趋势图">
{(data.trend || []).map((item) => (
<div className="usage-chart-column" key={item.date} title={`${item.date}:${item.count} 次,${item.users} 人`}>
<i style={{ height: `${Math.max(4, (item.count / maxCount) * 100)}%` }} />
<small>{item.date.slice(5).replace("-", "/")}</small>
</div>
))}
</div>
</section>
<section className="usage-users-section">
<div className="usage-section-heading"><h3>使用人员</h3><span>按近30天使用次数排序</span></div>
{(data.users || []).length ? (
<div className="usage-user-list">
{data.users.map((item) => (
<div className="usage-user-row" key={`${item.user_id}-${item.username}`}>
<div><strong>{item.display_name}</strong><span>{item.department_name}</span></div>
<b>{item.count} 次</b>
</div>
))}
</div>
) : <div className="empty-state">暂无使用记录</div>}
</section>
</div>
);
}
function AdminPage({ type }) {
const [data, setData] = useState(null);
const [error, setError] = useState("");
const [reload, setReload] = useState(0);
const config = {
usage: {
title: "使用统计",
description: "查看运营后台的进入次数、使用人员和所属部门。",
endpoint: "/api/admin/usage/stats",
},
users: {
title: "\u7528\u6237\u7ba1\u7406",
description:
"\u7ba1\u7406\u8fd0\u8425\u540e\u53f0\u8d26\u53f7\u53ca\u89d2\u8272\u72b6\u6001\u3002",
endpoint: "/api/admin/users",
},
logs: {
title: "\u767b\u5f55\u65e5\u5fd7",
description:
"\u67e5\u770b\u8fd0\u8425\u540e\u53f0\u8d26\u53f7\u7684\u767b\u5f55\u8bb0\u5f55\u3002",
endpoint: "/api/admin/auth/login-logs",
},
settings: {
title: "\u7cfb\u7edf\u914d\u7f6e",
description:
"\u67e5\u770b\u5f53\u524d\u540e\u53f0\u8fd0\u884c\u53c2\u6570\u4e0e\u53ef\u7528\u89d2\u8272\u3002",
endpoint: "/api/admin/settings",
},
review: {
title: "\u5ba1\u6838\u4e2d\u5fc3",
description:
"\u67e5\u770b\u56de\u7b54\u7528\u6237\u4e0a\u4f20\u7684\u622a\u56fe\u4e0e\u8bc6\u522b\u95ee\u9898\u3002",
endpoint: "/api/admin/reviews",
},
}[type];
useEffect(() => {
setData(null);
setError("");
api(config.endpoint)
.then((result) => setData(result.data))
.catch((err) => setError(err.message));
}, [type, reload]);
return (
<>
<PageHeading title={config.title} description={config.description} />
<section className="admin-panel panel">
{error ? (
<div className="form-error">{error}</div>
) : data === null ? (
<div className="empty-state">{"\u52a0\u8f7d\u4e2d\u2026"}</div>
) : type === "usage" ? (
<UsagePanel data={data} />
) : type === "users" ? (
<UserTable
users={data}
onChanged={() => setReload((value) => value + 1)}
/>
) : type === "logs" ? (
<LogTable logs={data} />
) : type === "review" ? (
<ReviewPanel
records={data}
onRefresh={() => setReload((value) => value + 1)}
/>
) : (
<SettingsTable settings={data} />
)}
</section>
</>
);
}
function AnswerTaskPage() {
const segments = window.location.pathname.split('/').filter(Boolean)
const linkToken = segments[0] === 'answer' ? (segments[1] || '') : ''
if (!linkToken) return <InvalidAnswerLink />;
return <AnswerUserPortalV2 api={api} linkToken={linkToken} />;
/* Legacy task-specific flow retained temporarily for migration. */
useEffect(() => {
const button = document.createElement("button");
button.textContent = "← 返回";
button.className = "answer-back-floating";
button.onclick = () => {
if (window.history.length > 1) window.history.back();
else window.location.href = "/";
};
document.body.appendChild(button);
return () => button.remove();
}, []);
const taskId = "";
const token = "";
const [task, setTask] = useState(null);
const [files, setFiles] = useState([]);
const [selected, setSelected] = useState([]);
const [userName, setUserName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
useEffect(() => {
setLoading(false);
setTask({
task_id: "公共回答链接",
product_name: "淘宝问大家",
status: "待上传",
recognized_questions: [],
selected_questions: [],
});
}, []);
function choose(event) {
const next = Array.from(event.target.files || []);
if (next.length > 10) return setError("最多上传10张截图");
setError("");
setFiles(next);
}
async function uploadInvited(event) {
event.preventDefault();
if (!files.length) return setError("请先选择截图");
setSubmitting(true);
setError("");
const body = new FormData();
body.append("token", token);
body.append("user_name", userName);
files.forEach((file) => body.append("files", file));
try {
await api(
`/api/answer/tasks/${encodeURIComponent(taskId)}/invited-screenshots`,
{ method: "POST", body, headers: {} },
);
const result = await api(
`/api/answer/tasks/${encodeURIComponent(taskId)}?token=${encodeURIComponent(token)}`,
);
setTask(result.data);
setFiles([]);
} catch (err) {
setError(err.message);
} finally {
setSubmitting(false);
}
}
async function submitSelection() {
if (!selected.length) return setError("请至少选择一个要回答的问题");
setSubmitting(true);
try {
const result = await api(
`/api/answer/tasks/${encodeURIComponent(taskId)}/select`,
{
method: "POST",
body: JSON.stringify({ token, question_ids: selected }),
},
);
setTask(result.data);
setError("");
} catch (err) {
setError(err.message);
} finally {
setSubmitting(false);
}
}
async function uploadFinal(event) {
event.preventDefault();
if (!files.length) return setError("请先选择完成截图");
setSubmitting(true);
const body = new FormData();
body.append("token", token);
files.forEach((file) => body.append("files", file));
try {
await api(
`/api/answer/tasks/${encodeURIComponent(taskId)}/final-screenshots`,
{ method: "POST", body, headers: {} },
);
setDone(true);
setTask((current) => ({ ...current, status: "待审核" }));
setFiles([]);
} catch (err) {
setError(err.message);
} finally {
setSubmitting(false);
}
}
if (loading)
return (
<div className="answer-page">
<div className="answer-card">
<div className="empty-state">加载任务中…</div>
</div>
</div>
);
if (error && !task)
return (
<div className="answer-page">
<div className="answer-card">
<h1>回答任务链接</h1>
<div className="form-error">{error}</div>
</div>
</div>
);
const recognized = task?.recognized_questions || [];
const assigned = task?.selected_questions || [];
const canSelect = task?.status === "待筛选" && recognized.length > 0;
const canFinish = task?.status === "待完成" && assigned.length > 0;
return (
<div className="answer-page">
<div className="answer-card">
<div className="answer-brand">
<MessageCircle size={20} />
<span>【问大家】回答任务</span>
</div>
<div className="answer-head">
<h1>{task.product_name}</h1>
<span className="status-pill">{done ? "待审核" : task.status}</span>
</div>
<p className="answer-task-id">任务ID:{task.task_id}</p>
{error && <div className="form-error">{error}</div>}
{!recognized.length && (
<form onSubmit={uploadInvited} className="answer-section">
<h2>上传受邀回答截图</h2>
<p>请上传淘宝“被邀请回答”页面截图,系统会自动识别其中的问题。</p>
<label>
用户标识(选填)
<input
value={userName}
onChange={(event) => setUserName(event.target.value)}
placeholder="例如:淘宝昵称"
/>
</label>
<label className="upload-box">
<input
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
onChange={choose}
/>
<span>点击选择截图(最多10张,仅支持 JPG/PNG/WEBP)</span>
</label>
{files.length > 0 && (
<div className="file-list">已选择 {files.length} 张截图</div>
)}
<button
className="primary-button answer-submit"
disabled={submitting}
>
{submitting ? "识别提交中…" : "上传并提交审核"}
</button>
</form>
)}
{canSelect && (
<section className="answer-section">
<h2>请选择要回答的问题</h2>
<p>系统已识别出以下问题,请勾选后提交给运营筛选。</p>
<div className="answer-question-list">
{recognized.map((question) => (
<label key={question.id}>
<input
type="checkbox"
checked={selected.includes(question.id)}
onChange={(event) =>
setSelected((current) =>
event.target.checked
? [...current, question.id]
: current.filter((id) => id !== question.id),
)
}
/>
<span>
<b>{question.id}</b>
{question.question}
</span>
</label>
))}
</div>
<button
className="primary-button answer-submit"
onClick={submitSelection}
disabled={submitting}
>
{submitting ? "提交中…" : "确认选择"}
</button>
</section>
)}
{canFinish && (
<section className="answer-section">
<h2>完成回答</h2>
<p>请复制参考回答到淘宝完成回答,然后上传完成截图。</p>
<div className="assigned-list">
{assigned.map((question) => (
<article key={question.id}>
<h3>
{question.id} {question.question}
</h3>
<p>参考回答:{question.answer}</p>
</article>
))}
</div>
<form onSubmit={uploadFinal}>
<label className="upload-box">
<input
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
onChange={choose}
/>
<span>点击选择完成截图(最多10张)</span>
</label>
{files.length > 0 && (
<div className="file-list">已选择 {files.length} 张截图</div>
)}
<button
className="primary-button answer-submit"
disabled={submitting}
>
{submitting ? "提交中…" : "提交完成截图"}
</button>
</form>
</section>
)}
{done && (
<div className="answer-success">已提交运营审核,请等待处理。</div>
)}
{task.status === "待审核" && !done && (
<div className="answer-success">已提交运营审核,请等待处理。</div>
)}
</div>
</div>
);
}
function InvalidAnswerLink() {
return (
<main className="answer-v2-shell">
<section className="answer-v2-app login-only">
<section className="answer-v2-login">
<h1>回答任务链接无效</h1>
<p>请联系对应运营获取专属回答任务链接。</p>
</section>
</section>
</main>
);
}
function SubmissionStatusDynamic({
record,
history,
onRefresh,
onNew,
onHistory,
}) {
if (!record) return null;
const passed = record.status === "已通过";
const rejected = record.status === "未通过";
return (
<>
<div
className={`answer-status-card ${passed ? "passed" : rejected ? "rejected" : ""}`}
>
<div className="answer-status-heading">
<span>{passed ? "✓" : rejected ? "!" : "◷"}</span>
<div>
<h2>
{passed ? "审核已通过" : rejected ? "审核未通过" : "正在审核中"}{" "}
<em>
{passed ? "审核通过" : rejected ? "需要重传" : "截图初审"}
</em>
</h2>
<p>
{passed
? "您提交的截图已通过运营审核,后续问题派发功能接入后将在这里展示。"
: rejected
? "本次截图未通过审核,请重新上传清晰、完整的截图。"
: "您提交的被邀回答截图正在审核,运营正在核验问题,请耐心等待。"}
</p>
</div>
</div>
<div className="answer-status-meta">
<div>
<span>{passed || rejected ? "处理时间" : "提交时间"}</span>
<b>{new Date(record.updated_at).toLocaleString()}</b>
</div>
<div>
<span>当前状态</span>
<b>{record.status}</b>
</div>
</div>
<div className="answer-status-actions">
<span>
{passed
? "审核结果已更新"
: rejected
? "请重新上传材料"
: "页面可刷新查看最新审核结果"}
</span>
<button className="ghost-button" onClick={onRefresh}>
↻ 刷新状态
</button>
</div>
</div>
<div className="answer-material-card">
<div>
<h3>▧ 已提交核验材料(只读)</h3>
<span>♙ 已锁定审核</span>
</div>
<div className="answer-material-grid">
{record.invited_screenshots.map((image, index) => (
<figure
key={`${image.name}-${index}`}
>
<img src={image.data_url} alt={image.name} />
<figcaption>◉ 被邀截图{index + 1}</figcaption>
</figure>
))}
</div>
</div>
<div className="answer-history-actions">
<button className="ghost-button" onClick={onHistory}>
上传历史({history.length})
</button>
<button className="primary-button" onClick={onNew}>
{rejected ? "重新上传" : "继续上传"}
</button>
</div>
</>
);
}
function AnswerUserPortal() {
const [session, setSession] = useState(() =>
JSON.parse(localStorage.getItem("answer_user_session") || "null"),
);
const [phone, setPhone] = useState("");
const [records, setRecords] = useState([]);
const [files, setFiles] = useState([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
async function login(event) {
event.preventDefault();
setBusy(true);
setError("");
try {
const result = await api("/api/answer/auth/phone-login", {
method: "POST",
body: JSON.stringify({ phone }),
});
localStorage.setItem("answer_user_session", JSON.stringify(result.data));
setSession(result.data);
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
async function load() {
if (!session) return;
try {
const result = await api(
`/api/answer/me/submissions?token=${encodeURIComponent(session.token)}`,
);
setRecords(result.data);
} catch (err) {
setError(err.message);
}
}
useEffect(() => {
load();
}, [session]);
useEffect(() => {
if (!session) return;
const timer = setInterval(load, 15000);
return () => clearInterval(timer);
}, [session]);
async function upload(event) {
event.preventDefault();
if (!files.length) return setError("请先选择截图");
setBusy(true);
const body = new FormData();
body.append("token", session.token);
files.forEach((file) => body.append("files", file));
try {
await api("/api/answer/me/submissions", { method: "POST", body });
setFiles([]);
await load();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
async function complete(record, event) {
event.preventDefault();
const selected = Array.from(event.currentTarget.elements.files.files || []);
if (!selected.length) return setError("请上传完成截图");
setBusy(true);
const body = new FormData();
body.append("token", session.token);
selected.forEach((file) => body.append("files", file));
try {
await api(`/api/answer/me/submissions/${record.id}/completion`, {
method: "POST",
body,
});
await load();
} catch (err) {
setError(err.message);
} finally {
setBusy(false);
}
}
if (!session)
return (
<main className="answer-public-shell">
<section className="answer-mobile-app answer-login">
<header className="answer-public-header">
<div>
<ShieldCheck size={16} />
<strong>淘宝问答运营官方承接通道</strong>
<em>官方认证</em>
</div>
</header>
<form className="answer-phone-card" onSubmit={login}>
<div className="answer-login-icon">
<MessageCircle size={28} />
</div>
<h1>手机号登录</h1>
<p>登录后系统会生成唯一用户ID,用于接收和跟踪回答任务。</p>
<label>
手机号码
<input
autoFocus
inputMode="numeric"
maxLength="11"
value={phone}
onChange={(event) =>
setPhone(event.target.value.replace(/\D/g, ""))
}
placeholder="请输入11位手机号"
/>
</label>
{error && <div className="form-error">{error}</div>}
<button className="primary-button" disabled={busy}>
{busy ? "登录中…" : "登录并进入任务中心"}
</button>
<small>第一版暂不发送短信验证码</small>
</form>
</section>
</main>
);
const latest = records[0];
return (
<main className="answer-public-shell">
<section className="answer-mobile-app">
<header className="answer-public-header">
<div>
<ShieldCheck size={16} />
<strong>淘宝问答运营官方承接通道</strong>
<em>官方认证</em>
</div>
<button
onClick={() => {
localStorage.removeItem("answer_user_session");
setSession(null);
}}
>
退出
</button>
</header>
<div className="answer-user-card">
<div>
<strong>用户ID:{session.user_id}</strong>
<span>{latest?.status || "待上传"}</span>
</div>
<p>{session.taobao_id || session.account || session.phone}</p>
</div>
<section className="answer-public-body">
{error && <div className="form-error">{error}</div>}
{(!latest || ["已通过", "未通过"].includes(latest.status)) && (
<form onSubmit={upload}>
<div className="answer-guide">
<small>✣ 邀答问题识别</small>
<h1>上传被邀请回答截图</h1>
<p>
系统将模拟识别商品标题、商品主图和问题,并自动生成参考回答,交由运营派发。
</p>
</div>
<div className="answer-upload-card">
<label className="answer-dropzone">
<input
name="files"
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
onChange={(event) =>
setFiles(Array.from(event.target.files).slice(0, 10))
}
/>
<span className="answer-upload-plus">+</span>
<strong>上传被邀截图</strong>
<small>最多10张 JPG、PNG、WEBP</small>
</label>
{files.length > 0 && (
<div className="file-list">已选择 {files.length} 张</div>
)}
</div>
<button
className="primary-button answer-submit"
disabled={busy || !files.length}
>
{busy ? "识别和生成回答中…" : "提交识别"}
</button>
</form>
)}
{latest && (
<div className="answer-personal-task">
<div className="answer-status-card">
<h2>{latest.status}</h2>
<p>提交编号:{latest.submission_no}</p>
<p>
{latest.status === "待运营派发"
? "问题和回答已同步至运营后台“创建任务”。"
: latest.status === "待用户完成"
? "运营已派发回答任务,请按参考回答完成淘宝回答。"
: latest.status === "待审核"
? "完成截图已提交,等待运营审核。"
: latest.status === "已通过"
? "任务审核已通过。"
: "请关注任务进度。"}
</p>
</div>
{latest.status === "待用户完成" && (
<>
<div className="assigned-list">
{latest.questions
.filter((q) => q.selected)
.map((q) => (
<article key={q.id}>
<h3>{q.question}</h3>
<p>商品:{q.product_title}</p>
<p>参考回答:{q.answer}</p>
</article>
))}
</div>
<form
className="answer-upload-card"
onSubmit={(event) => complete(latest, event)}
>
<h3>上传完成回答截图</h3>
<input
name="files"
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
/>
<button className="primary-button" disabled={busy}>
提交完成截图
</button>
</form>
</>
)}
</div>
)}
<div className="answer-help">
<strong>上传历史</strong>
{records.map((item) => (
<p key={item.id}>
{item.submission_no} · {item.status} ·{" "}
{new Date(item.created_at).toLocaleString()}
</p>
))}
</div>
</section>
</section>
</main>
);
}
function PublicAnswerPageWithStatus() {
const [records, setRecords] = useState([]);
const [showHistory, setShowHistory] = useState(false);
const [uploading, setUploading] = useState(false);
const [poll, setPoll] = useState(0);
async function load() {
try {
const result = await api(
"/api/answer/public/submissions?client_id=anonymous-browser",
);
setRecords(result.data);
} catch {}
}
useEffect(() => {
load();
}, [poll]);
useEffect(() => {
const timer = setInterval(() => setPoll((value) => value + 1), 15000);
return () => clearInterval(timer);
}, []);
if (showHistory)
return (
<main className="answer-public-shell">
<section className="answer-mobile-app">
<SubmissionHistory
records={records}
onBack={() => setShowHistory(false)}
/>
</section>
</main>
);
if (records.length && !uploading) {
const status = records[0].status;
const passed = status === "已通过";
const rejected = status === "未通过";
return (
<main className="answer-public-shell">
<section className="answer-mobile-app">
<header className="answer-public-header">
<div>
<ShieldCheck size={16} />
<strong>淘宝问答运营官方承接通道</strong>
<em>官方认证</em>
</div>
</header>
<div className="answer-tabs">
<strong>
<ClipboardList size={17} />
任务认证 <i />
</strong>
<span>⌁ 任务状态 {status}</span>
</div>
<div className="answer-progress">
<div className="complete">
<b>✓</b>
<span>上传截图</span>
</div>
<div className={passed ? "complete" : "active"}>
<b>{passed ? "✓" : "2"}</b>
<span>运营审核</span>
</div>
<div className={passed ? "active" : ""}>
<b>3</b>
<span>问题派发</span>
</div>
<div>
<b>4</b>
<span>完成任务</span>
</div>
</div>
<section className="answer-public-body">
<SubmissionStatusDynamic
record={records[0]}
history={records}
onRefresh={load}
onNew={() => setUploading(true)}
onHistory={() => setShowHistory(true)}
/>
{rejected && (
<div className="answer-public-error form-error">
审核未通过,请重新上传符合要求的截图。
</div>
)}
</section>
</section>
</main>
);
}
return (
<PublicAnswerPageEnhanced
key={records.length}
onSubmitted={() => {
setUploading(false);
setPoll((value) => value + 1);
}}
/>
);
}
function PublicAnswerPageEnhanced({ onSubmitted }) {
const [files, setFiles] = useState([]);
const [userName, setUserName] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
const [dragging, setDragging] = useState(false);
const [history, setHistory] = useState([]);
const [showHistory, setShowHistory] = useState(false);
const [current, setCurrent] = useState(null);
const clientId = (() => {
let value = localStorage.getItem("answer_client_id");
if (!value) {
value = `web-${Date.now()}-${Math.random().toString(36).slice(2)}`;
localStorage.setItem("answer_client_id", value);
}
return value;
})();
async function refreshHistory() {
try {
const result = await api(
`/api/answer/public/submissions?client_id=${encodeURIComponent(clientId)}`,
);
setHistory(result.data);
if (result.data.length) {
setCurrent(result.data[0]);
setDone(true);
}
} catch {}
}
useEffect(() => {
refreshHistory();
}, []);
useEffect(
() => () => files.forEach((item) => URL.revokeObjectURL(item.preview)),
[files],
);
function addFiles(list) {
const incoming = Array.from(list || []).filter((file) =>
["image/jpeg", "image/png", "image/webp"].includes(file.type),
);
if (files.length + incoming.length > 10)
return setError("每次最多上传10张截图");
if (incoming.length !== Array.from(list || []).length)
setError("仅支持 JPG、PNG、WEBP 图片");
else setError("");
setFiles((current) => [
...current,
...incoming.map((file) => ({ file, preview: URL.createObjectURL(file) })),
]);
}
function removeFile(index) {
setFiles((current) => {
URL.revokeObjectURL(current[index].preview);
return current.filter((_, fileIndex) => fileIndex !== index);
});
}
async function submit(event) {
event.preventDefault();
if (!files.length) return setError("请先上传至少一张受邀回答截图");
setSubmitting(true);
setError("");
const body = new FormData();
body.append("client_id", "anonymous-browser");
body.append("user_name", userName);
files.forEach((item) => body.append("files", item.file));
try {
await api("/api/answer/public/submissions", { method: "POST", body });
setDone(true);
files.forEach((item) => URL.revokeObjectURL(item.preview));
setFiles([]);
onSubmitted?.();
} catch (err) {
setError(err.message);
} finally {
setSubmitting(false);
}
}
return (
<main className="answer-public-shell">
<section className="answer-mobile-app">
<header className="answer-public-header">
<div>
<ShieldCheck size={16} />
<strong>淘宝问答运营官方承接通道</strong>
<em>官方认证</em>
</div>
<button
type="button"
onClick={() =>
window.alert(
"请上传清晰、完整的淘宝“被邀请回答”页面截图;单次最多10张。",
)
}
>
? 规则说明
</button>
</header>
<div className="answer-user-card">
<div>
<strong>淘宝问答承接</strong>
<span className={done ? "finished" : ""}>
{done ? "已提交" : "待提交"}
</span>
</div>
<p>公共回答截图识别通道</p>
</div>
<div className="answer-tabs">
<strong>
<ClipboardList size={17} />
任务认证 <i />
</strong>
<span>⌁ 任务状态 {done ? "已提交审核" : "第1步·上传截图"}</span>
</div>
<div className="answer-progress">
<div className={!done ? "active" : "complete"}>
<b>{done ? "✓" : "1"}</b>
<span>上传截图</span>
</div>
<div className={done ? "active" : ""}>
<b>2</b>
<span>运营审核</span>
</div>
<div>
<b>3</b>
<span>问题派发</span>
</div>
<div>
<b>4</b>
<span>完成任务</span>
</div>
</div>
<section className="answer-public-body">
<div className="answer-guide">
<small>✣ 任务认证与核验</small>
<h1>{done ? "截图已提交审核" : "上传被邀请回答截图"}</h1>
<p>
{done
? "运营将在审核中心查看原始截图和系统识别的问题,请留意后续通知。"
: "请上传淘宝“被邀请回答”页面的真实截图。提交后系统将识别截图中的问题,并连同原始截图提交运营审核。"}
</p>
</div>
{done ? (
<div className="answer-done-card">
<div className="answer-done-icon">✓</div>
<h2>已提交运营审核</h2>
<p>本次截图已成功接收,请等待运营处理。</p>
<button className="primary-button" onClick={() => setDone(false)}>
继续上传一批截图
</button>
</div>
) : (
<form onSubmit={submit}>
<div className="answer-upload-card">
<div className="answer-upload-title">
<div>
<h2>上传被邀请回答截图</h2>
<p>支持多张上传,最多10张</p>
</div>
<span>必传</span>
</div>
<label>
用户标识(选填)
<input
value={userName}
onChange={(event) => setUserName(event.target.value)}
placeholder="填写淘宝昵称,便于运营识别"
/>
</label>
<label
className={`answer-dropzone ${dragging ? "dragging" : ""}`}
onDragEnter={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
event.preventDefault();
setDragging(false);
addFiles(event.dataTransfer.files);
}}
>
<input
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
onChange={(event) => {
addFiles(event.target.files);
event.target.value = "";
}}
/>
<span className="answer-upload-plus">+</span>
<strong>上传被邀截图</strong>
<small>
点击选择或拖入图片
<br />
支持 JPG、PNG、WEBP
</small>
</label>
{files.length > 0 && (
<div className="answer-preview-grid">
{files.map((item, index) => (
<figure key={`${item.file.name}-${index}`}>
<img src={item.preview} alt={item.file.name} />
<button type="button" onClick={() => removeFile(index)}>
×
</button>
<figcaption>{item.file.name}</figcaption>
</figure>
))}
</div>
)}
<div className="answer-upload-note">
<span>▧ 请确保截图清晰、信息完整、无遮挡</span>
<b>已添加 {files.length}/10 张</b>
</div>
</div>
{error && (
<div className="form-error answer-public-error">{error}</div>
)}
<div className="answer-submit-card">
<div>
<span>♢ 请先上传截图后再提交</span>
<b>已添加 {files.length} 张</b>
</div>
<button
className="primary-button"
disabled={!files.length || submitting}
>
{submitting ? "正在识别并提交…" : "确认提交截图,进入审核 →"}
</button>
</div>
</form>
)}
<div className="answer-help">
<strong>上传说明</strong>
<ul>
<li>请上传淘宝“被邀请回答”页面截图。</li>
<li>截图需要完整显示问题文字,不要裁剪关键内容。</li>
<li>提交后用户端不展示识别结果,由运营统一审核。</li>
</ul>
</div>
</section>
</section>
</main>
);
}
function SubmissionStatus({ record, history, onRefresh, onNew, onHistory }) {
if (!record) return null;
return (
<>
<div className="answer-status-card">
<div className="answer-status-heading">
<span>◷</span>
<div>
<h2>
正在审核中 <em>截图初审</em>
</h2>
<p>您提交的被邀回答截图正在审核,运营正在核验问题,请耐心等待。</p>
</div>
</div>
<div className="answer-status-meta">
<div>
<span>提交时间</span>
<b>{new Date(record.updated_at).toLocaleString()}</b>
</div>
<div>
<span>预计审核耗时</span>
<b>通常 3~10 分钟</b>
</div>
</div>
<div className="answer-status-actions">
<span>页面可刷新查看最新审核结果</span>
<button className="ghost-button" onClick={onRefresh}>
↻ 刷新状态
</button>
</div>
</div>
<div className="answer-material-card">
<div>
<h3>▧ 已提交核验材料(只读)</h3>
<span>♙ 已锁定审核</span>
</div>
<div className="answer-material-grid">
{record.invited_screenshots.map((image, index) => (
<figure
key={`${image.name}-${index}`}
>
<img src={image.data_url} alt={image.name} />
<figcaption>◉ 被邀截图{index + 1}</figcaption>
</figure>
))}
</div>
</div>
<div className="answer-history-actions">
<button className="ghost-button" onClick={onHistory}>
上传历史({history.length})
</button>
<button className="primary-button" onClick={onNew}>
继续上传
</button>
</div>
</>
);
}
function SubmissionHistory({ records, onBack }) {
return (
<div className="answer-history-page">
<div className="answer-history-head">
<button onClick={onBack}>← 返回</button>
<h2>上传历史</h2>
</div>
{records.length === 0 ? (
<div className="empty-state">暂无上传记录</div>
) : (
records.map((record) => (
<article key={record.task_id}>
<div>
<strong>{record.task_id}</strong>
<span className="status-pill">{record.status}</span>
</div>
<p>
{new Date(record.updated_at).toLocaleString()} ·{" "}
{record.invited_screenshots.length} 张截图
</p>
<div className="answer-history-thumbs">
{record.invited_screenshots.map((image, index) => (
<img
key={index}
src={image.data_url}
alt={image.name}
/>
))}
</div>
</article>
))
)}
</div>
);
}
function PublicAnswerPageEnhancedBridge(props) {
const [finished, setFinished] = useState(false);
if (finished) {
props.onSubmitted?.();
return null;
}
return <PublicAnswerPage />;
}
function PublicAnswerPage() {
const [files, setFiles] = useState([]);
const [userName, setUserName] = useState("");
const [error, setError] = useState("");
const [submitting, setSubmitting] = useState(false);
const [done, setDone] = useState(false);
function choose(event) {
const next = Array.from(event.target.files || []);
if (next.length > 10) {
setError("最多上传10张截图");
return;
}
setFiles(next);
setError("");
}
async function submit(event) {
event.preventDefault();
if (!files.length) return setError("请先选择受邀回答截图");
setSubmitting(true);
const body = new FormData();
body.append("user_name", userName);
files.forEach((file) => body.append("files", file));
try {
await api("/api/answer/public/submissions", { method: "POST", body });
setDone(true);
setFiles([]);
setError("");
} catch (err) {
setError(err.message);
} finally {
setSubmitting(false);
}
}
return (
<div className="answer-page">
<div className="answer-card">
<div className="answer-brand">
<MessageCircle size={20} />
<span>【问大家】回答任务中心</span>
</div>
<div className="answer-head">
<h1>上传受邀回答截图</h1>
</div>
<p className="answer-task-id">
所有回答用户使用同一个链接,无需登录或输入任务ID
</p>
{error && <div className="form-error">{error}</div>}
{done ? (
<div className="answer-success">
<strong>已提交运营审核</strong>
<p>系统已接收截图并识别问题,请等待运营处理。</p>
<button className="ghost-button" onClick={() => setDone(false)}>
继续上传
</button>
</div>
) : (
<form onSubmit={submit} className="answer-section">
<p>
请上传淘宝“被邀请回答”页面截图,系统将识别截图中的问题,并连同原始截图提交给运营审核。
</p>
<label>
用户标识(选填)
<input
value={userName}
onChange={(event) => setUserName(event.target.value)}
placeholder="例如:淘宝昵称"
/>
</label>
<label className="upload-box">
<input
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
onChange={choose}
/>
<span>点击选择截图(最多10张,仅支持 JPG、PNG、WEBP)</span>
</label>
{files.length > 0 && (
<div className="file-list">
已选择 {files.length} 张截图:
{files.map((file) => file.name).join("、")}
</div>
)}
<button
className="primary-button answer-submit"
disabled={submitting}
>
{submitting ? "识别并提交中…" : "上传并提交审核"}
</button>
</form>
)}
</div>
</div>
);
}
function CreateAnswerTasksPage() {
const [records, setRecords] = useState([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState("");
async function load() {
try {
const result = await api("/api/admin/answer-task-drafts");
setRecords(result.data);
} catch (err) {
setError(err.message);
}
}
useEffect(() => {
load();
}, []);
async function update(question, payload) {
setBusy(question.id);
try {
await api(`/api/admin/answer-task-drafts/questions/${question.id}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
await load();
} catch (err) {
window.alert(err.message);
} finally {
setBusy("");
}
}
async function publish(record) {
setBusy(record.id);
try {
await api(`/api/admin/answer-task-drafts/${record.id}/publish`, {
method: "POST",
});
await load();
} catch (err) {
window.alert(err.message);
} finally {
setBusy("");
}
}
return (
<>
<PageHeading
title="创建任务"
description="按用户ID查看截图识别的问题,筛选并发布回答任务。"
/>
<div className="review-tip">
<strong>选择任务说明</strong>
<span>
每个用户上传批次独立分组;勾选需要派发的问题,可编辑或重新生成回答,确认后生成任务。
</span>
</div>
{error && <div className="form-error">{error}</div>}
<div className="answer-draft-list">
{records.length === 0 && (
<div className="empty-results panel">暂无待创建的回答任务</div>
)}
{records.map((record) => (
<article className="answer-draft-group panel" key={record.id}>
<header>
<div>
<strong>{record.user_id}</strong>
<span>
{record.taobao_id || record.phone}
</span>
<code>{record.submission_no}</code>
<em>{record.status}</em>
</div>
<button
className="primary-button"
disabled={!!busy || record.status === "待用户完成"}
onClick={() => publish(record)}
>
{record.status === "待用户完成" ? "已派发" : "生成任务"}
</button>
</header>
{record.questions.map((question) => (
<section className="answer-draft-question" key={question.id}>
<input
type="checkbox"
checked={question.selected}
disabled={record.status === "待用户完成"}
onChange={(event) =>
update(question, { selected: event.target.checked })
}
/>
<div className="answer-draft-content">
<h3>
Q{question.sequence_no}:{question.question}
</h3>
<div className="answer-product-line">
{question.product_image && (
<img src={question.product_image} alt="商品主图" />
)}
<span>
<b>{question.product_title}</b>
<small>来源:模拟OCR识别</small>
</span>
{question.source_image && (
<img
className="source-shot"
src={question.source_image}
alt="识别原始截图"
/>
)}
</div>
<textarea
defaultValue={question.answer}
disabled={record.status === "待用户完成"}
onBlur={(event) =>
event.target.value !== question.answer &&
update(question, { answer: event.target.value })
}
/>
</div>
<button
className="ghost-button"
disabled={!!busy || record.status === "待用户完成"}
onClick={() => update(question, { regenerate: true })}
>
↻
</button>
</section>
))}
</article>
))}
</div>
</>
);
}
function CompletionReviewPage() {
const [records, setRecords] = useState([]);
async function load() {
const result = await api("/api/admin/answer-completion-reviews");
setRecords(result.data);
}
useEffect(() => {
load();
}, []);
async function decide(record, approved) {
await api(`/api/admin/answer-completion-reviews/${record.id}/decision`, {
method: "POST",
body: JSON.stringify({ approved }),
});
load();
}
return (
<>
<PageHeading
title="审核中心"
description="仅审核用户完成回答后上传的完成截图。"
/>
<div className="answer-draft-list">
{records.length === 0 && (
<div className="empty-results panel">暂无完成截图待审核</div>
)}
{records.map((record) => (
<article className="answer-draft-group panel" key={record.id}>
<header>
<div>
<strong>{record.user_id}</strong>
<code>{record.submission_no}</code>
<em>{record.status}</em>
</div>
</header>
<div className="review-columns">
<div>
<h4>派发问答</h4>
{record.questions
.filter((q) => q.selected)
.map((q) => (
<div className="review-question" key={q.id}>
{q.question}
<br />
<small>{q.answer}</small>
</div>
))}
</div>
<div>
<h4>完成截图</h4>
<div className="review-images">
{record.completion_screenshots.map((image) => (
<img key={image.id} src={image.data_url} alt={image.name} />
))}
</div>
</div>
</div>
<div className="review-actions">
<button
className="danger-button"
onClick={() => decide(record, false)}
>
驳回
</button>
<button
className="primary-button"
onClick={() => decide(record, true)}
>
审核通过
</button>
</div>
</article>
))}
</div>
</>
);
}
function GlobalImageViewer() {
const [gallery, setGallery] = useState([]);
const [currentIndex, setCurrentIndex] = useState(-1);
const [scale, setScale] = useState(1);
const [position, setPosition] = useState({ x: 0, y: 0 });
const [dragging, setDragging] = useState(false);
const [dockSide, setDockSide] = useState("right");
const [floatingPosition, setFloatingPosition] = useState(null);
const [panelDragging, setPanelDragging] = useState(false);
const panelRef = useRef(null);
const panelDragStart = useRef(null);
const dragStart = useRef(null);
const src = gallery[currentIndex]?.src || "";
useEffect(() => {
const handleClick = (event) => {
const image = event.target?.closest?.("img");
if (!image || !image.src || image.closest(".global-image-viewer")) return;
const scope = image.closest(".review-v2-modal") || document;
const imageNodes = Array.from(scope.querySelectorAll("img")).filter(
(node) => node.src && !node.closest(".global-image-viewer"),
);
const items = imageNodes.map((node) => ({
src: node.currentSrc || node.src,
alt: node.alt || "审核图片",
}));
setGallery(items);
setCurrentIndex(Math.max(0, imageNodes.indexOf(image)));
setScale(1);
setPosition({ x: 0, y: 0 });
};
const handleKeyDown = (event) => {
if (event.key === "Escape") {
setGallery([]);
setCurrentIndex(-1);
setFloatingPosition(null);
}
};
document.addEventListener("click", handleClick);
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("click", handleClick);
document.removeEventListener("keydown", handleKeyDown);
};
}, []);
useEffect(() => {
if (!src) return undefined;
const handleGalleryKeyDown = (event) => {
if (event.key === "ArrowLeft") {
setCurrentIndex((current) => Math.max(0, current - 1));
}
if (event.key === "ArrowRight") {
setCurrentIndex((current) => Math.min(gallery.length - 1, current + 1));
}
};
document.addEventListener("keydown", handleGalleryKeyDown);
return () => document.removeEventListener("keydown", handleGalleryKeyDown);
}, [src, gallery.length]);
useEffect(() => {
if (!src) return;
setScale(1);
setPosition({ x: 0, y: 0 });
}, [currentIndex, src]);
useEffect(() => {
document.body.classList.remove("image-preview-left", "image-preview-right");
if (!src) return undefined;
const className = `image-preview-${dockSide}`;
document.body.classList.add(className);
return () => document.body.classList.remove(className);
}, [src, dockSide]);
if (!src) return null;
const changeScale = (delta) => {
setScale((current) => {
const next = Math.min(5, Math.max(0.5, current + delta));
if (next <= 1) setPosition({ x: 0, y: 0 });
return next;
});
};
const resetView = () => {
setScale(1);
setPosition({ x: 0, y: 0 });
};
const beginDrag = (event) => {
if (event.button !== 0) return;
event.currentTarget.setPointerCapture?.(event.pointerId);
dragStart.current = {
x: event.clientX - position.x,
y: event.clientY - position.y,
};
setDragging(true);
};
const moveDrag = (event) => {
if (!dragStart.current) return;
setPosition({
x: event.clientX - dragStart.current.x,
y: event.clientY - dragStart.current.y,
});
};
const endDrag = () => {
dragStart.current = null;
setDragging(false);
};
const closeViewer = () => {
setGallery([]);
setCurrentIndex(-1);
setFloatingPosition(null);
};
const showImage = (nextIndex) => {
if (nextIndex < 0 || nextIndex >= gallery.length) return;
setCurrentIndex(nextIndex);
};
const beginPanelDrag = (event) => {
if (event.button !== 0 || event.target.closest("button") || !panelRef.current) return;
const rect = panelRef.current.getBoundingClientRect();
event.currentTarget.setPointerCapture?.(event.pointerId);
panelDragStart.current = {
pointerX: event.clientX,
pointerY: event.clientY,
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
setPanelDragging(true);
};
const movePanelDrag = (event) => {
if (!panelDragStart.current) return;
const start = panelDragStart.current;
const margin = 8;
const left = Math.min(
window.innerWidth - start.width - margin,
Math.max(margin, start.left + event.clientX - start.pointerX),
);
const top = Math.min(
window.innerHeight - start.height - margin,
Math.max(margin, start.top + event.clientY - start.pointerY),
);
setFloatingPosition({ left, top, height: start.height });
};
const endPanelDrag = () => {
panelDragStart.current = null;
setPanelDragging(false);
};
const switchDockSide = () => {
setFloatingPosition(null);
setDockSide((current) => current === "right" ? "left" : "right");
};
return (
<aside
ref={panelRef}
className={`global-image-viewer ${dockSide === "left" ? "left" : ""}${panelDragging ? " dragging-window" : ""}`}
role="complementary"
aria-label="图片预览"
style={floatingPosition ? {
left: `${floatingPosition.left}px`,
top: `${floatingPosition.top}px`,
right: "auto",
bottom: "auto",
height: `${floatingPosition.height}px`,
} : undefined}
>
<header
className="global-image-viewer-header"
onPointerDown={beginPanelDrag}
onPointerMove={movePanelDrag}
onPointerUp={endPanelDrag}
onPointerCancel={endPanelDrag}
>
<div>
<strong>图片预览</strong>
<span>拖动标题栏可移动 · 页面仍可滚动</span>
</div>
<button
type="button"
className="global-image-viewer-dock"
aria-label={dockSide === "right" ? "将预览移到左侧" : "将预览移到右侧"}
title={dockSide === "right" ? "移到左侧" : "移到右侧"}
onClick={switchDockSide}
>
{dockSide === "right" ? "←" : "→"}
</button>
<button type="button" className="global-image-viewer-close" aria-label="关闭预览" onClick={closeViewer}>×</button>
</header>
<div
className="global-image-viewer-canvas"
onWheel={(event) => {
event.preventDefault();
changeScale(event.deltaY > 0 ? -0.15 : 0.15);
}}
>
<img
src={src}
alt="大图预览"
draggable={false}
className={dragging ? "dragging" : ""}
style={{ transform: `translate(${position.x}px, ${position.y}px) scale(${scale})` }}
onPointerDown={beginDrag}
onPointerMove={moveDrag}
onPointerUp={endDrag}
onPointerCancel={endDrag}
/>
</div>
<div className="global-image-viewer-tools">
<button
type="button"
className="gallery-nav"
disabled={currentIndex <= 0}
onClick={() => showImage(currentIndex - 1)}
>
← 上一张
</button>
<span className="gallery-count">{currentIndex + 1}/{gallery.length}</span>
<button
type="button"
className="gallery-nav"
disabled={currentIndex >= gallery.length - 1}
onClick={() => showImage(currentIndex + 1)}
>
下一张 →
</button>
<i aria-hidden="true" />
<button type="button" aria-label="缩小" onClick={() => changeScale(-0.25)}>−</button>
<span>{Math.round(scale * 100)}%</span>
<button type="button" aria-label="放大" onClick={() => changeScale(0.25)}>+</button>
<button type="button" className="reset" onClick={resetView}>重置</button>
</div>
</aside>
);
}
function Dashboard({ user, onLogout }) {
const [active, setActive] = useState("assistant");
const [createMode, setCreateMode] = useState("ask");
const [reviewBadge, setReviewBadge] = useState(0);
const [librarySelection, setLibrarySelection] = useState([]);
useEffect(() => {
api("/api/admin/usage/enter", { method: "POST" }).catch(() => {});
}, [user.id]);
async function loadReviewBadge() {
try {
const result = await api("/api/admin/review-pending-count");
setReviewBadge(result.data.total || 0);
} catch {
// Keep navigation usable if the counter request temporarily fails.
}
}
useEffect(() => {
const handler = (event) => {
if (event.type === "open-task-board") {
clearTaskBoardCache();
if (event.detail?.status) window.__taskBoardInitialStatus = event.detail.status;
setActive("board");
}
if (event.type === "switch-create-answer") {
setActive("create");
setCreateMode("answer");
}
if (event.type === "switch-create-ask") {
setActive("create");
setCreateMode("ask");
window.__qaLibrarySelectionMode = false;
}
if (event.type === "open-qa-library-picker") {
setActive("library");
setCreateMode("ask");
window.__qaLibrarySelectionMode = true;
}
if (event.type === "qa-library-selected") {
setLibrarySelection(event.detail || []);
window.__qaLibrarySelectionMode = false;
setActive("create");
setCreateMode("ask");
}
};
document.addEventListener("click", handler);
window.addEventListener("open-task-board", handler);
window.addEventListener("open-qa-library-picker", handler);
window.addEventListener("qa-library-selected", handler);
window.addEventListener("switch-create-answer", handler);
window.addEventListener("switch-create-ask", handler);
window.addEventListener("review-count-changed", loadReviewBadge);
loadReviewBadge();
const reviewCountTimer = window.setInterval(loadReviewBadge, 30000);
return () => {
document.removeEventListener("click", handler);
window.removeEventListener("open-task-board", handler);
window.removeEventListener("open-qa-library-picker", handler);
window.removeEventListener("qa-library-selected", handler);
window.removeEventListener("switch-create-answer", handler);
window.removeEventListener("switch-create-ask", handler);
window.removeEventListener("review-count-changed", loadReviewBadge);
window.clearInterval(reviewCountTimer);
};
}, []);
const isAdminPage = ["usage", "users", "logs", "settings"].includes(active);
const selectPage = (next) => {
if (user.permissions.includes(next)) {
setActive(next);
if (next === "create") setCreateMode("ask");
if (next === "review") loadReviewBadge();
}
};
const askTaskOpen = active === "create" && createMode === "ask";
return (
<div className="app-shell">
<Sidebar
user={user}
active={active}
setActive={selectPage}
reviewBadge={reviewBadge}
/>
<section className="main-shell">
<Topbar user={user} onLogout={onLogout} />
<main
className={`dashboard${askTaskOpen ? " ask-task-dashboard" : ""}`}
>
{active === "create" ? (
createMode === "ask" ? (
<CreateAskTasksPage api={api} librarySelection={librarySelection} onLibrarySelectionConsumed={() => setLibrarySelection([])} />
) : (
<CreateAnswerTasksPageV2 api={api} user={user} />
)
) : active === "board" ? (
<TaskBoardPage api={api} user={user} />
) : active === "review" ? (
<ReviewCenterPage api={api} />
) : active === "library" ? (
<QALibraryPage selectionMode={Boolean(window.__qaLibrarySelectionMode)} />
) : isAdminPage ? (
<AdminPage type={active} />
) : (
<AssistantPageFinal />
)}
</main>
</section>
</div>
);
}
function TextDecoder() {
useEffect(() => {
const decode = (value) =>
value.replace(/\\u([0-9a-fA-F]{4})/g, (_, code) =>
String.fromCharCode(parseInt(code, 16)),
);
const scan = (root) => {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
const decoded = decode(node.nodeValue);
if (decoded !== node.nodeValue) node.nodeValue = decoded;
}
root.querySelectorAll?.("[placeholder],[title]").forEach((element) => {
["placeholder", "title"].forEach((attribute) => {
const value = element.getAttribute(attribute);
if (value) element.setAttribute(attribute, decode(value));
});
});
};
const observer = new MutationObserver((records) =>
records.forEach((record) =>
record.addedNodes.forEach((node) => {
if (node.nodeType === Node.TEXT_NODE) {
const decoded = decode(node.nodeValue);
if (decoded !== node.nodeValue) node.nodeValue = decoded;
} else if (node.nodeType === Node.ELEMENT_NODE) scan(node);
}),
),
);
scan(document.body);
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true,
});
return () => observer.disconnect();
}, []);
return null;
}
function App() {
if (window.location.pathname.startsWith("/refund-upload/"))
return <RefundUploadPage api={api} />;
if (window.location.pathname === "/ask" || window.location.pathname.startsWith("/ask/"))
return <AskUserPortal api={api} />;
if (window.location.pathname.startsWith("/answer/"))
return <AnswerTaskPage />;
const [user, setUser] = useState(null);
const [checking, setChecking] = useState(true);
useEffect(() => {
api("/api/admin/auth/me")
.then((result) => setUser(result.data.user))
.catch(() => {})
.finally(() => setChecking(false));
}, []);
async function logout() {
await api("/api/admin/auth/logout", { method: "POST" }).catch(() => {});
clearTaskBoardCache();
setUser(null);
}
if (checking)
return (
<div className="loading-screen">{"\u6b63\u5728\u52a0\u8f7d\u2026"}</div>
);
return user ? (
<Dashboard user={user} onLogout={logout} />
) : (
<LoginPage onLogin={setUser} />
);
}
createRoot(document.getElementById("root")).render(
<React.StrictMode>
<TextDecoder />
<GlobalImageViewer />
<App />
</React.StrictMode>,
);