@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.
- package/dist/controllers/humble-brag.controller.d.ts +3 -0
- package/dist/controllers/humble-brag.controller.d.ts.map +1 -0
- package/dist/controllers/humble-brag.controller.js +60 -0
- package/dist/controllers/humble-brag.controller.js.map +1 -0
- package/dist/data/classic-quotes.d.ts +9 -0
- package/dist/data/classic-quotes.d.ts.map +1 -0
- package/dist/data/classic-quotes.js +76 -0
- package/dist/data/classic-quotes.js.map +1 -0
- package/dist/entities/analysis-record.entity.d.ts +17 -0
- package/dist/entities/analysis-record.entity.d.ts.map +1 -0
- package/dist/entities/analysis-record.entity.js +66 -0
- package/dist/entities/analysis-record.entity.js.map +1 -0
- package/dist/entities/index.d.ts +5 -0
- package/dist/entities/index.d.ts.map +1 -0
- package/dist/entities/index.js +12 -0
- package/dist/entities/index.js.map +1 -0
- package/dist/entities/quote-collection.entity.d.ts +17 -0
- package/dist/entities/quote-collection.entity.d.ts.map +1 -0
- package/dist/entities/quote-collection.entity.js +66 -0
- package/dist/entities/quote-collection.entity.js.map +1 -0
- package/dist/humble-brag-lab.module.d.ts +8 -0
- package/dist/humble-brag-lab.module.d.ts.map +1 -0
- package/dist/humble-brag-lab.module.js +21 -0
- package/dist/humble-brag-lab.module.js.map +1 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/schemas/humble-brag.schema.d.ts +33 -0
- package/dist/schemas/humble-brag.schema.d.ts.map +1 -0
- package/dist/schemas/humble-brag.schema.js +16 -0
- package/dist/schemas/humble-brag.schema.js.map +1 -0
- package/dist/services/analysis-record.service.d.ts +15 -0
- package/dist/services/analysis-record.service.d.ts.map +1 -0
- package/dist/services/analysis-record.service.js +32 -0
- package/dist/services/analysis-record.service.js.map +1 -0
- package/dist/services/humble-brag-analyzer.service.d.ts +22 -0
- package/dist/services/humble-brag-analyzer.service.d.ts.map +1 -0
- package/dist/services/humble-brag-analyzer.service.js +321 -0
- package/dist/services/humble-brag-analyzer.service.js.map +1 -0
- package/dist/services/quote-collection.service.d.ts +15 -0
- package/dist/services/quote-collection.service.d.ts.map +1 -0
- package/dist/services/quote-collection.service.js +39 -0
- package/dist/services/quote-collection.service.js.map +1 -0
- package/package.json +88 -0
- package/web/index.ts +9 -0
- package/web/manifest.ts +18 -0
- package/web/messages/en-US.json +76 -0
- package/web/messages/zh-CN.json +76 -0
- 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
|
+
}
|