Initial MVP for QA asset backend

This commit is contained in:
2026-07-05 17:44:15 +08:00
commit 95b5e09fd4
71 changed files with 6546 additions and 0 deletions

View File

@@ -0,0 +1,46 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { CallableQAItem } from "@/lib/types";
export default function CallableQAPage() {
const [items, setItems] = useState<CallableQAItem[]>([]);
useEffect(() => {
api.get<CallableQAItem[]>("/standard-qa/callable").then(setItems);
}, []);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-ink"></h1>
</div>
<div className="grid gap-4">
{items.map((item) => (
<article key={item.id} className="rounded-md border border-line bg-white p-5">
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-slate-500">
<span>{item.standard_code}</span>
<span>{item.primary_topic}</span>
<span>{item.course_stage}</span>
</div>
<h2 className="text-base font-semibold text-ink">{item.standard_question}</h2>
<p className="mt-3 whitespace-pre-wrap text-sm leading-6 text-slate-700">{item.standard_answer}</p>
<div className="mt-4 flex flex-wrap gap-2 text-xs">
{item.problem_tags.map((tag) => (
<span key={tag} className="rounded-md border border-line bg-panel px-2 py-1">
{tag}
</span>
))}
</div>
{item.answer_boundary ? <p className="mt-4 text-sm text-amber-800">{item.answer_boundary}</p> : null}
</article>
))}
{items.length === 0 ? (
<div className="rounded-md border border-line bg-white p-8 text-center text-sm text-slate-500"></div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { AlertTriangle, CheckCircle2, Database, FileQuestion, Library, Play, Rows3, Timer } from "lucide-react";
import { api, formatDateTime } from "@/lib/api";
import type { DashboardSummary, TaskRun } from "@/lib/types";
import StatCard from "@/components/StatCard";
import StatusBadge from "@/components/StatusBadge";
export default function DashboardPage() {
const [summary, setSummary] = useState<DashboardSummary | null>(null);
const [loading, setLoading] = useState(true);
const [running, setRunning] = useState(false);
async function load() {
setLoading(true);
const data = await api.get<DashboardSummary>("/dashboard/summary");
setSummary(data);
setLoading(false);
}
async function runTask() {
setRunning(true);
await api.post<TaskRun>("/tasks/run-now", {});
await load();
setRunning(false);
}
useEffect(() => {
load().catch(() => setLoading(false));
}, []);
if (loading || !summary) {
return <div className="text-sm text-slate-500">...</div>;
}
const actions = [
{ href: "/review", label: "去审核" },
{ href: "/high-risk", label: "查看高风险" },
{ href: "/standard-qa", label: "查看标准问答库" },
{ href: "/logs", label: "查看运行日志" }
];
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-ink"></h1>
<p className="mt-1 text-sm text-slate-500">{summary.last_task_time ? formatDateTime(summary.last_task_time) : "-"}</p>
</div>
<button
onClick={runTask}
disabled={running}
className="inline-flex h-10 items-center gap-2 rounded-md bg-jade px-4 text-sm font-medium text-white hover:bg-teal-800 disabled:opacity-60"
>
<Play className="h-4 w-4" aria-hidden />
{running ? "处理中" : "手动触发处理任务"}
</button>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<StatCard title="昨晚处理答疑场次数" value={summary.processed_sessions} icon={<Timer className="h-5 w-5" />} />
<StatCard title="已拆解问答总数" value={summary.pending_review_count + summary.approved_qa_count} icon={<Rows3 className="h-5 w-5" />} tone="indigo" />
<StatCard title="待审核问答数" value={summary.pending_review_count} icon={<FileQuestion className="h-5 w-5" />} tone="amber" />
<StatCard title="高风险问答数" value={summary.high_risk_count} icon={<AlertTriangle className="h-5 w-5" />} tone="rose" />
<StatCard title="已审核问答数" value={summary.approved_qa_count} icon={<CheckCircle2 className="h-5 w-5" />} />
<StatCard title="标准问答数" value={summary.standard_qa_count} icon={<Library className="h-5 w-5" />} tone="indigo" />
<StatCard title="可被智能体调用问答数" value={summary.callable_qa_count} icon={<Database className="h-5 w-5" />} />
<div className="rounded-md border border-line bg-white p-4">
<div className="text-sm text-slate-500"></div>
<div className="mt-3">
{summary.last_task_status ? <StatusBadge status={summary.last_task_status} /> : <span className="text-sm text-slate-400"></span>}
</div>
{summary.last_task_error ? <p className="mt-3 text-sm text-rose-700">{summary.last_task_error}</p> : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
{actions.map((action) => (
<Link
key={action.href}
href={action.href}
className="inline-flex h-10 items-center rounded-md border border-line bg-white px-4 text-sm font-medium text-ink hover:bg-slate-50"
>
{action.label}
</Link>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,26 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
}
body {
margin: 0;
background: #f5f7fa;
color: #1f2933;
}
button,
input,
textarea,
select {
font: inherit;
}
.table-wrap {
width: 100%;
overflow-x: auto;
}

View File

@@ -0,0 +1,67 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { api } from "@/lib/api";
import type { RawQAItem } from "@/lib/types";
import QAReviewCard from "@/components/QAReviewCard";
const riskFilters = [
"全部",
"学员隐私",
"未成年人信息",
"婚姻家庭敏感内容",
"心理 / 医疗边界",
"法律边界",
"课程效果承诺",
"团队表达风险",
"其他"
];
export default function HighRiskPage() {
const [items, setItems] = useState<RawQAItem[]>([]);
const [filter, setFilter] = useState("全部");
async function load() {
const [high, forbidden] = await Promise.all([
api.get<RawQAItem[]>("/raw-qa?risk_level=high"),
api.get<RawQAItem[]>("/raw-qa?risk_level=forbidden")
]);
setItems([...high, ...forbidden]);
}
useEffect(() => {
load();
}, []);
const visible = useMemo(() => {
if (filter === "全部") return items;
if (filter === "其他") return items.filter((item) => item.risk_types.length === 0);
return items.filter((item) => item.risk_types.includes(filter));
}, [filter, items]);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-ink"></h1>
<select
value={filter}
onChange={(event) => setFilter(event.target.value)}
className="h-10 rounded-md border border-line bg-white px-3 text-sm outline-none focus:border-jade"
>
{riskFilters.map((item) => (
<option key={item}>{item}</option>
))}
</select>
</div>
<div className="space-y-4">
{visible.map((item) => (
<QAReviewCard key={item.id} item={item} onChanged={load} />
))}
{visible.length === 0 ? (
<div className="rounded-md border border-line bg-white p-8 text-center text-sm text-slate-500"></div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import type { Metadata } from "next";
import "./globals.css";
import Layout from "@/components/Layout";
export const metadata: Metadata = {
title: "大本营答疑资产后台系统 MVP",
description: "答疑转写清洗、审核与标准问答沉淀后台"
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="zh-CN">
<body>
<Layout>{children}</Layout>
</body>
</html>
);
}

View File

@@ -0,0 +1,89 @@
"use client";
import { useEffect, useState } from "react";
import { api, formatDateTime } from "@/lib/api";
import type { AuditLog, TaskRun } from "@/lib/types";
import StatusBadge from "@/components/StatusBadge";
export default function LogsPage() {
const [tasks, setTasks] = useState<TaskRun[]>([]);
const [audit, setAudit] = useState<AuditLog[]>([]);
useEffect(() => {
Promise.all([api.get<TaskRun[]>("/logs/tasks"), api.get<AuditLog[]>("/logs/audit")]).then(([taskRows, auditRows]) => {
setTasks(taskRows);
setAudit(auditRows);
});
}, []);
return (
<div className="space-y-6">
<h1 className="text-2xl font-semibold text-ink"></h1>
<div className="table-wrap rounded-md border border-line bg-white">
<table className="min-w-[980px] w-full border-collapse text-left text-sm">
<thead className="bg-panel text-xs uppercase text-slate-500">
<tr>
<th className="px-4 py-3"> ID</th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{tasks.map((task) => (
<tr key={task.id} className="border-t border-line">
<td className="px-4 py-3">{task.id}</td>
<td className="px-4 py-3">{formatDateTime(task.started_at)}</td>
<td className="px-4 py-3">{formatDateTime(task.ended_at)}</td>
<td className="px-4 py-3">
<StatusBadge status={task.task_status} />
</td>
<td className="px-4 py-3">{task.sessions_processed}/{task.sessions_scanned}</td>
<td className="px-4 py-3">{task.qa_created}</td>
<td className="px-4 py-3">{task.qa_failed}</td>
<td className="px-4 py-3 text-rose-700">{task.error_message ?? "-"}</td>
<td className="px-4 py-3">{task.retry_count}</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="table-wrap rounded-md border border-line bg-white">
<table className="min-w-[900px] w-full border-collapse text-left text-sm">
<thead className="bg-panel text-xs uppercase text-slate-500">
<tr>
<th className="px-4 py-3"> ID</th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{audit.map((log) => (
<tr key={log.id} className="border-t border-line">
<td className="px-4 py-3">{log.id}</td>
<td className="px-4 py-3">{log.target_type} #{log.target_id}</td>
<td className="px-4 py-3">{log.action}</td>
<td className="px-4 py-3">{log.before_status ?? "-"}</td>
<td className="px-4 py-3">{log.after_status ?? "-"}</td>
<td className="px-4 py-3">{log.comment ?? "-"}</td>
<td className="px-4 py-3">{formatDateTime(log.created_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/dashboard");
}

View File

@@ -0,0 +1,36 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { RawQAItem } from "@/lib/types";
import QAReviewCard from "@/components/QAReviewCard";
export default function ReviewPage() {
const [items, setItems] = useState<RawQAItem[]>([]);
async function load() {
const data = await api.get<RawQAItem[]>("/raw-qa?review_status=pending");
setItems(data);
}
useEffect(() => {
load();
}, []);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-ink"></h1>
</div>
<div className="space-y-4">
{items.map((item) => (
<QAReviewCard key={item.id} item={item} onChanged={load} />
))}
{items.length === 0 ? (
<div className="rounded-md border border-line bg-white p-8 text-center text-sm text-slate-500"></div>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,73 @@
"use client";
import { useEffect, useState } from "react";
import { useParams } from "next/navigation";
import { api } from "@/lib/api";
import type { FeishuSession, RawQAItem } from "@/lib/types";
import RiskBadge from "@/components/RiskBadge";
import StatusBadge from "@/components/StatusBadge";
export default function SessionDetailPage() {
const params = useParams<{ id: string }>();
const [session, setSession] = useState<FeishuSession | null>(null);
const [items, setItems] = useState<RawQAItem[]>([]);
useEffect(() => {
async function load() {
const id = Number(params.id);
const [sessionData, rawData] = await Promise.all([
api.get<FeishuSession>(`/sessions/${id}`),
api.get<RawQAItem[]>(`/raw-qa?session_id=${id}`)
]);
setSession(sessionData);
setItems(rawData);
}
load();
}, [params.id]);
if (!session) return <div className="text-sm text-slate-500">...</div>;
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-2xl font-semibold text-ink">{session.title}</h1>
<p className="mt-1 text-sm text-slate-500">
{session.session_code} / {session.date ?? "-"} / {session.teachers ?? "-"}
</p>
</div>
<StatusBadge status={session.process_status} />
</div>
<div className="rounded-md border border-line bg-white p-4">
<div className="mb-3 text-sm font-semibold text-ink"></div>
<pre className="max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-panel p-4 text-sm leading-6 text-slate-700">
{session.source_text || "暂无原始资料"}
</pre>
</div>
<div className="space-y-3">
{items.map((item) => (
<div key={item.id} className="rounded-md border border-line bg-white p-4">
<div className="mb-3 flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">{item.qa_code}</span>
<RiskBadge level={item.risk_level} />
<StatusBadge status={item.review_status} />
</div>
<div className="grid gap-4 lg:grid-cols-2">
<div>
<div className="text-xs text-slate-500"></div>
<div className="mt-1 text-sm leading-6">{item.raw_question}</div>
</div>
<div>
<div className="text-xs text-slate-500"></div>
<div className="mt-1 text-sm leading-6">{item.raw_answer}</div>
</div>
</div>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,84 @@
"use client";
import { useEffect, useState } from "react";
import { Plus } from "lucide-react";
import { api } from "@/lib/api";
import type { FeishuSession } from "@/lib/types";
import SessionTable from "@/components/SessionTable";
export default function SessionsPage() {
const [sessions, setSessions] = useState<FeishuSession[]>([]);
const [title, setTitle] = useState("大本营周三答疑测试场次");
const [teachers, setTeachers] = useState("院长, 辅导老师");
const [sourceText, setSourceText] = useState("");
async function load() {
const data = await api.get<FeishuSession[]>("/sessions");
setSessions(data);
}
async function createSession() {
await api.post<FeishuSession>("/sessions", {
title,
teachers,
source_text: sourceText || undefined,
source_file_name: sourceText ? "manual_transcript.txt" : undefined
});
setSourceText("");
await load();
}
useEffect(() => {
load();
}, []);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-ink"></h1>
</div>
<div className="rounded-md border border-line bg-white p-4">
<div className="grid gap-3 md:grid-cols-3">
<label className="text-sm">
<span className="mb-1 block text-slate-500"></span>
<input
value={title}
onChange={(event) => setTitle(event.target.value)}
className="h-10 w-full rounded-md border border-line px-3 outline-none focus:border-jade"
/>
</label>
<label className="text-sm">
<span className="mb-1 block text-slate-500"></span>
<input
value={teachers}
onChange={(event) => setTeachers(event.target.value)}
className="h-10 w-full rounded-md border border-line px-3 outline-none focus:border-jade"
/>
</label>
<div className="flex items-end">
<button
onClick={createSession}
className="inline-flex h-10 items-center gap-2 rounded-md bg-jade px-4 text-sm font-medium text-white hover:bg-teal-800"
>
<Plus className="h-4 w-4" aria-hidden />
</button>
</div>
</div>
<label className="mt-3 block text-sm">
<span className="mb-1 block text-slate-500">稿</span>
<textarea
value={sourceText}
onChange={(event) => setSourceText(event.target.value)}
rows={5}
className="w-full rounded-md border border-line px-3 py-2 outline-none focus:border-jade"
/>
</label>
</div>
<SessionTable sessions={sessions} onChanged={load} />
</div>
);
}

View File

@@ -0,0 +1,166 @@
"use client";
import { useEffect, useState } from "react";
import { Save, RefreshCw } from "lucide-react";
import { api } from "@/lib/api";
import type { FeishuSetupGuide, FeishuStatus, SafeSettings } from "@/lib/types";
import StatusBadge from "@/components/StatusBadge";
export default function SettingsPage() {
const [settings, setSettings] = useState<SafeSettings | null>(null);
const [feishuStatus, setFeishuStatus] = useState<FeishuStatus | null>(null);
const [feishuGuide, setFeishuGuide] = useState<FeishuSetupGuide | null>(null);
const [keyName, setKeyName] = useState("");
const [value, setValue] = useState("");
async function load() {
const [settingsData, statusData, guideData] = await Promise.all([
api.get<SafeSettings>("/settings"),
api.get<FeishuStatus>("/feishu/status"),
api.get<FeishuSetupGuide>("/feishu/setup-guide")
]);
setSettings(settingsData);
setFeishuStatus(statusData);
setFeishuGuide(guideData);
}
async function saveSetting() {
if (!keyName) return;
await api.patch<SafeSettings>("/settings", { settings: { [keyName]: value } });
setKeyName("");
setValue("");
await load();
}
useEffect(() => {
load();
}, []);
if (!settings || !feishuStatus || !feishuGuide) {
return <div className="text-sm text-slate-500">...</div>;
}
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-2xl font-semibold text-ink"></h1>
<button
onClick={load}
className="inline-flex h-10 items-center gap-2 rounded-md border border-line bg-white px-4 text-sm font-medium text-ink hover:bg-slate-50"
>
<RefreshCw className="h-4 w-4" aria-hidden />
</button>
</div>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
<div className="rounded-md border border-line bg-white p-4">
<div className="text-sm text-slate-500"></div>
<div className="mt-3">
<StatusBadge status={settings.feishu_configured ? "approved" : "pending"} />
</div>
</div>
<div className="rounded-md border border-line bg-white p-4">
<div className="text-sm text-slate-500"> token</div>
<div className="mt-3">
<StatusBadge status={feishuStatus.token_ok ? "approved" : "pending"} />
</div>
<p className="mt-2 text-sm text-slate-600">{feishuStatus.message}</p>
<p className="mt-1 text-xs text-slate-500">
{feishuStatus.app_token_configured ? "Bitable app_token 模式" : "Wiki 节点 token 模式"}
</p>
</div>
<div className="rounded-md border border-line bg-white p-4">
<div className="text-sm text-slate-500"></div>
<div className="mt-3">
<StatusBadge status={settings.openai_configured ? "approved" : "pending"} />
</div>
</div>
<div className="rounded-md border border-line bg-white p-4">
<div className="text-sm text-slate-500"></div>
<div className="mt-2 text-lg font-semibold">{settings.model_name}</div>
</div>
<div className="rounded-md border border-line bg-white p-4">
<div className="text-sm text-slate-500"></div>
<div className="mt-2 text-lg font-semibold">{settings.schedule_cron}</div>
<div className="mt-1 text-sm text-slate-500">{settings.schedule_timezone}</div>
</div>
<div className="rounded-md border border-line bg-white p-4">
<div className="text-sm text-slate-500">mock </div>
<div className="mt-3">
<StatusBadge status={settings.mock_mode ? "running" : "completed"} />
</div>
</div>
</div>
<div className="rounded-md border border-line bg-white p-4">
<div className="mb-3 text-sm font-semibold text-ink"></div>
<div className="grid gap-4 xl:grid-cols-3">
{feishuGuide.tables.map((table) => (
<div key={table.table_key} className="rounded-md border border-line p-3">
<div className="font-medium text-ink">{table.table_name}</div>
<div className="mt-1 text-xs text-slate-500">{table.env_name}</div>
<div className="mt-3 flex flex-wrap gap-1">
{table.required_fields.map((field) => (
<span key={field} className="rounded-md bg-panel px-2 py-1 text-xs text-slate-700">
{field}
</span>
))}
</div>
</div>
))}
</div>
<div className="mt-4 text-sm text-slate-600">
{feishuGuide.notes.map((note) => (
<p key={note}>{note}</p>
))}
</div>
</div>
<div className="rounded-md border border-line bg-white p-4">
<div className="grid gap-3 md:grid-cols-[1fr_1fr_auto]">
<input
value={keyName}
onChange={(event) => setKeyName(event.target.value)}
placeholder="非敏感配置 key"
className="h-10 rounded-md border border-line px-3 outline-none focus:border-jade"
/>
<input
value={value}
onChange={(event) => setValue(event.target.value)}
placeholder="value"
className="h-10 rounded-md border border-line px-3 outline-none focus:border-jade"
/>
<button
onClick={saveSetting}
className="inline-flex h-10 items-center justify-center gap-2 rounded-md bg-jade px-4 text-sm font-medium text-white hover:bg-teal-800"
>
<Save className="h-4 w-4" aria-hidden />
</button>
</div>
</div>
<div className="table-wrap rounded-md border border-line bg-white">
<table className="w-full min-w-[760px] border-collapse text-left text-sm">
<thead className="bg-panel text-xs uppercase text-slate-500">
<tr>
<th className="px-4 py-3">key</th>
<th className="px-4 py-3">value</th>
<th className="px-4 py-3"></th>
</tr>
</thead>
<tbody>
{settings.system_settings.map((row) => (
<tr key={row.key} className="border-t border-line">
<td className="px-4 py-3 font-medium">{row.key}</td>
<td className="px-4 py-3">{row.value ?? "-"}</td>
<td className="px-4 py-3">{row.description ?? "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,29 @@
"use client";
import { useEffect, useState } from "react";
import { api } from "@/lib/api";
import type { StandardQAItem } from "@/lib/types";
import StandardQATable from "@/components/StandardQATable";
export default function StandardQAPage() {
const [items, setItems] = useState<StandardQAItem[]>([]);
async function load() {
const data = await api.get<StandardQAItem[]>("/standard-qa");
setItems(data);
}
useEffect(() => {
load();
}, []);
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-semibold text-ink"></h1>
</div>
<StandardQATable items={items} onChanged={load} />
</div>
);
}