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
+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>
</>
)
}