返回 oh-my-ppt
HistoryDialog.tsx
根目录 / src / renderer / src / components / session-detail / modal / HistoryDialog.tsx
1 import { useEffect, useState } from 'react'
2 import dayjs from 'dayjs'
3 import { useT } from '@renderer/i18n'
4 import { ipc } from '@renderer/lib/ipc'
5 import {
6 useGenerateStore,
7 resetSessionDetailEditingStores,
8 useSessionDetailUiStore,
9 useSessionStore,
10 useToastStore
11 } from '@renderer/store'
12 import { HISTORY_VERSION_LIMIT, type HistoryVersion } from '@shared/history.js'
13 import { Button } from '../../ui/Button'
14 import {
15 Dialog,
16 DialogContent,
17 DialogDescription,
18 DialogFooter,
19 DialogHeader,
20 DialogTitle
21 } from '../../ui/Dialog'
22
23 interface HistoryDialogProps {
24 sessionId: string
25 }
26
27 function formatHistoryTime(value: number): string {
28 const timestamp = value > 1e12 ? value : value * 1000
29 const parsed = dayjs(timestamp)
30 if (!parsed.isValid()) return ''
31 return parsed.format('YYYY/MM/DD HH:mm')
32 }
33
34 export function HistoryDialog({ sessionId }: HistoryDialogProps): React.JSX.Element | null {
35 const t = useT()
36 const open = useSessionDetailUiStore((state) => state.historyDialogOpen)
37 const setOpen = useSessionDetailUiStore((state) => state.setHistoryDialogOpen)
38 const isAddingPage = useSessionDetailUiStore((state) => state.isAddingPage)
39 const isRetryingSinglePage = useSessionDetailUiStore((state) => state.isRetryingSinglePage)
40 const isManagingPages = useSessionDetailUiStore((state) => state.isManagingPages)
41 const isGenerating = useGenerateStore((state) => state.isGenerating)
42 const isDeckEditing = useGenerateStore((state) => Boolean(state.deckEditJobs[sessionId]))
43 const currentSession = useSessionStore((state) => state.currentSession)
44 const loadSession = useSessionStore((state) => state.loadSession)
45 const toastError = useToastStore((state) => state.error)
46 const toastSuccess = useToastStore((state) => state.success)
47 const [versions, setVersions] = useState<HistoryVersion[]>([])
48 const [loading, setLoading] = useState(false)
49 const [rollbackId, setRollbackId] = useState<string | null>(null)
50 const [rollbackConfirmVersion, setRollbackConfirmVersion] = useState<HistoryVersion | null>(null)
51 const visibleVersions = versions.slice(0, HISTORY_VERSION_LIMIT)
52
53 const sessionStatus =
54 currentSession && typeof (currentSession as { status?: unknown }).status === 'string'
55 ? String((currentSession as { status?: unknown }).status)
56 : ''
57 const historyDisabled =
58 isGenerating ||
59 isDeckEditing ||
60 isAddingPage ||
61 isRetryingSinglePage ||
62 isManagingPages ||
63 rollbackId !== null ||
64 sessionStatus === 'active'
65
66 const loadHistoryVersions = async (): Promise<void> => {
67 if (!sessionId) return
68 setLoading(true)
69 try {
70 const nextVersions = await ipc.listHistoryVersions({
71 sessionId,
72 limit: HISTORY_VERSION_LIMIT
73 })
74 setVersions(nextVersions)
75 } catch (err) {
76 toastError(err instanceof Error ? err.message : t('sessionDetail.historyLoadFailed'))
77 } finally {
78 setLoading(false)
79 }
80 }
81
82 useEffect(() => {
83 if (!open || !sessionId) return
84 void loadHistoryVersions()
85 }, [open, sessionId])
86
87 const handleRollbackHistory = async (version: HistoryVersion): Promise<void> => {
88 if (!sessionId || version.isCurrent || historyDisabled) return
89 setRollbackId(version.id)
90 setRollbackConfirmVersion(null)
91 try {
92 await ipc.rollbackToHistoryVersion({ sessionId, versionId: version.id })
93 resetSessionDetailEditingStores()
94 await loadSession(sessionId)
95 useGenerateStore.getState().setPages(useSessionStore.getState().currentGeneratedPages)
96 useSessionDetailUiStore.getState().bumpPreviewKey()
97 await loadHistoryVersions()
98 toastSuccess(t('sessionDetail.historyRollbackSuccess'))
99 setOpen(false)
100 } catch (err) {
101 toastError(err instanceof Error ? err.message : t('sessionDetail.historyRollbackFailed'))
102 } finally {
103 setRollbackId(null)
104 }
105 }
106
107 const requestRollbackHistory = (version: HistoryVersion): void => {
108 if (version.isCurrent || historyDisabled || rollbackId) return
109 setRollbackConfirmVersion(version)
110 }
111
112 if (!open) return null
113
114 return (
115 <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
116 <div className="flex max-h-[min(640px,78vh)] w-[560px] flex-col rounded-2xl bg-white shadow-2xl">
117 <div className="flex items-center justify-between border-b border-[#e8e0d0] px-5 py-4">
118 <div>
119 <h3 className="text-base font-semibold text-[#2f3a2a]">
120 {t('sessionDetail.historyTitle')}
121 </h3>
122 <p className="mt-1 text-xs text-[#8a9a7b]">{t('sessionDetail.historyRecent')}</p>
123 </div>
124 <button
125 type="button"
126 onClick={() => setOpen(false)}
127 className="rounded-lg px-2 py-1 text-sm text-[#6f7d62] hover:bg-[#f2efe7]"
128 disabled={Boolean(rollbackId)}
129 >
130 {t('common.cancel')}
131 </button>
132 </div>
133 <div className="min-h-0 overflow-y-auto px-5 py-4">
134 {loading ? (
135 <div className="flex min-h-[220px] items-center justify-center text-sm text-[#8a9a7b]">
136 {t('sessionDetail.historyLoading')}
137 </div>
138 ) : versions.length === 0 ? (
139 <div className="flex min-h-[220px] flex-col items-center justify-center text-center">
140 <p className="text-sm font-medium text-[#3e4a32]">
141 {t('sessionDetail.historyEmptyTitle')}
142 </p>
143 <p className="mt-2 text-xs text-[#8a9a7b]">
144 {t('sessionDetail.historyEmptyDescription')}
145 </p>
146 </div>
147 ) : (
148 <div className="space-y-2">
149 {visibleVersions.map((version) => {
150 const rollbackDisabled =
151 version.isCurrent ||
152 !version.isRestorable ||
153 historyDisabled ||
154 Boolean(rollbackId)
155 return (
156 <div
157 key={version.id}
158 className="rounded-xl border border-[#e8e0d0] bg-[#faf8f2] px-4 py-3"
159 >
160 <div className="flex items-start justify-between gap-3">
161 <div className="min-w-0">
162 <div className="flex flex-wrap items-center gap-2">
163 <p className="truncate text-sm font-semibold text-[#2f3a2a]">
164 {version.title}
165 </p>
166 {version.isCurrent && (
167 <span className="rounded-full bg-[#d4e4c1] px-2 py-0.5 text-[10px] font-medium text-[#3e4a32]">
168 {t('sessionDetail.historyCurrent')}
169 </span>
170 )}
171 </div>
172 <p className="mt-1 text-xs text-[#8a9a7b]">
173 {formatHistoryTime(version.createdAt)}
174 </p>
175 <p className="mt-2 line-clamp-2 text-xs leading-relaxed text-[#5d6b4d]">
176 {version.description}
177 </p>
178 {version.changedPages.length > 0 && (
179 <p className="mt-2 text-[11px] text-[#7b6d55]">
180 {t('sessionDetail.historyChangedPages', {
181 pages: version.changedPages.join('、')
182 })}
183 </p>
184 )}
185 </div>
186 {!version.isCurrent && (
187 <button
188 type="button"
189 disabled={rollbackDisabled}
190 onClick={() => requestRollbackHistory(version)}
191 className="shrink-0 rounded-lg bg-[#3e4a32] px-3 py-1.5 text-xs font-medium text-white shadow-sm hover:bg-[#2f3a2a] disabled:cursor-not-allowed disabled:bg-[#c8c0b3]"
192 >
193 {rollbackId === version.id
194 ? t('sessionDetail.historyRollingBack')
195 : t('sessionDetail.historyRollback')}
196 </button>
197 )}
198 </div>
199 </div>
200 )
201 })}
202 </div>
203 )}
204 </div>
205 </div>
206 <Dialog
207 open={Boolean(rollbackConfirmVersion)}
208 onOpenChange={(nextOpen) => {
209 if (!nextOpen && !rollbackId) setRollbackConfirmVersion(null)
210 }}
211 >
212 <DialogContent showClose={false}>
213 <DialogHeader>
214 <DialogTitle>{t('sessionDetail.historyRollback')}</DialogTitle>
215 <DialogDescription>{t('sessionDetail.historyRollbackConfirm')}</DialogDescription>
216 </DialogHeader>
217 <DialogFooter>
218 <Button
219 type="button"
220 variant="outline"
221 size="sm"
222 onClick={() => setRollbackConfirmVersion(null)}
223 disabled={Boolean(rollbackId)}
224 >
225 {t('common.cancel')}
226 </Button>
227 <Button
228 type="button"
229 size="sm"
230 onClick={() =>
231 rollbackConfirmVersion && void handleRollbackHistory(rollbackConfirmVersion)
232 }
233 disabled={Boolean(rollbackId)}
234 >
235 {rollbackId
236 ? t('sessionDetail.historyRollingBack')
237 : t('sessionDetail.historyRollback')}
238 </Button>
239 </DialogFooter>
240 </DialogContent>
241 </Dialog>
242 </div>
243 )
244 }
245
245 lines Plain Text