@octo-cyber/humble-brag-lab 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/controllers/humble-brag.controller.d.ts +3 -0
  2. package/dist/controllers/humble-brag.controller.d.ts.map +1 -0
  3. package/dist/controllers/humble-brag.controller.js +60 -0
  4. package/dist/controllers/humble-brag.controller.js.map +1 -0
  5. package/dist/data/classic-quotes.d.ts +9 -0
  6. package/dist/data/classic-quotes.d.ts.map +1 -0
  7. package/dist/data/classic-quotes.js +76 -0
  8. package/dist/data/classic-quotes.js.map +1 -0
  9. package/dist/entities/analysis-record.entity.d.ts +17 -0
  10. package/dist/entities/analysis-record.entity.d.ts.map +1 -0
  11. package/dist/entities/analysis-record.entity.js +66 -0
  12. package/dist/entities/analysis-record.entity.js.map +1 -0
  13. package/dist/entities/index.d.ts +5 -0
  14. package/dist/entities/index.d.ts.map +1 -0
  15. package/dist/entities/index.js +12 -0
  16. package/dist/entities/index.js.map +1 -0
  17. package/dist/entities/quote-collection.entity.d.ts +17 -0
  18. package/dist/entities/quote-collection.entity.d.ts.map +1 -0
  19. package/dist/entities/quote-collection.entity.js +66 -0
  20. package/dist/entities/quote-collection.entity.js.map +1 -0
  21. package/dist/humble-brag-lab.module.d.ts +8 -0
  22. package/dist/humble-brag-lab.module.d.ts.map +1 -0
  23. package/dist/humble-brag-lab.module.js +21 -0
  24. package/dist/humble-brag-lab.module.js.map +1 -0
  25. package/dist/index.d.ts +6 -0
  26. package/dist/index.d.ts.map +1 -0
  27. package/dist/index.js +12 -0
  28. package/dist/index.js.map +1 -0
  29. package/dist/schemas/humble-brag.schema.d.ts +33 -0
  30. package/dist/schemas/humble-brag.schema.d.ts.map +1 -0
  31. package/dist/schemas/humble-brag.schema.js +16 -0
  32. package/dist/schemas/humble-brag.schema.js.map +1 -0
  33. package/dist/services/analysis-record.service.d.ts +15 -0
  34. package/dist/services/analysis-record.service.d.ts.map +1 -0
  35. package/dist/services/analysis-record.service.js +32 -0
  36. package/dist/services/analysis-record.service.js.map +1 -0
  37. package/dist/services/humble-brag-analyzer.service.d.ts +22 -0
  38. package/dist/services/humble-brag-analyzer.service.d.ts.map +1 -0
  39. package/dist/services/humble-brag-analyzer.service.js +321 -0
  40. package/dist/services/humble-brag-analyzer.service.js.map +1 -0
  41. package/dist/services/quote-collection.service.d.ts +15 -0
  42. package/dist/services/quote-collection.service.d.ts.map +1 -0
  43. package/dist/services/quote-collection.service.js +39 -0
  44. package/dist/services/quote-collection.service.js.map +1 -0
  45. package/package.json +88 -0
  46. package/web/index.ts +9 -0
  47. package/web/manifest.ts +18 -0
  48. package/web/messages/en-US.json +76 -0
  49. package/web/messages/zh-CN.json +76 -0
  50. package/web/pages/HumbleBragLabPage.tsx +561 -0
@@ -0,0 +1,76 @@
1
+ {
2
+ "nav": {
3
+ "humbleBragLab": {
4
+ "title": "凡尔赛鉴定师",
5
+ "description": "识别和分析隐性炫耀式发言",
6
+ "pages": {
7
+ "analyzer": "凡尔赛鉴定"
8
+ }
9
+ }
10
+ },
11
+ "humbleBragLab": {
12
+ "title": "凡尔赛鉴定师",
13
+ "description": "识别和分析隐性炫耀式发言",
14
+ "pages": {
15
+ "analyzer": "凡尔赛鉴定"
16
+ },
17
+ "analyzer": {
18
+ "title": "凡尔赛鉴定师",
19
+ "subtitle": "一眼看穿隐性炫耀,翻译成大白话",
20
+ "placeholder": "请输入一段话,例如:「唉,又被老板让我带新项目了,真的好累……」",
21
+ "analyze": "开始鉴定",
22
+ "analyzing": "鉴定中...",
23
+ "clear": "清空",
24
+ "charCount": "{count} / 2000"
25
+ },
26
+ "result": {
27
+ "title": "鉴定结果",
28
+ "scoreLabel": "凡尔赛指数",
29
+ "rankLabel": "段位",
30
+ "translationLabel": "直白翻译",
31
+ "patternsLabel": "识别到的凡尔赛模式",
32
+ "highlightsLabel": "凡尔赛片段",
33
+ "collectBtn": "收藏这句话",
34
+ "collected": "已收藏",
35
+ "copyTranslation": "复制翻译",
36
+ "copied": "已复制"
37
+ },
38
+ "patterns": {
39
+ "fakeComplaint": "抱怨式炫耀",
40
+ "luxuryMention": "奢侈品随口一提",
41
+ "eliteAffiliation": "名校/顶尖机构背书",
42
+ "humilityReversal": "假谦虚反转句",
43
+ "disguisedSuccess": "以悲剧包装喜讯",
44
+ "imposedHonor": "被迫承受荣誉",
45
+ "wealthHint": "财富暗示",
46
+ "superiorityCompare": "优越感对比",
47
+ "surpriseBoast": "假装意外的成就",
48
+ "casualFlexing": "随手式炫耀"
49
+ },
50
+ "ranks": {
51
+ "description": "凡尔赛段位说明",
52
+ "levels": "路人 → 学徒 → 白银 → 黄金 → 铂金 → 钻石 → 王者"
53
+ },
54
+ "classics": {
55
+ "title": "经典凡尔赛语录",
56
+ "subtitle": "来自民间的真实案例",
57
+ "tryIt": "试试这句"
58
+ },
59
+ "history": {
60
+ "title": "最近鉴定记录",
61
+ "empty": "还没有鉴定记录",
62
+ "reanalyze": "再次鉴定"
63
+ },
64
+ "collections": {
65
+ "title": "我的凡尔赛收藏",
66
+ "empty": "收藏夹是空的",
67
+ "remove": "取消收藏"
68
+ },
69
+ "tabs": {
70
+ "analyzer": "鉴定",
71
+ "classics": "经典语录",
72
+ "history": "历史记录",
73
+ "collections": "我的收藏"
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,561 @@
1
+ 'use client'
2
+
3
+ import { useState, useCallback } from 'react'
4
+ import { useTranslations } from 'next-intl'
5
+ import { toast } from 'sonner'
6
+ import { Card, CardContent, CardHeader, CardTitle } from '@octo-cyber/ui/components/ui/card'
7
+ import { Button } from '@octo-cyber/ui/components/ui/button'
8
+ import { Badge } from '@octo-cyber/ui/components/ui/badge'
9
+ import { Textarea } from '@octo-cyber/ui/components/ui/textarea'
10
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@octo-cyber/ui/components/ui/tabs'
11
+ import {
12
+ Sparkles, Copy, BookmarkPlus, BookmarkCheck, RotateCcw, ChevronRight,
13
+ } from 'lucide-react'
14
+
15
+ // ── Types ─────────────────────────────────────────────────────────────────────
16
+
17
+ interface PatternMatch {
18
+ pattern: string
19
+ snippet: string
20
+ weight: number
21
+ }
22
+
23
+ interface AnalysisResult {
24
+ id: string
25
+ score: number
26
+ rank: string
27
+ translation: string
28
+ patterns: PatternMatch[]
29
+ highlights: string[]
30
+ }
31
+
32
+ interface AnalysisRecord {
33
+ id: string
34
+ inputText: string
35
+ score: number
36
+ rank: string
37
+ translation: string
38
+ createdAt: string
39
+ }
40
+
41
+ interface CollectionItem {
42
+ id: string
43
+ text: string
44
+ translation: string
45
+ score: number
46
+ rank: string
47
+ createdAt: string
48
+ }
49
+
50
+ interface ClassicQuote {
51
+ id: string
52
+ text: string
53
+ translation: string
54
+ score: number
55
+ rank: string
56
+ category: string
57
+ }
58
+
59
+ // ── API helpers ───────────────────────────────────────────────────────────────
60
+
61
+ async function apiAnalyze(text: string): Promise<AnalysisResult> {
62
+ const res = await fetch('/api/v1/humble-brag-lab/analyze', {
63
+ method: 'POST',
64
+ headers: { 'Content-Type': 'application/json' },
65
+ body: JSON.stringify({ text }),
66
+ })
67
+ if (!res.ok) throw new Error('分析失败')
68
+ const json = await res.json()
69
+ return json.data as AnalysisResult
70
+ }
71
+
72
+ async function apiFetchRecords(): Promise<AnalysisRecord[]> {
73
+ const res = await fetch('/api/v1/humble-brag-lab/records')
74
+ if (!res.ok) return []
75
+ const json = await res.json()
76
+ return json.data as AnalysisRecord[]
77
+ }
78
+
79
+ async function apiFetchClassics(): Promise<ClassicQuote[]> {
80
+ const res = await fetch('/api/v1/humble-brag-lab/classic-quotes')
81
+ if (!res.ok) return []
82
+ const json = await res.json()
83
+ return json.data as ClassicQuote[]
84
+ }
85
+
86
+ async function apiFetchCollections(): Promise<CollectionItem[]> {
87
+ const res = await fetch('/api/v1/humble-brag-lab/collections')
88
+ if (!res.ok) return []
89
+ const json = await res.json()
90
+ return json.data as CollectionItem[]
91
+ }
92
+
93
+ async function apiAddCollection(data: {
94
+ text: string; translation: string; score: number; rank: string; recordId?: string
95
+ }): Promise<void> {
96
+ const res = await fetch('/api/v1/humble-brag-lab/collections', {
97
+ method: 'POST',
98
+ headers: { 'Content-Type': 'application/json' },
99
+ body: JSON.stringify(data),
100
+ })
101
+ if (!res.ok) throw new Error('收藏失败')
102
+ }
103
+
104
+ async function apiRemoveCollection(id: string): Promise<void> {
105
+ await fetch(`/api/v1/humble-brag-lab/collections/${id}`, { method: 'DELETE' })
106
+ }
107
+
108
+ // ── Score Gauge ───────────────────────────────────────────────────────────────
109
+
110
+ function ScoreGauge({ score }: { score: number }) {
111
+ const color =
112
+ score >= 80 ? 'text-red-500' :
113
+ score >= 60 ? 'text-orange-500' :
114
+ score >= 40 ? 'text-yellow-500' :
115
+ score >= 20 ? 'text-blue-500' : 'text-muted-foreground'
116
+
117
+ const ringColor =
118
+ score >= 80 ? 'stroke-red-500' :
119
+ score >= 60 ? 'stroke-orange-500' :
120
+ score >= 40 ? 'stroke-yellow-500' :
121
+ score >= 20 ? 'stroke-blue-500' : 'stroke-muted'
122
+
123
+ const radius = 52
124
+ const circumference = 2 * Math.PI * radius
125
+ const progress = (score / 100) * circumference
126
+
127
+ return (
128
+ <div className="flex flex-col items-center gap-2">
129
+ <div className="relative h-32 w-32">
130
+ <svg className="h-32 w-32 -rotate-90" viewBox="0 0 128 128">
131
+ <circle cx="64" cy="64" r={radius} fill="none" stroke="currentColor"
132
+ className="text-muted/30" strokeWidth="10" />
133
+ <circle cx="64" cy="64" r={radius} fill="none" strokeWidth="10"
134
+ className={`transition-all duration-700 ${ringColor}`}
135
+ strokeDasharray={`${progress} ${circumference}`}
136
+ strokeLinecap="round" />
137
+ </svg>
138
+ <div className="absolute inset-0 flex flex-col items-center justify-center">
139
+ <span className={`text-3xl font-bold tabular-nums ${color}`}>{score}</span>
140
+ <span className="text-xs text-muted-foreground">/ 100</span>
141
+ </div>
142
+ </div>
143
+ </div>
144
+ )
145
+ }
146
+
147
+ // ── Rank Badge ────────────────────────────────────────────────────────────────
148
+
149
+ function RankBadge({ rank, size = 'default' }: { rank: string; size?: 'default' | 'lg' }) {
150
+ const isKing = rank.includes('王者')
151
+ const isDiamond = rank.includes('钻石') || rank.includes('Diamond')
152
+ const isPlatinum = rank.includes('铂金') || rank.includes('Platinum')
153
+ const isGold = rank.includes('黄金') || rank.includes('Gold')
154
+
155
+ const cls =
156
+ isKing ? 'bg-yellow-100 text-yellow-800 border-yellow-300 dark:bg-yellow-900/30 dark:text-yellow-300 dark:border-yellow-700' :
157
+ isDiamond ? 'bg-purple-100 text-purple-800 border-purple-300 dark:bg-purple-900/30 dark:text-purple-300 dark:border-purple-700' :
158
+ isPlatinum ? 'bg-cyan-100 text-cyan-800 border-cyan-300 dark:bg-cyan-900/30 dark:text-cyan-300 dark:border-cyan-700' :
159
+ isGold ? 'bg-amber-100 text-amber-800 border-amber-300 dark:bg-amber-900/30 dark:text-amber-300 dark:border-amber-700' :
160
+ 'bg-zinc-100 text-zinc-700 border-zinc-300 dark:bg-zinc-800 dark:text-zinc-300 dark:border-zinc-600'
161
+
162
+ return (
163
+ <span className={`inline-flex items-center rounded-full border px-3 font-semibold
164
+ ${size === 'lg' ? 'py-2 text-base' : 'py-1 text-sm'} ${cls}`}>
165
+ {rank}
166
+ </span>
167
+ )
168
+ }
169
+
170
+ // ── Result Panel ──────────────────────────────────────────────────────────────
171
+
172
+ function ResultPanel({
173
+ result,
174
+ inputText,
175
+ onCollect,
176
+ }: {
177
+ result: AnalysisResult
178
+ inputText: string
179
+ onCollect: () => void
180
+ }) {
181
+ const t = useTranslations('humbleBragLab')
182
+ const [copied, setCopied] = useState(false)
183
+ const [collected, setCollected] = useState(false)
184
+
185
+ const handleCopy = useCallback(async () => {
186
+ await navigator.clipboard.writeText(result.translation)
187
+ setCopied(true)
188
+ setTimeout(() => setCopied(false), 2000)
189
+ }, [result.translation])
190
+
191
+ const handleCollect = useCallback(async () => {
192
+ try {
193
+ await apiAddCollection({
194
+ text: inputText,
195
+ translation: result.translation,
196
+ score: result.score,
197
+ rank: result.rank,
198
+ recordId: result.id,
199
+ })
200
+ setCollected(true)
201
+ onCollect()
202
+ toast.success('已加入收藏')
203
+ } catch {
204
+ toast.error('收藏失败')
205
+ }
206
+ }, [inputText, result, onCollect])
207
+
208
+ return (
209
+ <div className="space-y-4">
210
+ {/* Score + Rank */}
211
+ <Card>
212
+ <CardContent className="flex flex-col items-center gap-4 py-6 sm:flex-row sm:justify-center sm:gap-8">
213
+ <div className="flex flex-col items-center gap-1">
214
+ <p className="text-xs font-medium text-muted-foreground">{t('result.scoreLabel')}</p>
215
+ <ScoreGauge score={result.score} />
216
+ </div>
217
+ <div className="flex flex-col items-center gap-2">
218
+ <p className="text-xs font-medium text-muted-foreground">{t('result.rankLabel')}</p>
219
+ <RankBadge rank={result.rank} size="lg" />
220
+ </div>
221
+ </CardContent>
222
+ </Card>
223
+
224
+ {/* Translation */}
225
+ <Card>
226
+ <CardHeader className="pb-2">
227
+ <CardTitle className="text-sm font-medium text-muted-foreground">
228
+ {t('result.translationLabel')}
229
+ </CardTitle>
230
+ </CardHeader>
231
+ <CardContent>
232
+ <p className="text-base leading-relaxed">{result.translation}</p>
233
+ <div className="mt-3 flex gap-2">
234
+ <Button variant="outline" size="sm" onClick={handleCopy}>
235
+ <Copy className="mr-1.5 h-3.5 w-3.5" />
236
+ {copied ? t('result.copied') : t('result.copyTranslation')}
237
+ </Button>
238
+ <Button
239
+ variant={collected ? 'secondary' : 'outline'}
240
+ size="sm"
241
+ onClick={handleCollect}
242
+ disabled={collected}
243
+ >
244
+ {collected
245
+ ? <><BookmarkCheck className="mr-1.5 h-3.5 w-3.5" />{t('result.collected')}</>
246
+ : <><BookmarkPlus className="mr-1.5 h-3.5 w-3.5" />{t('result.collectBtn')}</>
247
+ }
248
+ </Button>
249
+ </div>
250
+ </CardContent>
251
+ </Card>
252
+
253
+ {/* Patterns */}
254
+ {result.patterns.length > 0 && (
255
+ <Card>
256
+ <CardHeader className="pb-2">
257
+ <CardTitle className="text-sm font-medium text-muted-foreground">
258
+ {t('result.patternsLabel')}
259
+ </CardTitle>
260
+ </CardHeader>
261
+ <CardContent>
262
+ <div className="flex flex-wrap gap-2">
263
+ {result.patterns.map((p) => (
264
+ <Badge key={p.pattern} variant="secondary" className="text-xs">
265
+ {t(`patterns.${p.pattern}` as Parameters<typeof t>[0])}
266
+ </Badge>
267
+ ))}
268
+ </div>
269
+ </CardContent>
270
+ </Card>
271
+ )}
272
+
273
+ {/* Highlights */}
274
+ {result.highlights.length > 0 && (
275
+ <Card>
276
+ <CardHeader className="pb-2">
277
+ <CardTitle className="text-sm font-medium text-muted-foreground">
278
+ {t('result.highlightsLabel')}
279
+ </CardTitle>
280
+ </CardHeader>
281
+ <CardContent>
282
+ <div className="space-y-1">
283
+ {result.highlights.map((h, i) => (
284
+ <p key={i} className="rounded bg-amber-50 px-2 py-1 text-sm dark:bg-amber-950/20">
285
+ 「{h}」
286
+ </p>
287
+ ))}
288
+ </div>
289
+ </CardContent>
290
+ </Card>
291
+ )}
292
+ </div>
293
+ )
294
+ }
295
+
296
+ // ── Classics Tab ──────────────────────────────────────────────────────────────
297
+
298
+ function ClassicsTab({ onTry }: { onTry: (text: string) => void }) {
299
+ const t = useTranslations('humbleBragLab')
300
+ const [quotes, setQuotes] = useState<ClassicQuote[]>([])
301
+ const [loaded, setLoaded] = useState(false)
302
+
303
+ const load = useCallback(async () => {
304
+ if (loaded) return
305
+ const data = await apiFetchClassics()
306
+ setQuotes(data)
307
+ setLoaded(true)
308
+ }, [loaded])
309
+
310
+ // Auto-load on mount
311
+ useState(() => { load() })
312
+
313
+ return (
314
+ <div className="space-y-3">
315
+ <p className="text-sm text-muted-foreground">{t('classics.subtitle')}</p>
316
+ {quotes.map((q) => (
317
+ <Card key={q.id} className="cursor-default">
318
+ <CardContent className="p-4">
319
+ <div className="flex items-start justify-between gap-3">
320
+ <div className="flex-1 space-y-1">
321
+ <p className="text-sm font-medium leading-relaxed">「{q.text}」</p>
322
+ <p className="text-xs text-muted-foreground">{q.translation}</p>
323
+ <div className="flex items-center gap-2 pt-1">
324
+ <RankBadge rank={q.rank} />
325
+ <span className="text-xs text-muted-foreground">{q.score}分</span>
326
+ <Badge variant="outline" className="text-xs">{q.category}</Badge>
327
+ </div>
328
+ </div>
329
+ <Button
330
+ variant="ghost"
331
+ size="sm"
332
+ className="shrink-0 text-xs"
333
+ onClick={() => onTry(q.text)}
334
+ >
335
+ {t('classics.tryIt')}
336
+ <ChevronRight className="ml-1 h-3 w-3" />
337
+ </Button>
338
+ </div>
339
+ </CardContent>
340
+ </Card>
341
+ ))}
342
+ </div>
343
+ )
344
+ }
345
+
346
+ // ── History Tab ───────────────────────────────────────────────────────────────
347
+
348
+ function HistoryTab({ onRetry }: { onRetry: (text: string) => void }) {
349
+ const t = useTranslations('humbleBragLab')
350
+ const [records, setRecords] = useState<AnalysisRecord[]>([])
351
+ const [loaded, setLoaded] = useState(false)
352
+
353
+ const load = useCallback(async () => {
354
+ const data = await apiFetchRecords()
355
+ setRecords(data)
356
+ setLoaded(true)
357
+ }, [])
358
+
359
+ useState(() => { load() })
360
+
361
+ if (loaded && records.length === 0) {
362
+ return <p className="py-8 text-center text-sm text-muted-foreground">{t('history.empty')}</p>
363
+ }
364
+
365
+ return (
366
+ <div className="space-y-2">
367
+ {records.map((r) => (
368
+ <Card key={r.id}>
369
+ <CardContent className="flex items-start gap-3 p-3">
370
+ <div className="flex-1 space-y-1">
371
+ <p className="line-clamp-2 text-sm">{r.inputText}</p>
372
+ <div className="flex items-center gap-2">
373
+ <RankBadge rank={r.rank} />
374
+ <span className="text-xs text-muted-foreground">{r.score}分</span>
375
+ </div>
376
+ </div>
377
+ <Button variant="ghost" size="icon" onClick={() => onRetry(r.inputText)}>
378
+ <RotateCcw className="h-4 w-4" />
379
+ </Button>
380
+ </CardContent>
381
+ </Card>
382
+ ))}
383
+ </div>
384
+ )
385
+ }
386
+
387
+ // ── Collections Tab ───────────────────────────────────────────────────────────
388
+
389
+ function CollectionsTab({ refreshKey }: { refreshKey: number }) {
390
+ const t = useTranslations('humbleBragLab')
391
+ const [items, setItems] = useState<CollectionItem[]>([])
392
+
393
+ const load = useCallback(async () => {
394
+ const data = await apiFetchCollections()
395
+ setItems(data)
396
+ }, [])
397
+
398
+ // Re-load when refreshKey changes
399
+ useState(() => { load() })
400
+ // eslint-disable-next-line react-hooks/exhaustive-deps
401
+ const loadRef = load
402
+ useState(() => { loadRef() })
403
+
404
+ const handleRemove = useCallback(async (id: string) => {
405
+ await apiRemoveCollection(id)
406
+ setItems((prev) => prev.filter((i) => i.id !== id))
407
+ toast.success('已取消收藏')
408
+ }, [])
409
+
410
+ if (items.length === 0) {
411
+ return <p className="py-8 text-center text-sm text-muted-foreground">{t('collections.empty')}</p>
412
+ }
413
+
414
+ return (
415
+ <div className="space-y-2">
416
+ {items.map((item) => (
417
+ <Card key={item.id}>
418
+ <CardContent className="flex items-start gap-3 p-3">
419
+ <div className="flex-1 space-y-1">
420
+ <p className="line-clamp-2 text-sm font-medium">「{item.text}」</p>
421
+ <p className="text-xs text-muted-foreground">{item.translation}</p>
422
+ <div className="flex items-center gap-2">
423
+ <RankBadge rank={item.rank} />
424
+ <span className="text-xs text-muted-foreground">{item.score}分</span>
425
+ </div>
426
+ </div>
427
+ <Button
428
+ variant="ghost"
429
+ size="sm"
430
+ className="shrink-0 text-xs text-muted-foreground hover:text-destructive"
431
+ onClick={() => handleRemove(item.id)}
432
+ >
433
+ {t('collections.remove')}
434
+ </Button>
435
+ </CardContent>
436
+ </Card>
437
+ ))}
438
+ </div>
439
+ )
440
+ }
441
+
442
+ // ── Main Page ─────────────────────────────────────────────────────────────────
443
+
444
+ export default function HumbleBragLabPage() {
445
+ const t = useTranslations('humbleBragLab')
446
+ const [inputText, setInputText] = useState('')
447
+ const [isAnalyzing, setIsAnalyzing] = useState(false)
448
+ const [result, setResult] = useState<AnalysisResult | null>(null)
449
+ const [collectRefresh, setCollectRefresh] = useState(0)
450
+ const [activeTab, setActiveTab] = useState('analyzer')
451
+
452
+ const handleAnalyze = useCallback(async () => {
453
+ const text = inputText.trim()
454
+ if (!text) return
455
+ setIsAnalyzing(true)
456
+ setResult(null)
457
+ try {
458
+ const data = await apiAnalyze(text)
459
+ setResult(data)
460
+ } catch {
461
+ toast.error('分析失败,请稍后重试')
462
+ } finally {
463
+ setIsAnalyzing(false)
464
+ }
465
+ }, [inputText])
466
+
467
+ const handleTryClassic = useCallback((text: string) => {
468
+ setInputText(text)
469
+ setResult(null)
470
+ setActiveTab('analyzer')
471
+ }, [])
472
+
473
+ const handleClear = useCallback(() => {
474
+ setInputText('')
475
+ setResult(null)
476
+ }, [])
477
+
478
+ const handleCollect = useCallback(() => {
479
+ setCollectRefresh((n) => n + 1)
480
+ }, [])
481
+
482
+ return (
483
+ <div className="mx-auto max-w-2xl space-y-6 py-6">
484
+ {/* Header */}
485
+ <div className="text-center">
486
+ <h1 className="flex items-center justify-center gap-2 text-2xl font-bold tracking-tight">
487
+ <Sparkles className="h-6 w-6 text-amber-500" />
488
+ {t('analyzer.title')}
489
+ </h1>
490
+ <p className="mt-1 text-sm text-muted-foreground">{t('analyzer.subtitle')}</p>
491
+ </div>
492
+
493
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
494
+ <TabsList className="grid w-full grid-cols-4">
495
+ <TabsTrigger value="analyzer">{t('tabs.analyzer')}</TabsTrigger>
496
+ <TabsTrigger value="classics">{t('tabs.classics')}</TabsTrigger>
497
+ <TabsTrigger value="history">{t('tabs.history')}</TabsTrigger>
498
+ <TabsTrigger value="collections">{t('tabs.collections')}</TabsTrigger>
499
+ </TabsList>
500
+
501
+ {/* Analyzer Tab */}
502
+ <TabsContent value="analyzer" className="space-y-4">
503
+ <Card>
504
+ <CardContent className="pt-4">
505
+ <Textarea
506
+ value={inputText}
507
+ onChange={(e) => { setInputText(e.target.value); setResult(null) }}
508
+ placeholder={t('analyzer.placeholder')}
509
+ className="min-h-[120px] resize-none text-base"
510
+ maxLength={2000}
511
+ />
512
+ <div className="mt-2 flex items-center justify-between">
513
+ <span className="text-xs text-muted-foreground">
514
+ {t('analyzer.charCount', { count: inputText.length })}
515
+ </span>
516
+ <div className="flex gap-2">
517
+ {inputText && (
518
+ <Button variant="ghost" size="sm" onClick={handleClear}>
519
+ {t('analyzer.clear')}
520
+ </Button>
521
+ )}
522
+ <Button
523
+ onClick={handleAnalyze}
524
+ disabled={!inputText.trim() || isAnalyzing}
525
+ size="sm"
526
+ >
527
+ <Sparkles className="mr-1.5 h-4 w-4" />
528
+ {isAnalyzing ? t('analyzer.analyzing') : t('analyzer.analyze')}
529
+ </Button>
530
+ </div>
531
+ </div>
532
+ </CardContent>
533
+ </Card>
534
+
535
+ {result && (
536
+ <ResultPanel
537
+ result={result}
538
+ inputText={inputText}
539
+ onCollect={handleCollect}
540
+ />
541
+ )}
542
+ </TabsContent>
543
+
544
+ {/* Classics Tab */}
545
+ <TabsContent value="classics">
546
+ <ClassicsTab onTry={handleTryClassic} />
547
+ </TabsContent>
548
+
549
+ {/* History Tab */}
550
+ <TabsContent value="history">
551
+ <HistoryTab onRetry={handleTryClassic} />
552
+ </TabsContent>
553
+
554
+ {/* Collections Tab */}
555
+ <TabsContent value="collections">
556
+ <CollectionsTab refreshKey={collectRefresh} />
557
+ </TabsContent>
558
+ </Tabs>
559
+ </div>
560
+ )
561
+ }