返回 oh-my-ppt
ThinkingPageCards.tsx
根目录 / src / renderer / src / components / thinking / ThinkingPageCards.tsx
1 import { useState, type ReactElement } from 'react'
2 import ReactMarkdown from 'react-markdown'
3 import { useT } from '@renderer/i18n'
4 import { useThinkingStore, useToastStore } from '@renderer/store'
5 import type { ThinkingStage } from '@shared/thinking'
6 import {
7 CheckCircle2,
8 FileText,
9 LayoutList,
10 Loader2,
11 Pencil,
12 Save,
13 Sparkles,
14 X
15 } from 'lucide-react'
16 import { Input, Textarea } from '../ui/Input'
17
18 interface PageCard {
19 pageNumber: number
20 title: string
21 role: string
22 objective: string
23 summary: string
24 keyPoints: string[]
25 }
26
27 interface PageCardDraft {
28 title: string
29 objective: string
30 summary: string
31 keyPoints: string
32 }
33
34 interface ThinkingPageCardsProps {
35 thinkingMd: string
36 stage: ThinkingStage
37 onConfirmGenerate: () => void
38 loading: boolean
39 }
40
41 function parsePageCards(thinkingMd: string): PageCard[] {
42 const matches: Array<{ pageNumber: number; title: string; index: number; length: number }> = []
43 const regex = /^##\s*Page\s+(\d+)\s*:\s*(.+)$/gm
44 let match: RegExpExecArray | null
45 while ((match = regex.exec(thinkingMd)) !== null) {
46 matches.push({
47 pageNumber: Number.parseInt(match[1], 10),
48 title: match[2].trim(),
49 index: match.index,
50 length: match[0].length
51 })
52 }
53 return matches.map((item, index) => {
54 const next = matches[index + 1]
55 const contentStart = item.index + item.length
56 const contentEnd = next?.index ?? thinkingMd.length
57 const rawLines = thinkingMd
58 .slice(contentStart, contentEnd)
59 .split('\n')
60 .map((line) => line.trim())
61 .filter(Boolean)
62 const roleLine = rawLines.find((line) => /^-\s*Role\s*:/i.test(line))
63 const objectiveLine = rawLines.find((line) => /^-\s*Objective\s*:/i.test(line))
64 const role = roleLine?.replace(/^-\s*Role\s*:\s*/i, '').trim() || ''
65 const objective = objectiveLine?.replace(/^-\s*Objective\s*:\s*/i, '').trim() || ''
66 const bodyLines = rawLines.filter((line) => line !== roleLine && line !== objectiveLine)
67 const keyPoints = bodyLines
68 .filter((line) => /^-\s+/.test(line))
69 .map((line) => line.replace(/^-\s+/, '').trim())
70 .filter(Boolean)
71 const summary = bodyLines
72 .filter((line) => !/^-\s+/.test(line))
73 .join(' ')
74 .replace(/\s+/g, ' ')
75 .trim()
76 return {
77 pageNumber: item.pageNumber,
78 title: item.title,
79 role,
80 objective,
81 summary,
82 keyPoints
83 }
84 })
85 }
86
87 const STAGE_COLORS: Record<ThinkingStage, { bg: string; text: string; border: string }> = {
88 collect: { bg: 'bg-[#e8e0d0]', text: 'text-[#5d6b4d]', border: 'border-[#c8b89e]' },
89 outline: { bg: 'bg-[#f5f1e8]', text: 'text-[#5d6b4d]', border: 'border-[#e0d8c8]' },
90 draft: { bg: 'bg-[#e8e0d0]', text: 'text-[#5d6b4d]', border: 'border-[#c8b89e]' },
91 refine: { bg: 'bg-[#f5f1e8]', text: 'text-[#5d6b4d]', border: 'border-[#e0d8c8]' },
92 ready: { bg: 'bg-[#8fbc8f]', text: 'text-[#3e4a32]', border: 'border-[#8fbc8f]' }
93 }
94
95 const STAGE_I18N_KEYS: Record<ThinkingStage, string> = {
96 collect: 'thinking.stageCollect',
97 outline: 'thinking.stageOutline',
98 draft: 'thinking.stageDraft',
99 refine: 'thinking.stageRefine',
100 ready: 'thinking.stageReady'
101 }
102
103 export function ThinkingPageCards({
104 thinkingMd,
105 stage,
106 onConfirmGenerate,
107 loading
108 }: ThinkingPageCardsProps): ReactElement {
109 const t = useT()
110 const updatePageOutline = useThinkingStore((state) => state.updatePageOutline)
111 const { success, error: toastError } = useToastStore()
112 const [viewMode, setViewMode] = useState<'outline' | 'document'>('outline')
113 const [editingPageNumber, setEditingPageNumber] = useState<number | null>(null)
114 const [savingPageNumber, setSavingPageNumber] = useState<number | null>(null)
115 const [draft, setDraft] = useState<PageCardDraft | null>(null)
116 const cards = parsePageCards(thinkingMd)
117 const colors = STAGE_COLORS[stage]
118 const canGenerate = cards.length > 0 && stage !== 'collect'
119 const hasDocument = thinkingMd.trim().length > 0
120 const busy = loading || savingPageNumber !== null
121
122 const startEditing = (card: PageCard): void => {
123 if (busy) return
124 setEditingPageNumber(card.pageNumber)
125 setDraft({
126 title: card.title,
127 objective: card.objective,
128 summary: card.summary,
129 keyPoints: card.keyPoints.join('\n')
130 })
131 }
132
133 const cancelEditing = (): void => {
134 if (savingPageNumber !== null) return
135 setEditingPageNumber(null)
136 setDraft(null)
137 }
138
139 const savePage = async (card: PageCard): Promise<void> => {
140 if (!draft || savingPageNumber !== null) return
141 const keyPoints = draft.keyPoints
142 .split('\n')
143 .map((point) => point.trim().replace(/^[-*]\s+/, ''))
144 .filter(Boolean)
145 if (
146 !draft.title.trim() ||
147 !draft.objective.trim() ||
148 !draft.summary.trim() ||
149 keyPoints.length === 0
150 ) {
151 toastError(t('thinking.outlineRequired'))
152 return
153 }
154
155 setSavingPageNumber(card.pageNumber)
156 try {
157 await updatePageOutline({
158 pageNumber: card.pageNumber,
159 title: draft.title,
160 role: card.role,
161 objective: draft.objective,
162 summary: draft.summary,
163 keyPoints
164 })
165 setEditingPageNumber(null)
166 setDraft(null)
167 success(t('thinking.outlineSaved'))
168 } catch (error) {
169 toastError(t('thinking.outlineSaveFailed'), {
170 description: error instanceof Error ? error.message : t('common.retryLater')
171 })
172 } finally {
173 setSavingPageNumber(null)
174 }
175 }
176
177 return (
178 <div className="flex h-full min-h-0 flex-col">
179 <div className="shrink-0 border-b border-[#c8d6ba] px-5 py-4">
180 <div className="flex items-center justify-between gap-3">
181 <div>
182 <h3 className="organic-serif text-[22px] font-semibold leading-none text-[#3e4a32]">
183 {t('thinking.pageCardsTitle')}
184 </h3>
185 <p className="mt-1 text-[11px] text-[#5d6b4d]">
186 {cards.length > 0
187 ? t('thinking.pageCountLabel', { count: cards.length })
188 : t('thinking.noPagesYet')}
189 </p>
190 </div>
191 <span
192 className={`rounded-full border px-3 py-1 text-[10px] font-semibold ${colors.bg} ${colors.text} ${colors.border}`}
193 >
194 {t(STAGE_I18N_KEYS[stage] as Parameters<typeof t>[0])}
195 </span>
196 </div>
197 <div className="mt-4 grid grid-cols-2 gap-1 rounded-full border border-[#c8d6ba] bg-[#e8e0d0]/70 p-1">
198 <button
199 type="button"
200 onClick={() => setViewMode('outline')}
201 className={`flex h-8 items-center justify-center gap-1.5 rounded-full text-[11px] font-semibold transition-colors ${
202 viewMode === 'outline'
203 ? 'bg-[#fffdf8] text-[#3e4a32] shadow-sm'
204 : 'text-[#5d6b4d] hover:bg-[#fffdf8]/60'
205 }`}
206 >
207 <LayoutList className="h-3.5 w-3.5" />
208 {t('thinking.outlineView')}
209 </button>
210 <button
211 type="button"
212 onClick={() => setViewMode('document')}
213 className={`flex h-8 items-center justify-center gap-1.5 rounded-full text-[11px] font-semibold transition-colors ${
214 viewMode === 'document'
215 ? 'bg-[#fffdf8] text-[#3e4a32] shadow-sm'
216 : 'text-[#5d6b4d] hover:bg-[#fffdf8]/60'
217 }`}
218 >
219 <FileText className="h-3.5 w-3.5" />
220 thinking.md
221 </button>
222 </div>
223 </div>
224
225 <div className="min-h-0 flex-1 overflow-y-auto px-3 py-4">
226 {viewMode === 'document' ? (
227 hasDocument ? (
228 <div className="rounded-[2rem] border border-[#e0d8c8] bg-[#fffdf8] px-4 py-4 shadow-sm">
229 <div className="mb-3 flex items-center gap-2 border-b border-[#e8e0d0] pb-2 text-[11px] font-semibold text-[#5d6b4d]">
230 <FileText className="h-3.5 w-3.5" />
231 <span>thinking.md</span>
232 </div>
233 <div className="thinking-md-preview break-words text-[#2f3329] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0">
234 <ReactMarkdown
235 components={{
236 h1: ({ children }) => (
237 <h1 className="mb-3 text-[18px] font-bold leading-tight text-[#26301f]">
238 {children}
239 </h1>
240 ),
241 h2: ({ children }) => (
242 <h2 className="mb-2 mt-4 border-t border-[#edf1e8] pt-3 text-[13px] font-bold leading-snug text-[#34422a]">
243 {children}
244 </h2>
245 ),
246 h3: ({ children }) => (
247 <h3 className="mb-1.5 mt-3 text-[12px] font-semibold text-[#3f4c36]">
248 {children}
249 </h3>
250 ),
251 p: ({ children }) => (
252 <p className="mb-2 whitespace-pre-wrap text-[12px] leading-relaxed text-[#4f5649]">
253 {children}
254 </p>
255 ),
256 ul: ({ children }) => (
257 <ul className="mb-2 list-disc space-y-1 pl-5 text-[12px] leading-relaxed marker:text-[#8b967e]">
258 {children}
259 </ul>
260 ),
261 ol: ({ children }) => (
262 <ol className="mb-2 list-decimal space-y-1 pl-5 text-[12px] leading-relaxed marker:text-[#8b967e]">
263 {children}
264 </ol>
265 ),
266 li: ({ children }) => <li className="text-[#4f5649]">{children}</li>,
267 code: ({ children }) => (
268 <code className="rounded bg-[#edf0e7] px-1 py-0.5 font-mono text-[11px] text-[#2f3329]">
269 {children}
270 </code>
271 ),
272 pre: ({ children }) => (
273 <pre className="mb-2 overflow-x-auto rounded-md bg-[#edf0e7] p-3 text-[11px] leading-relaxed text-[#2f3329]">
274 {children}
275 </pre>
276 ),
277 blockquote: ({ children }) => (
278 <blockquote className="mb-2 border-l-2 border-[#d7ddcf] pl-3 text-[12px] leading-relaxed text-[#6f7867]">
279 {children}
280 </blockquote>
281 )
282 }}
283 >
284 {thinkingMd}
285 </ReactMarkdown>
286 </div>
287 </div>
288 ) : (
289 <div className="flex min-h-[260px] flex-col items-center justify-center rounded-[2rem] border border-dashed border-[#c8d6ba] bg-[#f5f1e8]/72 px-6 text-center">
290 <div className="flex h-12 w-12 items-center justify-center rounded-[5%_95%_10%_90%/85%_15%_85%_15%] bg-[#8fbc8f] text-white">
291 <FileText className="h-5 w-5" />
292 </div>
293 <p className="mt-3 text-xs leading-relaxed text-[#7a806c]">
294 {t('thinking.noDocumentYet')}
295 </p>
296 </div>
297 )
298 ) : cards.length === 0 ? (
299 <div className="flex min-h-[260px] flex-col items-center justify-center rounded-[2rem] border border-dashed border-[#c8d6ba] bg-[#f5f1e8]/72 px-6 text-center">
300 <div className="flex h-12 w-12 items-center justify-center rounded-[5%_95%_10%_90%/85%_15%_85%_15%] bg-[#8fbc8f] text-white">
301 <FileText className="h-5 w-5" />
302 </div>
303 <p className="mt-3 text-xs leading-relaxed text-[#7a806c]">
304 {t('thinking.noPagesYet')}
305 </p>
306 </div>
307 ) : (
308 <div className="space-y-2.5">
309 {cards.map((card) => (
310 <div
311 key={card.pageNumber}
312 className="group rounded-[1.5rem] border border-[#c8d6ba] bg-[#f5f1e8] px-3 py-3 shadow-sm transition-colors hover:border-[#8fbc8f]"
313 >
314 <div className="flex items-start gap-3">
315 <span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[#8fbc8f] text-[11px] font-bold text-[#3e4a32]">
316 {card.pageNumber}
317 </span>
318 <div className="min-w-0 flex-1">
319 {editingPageNumber === card.pageNumber && draft ? (
320 <div className="space-y-2.5">
321 <div className="flex items-center justify-between gap-2">
322 <span className="rounded-full border border-[#c8d6ba] bg-[#fffdf8] px-2 py-0.5 text-[9px] font-semibold uppercase tracking-[0.04em] text-[#5d6b4d]">
323 {card.role}
324 </span>
325 <div className="flex items-center gap-1">
326 <button
327 type="button"
328 onClick={cancelEditing}
329 disabled={savingPageNumber !== null}
330 className="inline-flex h-7 w-7 items-center justify-center rounded-full text-[#7a806c] transition-colors hover:bg-[#e8e0d0] hover:text-[#3e4a32] disabled:opacity-40"
331 title={t('common.cancel')}
332 >
333 <X className="h-3.5 w-3.5" />
334 </button>
335 <button
336 type="button"
337 onClick={() => void savePage(card)}
338 disabled={savingPageNumber !== null}
339 className="inline-flex h-7 w-7 items-center justify-center rounded-full bg-[#3e4a32] text-white transition-colors hover:bg-[#5d6b4d] disabled:opacity-50"
340 title={t('thinking.saveOutline')}
341 >
342 {savingPageNumber === card.pageNumber ? (
343 <Loader2 className="h-3.5 w-3.5 animate-spin" />
344 ) : (
345 <Save className="h-3.5 w-3.5" />
346 )}
347 </button>
348 </div>
349 </div>
350 <label className="block">
351 <span className="mb-1 block text-[10px] font-semibold text-[#5d6b4d]">
352 {t('thinking.outlineTitle')}
353 </span>
354 <Input
355 value={draft.title}
356 onChange={(event) =>
357 setDraft((current) =>
358 current ? { ...current, title: event.target.value } : current
359 )
360 }
361 className="h-8 rounded-lg border-[#c8d6ba] bg-[#fffdf8] px-2.5 text-[12px]"
362 />
363 </label>
364 <label className="block">
365 <span className="mb-1 block text-[10px] font-semibold text-[#5d6b4d]">
366 {t('thinking.outlineObjective')}
367 </span>
368 <Textarea
369 value={draft.objective}
370 onChange={(event) =>
371 setDraft((current) =>
372 current ? { ...current, objective: event.target.value } : current
373 )
374 }
375 rows={2}
376 className="min-h-14 resize-y rounded-lg border-[#c8d6ba] bg-[#fffdf8] px-2.5 py-2 text-[12px]"
377 />
378 </label>
379 <label className="block">
380 <span className="mb-1 block text-[10px] font-semibold text-[#5d6b4d]">
381 {t('thinking.outlineSummary')}
382 </span>
383 <Textarea
384 value={draft.summary}
385 onChange={(event) =>
386 setDraft((current) =>
387 current ? { ...current, summary: event.target.value } : current
388 )
389 }
390 rows={3}
391 className="min-h-20 resize-y rounded-lg border-[#c8d6ba] bg-[#fffdf8] px-2.5 py-2 text-[12px]"
392 />
393 </label>
394 <label className="block">
395 <span className="mb-1 block text-[10px] font-semibold text-[#5d6b4d]">
396 {t('thinking.outlineKeyPoints')}
397 </span>
398 <Textarea
399 value={draft.keyPoints}
400 onChange={(event) =>
401 setDraft((current) =>
402 current ? { ...current, keyPoints: event.target.value } : current
403 )
404 }
405 rows={4}
406 placeholder={t('thinking.outlineKeyPointsHint')}
407 className="min-h-24 resize-y rounded-lg border-[#c8d6ba] bg-[#fffdf8] px-2.5 py-2 text-[12px]"
408 />
409 </label>
410 </div>
411 ) : (
412 <>
413 <div className="flex min-w-0 items-start justify-between gap-2">
414 <div className="line-clamp-2 min-w-0 text-[13px] font-semibold leading-snug text-[#2f3329]">
415 {card.title}
416 </div>
417 <div className="flex shrink-0 items-center gap-1">
418 <span className="rounded-full border border-[#c8d6ba] bg-[#fffdf8] px-2 py-0.5 text-[9px] font-semibold uppercase tracking-[0.04em] text-[#5d6b4d]">
419 {card.role}
420 </span>
421 <button
422 type="button"
423 onClick={() => startEditing(card)}
424 disabled={busy || editingPageNumber !== null}
425 className="inline-flex h-6 w-6 items-center justify-center rounded-full text-[#7a806c] opacity-70 transition-colors hover:bg-[#e8e0d0] hover:text-[#3e4a32] hover:opacity-100 disabled:cursor-not-allowed disabled:opacity-30"
426 title={t('thinking.editOutline')}
427 >
428 <Pencil className="h-3 w-3" />
429 </button>
430 </div>
431 </div>
432 <p className="mt-1.5 text-[11px] font-medium leading-relaxed text-[#4f6340]">
433 {card.objective}
434 </p>
435 {card.summary ? (
436 <p className="mt-1.5 line-clamp-3 text-[11px] leading-relaxed text-[#747968]">
437 {card.summary}
438 </p>
439 ) : null}
440 {card.keyPoints.length > 0 && (
441 <ul className="mt-2 space-y-1 text-[11px] leading-relaxed text-[#747968]">
442 {card.keyPoints.slice(0, 3).map((point, pointIndex) => (
443 <li key={pointIndex} className="flex gap-1.5">
444 <span className="mt-[0.55em] h-1 w-1 shrink-0 rounded-full bg-[#8fbc8f]" />
445 <span className="line-clamp-2">{point}</span>
446 </li>
447 ))}
448 </ul>
449 )}
450 </>
451 )}
452 </div>
453 </div>
454 </div>
455 ))}
456 </div>
457 )}
458 </div>
459
460 <div className="shrink-0 border-t border-[#c8d6ba] bg-[#d4e4c1] p-3">
461 <button
462 type="button"
463 onClick={onConfirmGenerate}
464 disabled={busy || editingPageNumber !== null || !canGenerate}
465 className="flex h-11 w-full items-center justify-center gap-2 rounded-full bg-[#3e4a32] text-[13px] font-semibold text-white shadow-sm transition-colors hover:bg-[#5d6b4d] disabled:opacity-40 disabled:hover:bg-[#3e4a32]"
466 >
467 {busy ? (
468 <Loader2 className="h-4 w-4 animate-spin" />
469 ) : canGenerate ? (
470 <Sparkles className="h-4 w-4" />
471 ) : (
472 <CheckCircle2 className="h-4 w-4" />
473 )}
474 {busy ? t('thinking.thinking') : t('thinking.confirmAndGenerate')}
475 </button>
476 {!canGenerate && (
477 <p className="mt-2 text-center text-[10px] text-[#7a806c]">
478 {t('thinking.needMoreWork')}
479 </p>
480 )}
481 </div>
482 </div>
483 )
484 }
485
485 lines Plain Text