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 &&
运营后台账号登录
}
{error &&
{error}
}
{ "\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 (

{title}

{description}

{actions &&
{actions}
}
); } 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 ( <>
生成条件配置 * 商品信息至少填写一项

商品信息