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

@@ -0,0 +1,5 @@
import H5QAExperience from "@/components/h5/H5QAExperience";
export default function H5Page() {
return <H5QAExperience />;
}

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>
);
}

View File

@@ -0,0 +1,119 @@
import type { CallableQAItem } from "./types";
export type ChatRole = "assistant" | "user";
export type ChatMessage = {
id: string;
role: ChatRole;
content: string;
source?: string;
boundary?: string;
createdAt: string;
};
export type SuggestedQuestion = {
id: string;
text: string;
tags: string[];
};
export const suggestedQuestions: SuggestedQuestion[] = [
{
id: "parent-child-emotion",
text: "孩子一写作业就发脾气,我怎么接住他的情绪?",
tags: ["亲子关系", "情绪"]
},
{
id: "self-growth",
text: "我知道要行动,但总是拖延,应该先从哪里开始?",
tags: ["个人成长", "行动力"]
},
{
id: "marriage-talk",
text: "伴侣不愿意沟通,我怎么表达才不容易吵起来?",
tags: ["婚姻关系", "沟通"]
}
];
export const initialMessages: ChatMessage[] = [
{
id: "welcome",
role: "assistant",
content:
"你好,我是千问千答助手。你可以把最近遇到的亲子、关系、情绪或行动问题发给我,我会优先基于已审核的答疑资产给你一个可落地的回应。",
boundary: "当前为 H5 体验版,登录与个性化记录暂用 mock 数据。",
createdAt: "09:30"
}
];
export const mockCallableQa: CallableQAItem[] = [
{
id: 9001,
standard_code: "MOCK-001",
standard_question: "孩子一写作业就发脾气,家长应该怎么办?",
standard_answer:
"先不要急着纠正行为,建议先区分两件事:孩子是在逃避任务,还是被情绪淹没了。可以先用一句很短的话接住情绪,比如“我看到你现在很烦,先停两分钟”。等情绪下降后,再把任务拆小,只约定下一步,而不是一次性要求全部完成。",
similar_questions: ["孩子写作业崩溃怎么办", "孩子学习时情绪大怎么办"],
primary_topic: "亲子关系",
problem_tags: ["情绪接纳", "作业冲突", "边界"],
course_stage: "大本营综合",
audience_tags: ["家长"],
answer_boundary: "不替代心理诊断;如果孩子长期极端失控,建议寻求专业支持。",
forbidden_expressions: ["保证有效", "一定改变"],
source_raw_qa_id: 9001,
session_id: 9001
},
{
id: 9002,
standard_code: "MOCK-002",
standard_question: "知道要行动但总是拖延,应该怎么开始?",
standard_answer:
"拖延时不要先给自己贴标签,可以先把目标缩小到“今天能完成的最小动作”。比如不是要求自己完整学习一小时,而是先打开资料、看 5 分钟、记一句话。行动力常常不是靠情绪准备好了才开始,而是靠一个足够小的开始把状态带起来。",
similar_questions: ["总是拖延怎么办", "行动力差怎么调整"],
primary_topic: "个人成长",
problem_tags: ["行动力", "拖延", "自我要求"],
course_stage: "觉知",
audience_tags: ["学员"],
answer_boundary: "只提供学习和自我观察建议,不承诺课程效果。",
forbidden_expressions: ["一定改变", "保证有效"],
source_raw_qa_id: 9002,
session_id: 9002
},
{
id: 9003,
standard_code: "MOCK-003",
standard_question: "伴侣不愿意沟通时,如何降低冲突?",
standard_answer:
"可以先把“让对方马上理解我”调整成“先让对话能继续”。表达时尽量少用评判句,多用事实加感受,比如“刚才你直接走开,我有点失落,也有点不知道怎么继续”。如果对方暂时不想谈,可以约一个明确时间,而不是追着当场解决。",
similar_questions: ["伴侣拒绝沟通怎么办", "夫妻沟通容易吵架怎么办"],
primary_topic: "婚姻关系",
problem_tags: ["沟通", "冲突降温", "关系边界"],
course_stage: "原生",
audience_tags: ["伴侣关系"],
answer_boundary: "不做法律判断;涉及暴力或安全风险时应优先保护自身安全。",
forbidden_expressions: ["法律上必然", "一定改变"],
source_raw_qa_id: 9003,
session_id: 9003
}
];
function scoreQuestion(question: string, qa: CallableQAItem): number {
const haystack = [
qa.standard_question,
qa.standard_answer,
qa.primary_topic,
qa.course_stage,
...qa.problem_tags,
...qa.similar_questions
].join(" ");
return Array.from(new Set(question.trim().split(""))).reduce((score, char) => {
if (!char.trim()) return score;
return haystack.includes(char) ? score + 1 : score;
}, 0);
}
export function findQaAnswer(question: string, qaItems: CallableQAItem[]) {
const candidates = qaItems.length > 0 ? qaItems : mockCallableQa;
const [best] = [...candidates].sort((left, right) => scoreQuestion(question, right) - scoreQuestion(question, left));
return best ?? mockCallableQa[0];
}