diff --git a/package-lock.json b/package-lock.json index 6a1751f..265fe34 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@supabase/auth-ui-shared": "^0.1.8", "@supabase/supabase-js": "^2.39.3", "axios": "^1.6.7", + "date-fns": "^4.1.0", "i18next": "^23.16.5", "i18next-browser-languagedetector": "^8.0.0", "katex": "^0.16.9", @@ -2155,6 +2156,15 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", diff --git a/package.json b/package.json index b8cf175..dc540cd 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "@supabase/auth-ui-shared": "^0.1.8", "@supabase/supabase-js": "^2.39.3", "axios": "^1.6.7", + "date-fns": "^4.1.0", "i18next": "^23.16.5", "i18next-browser-languagedetector": "^8.0.0", "katex": "^0.16.9", diff --git a/src/App.tsx b/src/App.tsx index 6182351..af24981 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; import { I18nextProvider } from 'react-i18next'; import i18n from './i18n/config'; -import { Github, Settings, BookOpen, Save, Download, Upload, Clipboard } from 'lucide-react'; +import { Github, Settings, BookOpen, Save, Download, Upload, Clipboard, Clock } from 'lucide-react'; import { ToastContainer } from 'react-toastify'; import useStore from './store/index'; import AuthPage from './components/auth/AuthPage'; @@ -16,9 +16,10 @@ import 'react-toastify/dist/ReactToastify.css'; import useAuthStore from './store/auth'; import { useSettingsStore } from './store/settings'; import { useTemplateStore } from './store/templates'; +import HistoryDialog from './components/history/HistoryDialog'; function App() { - const { darkMode, openSettings, uploadFile, downloadFile, saveFile, copyToClipboard, isSettingsOpen } = useStore(); + const { darkMode, openSettings, uploadFile, downloadFile, saveFile, copyToClipboard, isSettingsOpen, isHistoryOpen, openHistory, closeHistory } = useStore(); const { checkAuth, isAuthenticated } = useAuthStore(); const { fetchSettings } = useSettingsStore(); const { fetchTemplates } = useTemplateStore(); @@ -92,6 +93,13 @@ function App() { > + {isSettingsOpen && } + {isHistoryOpen && } } diff --git a/src/components/history/HistoryDialog.tsx b/src/components/history/HistoryDialog.tsx new file mode 100644 index 0000000..b684d2b --- /dev/null +++ b/src/components/history/HistoryDialog.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { X, Clock, Star, Search } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { useHistoryStore } from '../../store/history'; +import useStore from '../../store'; +import HistoryList from './HistoryList'; + +interface HistoryDialogProps { + onClose: () => void; +} + +const HistoryDialog: React.FC = ({ onClose }) => { + const { t } = useTranslation(); + const { darkMode } = useStore(); + const [searchQuery, setSearchQuery] = React.useState(''); + const [showFavorites, setShowFavorites] = React.useState(false); + + return ( +
{ + if (e.target === e.currentTarget) { + onClose(); + } + }} + > +
+ {/* 头部 */} +
+
+ +

+ {t('history.title')} +

+
+ +
+ + {/* 工具栏 */} +
+
+ + setSearchQuery(e.target.value)} + className="w-full rounded-md border-gray-300 pl-10 focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white" + /> +
+ +
+ + {/* 历史记录列表 */} +
+ +
+
+
+ ); +}; + +export default HistoryDialog; \ No newline at end of file diff --git a/src/components/history/HistoryList.tsx b/src/components/history/HistoryList.tsx index 58af405..f8f4229 100644 --- a/src/components/history/HistoryList.tsx +++ b/src/components/history/HistoryList.tsx @@ -1,18 +1,111 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Star, ArrowRight, Trash2, Clock } from 'lucide-react'; import { useHistoryStore } from '../../store/history'; +import useStore from '../../store'; +import { formatDistanceToNow } from 'date-fns'; +import { zhCN, enUS } from 'date-fns/locale'; -export const HistoryList = () => { - const { history, fetchHistory, toggleFavorite } = useHistoryStore(); +interface HistoryListProps { + searchQuery: string; + showFavorites: boolean; +} - useEffect(() => { - fetchHistory(); - }, []); +const HistoryList: React.FC = ({ searchQuery, showFavorites }) => { + const { t, i18n } = useTranslation(); + const { history, toggleFavorite, deleteHistory } = useHistoryStore(); + const { setSourceText, setTranslatedText, sourceLang, targetLang, setSourceLang, setTargetLang } = useStore(); - // 处理收藏切换 - const handleFavoriteToggle = async (id: string, isFavorite: boolean) => { - await toggleFavorite(id, isFavorite); + const dateLocale = i18n.language === 'zh' ? zhCN : enUS; + + const filteredHistory = history + .filter(item => { + const matchesSearch = !searchQuery || + item.source_text.toLowerCase().includes(searchQuery.toLowerCase()) || + item.translated_text.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesFavorite = !showFavorites || item.is_favorite; + return matchesSearch && matchesFavorite; + }) + .sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()); + + const handleRestore = (item: typeof history[0]) => { + setSourceText(item.source_text); + setTranslatedText(item.translated_text); + setSourceLang(item.source_lang); + setTargetLang(item.target_lang); }; return ( - // 渲染历史记录列表 +
+ {filteredHistory.map(item => ( +
+
+
+ + + {formatDistanceToNow(new Date(item.created_at), { + addSuffix: true, + locale: dateLocale + })} + +
+
+ + +
+
+ +
+
+
+ {t(`common.${item.source_lang}`)} +
+
+ {item.source_text} +
+
+
+
+ {t(`common.${item.target_lang}`)} +
+
+ {item.translated_text} +
+
+
+
+ ))} + + {filteredHistory.length === 0 && ( +
+ +

{showFavorites ? t('history.noFavorites') : t('history.noHistory')}

+
+ )} +
); -}; \ No newline at end of file +}; + +export default HistoryList; \ No newline at end of file diff --git a/src/services/supabase/history.ts b/src/services/supabase/history.ts index 01be790..2cc0003 100644 --- a/src/services/supabase/history.ts +++ b/src/services/supabase/history.ts @@ -1,5 +1,6 @@ import { supabase } from '../../config/supabase-auth'; import type { Database } from '../../types/supabase'; +import { withRetry } from '../../utils/supabase-helper'; type History = Database['public']['Tables']['translation_history']['Row']; type HistoryInsert = Database['public']['Tables']['translation_history']['Insert']; @@ -7,40 +8,58 @@ type HistoryUpdate = Database['public']['Tables']['translation_history']['Update export const historyService = { // 获取翻译历史 - async getHistory(userId: string, limit = 20) { - const { data, error } = await supabase - .from('translation_history') - .select('*') - .eq('user_id', userId) - .order('created_at', { ascending: false }) - .limit(limit); - - if (error) throw error; - return data; + async getHistory(userId: string, limit = 50) { + return withRetry(async () => { + const { data, error } = await supabase + .from('translation_history') + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .limit(limit); + + if (error) throw error; + return data; + }); }, // 添加翻译记录 async addHistory(history: HistoryInsert) { - const { data, error } = await supabase - .from('translation_history') - .insert(history) - .select() - .single(); - - if (error) throw error; - return data; + return withRetry(async () => { + const { data, error } = await supabase + .from('translation_history') + .insert(history) + .select() + .single(); + + if (error) throw error; + return data; + }); }, // 更新收藏状态 async toggleFavorite(id: string, isFavorite: boolean) { - const { data, error } = await supabase - .from('translation_history') - .update({ is_favorite: isFavorite }) - .eq('id', id) - .select() - .single(); - - if (error) throw error; - return data; + return withRetry(async () => { + const { data, error } = await supabase + .from('translation_history') + .update({ is_favorite: isFavorite }) + .eq('id', id) + .select() + .single(); + + if (error) throw error; + return data; + }); + }, + + // 删除历史记录 + async deleteHistory(id: string) { + return withRetry(async () => { + const { error } = await supabase + .from('translation_history') + .delete() + .eq('id', id); + + if (error) throw error; + }); } }; \ No newline at end of file diff --git a/src/store/history.ts b/src/store/history.ts index bed3669..8f9f404 100644 --- a/src/store/history.ts +++ b/src/store/history.ts @@ -3,6 +3,7 @@ import { historyService } from '../services/supabase/history'; import { toast } from 'react-toastify'; import type { Database } from '../types/supabase'; import useAuthStore from './auth'; +import { handleSupabaseError } from '../utils/supabase-helper'; type History = Database['public']['Tables']['translation_history']['Row']; type HistoryInsert = Database['public']['Tables']['translation_history']['Insert']; @@ -14,6 +15,7 @@ interface HistoryStore { fetchHistory: (limit?: number) => Promise; addHistory: (history: Omit) => Promise; toggleFavorite: (id: string, isFavorite: boolean) => Promise; + deleteHistory: (id: string) => Promise; } export const useHistoryStore = create((set) => ({ @@ -21,7 +23,7 @@ export const useHistoryStore = create((set) => ({ isLoading: false, error: null, - fetchHistory: async (limit = 20) => { + fetchHistory: async (limit = 50) => { const user = useAuthStore.getState().user; if (!user) return; @@ -31,7 +33,7 @@ export const useHistoryStore = create((set) => ({ set({ history }); } catch (error) { set({ error: error as Error }); - toast.error('加载历史记录失败'); + handleSupabaseError(error as Error, '加载历史记录失败'); } finally { set({ isLoading: false }); } @@ -52,7 +54,7 @@ export const useHistoryStore = create((set) => ({ })); } catch (error) { set({ error: error as Error }); - toast.error('保存历史记录失败'); + handleSupabaseError(error as Error, '保存历史记录失败'); } finally { set({ isLoading: false }); } @@ -67,7 +69,23 @@ export const useHistoryStore = create((set) => ({ })); } catch (error) { set({ error: error as Error }); - toast.error('更新收藏状态失败'); + handleSupabaseError(error as Error, '更新收藏状态失败'); + } finally { + set({ isLoading: false }); + } + }, + + deleteHistory: async (id) => { + set({ isLoading: true, error: null }); + try { + await historyService.deleteHistory(id); + set(state => ({ + history: state.history.filter(h => h.id !== id) + })); + toast.success('已删除历史记录'); + } catch (error) { + set({ error: error as Error }); + handleSupabaseError(error as Error, '删除历史记录失败'); } finally { set({ isLoading: false }); } diff --git a/src/store/index.ts b/src/store/index.ts index d0c7881..5ceb8c2 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -48,6 +48,11 @@ interface TranslationStore { isChangingLanguage: boolean; }; setLanguage: (lang: string) => Promise; + + // 历史记录 + isHistoryOpen: boolean; + openHistory: () => void; + closeHistory: () => void; } const useStore = create((set, get) => ({ @@ -226,6 +231,11 @@ const useStore = create((set, get) => ({ throw error; } }, + + // 历史记录 + isHistoryOpen: false, + openHistory: () => set({ isHistoryOpen: true }), + closeHistory: () => set({ isHistoryOpen: false }), })); export default useStore; \ No newline at end of file