@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.
- package/.turbo/turbo-build.log +22 -0
- package/dist/controllers/family-tree.controller.d.ts +27 -0
- package/dist/controllers/family-tree.controller.d.ts.map +1 -0
- package/dist/controllers/family-tree.controller.js +88 -0
- package/dist/controllers/family-tree.controller.js.map +1 -0
- package/dist/controllers/kinship-calc.controller.d.ts +15 -0
- package/dist/controllers/kinship-calc.controller.d.ts.map +1 -0
- package/dist/controllers/kinship-calc.controller.js +45 -0
- package/dist/controllers/kinship-calc.controller.js.map +1 -0
- package/dist/engine/index.d.ts +7 -0
- package/dist/engine/index.d.ts.map +1 -0
- package/dist/engine/index.js +15 -0
- package/dist/engine/index.js.map +1 -0
- package/dist/engine/kinship-data.d.ts +31 -0
- package/dist/engine/kinship-data.d.ts.map +1 -0
- package/dist/engine/kinship-data.js +150 -0
- package/dist/engine/kinship-data.js.map +1 -0
- package/dist/engine/kinship-engine.d.ts +44 -0
- package/dist/engine/kinship-engine.d.ts.map +1 -0
- package/dist/engine/kinship-engine.js +184 -0
- package/dist/engine/kinship-engine.js.map +1 -0
- package/dist/engine/relation-path.d.ts +37 -0
- package/dist/engine/relation-path.d.ts.map +1 -0
- package/dist/engine/relation-path.js +60 -0
- package/dist/engine/relation-path.js.map +1 -0
- package/dist/entities/family-tree-person.entity.d.ts +12 -0
- package/dist/entities/family-tree-person.entity.d.ts.map +1 -0
- package/dist/entities/family-tree-person.entity.js +61 -0
- package/dist/entities/family-tree-person.entity.js.map +1 -0
- package/dist/entities/family-tree-relation.entity.d.ts +15 -0
- package/dist/entities/family-tree-relation.entity.d.ts.map +1 -0
- package/dist/entities/family-tree-relation.entity.js +58 -0
- package/dist/entities/family-tree-relation.entity.js.map +1 -0
- package/dist/entities/family-tree.entity.d.ts +9 -0
- package/dist/entities/family-tree.entity.d.ts.map +1 -0
- package/dist/entities/family-tree.entity.js +50 -0
- package/dist/entities/family-tree.entity.js.map +1 -0
- package/dist/entities/index.d.ts +12 -0
- package/dist/entities/index.d.ts.map +1 -0
- package/dist/entities/index.js +22 -0
- package/dist/entities/index.js.map +1 -0
- package/dist/entities/kinship-title.entity.d.ts +18 -0
- package/dist/entities/kinship-title.entity.d.ts.map +1 -0
- package/dist/entities/kinship-title.entity.js +59 -0
- package/dist/entities/kinship-title.entity.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +33 -0
- package/dist/index.js.map +1 -0
- package/dist/kinship-calc.module.d.ts +9 -0
- package/dist/kinship-calc.module.d.ts.map +1 -0
- package/dist/kinship-calc.module.js +45 -0
- package/dist/kinship-calc.module.js.map +1 -0
- package/dist/schemas/kinship.schema.d.ts +129 -0
- package/dist/schemas/kinship.schema.d.ts.map +1 -0
- package/dist/schemas/kinship.schema.js +50 -0
- package/dist/schemas/kinship.schema.js.map +1 -0
- package/dist/services/family-tree.service.d.ts +38 -0
- package/dist/services/family-tree.service.d.ts.map +1 -0
- package/dist/services/family-tree.service.js +179 -0
- package/dist/services/family-tree.service.js.map +1 -0
- package/dist/services/kinship-calc.service.d.ts +26 -0
- package/dist/services/kinship-calc.service.d.ts.map +1 -0
- package/dist/services/kinship-calc.service.js +66 -0
- package/dist/services/kinship-calc.service.js.map +1 -0
- package/package.json +60 -0
- package/src/controllers/family-tree.controller.ts +102 -0
- package/src/controllers/kinship-calc.controller.ts +50 -0
- package/src/engine/index.ts +6 -0
- package/src/engine/kinship-data.ts +188 -0
- package/src/engine/kinship-engine.ts +230 -0
- package/src/engine/relation-path.ts +63 -0
- package/src/entities/family-tree-person.entity.ts +37 -0
- package/src/entities/family-tree-relation.entity.ts +36 -0
- package/src/entities/family-tree.entity.ts +28 -0
- package/src/entities/index.ts +18 -0
- package/src/entities/kinship-title.entity.ts +38 -0
- package/src/index.ts +33 -0
- package/src/kinship-calc.module.ts +51 -0
- package/src/schemas/kinship.schema.ts +70 -0
- package/src/services/family-tree.service.ts +211 -0
- package/src/services/kinship-calc.service.ts +68 -0
- package/tsconfig.build.json +4 -0
- package/tsconfig.json +10 -0
- package/web/components/FamilyTreeCanvas.tsx +177 -0
- package/web/components/FamilyTreeDialogs.tsx +275 -0
- package/web/components/KinshipResultCard.tsx +98 -0
- package/web/components/RegionSelector.tsx +32 -0
- package/web/components/RelationChainBuilder.tsx +104 -0
- package/web/index.ts +29 -0
- package/web/manifest.ts +24 -0
- package/web/messages/en-US.json +108 -0
- package/web/messages/zh-CN.json +108 -0
- package/web/pages/FamilyTreePage.tsx +240 -0
- package/web/pages/KinshipCalcPage.tsx +140 -0
- package/web/services/kinship-service.ts +140 -0
- package/web/stores/kinship-store.ts +80 -0
- 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'
|
package/web/manifest.ts
ADDED
|
@@ -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
|
+
}
|