Files
QuestionProject/hy_qa_asset_backend/frontend/components/h5/H5QAExperience.tsx
2026-07-05 18:32:28 +08:00

256 lines
10 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, SendHorizonal, Settings, ShieldCheck, UserRound } from "lucide-react";
import { api } from "@/lib/api";
import type { CallableQAItem } from "@/lib/types";
import {
findQaAnswer,
initialMessages,
mockCallableQa,
suggestedQuestions,
type ChatMessage
} from "@/lib/h5MockQa";
import ChatBubble from "./ChatBubble";
import MockAuthPanel, { type MockUser } from "./MockAuthPanel";
type MobileTab = "qa" | "history" | "me";
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 EmptyState({ tab }: { tab: MobileTab }) {
const copy = {
history: "登录后这里会展示你的历史提问记录。当前先用本机 mock 会话演示。",
me: "账号、学习阶段、隐私设置后续会接真实接口;现在先预留微信授权入口。",
qa: ""
};
if (tab === "qa") return null;
return (
<div className="flex flex-1 items-center justify-center px-6 text-center">
<div>
<div className="mx-auto mb-3 flex h-12 w-12 items-center justify-center rounded-md bg-teal-50 text-jade">
{tab === "history" ? <Clock3 className="h-6 w-6" aria-hidden /> : <UserRound className="h-6 w-6" aria-hidden />}
</div>
<p className="text-sm leading-6 text-slate-500">{copy[tab]}</p>
</div>
</div>
);
}
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 [callableQa, setCallableQa] = useState<CallableQAItem[]>([]);
const [loadingQa, setLoadingQa] = useState(true);
const [user, setUser] = useState<MockUser | null>(null);
const bottomRef = useRef<HTMLDivElement | null>(null);
const qaSource = useMemo(() => (callableQa.length > 0 ? callableQa : mockCallableQa), [callableQa]);
useEffect(() => {
api
.get<CallableQAItem[]>("/standard-qa/callable")
.then(setCallableQa)
.catch(() => setCallableQa([]))
.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 ask(question: string) {
const trimmed = question.trim();
if (!trimmed) return;
const matchedQa = findQaAnswer(trimmed, qaSource);
setMessages((current) => [
...current,
{
id: `user-${Date.now()}`,
role: "user",
content: trimmed,
createdAt: nowText()
},
buildAnswerMessage(trimmed, matchedQa)
]);
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 />
{loadingQa ? "正在检查可调用库" : callableQa.length > 0 ? "已连接可调用库" : "演示模式"}
</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>
</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>
<div className="space-y-4">
{messages.map((message) => (
<ChatBubble key={message.id} message={message} />
))}
</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>
</>
) : (
<EmptyState tab={activeTab} />
)}
<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>
);
}