4464 lines
154 KiB
JavaScript
4464 lines
154 KiB
JavaScript
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>,
|
||
);
|