Enhance H5 QA session interactions
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { Bot, UserRound } from "lucide-react";
|
||||
import { Bot, Check, Copy, ThumbsDown, ThumbsUp, UserRound } from "lucide-react";
|
||||
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";
|
||||
|
||||
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>
|
||||
) : null}
|
||||
</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>
|
||||
{isUser ? (
|
||||
<div className="mt-1 flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-slate-200 text-slate-600">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -8,9 +8,19 @@ export type ChatMessage = {
|
||||
content: string;
|
||||
source?: string;
|
||||
boundary?: string;
|
||||
feedback?: "liked" | "disliked";
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type ChatSession = {
|
||||
id: string;
|
||||
title: string;
|
||||
preview: string;
|
||||
messages: ChatMessage[];
|
||||
updatedAt: string;
|
||||
questionCount: number;
|
||||
};
|
||||
|
||||
export type SuggestedQuestion = {
|
||||
id: 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[] = [
|
||||
{
|
||||
id: 9001,
|
||||
|
||||
Reference in New Issue
Block a user