Initial MVP for QA asset backend
This commit is contained in:
13
hy_qa_asset_backend/frontend/Dockerfile
Normal file
13
hy_qa_asset_backend/frontend/Dockerfile
Normal file
@@ -0,0 +1,13 @@
|
||||
FROM node:20-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "start"]
|
||||
|
||||
46
hy_qa_asset_backend/frontend/app/callable-qa/page.tsx
Normal file
46
hy_qa_asset_backend/frontend/app/callable-qa/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
93
hy_qa_asset_backend/frontend/app/dashboard/page.tsx
Normal file
93
hy_qa_asset_backend/frontend/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
26
hy_qa_asset_backend/frontend/app/globals.css
Normal file
26
hy_qa_asset_backend/frontend/app/globals.css
Normal 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;
|
||||
}
|
||||
|
||||
67
hy_qa_asset_backend/frontend/app/high-risk/page.tsx
Normal file
67
hy_qa_asset_backend/frontend/app/high-risk/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
19
hy_qa_asset_backend/frontend/app/layout.tsx
Normal file
19
hy_qa_asset_backend/frontend/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
89
hy_qa_asset_backend/frontend/app/logs/page.tsx
Normal file
89
hy_qa_asset_backend/frontend/app/logs/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
6
hy_qa_asset_backend/frontend/app/page.tsx
Normal file
6
hy_qa_asset_backend/frontend/app/page.tsx
Normal file
@@ -0,0 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
|
||||
36
hy_qa_asset_backend/frontend/app/review/page.tsx
Normal file
36
hy_qa_asset_backend/frontend/app/review/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
73
hy_qa_asset_backend/frontend/app/sessions/[id]/page.tsx
Normal file
73
hy_qa_asset_backend/frontend/app/sessions/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
84
hy_qa_asset_backend/frontend/app/sessions/page.tsx
Normal file
84
hy_qa_asset_backend/frontend/app/sessions/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
166
hy_qa_asset_backend/frontend/app/settings/page.tsx
Normal file
166
hy_qa_asset_backend/frontend/app/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
hy_qa_asset_backend/frontend/app/standard-qa/page.tsx
Normal file
29
hy_qa_asset_backend/frontend/app/standard-qa/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
13
hy_qa_asset_backend/frontend/components/Layout.tsx
Normal file
13
hy_qa_asset_backend/frontend/components/Layout.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import Sidebar from "./Sidebar";
|
||||
|
||||
export default function Layout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-[#f5f7fa]">
|
||||
<Sidebar />
|
||||
<main className="min-h-screen px-4 py-5 md:ml-64 md:px-8 md:py-7">{children}</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
138
hy_qa_asset_backend/frontend/components/QAReviewCard.tsx
Normal file
138
hy_qa_asset_backend/frontend/components/QAReviewCard.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { Check, EyeOff, FileX, Pencil, ShieldAlert, Sparkles, X } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { api } from "@/lib/api";
|
||||
import type { RawQAItem } from "@/lib/types";
|
||||
import RiskBadge from "./RiskBadge";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
|
||||
function Field({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-medium text-slate-500">{label}</div>
|
||||
<div className="whitespace-pre-wrap text-sm leading-6 text-ink">{children || "-"}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function QAReviewCard({ item, onChanged }: { item: RawQAItem; onChanged: () => void }) {
|
||||
async function action(path: string) {
|
||||
await api.post(`/raw-qa/${item.id}/${path}`, {});
|
||||
onChanged();
|
||||
}
|
||||
|
||||
async function editAndApprove() {
|
||||
const standardQuestion = window.prompt("标准问题", item.suggested_standard_question ?? item.normalized_question ?? "");
|
||||
if (standardQuestion === null) return;
|
||||
const standardAnswer = window.prompt("标准回答", item.suggested_standard_answer ?? item.normalized_answer ?? "");
|
||||
if (standardAnswer === null) return;
|
||||
await api.patch(`/raw-qa/${item.id}`, {
|
||||
suggested_standard_question: standardQuestion,
|
||||
suggested_standard_answer: standardAnswer
|
||||
});
|
||||
await action("approve");
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="rounded-md border border-line bg-white p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-semibold text-ink">{item.qa_code}</span>
|
||||
<RiskBadge level={item.risk_level} />
|
||||
<StatusBadge status={item.review_status} />
|
||||
{item.need_desensitization ? (
|
||||
<span className="rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-xs font-medium text-amber-700">
|
||||
需脱敏
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-xs text-slate-500">场次 #{item.session_id}</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<Field label="原始问题">{item.raw_question}</Field>
|
||||
<Field label="问题整理版">{item.normalized_question}</Field>
|
||||
<Field label="原始回答">{item.raw_answer}</Field>
|
||||
<Field label="回答整理版">{item.normalized_answer}</Field>
|
||||
<Field label="AI 建议标准问题">{item.suggested_standard_question}</Field>
|
||||
<Field label="AI 建议标准回答">{item.suggested_standard_answer}</Field>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 grid gap-3 border-t border-line pt-4 text-sm md:grid-cols-2 xl:grid-cols-4">
|
||||
<div>
|
||||
<span className="text-slate-500">主题:</span>
|
||||
{item.primary_topic}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">阶段:</span>
|
||||
{item.course_stage}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">标签:</span>
|
||||
{item.problem_tags.join("、") || "-"}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500">来源时间:</span>
|
||||
{item.source_timestamp || "-"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 rounded-md border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900">
|
||||
{item.risk_notes || "无风险说明"}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => action("approve")}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md bg-jade px-3 text-sm font-medium text-white hover:bg-teal-800"
|
||||
>
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
通过入库
|
||||
</button>
|
||||
<button
|
||||
onClick={editAndApprove}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-line bg-white px-3 text-sm font-medium text-ink hover:bg-slate-50"
|
||||
>
|
||||
<Pencil className="h-4 w-4" aria-hidden />
|
||||
修改后通过
|
||||
</button>
|
||||
<button
|
||||
onClick={() => action("revise")}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-line bg-white px-3 text-sm font-medium text-ink hover:bg-slate-50"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" aria-hidden />
|
||||
需修改
|
||||
</button>
|
||||
<button
|
||||
onClick={() => action("reject")}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-line bg-white px-3 text-sm font-medium text-ink hover:bg-slate-50"
|
||||
>
|
||||
<FileX className="h-4 w-4" aria-hidden />
|
||||
不入库
|
||||
</button>
|
||||
<button
|
||||
onClick={() => action("forbid")}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-rose-200 bg-white px-3 text-sm font-medium text-rose-700 hover:bg-rose-50"
|
||||
>
|
||||
<X className="h-4 w-4" aria-hidden />
|
||||
禁止使用
|
||||
</button>
|
||||
<button
|
||||
onClick={() => action("mark-high-risk")}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-orange-200 bg-white px-3 text-sm font-medium text-orange-700 hover:bg-orange-50"
|
||||
>
|
||||
<ShieldAlert className="h-4 w-4" aria-hidden />
|
||||
标记高风险
|
||||
</button>
|
||||
<button
|
||||
onClick={() => action("mark-desensitization")}
|
||||
className="inline-flex h-9 items-center gap-2 rounded-md border border-amber-200 bg-white px-3 text-sm font-medium text-amber-700 hover:bg-amber-50"
|
||||
>
|
||||
<EyeOff className="h-4 w-4" aria-hidden />
|
||||
标记需脱敏
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
20
hy_qa_asset_backend/frontend/components/RiskBadge.tsx
Normal file
20
hy_qa_asset_backend/frontend/components/RiskBadge.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
export default function RiskBadge({ level }: { level: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
low: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
medium: "border-amber-200 bg-amber-50 text-amber-700",
|
||||
high: "border-orange-200 bg-orange-50 text-orange-700",
|
||||
forbidden: "border-rose-200 bg-rose-50 text-rose-700"
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
low: "低风险",
|
||||
medium: "中风险",
|
||||
high: "高风险",
|
||||
forbidden: "禁止"
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex rounded-md border px-2 py-1 text-xs font-medium ${styles[level] ?? styles.medium}`}>
|
||||
{labels[level] ?? level}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
91
hy_qa_asset_backend/frontend/components/SessionTable.tsx
Normal file
91
hy_qa_asset_backend/frontend/components/SessionTable.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { FileText, Play, RotateCw } from "lucide-react";
|
||||
import { api, formatDateTime } from "@/lib/api";
|
||||
import type { FeishuSession, TaskRun } from "@/lib/types";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
|
||||
export default function SessionTable({ sessions, onChanged }: { sessions: FeishuSession[]; onChanged: () => void }) {
|
||||
async function run(id: number, reprocess = false) {
|
||||
await api.post<TaskRun>(`/sessions/${id}/${reprocess ? "reprocess" : "process"}`, {});
|
||||
onChanged();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="table-wrap rounded-md border border-line bg-white">
|
||||
<table className="min-w-[1100px] 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">日期</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>
|
||||
<th className="px-4 py-3">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.map((session) => (
|
||||
<tr key={session.id} className="border-t border-line align-top">
|
||||
<td className="px-4 py-3">{session.date ?? "-"}</td>
|
||||
<td className="px-4 py-3 font-medium text-ink">{session.title}</td>
|
||||
<td className="px-4 py-3">{session.teachers ?? "-"}</td>
|
||||
<td className="px-4 py-3">
|
||||
{session.feishu_doc_url ? (
|
||||
<span className="text-jade">{session.feishu_doc_url}</span>
|
||||
) : (
|
||||
<span className="text-slate-400">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusBadge status={session.process_status} />
|
||||
</td>
|
||||
<td className="px-4 py-3">{session.qa_count}</td>
|
||||
<td className="px-4 py-3">{session.pending_review_count}</td>
|
||||
<td className="px-4 py-3">{session.standard_qa_count}</td>
|
||||
<td className="max-w-xs px-4 py-3 text-rose-700">{session.failed_reason ?? "-"}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link
|
||||
href={`/sessions/${session.id}`}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-line px-2 text-xs hover:bg-slate-50"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" aria-hidden />
|
||||
详情
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => run(session.id)}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-line px-2 text-xs hover:bg-slate-50"
|
||||
>
|
||||
<Play className="h-3.5 w-3.5" aria-hidden />
|
||||
处理
|
||||
</button>
|
||||
<button
|
||||
onClick={() => run(session.id, true)}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-line px-2 text-xs hover:bg-slate-50"
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" aria-hidden />
|
||||
重跑
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{sessions.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-4 py-8 text-center text-slate-500" colSpan={10}>
|
||||
暂无场次
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
60
hy_qa_asset_backend/frontend/components/Sidebar.tsx
Normal file
60
hy_qa_asset_backend/frontend/components/Sidebar.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import {
|
||||
BookOpenCheck,
|
||||
CalendarDays,
|
||||
ClipboardCheck,
|
||||
DatabaseZap,
|
||||
Gauge,
|
||||
Library,
|
||||
ListChecks,
|
||||
Settings,
|
||||
ShieldAlert
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ href: "/dashboard", label: "仪表盘", icon: Gauge },
|
||||
{ href: "/sessions", label: "答疑场次", icon: CalendarDays },
|
||||
{ href: "/review", label: "待审核", icon: ClipboardCheck },
|
||||
{ href: "/high-risk", label: "高风险", icon: ShieldAlert },
|
||||
{ href: "/standard-qa", label: "标准库", icon: Library },
|
||||
{ href: "/callable-qa", label: "可调用库", icon: DatabaseZap },
|
||||
{ href: "/logs", label: "运行日志", icon: ListChecks },
|
||||
{ href: "/settings", label: "系统设置", icon: Settings }
|
||||
];
|
||||
|
||||
export default function Sidebar() {
|
||||
const pathname = usePathname();
|
||||
|
||||
return (
|
||||
<aside className="z-20 flex w-full flex-col border-b border-line bg-white md:fixed md:inset-y-0 md:left-0 md:w-64 md:border-b-0 md:border-r">
|
||||
<div className="flex h-16 shrink-0 items-center gap-3 border-b border-line px-5">
|
||||
<BookOpenCheck className="h-6 w-6 text-jade" aria-hidden />
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-ink">大本营答疑资产</div>
|
||||
<div className="text-xs text-slate-500">后台系统 MVP</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex gap-1 overflow-x-auto px-3 py-3 md:block md:flex-1 md:space-y-1 md:overflow-visible md:py-4">
|
||||
{navItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const active = pathname === item.href || pathname.startsWith(`${item.href}/`);
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex h-10 shrink-0 items-center gap-3 rounded-md px-3 text-sm transition ${
|
||||
active ? "bg-teal-50 text-jade" : "text-slate-600 hover:bg-slate-100 hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" aria-hidden />
|
||||
<span>{item.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
126
hy_qa_asset_backend/frontend/components/StandardQATable.tsx
Normal file
126
hy_qa_asset_backend/frontend/components/StandardQATable.tsx
Normal file
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { Ban, CheckCircle2, PauseCircle, Pencil, RotateCw } from "lucide-react";
|
||||
import { api, formatDateTime } from "@/lib/api";
|
||||
import type { StandardQAItem } from "@/lib/types";
|
||||
import RiskBadge from "./RiskBadge";
|
||||
import StatusBadge from "./StatusBadge";
|
||||
|
||||
export default function StandardQATable({ items, onChanged }: { items: StandardQAItem[]; onChanged: () => void }) {
|
||||
async function action(id: number, path: string) {
|
||||
try {
|
||||
await api.post(`/standard-qa/${id}/${path}`, {});
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
alert(error instanceof Error ? error.message : "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function edit(item: StandardQAItem) {
|
||||
const standardQuestion = window.prompt("标准问题", item.standard_question);
|
||||
if (standardQuestion === null) return;
|
||||
const standardAnswer = window.prompt("标准回答", item.standard_answer);
|
||||
if (standardAnswer === null) return;
|
||||
await api.patch(`/standard-qa/${item.id}`, {
|
||||
standard_question: standardQuestion,
|
||||
standard_answer: standardAnswer
|
||||
});
|
||||
onChanged();
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="table-wrap rounded-md border border-line bg-white">
|
||||
<table className="min-w-[1350px] 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">标准问题</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>
|
||||
<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>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="border-t border-line align-top">
|
||||
<td className="max-w-sm px-4 py-3 font-medium text-ink">{item.standard_question}</td>
|
||||
<td className="max-w-md whitespace-pre-wrap px-4 py-3">{item.standard_answer}</td>
|
||||
<td className="px-4 py-3">{item.similar_questions.join("、") || "-"}</td>
|
||||
<td className="px-4 py-3">{item.primary_topic}</td>
|
||||
<td className="px-4 py-3">{item.problem_tags.join("、") || "-"}</td>
|
||||
<td className="px-4 py-3">{item.course_stage}</td>
|
||||
<td className="px-4 py-3">{item.audience_tags.join("、") || "-"}</td>
|
||||
<td className="max-w-xs px-4 py-3">{item.answer_boundary ?? "-"}</td>
|
||||
<td className="px-4 py-3">{item.forbidden_expressions.join("、") || "-"}</td>
|
||||
<td className="px-4 py-3">
|
||||
QA #{item.source_raw_qa_id}
|
||||
<br />
|
||||
场次 #{item.session_id}
|
||||
</td>
|
||||
<td className="space-y-2 px-4 py-3">
|
||||
<StatusBadge status={item.audit_status} />
|
||||
<StatusBadge status={item.call_status} />
|
||||
<RiskBadge level={item.risk_level} />
|
||||
</td>
|
||||
<td className="px-4 py-3">{formatDateTime(item.updated_at)}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => edit(item)}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-line px-2 text-xs hover:bg-slate-50"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" aria-hidden />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
onClick={() => action(item.id, "mark-callable")}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-emerald-200 px-2 text-xs text-emerald-700 hover:bg-emerald-50"
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" aria-hidden />
|
||||
可调用
|
||||
</button>
|
||||
<button
|
||||
onClick={() => action(item.id, "mark-not-callable")}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-line px-2 text-xs hover:bg-slate-50"
|
||||
>
|
||||
<PauseCircle className="h-3.5 w-3.5" aria-hidden />
|
||||
暂不调用
|
||||
</button>
|
||||
<button
|
||||
onClick={() => action(item.id, "need-review")}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-line px-2 text-xs hover:bg-slate-50"
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" aria-hidden />
|
||||
复审
|
||||
</button>
|
||||
<button
|
||||
onClick={() => action(item.id, "disable")}
|
||||
className="inline-flex h-8 items-center gap-1 rounded-md border border-rose-200 px-2 text-xs text-rose-700 hover:bg-rose-50"
|
||||
>
|
||||
<Ban className="h-3.5 w-3.5" aria-hidden />
|
||||
禁用
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-4 py-8 text-center text-slate-500" colSpan={13}>
|
||||
暂无标准问答
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
33
hy_qa_asset_backend/frontend/components/StatCard.tsx
Normal file
33
hy_qa_asset_backend/frontend/components/StatCard.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ReactNode } from "react";
|
||||
|
||||
export default function StatCard({
|
||||
title,
|
||||
value,
|
||||
icon,
|
||||
tone = "teal"
|
||||
}: {
|
||||
title: string;
|
||||
value: string | number;
|
||||
icon: ReactNode;
|
||||
tone?: "teal" | "amber" | "rose" | "indigo";
|
||||
}) {
|
||||
const tones = {
|
||||
teal: "bg-teal-50 text-jade",
|
||||
amber: "bg-amber-50 text-amber-700",
|
||||
rose: "bg-rose-50 text-rose-700",
|
||||
indigo: "bg-indigo-50 text-indigo-700"
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-white p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm text-slate-500">{title}</div>
|
||||
<div className="mt-2 text-2xl font-semibold text-ink">{value}</div>
|
||||
</div>
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-md ${tones[tone]}`}>{icon}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
46
hy_qa_asset_backend/frontend/components/StatusBadge.tsx
Normal file
46
hy_qa_asset_backend/frontend/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
export default function StatusBadge({ status }: { status: string }) {
|
||||
const styles: Record<string, string> = {
|
||||
unprocessed: "border-slate-200 bg-slate-50 text-slate-700",
|
||||
processing: "border-cyan-200 bg-cyan-50 text-cyan-700",
|
||||
parsed: "border-indigo-200 bg-indigo-50 text-indigo-700",
|
||||
pending_review: "border-amber-200 bg-amber-50 text-amber-700",
|
||||
completed: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
failed: "border-rose-200 bg-rose-50 text-rose-700",
|
||||
pending: "border-amber-200 bg-amber-50 text-amber-700",
|
||||
approved: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
rejected: "border-slate-200 bg-slate-50 text-slate-700",
|
||||
forbidden: "border-rose-200 bg-rose-50 text-rose-700",
|
||||
callable: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
not_callable: "border-slate-200 bg-slate-50 text-slate-700",
|
||||
need_review: "border-amber-200 bg-amber-50 text-amber-700",
|
||||
success: "border-emerald-200 bg-emerald-50 text-emerald-700",
|
||||
partial_success: "border-orange-200 bg-orange-50 text-orange-700",
|
||||
running: "border-cyan-200 bg-cyan-50 text-cyan-700"
|
||||
};
|
||||
const labels: Record<string, string> = {
|
||||
unprocessed: "未处理",
|
||||
processing: "处理中",
|
||||
parsed: "已拆解",
|
||||
pending_review: "待审核",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
pending: "待审核",
|
||||
reviewing: "审核中",
|
||||
approved: "已通过",
|
||||
needs_revision: "需修改",
|
||||
rejected: "不入库",
|
||||
forbidden: "禁止",
|
||||
callable: "可调用",
|
||||
not_callable: "暂不调用",
|
||||
need_review: "需复审",
|
||||
success: "成功",
|
||||
partial_success: "部分成功",
|
||||
running: "运行中"
|
||||
};
|
||||
return (
|
||||
<span className={`inline-flex rounded-md border px-2 py-1 text-xs font-medium ${styles[status] ?? styles.pending}`}>
|
||||
{labels[status] ?? status}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
40
hy_qa_asset_backend/frontend/lib/api.ts
Normal file
40
hy_qa_asset_backend/frontend/lib/api.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://127.0.0.1:8000/api";
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
cache: "no-store",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init?.headers ?? {})
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
let message = `${response.status} ${response.statusText}`;
|
||||
try {
|
||||
const body = await response.json();
|
||||
message = body.detail ?? message;
|
||||
} catch {
|
||||
// keep response status as message
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>(path),
|
||||
post: <T>(path: string, body: unknown = {}) =>
|
||||
request<T>(path, { method: "POST", body: JSON.stringify(body) }),
|
||||
patch: <T>(path: string, body: unknown) =>
|
||||
request<T>(path, { method: "PATCH", body: JSON.stringify(body) })
|
||||
};
|
||||
|
||||
export function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) return "-";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "short"
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
163
hy_qa_asset_backend/frontend/lib/types.ts
Normal file
163
hy_qa_asset_backend/frontend/lib/types.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
export type DashboardSummary = {
|
||||
total_sessions: number;
|
||||
processed_sessions: number;
|
||||
pending_review_count: number;
|
||||
high_risk_count: number;
|
||||
approved_qa_count: number;
|
||||
standard_qa_count: number;
|
||||
callable_qa_count: number;
|
||||
last_task_status: string | null;
|
||||
last_task_time: string | null;
|
||||
last_task_error: string | null;
|
||||
};
|
||||
|
||||
export type FeishuSession = {
|
||||
id: number;
|
||||
session_code: string;
|
||||
title: string;
|
||||
date: string | null;
|
||||
teachers: string | null;
|
||||
feishu_doc_url: string | null;
|
||||
feishu_record_id: string | null;
|
||||
source_file_name: string | null;
|
||||
source_text: string | null;
|
||||
process_status: string;
|
||||
qa_count: number;
|
||||
pending_review_count: number;
|
||||
approved_count: number;
|
||||
standard_qa_count: number;
|
||||
failed_reason: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type RawQAItem = {
|
||||
id: number;
|
||||
session_id: number;
|
||||
qa_code: string;
|
||||
raw_question: string;
|
||||
normalized_question: string | null;
|
||||
raw_answer: string;
|
||||
normalized_answer: string | null;
|
||||
suggested_standard_question: string | null;
|
||||
suggested_standard_answer: string | null;
|
||||
answer_person: string | null;
|
||||
primary_topic: string;
|
||||
problem_tags: string[];
|
||||
course_stage: string;
|
||||
audience_tags: string[];
|
||||
emotion_intensity: string | null;
|
||||
risk_level: string;
|
||||
risk_types: string[];
|
||||
risk_notes: string | null;
|
||||
need_desensitization: boolean;
|
||||
desensitization_status: string;
|
||||
review_status: string;
|
||||
reviewer_id: number | null;
|
||||
review_notes: string | null;
|
||||
suggested_to_standard_qa: boolean;
|
||||
source_timestamp: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type StandardQAItem = {
|
||||
id: number;
|
||||
standard_code: string;
|
||||
source_raw_qa_id: number;
|
||||
session_id: number;
|
||||
standard_question: string;
|
||||
standard_answer: string;
|
||||
similar_questions: string[];
|
||||
primary_topic: string;
|
||||
problem_tags: string[];
|
||||
course_stage: string;
|
||||
audience_tags: string[];
|
||||
answer_boundary: string | null;
|
||||
forbidden_expressions: string[];
|
||||
risk_level: string;
|
||||
audit_status: string;
|
||||
call_status: string;
|
||||
last_reviewer_id: number | null;
|
||||
disabled_reason: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type CallableQAItem = Pick<
|
||||
StandardQAItem,
|
||||
| "id"
|
||||
| "standard_code"
|
||||
| "standard_question"
|
||||
| "standard_answer"
|
||||
| "similar_questions"
|
||||
| "primary_topic"
|
||||
| "problem_tags"
|
||||
| "course_stage"
|
||||
| "audience_tags"
|
||||
| "answer_boundary"
|
||||
| "forbidden_expressions"
|
||||
| "source_raw_qa_id"
|
||||
| "session_id"
|
||||
>;
|
||||
|
||||
export type TaskRun = {
|
||||
id: number;
|
||||
task_type: string;
|
||||
task_status: string;
|
||||
started_at: string | null;
|
||||
ended_at: string | null;
|
||||
sessions_scanned: number;
|
||||
sessions_processed: number;
|
||||
qa_created: number;
|
||||
qa_failed: number;
|
||||
error_message: string | null;
|
||||
retry_count: number;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type AuditLog = {
|
||||
id: number;
|
||||
user_id: number | null;
|
||||
target_type: string;
|
||||
target_id: number;
|
||||
action: string;
|
||||
before_status: string | null;
|
||||
after_status: string | null;
|
||||
comment: string | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type SafeSettings = {
|
||||
feishu_configured: boolean;
|
||||
openai_configured: boolean;
|
||||
model_name: string;
|
||||
schedule_cron: string;
|
||||
schedule_timezone: string;
|
||||
mock_mode: boolean;
|
||||
database_url: string;
|
||||
feishu_tables_configured: Record<string, boolean>;
|
||||
system_settings: Array<{ key: string; value: string | null; description: string | null; updated_at: string }>;
|
||||
};
|
||||
|
||||
export type FeishuStatus = {
|
||||
configured: boolean;
|
||||
mock_mode: boolean;
|
||||
app_token_configured: boolean;
|
||||
wiki_node_token_configured: boolean;
|
||||
table_ids: Record<string, boolean>;
|
||||
token_ok: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type FeishuSetupGuide = {
|
||||
required_env: string[];
|
||||
required_permissions: string[];
|
||||
tables: Array<{
|
||||
table_key: string;
|
||||
table_name: string;
|
||||
env_name: string;
|
||||
required_fields: string[];
|
||||
}>;
|
||||
notes: string[];
|
||||
};
|
||||
5
hy_qa_asset_backend/frontend/next-env.d.ts
vendored
Normal file
5
hy_qa_asset_backend/frontend/next-env.d.ts
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
7
hy_qa_asset_backend/frontend/next.config.js
Normal file
7
hy_qa_asset_backend/frontend/next.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true
|
||||
};
|
||||
|
||||
module.exports = nextConfig;
|
||||
|
||||
1645
hy_qa_asset_backend/frontend/package-lock.json
generated
Normal file
1645
hy_qa_asset_backend/frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
hy_qa_asset_backend/frontend/package.json
Normal file
27
hy_qa_asset_backend/frontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "hy-qa-asset-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "14.2.22",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
|
||||
7
hy_qa_asset_backend/frontend/postcss.config.js
Normal file
7
hy_qa_asset_backend/frontend/postcss.config.js
Normal file
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
|
||||
24
hy_qa_asset_backend/frontend/tailwind.config.ts
Normal file
24
hy_qa_asset_backend/frontend/tailwind.config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { Config } from "tailwindcss";
|
||||
|
||||
const config: Config = {
|
||||
content: [
|
||||
"./app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||
"./lib/**/*.{js,ts,jsx,tsx,mdx}"
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
ink: "#1f2933",
|
||||
line: "#d8dee6",
|
||||
panel: "#f8fafc",
|
||||
jade: "#0f766e",
|
||||
coral: "#c2410c"
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
||||
24
hy_qa_asset_backend/frontend/tsconfig.json
Normal file
24
hy_qa_asset_backend/frontend/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }]
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user