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
正在验证钉钉身份…
;
}
if (inDingtalkWorkbench && dingtalkLoginFailed) {
return (
{Z.brand}
钉钉工作台免登
正在使用当前钉钉账号验证身份,不需要输入后台账号密码。
{error || "钉钉免登失败,请稍后重试"}
请从钉钉工作台打开此应用,并确认应用已发布、配置安全域名和免登权限。
);
}
return (
{Z.brand}
{Z.backend}
从钉钉工作台进入,使用钉钉身份安全登录
{!inDingtalkWorkbench && 运营后台账号登录
}
{
"\u8fd0\u8425\u8d26\u53f7\u767b\u5f55 \u00b7 \u7528\u6237 A/B \u8bf7\u4f7f\u7528\u72ec\u7acb\u4efb\u52a1\u94fe\u63a5"
}
);
}
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 (
);
}
function NavItem({ item, active, setActive }) {
const Icon = item.icon;
if (item.href) {
return (
{item.label}
↗
);
}
return (
);
}
function Topbar({ user, onLogout }) {
return (
{user.display_name.slice(0, 1)}
{user.display_name}({user.role_label})
);
}
function PageHeading({ title, description, actions }) {
return (
);
}
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 (
<>
生成条件配置 * 商品信息至少填写一项
商品信息
生成偏好与数量
种草式回答
{error && {error}
}
{items.length === 0 && (
填写左侧商品条件后,点击“生成 Q&A”
)}
{items.map((item) => (
问题:{item.question}
{item.answers.map((answer, index) => (
回答{index + 1}:
{answer}
))}
))}
>
);
}
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 (
{editing ? (
{answers.map((answer, index) => (
{answers.length > 1 && (
)}
))}
) : (
<>
问题:{item.question}
{(item.answers || []).map((answer, index) => (
回答{index + 1}:
{answer}
))}
{!(item.answers || []).length && 尚未生成回答,请点击“编辑”补充
}
>
)}
);
}
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 (
);
}
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 (
event.target === event.currentTarget && !busy && onClose()}>
);
}
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 (
<>
生成条件配置 * 商品信息至少填写一项
商品信息
生成偏好与数量
种草式回答
{error && {error}
}
{items.length === 0 && (
填写左侧商品条件后,点击“生成 Q&A”
)}
{items.map((item) => (
))}
>
);
}
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 (
<>
{error && {error}
}
{loading ? (
正在加载问答库…
) : records.length === 0 ? (
暂无已采纳问答,请先在问答助手中采纳内容。
) : (
records.map((record) => (
Q:{record.question}
{record.answers.map((answer, index) => (
A{index + 1}
{answer}
))}
{(record.benefits || record.selling_points) && (
)}
))
)}
{editing && (
setEditing(null)}
onSaved={(updated) => {
setRecords((current) =>
current.map((item) => (item.id === updated.id ? updated : item)),
);
setEditing(null);
}}
/>
)}
{deleting && (
event.target === event.currentTarget && setDeleting(null)}>
确认删除
确定要删除这条问答吗?删除后将无法恢复。
Q:{deleting.question}
)}
>
);
}
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 (
);
}
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 (
<>
生成条件配置 * 商品信息至少填写一项
商品信息
生成偏好与数量
种草式回答
{error && {error}
}
{savedMessage && {savedMessage}
}
{items.length === 0 && (
填写左侧商品条件后,点击“生成 Q&A”
)}
{items.map((item) => (
))}
{manualOpen && (
setManualOpen(false)} onSave={addManual} />
)}
{importOpen && (
setImportOpen(false)} onImport={addImportedQuestions} />
)}
{adoptedDialog && (
event.target === event.currentTarget && setAdoptedDialog(null)}
>
✓
问答已采纳
已采纳 {adoptedDialog.count} 条,采纳问答已收录至问答库。
)}
>
);
}
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 (
<>
{editing && (
)}
| {"\u8d26\u53f7"} |
{"\u663e\u793a\u540d\u79f0"} |
{"\u89d2\u8272"} |
{"\u72b6\u6001"} |
{"\u6700\u8fd1\u767b\u5f55"} |
{"\u64cd\u4f5c"} |
{users.map((user) => (
| {user.username} |
{user.display_name} |
{user.role_label}
|
{user.is_active ? "\u6b63\u5e38" : "\u505c\u7528"}
|
{user.last_login_at
? new Date(user.last_login_at).toLocaleString()
: "\u672a\u767b\u5f55"}
|
|
))}
>
);
}
function LogTable({ logs }) {
return (
| {"\u8d26\u53f7"} |
{"\u7ed3\u679c"} |
IP \u5730\u5740 |
{"\u65f6\u95f4"} |
{logs.map((log, index) => (
| {log.username} |
{log.success ? "\u6210\u529f" : "\u5931\u8d25"}
|
{log.ip_address || "-"} |
{new Date(log.created_at).toLocaleString()} |
))}
);
}
function SettingsTable({ settings }) {
return (
{Object.entries(settings).map(([key, value]) => (
{key}
{Array.isArray(value)
? value.map((item) => item.label).join("\u3001")
: String(value)}
))}
);
}
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 (
{records.length === 0 && (
暂无待审核记录
)}
{records.map((record) => (
{record.task_id}
{record.user_name} · {record.product_name}
{record.status}
邀答截图({record.invited_screenshots.length})
{record.invited_screenshots.map((image) => (

))}
识别问题({record.recognized_questions.length})
{record.recognized_questions.map((question) => (
{question.id}
{question.question}
))}
完成截图({record.final_screenshots.length})
{record.final_screenshots.map((image) => (

))}
))}
);
}
function ReviewPanel({ records, onRefresh }) {
const [queue, setQueue] = useState("answer");
return (
<>
{queue === "answer" ? "回答审核说明" : "提问审核说明"}
{queue === "answer"
? "查看用户上传的邀答截图、识别问题及完成回答截图,确认问题与回答内容是否符合任务要求。点击截图可放大查看。"
: "对照已发布的问题文字与用户提交截图,审核提问是否合规、相关、无重复。"}
{queue === "answer" ? (
) : (
暂无提问审核记录
)}
>
);
}
function UsagePanel({ data }) {
const maxCount = Math.max(1, ...(data.trend || []).map((item) => item.count || 0));
return (
近30天使用次数{data.total_count || 0}
今日使用次数{data.today_count || 0}
今日使用人数{data.today_users || 0}
近30天使用趋势
每次进入后台计 1 次(10 分钟内不重复)
{(data.trend || []).map((item) => (
{item.date.slice(5).replace("-", "/")}
))}
使用人员
按近30天使用次数排序
{(data.users || []).length ? (
{data.users.map((item) => (
{item.display_name}{item.department_name}
{item.count} 次
))}
) : 暂无使用记录
}
);
}
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 (
<>
{error ? (
{error}
) : data === null ? (
{"\u52a0\u8f7d\u4e2d\u2026"}
) : type === "usage" ? (
) : type === "users" ? (
setReload((value) => value + 1)}
/>
) : type === "logs" ? (
) : type === "review" ? (
setReload((value) => value + 1)}
/>
) : (
)}
>
);
}
function AnswerTaskPage() {
const segments = window.location.pathname.split('/').filter(Boolean)
const linkToken = segments[0] === 'answer' ? (segments[1] || '') : ''
if (!linkToken) return ;
return ;
/* 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 (
);
if (error && !task)
return (
);
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 (
【问大家】回答任务
{task.product_name}
{done ? "待审核" : task.status}
任务ID:{task.task_id}
{error &&
{error}
}
{!recognized.length && (
)}
{canSelect && (
)}
{canFinish && (
完成回答
请复制参考回答到淘宝完成回答,然后上传完成截图。
{assigned.map((question) => (
{question.id} {question.question}
参考回答:{question.answer}
))}
)}
{done && (
已提交运营审核,请等待处理。
)}
{task.status === "待审核" && !done && (
已提交运营审核,请等待处理。
)}
);
}
function InvalidAnswerLink() {
return (
回答任务链接无效
请联系对应运营获取专属回答任务链接。
);
}
function SubmissionStatusDynamic({
record,
history,
onRefresh,
onNew,
onHistory,
}) {
if (!record) return null;
const passed = record.status === "已通过";
const rejected = record.status === "未通过";
return (
<>
{passed ? "✓" : rejected ? "!" : "◷"}
{passed ? "审核已通过" : rejected ? "审核未通过" : "正在审核中"}{" "}
{passed ? "审核通过" : rejected ? "需要重传" : "截图初审"}
{passed
? "您提交的截图已通过运营审核,后续问题派发功能接入后将在这里展示。"
: rejected
? "本次截图未通过审核,请重新上传清晰、完整的截图。"
: "您提交的被邀回答截图正在审核,运营正在核验问题,请耐心等待。"}
{passed || rejected ? "处理时间" : "提交时间"}
{new Date(record.updated_at).toLocaleString()}
当前状态
{record.status}
{passed
? "审核结果已更新"
: rejected
? "请重新上传材料"
: "页面可刷新查看最新审核结果"}
▧ 已提交核验材料(只读)
♙ 已锁定审核
{record.invited_screenshots.map((image, index) => (
◉ 被邀截图{index + 1}
))}
>
);
}
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 (
);
const latest = records[0];
return (
淘宝问答运营官方承接通道
官方认证
用户ID:{session.user_id}
{latest?.status || "待上传"}
{session.taobao_id || session.account || session.phone}
{error && {error}
}
{(!latest || ["已通过", "未通过"].includes(latest.status)) && (
)}
{latest && (
{latest.status}
提交编号:{latest.submission_no}
{latest.status === "待运营派发"
? "问题和回答已同步至运营后台“创建任务”。"
: latest.status === "待用户完成"
? "运营已派发回答任务,请按参考回答完成淘宝回答。"
: latest.status === "待审核"
? "完成截图已提交,等待运营审核。"
: latest.status === "已通过"
? "任务审核已通过。"
: "请关注任务进度。"}
{latest.status === "待用户完成" && (
<>
{latest.questions
.filter((q) => q.selected)
.map((q) => (
{q.question}
商品:{q.product_title}
参考回答:{q.answer}
))}
>
)}
)}
上传历史
{records.map((item) => (
{item.submission_no} · {item.status} ·{" "}
{new Date(item.created_at).toLocaleString()}
))}
);
}
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 (
setShowHistory(false)}
/>
);
if (records.length && !uploading) {
const status = records[0].status;
const passed = status === "已通过";
const rejected = status === "未通过";
return (
任务认证
⌁ 任务状态 {status}
✓
上传截图
{passed ? "✓" : "2"}
运营审核
3
问题派发
4
完成任务
setUploading(true)}
onHistory={() => setShowHistory(true)}
/>
{rejected && (
审核未通过,请重新上传符合要求的截图。
)}
);
}
return (
{
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 (
淘宝问答运营官方承接通道
官方认证
淘宝问答承接
{done ? "已提交" : "待提交"}
公共回答截图识别通道
任务认证
⌁ 任务状态 {done ? "已提交审核" : "第1步·上传截图"}
{done ? "✓" : "1"}
上传截图
2
运营审核
3
问题派发
4
完成任务
✣ 任务认证与核验
{done ? "截图已提交审核" : "上传被邀请回答截图"}
{done
? "运营将在审核中心查看原始截图和系统识别的问题,请留意后续通知。"
: "请上传淘宝“被邀请回答”页面的真实截图。提交后系统将识别截图中的问题,并连同原始截图提交运营审核。"}
{done ? (
✓
已提交运营审核
本次截图已成功接收,请等待运营处理。
) : (
)}
上传说明
- 请上传淘宝“被邀请回答”页面截图。
- 截图需要完整显示问题文字,不要裁剪关键内容。
- 提交后用户端不展示识别结果,由运营统一审核。
);
}
function SubmissionStatus({ record, history, onRefresh, onNew, onHistory }) {
if (!record) return null;
return (
<>
◷
正在审核中 截图初审
您提交的被邀回答截图正在审核,运营正在核验问题,请耐心等待。
提交时间
{new Date(record.updated_at).toLocaleString()}
预计审核耗时
通常 3~10 分钟
页面可刷新查看最新审核结果
▧ 已提交核验材料(只读)
♙ 已锁定审核
{record.invited_screenshots.map((image, index) => (
◉ 被邀截图{index + 1}
))}
>
);
}
function SubmissionHistory({ records, onBack }) {
return (
上传历史
{records.length === 0 ? (
暂无上传记录
) : (
records.map((record) => (
{record.task_id}
{record.status}
{new Date(record.updated_at).toLocaleString()} ·{" "}
{record.invited_screenshots.length} 张截图
{record.invited_screenshots.map((image, index) => (

))}
))
)}
);
}
function PublicAnswerPageEnhancedBridge(props) {
const [finished, setFinished] = useState(false);
if (finished) {
props.onSubmitted?.();
return null;
}
return ;
}
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 (
【问大家】回答任务中心
上传受邀回答截图
所有回答用户使用同一个链接,无需登录或输入任务ID
{error &&
{error}
}
{done ? (
已提交运营审核
系统已接收截图并识别问题,请等待运营处理。
) : (
)}
);
}
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 (
<>
选择任务说明
每个用户上传批次独立分组;勾选需要派发的问题,可编辑或重新生成回答,确认后生成任务。
{error && {error}
}
{records.length === 0 && (
暂无待创建的回答任务
)}
{records.map((record) => (
{record.questions.map((question) => (
update(question, { selected: event.target.checked })
}
/>
Q{question.sequence_no}:{question.question}
{question.product_image && (

)}
{question.product_title}
来源:模拟OCR识别
{question.source_image && (

)}
))}
))}
>
);
}
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 (
<>
{records.length === 0 && (
暂无完成截图待审核
)}
{records.map((record) => (
派发问答
{record.questions
.filter((q) => q.selected)
.map((q) => (
{q.question}
{q.answer}
))}
完成截图
{record.completion_screenshots.map((image) => (

))}
))}
>
);
}
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 (
);
}
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 (
{active === "create" ? (
createMode === "ask" ? (
setLibrarySelection([])} />
) : (
)
) : active === "board" ? (
) : active === "review" ? (
) : active === "library" ? (
) : isAdminPage ? (
) : (
)}
);
}
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 ;
if (window.location.pathname === "/ask" || window.location.pathname.startsWith("/ask/"))
return ;
if (window.location.pathname.startsWith("/answer/"))
return ;
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 (
{"\u6b63\u5728\u52a0\u8f7d\u2026"}
);
return user ? (
) : (
);
}
createRoot(document.getElementById("root")).render(
,
);