407 lines
13 KiB
JavaScript
407 lines
13 KiB
JavaScript
import React, { useState } from "react";
|
||
import { Check, Link2, Plus, Sparkles } from "lucide-react";
|
||
|
||
const blankForm = {
|
||
product_link: "",
|
||
product_name: "",
|
||
category: "",
|
||
benefits: "",
|
||
selling_points: "",
|
||
language_style: "口语(亲切随性)",
|
||
target_group: "女性",
|
||
usage_scene: "居家",
|
||
purchase_channel: "电商平台",
|
||
question_count: 5,
|
||
};
|
||
|
||
async function readReferenceFile(file) {
|
||
if (!file) return "";
|
||
return (await file.text()).slice(0, 20000);
|
||
}
|
||
|
||
export default function CreateAskTasksPage({ api }) {
|
||
const [form, setForm] = useState(blankForm);
|
||
const [task, setTask] = useState(null);
|
||
const [questions, setQuestions] = useState([]);
|
||
const [editing, setEditing] = useState(null);
|
||
const [busy, setBusy] = useState(false);
|
||
const [error, setError] = useState("");
|
||
const [referenceFile, setReferenceFile] = useState(null);
|
||
const update = (key, value) =>
|
||
setForm((current) => ({ ...current, [key]: value }));
|
||
|
||
async function generate() {
|
||
setBusy(true);
|
||
setError("");
|
||
try {
|
||
const reference_qa = await readReferenceFile(referenceFile);
|
||
const result = await api("/api/admin/ask-tasks/generate", {
|
||
method: "POST",
|
||
body: JSON.stringify({
|
||
...form,
|
||
question_count: Number(form.question_count),
|
||
reference_qa,
|
||
}),
|
||
});
|
||
setTask(result.data);
|
||
setQuestions(
|
||
result.data.selected_questions.map((item) => ({
|
||
...item,
|
||
selected: true,
|
||
})),
|
||
);
|
||
} catch (err) {
|
||
setError(err.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
async function saveQuestion(index, value) {
|
||
setQuestions((current) =>
|
||
current.map((item, i) =>
|
||
i === index ? { ...item, question: value } : item,
|
||
),
|
||
);
|
||
const question = questions[index];
|
||
if (
|
||
task &&
|
||
question &&
|
||
!String(question.id).startsWith("new-") &&
|
||
value.trim()
|
||
)
|
||
await api(`/api/admin/ask-tasks/${task.id}/questions/${question.id}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify({ question: value.trim() }),
|
||
});
|
||
}
|
||
async function toggle(index) {
|
||
const question = questions[index];
|
||
const selected = !question.selected;
|
||
setQuestions((current) =>
|
||
current.map((item, i) => (i === index ? { ...item, selected } : item)),
|
||
);
|
||
if (task && !String(question.id).startsWith("new-"))
|
||
await api(`/api/admin/ask-tasks/${task.id}/questions/${question.id}`, {
|
||
method: "PATCH",
|
||
body: JSON.stringify({ selected }),
|
||
});
|
||
}
|
||
async function addQuestion() {
|
||
const value = window.prompt("请输入提问内容");
|
||
if (!value?.trim()) return;
|
||
if (!task)
|
||
return setQuestions((current) => [
|
||
...current,
|
||
{ id: `new-${Date.now()}`, question: value.trim(), selected: true },
|
||
]);
|
||
try {
|
||
const result = await api(`/api/admin/ask-tasks/${task.id}/questions`, {
|
||
method: "POST",
|
||
body: JSON.stringify({ question: value.trim() }),
|
||
});
|
||
setQuestions((current) => [...current, result.data]);
|
||
} catch (err) {
|
||
setError(err.message);
|
||
}
|
||
}
|
||
async function publish() {
|
||
if (!task) return setError("请先生成提问");
|
||
setBusy(true);
|
||
setError("");
|
||
try {
|
||
const result = await api(`/api/admin/ask-tasks/${task.id}/publish`, {
|
||
method: "POST",
|
||
});
|
||
setTask(result.data);
|
||
} catch (err) {
|
||
setError(err.message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
|
||
if (task?.status === "已发布")
|
||
return (
|
||
<>
|
||
<div className="create-page-heading">
|
||
<div>
|
||
<h2>创建任务</h2>
|
||
<p>提问任务已发布,可在任务看板查看。</p>
|
||
</div>
|
||
</div>
|
||
<section className="ask-created-card panel">
|
||
<div className="ask-created-icon">
|
||
<Check size={34} />
|
||
</div>
|
||
<h2>任务创建成功!</h2>
|
||
<p>
|
||
任务ID:<strong>{task.task_id}</strong>
|
||
</p>
|
||
<div className="ask-link-box">
|
||
<span>
|
||
<Link2 size={14} />
|
||
提问任务链接
|
||
</span>
|
||
<code>
|
||
{window.location.origin}/ask/{task.task_id}
|
||
</code>
|
||
<small>当前链接为任务预留入口,后续接入提问用户页面。</small>
|
||
</div>
|
||
<div>
|
||
<button
|
||
className="primary-button"
|
||
onClick={() =>
|
||
window.dispatchEvent(new CustomEvent("open-task-board"))
|
||
}
|
||
>
|
||
前往任务看板查看
|
||
</button>
|
||
<button
|
||
className="ghost-button"
|
||
onClick={() => {
|
||
setForm(blankForm);
|
||
setQuestions([]);
|
||
setTask(null);
|
||
}}
|
||
>
|
||
继续创建新任务
|
||
</button>
|
||
</div>
|
||
</section>
|
||
</>
|
||
);
|
||
|
||
return (
|
||
<>
|
||
<div className="create-page-heading">
|
||
<div>
|
||
<h2>创建任务</h2>
|
||
<p>通过顶部切换创建「提问任务」或「回答任务」。</p>
|
||
</div>
|
||
<div className="create-mode-tabs">
|
||
<button className="active">创建提问任务</button>
|
||
<button
|
||
onClick={() =>
|
||
window.dispatchEvent(new CustomEvent("switch-create-answer"))
|
||
}
|
||
>
|
||
创建回答任务
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="ask-stepper">
|
||
<b>1</b>
|
||
<strong>Step 1 生成并勾选提问</strong>
|
||
<i />
|
||
<span>2 Step 2 发布成功</span>
|
||
</div>
|
||
<div className="ask-workspace panel">
|
||
<section className="ask-config">
|
||
<div className="panel-title">
|
||
<Sparkles size={17} />
|
||
生成条件配置 <small>* 商品链接为必填项</small>
|
||
</div>
|
||
<label>
|
||
商品链接 *
|
||
<input
|
||
value={form.product_link}
|
||
onChange={(event) => update("product_link", event.target.value)}
|
||
placeholder="粘贴商品链接,例如:https://item.jd.com/100028981.html"
|
||
/>
|
||
</label>
|
||
<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,text/plain,text/csv,application/json,text/markdown" onChange={(event) => setReferenceFile(event.target.files?.[0] || null)} />
|
||
<small>{referenceFile ? `已选择:${referenceFile.name}` : "支持 TXT、CSV、JSON、Markdown,生成提问时会参考其中的问题角度"}</small>
|
||
</label>
|
||
<h4>生成偏好与数量</h4>
|
||
<div className="two-col">
|
||
<label>
|
||
语言风格
|
||
<select
|
||
value={form.language_style}
|
||
onChange={(event) =>
|
||
update("language_style", event.target.value)
|
||
}
|
||
>
|
||
<option>口语(亲切随性)</option>
|
||
<option>专业</option>
|
||
<option>幽默</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
目标人群
|
||
<select
|
||
value={form.target_group}
|
||
onChange={(event) => update("target_group", event.target.value)}
|
||
>
|
||
<option>女性</option>
|
||
<option>男性</option>
|
||
<option>学生</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<label>
|
||
提问生成条数
|
||
<input
|
||
type="number"
|
||
min="1"
|
||
value={form.question_count}
|
||
onChange={(event) => update("question_count", event.target.value)}
|
||
/>
|
||
</label>
|
||
<div className="two-col">
|
||
<label>
|
||
使用场景
|
||
<select
|
||
value={form.usage_scene}
|
||
onChange={(event) => update("usage_scene", event.target.value)}
|
||
>
|
||
<option>居家</option>
|
||
<option>旅行</option>
|
||
<option>通勤</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
购买渠道
|
||
<select
|
||
value={form.purchase_channel}
|
||
onChange={(event) =>
|
||
update("purchase_channel", event.target.value)
|
||
}
|
||
>
|
||
<option>电商平台</option>
|
||
<option>大型商超</option>
|
||
<option>药房</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
{error && <div className="form-error">{error}</div>}
|
||
<button
|
||
className="answer-v2-primary wide"
|
||
disabled={busy}
|
||
onClick={generate}
|
||
>
|
||
<Sparkles size={16} />
|
||
{busy ? "生成中…" : "生成提问"}
|
||
</button>
|
||
</section>
|
||
<section className="ask-results">
|
||
<div className="ask-toolbar">
|
||
<label>
|
||
<input
|
||
type="checkbox"
|
||
checked={
|
||
questions.length > 0 &&
|
||
questions.every((item) => item.selected)
|
||
}
|
||
onChange={(event) =>
|
||
setQuestions((current) =>
|
||
current.map((item) => ({
|
||
...item,
|
||
selected: event.target.checked,
|
||
})),
|
||
)
|
||
}
|
||
/>{" "}
|
||
全选 ({questions.filter((item) => item.selected).length}/
|
||
{questions.length})
|
||
</label>
|
||
<button className="ghost-button" onClick={addQuestion}>
|
||
<Plus size={14} />
|
||
添加提问
|
||
</button>
|
||
<button
|
||
className="primary-button"
|
||
disabled={
|
||
!questions.some((item) => item.selected) || !task || busy
|
||
}
|
||
onClick={publish}
|
||
>
|
||
发布任务
|
||
</button>
|
||
</div>
|
||
{questions.length === 0 ? (
|
||
<div className="ask-empty">
|
||
点击左侧「生成提问」,AI 生成的提问将展示在这里,可逐条勾选。
|
||
</div>
|
||
) : (
|
||
questions.map((question, index) => (
|
||
<article
|
||
className={question.selected ? "selected" : ""}
|
||
key={question.id}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={question.selected}
|
||
onChange={() => toggle(index)}
|
||
/>
|
||
<div>
|
||
<header>
|
||
<b>Q{index + 1}</b>
|
||
{editing === index ? (
|
||
<input
|
||
autoFocus
|
||
value={question.question}
|
||
onChange={(event) =>
|
||
saveQuestion(index, event.target.value)
|
||
}
|
||
onBlur={() => setEditing(null)}
|
||
/>
|
||
) : (
|
||
<strong>{question.question}</strong>
|
||
)}
|
||
<button
|
||
onClick={() =>
|
||
setEditing(editing === index ? null : index)
|
||
}
|
||
>
|
||
{editing === index ? "完成" : "编辑"}
|
||
</button>
|
||
</header>
|
||
<small>来源:AI生成 · 可编辑后发布</small>
|
||
</div>
|
||
</article>
|
||
))
|
||
)}
|
||
</section>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|