@octo-cyber/kinship-calc 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 (98) hide show
  1. package/.turbo/turbo-build.log +22 -0
  2. package/dist/controllers/family-tree.controller.d.ts +27 -0
  3. package/dist/controllers/family-tree.controller.d.ts.map +1 -0
  4. package/dist/controllers/family-tree.controller.js +88 -0
  5. package/dist/controllers/family-tree.controller.js.map +1 -0
  6. package/dist/controllers/kinship-calc.controller.d.ts +15 -0
  7. package/dist/controllers/kinship-calc.controller.d.ts.map +1 -0
  8. package/dist/controllers/kinship-calc.controller.js +45 -0
  9. package/dist/controllers/kinship-calc.controller.js.map +1 -0
  10. package/dist/engine/index.d.ts +7 -0
  11. package/dist/engine/index.d.ts.map +1 -0
  12. package/dist/engine/index.js +15 -0
  13. package/dist/engine/index.js.map +1 -0
  14. package/dist/engine/kinship-data.d.ts +31 -0
  15. package/dist/engine/kinship-data.d.ts.map +1 -0
  16. package/dist/engine/kinship-data.js +150 -0
  17. package/dist/engine/kinship-data.js.map +1 -0
  18. package/dist/engine/kinship-engine.d.ts +44 -0
  19. package/dist/engine/kinship-engine.d.ts.map +1 -0
  20. package/dist/engine/kinship-engine.js +184 -0
  21. package/dist/engine/kinship-engine.js.map +1 -0
  22. package/dist/engine/relation-path.d.ts +37 -0
  23. package/dist/engine/relation-path.d.ts.map +1 -0
  24. package/dist/engine/relation-path.js +60 -0
  25. package/dist/engine/relation-path.js.map +1 -0
  26. package/dist/entities/family-tree-person.entity.d.ts +12 -0
  27. package/dist/entities/family-tree-person.entity.d.ts.map +1 -0
  28. package/dist/entities/family-tree-person.entity.js +61 -0
  29. package/dist/entities/family-tree-person.entity.js.map +1 -0
  30. package/dist/entities/family-tree-relation.entity.d.ts +15 -0
  31. package/dist/entities/family-tree-relation.entity.d.ts.map +1 -0
  32. package/dist/entities/family-tree-relation.entity.js +58 -0
  33. package/dist/entities/family-tree-relation.entity.js.map +1 -0
  34. package/dist/entities/family-tree.entity.d.ts +9 -0
  35. package/dist/entities/family-tree.entity.d.ts.map +1 -0
  36. package/dist/entities/family-tree.entity.js +50 -0
  37. package/dist/entities/family-tree.entity.js.map +1 -0
  38. package/dist/entities/index.d.ts +12 -0
  39. package/dist/entities/index.d.ts.map +1 -0
  40. package/dist/entities/index.js +22 -0
  41. package/dist/entities/index.js.map +1 -0
  42. package/dist/entities/kinship-title.entity.d.ts +18 -0
  43. package/dist/entities/kinship-title.entity.d.ts.map +1 -0
  44. package/dist/entities/kinship-title.entity.js +59 -0
  45. package/dist/entities/kinship-title.entity.js.map +1 -0
  46. package/dist/index.d.ts +12 -0
  47. package/dist/index.d.ts.map +1 -0
  48. package/dist/index.js +33 -0
  49. package/dist/index.js.map +1 -0
  50. package/dist/kinship-calc.module.d.ts +9 -0
  51. package/dist/kinship-calc.module.d.ts.map +1 -0
  52. package/dist/kinship-calc.module.js +45 -0
  53. package/dist/kinship-calc.module.js.map +1 -0
  54. package/dist/schemas/kinship.schema.d.ts +129 -0
  55. package/dist/schemas/kinship.schema.d.ts.map +1 -0
  56. package/dist/schemas/kinship.schema.js +50 -0
  57. package/dist/schemas/kinship.schema.js.map +1 -0
  58. package/dist/services/family-tree.service.d.ts +38 -0
  59. package/dist/services/family-tree.service.d.ts.map +1 -0
  60. package/dist/services/family-tree.service.js +179 -0
  61. package/dist/services/family-tree.service.js.map +1 -0
  62. package/dist/services/kinship-calc.service.d.ts +26 -0
  63. package/dist/services/kinship-calc.service.d.ts.map +1 -0
  64. package/dist/services/kinship-calc.service.js +66 -0
  65. package/dist/services/kinship-calc.service.js.map +1 -0
  66. package/package.json +60 -0
  67. package/src/controllers/family-tree.controller.ts +102 -0
  68. package/src/controllers/kinship-calc.controller.ts +50 -0
  69. package/src/engine/index.ts +6 -0
  70. package/src/engine/kinship-data.ts +188 -0
  71. package/src/engine/kinship-engine.ts +230 -0
  72. package/src/engine/relation-path.ts +63 -0
  73. package/src/entities/family-tree-person.entity.ts +37 -0
  74. package/src/entities/family-tree-relation.entity.ts +36 -0
  75. package/src/entities/family-tree.entity.ts +28 -0
  76. package/src/entities/index.ts +18 -0
  77. package/src/entities/kinship-title.entity.ts +38 -0
  78. package/src/index.ts +33 -0
  79. package/src/kinship-calc.module.ts +51 -0
  80. package/src/schemas/kinship.schema.ts +70 -0
  81. package/src/services/family-tree.service.ts +211 -0
  82. package/src/services/kinship-calc.service.ts +68 -0
  83. package/tsconfig.build.json +4 -0
  84. package/tsconfig.json +10 -0
  85. package/web/components/FamilyTreeCanvas.tsx +177 -0
  86. package/web/components/FamilyTreeDialogs.tsx +275 -0
  87. package/web/components/KinshipResultCard.tsx +98 -0
  88. package/web/components/RegionSelector.tsx +32 -0
  89. package/web/components/RelationChainBuilder.tsx +104 -0
  90. package/web/index.ts +29 -0
  91. package/web/manifest.ts +24 -0
  92. package/web/messages/en-US.json +108 -0
  93. package/web/messages/zh-CN.json +108 -0
  94. package/web/pages/FamilyTreePage.tsx +240 -0
  95. package/web/pages/KinshipCalcPage.tsx +140 -0
  96. package/web/services/kinship-service.ts +140 -0
  97. package/web/stores/kinship-store.ts +80 -0
  98. package/web/types/kinship.ts +85 -0
@@ -0,0 +1,275 @@
1
+ 'use client'
2
+
3
+ import { useState, useEffect } from 'react'
4
+ import { useTranslations } from 'next-intl'
5
+ import { toast } from 'sonner'
6
+ import { Button } from '@octo-cyber/ui/components/ui/button'
7
+ import { Input } from '@octo-cyber/ui/components/ui/input'
8
+ import { Label } from '@octo-cyber/ui/components/ui/label'
9
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@octo-cyber/ui/components/ui/select'
10
+ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@octo-cyber/ui/components/ui/dialog'
11
+ import { kinshipService } from '../services/kinship-service'
12
+ import type { FamilyTree, FamilyTreeGraph, FamilyTreePerson } from '../types/kinship'
13
+ import { ATOM_LABELS } from '../types/kinship'
14
+
15
+ export function NewTreeDialog({ open, onClose, onCreated }: {
16
+ open: boolean; onClose: () => void; onCreated: (tree: FamilyTree) => void
17
+ }) {
18
+ const t = useTranslations('kinship')
19
+ const [name, setName] = useState('')
20
+ const [saving, setSaving] = useState(false)
21
+
22
+ function handleOpenChange(v: boolean) { if (!v) { setName(''); onClose() } }
23
+
24
+ async function handleSubmit() {
25
+ if (!name.trim()) { toast.error('请输入家谱名称'); return }
26
+ setSaving(true)
27
+ try {
28
+ const tree = await kinshipService.createTree({ name: name.trim() })
29
+ setName('')
30
+ onCreated(tree)
31
+ } catch (err) {
32
+ toast.error((err as Error).message)
33
+ } finally { setSaving(false) }
34
+ }
35
+
36
+ return (
37
+ <Dialog open={open} onOpenChange={handleOpenChange}>
38
+ <DialogContent>
39
+ <DialogHeader><DialogTitle>{t('tree.newTitle')}</DialogTitle></DialogHeader>
40
+ <div className="space-y-3 py-2">
41
+ <Label>{t('tree.nameLabel')}</Label>
42
+ <Input value={name} onChange={(e) => setName(e.target.value)}
43
+ placeholder={t('tree.namePlaceholder')} autoFocus
44
+ onKeyDown={(e) => e.key === 'Enter' && handleSubmit()} />
45
+ </div>
46
+ <DialogFooter>
47
+ <Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
48
+ <Button onClick={handleSubmit} disabled={saving || !name.trim()}>
49
+ {saving ? '保存中…' : t('common.save')}
50
+ </Button>
51
+ </DialogFooter>
52
+ </DialogContent>
53
+ </Dialog>
54
+ )
55
+ }
56
+
57
+ export function AddPersonDialog({ open, treeId, onClose, onAdded }: {
58
+ open: boolean; treeId: number; onClose: () => void; onAdded: () => void
59
+ }) {
60
+ const t = useTranslations('kinship')
61
+ const [name, setName] = useState('')
62
+ const [gender, setGender] = useState<'male' | 'female'>('male')
63
+ const [birthYear, setBirthYear] = useState('')
64
+ const [isRoot, setIsRoot] = useState(false)
65
+ const [saving, setSaving] = useState(false)
66
+
67
+ function handleOpenChange(v: boolean) {
68
+ if (!v) { setName(''); setBirthYear(''); setIsRoot(false); onClose() }
69
+ }
70
+
71
+ async function handleSubmit() {
72
+ if (!name.trim()) { toast.error('请输入成员姓名'); return }
73
+ const year = birthYear ? parseInt(birthYear) : undefined
74
+ if (year !== undefined && (isNaN(year) || year < 1000 || year > 2100)) {
75
+ toast.error('出生年份格式不正确'); return
76
+ }
77
+ setSaving(true)
78
+ try {
79
+ await kinshipService.addPerson({ treeId, name: name.trim(), gender, birthYear: year, isRoot })
80
+ setName(''); setBirthYear(''); setIsRoot(false)
81
+ onAdded()
82
+ } catch (err) {
83
+ toast.error((err as Error).message)
84
+ } finally { setSaving(false) }
85
+ }
86
+
87
+ return (
88
+ <Dialog open={open} onOpenChange={handleOpenChange}>
89
+ <DialogContent>
90
+ <DialogHeader><DialogTitle>{t('tree.addPersonTitle')}</DialogTitle></DialogHeader>
91
+ <div className="space-y-3 py-2">
92
+ <div className="space-y-1">
93
+ <Label>{t('person.name')}</Label>
94
+ <Input value={name} onChange={(e) => setName(e.target.value)} autoFocus
95
+ onKeyDown={(e) => e.key === 'Enter' && handleSubmit()} />
96
+ </div>
97
+ <div className="space-y-1">
98
+ <Label>{t('person.gender')}</Label>
99
+ <Select value={gender} onValueChange={(v) => setGender(v as 'male' | 'female')}>
100
+ <SelectTrigger><SelectValue /></SelectTrigger>
101
+ <SelectContent>
102
+ <SelectItem value="male">{t('gender.male')}</SelectItem>
103
+ <SelectItem value="female">{t('gender.female')}</SelectItem>
104
+ </SelectContent>
105
+ </Select>
106
+ </div>
107
+ <div className="space-y-1">
108
+ <Label>{t('person.birthYear')}</Label>
109
+ <Input type="number" value={birthYear} onChange={(e) => setBirthYear(e.target.value)} placeholder="可选" />
110
+ </div>
111
+ <label className="flex cursor-pointer items-center gap-2 text-sm">
112
+ <input type="checkbox" checked={isRoot} onChange={(e) => setIsRoot(e.target.checked)} />
113
+ {t('person.isRoot')}
114
+ </label>
115
+ </div>
116
+ <DialogFooter>
117
+ <Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
118
+ <Button onClick={handleSubmit} disabled={saving || !name.trim()}>
119
+ {saving ? '保存中…' : t('common.save')}
120
+ </Button>
121
+ </DialogFooter>
122
+ </DialogContent>
123
+ </Dialog>
124
+ )
125
+ }
126
+
127
+ export function EditPersonDialog({ open, person, treeId, onClose, onSaved }: {
128
+ open: boolean
129
+ person: FamilyTreePerson
130
+ treeId: number
131
+ onClose: () => void
132
+ onSaved: () => void
133
+ }) {
134
+ const t = useTranslations('kinship')
135
+ const [name, setName] = useState(person.name)
136
+ const [gender, setGender] = useState<'male' | 'female'>(person.gender)
137
+ const [birthYear, setBirthYear] = useState(person.birthYear?.toString() ?? '')
138
+ const [saving, setSaving] = useState(false)
139
+
140
+ useEffect(() => {
141
+ setName(person.name)
142
+ setGender(person.gender)
143
+ setBirthYear(person.birthYear?.toString() ?? '')
144
+ }, [person])
145
+
146
+ async function handleSubmit() {
147
+ if (!name.trim()) { toast.error('请输入成员姓名'); return }
148
+ const year = birthYear ? parseInt(birthYear) : null
149
+ if (year !== null && (isNaN(year) || year < 1000 || year > 2100)) {
150
+ toast.error('出生年份格式不正确'); return
151
+ }
152
+ setSaving(true)
153
+ try {
154
+ await kinshipService.updatePerson(person.id, treeId, { name: name.trim(), gender, birthYear: year })
155
+ onSaved()
156
+ } catch (err) {
157
+ toast.error((err as Error).message)
158
+ } finally { setSaving(false) }
159
+ }
160
+
161
+ return (
162
+ <Dialog open={open} onOpenChange={(v) => !v && onClose()}>
163
+ <DialogContent>
164
+ <DialogHeader><DialogTitle>编辑成员</DialogTitle></DialogHeader>
165
+ <div className="space-y-3 py-2">
166
+ <div className="space-y-1">
167
+ <Label>{t('person.name')}</Label>
168
+ <Input value={name} onChange={(e) => setName(e.target.value)} autoFocus />
169
+ </div>
170
+ <div className="space-y-1">
171
+ <Label>{t('person.gender')}</Label>
172
+ <Select value={gender} onValueChange={(v) => setGender(v as 'male' | 'female')}>
173
+ <SelectTrigger><SelectValue /></SelectTrigger>
174
+ <SelectContent>
175
+ <SelectItem value="male">{t('gender.male')}</SelectItem>
176
+ <SelectItem value="female">{t('gender.female')}</SelectItem>
177
+ </SelectContent>
178
+ </Select>
179
+ </div>
180
+ <div className="space-y-1">
181
+ <Label>{t('person.birthYear')}</Label>
182
+ <Input type="number" value={birthYear} onChange={(e) => setBirthYear(e.target.value)} placeholder="可选" />
183
+ </div>
184
+ </div>
185
+ <DialogFooter>
186
+ <Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
187
+ <Button onClick={handleSubmit} disabled={saving || !name.trim()}>
188
+ {saving ? '保存中…' : t('common.save')}
189
+ </Button>
190
+ </DialogFooter>
191
+ </DialogContent>
192
+ </Dialog>
193
+ )
194
+ }
195
+
196
+ export function AddRelationDialog({ open, graph, onClose, onAdded }: {
197
+ open: boolean; graph: FamilyTreeGraph; onClose: () => void; onAdded: () => void
198
+ }) {
199
+ const t = useTranslations('kinship')
200
+ const [fromId, setFromId] = useState('')
201
+ const [toId, setToId] = useState('')
202
+ const [relationType, setRelationType] = useState('F')
203
+ const [saving, setSaving] = useState(false)
204
+
205
+ function handleOpenChange(v: boolean) {
206
+ if (!v) { setFromId(''); setToId(''); setRelationType('F'); onClose() }
207
+ }
208
+
209
+ async function handleSubmit() {
210
+ if (!fromId || !toId) { toast.error('请选择双方成员'); return }
211
+ if (fromId === toId) { toast.error('不能建立自身关系'); return }
212
+ setSaving(true)
213
+ try {
214
+ await kinshipService.addRelation({
215
+ treeId: graph.tree.id,
216
+ fromPersonId: parseInt(fromId),
217
+ toPersonId: parseInt(toId),
218
+ relationType,
219
+ })
220
+ setFromId(''); setToId(''); setRelationType('F')
221
+ onAdded()
222
+ } catch (err) {
223
+ toast.error((err as Error).message)
224
+ } finally { setSaving(false) }
225
+ }
226
+
227
+ return (
228
+ <Dialog open={open} onOpenChange={handleOpenChange}>
229
+ <DialogContent>
230
+ <DialogHeader><DialogTitle>{t('tree.addRelationTitle')}</DialogTitle></DialogHeader>
231
+ <div className="space-y-3 py-2">
232
+ <div className="space-y-1">
233
+ <Label>{t('relation.from')}</Label>
234
+ <Select value={fromId} onValueChange={setFromId}>
235
+ <SelectTrigger><SelectValue placeholder={t('relation.selectPerson')} /></SelectTrigger>
236
+ <SelectContent>
237
+ {graph.persons.map((p) => (
238
+ <SelectItem key={p.id} value={p.id.toString()}>{p.name}</SelectItem>
239
+ ))}
240
+ </SelectContent>
241
+ </Select>
242
+ </div>
243
+ <div className="space-y-1">
244
+ <Label>{t('relation.type')}</Label>
245
+ <Select value={relationType} onValueChange={setRelationType}>
246
+ <SelectTrigger><SelectValue /></SelectTrigger>
247
+ <SelectContent>
248
+ {Object.entries(ATOM_LABELS).map(([code, label]) => (
249
+ <SelectItem key={code} value={code}>{label}({code})</SelectItem>
250
+ ))}
251
+ </SelectContent>
252
+ </Select>
253
+ </div>
254
+ <div className="space-y-1">
255
+ <Label>{t('relation.to')}</Label>
256
+ <Select value={toId} onValueChange={setToId}>
257
+ <SelectTrigger><SelectValue placeholder={t('relation.selectPerson')} /></SelectTrigger>
258
+ <SelectContent>
259
+ {graph.persons.map((p) => (
260
+ <SelectItem key={p.id} value={p.id.toString()}>{p.name}</SelectItem>
261
+ ))}
262
+ </SelectContent>
263
+ </Select>
264
+ </div>
265
+ </div>
266
+ <DialogFooter>
267
+ <Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
268
+ <Button onClick={handleSubmit} disabled={saving || !fromId || !toId}>
269
+ {saving ? '保存中…' : t('common.save')}
270
+ </Button>
271
+ </DialogFooter>
272
+ </DialogContent>
273
+ </Dialog>
274
+ )
275
+ }
@@ -0,0 +1,98 @@
1
+ 'use client'
2
+
3
+ import { useTranslations } from 'next-intl'
4
+ import { Card, CardContent, CardHeader, CardTitle } from '@octo-cyber/ui/components/ui/card'
5
+ import { Badge } from '@octo-cyber/ui/components/ui/badge'
6
+ import { AlertCircle, CheckCircle2, ArrowLeftRight } from 'lucide-react'
7
+ import type { KinshipResult, Region } from '../types/kinship'
8
+ import { REGION_LABELS } from '../types/kinship'
9
+
10
+ interface KinshipResultCardProps {
11
+ result: KinshipResult
12
+ currentRegion: Region
13
+ className?: string
14
+ }
15
+
16
+ export function KinshipResultCard({ result, currentRegion, className }: KinshipResultCardProps) {
17
+ const t = useTranslations('kinship')
18
+
19
+ const generationLabel = result.generationDelta > 0
20
+ ? t('generation.elder', { n: result.generationDelta })
21
+ : result.generationDelta < 0
22
+ ? t('generation.younger', { n: Math.abs(result.generationDelta) })
23
+ : t('generation.same')
24
+
25
+ const variantEntries = Object.entries(result.variants) as [Region, string][]
26
+
27
+ return (
28
+ <Card className={`border-2 ${result.isExact ? 'border-green-500/30 dark:border-green-500/20' : 'border-amber-500/30 dark:border-amber-500/20'} ${className}`}>
29
+ <CardHeader className="pb-3">
30
+ <CardTitle className="flex items-center gap-2 text-base">
31
+ {result.isExact
32
+ ? <CheckCircle2 className="h-4 w-4 text-green-500" />
33
+ : <AlertCircle className="h-4 w-4 text-amber-500" />}
34
+ {t('result.title')}
35
+ </CardTitle>
36
+ </CardHeader>
37
+ <CardContent className="space-y-4">
38
+ {/* Main title */}
39
+ <div className="text-center">
40
+ <div className="text-5xl font-bold tracking-wide text-foreground">{result.title}</div>
41
+ {result.title !== result.standardTitle && (
42
+ <div className="mt-1 text-sm text-muted-foreground">
43
+ {t('result.standard')}: {result.standardTitle}
44
+ </div>
45
+ )}
46
+ </div>
47
+
48
+ {/* Meta info */}
49
+ <div className="flex flex-wrap justify-center gap-2">
50
+ <Badge variant="outline">{generationLabel}</Badge>
51
+ <Badge variant="outline">
52
+ {result.relativeGender === 'male' ? t('gender.male') : result.relativeGender === 'female' ? t('gender.female') : t('gender.unknown')}
53
+ </Badge>
54
+ <Badge variant={result.isExact ? 'default' : 'secondary'}>
55
+ {result.isExact ? t('result.exact') : t('result.estimated')}
56
+ </Badge>
57
+ </div>
58
+
59
+ {/* Reverse title */}
60
+ {result.reverseTitle && (
61
+ <div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
62
+ <ArrowLeftRight className="h-3.5 w-3.5" />
63
+ <span>{t('result.reverseTitle')}: <strong className="text-foreground">{result.reverseTitle}</strong></span>
64
+ </div>
65
+ )}
66
+
67
+ {/* Regional variants */}
68
+ {variantEntries.length > 1 && (
69
+ <div className="space-y-1.5">
70
+ <div className="text-xs font-medium text-muted-foreground">{t('result.variants')}</div>
71
+ <div className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
72
+ {variantEntries.map(([region, variant]) => (
73
+ <div
74
+ key={region}
75
+ className={`rounded border px-2 py-1 text-center text-xs ${
76
+ region === currentRegion
77
+ ? 'border-primary/40 bg-primary/5 font-medium text-primary dark:bg-primary/10'
78
+ : 'border-border bg-muted/30 dark:bg-zinc-800/30'
79
+ }`}
80
+ >
81
+ <div className="text-muted-foreground">{REGION_LABELS[region]?.split('(')[0]}</div>
82
+ <div className="font-medium">{variant}</div>
83
+ </div>
84
+ ))}
85
+ </div>
86
+ </div>
87
+ )}
88
+
89
+ {/* Note */}
90
+ {result.note && (
91
+ <div className="rounded bg-muted/40 px-3 py-1.5 text-xs text-muted-foreground dark:bg-zinc-800/40">
92
+ {result.note}
93
+ </div>
94
+ )}
95
+ </CardContent>
96
+ </Card>
97
+ )
98
+ }
@@ -0,0 +1,32 @@
1
+ 'use client'
2
+
3
+ import { useTranslations } from 'next-intl'
4
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@octo-cyber/ui/components/ui/select'
5
+ import type { Region } from '../types/kinship'
6
+
7
+ const REGIONS: Region[] = ['standard', 'northern', 'southern', 'cantonese', 'minnan', 'wu']
8
+
9
+ interface RegionSelectorProps {
10
+ value: Region
11
+ onChange: (region: Region) => void
12
+ className?: string
13
+ }
14
+
15
+ export function RegionSelector({ value, onChange, className }: RegionSelectorProps) {
16
+ const t = useTranslations('kinship')
17
+
18
+ return (
19
+ <Select value={value} onValueChange={(v) => onChange(v as Region)}>
20
+ <SelectTrigger className={className}>
21
+ <SelectValue placeholder={t('selectRegion')} />
22
+ </SelectTrigger>
23
+ <SelectContent>
24
+ {REGIONS.map((r) => (
25
+ <SelectItem key={r} value={r}>
26
+ {t(`regions.${r}`)}
27
+ </SelectItem>
28
+ ))}
29
+ </SelectContent>
30
+ </Select>
31
+ )
32
+ }
@@ -0,0 +1,104 @@
1
+ 'use client'
2
+
3
+ import { useEffect } from 'react'
4
+ import { useTranslations } from 'next-intl'
5
+ import { Button } from '@octo-cyber/ui/components/ui/button'
6
+ import { Badge } from '@octo-cyber/ui/components/ui/badge'
7
+ import { ArrowRight, Delete, RotateCcw } from 'lucide-react'
8
+ import { useKinshipStore } from '../stores/kinship-store'
9
+ import { ATOM_LABELS } from '../types/kinship'
10
+
11
+ const ATOM_GROUPS = [
12
+ { label: '长辈', atoms: ['F', 'M'] },
13
+ { label: '晚辈', atoms: ['S', 'D'] },
14
+ { label: '兄弟姐妹', atoms: ['B+', 'B-', 'Z+', 'Z-'] },
15
+ { label: '配偶', atoms: ['H', 'W'] },
16
+ ]
17
+
18
+ interface RelationChainBuilderProps {
19
+ onCalculate?: () => void
20
+ }
21
+
22
+ export function RelationChainBuilder({ onCalculate }: RelationChainBuilderProps) {
23
+ const t = useTranslations('kinship')
24
+ const {
25
+ chain, atoms, addAtom, removeLastAtom, clearChain,
26
+ isLoading, loadAtoms,
27
+ } = useKinshipStore()
28
+
29
+ useEffect(() => {
30
+ loadAtoms()
31
+ }, [loadAtoms])
32
+
33
+ return (
34
+ <div className="space-y-4">
35
+ {/* Current chain display */}
36
+ <div className="min-h-12 flex flex-wrap items-center gap-1 rounded-lg border bg-muted/40 px-3 py-2 dark:bg-zinc-800/40">
37
+ {chain.length === 0 ? (
38
+ <span className="text-sm text-muted-foreground">{t('chain.empty')}</span>
39
+ ) : (
40
+ chain.map((atom, i) => (
41
+ <span key={i} className="flex items-center gap-1">
42
+ {i > 0 && <ArrowRight className="h-3 w-3 text-muted-foreground" />}
43
+ <Badge variant="secondary" className="text-sm font-medium">
44
+ {ATOM_LABELS[atom] ?? atom}
45
+ </Badge>
46
+ </span>
47
+ ))
48
+ )}
49
+ </div>
50
+
51
+ {/* Atom buttons grouped by category */}
52
+ <div className="space-y-2">
53
+ {ATOM_GROUPS.map((group) => (
54
+ <div key={group.label} className="flex items-center gap-2 flex-wrap">
55
+ <span className="w-20 shrink-0 text-xs text-muted-foreground">{group.label}</span>
56
+ {group.atoms.map((atom) => (
57
+ <Button
58
+ key={atom}
59
+ variant="outline"
60
+ size="sm"
61
+ onClick={() => addAtom(atom)}
62
+ className="dark:border-zinc-700 dark:hover:bg-zinc-700"
63
+ >
64
+ {ATOM_LABELS[atom]}
65
+ </Button>
66
+ ))}
67
+ </div>
68
+ ))}
69
+ </div>
70
+
71
+ {/* Control buttons */}
72
+ <div className="flex gap-2">
73
+ <Button
74
+ variant="outline"
75
+ size="sm"
76
+ onClick={removeLastAtom}
77
+ disabled={chain.length === 0}
78
+ className="gap-1"
79
+ >
80
+ <Delete className="h-3.5 w-3.5" />
81
+ {t('chain.undo')}
82
+ </Button>
83
+ <Button
84
+ variant="outline"
85
+ size="sm"
86
+ onClick={clearChain}
87
+ disabled={chain.length === 0}
88
+ className="gap-1"
89
+ >
90
+ <RotateCcw className="h-3.5 w-3.5" />
91
+ {t('chain.clear')}
92
+ </Button>
93
+ <Button
94
+ size="sm"
95
+ onClick={onCalculate}
96
+ disabled={chain.length === 0 || isLoading}
97
+ className="ml-auto"
98
+ >
99
+ {isLoading ? t('calculating') : t('calculate')}
100
+ </Button>
101
+ </div>
102
+ </div>
103
+ )
104
+ }
package/web/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ // Manifest
2
+ export { kinshipCalcFrontendManifest } from './manifest'
3
+
4
+ // i18n Messages
5
+ export { default as kinshipCalcMessagesZhCN } from './messages/zh-CN.json'
6
+ export { default as kinshipCalcMessagesEnUS } from './messages/en-US.json'
7
+
8
+ // Pages
9
+ export { default as KinshipCalcPage } from './pages/KinshipCalcPage'
10
+ export { default as FamilyTreePage } from './pages/FamilyTreePage'
11
+
12
+ // Components
13
+ export { RegionSelector } from './components/RegionSelector'
14
+ export { RelationChainBuilder } from './components/RelationChainBuilder'
15
+ export { KinshipResultCard } from './components/KinshipResultCard'
16
+ export { FamilyTreeCanvas } from './components/FamilyTreeCanvas'
17
+
18
+ // Services & Stores
19
+ export { kinshipService } from './services/kinship-service'
20
+ export { useKinshipStore } from './stores/kinship-store'
21
+
22
+ // Types
23
+ export type {
24
+ Region, EgoGender, PersonGender,
25
+ KinshipResult, RelationAtom,
26
+ FamilyTree, FamilyTreePerson, FamilyTreeRelation,
27
+ FamilyTreeGraph, ResolveResult,
28
+ } from './types/kinship'
29
+ export { REGION_LABELS, ATOM_LABELS } from './types/kinship'
@@ -0,0 +1,24 @@
1
+ import type { IFrontendManifest } from '@octo-cyber/core'
2
+
3
+ export const kinshipCalcFrontendManifest: IFrontendManifest = {
4
+ moduleId: 'kinship-calc',
5
+ icon: 'Users',
6
+ color: 'amber',
7
+ position: 'main',
8
+ titleKey: 'kinshipCalc.title',
9
+ descriptionKey: 'kinshipCalc.description',
10
+ pages: [
11
+ {
12
+ path: '/kinship-calc',
13
+ titleKey: 'kinshipCalc.pages.calc',
14
+ icon: 'Calculator',
15
+ component: '@octo-cyber/kinship-calc/web/pages/KinshipCalcPage',
16
+ },
17
+ {
18
+ path: '/kinship-calc/family-tree',
19
+ titleKey: 'kinshipCalc.pages.familyTree',
20
+ icon: 'TreePine',
21
+ component: '@octo-cyber/kinship-calc/web/pages/FamilyTreePage',
22
+ },
23
+ ],
24
+ }
@@ -0,0 +1,108 @@
1
+ {
2
+ "nav": {
3
+ "kinshipCalc": {
4
+ "title": "Kinship Calc",
5
+ "description": "Family relationship & title calculator",
6
+ "pages": {
7
+ "calc": "Title Calculator",
8
+ "familyTree": "Family Tree"
9
+ }
10
+ }
11
+ },
12
+ "kinshipCalc": {
13
+ "title": "Kinship Calculator",
14
+ "description": "Family relationship & title calculator",
15
+ "pages": {
16
+ "calc": "Title Calculator",
17
+ "familyTree": "Family Tree"
18
+ }
19
+ },
20
+ "kinship": {
21
+ "calc": {
22
+ "title": "Kinship Title Calculator",
23
+ "subtitle": "Build a relation chain to calculate the correct Chinese kinship title"
24
+ },
25
+ "settings": {
26
+ "title": "Settings",
27
+ "egoGender": "My gender",
28
+ "region": "Dialect region"
29
+ },
30
+ "chain": {
31
+ "title": "Relation Chain",
32
+ "hint": "Click the relation buttons below to build a chain from 'me' to the person",
33
+ "empty": "No relations selected yet",
34
+ "undo": "Undo",
35
+ "clear": "Clear"
36
+ },
37
+ "calculate": "Calculate Title",
38
+ "calculating": "Calculating…",
39
+ "selectRegion": "Select dialect",
40
+ "regions": {
41
+ "standard": "Standard Mandarin",
42
+ "northern": "Northern dialect",
43
+ "southern": "Southern Mandarin",
44
+ "cantonese": "Cantonese",
45
+ "minnan": "Min-nan / Hokkien",
46
+ "wu": "Wu (Shanghainese)"
47
+ },
48
+ "gender": {
49
+ "male": "Male",
50
+ "female": "Female",
51
+ "unknown": "Unknown"
52
+ },
53
+ "generation": {
54
+ "elder": "{n} generation(s) older",
55
+ "younger": "{n} generation(s) younger",
56
+ "same": "Same generation"
57
+ },
58
+ "result": {
59
+ "title": "Result",
60
+ "standard": "Standard term",
61
+ "reverseTitle": "They call me",
62
+ "variants": "Regional variants",
63
+ "exact": "Exact match",
64
+ "estimated": "Estimated"
65
+ },
66
+ "examples": {
67
+ "title": "Quick Examples"
68
+ },
69
+ "tree": {
70
+ "title": "Family Tree",
71
+ "subtitle": "Visual family tree — click two people to calculate their relationship",
72
+ "new": "New Tree",
73
+ "select": "Select tree",
74
+ "newTitle": "New Family Tree",
75
+ "nameLabel": "Tree name",
76
+ "namePlaceholder": "e.g. Zhang Family Tree",
77
+ "addPerson": "Add Person",
78
+ "addRelation": "Add Relation",
79
+ "addPersonTitle": "Add Family Member",
80
+ "addRelationTitle": "Add Relationship",
81
+ "relations": "Established relations",
82
+ "emptyHint": "No members yet — click Add Person",
83
+ "selectSecondPerson": "Person selected — click another person to calculate their relationship",
84
+ "deleteConfirm": "Delete this family tree? This cannot be undone.",
85
+ "deleted": "Family tree deleted"
86
+ },
87
+ "person": {
88
+ "name": "Name",
89
+ "gender": "Gender",
90
+ "birthYear": "Birth year",
91
+ "isRoot": "Set as 'Me' (reference person)"
92
+ },
93
+ "relation": {
94
+ "from": "From (who)",
95
+ "to": "To (who)",
96
+ "type": "Relation type",
97
+ "selectPerson": "Select person"
98
+ },
99
+ "common": {
100
+ "save": "Save",
101
+ "cancel": "Cancel"
102
+ },
103
+ "error": {
104
+ "calculateFailed": "Calculation failed — please check the relation chain",
105
+ "loadFailed": "Load failed — please refresh"
106
+ }
107
+ }
108
+ }