This commit is contained in:
freedakgmail
2025-11-22 23:49:24 +08:00
parent b83382b604
commit c033e46782
3580 changed files with 1745279 additions and 2428 deletions
+352
View File
@@ -0,0 +1,352 @@
"use client"
import { useEffect, useRef } from "react"
import * as echarts from "echarts"
import type { FamilyMember } from "@/types/family"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
interface StatisticsChartsProps {
members: FamilyMember[]
}
export function StatisticsCharts({ members }: StatisticsChartsProps) {
const genderChartRef = useRef<HTMLDivElement>(null)
const generationChartRef = useRef<HTMLDivElement>(null)
const ageDistributionRef = useRef<HTMLDivElement>(null)
const birthYearChartRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!genderChartRef.current || members.length === 0) return
// 性别分布饼图
const genderChart = echarts.init(genderChartRef.current)
const genderData = {
male: members.filter(m => m.gender === 'male').length,
female: members.filter(m => m.gender === 'female').length,
other: members.filter(m => m.gender === 'other').length,
}
genderChart.setOption({
title: {
text: '性别分布',
left: 'center',
top: 10,
textStyle: {
fontSize: 14,
fontWeight: 'normal'
}
},
tooltip: {
trigger: 'item',
formatter: '{b}: {c} 人 ({d}%)'
},
legend: {
bottom: 10,
left: 'center'
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '50%'],
avoidLabelOverlap: false,
itemStyle: {
borderRadius: 10,
borderColor: '#fff',
borderWidth: 2
},
label: {
show: false
},
emphasis: {
label: {
show: true,
fontSize: 16,
fontWeight: 'bold'
}
},
data: [
{ value: genderData.male, name: '男性', itemStyle: { color: '#3b82f6' } },
{ value: genderData.female, name: '女性', itemStyle: { color: '#ec4899' } },
...(genderData.other > 0 ? [{ value: genderData.other, name: '其他', itemStyle: { color: '#8b5cf6' } }] : [])
]
}
]
})
return () => {
genderChart.dispose()
}
}, [members])
useEffect(() => {
if (!generationChartRef.current || members.length === 0) return
// 世代分布柱状图
const generationChart = echarts.init(generationChartRef.current)
const generationData = new Map<number, number>()
members.forEach(m => {
const gen = m.generation || 0
generationData.set(gen, (generationData.get(gen) || 0) + 1)
})
const sortedGenerations = Array.from(generationData.entries()).sort((a, b) => a[0] - b[0])
generationChart.setOption({
title: {
text: '世代分布',
left: 'center',
top: 10,
textStyle: {
fontSize: 14,
fontWeight: 'normal'
}
},
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
},
formatter: '{b}世: {c} 人'
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: sortedGenerations.map(([gen]) => `${gen}`),
axisLabel: {
rotate: 45
}
},
yAxis: {
type: 'value',
name: '人数'
},
series: [
{
type: 'bar',
data: sortedGenerations.map(([, count]) => count),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#83bff6' },
{ offset: 0.5, color: '#188df0' },
{ offset: 1, color: '#188df0' }
])
},
emphasis: {
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#2378f7' },
{ offset: 0.7, color: '#2378f7' },
{ offset: 1, color: '#83bff6' }
])
}
}
}
]
})
return () => {
generationChart.dispose()
}
}, [members])
useEffect(() => {
if (!ageDistributionRef.current || members.length === 0) return
// 年龄分布图
const ageChart = echarts.init(ageDistributionRef.current)
const currentYear = new Date().getFullYear()
const ageGroups = {
'0-18': 0,
'19-35': 0,
'36-50': 0,
'51-65': 0,
'66+': 0,
'已故': 0
}
members.forEach(m => {
if (m.deathDate) {
ageGroups['已故']++
} else if (m.birthDate) {
const age = currentYear - new Date(m.birthDate).getFullYear()
if (age <= 18) ageGroups['0-18']++
else if (age <= 35) ageGroups['19-35']++
else if (age <= 50) ageGroups['36-50']++
else if (age <= 65) ageGroups['51-65']++
else ageGroups['66+']++
}
})
ageChart.setOption({
title: {
text: '年龄分布',
left: 'center',
top: 10,
textStyle: {
fontSize: 14,
fontWeight: 'normal'
}
},
tooltip: {
trigger: 'item',
formatter: '{b}: {c} 人 ({d}%)'
},
legend: {
bottom: 10,
left: 'center'
},
series: [
{
type: 'pie',
radius: '60%',
center: ['50%', '50%'],
data: Object.entries(ageGroups)
.filter(([, count]) => count > 0)
.map(([name, value]) => ({ name, value })),
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}
]
})
return () => {
ageChart.dispose()
}
}, [members])
useEffect(() => {
if (!birthYearChartRef.current || members.length === 0) return
// 出生年份趋势图
const birthYearChart = echarts.init(birthYearChartRef.current)
const birthYearData = new Map<number, number>()
members.forEach(m => {
if (m.birthDate) {
const year = new Date(m.birthDate).getFullYear()
if (year >= 1900) { // 只统计1900年以后的
const decade = Math.floor(year / 10) * 10
birthYearData.set(decade, (birthYearData.get(decade) || 0) + 1)
}
}
})
const sortedYears = Array.from(birthYearData.entries()).sort((a, b) => a[0] - b[0])
birthYearChart.setOption({
title: {
text: '出生年代分布',
left: 'center',
top: 10,
textStyle: {
fontSize: 14,
fontWeight: 'normal'
}
},
tooltip: {
trigger: 'axis',
formatter: '{b}年代: {c} 人'
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
top: '15%',
containLabel: true
},
xAxis: {
type: 'category',
data: sortedYears.map(([year]) => `${year}s`),
axisLabel: {
rotate: 45
}
},
yAxis: {
type: 'value',
name: '人数'
},
series: [
{
type: 'line',
data: sortedYears.map(([, count]) => count),
smooth: true,
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(99, 102, 241, 0.5)' },
{ offset: 1, color: 'rgba(99, 102, 241, 0.1)' }
])
},
lineStyle: {
color: '#6366f1',
width: 2
},
itemStyle: {
color: '#6366f1'
}
}
]
})
return () => {
birthYearChart.dispose()
}
}, [members])
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div ref={genderChartRef} className="h-[300px]" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div ref={generationChartRef} className="h-[300px]" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div ref={ageDistributionRef} className="h-[300px]" />
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div ref={birthYearChartRef} className="h-[300px]" />
</CardContent>
</Card>
</div>
)
}
+126 -2
View File
@@ -9,9 +9,11 @@ import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import { CalendarIcon, User, Scroll, Users } from "lucide-react"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { CalendarIcon, User, Scroll, Users, Images, BookOpen } from "lucide-react"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { ImageUpload } from "@/components/ui/image-upload"
import { PhotoGallery } from "./photo-gallery"
import { StoryManager } from "./story-manager"
interface MemberFormProps {
initialData?: Partial<FamilyMember>
@@ -110,6 +112,42 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
}
}
// 4. Circular Reference Detection (循环引用检测)
if (formData.fatherId || formData.motherId) {
const visited = new Set<string>()
const checkCircular = (memberId: string): boolean => {
if (memberId === formData.id) return true
if (visited.has(memberId)) return false
visited.add(memberId)
const member = existingMembers.find((m) => m.id === memberId)
if (!member) return false
if (member.fatherId && checkCircular(member.fatherId)) return true
if (member.motherId && checkCircular(member.motherId)) return true
return false
}
if (formData.fatherId && checkCircular(formData.fatherId)) {
newErrors.push("检测到循环引用:不能将自己的后代设置为父亲")
}
if (formData.motherId && checkCircular(formData.motherId)) {
newErrors.push("检测到循环引用:不能将自己的后代设置为母亲")
}
}
// 5. Self-Reference Detection (自我引用检测)
if (formData.fatherId === formData.id) {
newErrors.push("不能将自己设置为自己的父亲")
}
if (formData.motherId === formData.id) {
newErrors.push("不能将自己设置为自己的母亲")
}
if (formData.spouseIds?.includes(formData.id || "")) {
newErrors.push("不能将自己设置为自己的配偶")
}
setErrors(newErrors)
return newErrors.length === 0
}
@@ -297,6 +335,58 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
</CardContent>
</Card>
{/* Contact Information */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-serif">
<User className="h-5 w-5" />
(Contact Information)
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="space-y-2">
<Label htmlFor="phone"> (Mobile Phone)</Label>
<Input
id="phone"
type="tel"
value={formData.phone || ""}
onChange={(e) => handleChange("phone", e.target.value)}
placeholder="e.g. 13800138000"
/>
</div>
<div className="space-y-2">
<Label htmlFor="telephone"> (Telephone)</Label>
<Input
id="telephone"
type="tel"
value={formData.telephone || ""}
onChange={(e) => handleChange("telephone", e.target.value)}
placeholder="e.g. 0592-1234567"
/>
</div>
<div className="space-y-2">
<Label htmlFor="email"> (Email)</Label>
<Input
id="email"
type="email"
value={formData.email || ""}
onChange={(e) => handleChange("email", e.target.value)}
placeholder="e.g. example@email.com"
/>
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="address"> (Address)</Label>
<Input
id="address"
value={formData.address || ""}
onChange={(e) => handleChange("address", e.target.value)}
placeholder="e.g. 福建省厦门市思明区XX路XX号"
/>
</div>
</CardContent>
</Card>
{/* Biography */}
<Card>
<CardHeader>
@@ -315,6 +405,40 @@ export function MemberForm({ initialData, existingMembers = [], onSubmit, onCanc
</CardContent>
</Card>
{/* Photo Gallery */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-serif">
<Images className="h-5 w-5" />
(Photo Gallery)
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<PhotoGallery
photoIds={formData.photoIds || []}
onChange={(photoIds) => handleChange("photoIds", photoIds)}
/>
</CardContent>
</Card>
{/* Family Stories */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg font-serif">
<BookOpen className="h-5 w-5" />
(Family Stories)
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<StoryManager
stories={formData.stories || []}
onChange={(stories) => handleChange("stories", stories)}
/>
</CardContent>
</Card>
{/* Relationships */}
<Card>
<CardHeader>
+187
View File
@@ -0,0 +1,187 @@
"use client"
import { useState, useEffect } from "react"
import { db } from "@/lib/db"
import { Button } from "@/components/ui/button"
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"
import { X, Plus, ZoomIn } from "lucide-react"
import { v4 as uuidv4 } from "uuid"
import imageCompression from "browser-image-compression"
interface PhotoGalleryProps {
photoIds?: string[]
onChange: (photoIds: string[]) => void
readonly?: boolean
}
export function PhotoGallery({ photoIds = [], onChange, readonly = false }: PhotoGalleryProps) {
const [photos, setPhotos] = useState<{ id: string; url: string }[]>([])
const [selectedPhoto, setSelectedPhoto] = useState<string | null>(null)
const [isUploading, setIsUploading] = useState(false)
// 加载照片
useEffect(() => {
const loadPhotos = async () => {
const loadedPhotos = await Promise.all(
photoIds.map(async (id) => {
try {
const image = await db.images.get(id)
if (image) {
const url = URL.createObjectURL(image.blob)
return { id, url }
}
} catch (error) {
console.error('加载照片失败:', error)
}
return null
})
)
setPhotos(loadedPhotos.filter(Boolean) as { id: string; url: string }[])
}
if (photoIds.length > 0) {
loadPhotos()
}
// 清理 URL
return () => {
photos.forEach(photo => URL.revokeObjectURL(photo.url))
}
}, [photoIds])
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files
if (!files || files.length === 0) return
setIsUploading(true)
try {
const newPhotoIds: string[] = []
for (const file of Array.from(files)) {
if (!file.type.startsWith('image/')) continue
// 压缩图片
const options = {
maxSizeMB: 2,
maxWidthOrHeight: 1920,
useWebWorker: true,
}
const compressedFile = await imageCompression(file, options)
// 保存到数据库
const imageId = uuidv4()
await db.images.add({
id: imageId,
blob: compressedFile,
mimeType: compressedFile.type,
createdAt: new Date().toISOString(),
})
newPhotoIds.push(imageId)
}
// 更新照片列表
onChange([...photoIds, ...newPhotoIds])
} catch (error) {
console.error('上传照片失败:', error)
alert('上传照片失败')
} finally {
setIsUploading(false)
e.target.value = ''
}
}
const handleDelete = (photoId: string) => {
if (confirm('确定要删除这张照片吗?')) {
onChange(photoIds.filter(id => id !== photoId))
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium"> ({photos.length})</h3>
{!readonly && (
<label>
<input
type="file"
multiple
accept="image/*"
className="hidden"
onChange={handleUpload}
disabled={isUploading}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={isUploading}
asChild
>
<span>
<Plus className="mr-2 h-4 w-4" />
{isUploading ? '上传中...' : '添加照片'}
</span>
</Button>
</label>
)}
</div>
{photos.length > 0 ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{photos.map((photo) => (
<div key={photo.id} className="relative group aspect-square">
<img
src={photo.url}
alt="家族照片"
className="w-full h-full object-cover rounded-lg border border-border cursor-pointer hover:opacity-90 transition-opacity"
onClick={() => setSelectedPhoto(photo.url)}
/>
{!readonly && (
<Button
type="button"
variant="destructive"
size="icon"
className="absolute top-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handleDelete(photo.id)}
>
<X className="h-3 w-3" />
</Button>
)}
<Button
type="button"
variant="secondary"
size="icon"
className="absolute bottom-2 right-2 h-6 w-6 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => setSelectedPhoto(photo.url)}
>
<ZoomIn className="h-3 w-3" />
</Button>
</div>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground text-sm border border-dashed rounded-lg">
</div>
)}
{/* 照片预览对话框 */}
<Dialog open={!!selectedPhoto} onOpenChange={() => setSelectedPhoto(null)}>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{selectedPhoto && (
<img
src={selectedPhoto}
alt="照片预览"
className="w-full h-auto max-h-[70vh] object-contain"
/>
)}
</DialogContent>
</Dialog>
</div>
)
}
+208
View File
@@ -0,0 +1,208 @@
"use client"
import { useState } from "react"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Label } from "@/components/ui/label"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Plus, Edit, Trash, BookOpen, Calendar } from "lucide-react"
import type { FamilyStory } from "@/types/family"
import { v4 as uuidv4 } from "uuid"
interface StoryManagerProps {
stories?: FamilyStory[]
onChange: (stories: FamilyStory[]) => void
readonly?: boolean
}
export function StoryManager({ stories = [], onChange, readonly = false }: StoryManagerProps) {
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingStory, setEditingStory] = useState<FamilyStory | null>(null)
const [formData, setFormData] = useState<Partial<FamilyStory>>({
title: "",
content: "",
date: "",
})
const handleAdd = () => {
setEditingStory(null)
setFormData({ title: "", content: "", date: "" })
setIsDialogOpen(true)
}
const handleEdit = (story: FamilyStory) => {
setEditingStory(story)
setFormData(story)
setIsDialogOpen(true)
}
const handleDelete = (storyId: string) => {
if (confirm("确定要删除这个故事吗?")) {
onChange(stories.filter(s => s.id !== storyId))
}
}
const handleSave = () => {
if (!formData.title || !formData.content) {
alert("请填写标题和内容")
return
}
const now = new Date().toISOString()
if (editingStory) {
// 更新现有故事
onChange(
stories.map(s =>
s.id === editingStory.id
? { ...s, ...formData, updatedAt: now }
: s
)
)
} else {
// 添加新故事
const newStory: FamilyStory = {
id: uuidv4(),
title: formData.title!,
content: formData.content!,
date: formData.date,
imageIds: formData.imageIds || [],
createdAt: now,
updatedAt: now,
}
onChange([...stories, newStory])
}
setIsDialogOpen(false)
setFormData({ title: "", content: "", date: "" })
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium flex items-center gap-2">
<BookOpen className="h-4 w-4" />
({stories.length})
</h3>
{!readonly && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAdd}
>
<Plus className="mr-2 h-4 w-4" />
</Button>
)}
</div>
{stories.length > 0 ? (
<div className="space-y-4">
{stories
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
.map((story) => (
<Card key={story.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex-1">
<CardTitle className="text-lg font-serif">{story.title}</CardTitle>
{story.date && (
<div className="flex items-center gap-1 text-sm text-muted-foreground mt-1">
<Calendar className="h-3 w-3" />
{story.date}
</div>
)}
</div>
{!readonly && (
<div className="flex gap-1">
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => handleEdit(story)}
>
<Edit className="h-3 w-3" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handleDelete(story.id)}
>
<Trash className="h-3 w-3" />
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground whitespace-pre-line leading-relaxed">
{story.content}
</p>
<div className="mt-2 text-xs text-muted-foreground">
{new Date(story.createdAt).toLocaleDateString('zh-CN')}
</div>
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-center py-8 text-muted-foreground text-sm border border-dashed rounded-lg">
</div>
)}
{/* 添加/编辑故事对话框 */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{editingStory ? "编辑故事" : "添加故事"}</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="story-title"> *</Label>
<Input
id="story-title"
placeholder="例如:爷爷的创业故事"
value={formData.title || ""}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="story-date"></Label>
<Input
id="story-date"
type="date"
value={formData.date || ""}
onChange={(e) => setFormData({ ...formData, date: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="story-content"> *</Label>
<Textarea
id="story-content"
placeholder="记录家族的珍贵故事..."
className="min-h-[200px]"
value={formData.content || ""}
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsDialogOpen(false)}>
</Button>
<Button onClick={handleSave}>
{editingStory ? "保存" : "添加"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
+146
View File
@@ -0,0 +1,146 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Download, X, Smartphone } from "lucide-react"
import { Alert, AlertDescription } from "@/components/ui/alert"
export function PWAInstaller() {
const [deferredPrompt, setDeferredPrompt] = useState<any>(null)
const [isInstallable, setIsInstallable] = useState(false)
const [isInstalled, setIsInstalled] = useState(false)
const [showPrompt, setShowPrompt] = useState(false)
useEffect(() => {
// 检查是否已安装
if (typeof window !== "undefined") {
const isStandalone = window.matchMedia('(display-mode: standalone)').matches
const isIOSStandalone = (window.navigator as any).standalone === true
if (isStandalone || isIOSStandalone) {
setIsInstalled(true)
return
}
}
// 监听 beforeinstallprompt 事件
const handleBeforeInstallPrompt = (e: Event) => {
e.preventDefault()
setDeferredPrompt(e)
setIsInstallable(true)
// 延迟显示提示(给用户一些时间浏览应用)
setTimeout(() => {
const hasSeenPrompt = localStorage.getItem('pwa-install-prompt-seen')
if (!hasSeenPrompt) {
setShowPrompt(true)
}
}, 30000) // 30秒后显示
}
// 监听安装完成事件
const handleAppInstalled = () => {
setIsInstalled(true)
setShowPrompt(false)
setIsInstallable(false)
}
if (typeof window !== "undefined") {
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.addEventListener('appinstalled', handleAppInstalled)
return () => {
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt)
window.removeEventListener('appinstalled', handleAppInstalled)
}
}
}, [])
const handleInstallClick = async () => {
if (!deferredPrompt) return
// 显示安装提示
deferredPrompt.prompt()
// 等待用户响应
const { outcome } = await deferredPrompt.userChoice
if (outcome === 'accepted') {
console.log('用户接受了安装')
} else {
console.log('用户拒绝了安装')
}
setDeferredPrompt(null)
setShowPrompt(false)
localStorage.setItem('pwa-install-prompt-seen', 'true')
}
const handleDismiss = () => {
setShowPrompt(false)
localStorage.setItem('pwa-install-prompt-seen', 'true')
}
if (isInstalled) {
return null
}
if (!isInstallable) {
return null
}
return (
<>
{/* 浮动安装提示 */}
{showPrompt && (
<div className="fixed bottom-4 right-4 z-50 max-w-sm animate-in slide-in-from-bottom-5">
<Card className="shadow-lg border-2 border-primary">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<Smartphone className="h-5 w-5 text-primary" />
<CardTitle className="text-base"></CardTitle>
</div>
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={handleDismiss}
>
<X className="h-4 w-4" />
</Button>
</div>
<CardDescription className="text-xs">
访
</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<Button
onClick={handleInstallClick}
className="w-full"
size="sm"
>
<Download className="mr-2 h-4 w-4" />
</Button>
</CardContent>
</Card>
</div>
)}
{/* 设置页面的安装按钮 */}
{!showPrompt && (
<Alert className="cursor-pointer hover:bg-muted/50 transition-colors" onClick={handleInstallClick}>
<Download className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between">
<span></span>
<Button size="sm" variant="outline">
</Button>
</AlertDescription>
</Alert>
)}
</>
)
}
+18
View File
@@ -0,0 +1,18 @@
"use client"
import { useEffect } from "react"
import { registerServiceWorker } from "@/lib/register-sw"
import { PWAInstaller } from "./pwa-installer"
export function PWAProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
registerServiceWorker()
}, [])
return (
<>
{children}
<PWAInstaller />
</>
)
}
@@ -0,0 +1,218 @@
"use client"
import { useState, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Switch } from "@/components/ui/switch"
import { Label } from "@/components/ui/label"
import { Bell, BellOff, Check, X } from "lucide-react"
import { requestNotificationPermission, checkUpcomingAnniversaries } from "@/lib/notifications"
import { useFamily } from "@/context/family-context"
import { Alert, AlertDescription } from "@/components/ui/alert"
export function NotificationSettings() {
const { treeData } = useFamily()
const [notificationEnabled, setNotificationEnabled] = useState(false)
const [permission, setPermission] = useState<NotificationPermission>("default")
const [isClient, setIsClient] = useState(false)
const [upcomingEvents, setUpcomingEvents] = useState<{
birthdays: Array<{ member: any; daysUntil: number }>
deathdays: Array<{ member: any; daysUntil: number }>
}>({ birthdays: [], deathdays: [] })
useEffect(() => {
// 标记为客户端
setIsClient(true)
// 检查通知权限
if (typeof window !== "undefined" && "Notification" in window) {
setPermission(Notification.permission)
setNotificationEnabled(Notification.permission === "granted")
}
// 检查即将到来的纪念日
const members = Object.values(treeData.members)
const upcoming = checkUpcomingAnniversaries(members, 7)
setUpcomingEvents(upcoming)
}, [treeData])
const handleEnableNotifications = async () => {
const granted = await requestNotificationPermission()
if (granted) {
setNotificationEnabled(true)
setPermission("granted")
// 发送测试通知
new Notification("通知已启用", {
body: "您将收到家族纪念日提醒",
icon: "/icon.svg",
})
} else {
setPermission(Notification.permission)
}
}
const handleDisableNotifications = () => {
setNotificationEnabled(false)
alert("请在浏览器设置中禁用此网站的通知权限")
}
return (
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
</CardTitle>
<CardDescription>
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!isClient ? (
<div className="text-center py-4 text-muted-foreground">
...
</div>
) : !(typeof window !== "undefined" && "Notification" in window) ? (
<Alert>
<AlertDescription>
</AlertDescription>
</Alert>
) : (
<>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notifications"></Label>
<p className="text-sm text-muted-foreground">
</p>
</div>
<Switch
id="notifications"
checked={notificationEnabled}
onCheckedChange={(checked) => {
if (checked) {
handleEnableNotifications()
} else {
handleDisableNotifications()
}
}}
/>
</div>
{permission === "denied" && (
<Alert variant="destructive">
<X className="h-4 w-4" />
<AlertDescription>
</AlertDescription>
</Alert>
)}
{permission === "granted" && (
<Alert>
<Check className="h-4 w-4" />
<AlertDescription>
访
</AlertDescription>
</Alert>
)}
{notificationEnabled && (
<Button
variant="outline"
size="sm"
onClick={() => {
new Notification("测试通知", {
body: "这是一条测试通知",
icon: "/icon.svg",
})
}}
>
</Button>
)}
</>
)}
</CardContent>
</Card>
{/* 即将到来的纪念日 */}
{(upcomingEvents.birthdays.length > 0 || upcomingEvents.deathdays.length > 0) && (
<Card>
<CardHeader>
<CardTitle>7</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{upcomingEvents.birthdays.length > 0 && (
<div>
<h4 className="font-medium mb-2 flex items-center gap-2">
🎂
</h4>
<div className="space-y-2">
{upcomingEvents.birthdays.map(({ member, daysUntil }) => (
<div
key={member.id}
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
>
<div>
<p className="font-medium">{member.fullName}</p>
<p className="text-sm text-muted-foreground">
{new Date(member.birthDate).toLocaleDateString('zh-CN', {
month: 'long',
day: 'numeric'
})}
</p>
</div>
<div className="text-right">
<p className="text-sm font-medium text-primary">
{daysUntil === 1 ? '明天' : `${daysUntil}天后`}
</p>
</div>
</div>
))}
</div>
</div>
)}
{upcomingEvents.deathdays.length > 0 && (
<div>
<h4 className="font-medium mb-2 flex items-center gap-2">
🕯
</h4>
<div className="space-y-2">
{upcomingEvents.deathdays.map(({ member, daysUntil }) => (
<div
key={member.id}
className="flex items-center justify-between p-3 bg-muted/50 rounded-lg"
>
<div>
<p className="font-medium">{member.fullName}</p>
<p className="text-sm text-muted-foreground">
{new Date(member.deathDate!).toLocaleDateString('zh-CN', {
month: 'long',
day: 'numeric'
})}
</p>
</div>
<div className="text-right">
<p className="text-sm font-medium text-muted-foreground">
{daysUntil === 1 ? '明天' : `${daysUntil}天后`}
</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
)}
</div>
)
}
+96 -11
View File
@@ -2,21 +2,72 @@
import type { FamilyMember } from "@/types/family"
import { cn } from "@/lib/utils"
import { useAvatarCache } from "@/hooks/use-avatar-cache"
import { CircleUser, CircleUserRound, Heart } from "lucide-react"
interface FamilyNodeProps {
member: FamilyMember
spouse?: FamilyMember
isRoot?: boolean
onSelect?: (member: FamilyMember) => void
isHighlighted?: boolean
}
export function FamilyNode({ member, spouse, isRoot, onSelect }: FamilyNodeProps) {
// 格式化日期显示(只显示年份)
function formatYear(dateString?: string): string {
if (!dateString) return ''
const year = dateString.split('-')[0]
return year
}
// 计算年龄
function calculateAge(birthDate?: string, deathDate?: string): number | null {
if (!birthDate) return null
const birth = new Date(birthDate)
const end = deathDate ? new Date(deathDate) : new Date()
let age = end.getFullYear() - birth.getFullYear()
const monthDiff = end.getMonth() - birth.getMonth()
if (monthDiff < 0 || (monthDiff === 0 && end.getDate() < birth.getDate())) {
age--
}
return age
}
// 单个人员卡片组件
function PersonCard({ member, isRoot, isSpouse, onSelect, isHighlighted }: { member: FamilyMember; isRoot?: boolean; isSpouse?: boolean; onSelect?: (member: FamilyMember) => void; isHighlighted?: boolean }) {
const { avatarUrl: avatarBlobUrl } = useAvatarCache(member?.avatarImageId)
const birthYear = formatYear(member.birthDate)
const deathYear = formatYear(member.deathDate)
const age = calculateAge(member.birthDate, member.deathDate)
const lifeSpan = birthYear
? deathYear
? `${birthYear}-${deathYear}`
: `${birthYear}-`
: ''
const ageText = age !== null
? member.deathDate
? `享年${age}`
: `${age}`
: ''
return (
<div
className={cn(
"relative flex flex-col items-center gap-2 p-3 rounded-lg border-2 transition-all cursor-pointer hover:scale-105 shadow-sm w-32",
isRoot ? "border-primary bg-primary/5" : "border-border bg-card",
member.gender === "female" ? "rounded-full w-28 h-28 justify-center" : "rounded-lg", // Traditional round sky/square earth or just variety
"family-node-card relative flex flex-col items-center gap-2 p-3 rounded-lg transition-all cursor-pointer hover:scale-105 shadow-sm w-32",
isHighlighted
? "border-4 border-yellow-400 bg-yellow-50 dark:bg-yellow-900/20 ring-4 ring-yellow-200 dark:ring-yellow-800 animate-pulse"
: isRoot
? "border-2 border-primary bg-primary/5"
: isSpouse
? "border-2 border-dashed border-border/60 bg-muted/30"
: "border-2 border-border bg-card",
)}
onClick={() => onSelect?.(member)}
>
@@ -24,28 +75,62 @@ export function FamilyNode({ member, spouse, isRoot, onSelect }: FamilyNodeProps
{member.generation}
</div>
<div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center text-muted-foreground overflow-hidden border border-border/50">
{member.avatarUrl ? (
<div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center text-muted-foreground overflow-hidden border border-border/50">
{avatarBlobUrl ? (
<img
src={member.avatarUrl || "/placeholder.svg"}
src={avatarBlobUrl}
alt={member.fullName}
className="h-full w-full object-cover"
/>
) : (
<span className="font-serif font-bold text-lg text-foreground/70">{member.surname}</span>
member.gender === 'female' ? (
<CircleUserRound className="h-8 w-8 text-pink-400" />
) : (
<CircleUser className="h-8 w-8 text-blue-400" />
)
)}
</div>
<div className="text-center">
<div className="font-serif font-bold text-foreground leading-tight">{member.givenName}</div>
<div className="text-center w-full">
<div className="font-serif font-bold text-foreground leading-tight">{member.surname} {member.givenName}</div>
{member.courtesyName && (
<div className="text-[10px] text-muted-foreground mt-0.5"> {member.courtesyName}</div>
)}
{lifeSpan && (
<div className="text-[9px] text-muted-foreground/70 mt-1 font-mono">
{lifeSpan}
</div>
)}
{ageText && (
<div className="text-[9px] text-muted-foreground/60 font-mono">
{ageText}
</div>
)}
{member.phone && (
<div className="text-[9px] text-muted-foreground/60 font-mono truncate">
📱 {member.phone}
</div>
)}
</div>
{member.lifeStatus === "deceased" && (
{member.deathDate && (
<div className="absolute -top-1 -right-1 h-2 w-2 rounded-full bg-neutral-400 ring-2 ring-background" />
)}
</div>
)
}
export function FamilyNode({ member, spouse, isRoot, onSelect, isHighlighted }: FamilyNodeProps) {
if (!spouse) {
return <PersonCard member={member} isRoot={isRoot} onSelect={onSelect} isHighlighted={isHighlighted} />
}
// 有配偶时,并排显示
return (
<div className="flex items-center gap-2">
<PersonCard member={member} isRoot={isRoot} onSelect={onSelect} isHighlighted={isHighlighted} />
<Heart className="h-4 w-4 text-red-400 flex-shrink-0" />
<PersonCard member={spouse} isSpouse={true} onSelect={onSelect} />
</div>
)
}
+179 -46
View File
@@ -2,71 +2,169 @@
import type { FamilyMember } from "@/types/family"
import { FamilyNode } from "./family-node"
import { useFamily } from "@/context/family-context"
import { useEffect, useRef, useState, useCallback } from "react"
import { useRouter } from "next/navigation"
interface TreeLayoutProps {
rootId: string
onSelectMember?: (member: FamilyMember) => void
}
interface Connection {
from: { x: number; y: number }
to: { x: number; y: number }
type: 'vertical' | 'horizontal'
}
export function TreeLayout({ rootId, onSelectMember }: TreeLayoutProps) {
const { getMember } = useFamily()
const { getMember, treeData, highlightedMemberId } = useFamily()
const root = getMember(rootId)
const router = useRouter()
const containerRef = useRef<HTMLDivElement>(null)
const [connections, setConnections] = useState<Connection[]>([])
const nodeRefs = useRef<Map<string, HTMLDivElement>>(new Map())
// 节点间距配置
const HORIZONTAL_GAP = 32 // 2rem = 32px
const VERTICAL_GAP = 48 // 3rem = 48px
const CONNECTOR_HEIGHT = 48 // 连接线垂直高度
// 处理节点点击
const handleNodeClick = useCallback((member: FamilyMember) => {
router.push(`/members/${member.id}`)
}, [router])
// 计算所有连线位置
const calculateConnections = useCallback(() => {
const newConnections: Connection[] = []
const containerRect = containerRef.current?.getBoundingClientRect()
if (!containerRect) return
// 检查是否有足够的节点已注册
if (nodeRefs.current.size === 0) return
nodeRefs.current.forEach((nodeElement, nodeId) => {
const member = getMember(nodeId)
if (!member || !member.childrenIds || member.childrenIds.length === 0) return
const parentRect = nodeElement.getBoundingClientRect()
const parentCenterX = parentRect.left + parentRect.width / 2 - containerRect.left
const parentBottomY = parentRect.bottom - containerRect.top
// 父节点到水平线的垂直连线
const verticalLineY = parentBottomY + CONNECTOR_HEIGHT / 2
newConnections.push({
from: { x: parentCenterX, y: parentBottomY },
to: { x: parentCenterX, y: verticalLineY },
type: 'vertical'
})
// 获取所有子节点的位置
const childPositions = member.childrenIds
.map(childId => {
const childElement = nodeRefs.current.get(childId)
if (!childElement) return null
const childRect = childElement.getBoundingClientRect()
return {
id: childId,
centerX: childRect.left + childRect.width / 2 - containerRect.left,
topY: childRect.top - containerRect.top
}
})
.filter(Boolean) as Array<{ id: string; centerX: number; topY: number }>
if (childPositions.length === 0) return
// 如果有多个子节点,绘制水平连线
if (childPositions.length > 1) {
const leftmostX = Math.min(...childPositions.map(p => p.centerX))
const rightmostX = Math.max(...childPositions.map(p => p.centerX))
newConnections.push({
from: { x: leftmostX, y: verticalLineY },
to: { x: rightmostX, y: verticalLineY },
type: 'horizontal'
})
}
// 每个子节点的垂直连线
childPositions.forEach(child => {
newConnections.push({
from: { x: child.centerX, y: verticalLineY },
to: { x: child.centerX, y: child.topY },
type: 'vertical'
})
})
})
setConnections(newConnections)
}, [getMember])
useEffect(() => {
// 多次尝试计算,确保 DOM 完全渲染
const timers: NodeJS.Timeout[] = []
timers.push(setTimeout(calculateConnections, 50))
timers.push(setTimeout(calculateConnections, 150))
timers.push(setTimeout(calculateConnections, 300))
timers.push(setTimeout(calculateConnections, 500))
// 监听窗口大小变化
window.addEventListener('resize', calculateConnections)
return () => {
timers.forEach(timer => clearTimeout(timer))
window.removeEventListener('resize', calculateConnections)
}
}, [calculateConnections, treeData])
if (!root) return null
// Recursive component to render the tree
const TreeNode = ({ memberId }: { memberId: string }) => {
// 递归渲染树节点
const TreeNode = ({ memberId, isRoot = false }: { memberId: string; isRoot?: boolean }) => {
const member = getMember(memberId)
if (!member) return null
const hasChildren = member.childrenIds && member.childrenIds.length > 0
// 获取配偶(取第一个配偶)
const spouse = member.spouseIds && member.spouseIds.length > 0
? getMember(member.spouseIds[0])
: undefined
return (
<div className="flex flex-col items-center">
<div className="relative z-10">
<FamilyNode member={member} onSelect={onSelectMember} />
{/* Connector to children */}
{hasChildren && <div className="absolute top-full left-1/2 -translate-x-1/2 h-8 w-px bg-border" />}
{/* 节点本身 */}
<div
ref={(el) => {
if (el) {
nodeRefs.current.set(memberId, el)
} else {
nodeRefs.current.delete(memberId)
}
}}
className="relative z-10"
>
<FamilyNode
member={member}
spouse={spouse}
onSelect={handleNodeClick}
isRoot={isRoot}
isHighlighted={member.id === highlightedMemberId}
/>
</div>
{/* 子节点 */}
{hasChildren && (
<div className="flex items-start pt-8 relative">
{/* Horizontal connecting line */}
{member.childrenIds.length > 1 && (
<div className="absolute top-0 left-[50%] -translate-x-1/2 h-px bg-border w-[calc(100%-8rem)]" /> // Rough approx for connector width
)}
<div className="flex gap-8 relative">
{/* Top connecting lines for children */}
{member.childrenIds.length > 1 && (
<div className="absolute top-0 left-0 right-0 h-px bg-transparent">
{/* This needs precise calculation or just use CSS pseudo elements on children */}
</div>
)}
{member.childrenIds.map((childId, index) => (
<div key={childId} className="flex flex-col items-center relative">
{/* Vertical line from parent's horizontal line to child */}
<div className="h-8 w-px bg-border absolute -top-8 left-1/2 -translate-x-1/2"></div>
{/* Horizontal connector fix for first/last child */}
{member.childrenIds.length > 1 && (
<>
{index === 0 && <div className="absolute -top-8 right-0 w-1/2 h-px bg-border"></div>}
{index === member.childrenIds.length - 1 && (
<div className="absolute -top-8 left-0 w-1/2 h-px bg-border"></div>
)}
{index > 0 && index < member.childrenIds.length - 1 && (
<div className="absolute -top-8 left-0 w-full h-px bg-border"></div>
)}
</>
)}
<TreeNode memberId={childId} />
</div>
))}
</div>
<div
className="flex items-start relative"
style={{
marginTop: `${VERTICAL_GAP}px`,
gap: `${HORIZONTAL_GAP}px`
}}
>
{member.childrenIds.map((childId) => (
<TreeNode key={childId} memberId={childId} />
))}
</div>
)}
</div>
@@ -74,8 +172,43 @@ export function TreeLayout({ rootId, onSelectMember }: TreeLayoutProps) {
}
return (
<div className="p-12 min-w-fit">
<TreeNode memberId={rootId} />
<div className="relative">
<div ref={containerRef} className="p-12 min-w-fit relative" style={{ minHeight: '100%' }}>
{/* 连线层 - 使用 div 绘制 */}
<div className="absolute inset-0 pointer-events-none" style={{ zIndex: 1 }}>
{connections.map((conn, index) => {
const isHorizontal = conn.type === 'horizontal'
// 使用 1px 但通过 transform scale 缩小来实现细线
const width = isHorizontal ? Math.abs(conn.to.x - conn.from.x) : 1
const height = isHorizontal ? 1 : Math.abs(conn.to.y - conn.from.y)
const left = Math.min(conn.from.x, conn.to.x)
const top = Math.min(conn.from.y, conn.to.y)
// 跳过无效的连线(宽度或高度为0)
if (width <= 0 || height <= 0) return null
return (
<div
key={index}
className="absolute bg-foreground/25"
style={{
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
transform: isHorizontal ? 'scaleY(0.3)' : 'scaleX(0.3)',
transformOrigin: 'top left',
}}
/>
)
})}
</div>
{/* 树节点层 */}
<div className="relative" style={{ zIndex: 10 }}>
<TreeNode memberId={rootId} isRoot={true} />
</div>
</div>
</div>
)
}
+22 -3
View File
@@ -3,15 +3,18 @@
import { useState, useEffect } from "react"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { db } from "@/lib/db"
import { CircleUser, CircleUserRound } from "lucide-react"
import type { Gender } from "@/types/family"
interface AvatarDisplayProps {
imageId?: string
fallbackUrl?: string
fallbackText?: string
gender?: Gender
className?: string
}
export function AvatarDisplay({ imageId, fallbackUrl, fallbackText, className }: AvatarDisplayProps) {
export function AvatarDisplay({ imageId, fallbackUrl, fallbackText, gender, className }: AvatarDisplayProps) {
const [blobUrl, setBlobUrl] = useState<string | undefined>(undefined)
useEffect(() => {
@@ -28,10 +31,26 @@ export function AvatarDisplay({ imageId, fallbackUrl, fallbackText, className }:
}
}, [imageId])
// 如果有头像,显示头像
if (blobUrl) {
return (
<Avatar className={className}>
<AvatarImage src={blobUrl} />
<AvatarFallback className="text-lg font-serif bg-muted">{fallbackText}</AvatarFallback>
</Avatar>
)
}
// 没有头像,显示性别图标
return (
<Avatar className={className}>
<AvatarImage src={blobUrl || fallbackUrl || "/placeholder.svg"} />
<AvatarFallback className="text-lg font-serif bg-muted">{fallbackText}</AvatarFallback>
<AvatarFallback className="bg-muted flex items-center justify-center">
{gender === 'female' ? (
<CircleUserRound className="h-8 w-8 text-pink-400" />
) : (
<CircleUser className="h-8 w-8 text-blue-400" />
)}
</AvatarFallback>
</Avatar>
)
}
+172 -42
View File
@@ -3,9 +3,13 @@
import { useState, useRef, useEffect } from "react"
import { Button } from "@/components/ui/button"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Upload, X, Loader2 } from "lucide-react"
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"
import { Upload, X, Loader2, Crop } from "lucide-react"
import { db } from "@/lib/db"
import { v4 as uuidv4 } from "uuid"
import ReactCrop, { type Crop as CropType } from "react-image-crop"
import "react-image-crop/dist/ReactCrop.css"
import imageCompression from "browser-image-compression"
interface ImageUploadProps {
value?: string // The image ID
@@ -16,6 +20,11 @@ interface ImageUploadProps {
export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
const [previewUrl, setPreviewUrl] = useState<string | undefined>(undefined)
const [isLoading, setIsLoading] = useState(false)
const [showCropDialog, setShowCropDialog] = useState(false)
const [imageToCrop, setImageToCrop] = useState<string | null>(null)
const [crop, setCrop] = useState<CropType>()
const [completedCrop, setCompletedCrop] = useState<CropType>()
const imgRef = useRef<HTMLImageElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
// Load preview if value (imageId) exists
@@ -50,7 +59,6 @@ export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
const file = e.target.files?.[0]
if (!file) return
setIsLoading(true)
try {
// Basic validation
if (!file.type.startsWith("image/")) {
@@ -58,28 +66,103 @@ export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
return
}
if (file.size > 5 * 1024 * 1024) {
alert("图片大小不能超过 5MB")
return
// 压缩图片
const options = {
maxSizeMB: 1,
maxWidthOrHeight: 1024,
useWebWorker: true,
}
const compressedFile = await imageCompression(file, options)
// 显示裁剪对话框
const reader = new FileReader()
reader.onload = () => {
setImageToCrop(reader.result as string)
setShowCropDialog(true)
}
reader.readAsDataURL(compressedFile)
} catch (error) {
console.error("Failed to process image:", error)
alert("图片处理失败")
} finally {
// Reset input
if (fileInputRef.current) fileInputRef.current.value = ""
}
}
const handleCropComplete = async () => {
if (!completedCrop || !imgRef.current) {
// 如果没有裁剪,直接保存原图
await saveImage(imageToCrop!)
return
}
try {
setIsLoading(true)
const canvas = document.createElement('canvas')
const scaleX = imgRef.current.naturalWidth / imgRef.current.width
const scaleY = imgRef.current.naturalHeight / imgRef.current.height
canvas.width = completedCrop.width
canvas.height = completedCrop.height
const ctx = canvas.getContext('2d')
if (ctx) {
ctx.drawImage(
imgRef.current,
completedCrop.x * scaleX,
completedCrop.y * scaleY,
completedCrop.width * scaleX,
completedCrop.height * scaleY,
0,
0,
completedCrop.width,
completedCrop.height
)
}
canvas.toBlob(async (blob) => {
if (blob) {
await saveImage(URL.createObjectURL(blob), blob)
}
}, 'image/jpeg', 0.9)
} catch (error) {
console.error("Failed to crop image:", error)
alert("图片裁剪失败")
} finally {
setIsLoading(false)
setShowCropDialog(false)
setImageToCrop(null)
}
}
const saveImage = async (dataUrl: string, blob?: Blob) => {
try {
setIsLoading(true)
let imageBlob = blob
if (!imageBlob) {
const response = await fetch(dataUrl)
imageBlob = await response.blob()
}
// Save to DB
const imageId = uuidv4()
await db.images.add({
id: imageId,
blob: file,
mimeType: file.type,
blob: imageBlob,
mimeType: imageBlob.type,
createdAt: new Date().toISOString(),
})
onChange(imageId)
setShowCropDialog(false)
setImageToCrop(null)
} catch (error) {
console.error("Failed to save image:", error)
alert("图片上传失败")
alert("图片保存失败")
} finally {
setIsLoading(false)
// Reset input
if (fileInputRef.current) fileInputRef.current.value = ""
}
}
@@ -88,43 +171,90 @@ export function ImageUpload({ value, onChange, className }: ImageUploadProps) {
}
return (
<div className={`flex items-center gap-4 ${className}`}>
<Avatar className="h-20 w-20 border-2 border-dashed border-muted-foreground/50">
<AvatarImage src={previewUrl} className="object-cover" />
<AvatarFallback className="bg-transparent">
{isLoading ? <Loader2 className="h-6 w-6 animate-spin" /> : <Upload className="h-6 w-6 text-muted-foreground" />}
</AvatarFallback>
</Avatar>
<>
<div className={`flex items-center gap-4 ${className}`}>
<Avatar className="h-20 w-20 border-2 border-dashed border-muted-foreground/50">
<AvatarImage src={previewUrl} className="object-cover" />
<AvatarFallback className="bg-transparent">
{isLoading ? <Loader2 className="h-6 w-6 animate-spin" /> : <Upload className="h-6 w-6 text-muted-foreground" />}
</AvatarFallback>
</Avatar>
<div className="flex flex-col gap-2">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileSelect}
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
>
{value ? "更换头像" : "上传头像"}
</Button>
{value && (
<div className="flex flex-col gap-2">
<input
type="file"
ref={fileInputRef}
className="hidden"
accept="image/*"
onChange={handleFileSelect}
/>
<Button
type="button"
variant="ghost"
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={handleRemove}
onClick={() => fileInputRef.current?.click()}
disabled={isLoading}
>
<X className="mr-2 h-3 w-3" />
{value ? "更换头像" : "上传头像"}
</Button>
)}
{value && (
<Button
type="button"
variant="ghost"
size="sm"
className="text-destructive hover:text-destructive"
onClick={handleRemove}
>
<X className="mr-2 h-3 w-3" />
</Button>
)}
</div>
</div>
</div>
{/* 裁剪对话框 */}
<Dialog open={showCropDialog} onOpenChange={setShowCropDialog}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Crop className="h-5 w-5" />
</DialogTitle>
</DialogHeader>
<div className="max-h-[60vh] overflow-auto">
{imageToCrop && (
<ReactCrop
crop={crop}
onChange={(c) => setCrop(c)}
onComplete={(c) => setCompletedCrop(c)}
aspect={1}
circularCrop
>
<img
ref={imgRef}
src={imageToCrop}
alt="待裁剪图片"
className="max-w-full"
/>
</ReactCrop>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowCropDialog(false)
setImageToCrop(null)
}}
>
</Button>
<Button onClick={handleCropComplete} disabled={isLoading}>
{isLoading ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : null}
{completedCrop ? "确认裁剪" : "跳过裁剪"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}