crush-level-web/src/app/(main)/character/[id]/chat/Sider/Personal.tsx

373 lines
12 KiB
TypeScript
Raw Normal View History

2025-12-09 09:13:46 +00:00
'use client'
import { useEffect, useState, useCallback } from 'react'
import { SiderHeader } from '.'
import { useChatStore } from '../store'
import { z } from 'zod'
import dayjs from 'dayjs'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { Gender } from '@/types/user'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { calculateAge, getDaysInMonth } from '@/lib/utils'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
const currentYear = dayjs().year()
const years = Array.from({ length: currentYear - 1950 + 1 }, (_, i) => `${1950 + i}`)
const months = Array.from({ length: 12 }, (_, i) => `${i + 1}`.padStart(2, '0'))
const monthTexts = Array.from({ length: 12 }, (_, i) => dayjs().month(i).format('MMM'))
const characterFormSchema = z
.object({
nickname: z
.string()
.trim()
.min(1, 'Please Enter nickname')
.min(2, 'Nickname must be between 2 and 20 characters')
.max(20, 'Nickname must be less than 20 characters'),
sex: z.enum(Gender, { message: 'Please select gender' }),
year: z.string().min(1, 'Please select year'),
month: z.string().min(1, 'Please select month'),
day: z.string().min(1, 'Please select day'),
profile: z.string().trim().optional(),
})
.refine(
(data) => {
const age = calculateAge(data.year, data.month, data.day)
return age >= 18
},
{
message: 'Character age must be at least 18 years old',
path: ['year'],
}
)
.refine(
(data) => {
if (data.profile) {
if (data.profile.trim().length > 300) {
return false
}
return data.profile.trim().length >= 10
}
return true
},
{
message: 'At least 10 characters',
path: ['profile'],
}
)
export default function Personal() {
const setSideBar = useChatStore((store) => store.setSideBar)
const [loading, setLoading] = useState(false)
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
// 静态数据,模拟从接口获取的数据
const chatSettingData = {
nickname: 'John',
sex: Gender.MALE,
birthday: dayjs('1995-06-15').valueOf(),
whoAmI: 'A creative and passionate developer',
}
const birthday = chatSettingData?.birthday ? dayjs(chatSettingData.birthday) : undefined
const form = useForm<z.infer<typeof characterFormSchema>>({
resolver: zodResolver(characterFormSchema),
defaultValues: {
nickname: chatSettingData?.nickname || '',
sex: chatSettingData?.sex,
year: birthday?.year().toString() || undefined,
month:
birthday?.month() !== undefined
? (birthday.month() + 1).toString().padStart(2, '0')
: undefined,
day: birthday?.date().toString().padStart(2, '0') || undefined,
profile: chatSettingData?.whoAmI || '',
},
})
// 处理返回的逻辑
const handleGoBack = useCallback(() => {
if (form.formState.isDirty) {
setShowConfirmDialog(true)
} else {
setSideBar('profile')
}
}, [form.formState.isDirty, setSideBar])
// 确认放弃修改
const handleConfirmDiscard = useCallback(() => {
form.reset()
setShowConfirmDialog(false)
setSideBar('profile')
}, [form, setSideBar])
async function onSubmit(data: z.infer<typeof characterFormSchema>) {
if (!form.formState.isDirty) {
setSideBar('profile')
return
}
setLoading(true)
try {
// TODO: 这里应该调用实际的 API
// 模拟检查昵称是否存在
const isExist = false // await checkNickname({ nickname: data.nickname.trim() })
if (isExist) {
form.setError('nickname', {
message: 'This nickname is already taken',
})
return
}
// TODO: 这里应该调用实际的保存 API
// await setMyChatSetting({
// aiId,
// nickname: data.nickname,
// birthday: new Date(`${data.year}-${data.month}-${data.day}`).getTime(),
// whoAmI: data.profile || '',
// })
console.log('Saved data:', {
nickname: data.nickname,
birthday: new Date(`${data.year}-${data.month}-${data.day}`).getTime(),
whoAmI: data.profile || '',
})
setSideBar('profile')
} catch (error) {
console.error(error)
} finally {
setLoading(false)
}
}
const selectedYear = form.watch('year')
const selectedMonth = form.watch('month')
const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : []
const genderTexts = [
{
value: Gender.MALE,
label: 'Male',
},
{
value: Gender.FEMALE,
label: 'Female',
},
{
value: Gender.OTHER,
label: 'Other',
},
]
const gender = form.watch('sex')
const genderText = genderTexts.find((text) => text.value === gender)?.label
return (
<>
<div className="flex flex-col gap-6">
<SiderHeader title="My Chat Persona" />
<Form {...form}>
<form className="space-y-8">
<FormField
control={form.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel>Nickname</FormLabel>
<FormControl>
<Input
placeholder="Enter nickname"
maxLength={20}
error={!!form.formState.errors.nickname}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="">
<div className="txt-label-m text-txt-secondary-normal">Gender</div>
<div className="mt-3">
<div className="bg-surface-element-normal rounded-m txt-body-l text-txt-secondary-disabled flex h-12 items-center px-4 py-3">
{genderText}
</div>
<div className="txt-body-s text-txt-secondary-disabled mt-1">
Please note: gender cannot be changed after setting
</div>
</div>
</div>
<div>
<Label className="txt-label-m mb-3 block">Birthday</Label>
<div className="flex gap-2">
<FormField
control={form.control}
name="year"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Year" />
</SelectTrigger>
<SelectContent>
{years.map((year) => (
<SelectItem key={year} value={year}>
{year}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="month"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Month" />
</SelectTrigger>
<SelectContent>
{months.map((m, index) => (
<SelectItem key={m} value={m}>
{monthTexts[index]}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
<FormField
control={form.control}
name="day"
render={({ field }) => (
<FormItem className="flex-1">
<FormControl>
<Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Day" />
</SelectTrigger>
<SelectContent>
{days.map((d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
<FormMessage>
{form.formState.errors.year?.message ||
form.formState.errors.month?.message ||
form.formState.errors.day?.message}
</FormMessage>
</div>
<FormField
control={form.control}
name="profile"
render={({ field }) => (
<FormItem>
<FormLabel>
My Persona
<span className="txt-label-m text-txt-secondary-normal">(Optional)</span>
</FormLabel>
<FormControl>
<Textarea
{...field}
maxLength={300}
error={!!form.formState.errors.profile}
placeholder="Set your own persona in CrushLevel"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<div className="flex gap-3">
<Button variant="tertiary" size="large" className="flex-1" onClick={handleGoBack}>
Cancel
</Button>
<Button
type="submit"
size="large"
className="flex-1"
onClick={form.handleSubmit(onSubmit)}
loading={loading}
>
Save
</Button>
</div>
</div>
{/* 确认放弃修改的对话框 */}
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Unsaved Edits</AlertDialogTitle>
<AlertDialogDescription>
The edited content will not be saved after exiting. Please confirm whether to continue
exiting?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setShowConfirmDialog(false)}>
Cancel
</AlertDialogCancel>
<AlertDialogAction variant="destructive" onClick={handleConfirmDiscard}>
Exit
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}