Skip to content

Commit 1e65bee

Browse files
committed
feat: implement FavoriteDetailHeader component and integrate it into FavoriteDetailPage for improved UI and functionality
1 parent 601720e commit 1e65bee

5 files changed

Lines changed: 710 additions & 297 deletions

File tree

Lines changed: 362 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,362 @@
1+
import React, { useState, useRef, useEffect } from 'react'
2+
import { Button, Space, App, Tooltip, Input, Dropdown } from 'antd'
3+
import {
4+
StarOutlined,
5+
StarFilled,
6+
EditOutlined,
7+
LinkOutlined,
8+
DeleteOutlined,
9+
MoreOutlined,
10+
ClockCircleOutlined,
11+
ExportOutlined
12+
} from '@ant-design/icons'
13+
import type { MenuProps } from 'antd'
14+
import { FavoriteItem, ChatMessage } from '../../../types/type'
15+
import { RelativeTime } from '../../common/RelativeTime'
16+
import { useFavoritesStore } from '../../../stores/favoritesStore'
17+
import { usePagesStore } from '../../../stores/pagesStore'
18+
import { useTabsStore } from '../../../stores/tabsStore'
19+
import ExportModal, { ExportSettings } from '../chat/ExportModal'
20+
import { formatExactDateTime } from '../../../utils/timeFormatter'
21+
22+
interface FavoriteDetailHeaderProps {
23+
favoriteId: string
24+
sourceExists: boolean
25+
onNavigateToSource: () => void
26+
}
27+
28+
export default function FavoriteDetailHeader({
29+
favoriteId,
30+
sourceExists,
31+
onNavigateToSource
32+
}: FavoriteDetailHeaderProps) {
33+
const [isEditingTitle, setIsEditingTitle] = useState(false)
34+
const [isHoveringTitle, setIsHoveringTitle] = useState(false)
35+
const [isExportModalVisible, setIsExportModalVisible] = useState(false)
36+
const inputRef = useRef<any>(null)
37+
const { message } = App.useApp()
38+
const { getFavoriteById, toggleStarFavorite, updateFavorite, deleteFavorite } = useFavoritesStore()
39+
const { closeTab } = useTabsStore()
40+
41+
// 直接从 store 获取最新的 favorite 数据,确保状态同步
42+
const favorite = getFavoriteById(favoriteId)
43+
const [editingTitle, setEditingTitle] = useState(favorite?.title || '')
44+
45+
// 当进入编辑状态时,自动聚焦输入框
46+
useEffect(() => {
47+
if (isEditingTitle && inputRef.current) {
48+
inputRef.current.focus()
49+
inputRef.current.select()
50+
}
51+
}, [isEditingTitle])
52+
53+
// 更新编辑中的标题
54+
useEffect(() => {
55+
if (favorite) {
56+
setEditingTitle(favorite.title)
57+
}
58+
}, [favorite?.title])
59+
60+
// 处理编辑标题
61+
const handleEditTitle = () => {
62+
setIsEditingTitle(true)
63+
}
64+
65+
// 保存标题
66+
const handleSaveTitle = () => {
67+
if (!favorite) return
68+
const trimmedTitle = editingTitle.trim()
69+
if (trimmedTitle && trimmedTitle !== favorite.title) {
70+
updateFavorite(favorite.id, { title: trimmedTitle })
71+
message.success('标题已更新')
72+
}
73+
setIsEditingTitle(false)
74+
setIsHoveringTitle(false)
75+
}
76+
77+
// 取消编辑
78+
const handleCancelEdit = () => {
79+
if (favorite) {
80+
setEditingTitle(favorite.title)
81+
}
82+
setIsEditingTitle(false)
83+
setIsHoveringTitle(false)
84+
}
85+
86+
// 处理按键事件
87+
const handleKeyDown = (e: React.KeyboardEvent) => {
88+
if (e.key === 'Enter') {
89+
handleSaveTitle()
90+
} else if (e.key === 'Escape') {
91+
handleCancelEdit()
92+
}
93+
}
94+
95+
// 处理星标切换
96+
const handleToggleStar = () => {
97+
if (!favorite) return
98+
toggleStarFavorite(favorite.id)
99+
}
100+
101+
// 处理删除
102+
const handleDelete = () => {
103+
if (!favorite) return
104+
deleteFavorite(favorite.id)
105+
closeTab(`favorite-${favorite.id}`)
106+
message.success('已删除收藏')
107+
}
108+
109+
if (!favorite) {
110+
return null
111+
}
112+
113+
// 获取要导出的消息
114+
const getExportMessages = (): ChatMessage[] => {
115+
if (favorite.type === 'page' && 'pageSnapshot' in favorite.data) {
116+
return favorite.data.pageSnapshot.messages || []
117+
} else if (favorite.type === 'message' && 'message' in favorite.data) {
118+
// 包含主消息和上下文消息
119+
const messages: ChatMessage[] = [favorite.data.message]
120+
if (favorite.data.contextMessages) {
121+
messages.push(...favorite.data.contextMessages)
122+
}
123+
return messages
124+
} else if (favorite.type === 'text-fragment' && 'fullMessage' in favorite.data) {
125+
return [favorite.data.fullMessage]
126+
}
127+
return []
128+
}
129+
130+
// 处理导出
131+
const handleExport = async (selectedMessageIds: string[], exportSettings: ExportSettings) => {
132+
if (selectedMessageIds.length === 0) {
133+
message.warning('请选择要导出的消息')
134+
return
135+
}
136+
137+
try {
138+
const allMessages = getExportMessages()
139+
const selectedMessages = allMessages.filter((msg) => selectedMessageIds.includes(msg.id))
140+
141+
// 根据时间戳排序消息
142+
selectedMessages.sort((a, b) => a.timestamp - b.timestamp)
143+
144+
// 生成导出内容
145+
let exportContent = `# ${favorite.title}\n\n`
146+
if (exportSettings.includeMetadata) {
147+
exportContent += `收藏类型: ${getTypeText()}\n`
148+
exportContent += `导出时间: ${formatExactDateTime(Date.now())}\n`
149+
exportContent += `消息数量: ${selectedMessages.length}\n\n`
150+
}
151+
exportContent += '---\n\n'
152+
153+
selectedMessages.forEach((msg, index) => {
154+
const role = msg.role === 'user' ? '用户' : 'AI助手'
155+
const timestamp = exportSettings.includeTimestamp ? formatExactDateTime(msg.timestamp) : ''
156+
const model = exportSettings.includeModelName && msg.modelId ? ` (${msg.modelId})` : ''
157+
158+
exportContent += `## ${index + 1}. ${role}${model}\n`
159+
if (exportSettings.includeTimestamp) {
160+
exportContent += `时间: ${timestamp}\n\n`
161+
} else {
162+
exportContent += '\n'
163+
}
164+
165+
if (exportSettings.includeReasoningContent && msg.reasoning_content) {
166+
exportContent += `**思考过程:**\n${msg.reasoning_content}\n\n`
167+
}
168+
169+
exportContent += `${msg.content}\n\n`
170+
exportContent += '---\n\n'
171+
})
172+
173+
// 使用 Electron 的文件系统API保存文件
174+
const now = new Date()
175+
const timeString = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}_${String(now.getHours()).padStart(2, '0')}-${String(now.getMinutes()).padStart(2, '0')}`
176+
const fileName = `${favorite.title}_${timeString}.txt`
177+
178+
// 调用主进程保存文件
179+
const result = await window.api.saveFile({
180+
content: exportContent,
181+
defaultPath: fileName,
182+
filters: [
183+
{ name: 'Text Files', extensions: ['txt'] },
184+
{ name: 'Markdown Files', extensions: ['md'] },
185+
{ name: 'All Files', extensions: ['*'] }
186+
]
187+
})
188+
189+
if (result.success) {
190+
message.success(`导出成功: ${result.filePath}`)
191+
setIsExportModalVisible(false)
192+
} else if (result.cancelled) {
193+
// 用户取消了保存
194+
} else {
195+
message.error(`导出失败: ${result.error}`)
196+
}
197+
} catch (error) {
198+
console.error('Export failed:', error)
199+
message.error('导出失败,请重试')
200+
}
201+
}
202+
203+
// 处理打开导出弹窗
204+
const handleOpenExport = () => {
205+
const messages = getExportMessages()
206+
if (messages.length === 0) {
207+
message.warning('没有可导出的消息')
208+
return
209+
}
210+
setIsExportModalVisible(true)
211+
}
212+
213+
// 渲染类型标签文本
214+
const getTypeText = () => {
215+
const typeMap = {
216+
page: '页面',
217+
message: '消息',
218+
'text-fragment': '文本片段'
219+
}
220+
return typeMap[favorite.type] || favorite.type
221+
}
222+
223+
// 构建元数据提示内容
224+
const getMetadataTooltip = () => {
225+
const metadata: string[] = []
226+
227+
// 基本信息
228+
metadata.push(`类型: ${getTypeText()}`)
229+
metadata.push(`收藏时间: ${new Date(favorite.createdAt).toLocaleString('zh-CN')}`)
230+
231+
// 标签
232+
if (favorite.tags && favorite.tags.length > 0) {
233+
metadata.push(`\n--- 标签 ---`)
234+
metadata.push(favorite.tags.join(', '))
235+
}
236+
237+
// 溯源信息
238+
if (favorite.source) {
239+
metadata.push(`\n--- 溯源信息 ---`)
240+
metadata.push(`源类型: ${favorite.source.type === 'page' ? '页面' : '消息'}`)
241+
if (favorite.source.pageTitle) {
242+
metadata.push(`源页面: ${favorite.source.pageTitle}`)
243+
}
244+
metadata.push(`状态: ${sourceExists ? '源存在' : '源已删除'}`)
245+
}
246+
247+
return metadata.join('\n')
248+
}
249+
250+
const moreOptions: MenuProps['items'] = [
251+
{
252+
key: 'navigate',
253+
label: '跳转到源',
254+
icon: <LinkOutlined />,
255+
onClick: onNavigateToSource,
256+
disabled: !sourceExists
257+
},
258+
{
259+
key: 'delete',
260+
label: '删除',
261+
icon: <DeleteOutlined />,
262+
danger: true,
263+
onClick: handleDelete
264+
}
265+
]
266+
267+
return (
268+
<>
269+
<div className="chat-header">
270+
<div className="chat-header-left">
271+
{isEditingTitle ? (
272+
<Input
273+
ref={inputRef}
274+
value={editingTitle}
275+
onChange={(e) => setEditingTitle(e.target.value)}
276+
onBlur={handleSaveTitle}
277+
onKeyDown={handleKeyDown}
278+
style={{
279+
fontSize: '14px',
280+
fontWeight: 'bold',
281+
width: '300px'
282+
}}
283+
placeholder="输入标题"
284+
/>
285+
) : (
286+
<div
287+
style={{
288+
display: 'inline-flex',
289+
alignItems: 'center',
290+
gap: '8px'
291+
}}
292+
onMouseEnter={() => setIsHoveringTitle(true)}
293+
onMouseLeave={() => setIsHoveringTitle(false)}
294+
>
295+
<Tooltip
296+
title={<pre style={{ margin: 0, fontSize: '12px' }}>{getMetadataTooltip()}</pre>}
297+
placement="bottomLeft"
298+
overlayStyle={{ maxWidth: '400px' }}
299+
>
300+
<h3 className="chat-title" style={{ cursor: 'help', margin: 0 }}>
301+
{favorite.title}
302+
</h3>
303+
</Tooltip>
304+
<Tooltip title={favorite.starred ? '取消星标' : '添加星标'}>
305+
<Button
306+
type="text"
307+
size="small"
308+
icon={favorite.starred ? <StarFilled /> : <StarOutlined />}
309+
onClick={handleToggleStar}
310+
style={{
311+
opacity: isHoveringTitle || favorite.starred ? 1 : 0,
312+
transition: 'opacity 0.2s',
313+
visibility: isHoveringTitle || favorite.starred ? 'visible' : 'hidden',
314+
color: favorite.starred ? '#faad14' : undefined
315+
}}
316+
/>
317+
</Tooltip>
318+
<Button
319+
type="text"
320+
size="small"
321+
icon={<EditOutlined />}
322+
onClick={handleEditTitle}
323+
style={{
324+
opacity: isHoveringTitle ? 1 : 0,
325+
transition: 'opacity 0.2s',
326+
visibility: isHoveringTitle ? 'visible' : 'hidden'
327+
}}
328+
/>
329+
</div>
330+
)}
331+
</div>
332+
<div className="chat-header-right">
333+
<Space>
334+
{/* 导出按钮 */}
335+
{getExportMessages().length > 0 && (
336+
<Button icon={<ExportOutlined />} type="text" onClick={handleOpenExport}>
337+
导出
338+
</Button>
339+
)}
340+
341+
{/* 更多选项按钮 */}
342+
<Dropdown menu={{ items: moreOptions }} trigger={['click']}>
343+
<Button type="text" icon={<MoreOutlined />}></Button>
344+
</Dropdown>
345+
</Space>
346+
</div>
347+
</div>
348+
349+
{/* 导出弹窗 */}
350+
<ExportModal
351+
visible={isExportModalVisible}
352+
onClose={() => setIsExportModalVisible(false)}
353+
chatTitle={favorite.title}
354+
messages={getExportMessages()}
355+
currentPathMessages={getExportMessages()}
356+
selectMode="all"
357+
onExport={handleExport}
358+
llmConfigs={[]}
359+
/>
360+
</>
361+
)
362+
}

0 commit comments

Comments
 (0)