Files
QuestionProject/hy_qa_asset_backend/frontend/components/h5/H5QAExperience.tsx

459 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import {
BookOpenCheck,
Clock3,
Home,
MessageCircle,
Plus,
RotateCcw,
SendHorizonal,
Settings,
ShieldCheck,
Trash2,
UserRound
} from "lucide-react";
import { api } from "@/lib/api";
import type { CallableQAItem } from "@/lib/types";
import {
createEmptySession,
findQaAnswer,
initialMessages,
suggestedQuestions,
summarizeSession,
type ChatSession,
type ChatMessage
} from "@/lib/h5MockQa";
import ChatBubble from "./ChatBubble";
import MockAuthPanel, { type MockUser } from "./MockAuthPanel";
type MobileTab = "qa" | "history" | "me";
const STORAGE_KEY = "hy-qa-h5-sessions";
function nowText() {
return new Intl.DateTimeFormat("zh-CN", {
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date());
}
function buildAnswerMessage(question: string, qa: CallableQAItem): ChatMessage {
return {
id: `assistant-${Date.now()}`,
role: "assistant",
content: qa.standard_answer,
source: `${qa.standard_code} / ${qa.primary_topic} / ${qa.course_stage}`,
boundary: qa.answer_boundary ?? "答复仅基于已审核答疑资产生成,不能替代专业诊断、法律判断或医疗建议。",
createdAt: nowText()
};
}
function buildUnavailableMessage(reason: "loading" | "empty" | "error"): ChatMessage {
const content = {
loading: "我还在连接可调用问答库,请稍等几秒后再试。",
empty: "当前可调用问答库还没有可用内容。请先到后台标准问答库中,把已审核、低/中风险、已脱敏或无需脱敏的问答标记为“可调用”。",
error: "暂时连接不到后端可调用库,请检查后端服务或网络连接后再试。"
}[reason];
return {
id: `assistant-unavailable-${Date.now()}`,
role: "assistant",
content,
boundary: "H5 端不会使用未审核或不可调用的内容生成回答。",
createdAt: nowText()
};
}
function formatSessionTime(value: string) {
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
hour12: false
}).format(new Date(value));
}
export default function H5QAExperience() {
const [activeTab, setActiveTab] = useState<MobileTab>("qa");
const [authOpen, setAuthOpen] = useState(false);
const [input, setInput] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [currentSessionId, setCurrentSessionId] = useState("");
const [callableQa, setCallableQa] = useState<CallableQAItem[]>([]);
const [loadingQa, setLoadingQa] = useState(true);
const [qaLoadError, setQaLoadError] = useState<string | null>(null);
const [user, setUser] = useState<MockUser | null>(null);
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement | null>(null);
const qaStatusText = loadingQa ? "正在连接可调用库" : qaLoadError ? "后端连接异常" : callableQa.length > 0 ? "已连接可调用库" : "可调用库为空";
useEffect(() => {
const fallback = createEmptySession();
try {
const saved = window.localStorage.getItem(STORAGE_KEY);
const parsed = saved ? (JSON.parse(saved) as ChatSession[]) : [];
const validSessions = parsed.length > 0 ? parsed : [fallback];
setSessions(validSessions);
setCurrentSessionId(validSessions[0].id);
setMessages(validSessions[0].messages);
} catch {
setSessions([fallback]);
setCurrentSessionId(fallback.id);
setMessages(fallback.messages);
}
}, []);
useEffect(() => {
if (sessions.length > 0) {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(sessions));
}
}, [sessions]);
useEffect(() => {
api
.get<CallableQAItem[]>("/standard-qa/callable")
.then((items) => {
setCallableQa(items);
setQaLoadError(null);
})
.catch((error) => {
setCallableQa([]);
setQaLoadError(error instanceof Error ? error.message : "可调用库接口请求失败");
})
.finally(() => setLoadingQa(false));
}, []);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
}, [messages, activeTab]);
function mockLogin(mode: MockUser["mode"]) {
setUser({
mode,
name: mode === "login" ? "大本营学员" : "新学员",
phone: "138****2026"
});
setAuthOpen(false);
}
function persistMessages(nextMessages: ChatMessage[]) {
setMessages(nextMessages);
setSessions((current) => {
const sessionId = currentSessionId || createEmptySession().id;
const nextSession = summarizeSession(sessionId, nextMessages);
const withoutCurrent = current.filter((session) => session.id !== sessionId);
return [nextSession, ...withoutCurrent].slice(0, 20);
});
}
function startNewSession() {
const nextSession = createEmptySession();
setCurrentSessionId(nextSession.id);
setMessages(nextSession.messages);
setSessions((current) => [nextSession, ...current].slice(0, 20));
setActiveTab("qa");
}
function openSession(session: ChatSession) {
setCurrentSessionId(session.id);
setMessages(session.messages);
setActiveTab("qa");
}
function clearHistory() {
const nextSession = createEmptySession();
setCurrentSessionId(nextSession.id);
setMessages(nextSession.messages);
setSessions([nextSession]);
setActiveTab("qa");
}
async function copyMessage(message: ChatMessage) {
try {
await navigator.clipboard.writeText(message.content);
} catch {
// Some embedded webviews/headless browsers block clipboard writes.
// Keep the UI feedback so the user knows the copy action was triggered.
}
setCopiedMessageId(message.id);
window.setTimeout(() => setCopiedMessageId(null), 1400);
}
function setFeedback(messageId: string, feedback: "liked" | "disliked") {
const nextMessages = messages.map((message) =>
message.id === messageId ? { ...message, feedback: message.feedback === feedback ? undefined : feedback } : message
);
persistMessages(nextMessages);
}
function ask(question: string) {
const trimmed = question.trim();
if (!trimmed) return;
const matchedQa = findQaAnswer(trimmed, callableQa);
const timestamp = Date.now();
const assistantMessage = loadingQa
? buildUnavailableMessage("loading")
: qaLoadError
? buildUnavailableMessage("error")
: matchedQa
? { ...buildAnswerMessage(trimmed, matchedQa), id: `assistant-${timestamp}` }
: buildUnavailableMessage("empty");
const nextMessages: ChatMessage[] = [
...messages,
{
id: `user-${timestamp}`,
role: "user",
content: trimmed,
createdAt: nowText()
},
assistantMessage
];
persistMessages(nextMessages);
setInput("");
setActiveTab("qa");
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
ask(input);
}
return (
<div className="min-h-screen bg-[#eef3f1] text-ink lg:grid lg:grid-cols-[minmax(0,1fr)_420px_minmax(0,1fr)]">
<aside className="hidden px-8 py-10 lg:block">
<div className="sticky top-10 ml-auto max-w-sm">
<div className="mb-5 flex items-center gap-3">
<BookOpenCheck className="h-7 w-7 text-jade" aria-hidden />
<div>
<div className="text-lg font-semibold"> H5</div>
<div className="text-sm text-slate-500"></div>
</div>
</div>
<div className="space-y-3 text-sm leading-6 text-slate-600">
<p> mock </p>
<p></p>
</div>
</div>
</aside>
<main className="mx-auto flex min-h-screen w-full max-w-[430px] flex-col bg-[#f7faf8] shadow-2xl shadow-slate-900/10 lg:my-6 lg:min-h-[860px] lg:overflow-hidden lg:rounded-[22px]">
<header className="shrink-0 border-b border-line bg-white px-4 pb-3 pt-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-md bg-jade text-white">
<MessageCircle className="h-5 w-5" aria-hidden />
</div>
<div>
<h1 className="text-lg font-semibold leading-6"></h1>
<div className="mt-0.5 flex items-center gap-1 text-xs text-slate-500">
<ShieldCheck className="h-3.5 w-3.5 text-jade" aria-hidden />
{qaStatusText}
</div>
</div>
</div>
<button
onClick={() => setAuthOpen(true)}
className="flex h-10 min-w-10 items-center justify-center rounded-md border border-line bg-white px-3 text-sm font-medium hover:bg-slate-50"
>
{user ? user.name.slice(0, 2) : <UserRound className="h-5 w-5" aria-hidden />}
</button>
</div>
<div className="mt-3 flex items-center justify-between gap-2">
<button
onClick={startNewSession}
className="inline-flex h-8 items-center gap-1 rounded-md border border-line px-2 text-xs font-medium text-slate-600 hover:bg-slate-50"
>
<Plus className="h-3.5 w-3.5" aria-hidden />
</button>
{copiedMessageId ? <span className="text-xs text-jade"></span> : null}
</div>
</header>
<section className="relative flex min-h-0 flex-1 flex-col">
{activeTab === "qa" ? (
<>
<div className="flex-1 overflow-y-auto px-4 py-4">
<div className="mb-4 rounded-md border border-teal-100 bg-teal-50 px-3 py-2 text-xs leading-5 text-teal-800">
</div>
{!loadingQa && (qaLoadError || callableQa.length === 0) ? (
<div className="mb-4 rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">
{qaLoadError ? `可调用库接口异常:${qaLoadError}` : "当前后端可调用库为空H5 不会用本地 mock 内容代替真实答案。"}
</div>
) : null}
<div className="space-y-4">
{messages.map((message) => (
<ChatBubble key={message.id} message={message} onCopy={copyMessage} onFeedback={setFeedback} />
))}
</div>
<div ref={bottomRef} />
</div>
<div className="shrink-0 border-t border-line bg-white px-4 py-3">
<div className="mb-3 flex gap-2 overflow-x-auto pb-1">
{suggestedQuestions.map((item) => (
<button
key={item.id}
onClick={() => ask(item.text)}
className="shrink-0 rounded-md border border-line bg-panel px-3 py-2 text-left text-xs leading-5 text-slate-700 hover:bg-teal-50 hover:text-jade"
>
{item.text}
</button>
))}
</div>
<form onSubmit={submit} className="flex items-end gap-2">
<textarea
value={input}
onChange={(event) => setInput(event.target.value)}
rows={1}
placeholder="输入你的问题..."
className="max-h-28 min-h-11 flex-1 resize-none rounded-md border border-line bg-panel px-3 py-3 text-[15px] leading-5 outline-none focus:border-jade focus:bg-white"
/>
<button
type="submit"
disabled={!input.trim()}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-md bg-jade text-white hover:bg-teal-800 disabled:bg-slate-300"
aria-label="发送"
>
<SendHorizonal className="h-5 w-5" aria-hidden />
</button>
</form>
</div>
</>
) : activeTab === "history" ? (
<div className="flex-1 overflow-y-auto px-4 py-4">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-base font-semibold"></h2>
<p className="mt-1 text-xs text-slate-500"></p>
</div>
<button
onClick={clearHistory}
className="flex h-9 items-center gap-1 rounded-md border border-line px-2 text-xs text-slate-500 hover:bg-slate-50"
>
<Trash2 className="h-3.5 w-3.5" aria-hidden />
</button>
</div>
<div className="space-y-3">
{sessions.map((session) => (
<button
key={session.id}
onClick={() => openSession(session)}
className={`w-full rounded-md border bg-white p-3 text-left shadow-sm ${
session.id === currentSessionId ? "border-teal-200 ring-2 ring-teal-50" : "border-line"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium text-ink">{session.title}</div>
<div className="mt-1 line-clamp-2 text-xs leading-5 text-slate-500">{session.preview}</div>
</div>
<span className="shrink-0 text-xs text-slate-400">{formatSessionTime(session.updatedAt)}</span>
</div>
<div className="mt-3 text-xs text-slate-400">{session.questionCount} </div>
</button>
))}
</div>
</div>
) : activeTab === "me" ? (
<div className="flex-1 overflow-y-auto px-4 py-4">
<section className="rounded-md border border-line bg-white p-4 shadow-sm">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-md bg-jade text-white">
<UserRound className="h-6 w-6" aria-hidden />
</div>
<div>
<div className="font-semibold text-ink">{user?.name ?? "未登录用户"}</div>
<div className="mt-1 text-sm text-slate-500">{user?.phone ?? "登录后同步问答记录和反馈"}</div>
</div>
</div>
<button
onClick={() => setAuthOpen(true)}
className="mt-4 h-10 w-full rounded-md bg-jade text-sm font-medium text-white hover:bg-teal-800"
>
{user ? "管理 mock 账号" : "登录 / 注册"}
</button>
</section>
<section className="mt-4 space-y-3 rounded-md border border-line bg-white p-4 text-sm text-slate-600 shadow-sm">
<div className="flex items-center justify-between">
<span></span>
<span className="font-medium text-ink">{sessions.length} </span>
</div>
<div className="flex items-center justify-between">
<span></span>
<span className="font-medium text-ink">{messages.filter((message) => message.feedback).length} </span>
</div>
<div className="flex items-center justify-between">
<span></span>
<span className="font-medium text-ink">{qaLoadError ? "接口异常" : callableQa.length > 0 ? "接口数据" : "等待入库"}</span>
</div>
</section>
<button
onClick={startNewSession}
className="mt-4 flex h-10 w-full items-center justify-center gap-2 rounded-md border border-line bg-white text-sm font-medium text-ink hover:bg-slate-50"
>
<RotateCcw className="h-4 w-4" aria-hidden />
</button>
</div>
) : (
null
)}
<MockAuthPanel
open={authOpen}
user={user}
onClose={() => setAuthOpen(false)}
onMockLogin={mockLogin}
onLogout={() => {
setUser(null);
setAuthOpen(false);
}}
/>
</section>
<nav className="grid h-[62px] shrink-0 grid-cols-3 border-t border-line bg-white text-xs">
{[
{ key: "qa" as const, label: "问答", icon: Home },
{ key: "history" as const, label: "记录", icon: Clock3 },
{ key: "me" as const, label: "我的", icon: Settings }
].map((item) => {
const Icon = item.icon;
const active = activeTab === item.key;
return (
<button
key={item.key}
onClick={() => setActiveTab(item.key)}
className={`flex flex-col items-center justify-center gap-1 ${active ? "text-jade" : "text-slate-400"}`}
>
<Icon className="h-5 w-5" aria-hidden />
<span>{item.label}</span>
</button>
);
})}
</nav>
</main>
<aside className="hidden px-8 py-10 lg:block">
<div className="sticky top-10 max-w-sm rounded-md border border-line bg-white p-5 shadow-sm">
<h2 className="text-base font-semibold"></h2>
<ul className="mt-4 space-y-3 text-sm leading-6 text-slate-600">
<li> mock </li>
<li></li>
<li></li>
<li>/</li>
</ul>
</div>
</aside>
</div>
);
}