From 34aeb22760c3e3b5a463edfef058cb183c89be6f Mon Sep 17 00:00:00 2001
From: Nelson <1475262689@qq.com>
Date: Sun, 5 Jul 2026 18:32:28 +0800
Subject: [PATCH] Add mobile H5 QA experience
---
hy_qa_asset_backend/frontend/app/h5/page.tsx | 5 +
.../frontend/components/Layout.tsx | 7 +
.../frontend/components/Sidebar.tsx | 2 +
.../frontend/components/h5/ChatBubble.tsx | 41 +++
.../frontend/components/h5/H5QAExperience.tsx | 255 ++++++++++++++++++
.../frontend/components/h5/MockAuthPanel.tsx | 79 ++++++
hy_qa_asset_backend/frontend/lib/h5MockQa.ts | 119 ++++++++
7 files changed, 508 insertions(+)
create mode 100644 hy_qa_asset_backend/frontend/app/h5/page.tsx
create mode 100644 hy_qa_asset_backend/frontend/components/h5/ChatBubble.tsx
create mode 100644 hy_qa_asset_backend/frontend/components/h5/H5QAExperience.tsx
create mode 100644 hy_qa_asset_backend/frontend/components/h5/MockAuthPanel.tsx
create mode 100644 hy_qa_asset_backend/frontend/lib/h5MockQa.ts
diff --git a/hy_qa_asset_backend/frontend/app/h5/page.tsx b/hy_qa_asset_backend/frontend/app/h5/page.tsx
new file mode 100644
index 0000000..042ee2b
--- /dev/null
+++ b/hy_qa_asset_backend/frontend/app/h5/page.tsx
@@ -0,0 +1,5 @@
+import H5QAExperience from "@/components/h5/H5QAExperience";
+
+export default function H5Page() {
+ return ;
+}
diff --git a/hy_qa_asset_backend/frontend/components/Layout.tsx b/hy_qa_asset_backend/frontend/components/Layout.tsx
index 53e9f4a..f81a063 100644
--- a/hy_qa_asset_backend/frontend/components/Layout.tsx
+++ b/hy_qa_asset_backend/frontend/components/Layout.tsx
@@ -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
diff --git a/hy_qa_asset_backend/frontend/components/Sidebar.tsx b/hy_qa_asset_backend/frontend/components/Sidebar.tsx
index 6f231f6..5743240 100644
--- a/hy_qa_asset_backend/frontend/components/Sidebar.tsx
+++ b/hy_qa_asset_backend/frontend/components/Sidebar.tsx
@@ -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 }
];
diff --git a/hy_qa_asset_backend/frontend/components/h5/ChatBubble.tsx b/hy_qa_asset_backend/frontend/components/h5/ChatBubble.tsx
new file mode 100644
index 0000000..f4cdcad
--- /dev/null
+++ b/hy_qa_asset_backend/frontend/components/h5/ChatBubble.tsx
@@ -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 (
+
+ {!isUser ? (
+
+
+
+ ) : null}
+
+
+
{message.content}
+ {message.source ? (
+
+ 来源:{message.source}
+
+ ) : null}
+ {message.boundary ? (
+
{message.boundary}
+ ) : null}
+
+
{message.createdAt}
+
+ {isUser ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/hy_qa_asset_backend/frontend/components/h5/H5QAExperience.tsx b/hy_qa_asset_backend/frontend/components/h5/H5QAExperience.tsx
new file mode 100644
index 0000000..8e5a246
--- /dev/null
+++ b/hy_qa_asset_backend/frontend/components/h5/H5QAExperience.tsx
@@ -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 (
+
+
+
+ {tab === "history" ? : }
+
+
{copy[tab]}
+
+
+ );
+}
+
+export default function H5QAExperience() {
+ const [activeTab, setActiveTab] = useState
("qa");
+ const [authOpen, setAuthOpen] = useState(false);
+ const [input, setInput] = useState("");
+ const [messages, setMessages] = useState(initialMessages);
+ const [callableQa, setCallableQa] = useState([]);
+ const [loadingQa, setLoadingQa] = useState(true);
+ const [user, setUser] = useState(null);
+ const bottomRef = useRef(null);
+
+ const qaSource = useMemo(() => (callableQa.length > 0 ? callableQa : mockCallableQa), [callableQa]);
+
+ useEffect(() => {
+ api
+ .get("/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) {
+ event.preventDefault();
+ ask(input);
+ }
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
千问千答助手
+
+
+ {loadingQa ? "正在检查可调用库" : callableQa.length > 0 ? "已连接可调用库" : "演示模式"}
+
+
+
+
+
+
+
+
+ {activeTab === "qa" ? (
+ <>
+
+
+ 回答会优先遵守审核边界;涉及医疗、法律、极端风险或个人隐私时,请以人工复核为准。
+
+
+ {messages.map((message) => (
+
+ ))}
+
+
+
+
+
+
+ {suggestedQuestions.map((item) => (
+
+ ))}
+
+
+
+ >
+ ) : (
+
+ )}
+
+ setAuthOpen(false)}
+ onMockLogin={mockLogin}
+ onLogout={() => {
+ setUser(null);
+ setAuthOpen(false);
+ }}
+ />
+
+
+
+
+
+
+
+ );
+}
diff --git a/hy_qa_asset_backend/frontend/components/h5/MockAuthPanel.tsx b/hy_qa_asset_backend/frontend/components/h5/MockAuthPanel.tsx
new file mode 100644
index 0000000..bb7da90
--- /dev/null
+++ b/hy_qa_asset_backend/frontend/components/h5/MockAuthPanel.tsx
@@ -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 (
+
+
+
+
+
账号入口
+
先用 mock 登录,后续可替换为微信授权或手机号登录。
+
+
+
+
+ {user ? (
+
+
+
+
+
+
+
{user.name}
+
{user.phone}
+
+
+
+
+ ) : (
+
+
+
+
+ )}
+
+
+ 这里暂不写入后端,也不保存敏感信息;后续接微信小程序时可把这个面板替换成授权弹窗。
+
+
+
+ );
+}
diff --git a/hy_qa_asset_backend/frontend/lib/h5MockQa.ts b/hy_qa_asset_backend/frontend/lib/h5MockQa.ts
new file mode 100644
index 0000000..f8b97c2
--- /dev/null
+++ b/hy_qa_asset_backend/frontend/lib/h5MockQa.ts
@@ -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];
+}