@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,108 @@
|
|
|
1
|
+
{
|
|
2
|
+
"nav": {
|
|
3
|
+
"kinshipCalc": {
|
|
4
|
+
"title": "辈分计算",
|
|
5
|
+
"description": "亲属关系与称呼计算",
|
|
6
|
+
"pages": {
|
|
7
|
+
"calc": "称呼计算器",
|
|
8
|
+
"familyTree": "家谱管理"
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"kinshipCalc": {
|
|
13
|
+
"title": "辈分计算器",
|
|
14
|
+
"description": "亲属关系与称呼计算",
|
|
15
|
+
"pages": {
|
|
16
|
+
"calc": "称呼计算器",
|
|
17
|
+
"familyTree": "家谱管理"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"kinship": {
|
|
21
|
+
"calc": {
|
|
22
|
+
"title": "亲属称呼计算器",
|
|
23
|
+
"subtitle": "输入关系链,自动计算正确称呼"
|
|
24
|
+
},
|
|
25
|
+
"settings": {
|
|
26
|
+
"title": "计算设置",
|
|
27
|
+
"egoGender": "我的性别",
|
|
28
|
+
"region": "方言地区"
|
|
29
|
+
},
|
|
30
|
+
"chain": {
|
|
31
|
+
"title": "关系链",
|
|
32
|
+
"hint": "依次点击关系按钮,构建从「我」到「对方」的关系链",
|
|
33
|
+
"empty": "暂未选择关系,请点击下方按钮",
|
|
34
|
+
"undo": "撤销",
|
|
35
|
+
"clear": "清空"
|
|
36
|
+
},
|
|
37
|
+
"calculate": "计算称呼",
|
|
38
|
+
"calculating": "计算中…",
|
|
39
|
+
"selectRegion": "选择方言",
|
|
40
|
+
"regions": {
|
|
41
|
+
"standard": "普通话(标准)",
|
|
42
|
+
"northern": "北方方言",
|
|
43
|
+
"southern": "南方官话",
|
|
44
|
+
"cantonese": "粤语",
|
|
45
|
+
"minnan": "闽南语",
|
|
46
|
+
"wu": "吴语"
|
|
47
|
+
},
|
|
48
|
+
"gender": {
|
|
49
|
+
"male": "男",
|
|
50
|
+
"female": "女",
|
|
51
|
+
"unknown": "未知"
|
|
52
|
+
},
|
|
53
|
+
"generation": {
|
|
54
|
+
"elder": "长 {n} 辈",
|
|
55
|
+
"younger": "小 {n} 辈",
|
|
56
|
+
"same": "同辈"
|
|
57
|
+
},
|
|
58
|
+
"result": {
|
|
59
|
+
"title": "称呼结果",
|
|
60
|
+
"standard": "标准称呼",
|
|
61
|
+
"reverseTitle": "对方叫我",
|
|
62
|
+
"variants": "各地叫法",
|
|
63
|
+
"exact": "精确匹配",
|
|
64
|
+
"estimated": "估算结果"
|
|
65
|
+
},
|
|
66
|
+
"examples": {
|
|
67
|
+
"title": "常用示例"
|
|
68
|
+
},
|
|
69
|
+
"tree": {
|
|
70
|
+
"title": "家谱管理",
|
|
71
|
+
"subtitle": "可视化家谱,点击两人计算称呼关系",
|
|
72
|
+
"new": "新建家谱",
|
|
73
|
+
"select": "选择家谱",
|
|
74
|
+
"newTitle": "新建家谱",
|
|
75
|
+
"nameLabel": "家谱名称",
|
|
76
|
+
"namePlaceholder": "例如:张家族谱",
|
|
77
|
+
"addPerson": "添加成员",
|
|
78
|
+
"addRelation": "添加关系",
|
|
79
|
+
"addPersonTitle": "添加家族成员",
|
|
80
|
+
"addRelationTitle": "添加亲属关系",
|
|
81
|
+
"relations": "已建立的关系",
|
|
82
|
+
"emptyHint": "暂无成员,请点击「添加成员」",
|
|
83
|
+
"selectSecondPerson": "已选中一人,请点击另一人以计算称呼关系",
|
|
84
|
+
"deleteConfirm": "确定删除此家谱?此操作不可撤销。",
|
|
85
|
+
"deleted": "家谱已删除"
|
|
86
|
+
},
|
|
87
|
+
"person": {
|
|
88
|
+
"name": "姓名",
|
|
89
|
+
"gender": "性别",
|
|
90
|
+
"birthYear": "出生年份",
|
|
91
|
+
"isRoot": "设为「我」(参照人)"
|
|
92
|
+
},
|
|
93
|
+
"relation": {
|
|
94
|
+
"from": "从(谁)",
|
|
95
|
+
"to": "到(谁)",
|
|
96
|
+
"type": "关系类型",
|
|
97
|
+
"selectPerson": "选择成员"
|
|
98
|
+
},
|
|
99
|
+
"common": {
|
|
100
|
+
"save": "保存",
|
|
101
|
+
"cancel": "取消"
|
|
102
|
+
},
|
|
103
|
+
"error": {
|
|
104
|
+
"calculateFailed": "计算失败,请检查关系链",
|
|
105
|
+
"loadFailed": "加载失败,请刷新重试"
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect, 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@octo-cyber/ui/components/ui/select'
|
|
9
|
+
import { Badge } from '@octo-cyber/ui/components/ui/badge'
|
|
10
|
+
import { TreePine, Plus, Trash2 } from 'lucide-react'
|
|
11
|
+
import { kinshipService } from '../services/kinship-service'
|
|
12
|
+
import { FamilyTreeCanvas } from '../components/FamilyTreeCanvas'
|
|
13
|
+
import { KinshipResultCard } from '../components/KinshipResultCard'
|
|
14
|
+
import { RegionSelector } from '../components/RegionSelector'
|
|
15
|
+
import { NewTreeDialog, AddPersonDialog, EditPersonDialog, AddRelationDialog } from '../components/FamilyTreeDialogs'
|
|
16
|
+
import type { FamilyTree, FamilyTreeGraph, FamilyTreeRelation, KinshipResult, Region } from '../types/kinship'
|
|
17
|
+
import { ATOM_LABELS } from '../types/kinship'
|
|
18
|
+
|
|
19
|
+
export default function FamilyTreePage() {
|
|
20
|
+
const t = useTranslations('kinship')
|
|
21
|
+
const [trees, setTrees] = useState<FamilyTree[]>([])
|
|
22
|
+
const [selectedTreeId, setSelectedTreeId] = useState<number | null>(null)
|
|
23
|
+
const [graph, setGraph] = useState<FamilyTreeGraph | null>(null)
|
|
24
|
+
const [selectedPersonId, setSelectedPersonId] = useState<number | null>(null)
|
|
25
|
+
const [resolveResult, setResolveResult] = useState<KinshipResult | null>(null)
|
|
26
|
+
const [region, setRegion] = useState<Region>('standard')
|
|
27
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
28
|
+
|
|
29
|
+
const [showNewTree, setShowNewTree] = useState(false)
|
|
30
|
+
const [showAddPerson, setShowAddPerson] = useState(false)
|
|
31
|
+
const [showAddRelation, setShowAddRelation] = useState(false)
|
|
32
|
+
const [editPersonId, setEditPersonId] = useState<number | null>(null)
|
|
33
|
+
|
|
34
|
+
const loadGraph = useCallback(async (id: number) => {
|
|
35
|
+
setIsLoading(true)
|
|
36
|
+
try {
|
|
37
|
+
const g = await kinshipService.getTree(id)
|
|
38
|
+
setGraph(g)
|
|
39
|
+
} catch {
|
|
40
|
+
toast.error(t('error.loadFailed'))
|
|
41
|
+
} finally {
|
|
42
|
+
setIsLoading(false)
|
|
43
|
+
}
|
|
44
|
+
}, [t])
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
kinshipService.listTrees()
|
|
48
|
+
.then((list) => { setTrees(list); if (list.length > 0) setSelectedTreeId(list[0].id) })
|
|
49
|
+
.catch(() => toast.error(t('error.loadFailed')))
|
|
50
|
+
}, [t])
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (selectedTreeId) loadGraph(selectedTreeId)
|
|
54
|
+
else setGraph(null)
|
|
55
|
+
}, [selectedTreeId, loadGraph])
|
|
56
|
+
|
|
57
|
+
async function handleResolveKinship(fromId: number, toId: number) {
|
|
58
|
+
if (!selectedTreeId) return
|
|
59
|
+
try {
|
|
60
|
+
const res = await kinshipService.resolveKinship({ treeId: selectedTreeId, fromPersonId: fromId, toPersonId: toId, region })
|
|
61
|
+
setResolveResult(res.result)
|
|
62
|
+
toast.success(`已找到关系:${res.path}`)
|
|
63
|
+
} catch (err) {
|
|
64
|
+
toast.error((err as Error).message)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function handleDeleteTree() {
|
|
69
|
+
if (!selectedTreeId || !confirm(t('tree.deleteConfirm'))) return
|
|
70
|
+
try {
|
|
71
|
+
await kinshipService.deleteTree(selectedTreeId)
|
|
72
|
+
toast.success(t('tree.deleted'))
|
|
73
|
+
const list = await kinshipService.listTrees()
|
|
74
|
+
setTrees(list)
|
|
75
|
+
setSelectedTreeId(list[0]?.id ?? null)
|
|
76
|
+
setGraph(null)
|
|
77
|
+
setResolveResult(null)
|
|
78
|
+
} catch (err) {
|
|
79
|
+
toast.error((err as Error).message)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function handleDeleteRelation(rel: FamilyTreeRelation) {
|
|
84
|
+
if (!selectedTreeId) return
|
|
85
|
+
try {
|
|
86
|
+
await kinshipService.deleteRelation(rel.id, selectedTreeId)
|
|
87
|
+
toast.success('已删除关系')
|
|
88
|
+
loadGraph(selectedTreeId)
|
|
89
|
+
} catch (err) {
|
|
90
|
+
toast.error((err as Error).message)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const editPerson = graph?.persons.find((p) => p.id === editPersonId) ?? null
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<div className="mx-auto max-w-5xl space-y-6 p-6">
|
|
98
|
+
{/* Header */}
|
|
99
|
+
<div className="flex items-center justify-between">
|
|
100
|
+
<div className="flex items-center gap-3">
|
|
101
|
+
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 dark:bg-primary/20">
|
|
102
|
+
<TreePine className="h-5 w-5 text-primary" />
|
|
103
|
+
</div>
|
|
104
|
+
<div>
|
|
105
|
+
<h1 className="text-2xl font-bold">{t('tree.title')}</h1>
|
|
106
|
+
<p className="text-sm text-muted-foreground">{t('tree.subtitle')}</p>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
<Button size="sm" onClick={() => setShowNewTree(true)} className="gap-1">
|
|
110
|
+
<Plus className="h-4 w-4" />{t('tree.new')}
|
|
111
|
+
</Button>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
{/* Toolbar */}
|
|
115
|
+
<div className="flex flex-wrap items-center gap-3">
|
|
116
|
+
<Select
|
|
117
|
+
value={selectedTreeId?.toString() ?? ''}
|
|
118
|
+
onValueChange={(v) => { setSelectedTreeId(Number(v)); setResolveResult(null) }}
|
|
119
|
+
>
|
|
120
|
+
<SelectTrigger className="w-48">
|
|
121
|
+
<SelectValue placeholder={t('tree.select')} />
|
|
122
|
+
</SelectTrigger>
|
|
123
|
+
<SelectContent>
|
|
124
|
+
{trees.map((tree) => (
|
|
125
|
+
<SelectItem key={tree.id} value={tree.id.toString()}>{tree.name}</SelectItem>
|
|
126
|
+
))}
|
|
127
|
+
</SelectContent>
|
|
128
|
+
</Select>
|
|
129
|
+
|
|
130
|
+
<RegionSelector value={region} onChange={setRegion} className="w-40" />
|
|
131
|
+
|
|
132
|
+
{selectedTreeId && (
|
|
133
|
+
<>
|
|
134
|
+
<Button size="sm" variant="outline" onClick={() => setShowAddPerson(true)} className="gap-1">
|
|
135
|
+
<Plus className="h-3.5 w-3.5" />{t('tree.addPerson')}
|
|
136
|
+
</Button>
|
|
137
|
+
<Button size="sm" variant="outline" onClick={() => setShowAddRelation(true)} className="gap-1">
|
|
138
|
+
<Plus className="h-3.5 w-3.5" />{t('tree.addRelation')}
|
|
139
|
+
</Button>
|
|
140
|
+
<Button size="sm" variant="ghost" className="ml-auto text-destructive hover:text-destructive" onClick={handleDeleteTree}>
|
|
141
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
142
|
+
</Button>
|
|
143
|
+
</>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
|
|
147
|
+
{/* Loading */}
|
|
148
|
+
{isLoading && (
|
|
149
|
+
<div className="flex h-40 items-center justify-center text-sm text-muted-foreground">加载中…</div>
|
|
150
|
+
)}
|
|
151
|
+
|
|
152
|
+
{/* Tree card */}
|
|
153
|
+
{!isLoading && graph && (
|
|
154
|
+
<Card className="dark:bg-zinc-900">
|
|
155
|
+
<CardHeader className="pb-3">
|
|
156
|
+
<CardTitle className="text-base">{graph.tree.name}</CardTitle>
|
|
157
|
+
</CardHeader>
|
|
158
|
+
<CardContent className="space-y-4">
|
|
159
|
+
<FamilyTreeCanvas
|
|
160
|
+
graph={graph}
|
|
161
|
+
selectedPersonId={selectedPersonId}
|
|
162
|
+
onSelectPerson={setSelectedPersonId}
|
|
163
|
+
onResolveKinship={handleResolveKinship}
|
|
164
|
+
onEditPerson={(id) => setEditPersonId(id)}
|
|
165
|
+
onDeletePerson={async (id) => {
|
|
166
|
+
if (!selectedTreeId || !confirm('确定删除该成员?')) return
|
|
167
|
+
try {
|
|
168
|
+
await kinshipService.deletePerson(id, selectedTreeId)
|
|
169
|
+
toast.success('已删除成员')
|
|
170
|
+
loadGraph(selectedTreeId)
|
|
171
|
+
} catch (err) { toast.error((err as Error).message) }
|
|
172
|
+
}}
|
|
173
|
+
/>
|
|
174
|
+
|
|
175
|
+
{/* Relations with inline delete */}
|
|
176
|
+
{graph.relations.length > 0 && (
|
|
177
|
+
<div className="space-y-1.5">
|
|
178
|
+
<p className="text-xs font-medium text-muted-foreground">{t('tree.relations')}</p>
|
|
179
|
+
<div className="flex flex-wrap gap-1.5">
|
|
180
|
+
{graph.relations.map((rel) => {
|
|
181
|
+
const from = graph.persons.find((p) => p.id === rel.fromPersonId)
|
|
182
|
+
const to = graph.persons.find((p) => p.id === rel.toPersonId)
|
|
183
|
+
const label = ATOM_LABELS[rel.relationType as keyof typeof ATOM_LABELS] ?? rel.relationType
|
|
184
|
+
return (
|
|
185
|
+
<Badge key={rel.id} variant="secondary" className="group cursor-default gap-1 pr-1 text-xs">
|
|
186
|
+
{from?.name} ↔ {label} → {to?.name}
|
|
187
|
+
<button
|
|
188
|
+
onClick={() => handleDeleteRelation(rel)}
|
|
189
|
+
className="ml-0.5 rounded opacity-0 group-hover:opacity-100 hover:text-destructive"
|
|
190
|
+
title="删除关系"
|
|
191
|
+
>×</button>
|
|
192
|
+
</Badge>
|
|
193
|
+
)
|
|
194
|
+
})}
|
|
195
|
+
</div>
|
|
196
|
+
</div>
|
|
197
|
+
)}
|
|
198
|
+
</CardContent>
|
|
199
|
+
</Card>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
{resolveResult && <KinshipResultCard result={resolveResult} currentRegion={region} />}
|
|
203
|
+
|
|
204
|
+
{/* Dialogs */}
|
|
205
|
+
<NewTreeDialog
|
|
206
|
+
open={showNewTree}
|
|
207
|
+
onClose={() => setShowNewTree(false)}
|
|
208
|
+
onCreated={(tree) => { setTrees((prev) => [...prev, tree]); setSelectedTreeId(tree.id); setShowNewTree(false) }}
|
|
209
|
+
/>
|
|
210
|
+
|
|
211
|
+
{graph && (
|
|
212
|
+
<AddPersonDialog
|
|
213
|
+
open={showAddPerson}
|
|
214
|
+
treeId={graph.tree.id}
|
|
215
|
+
onClose={() => setShowAddPerson(false)}
|
|
216
|
+
onAdded={() => { loadGraph(graph.tree.id); setShowAddPerson(false) }}
|
|
217
|
+
/>
|
|
218
|
+
)}
|
|
219
|
+
|
|
220
|
+
{graph && (
|
|
221
|
+
<AddRelationDialog
|
|
222
|
+
open={showAddRelation}
|
|
223
|
+
graph={graph}
|
|
224
|
+
onClose={() => setShowAddRelation(false)}
|
|
225
|
+
onAdded={() => { loadGraph(graph.tree.id); setShowAddRelation(false) }}
|
|
226
|
+
/>
|
|
227
|
+
)}
|
|
228
|
+
|
|
229
|
+
{editPerson && graph && (
|
|
230
|
+
<EditPersonDialog
|
|
231
|
+
open={editPersonId !== null}
|
|
232
|
+
person={editPerson}
|
|
233
|
+
treeId={graph.tree.id}
|
|
234
|
+
onClose={() => setEditPersonId(null)}
|
|
235
|
+
onSaved={() => { loadGraph(graph.tree.id); setEditPersonId(null) }}
|
|
236
|
+
/>
|
|
237
|
+
)}
|
|
238
|
+
</div>
|
|
239
|
+
)
|
|
240
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useTranslations } from 'next-intl'
|
|
4
|
+
import { toast } from 'sonner'
|
|
5
|
+
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@octo-cyber/ui/components/ui/card'
|
|
6
|
+
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@octo-cyber/ui/components/ui/select'
|
|
7
|
+
import { Label } from '@octo-cyber/ui/components/ui/label'
|
|
8
|
+
import { Users } from 'lucide-react'
|
|
9
|
+
import { useKinshipStore } from '../stores/kinship-store'
|
|
10
|
+
import { RelationChainBuilder } from '../components/RelationChainBuilder'
|
|
11
|
+
import { KinshipResultCard } from '../components/KinshipResultCard'
|
|
12
|
+
import { RegionSelector } from '../components/RegionSelector'
|
|
13
|
+
import type { Region } from '../types/kinship'
|
|
14
|
+
|
|
15
|
+
export default function KinshipCalcPage() {
|
|
16
|
+
const t = useTranslations('kinship')
|
|
17
|
+
const {
|
|
18
|
+
result, error, region, egoGender,
|
|
19
|
+
setRegion, setEgoGender, calculate,
|
|
20
|
+
} = useKinshipStore()
|
|
21
|
+
|
|
22
|
+
const handleCalculate = async () => {
|
|
23
|
+
try {
|
|
24
|
+
await calculate()
|
|
25
|
+
} catch {
|
|
26
|
+
toast.error(t('error.calculateFailed'))
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="mx-auto max-w-3xl space-y-6 p-6">
|
|
32
|
+
{/* Header */}
|
|
33
|
+
<div className="flex items-center gap-3">
|
|
34
|
+
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10 dark:bg-primary/20">
|
|
35
|
+
<Users className="h-5 w-5 text-primary" />
|
|
36
|
+
</div>
|
|
37
|
+
<div>
|
|
38
|
+
<h1 className="text-2xl font-bold">{t('calc.title')}</h1>
|
|
39
|
+
<p className="text-sm text-muted-foreground">{t('calc.subtitle')}</p>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
{/* Settings */}
|
|
44
|
+
<Card className="dark:bg-zinc-900">
|
|
45
|
+
<CardHeader className="pb-3">
|
|
46
|
+
<CardTitle className="text-base">{t('settings.title')}</CardTitle>
|
|
47
|
+
</CardHeader>
|
|
48
|
+
<CardContent className="flex flex-wrap gap-4">
|
|
49
|
+
<div className="flex items-center gap-2">
|
|
50
|
+
<Label className="shrink-0 text-sm">{t('settings.egoGender')}</Label>
|
|
51
|
+
<Select value={egoGender} onValueChange={(v) => setEgoGender(v as 'male' | 'female')}>
|
|
52
|
+
<SelectTrigger className="w-24">
|
|
53
|
+
<SelectValue />
|
|
54
|
+
</SelectTrigger>
|
|
55
|
+
<SelectContent>
|
|
56
|
+
<SelectItem value="male">{t('gender.male')}</SelectItem>
|
|
57
|
+
<SelectItem value="female">{t('gender.female')}</SelectItem>
|
|
58
|
+
</SelectContent>
|
|
59
|
+
</Select>
|
|
60
|
+
</div>
|
|
61
|
+
<div className="flex items-center gap-2">
|
|
62
|
+
<Label className="shrink-0 text-sm">{t('settings.region')}</Label>
|
|
63
|
+
<RegionSelector
|
|
64
|
+
value={region}
|
|
65
|
+
onChange={setRegion}
|
|
66
|
+
className="w-48"
|
|
67
|
+
/>
|
|
68
|
+
</div>
|
|
69
|
+
</CardContent>
|
|
70
|
+
</Card>
|
|
71
|
+
|
|
72
|
+
{/* Chain builder */}
|
|
73
|
+
<Card className="dark:bg-zinc-900">
|
|
74
|
+
<CardHeader className="pb-3">
|
|
75
|
+
<CardTitle className="text-base">{t('chain.title')}</CardTitle>
|
|
76
|
+
<CardDescription>{t('chain.hint')}</CardDescription>
|
|
77
|
+
</CardHeader>
|
|
78
|
+
<CardContent>
|
|
79
|
+
<RelationChainBuilder onCalculate={handleCalculate} />
|
|
80
|
+
</CardContent>
|
|
81
|
+
</Card>
|
|
82
|
+
|
|
83
|
+
{/* Error */}
|
|
84
|
+
{error && (
|
|
85
|
+
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive dark:bg-destructive/20">
|
|
86
|
+
{error}
|
|
87
|
+
</div>
|
|
88
|
+
)}
|
|
89
|
+
|
|
90
|
+
{/* Result */}
|
|
91
|
+
{result && (
|
|
92
|
+
<KinshipResultCard result={result} currentRegion={region} />
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
{/* Quick examples */}
|
|
96
|
+
{!result && (
|
|
97
|
+
<QuickExamples onSelect={(chain) => {
|
|
98
|
+
useKinshipStore.setState({ chain })
|
|
99
|
+
}} />
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const QUICK_EXAMPLES = [
|
|
106
|
+
{ label: '爷爷(父父)', chain: ['F', 'F'] },
|
|
107
|
+
{ label: '外公(母父)', chain: ['M', 'F'] },
|
|
108
|
+
{ label: '伯父(父哥哥)', chain: ['F', 'B+'] },
|
|
109
|
+
{ label: '叔叔(父弟弟)', chain: ['F', 'B-'] },
|
|
110
|
+
{ label: '舅舅(母兄弟)', chain: ['M', 'B-'] },
|
|
111
|
+
{ label: '姑姑(父妹妹)', chain: ['F', 'Z-'] },
|
|
112
|
+
{ label: '阿姨(母妹妹)', chain: ['M', 'Z-'] },
|
|
113
|
+
{ label: '堂哥(伯父之子)', chain: ['F', 'B+', 'S'] },
|
|
114
|
+
{ label: '表姐(舅父之女)', chain: ['M', 'B+', 'D'] },
|
|
115
|
+
{ label: '岳父(妻父)', chain: ['W', 'F'] },
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
function QuickExamples({ onSelect }: { onSelect: (chain: string[]) => void }) {
|
|
119
|
+
const t = useTranslations('kinship')
|
|
120
|
+
return (
|
|
121
|
+
<Card className="dark:bg-zinc-900">
|
|
122
|
+
<CardHeader className="pb-3">
|
|
123
|
+
<CardTitle className="text-base">{t('examples.title')}</CardTitle>
|
|
124
|
+
</CardHeader>
|
|
125
|
+
<CardContent>
|
|
126
|
+
<div className="flex flex-wrap gap-2">
|
|
127
|
+
{QUICK_EXAMPLES.map((ex) => (
|
|
128
|
+
<button
|
|
129
|
+
key={ex.label}
|
|
130
|
+
onClick={() => onSelect(ex.chain)}
|
|
131
|
+
className="rounded-full border px-3 py-1 text-sm transition-colors hover:bg-accent dark:border-zinc-700 dark:hover:bg-zinc-700"
|
|
132
|
+
>
|
|
133
|
+
{ex.label}
|
|
134
|
+
</button>
|
|
135
|
+
))}
|
|
136
|
+
</div>
|
|
137
|
+
</CardContent>
|
|
138
|
+
</Card>
|
|
139
|
+
)
|
|
140
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
KinshipResult,
|
|
3
|
+
RelationAtom,
|
|
4
|
+
FamilyTree,
|
|
5
|
+
FamilyTreeGraph,
|
|
6
|
+
FamilyTreePerson,
|
|
7
|
+
FamilyTreeRelation,
|
|
8
|
+
ResolveResult,
|
|
9
|
+
Region,
|
|
10
|
+
EgoGender,
|
|
11
|
+
} from '../types/kinship'
|
|
12
|
+
|
|
13
|
+
const BASE = '/api/v1/kinship'
|
|
14
|
+
|
|
15
|
+
async function request<T>(url: string, init?: RequestInit): Promise<T> {
|
|
16
|
+
const res = await fetch(url, {
|
|
17
|
+
headers: { 'Content-Type': 'application/json' },
|
|
18
|
+
credentials: 'include',
|
|
19
|
+
...init,
|
|
20
|
+
})
|
|
21
|
+
const body = await res.json()
|
|
22
|
+
if (!res.ok) throw new Error(body?.message ?? body?.error ?? '请求失败')
|
|
23
|
+
return body.data as T
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const kinshipService = {
|
|
27
|
+
calculate(path: string, region: Region = 'standard', egoGender: EgoGender = 'male'): Promise<KinshipResult> {
|
|
28
|
+
return request<KinshipResult>(`${BASE}/calculate`, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
body: JSON.stringify({ path, region, egoGender }),
|
|
31
|
+
})
|
|
32
|
+
},
|
|
33
|
+
|
|
34
|
+
calculateFromChain(chain: string[], region: Region = 'standard', egoGender: EgoGender = 'male'): Promise<KinshipResult> {
|
|
35
|
+
return request<KinshipResult>(`${BASE}/chain`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
body: JSON.stringify({ chain, region, egoGender }),
|
|
38
|
+
})
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
checkConflict(paths: string[]): Promise<{ conflict: boolean; message: string | null }> {
|
|
42
|
+
return request(`${BASE}/conflict-check`, {
|
|
43
|
+
method: 'POST',
|
|
44
|
+
body: JSON.stringify({ paths }),
|
|
45
|
+
})
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
getAtoms(): Promise<RelationAtom[]> {
|
|
49
|
+
return request<RelationAtom[]>(`${BASE}/atoms`)
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
listTitles(region: Region = 'standard'): Promise<Array<{ path: string; title: string; generationDelta: number }>> {
|
|
53
|
+
return request(`${BASE}/titles?region=${region}`)
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// ── Family tree ──────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
listTrees(): Promise<FamilyTree[]> {
|
|
59
|
+
return request<FamilyTree[]>(`${BASE}/trees`)
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
getTree(id: number): Promise<FamilyTreeGraph> {
|
|
63
|
+
return request<FamilyTreeGraph>(`${BASE}/trees/${id}`)
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
createTree(data: { name: string; description?: string }): Promise<FamilyTree> {
|
|
67
|
+
return request<FamilyTree>(`${BASE}/trees`, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
body: JSON.stringify(data),
|
|
70
|
+
})
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
updateTree(id: number, data: { name?: string; description?: string }): Promise<FamilyTree> {
|
|
74
|
+
return request<FamilyTree>(`${BASE}/trees/${id}`, {
|
|
75
|
+
method: 'PUT',
|
|
76
|
+
body: JSON.stringify(data),
|
|
77
|
+
})
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
deleteTree(id: number): Promise<void> {
|
|
81
|
+
return request(`${BASE}/trees/${id}`, { method: 'DELETE' })
|
|
82
|
+
},
|
|
83
|
+
|
|
84
|
+
addPerson(data: {
|
|
85
|
+
treeId: number
|
|
86
|
+
name: string
|
|
87
|
+
gender: 'male' | 'female'
|
|
88
|
+
birthYear?: number
|
|
89
|
+
notes?: string
|
|
90
|
+
isRoot?: boolean
|
|
91
|
+
}): Promise<FamilyTreePerson> {
|
|
92
|
+
return request<FamilyTreePerson>(`${BASE}/persons`, {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
body: JSON.stringify(data),
|
|
95
|
+
})
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
updatePerson(
|
|
99
|
+
id: number,
|
|
100
|
+
treeId: number,
|
|
101
|
+
data: { name?: string; gender?: 'male' | 'female'; birthYear?: number | null; notes?: string | null; isRoot?: boolean },
|
|
102
|
+
): Promise<FamilyTreePerson> {
|
|
103
|
+
return request<FamilyTreePerson>(`${BASE}/persons/${id}?treeId=${treeId}`, {
|
|
104
|
+
method: 'PUT',
|
|
105
|
+
body: JSON.stringify(data),
|
|
106
|
+
})
|
|
107
|
+
},
|
|
108
|
+
|
|
109
|
+
deletePerson(id: number, treeId: number): Promise<void> {
|
|
110
|
+
return request(`${BASE}/persons/${id}?treeId=${treeId}`, { method: 'DELETE' })
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
addRelation(data: {
|
|
114
|
+
treeId: number
|
|
115
|
+
fromPersonId: number
|
|
116
|
+
toPersonId: number
|
|
117
|
+
relationType: string
|
|
118
|
+
}): Promise<FamilyTreeRelation> {
|
|
119
|
+
return request<FamilyTreeRelation>(`${BASE}/relations`, {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
body: JSON.stringify(data),
|
|
122
|
+
})
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
deleteRelation(id: number, treeId: number): Promise<void> {
|
|
126
|
+
return request(`${BASE}/relations/${id}?treeId=${treeId}`, { method: 'DELETE' })
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
resolveKinship(data: {
|
|
130
|
+
treeId: number
|
|
131
|
+
fromPersonId: number
|
|
132
|
+
toPersonId: number
|
|
133
|
+
region?: Region
|
|
134
|
+
}): Promise<ResolveResult> {
|
|
135
|
+
return request<ResolveResult>(`${BASE}/resolve`, {
|
|
136
|
+
method: 'POST',
|
|
137
|
+
body: JSON.stringify(data),
|
|
138
|
+
})
|
|
139
|
+
},
|
|
140
|
+
}
|