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,9 +1,17 @@
"use client"; "use client";
import { Bot, UserRound } from "lucide-react"; import { Bot, Check, Copy, ThumbsDown, ThumbsUp, UserRound } from "lucide-react";
import type { ChatMessage } from "@/lib/h5MockQa"; import type { ChatMessage } from "@/lib/h5MockQa";
export default function ChatBubble({ message }: { message: ChatMessage }) { export default function ChatBubble({
message,
onCopy,
onFeedback
}: {
message: ChatMessage;
onCopy?: (message: ChatMessage) => void;
onFeedback?: (messageId: string, feedback: "liked" | "disliked") => void;
}) {
const isUser = message.role === "user"; const isUser = message.role === "user";
return ( return (
@@ -29,7 +37,38 @@ export default function ChatBubble({ message }: { message: ChatMessage }) {
<div className="mt-3 rounded-md bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">{message.boundary}</div> <div className="mt-3 rounded-md bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">{message.boundary}</div>
) : null} ) : null}
</div> </div>
<div className={`mt-1 text-xs text-slate-400 ${isUser ? "text-right" : "text-left"}`}>{message.createdAt}</div> <div className={`mt-1 flex items-center gap-2 text-xs text-slate-400 ${isUser ? "justify-end" : "justify-start"}`}>
<span>{message.createdAt}</span>
{!isUser ? (
<span className="flex items-center gap-1">
<button
onClick={() => onCopy?.(message)}
className="flex h-7 w-7 items-center justify-center rounded-md hover:bg-slate-100 hover:text-jade"
aria-label="复制回答"
>
<Copy className="h-3.5 w-3.5" aria-hidden />
</button>
<button
onClick={() => onFeedback?.(message.id, "liked")}
className={`flex h-7 w-7 items-center justify-center rounded-md hover:bg-teal-50 hover:text-jade ${
message.feedback === "liked" ? "bg-teal-50 text-jade" : ""
}`}
aria-label="回答有帮助"
>
{message.feedback === "liked" ? <Check className="h-3.5 w-3.5" aria-hidden /> : <ThumbsUp className="h-3.5 w-3.5" aria-hidden />}
</button>
<button
onClick={() => onFeedback?.(message.id, "disliked")}
className={`flex h-7 w-7 items-center justify-center rounded-md hover:bg-rose-50 hover:text-rose-600 ${
message.feedback === "disliked" ? "bg-rose-50 text-rose-600" : ""
}`}
aria-label="回答没解决"
>
<ThumbsDown className="h-3.5 w-3.5" aria-hidden />
</button>
</span>
) : null}
</div>
</div> </div>
{isUser ? ( {isUser ? (
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-slate-200 text-slate-600"> <div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-slate-200 text-slate-600">

View File

@@ -1,20 +1,36 @@
"use client"; "use client";
import { FormEvent, useEffect, useMemo, useRef, useState } from "react"; 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 { api } from "@/lib/api";
import type { CallableQAItem } from "@/lib/types"; import type { CallableQAItem } from "@/lib/types";
import { import {
createEmptySession,
findQaAnswer, findQaAnswer,
initialMessages, initialMessages,
mockCallableQa, mockCallableQa,
suggestedQuestions, suggestedQuestions,
summarizeSession,
type ChatSession,
type ChatMessage type ChatMessage
} from "@/lib/h5MockQa"; } from "@/lib/h5MockQa";
import ChatBubble from "./ChatBubble"; import ChatBubble from "./ChatBubble";
import MockAuthPanel, { type MockUser } from "./MockAuthPanel"; import MockAuthPanel, { type MockUser } from "./MockAuthPanel";
type MobileTab = "qa" | "history" | "me"; type MobileTab = "qa" | "history" | "me";
const STORAGE_KEY = "hy-qa-h5-sessions";
function nowText() { function nowText() {
return new Intl.DateTimeFormat("zh-CN", { return new Intl.DateTimeFormat("zh-CN", {
@@ -35,25 +51,14 @@ function buildAnswerMessage(question: string, qa: CallableQAItem): ChatMessage {
}; };
} }
function EmptyState({ tab }: { tab: MobileTab }) { function formatSessionTime(value: string) {
const copy = { return new Intl.DateTimeFormat("zh-CN", {
history: "登录后这里会展示你的历史提问记录。当前先用本机 mock 会话演示。", month: "2-digit",
me: "账号、学习阶段、隐私设置后续会接真实接口;现在先预留微信授权入口。", day: "2-digit",
qa: "" hour: "2-digit",
}; minute: "2-digit",
hour12: false
if (tab === "qa") return null; }).format(new Date(value));
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() { export default function H5QAExperience() {
@@ -61,13 +66,38 @@ export default function H5QAExperience() {
const [authOpen, setAuthOpen] = useState(false); const [authOpen, setAuthOpen] = useState(false);
const [input, setInput] = useState(""); const [input, setInput] = useState("");
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages); const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
const [sessions, setSessions] = useState<ChatSession[]>([]);
const [currentSessionId, setCurrentSessionId] = useState("");
const [callableQa, setCallableQa] = useState<CallableQAItem[]>([]); const [callableQa, setCallableQa] = useState<CallableQAItem[]>([]);
const [loadingQa, setLoadingQa] = useState(true); const [loadingQa, setLoadingQa] = useState(true);
const [user, setUser] = useState<MockUser | null>(null); const [user, setUser] = useState<MockUser | null>(null);
const [copiedMessageId, setCopiedMessageId] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement | null>(null); const bottomRef = useRef<HTMLDivElement | null>(null);
const qaSource = useMemo(() => (callableQa.length > 0 ? callableQa : mockCallableQa), [callableQa]); 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(() => { useEffect(() => {
api api
.get<CallableQAItem[]>("/standard-qa/callable") .get<CallableQAItem[]>("/standard-qa/callable")
@@ -89,21 +119,73 @@ export default function H5QAExperience() {
setAuthOpen(false); 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) { function ask(question: string) {
const trimmed = question.trim(); const trimmed = question.trim();
if (!trimmed) return; if (!trimmed) return;
const matchedQa = findQaAnswer(trimmed, qaSource); const matchedQa = findQaAnswer(trimmed, qaSource);
setMessages((current) => [ const timestamp = Date.now();
...current, const nextMessages: ChatMessage[] = [
...messages,
{ {
id: `user-${Date.now()}`, id: `user-${timestamp}`,
role: "user", role: "user",
content: trimmed, content: trimmed,
createdAt: nowText() createdAt: nowText()
}, },
buildAnswerMessage(trimmed, matchedQa) { ...buildAnswerMessage(trimmed, matchedQa), id: `assistant-${timestamp}` }
]); ];
persistMessages(nextMessages);
setInput(""); setInput("");
setActiveTab("qa"); setActiveTab("qa");
} }
@@ -153,6 +235,16 @@ export default function H5QAExperience() {
{user ? user.name.slice(0, 2) : <UserRound className="h-5 w-5" aria-hidden />} {user ? user.name.slice(0, 2) : <UserRound className="h-5 w-5" aria-hidden />}
</button> </button>
</div> </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> </header>
<section className="relative flex min-h-0 flex-1 flex-col"> <section className="relative flex min-h-0 flex-1 flex-col">
@@ -164,7 +256,7 @@ export default function H5QAExperience() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
{messages.map((message) => ( {messages.map((message) => (
<ChatBubble key={message.id} message={message} /> <ChatBubble key={message.id} message={message} onCopy={copyMessage} onFeedback={setFeedback} />
))} ))}
</div> </div>
<div ref={bottomRef} /> <div ref={bottomRef} />
@@ -201,8 +293,85 @@ export default function H5QAExperience() {
</form> </form>
</div> </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 <MockAuthPanel

View File

@@ -8,9 +8,19 @@ export type ChatMessage = {
content: string; content: string;
source?: string; source?: string;
boundary?: string; boundary?: string;
feedback?: "liked" | "disliked";
createdAt: string; createdAt: string;
}; };
export type ChatSession = {
id: string;
title: string;
preview: string;
messages: ChatMessage[];
updatedAt: string;
questionCount: number;
};
export type SuggestedQuestion = { export type SuggestedQuestion = {
id: string; id: string;
text: string; text: string;
@@ -46,6 +56,32 @@ export const initialMessages: ChatMessage[] = [
} }
]; ];
export function createEmptySession(): ChatSession {
return {
id: `session-${Date.now()}`,
title: "新的问答",
preview: "还没有开始提问",
messages: initialMessages,
updatedAt: new Date().toISOString(),
questionCount: 0
};
}
export function summarizeSession(sessionId: string, messages: ChatMessage[]): ChatSession {
const userMessages = messages.filter((message) => message.role === "user");
const lastMessage = [...messages].reverse().find((message) => message.role === "assistant" || message.role === "user");
const firstQuestion = userMessages[0]?.content;
return {
id: sessionId,
title: firstQuestion ? firstQuestion.slice(0, 24) : "新的问答",
preview: lastMessage?.content.slice(0, 42) ?? "还没有开始提问",
messages,
updatedAt: new Date().toISOString(),
questionCount: userMessages.length
};
}
export const mockCallableQa: CallableQAItem[] = [ export const mockCallableQa: CallableQAItem[] = [
{ {
id: 9001, id: 9001,