style: 样式优化
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
// ============================================================
|
||||
// ChatPanel — 消息展示面板
|
||||
// 职责:渲染对话消息列表、流式光标、元数据、自动滚动、文本输入
|
||||
// 增强:空状态场景卡片、语音输入按钮
|
||||
// ============================================================
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
@@ -12,10 +13,33 @@ interface ChatPanelProps {
|
||||
messages: ChatMessage[];
|
||||
currentReply?: string;
|
||||
connectionStatus: ConnectionStatus;
|
||||
isMicOn?: boolean;
|
||||
isSpeaking?: boolean;
|
||||
onSendText?: (text: string) => void;
|
||||
onToggleMic?: () => void;
|
||||
onSceneCard?: (prompt: string) => void;
|
||||
}
|
||||
|
||||
export function ChatPanel({ messages, currentReply, connectionStatus, onSendText }: ChatPanelProps) {
|
||||
/** 场景卡片数据 */
|
||||
function getSceneCards(t: (key: string) => string) {
|
||||
return [
|
||||
{ icon: "👁", titleKey: "scene.describe", descKey: "scene.describe.desc", prompt: t("scene.describe") },
|
||||
{ icon: "🔤", titleKey: "scene.text", descKey: "scene.text.desc", prompt: t("scene.text") },
|
||||
{ icon: "📦", titleKey: "scene.object", descKey: "scene.object.desc", prompt: t("scene.object") },
|
||||
{ icon: "💡", titleKey: "scene.suggest", descKey: "scene.suggest.desc", prompt: t("scene.suggest") },
|
||||
];
|
||||
}
|
||||
|
||||
export function ChatPanel({
|
||||
messages,
|
||||
currentReply,
|
||||
connectionStatus,
|
||||
isMicOn,
|
||||
isSpeaking,
|
||||
onSendText,
|
||||
onToggleMic,
|
||||
onSceneCard,
|
||||
}: ChatPanelProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isAutoScroll = useRef(true);
|
||||
@@ -23,6 +47,8 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
|
||||
const { t } = useI18n();
|
||||
|
||||
const isConnected = connectionStatus === "connected";
|
||||
const isEmpty = messages.length === 0 && !currentReply;
|
||||
const sceneCards = getSceneCards(t);
|
||||
|
||||
// 用户上滚时暂停自动滚动,滚到底部时恢复
|
||||
useEffect(() => {
|
||||
@@ -53,15 +79,41 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
|
||||
setInputText("");
|
||||
};
|
||||
|
||||
// 点击场景卡片
|
||||
const handleSceneCard = (prompt: string) => {
|
||||
if (onSceneCard) {
|
||||
onSceneCard(prompt);
|
||||
} else if (onSendText) {
|
||||
onSendText(prompt);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="chat-panel">
|
||||
<div className="chat-panel__messages" ref={containerRef}>
|
||||
{/* 空状态提示 */}
|
||||
{messages.length === 0 && !currentReply && (
|
||||
{/* 空状态:场景卡片 */}
|
||||
{isEmpty && (
|
||||
<div className="chat-panel__welcome">
|
||||
<span className="chat-panel__welcome-icon">💬</span>
|
||||
<p>{isConnected ? t("chat.welcome.prompt") : t("chat.empty.prompt")}</p>
|
||||
<span className="chat-panel__welcome-hint">{isConnected ? t("chat.welcome.hint") : t("chat.empty.hint")}</span>
|
||||
|
||||
{/* 场景快捷卡片 */}
|
||||
<div className="scene-cards">
|
||||
{sceneCards.map((card) => (
|
||||
<button
|
||||
key={card.titleKey}
|
||||
className="scene-card"
|
||||
onClick={() => handleSceneCard(card.prompt)}
|
||||
>
|
||||
<span className="scene-card__icon">{card.icon}</span>
|
||||
<div className="scene-card__text">
|
||||
<span className="scene-card__title">{t(card.titleKey)}</span>
|
||||
<span className="scene-card__desc">{t(card.descKey)}</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -106,6 +158,17 @@ export function ChatPanel({ messages, currentReply, connectionStatus, onSendText
|
||||
onChange={(e) => setInputText(e.target.value)}
|
||||
disabled={connectionStatus === "connecting"}
|
||||
/>
|
||||
{/* 语音输入按钮 */}
|
||||
{onToggleMic && (
|
||||
<button
|
||||
type="button"
|
||||
className={`chat-input__voice ${isMicOn ? "chat-input__voice--active" : ""} ${isSpeaking ? "btn--speaking" : ""}`}
|
||||
onClick={onToggleMic}
|
||||
title={t("chat.input.voice")}
|
||||
>
|
||||
🎤
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
className="chat-input__send"
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
// ============================================================
|
||||
// SessionSidebar — 会话历史侧边栏
|
||||
// 职责:展示会话列表、新建/切换/删除/重命名会话
|
||||
// SessionSidebar — 会话历史侧边栏(Overlay 抽屉式)
|
||||
// 职责:展示会话列表、搜索、新建/切换/删除/重命名会话
|
||||
// 设计:overlay 覆盖在主界面之上,不挤压主界面空间
|
||||
// ============================================================
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useI18n } from "../../lib/i18n";
|
||||
import type { SessionSummary } from "../../types";
|
||||
|
||||
interface SessionSidebarProps {
|
||||
sessions: SessionSummary[];
|
||||
activeSessionId: string | null;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
onNewSession: () => void;
|
||||
onSelectSession: (id: string) => void;
|
||||
onDeleteSession: (id: string) => void;
|
||||
@@ -35,11 +36,27 @@ function formatRelativeTime(ts: number): string {
|
||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
||||
}
|
||||
|
||||
/** 判断两个时间戳是否为同一天 */
|
||||
function isSameDay(ts: number, ref: Date): boolean {
|
||||
const d = new Date(ts);
|
||||
return d.getFullYear() === ref.getFullYear() &&
|
||||
d.getMonth() === ref.getMonth() &&
|
||||
d.getDate() === ref.getDate();
|
||||
}
|
||||
|
||||
type TimeGroup = "today" | "yesterday" | "earlier";
|
||||
|
||||
function getTimeGroup(ts: number, today: Date, yesterday: Date): TimeGroup {
|
||||
if (isSameDay(ts, today)) return "today";
|
||||
if (isSameDay(ts, yesterday)) return "yesterday";
|
||||
return "earlier";
|
||||
}
|
||||
|
||||
export function SessionSidebar({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
open,
|
||||
onToggle,
|
||||
onNewSession,
|
||||
onSelectSession,
|
||||
onDeleteSession,
|
||||
@@ -48,6 +65,7 @@ export function SessionSidebar({
|
||||
const { t } = useI18n();
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editTitle, setEditTitle] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
const handleStartRename = (id: string, currentTitle: string) => {
|
||||
setEditingId(id);
|
||||
@@ -67,94 +85,151 @@ export function SessionSidebar({
|
||||
setEditTitle("");
|
||||
};
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="sidebar sidebar--collapsed">
|
||||
<button className="sidebar__toggle" onClick={onToggleCollapse} title={t("sidebar.expand")}>
|
||||
☰
|
||||
</button>
|
||||
<button className="sidebar__new-btn" onClick={onNewSession} title={t("sidebar.new")}>
|
||||
✚
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const handleSelect = (id: string) => {
|
||||
onSelectSession(id);
|
||||
// 选择后自动收起侧边栏
|
||||
onToggle();
|
||||
};
|
||||
|
||||
// 过滤 + 分组
|
||||
const grouped = useMemo(() => {
|
||||
const today = new Date();
|
||||
const yesterday = new Date(today);
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
|
||||
const filtered = searchQuery.trim()
|
||||
? sessions.filter((s) =>
|
||||
s.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
s.preview.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: sessions;
|
||||
|
||||
const groups: Record<TimeGroup, SessionSummary[]> = {
|
||||
today: [],
|
||||
yesterday: [],
|
||||
earlier: [],
|
||||
};
|
||||
|
||||
for (const session of filtered) {
|
||||
const group = getTimeGroup(session.lastActiveAt, today, yesterday);
|
||||
groups[group].push(session);
|
||||
}
|
||||
|
||||
return groups;
|
||||
}, [sessions, searchQuery]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const groupLabels: Record<TimeGroup, string> = {
|
||||
today: t("sidebar.today"),
|
||||
yesterday: t("sidebar.yesterday"),
|
||||
earlier: t("sidebar.earlier"),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="sidebar">
|
||||
<div className="sidebar__header">
|
||||
<button className="sidebar__new-btn sidebar__new-btn--full" onClick={onNewSession}>
|
||||
✚ {t("sidebar.new")}
|
||||
</button>
|
||||
<button className="sidebar__toggle" onClick={onToggleCollapse} title={t("sidebar.collapse")}>
|
||||
◀
|
||||
</button>
|
||||
</div>
|
||||
<>
|
||||
<div className="sidebar-backdrop" onClick={onToggle} />
|
||||
<div className="sidebar">
|
||||
{/* 头部:新建 + 收起 */}
|
||||
<div className="sidebar__header">
|
||||
<button className="sidebar__new-btn" onClick={onNewSession}>
|
||||
✚ {t("sidebar.new")}
|
||||
</button>
|
||||
<button className="sidebar__toggle" onClick={onToggle} title={t("sidebar.collapse")}>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="sidebar__list">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="sidebar__empty">{t("sidebar.empty")}</div>
|
||||
) : (
|
||||
sessions.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`sidebar__item ${session.id === activeSessionId ? "sidebar__item--active" : ""}`}
|
||||
onClick={() => onSelectSession(session.id)}
|
||||
>
|
||||
{editingId === session.id ? (
|
||||
<div className="sidebar__edit" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
className="sidebar__edit-input"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleConfirmRename();
|
||||
if (e.key === "Escape") handleCancelRename();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="sidebar__edit-btn" onClick={handleConfirmRename}>✓</button>
|
||||
<button className="sidebar__edit-btn" onClick={handleCancelRename}>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="sidebar__item-content">
|
||||
<div className="sidebar__item-title">{session.title}</div>
|
||||
<div className="sidebar__item-meta">
|
||||
{session.messageCount > 0 && (
|
||||
<span>{session.messageCount}{t("sidebar.messages")}</span>
|
||||
{/* 搜索栏 */}
|
||||
<div className="sidebar__search">
|
||||
<input
|
||||
className="sidebar__search-input"
|
||||
type="text"
|
||||
placeholder={t("sidebar.search")}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 会话列表(按时间分组) */}
|
||||
<div className="sidebar__list">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="sidebar__empty">{t("sidebar.empty")}</div>
|
||||
) : (
|
||||
(["today", "yesterday", "earlier"] as TimeGroup[]).map((groupKey) => {
|
||||
const items = grouped[groupKey];
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={groupKey}>
|
||||
<div className="sidebar__group">{groupLabels[groupKey]}</div>
|
||||
{items.map((session) => (
|
||||
<div
|
||||
key={session.id}
|
||||
className={`sidebar__item ${session.id === activeSessionId ? "sidebar__item--active" : ""}`}
|
||||
onClick={() => handleSelect(session.id)}
|
||||
>
|
||||
{editingId === session.id ? (
|
||||
<div className="sidebar__edit" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
className="sidebar__edit-input"
|
||||
value={editTitle}
|
||||
onChange={(e) => setEditTitle(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleConfirmRename();
|
||||
if (e.key === "Escape") handleCancelRename();
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button className="sidebar__edit-btn" onClick={handleConfirmRename}>✓</button>
|
||||
<button className="sidebar__edit-btn" onClick={handleCancelRename}>✕</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* 视频标记图标 */}
|
||||
{session.messageCount > 5 && (
|
||||
<span className="sidebar__item-icon" title={t("sidebar.video")}>📹</span>
|
||||
)}
|
||||
<div className="sidebar__item-content">
|
||||
<div className="sidebar__item-title">{session.title}</div>
|
||||
<div className="sidebar__item-meta">
|
||||
{session.messageCount > 0 && (
|
||||
<span>{session.messageCount}{t("sidebar.messages")}</span>
|
||||
)}
|
||||
<span>{formatRelativeTime(session.lastActiveAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sidebar__item-actions">
|
||||
<button
|
||||
className="sidebar__action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartRename(session.id, session.title);
|
||||
}}
|
||||
title={t("sidebar.rename")}
|
||||
>
|
||||
✏
|
||||
</button>
|
||||
<button
|
||||
className="sidebar__action-btn sidebar__action-btn--danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteSession(session.id);
|
||||
}}
|
||||
title={t("sidebar.delete")}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<span>{formatRelativeTime(session.lastActiveAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="sidebar__item-actions">
|
||||
<button
|
||||
className="sidebar__action-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleStartRename(session.id, session.title);
|
||||
}}
|
||||
title={t("sidebar.rename")}
|
||||
>
|
||||
✏
|
||||
</button>
|
||||
<button
|
||||
className="sidebar__action-btn sidebar__action-btn--danger"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteSession(session.id);
|
||||
}}
|
||||
title={t("sidebar.delete")}
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user