Enhance H5 QA session interactions

This commit is contained in:
2026-07-05 18:47:59 +08:00
parent 34aeb22760
commit cd73ed3001
3 changed files with 274 additions and 30 deletions

View File

@@ -1,20 +1,36 @@
"use client";
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { BookOpenCheck, Clock3, Home, MessageCircle, SendHorizonal, Settings, ShieldCheck, UserRound } from "lucide-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,
mockCallableQa,
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", {
@@ -35,25 +51,14 @@ function buildAnswerMessage(question: string, qa: CallableQAItem): ChatMessage {
};
}
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>
);
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() {
@@ -61,13 +66,38 @@ export default function H5QAExperience() {
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 [user, setUser] = useState<MockUser | null>(null);
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement | null>(null);
const qaSource = useMemo(() => (callableQa.length > 0 ? callableQa : mockCallableQa), [callableQa]);
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")
@@ -89,21 +119,73 @@ export default function H5QAExperience() {
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, qaSource);
setMessages((current) => [
...current,
const timestamp = Date.now();
const nextMessages: ChatMessage[] = [
...messages,
{
id: `user-${Date.now()}`,
id: `user-${timestamp}`,
role: "user",
content: trimmed,
createdAt: nowText()
},
buildAnswerMessage(trimmed, matchedQa)
]);
{ ...buildAnswerMessage(trimmed, matchedQa), id: `assistant-${timestamp}` }
];
persistMessages(nextMessages);
setInput("");
setActiveTab("qa");
}
@@ -153,6 +235,16 @@ export default function H5QAExperience() {
{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">
@@ -164,7 +256,7 @@ export default function H5QAExperience() {
</div>
<div className="space-y-4">
{messages.map((message) => (
<ChatBubble key={message.id} message={message} />
<ChatBubble key={message.id} message={message} onCopy={copyMessage} onFeedback={setFeedback} />
))}
</div>
<div ref={bottomRef} />
@@ -201,8 +293,85 @@ export default function H5QAExperience() {
</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">{callableQa.length > 0 ? "接口数据" : "mock 演示"}</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>
) : (
<EmptyState tab={activeTab} />
null
)}
<MockAuthPanel