Add mobile H5 QA experience

This commit is contained in:
2026-07-05 18:32:28 +08:00
parent b1b33b2d20
commit 34aeb22760
7 changed files with 508 additions and 0 deletions

View File

@@ -1,9 +1,16 @@
"use client";
import { ReactNode } from "react";
import { usePathname } from "next/navigation";
import Sidebar from "./Sidebar";
export default function Layout({ children }: { children: ReactNode }) {
const pathname = usePathname();
if (pathname.startsWith("/h5")) {
return <div className="min-h-screen bg-[#eef3f1]">{children}</div>;
}
return (
<div className="min-h-screen bg-[#f5f7fa]">
<Sidebar />

View File

@@ -8,6 +8,7 @@ import {
ClipboardCheck,
DatabaseZap,
Gauge,
MessageCircle,
Library,
ListChecks,
Settings,
@@ -21,6 +22,7 @@ const navItems = [
{ href: "/high-risk", label: "高风险", icon: ShieldAlert },
{ href: "/standard-qa", label: "标准库", icon: Library },
{ href: "/callable-qa", label: "可调用库", icon: DatabaseZap },
{ href: "/h5", label: "H5 问答", icon: MessageCircle },
{ href: "/logs", label: "运行日志", icon: ListChecks },
{ href: "/settings", label: "系统设置", icon: Settings }
];

View File

@@ -0,0 +1,41 @@
"use client";
import { Bot, UserRound } from "lucide-react";
import type { ChatMessage } from "@/lib/h5MockQa";
export default function ChatBubble({ message }: { message: ChatMessage }) {
const isUser = message.role === "user";
return (
<div className={`flex gap-2 ${isUser ? "justify-end" : "justify-start"}`}>
{!isUser ? (
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-jade text-white">
<Bot className="h-4 w-4" aria-hidden />
</div>
) : null}
<div className={`max-w-[82%] ${isUser ? "items-end" : "items-start"}`}>
<div
className={`rounded-[14px] px-4 py-3 text-[15px] leading-7 shadow-sm ${
isUser ? "rounded-tr-md bg-jade text-white" : "rounded-tl-md border border-line bg-white text-ink"
}`}
>
<div className="whitespace-pre-wrap">{message.content}</div>
{message.source ? (
<div className={`mt-3 border-t pt-2 text-xs ${isUser ? "border-white/25 text-teal-50" : "border-line text-slate-500"}`}>
{message.source}
</div>
) : null}
{message.boundary ? (
<div className="mt-3 rounded-md bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">{message.boundary}</div>
) : null}
</div>
<div className={`mt-1 text-xs text-slate-400 ${isUser ? "text-right" : "text-left"}`}>{message.createdAt}</div>
</div>
{isUser ? (
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-slate-200 text-slate-600">
<UserRound className="h-4 w-4" aria-hidden />
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,255 @@
"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>
);
}

View File

@@ -0,0 +1,79 @@
"use client";
import { LogIn, ShieldCheck, UserPlus, X } from "lucide-react";
export type MockUser = {
name: string;
phone: string;
mode: "login" | "register";
};
export default function MockAuthPanel({
open,
user,
onClose,
onMockLogin,
onLogout
}: {
open: boolean;
user: MockUser | null;
onClose: () => void;
onMockLogin: (mode: MockUser["mode"]) => void;
onLogout: () => void;
}) {
if (!open) return null;
return (
<div className="absolute inset-0 z-30 flex items-end bg-slate-950/35 px-3 pb-3">
<section className="w-full rounded-t-[18px] rounded-b-md bg-white p-4 shadow-2xl">
<div className="mb-4 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-ink"></h2>
<p className="mt-1 text-sm text-slate-500"> mock </p>
</div>
<button onClick={onClose} className="flex h-9 w-9 items-center justify-center rounded-md hover:bg-slate-100" aria-label="关闭">
<X className="h-5 w-5" aria-hidden />
</button>
</div>
{user ? (
<div className="rounded-md border border-line bg-panel p-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">
<ShieldCheck className="h-5 w-5" aria-hidden />
</div>
<div>
<div className="font-medium text-ink">{user.name}</div>
<div className="text-sm text-slate-500">{user.phone}</div>
</div>
</div>
<button onClick={onLogout} className="mt-3 h-10 w-full rounded-md border border-line text-sm font-medium hover:bg-white">
退 mock
</button>
</div>
) : (
<div className="grid gap-3">
<button
onClick={() => onMockLogin("login")}
className="flex h-11 items-center justify-center gap-2 rounded-md bg-jade text-sm font-medium text-white hover:bg-teal-800"
>
<LogIn className="h-4 w-4" aria-hidden />
mock
</button>
<button
onClick={() => onMockLogin("register")}
className="flex h-11 items-center justify-center gap-2 rounded-md border border-line text-sm font-medium text-ink hover:bg-slate-50"
>
<UserPlus className="h-4 w-4" aria-hidden />
mock
</button>
</div>
)}
<div className="mt-4 rounded-md bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">
</div>
</section>
</div>
);
}