feat: 增加一些变更

This commit is contained in:
liuyonghe0111 2025-12-15 19:31:18 +08:00
parent 506e702040
commit 8b524fbf79
30 changed files with 910 additions and 1462 deletions

15
.env
View File

@ -1,17 +1,20 @@
NEXT_PUBLIC_AUTH_API_URL=https://localhost:3000/api/mock
NEXT_PUBLIC_FROG_API_URL=http://35.82.37.117:8082/frog
NEXT_PUBLIC_BEAR_API_URL=https://test-bear.crushlevel.ai
NEXT_PUBLIC_LION_API_URL=https://test-lion.crushlevel.ai
NEXT_PUBLIC_SHARK_API_URL=https://test-shark.crushlevel.ai
NEXT_PUBLIC_COW_API_URL=https://test-cow.crushlevel.ai
NEXT_PUBLIC_PIGEON_API_URL=https://test-pigeon.crushlevel.ai
NEXT_PUBLIC_BEAR_API_URL=http://35.82.37.117:8082/bear
NEXT_PUBLIC_LION_API_URL=http://35.82.37.117:8082/lion
NEXT_PUBLIC_SHARK_API_URL=http://35.82.37.117:8082/shark
NEXT_PUBLIC_COW_API_URL=http://35.82.37.117:8082/cow
NEXT_PUBLIC_PIGEON_API_URL=http://35.82.37.117:8082/pigeon
# A18 服务
NEXT_PUBLIC_EDITOR_API_URL=http://35.82.37.117
NEXT_PUBLIC_EDITOR_API_URL=http://54.223.196.180
# 三方登录
NEXT_PUBLIC_DISCORD_CLIENT_ID=1448143535609217076
# STREAM_CHAT
NEXT_PUBLIC_STREAM_CHAT_API_KEY=rpwwpq5gvq3h
# S3
NEXT_PUBLIC_S3_URI=https://hhb.crushlevel.ai
NEXT_PUBLIC_S3_IM_URI=https://img.crushlevel.ai

View File

@ -44,7 +44,7 @@ const PolicyLayout = ({ children }: { children: React.ReactNode }) => {
<div className="relative flex min-w-0 flex-1 flex-col">
<header
className={cn(
'absolute z-40 flex h-16 w-full items-center justify-between px-8 transition-all',
'absolute z-40 flex h-16 w-full items-center justify-between px-6 transition-all',
{
'backdrop-blur-[10px]': isBlur,
}
@ -52,7 +52,7 @@ const PolicyLayout = ({ children }: { children: React.ReactNode }) => {
>
{isBlur && <div className="bg-background-default absolute inset-0 opacity-85" />}
<div className="relative inset-0 flex w-full items-center justify-between">
<div className="items-center justify-between inset-0 h-16 flex">
<div className="flex w-full items-center justify-between h-16">
<IconButton
variant="ghost"
size="large"
@ -60,7 +60,7 @@ const PolicyLayout = ({ children }: { children: React.ReactNode }) => {
iconfont="icon-arrow-left"
/>
<div className="txt-title-m">{title}</div>
<div></div>
<div className="w-12"></div>
</div>
</div>
</header>

View File

@ -1,36 +1,36 @@
'use client'
'use client';
import { IconButton } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useState, useRef, useEffect } from 'react'
import { IconButton } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { useState, useRef, useEffect } from 'react';
const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeight?: number }) => {
const { maxHeight = 200, className, value, onChange, ...restProps } = props
const textareaRef = useRef<HTMLTextAreaElement>(null)
const { maxHeight = 200, className, value, onChange, ...restProps } = props;
const textareaRef = useRef<HTMLTextAreaElement>(null);
// 调整高度的函数
const adjustHeight = () => {
const textarea = textareaRef.current
if (!textarea) return
const textarea = textareaRef.current;
if (!textarea) return;
// 先重置高度为 0这样才能获取真实的 scrollHeight
textarea.style.height = '0px'
textarea.style.height = '0px';
// 获取内容实际需要的高度
const scrollHeight = textarea.scrollHeight
const scrollHeight = textarea.scrollHeight;
// 计算新高度:取 scrollHeight 和 maxHeight 的较小值
const newHeight = Math.min(scrollHeight, maxHeight)
textarea.style.height = `${newHeight}px`
const newHeight = Math.min(scrollHeight, maxHeight);
textarea.style.height = `${newHeight}px`;
// 如果内容超过最大高度,显示滚动条
textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'
}
textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden';
};
// 监听内容变化,自动调整高度
useEffect(() => {
adjustHeight()
}, [value, maxHeight])
adjustHeight();
}, [value, maxHeight]);
return (
<textarea
@ -53,15 +53,15 @@ const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeigh
}}
{...restProps}
/>
)
}
);
};
export default function Input() {
const [isRecording, setIsRecording] = useState(false)
const [inputValue, setInputValue] = useState('')
const [isRecording, setIsRecording] = useState(false);
const [inputValue, setInputValue] = useState('');
return (
<div className="flex mb-6 items-end gap-4">
<div className="flex flex-col mb-6 items-end gap-4">
<div></div>
<div className="flex w-full items-end gap-4">
{/* 打电话按钮 */}
@ -100,5 +100,5 @@ export default function Input() {
/>
</div>
</div>
)
);
}

View File

@ -12,7 +12,7 @@ export default function ChatPage() {
return (
<div className="flex h-full">
<div className="relative w-full flex-1 flex justify-center">
<div className="relative px-4 w-full flex-1 flex justify-center">
<div className="max-w-[752px] w-full h-full flex flex-col">
<MessageList />
<Input />

View File

@ -1,8 +1,46 @@
import { Button } from '@/components/ui/button';
import { AvatarImage, AvatarFallback, Avatar } from '@radix-ui/react-avatar';
import Link from 'next/link';
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params
const { id } = await params;
const user = {
name: 'Honey Snow',
headImage: 'https://picsum.photos/200/300',
nickname: 'Crush',
};
return (
<div className="flex px-4 pt-10">
<div className="mx-auto w-full max-w-[752px]">
<header className="flex items-end justify-between">
<div className="flex gap-6 items-end">
<Avatar className="size-32 rounded-full overflow-hidden cursor-pointer">
<AvatarImage className="h-32 w-32 object-cover" src={user?.headImage} />
<AvatarFallback className="!txt-headline-m">
{user?.nickname?.slice(0, 1)}
</AvatarFallback>
</Avatar>
<div>
<h1>Character: {id}</h1>
<div className="txt-headline-s">{user?.name}</div>
<div className="text-sm mt-4 text-text-300">{user?.nickname}</div>
</div>
)
</div>
<div>
<Link href={`/character/${id}/chat`}>
<Button variant="primary">Chat</Button>
</Link>
</div>
</header>
<div className="mt-12 rounded-2xl bg-white/10 p-6">
<div className="txt-headline-s">Introduction</div>
<div>
She is a new and beautiful teacher and has just graduated. You are the most rebellious
student of the whole school workers. She is a new and beautiful teacher and has just
graduated. You are the most rebellious student of the whole school workers. In...
</div>
</div>
</div>
</div>
);
}

View File

@ -23,13 +23,7 @@ const Character = () => {
<div className="mt-8">
<InfiniteScrollList<any>
items={dataSource}
columns={{
xs: 2,
sm: 3,
md: 4,
lg: 5,
xl: 6,
}}
columns={(width) => Math.floor(width / 213)}
renderItem={(character) => <AIStandardCard character={character} />}
getItemKey={(character) => character.id}
hasNextPage={!noMoreData}

View File

@ -1,4 +1,4 @@
'use client'
'use client';
import {
AlertDialog,
@ -10,53 +10,53 @@ import {
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { IconButton, Button } from '@/components/ui/button'
import { useCurrentUser, useDeleteUser } from '@/hooks/auth'
import { ThirdType } from '@/services/auth'
import { useRouter } from 'next/navigation'
import { useState } from 'react'
import { toast } from 'sonner'
} from '@/components/ui/alert-dialog';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { IconButton, Button } from '@/components/ui/button';
import { useCurrentUser, useDeleteUser } from '@/hooks/auth';
import { ThirdType } from '@/services/auth';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { toast } from 'sonner';
const AccountPage = () => {
const router = useRouter()
const { data: user } = useCurrentUser()
const [isDisabling, setIsDisabling] = useState(false)
const { mutateAsync: deleteUser } = useDeleteUser()
const router = useRouter();
const { data: user } = useCurrentUser();
const [isDisabling, setIsDisabling] = useState(false);
const { mutateAsync: deleteUser } = useDeleteUser();
const handleBack = () => {
router.back()
}
router.back();
};
const handleDisableAccount = async () => {
setIsDisabling(true)
setIsDisabling(true);
try {
await deleteUser()
toast.success('Account disabled successfully')
await deleteUser();
toast.success('Account disabled successfully');
} catch (error) {
console.error('禁用账户失败:', error)
console.error('禁用账户失败:', error);
} finally {
setIsDisabling(false)
}
setIsDisabling(false);
}
};
const renderAvatar = () => {
const iconfonts = {
[ThirdType.Apple]: 'icon-social-apple',
[ThirdType.Discord]: 'icon-social-discord',
[ThirdType.Google]: 'icon-social-google',
}
};
return (
<div className="bg-surface-element-normal flex size-12 items-center justify-center rounded-full">
<i className={`iconfont ${iconfonts[user?.thirdType || ThirdType.Google]} !text-[24px]`} />
</div>
)
}
);
};
return (
<div className="mx-auto max-w-[752px] pt-6 pb-6">
<div className="mx-auto px-4 max-w-[752px] pt-6 pb-6">
{/* 标题栏 */}
<div className="mb-6 flex items-center gap-2">
<IconButton variant="ghost" size="large" onClick={handleBack} className="p-2">
@ -115,7 +115,7 @@ const AccountPage = () => {
</div>
</div>
</div>
)
}
);
};
export default AccountPage
export default AccountPage;

View File

@ -9,7 +9,6 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useLogout } from '@/hooks/auth';
import { useNimChat, useNimConversation } from '@/context/NimChat/useNimChat';
import { useSetAtom } from 'jotai';
import {
conversationListAtom,
@ -50,8 +49,7 @@ const ProfileDropdownItem = ({
const ProfileDropdown = () => {
const { mutateAsync: logout } = useLogout();
const { nim } = useNimChat();
const { clearAllConversations } = useNimConversation();
// const { clearAllConversations } = useNimConversation();
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false);
const [isLogoutDialogLoading, setIsLogoutDialogLoading] = useState(false);
const { isSidebarExpanded, setSidebarExpanded } = useLayoutStore();
@ -71,7 +69,7 @@ const ProfileDropdown = () => {
// 1. 断开IM连接
try {
console.log('开始断开IM连接...');
await nim.V2NIMLoginService.logout();
// await nim.V2NIMLoginService.logout();
console.log('IM连接已断开');
} catch (imError) {
console.error('断开IM连接失败:', imError);
@ -81,7 +79,7 @@ const ProfileDropdown = () => {
// 2. 清除所有聊天数据
try {
console.log('开始清除聊天历史数据...');
await clearAllConversations();
// await clearAllConversations();
console.log('聊天历史数据已清除');
} catch (clearError) {
console.error('清除聊天数据失败:', clearError);

View File

@ -1,5 +1,5 @@
import Image from 'next/image'
import Link from 'next/link'
import Image from 'next/image';
import Link from 'next/link';
const ProfileFeatureList = () => {
const items = [
@ -20,10 +20,10 @@ const ProfileFeatureList = () => {
),
href: '/wallet',
},
]
];
return (
<div className="flex w-full items-center justify-between gap-4">
<div className="flex w-full items-center flex-col sm:flex-row sm:justify-between gap-4">
{items.map((item) => (
<Link
key={item.href}
@ -47,7 +47,7 @@ const ProfileFeatureList = () => {
</Link>
))}
</div>
)
}
);
};
export default ProfileFeatureList
export default ProfileFeatureList;

View File

@ -1,343 +0,0 @@
'use client'
import { useForm } from 'react-hook-form'
import { IconButton, Button } from '@/components/ui/button'
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import GenderInput from '@/components/features/genderInput'
import { Gender } from '@/types/user'
import { useRouter } from 'next/navigation'
import { useCheckNickname, useCurrentUser, useUpdateUser } from '@/hooks/auth'
import { useEffect, useState } from 'react'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
import { calculateAge } from '@/lib/utils'
import dayjs from 'dayjs'
const schema = z
.object({
nickname: z
.string()
.trim()
.min(1, 'Nickname is required')
.min(2, 'Nickname must be between 2 and 20 characters'),
gender: z.enum(Gender, { message: 'Please select a gender' }),
year: z.string().min(1, 'Please select birth year'),
month: z.string().min(1, 'Please select birth month'),
day: z.string().min(1, 'Please select birth day'),
})
.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'],
}
)
type EditProfileFormData = z.infer<typeof schema>
const EditPage = () => {
const router = useRouter()
const { data: user, isLoading } = useCurrentUser()
const { mutateAsync: updateUser } = useUpdateUser()
const { mutateAsync: checkNickname } = useCheckNickname({
onError: (error) => {
form.setError('nickname', {
message: error.errorMsg,
})
},
})
const [loading, setLoading] = useState(false)
// 获取用户生日如果是时间戳则转换为Date对象
const getUserBirthday = () => {
if (!user?.birthday) return null
return new Date(user.birthday)
}
const userBirthday = getUserBirthday()
const form = useForm<EditProfileFormData>({
resolver: zodResolver(schema),
defaultValues: {
nickname: user?.nickname || '',
gender: user?.sex,
year: userBirthday ? userBirthday.getFullYear().toString() : '',
month: userBirthday ? (userBirthday.getMonth() + 1).toString().padStart(2, '0') : '',
day: userBirthday ? userBirthday.getDate().toString().padStart(2, '0') : '',
},
mode: 'onChange',
})
const {
formState: { isValid, isDirty },
} = form
// 当用户数据加载完成后,更新表单值
useEffect(() => {
if (user && !isLoading) {
const userBirthday = getUserBirthday()
form.reset({
nickname: user.nickname || '',
gender: user.sex,
year: userBirthday ? userBirthday.getFullYear().toString() : '',
month: userBirthday ? (userBirthday.getMonth() + 1).toString().padStart(2, '0') : '',
day: userBirthday ? userBirthday.getDate().toString().padStart(2, '0') : '',
})
}
}, [user, isLoading, form])
const onSubmit = async (data: EditProfileFormData) => {
if (!user?.userId) {
return
}
setLoading(true)
try {
const isExist = await checkNickname({
nickname: data.nickname.trim(),
exUserId: user.userId,
})
if (isExist) {
form.setError('nickname', {
message: 'This nickname is already taken',
})
return
}
// 将日期字符串转换为时间戳
const birthdayDate = new Date(`${data.year}-${data.month}-${data.day}`)
const birthdayTimestamp = birthdayDate.getTime()
await updateUser({
nickname: data.nickname,
birthday: birthdayTimestamp,
userId: user.userId,
})
router.push('/profile')
} catch (error) {
console.error(error)
} finally {
setLoading(false)
}
}
const handleBack = () => {
router.back()
}
// 生成年份选项 (当前年份往前100年)
const currentYear = new Date().getFullYear()
const years = Array.from({ length: 100 }, (_, i) => currentYear - i).map((year) => ({
value: year.toString(),
label: year.toString(),
}))
const monthTexts = Array.from({ length: 12 }, (_, i) => dayjs().month(i).format('MMM'))
// 生成月份选项
const months = Array.from({ length: 12 }, (_, i) => ({
value: (i + 1).toString().padStart(2, '0'),
label: monthTexts[i],
}))
// 根据年份和月份计算该月的天数
const getDaysInMonth = (year: string, month: string): number => {
if (!year || !month) return 31 // 默认返回31天
const yearNum = parseInt(year)
const monthNum = parseInt(month)
// 使用 Date 对象计算该月的实际天数
// 传入下个月的第0天会返回上个月的最后一天
return new Date(yearNum, monthNum, 0).getDate()
}
// 监听年份和月份变化,动态生成日期选项
const selectedYear = form.watch('year')
const selectedMonth = form.watch('month')
const selectedDay = form.watch('day')
const daysInMonth = getDaysInMonth(selectedYear, selectedMonth)
// 生成日期选项,根据选中的年月动态调整
const days = Array.from({ length: daysInMonth }, (_, i) => ({
value: (i + 1).toString().padStart(2, '0'),
label: (i + 1).toString(),
}))
// 当年份或月份变化时,检查并调整日期
useEffect(() => {
if (selectedYear && selectedMonth && selectedDay) {
const currentDay = parseInt(selectedDay)
const maxDay = getDaysInMonth(selectedYear, selectedMonth)
// 如果当前选中的日期超出了该月的最大天数,自动调整为该月最后一天
if (currentDay > maxDay) {
form.setValue('day', maxDay.toString().padStart(2, '0'), { shouldValidate: true })
}
}
}, [selectedYear, selectedMonth, selectedDay, form])
return (
<div className="mx-auto max-w-[752px] pt-6 pb-6">
{/* 标题栏 */}
<div className="mb-6 flex items-center gap-2">
<IconButton variant="ghost" size="large" onClick={handleBack} className="p-2">
<i className="iconfont icon-arrow-left !text-[16px]" />
</IconButton>
<h1 className="txt-title-l text-txt-primary-normal">Edit Profile</h1>
</div>
{/* 表单容器 */}
<div className="bg-surface-base-normal rounded-2xl p-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* 昵称字段 */}
<FormField
control={form.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel className="txt-label-m text-txt-primary-normal">Nickname</FormLabel>
<FormControl>
<Input
placeholder="Enter nickname"
maxLength={20}
showCount
error={!!form.formState.errors.nickname}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 性别字段 */}
<FormField
control={form.control}
name="gender"
disabled={true}
render={({ field }) => (
<FormItem>
<FormLabel className="txt-label-m text-txt-primary-normal">Gender</FormLabel>
<FormControl>
<GenderInput
value={field.value as Gender}
onChange={field.onChange}
disabled={true}
/>
</FormControl>
<p className="txt-body-s text-txt-secondary-normal">
Please note: gender cannot be changed after setting
</p>
<FormMessage />
</FormItem>
)}
/>
{/* 年龄字段 */}
<FormItem>
<FormLabel className="txt-label-m text-txt-primary-normal">Age</FormLabel>
<div className="grid grid-cols-3 gap-2">
<FormField
control={form.control}
name="year"
render={({ field }) => (
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger block error={!!form.formState.errors.year}>
<SelectValue placeholder="Year" />
</SelectTrigger>
<SelectContent>
{years.map((year) => (
<SelectItem key={`${year.value}`} value={`${year.value}`}>
{year.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
)}
/>
<FormField
control={form.control}
name="month"
render={({ field }) => (
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger block error={!!form.formState.errors.year}>
<SelectValue placeholder="Month" />
</SelectTrigger>
<SelectContent>
{months.map((month) => (
<SelectItem key={month.value} value={month.value}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
)}
/>
<FormField
control={form.control}
name="day"
render={({ field }) => (
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger block error={!!form.formState.errors.year}>
<SelectValue placeholder="Day" />
</SelectTrigger>
<SelectContent>
{days.map((day) => (
<SelectItem key={day.value} value={day.value}>
{day.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
)}
/>
</div>
<FormMessage>
{form.formState.errors.year?.message ||
form.formState.errors.month?.message ||
form.formState.errors.day?.message}
</FormMessage>
</FormItem>
{/* 保存按钮 */}
<div className="flex justify-end">
<Button type="submit" size="large" disabled={!isValid || !isDirty} loading={loading}>
Save
</Button>
</div>
</form>
</Form>
</div>
</div>
)
}
export default EditPage

View File

@ -1,7 +1,343 @@
import EditPage from './edit-page'
'use client';
const Page = () => {
return <EditPage />
}
import { useForm } from 'react-hook-form';
import { IconButton, Button } from '@/components/ui/button';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import GenderInput from '@/components/features/genderInput';
import { Gender } from '@/types/user';
import { useRouter } from 'next/navigation';
import { useCheckNickname, useCurrentUser, useUpdateUser } from '@/hooks/auth';
import { useEffect, useState } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { calculateAge } from '@/lib/utils';
import dayjs from 'dayjs';
export default Page
const schema = z
.object({
nickname: z
.string()
.trim()
.min(1, 'Nickname is required')
.min(2, 'Nickname must be between 2 and 20 characters'),
gender: z.enum(Gender, { message: 'Please select a gender' }),
year: z.string().min(1, 'Please select birth year'),
month: z.string().min(1, 'Please select birth month'),
day: z.string().min(1, 'Please select birth day'),
})
.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'],
}
);
type EditProfileFormData = z.infer<typeof schema>;
const EditPage = () => {
const router = useRouter();
const { data: user, isLoading } = useCurrentUser();
const { mutateAsync: updateUser } = useUpdateUser();
const { mutateAsync: checkNickname } = useCheckNickname({
onError: (error) => {
form.setError('nickname', {
message: error.errorMsg,
});
},
});
const [loading, setLoading] = useState(false);
// 获取用户生日如果是时间戳则转换为Date对象
const getUserBirthday = () => {
if (!user?.birthday) return null;
return new Date(user.birthday);
};
const userBirthday = getUserBirthday();
const form = useForm<EditProfileFormData>({
resolver: zodResolver(schema),
defaultValues: {
nickname: user?.nickname || '',
gender: user?.sex,
year: userBirthday ? userBirthday.getFullYear().toString() : '',
month: userBirthday ? (userBirthday.getMonth() + 1).toString().padStart(2, '0') : '',
day: userBirthday ? userBirthday.getDate().toString().padStart(2, '0') : '',
},
mode: 'onChange',
});
const {
formState: { isValid, isDirty },
} = form;
// 当用户数据加载完成后,更新表单值
useEffect(() => {
if (user && !isLoading) {
const userBirthday = getUserBirthday();
form.reset({
nickname: user.nickname || '',
gender: user.sex,
year: userBirthday ? userBirthday.getFullYear().toString() : '',
month: userBirthday ? (userBirthday.getMonth() + 1).toString().padStart(2, '0') : '',
day: userBirthday ? userBirthday.getDate().toString().padStart(2, '0') : '',
});
}
}, [user, isLoading, form]);
const onSubmit = async (data: EditProfileFormData) => {
if (!user?.userId) {
return;
}
setLoading(true);
try {
const isExist = await checkNickname({
nickname: data.nickname.trim(),
exUserId: user.userId,
});
if (isExist) {
form.setError('nickname', {
message: 'This nickname is already taken',
});
return;
}
// 将日期字符串转换为时间戳
const birthdayDate = new Date(`${data.year}-${data.month}-${data.day}`);
const birthdayTimestamp = birthdayDate.getTime();
await updateUser({
nickname: data.nickname,
birthday: birthdayTimestamp,
userId: user.userId,
});
router.push('/profile');
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
const handleBack = () => {
router.back();
};
// 生成年份选项 (当前年份往前100年)
const currentYear = new Date().getFullYear();
const years = Array.from({ length: 100 }, (_, i) => currentYear - i).map((year) => ({
value: year.toString(),
label: year.toString(),
}));
const monthTexts = Array.from({ length: 12 }, (_, i) => dayjs().month(i).format('MMM'));
// 生成月份选项
const months = Array.from({ length: 12 }, (_, i) => ({
value: (i + 1).toString().padStart(2, '0'),
label: monthTexts[i],
}));
// 根据年份和月份计算该月的天数
const getDaysInMonth = (year: string, month: string): number => {
if (!year || !month) return 31; // 默认返回31天
const yearNum = parseInt(year);
const monthNum = parseInt(month);
// 使用 Date 对象计算该月的实际天数
// 传入下个月的第0天会返回上个月的最后一天
return new Date(yearNum, monthNum, 0).getDate();
};
// 监听年份和月份变化,动态生成日期选项
const selectedYear = form.watch('year');
const selectedMonth = form.watch('month');
const selectedDay = form.watch('day');
const daysInMonth = getDaysInMonth(selectedYear, selectedMonth);
// 生成日期选项,根据选中的年月动态调整
const days = Array.from({ length: daysInMonth }, (_, i) => ({
value: (i + 1).toString().padStart(2, '0'),
label: (i + 1).toString(),
}));
// 当年份或月份变化时,检查并调整日期
useEffect(() => {
if (selectedYear && selectedMonth && selectedDay) {
const currentDay = parseInt(selectedDay);
const maxDay = getDaysInMonth(selectedYear, selectedMonth);
// 如果当前选中的日期超出了该月的最大天数,自动调整为该月最后一天
if (currentDay > maxDay) {
form.setValue('day', maxDay.toString().padStart(2, '0'), { shouldValidate: true });
}
}
}, [selectedYear, selectedMonth, selectedDay, form]);
return (
<div className="mx-auto px-4 max-w-[752px] pt-6 pb-6">
{/* 标题栏 */}
<div className="mb-6 flex items-center gap-2">
<IconButton variant="ghost" size="large" onClick={handleBack} className="p-2">
<i className="iconfont icon-arrow-left !text-[16px]" />
</IconButton>
<h1 className="txt-title-l text-txt-primary-normal">Edit Profile</h1>
</div>
{/* 表单容器 */}
<div className="bg-surface-base-normal rounded-2xl p-6">
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
{/* 昵称字段 */}
<FormField
control={form.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel className="txt-label-m text-txt-primary-normal">Nickname</FormLabel>
<FormControl>
<Input
placeholder="Enter nickname"
maxLength={20}
showCount
error={!!form.formState.errors.nickname}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{/* 性别字段 */}
<FormField
control={form.control}
name="gender"
disabled={true}
render={({ field }) => (
<FormItem>
<FormLabel className="txt-label-m text-txt-primary-normal">Gender</FormLabel>
<FormControl>
<GenderInput
value={field.value as Gender}
onChange={field.onChange}
disabled={true}
/>
</FormControl>
<p className="txt-body-s text-txt-secondary-normal">
Please note: gender cannot be changed after setting
</p>
<FormMessage />
</FormItem>
)}
/>
{/* 年龄字段 */}
<FormItem>
<FormLabel className="txt-label-m text-txt-primary-normal">Age</FormLabel>
<div className="grid grid-cols-3 gap-2">
<FormField
control={form.control}
name="year"
render={({ field }) => (
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger block error={!!form.formState.errors.year}>
<SelectValue placeholder="Year" />
</SelectTrigger>
<SelectContent>
{years.map((year) => (
<SelectItem key={`${year.value}`} value={`${year.value}`}>
{year.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
)}
/>
<FormField
control={form.control}
name="month"
render={({ field }) => (
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger block error={!!form.formState.errors.year}>
<SelectValue placeholder="Month" />
</SelectTrigger>
<SelectContent>
{months.map((month) => (
<SelectItem key={month.value} value={month.value}>
{month.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
)}
/>
<FormField
control={form.control}
name="day"
render={({ field }) => (
<FormControl>
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger block error={!!form.formState.errors.year}>
<SelectValue placeholder="Day" />
</SelectTrigger>
<SelectContent>
{days.map((day) => (
<SelectItem key={day.value} value={day.value}>
{day.label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
)}
/>
</div>
<FormMessage>
{form.formState.errors.year?.message ||
form.formState.errors.month?.message ||
form.formState.errors.day?.message}
</FormMessage>
</FormItem>
{/* 保存按钮 */}
<div className="flex justify-end">
<Button type="submit" size="large" disabled={!isValid || !isDirty} loading={loading}>
Save
</Button>
</div>
</form>
</Form>
</div>
</div>
);
};
export default EditPage;

View File

@ -1,9 +1,91 @@
import ProfilePage from './profile-page'
'use client';
import React, { useState } from 'react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useCurrentUser } from '@/hooks/auth';
import { IconButton } from '@/components/ui/button';
import { toast } from 'sonner';
import ProfileFeatureList from './components/ProfileFeatureList';
import ProfileDropdown from './components/ProfileDropdown';
import AvatarSetting from './components/AvatarSetting';
// import CharacterList from './components/CharacterList'
import Image from 'next/image';
const genderMap = {
0: '/icons/male.svg',
1: '/icons/female.svg',
2: '/icons/gender-neutral.svg',
};
export default function ProfilePage() {
const { data: user } = useCurrentUser();
const [isAvatarSettingOpen, setIsAvatarSettingOpen] = useState(false);
const openAvatarSetting = () => {
setIsAvatarSettingOpen(true);
};
const closeAvatarSetting = () => {
setIsAvatarSettingOpen(false);
};
export default function Profile() {
return (
<div className="container mx-auto py-8">
<ProfilePage />
<div className="px-4 sm:px-12 py-8">
<div className="relative mx-auto flex max-w-[736px] flex-col items-center pt-4 pb-8">
{/* 用户头像和信息 */}
<div className="flex w-full flex-col items-center gap-6">
{/* 头像 */}
<div className="relative cursor-pointer" onClick={openAvatarSetting}>
<Avatar className="size-20 cursor-pointer">
<AvatarImage
className="h-20 w-20 object-cover"
src={user?.headImage}
width={80}
height={80}
/>
<AvatarFallback className="!txt-headline-m">
{user?.nickname?.slice(0, 1)}
</AvatarFallback>
</Avatar>
<div className="absolute right-0 bottom-0">
<IconButton size="xs" variant="tertiaryDark" iconfont="icon-icon_order_remark" />
</div>
)
</div>
<AvatarSetting
isOpen={isAvatarSettingOpen}
onClose={closeAvatarSetting}
currentAvatar={user?.headImage}
/>
{/* 用户名和ID */}
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-1">
<h1 className="txt-headline-s text-white">{user?.nickname}</h1>
<Image src={genderMap[user?.sex ?? 0]} alt="Gender" width={24} height={24} />
</div>
<div className="flex items-center gap-1">
<span className="txt-label-m text-txt-secondary-normal">ID: {user?.idCard}</span>
<IconButton
size="xs"
variant="ghost"
onClick={() => {
navigator.clipboard.writeText(user?.idCard?.toString() || '');
toast.success('Copied to clipboard');
}}
>
<i className="iconfont icon-copy text-txt-secondary-normal"></i>
</IconButton>
</div>
</div>
{/* 功能卡片 */}
<ProfileFeatureList />
</div>
{/* <CharacterList /> */}
<ProfileDropdown />
</div>
</div>
);
}

View File

@ -1,91 +0,0 @@
'use client'
import React, { useState } from 'react'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useCurrentUser } from '@/hooks/auth'
import { IconButton } from '@/components/ui/button'
import { toast } from 'sonner'
import ProfileFeatureList from './components/ProfileFeatureList'
import ProfileDropdown from './components/ProfileDropdown'
import AvatarSetting from './components/AvatarSetting'
// import CharacterList from './components/CharacterList'
import Image from 'next/image'
const genderMap = {
0: '/icons/male.svg',
1: '/icons/female.svg',
2: '/icons/gender-neutral.svg',
}
export default function ProfilePage() {
const { data: user } = useCurrentUser()
const [isAvatarSettingOpen, setIsAvatarSettingOpen] = useState(false)
const openAvatarSetting = () => {
setIsAvatarSettingOpen(true)
}
const closeAvatarSetting = () => {
setIsAvatarSettingOpen(false)
}
return (
<div className="px-12">
<div className="relative mx-auto flex max-w-[736px] flex-col items-center pt-4 pb-8">
{/* 用户头像和信息 */}
<div className="flex w-full flex-col items-center gap-6">
{/* 头像 */}
<div className="relative cursor-pointer" onClick={openAvatarSetting}>
<Avatar className="size-20 cursor-pointer">
<AvatarImage
className="h-20 w-20 object-cover"
src={user?.headImage}
width={80}
height={80}
/>
<AvatarFallback className="!txt-headline-m">
{user?.nickname?.slice(0, 1)}
</AvatarFallback>
</Avatar>
<div className="absolute right-0 bottom-0">
<IconButton size="xs" variant="tertiaryDark" iconfont="icon-icon_order_remark" />
</div>
</div>
<AvatarSetting
isOpen={isAvatarSettingOpen}
onClose={closeAvatarSetting}
currentAvatar={user?.headImage}
/>
{/* 用户名和ID */}
<div className="flex flex-col items-center gap-1">
<div className="flex items-center gap-1">
<h1 className="txt-headline-s text-white">{user?.nickname}</h1>
<Image src={genderMap[user?.sex ?? 0]} alt="Gender" width={24} height={24} />
</div>
<div className="flex items-center gap-1">
<span className="txt-label-m text-txt-secondary-normal">ID: {user?.idCard}</span>
<IconButton
size="xs"
variant="ghost"
onClick={() => {
navigator.clipboard.writeText(user?.idCard?.toString() || '')
toast.success('Copied to clipboard')
}}
>
<i className="iconfont icon-copy text-txt-secondary-normal"></i>
</IconButton>
</div>
</div>
{/* 功能卡片 */}
<ProfileFeatureList />
</div>
{/* <CharacterList /> */}
<ProfileDropdown />
</div>
</div>
)
}

View File

@ -1,111 +1,111 @@
'use client'
'use client';
import { discordOAuth } from '@/lib/oauth/discord'
import { SocialButton } from './SocialButton'
import { toast } from 'sonner'
import { useEffect, useRef } from 'react'
import { useLogin } from '@/hooks/auth'
import { useRouter, useSearchParams } from 'next/navigation'
import { tokenManager } from '@/lib/auth/token'
import { AppClient, ThirdType } from '@/services/auth'
import { discordOAuth } from '@/lib/oauth/discord';
import { SocialButton } from './SocialButton';
import { toast } from 'sonner';
import { useEffect, useRef } from 'react';
import { useLogin } from '@/hooks/auth';
import { useRouter, useSearchParams } from 'next/navigation';
import { tokenManager } from '@/lib/auth/token';
import { AppClient, ThirdType } from '@/services/auth';
const DiscordButton = () => {
const login = useLogin()
const router = useRouter()
const searchParams = useSearchParams()
const redirect = searchParams.get('redirect')
const login = useLogin();
const router = useRouter();
const searchParams = useSearchParams();
const redirect = searchParams.get('redirect');
// 处理Discord OAuth回调
useEffect(() => {
const discordCode = searchParams.get('discord_code')
const discordState = searchParams.get('discord_state')
const error = searchParams.get('error')
const discordCode = searchParams.get('discord_code');
const discordState = searchParams.get('discord_state');
const error = searchParams.get('error');
// 处理错误情况
if (error) {
toast.error('Discord login failed')
toast.error('Discord login failed');
// 清理URL参数
const newUrl = new URL(window.location.href)
newUrl.searchParams.delete('error')
router.replace(newUrl.pathname)
return
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete('error');
router.replace(newUrl.pathname);
return;
}
// 处理Discord授权码
if (discordCode) {
// 验证state参数可选的安全检查
const savedState = sessionStorage.getItem('discord_oauth_state')
const savedState = sessionStorage.getItem('discord_oauth_state');
if (savedState && discordState && savedState !== discordState) {
toast.error('Discord login failed')
return
toast.error('Discord login failed');
return;
}
// 使用code调用后端登录接口
const deviceId = tokenManager.getDeviceId()
const deviceId = tokenManager.getDeviceId();
const loginData = {
appClient: AppClient.Web,
deviceCode: deviceId,
thirdToken: discordCode, // 直接传递Discord授权码
thirdType: ThirdType.Discord,
}
};
login.mutate(loginData, {
onSuccess: () => {
toast.success('Login successful')
toast.success('Login successful');
// 清除 Next.js 路由缓存,避免使用登录前 prefetch 的重定向响应
router.refresh()
router.refresh();
// 清理URL参数和sessionStorage
sessionStorage.removeItem('discord_oauth_state')
const newUrl = new URL(window.location.href)
newUrl.searchParams.delete('discord_code')
newUrl.searchParams.delete('discord_state')
router.replace(newUrl.pathname)
sessionStorage.removeItem('discord_oauth_state');
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete('discord_code');
newUrl.searchParams.delete('discord_state');
router.replace(newUrl.pathname);
const loginRedirectUrl = sessionStorage.getItem('login_redirect_url')
const loginRedirectUrl = sessionStorage.getItem('login_redirect_url');
// 重定向到首页或指定页面
if (loginRedirectUrl) {
router.push(loginRedirectUrl)
router.push(loginRedirectUrl);
} else {
router.push('/')
router.push('/');
}
},
onError: (error) => {
// 清理URL参数
const newUrl = new URL(window.location.href)
newUrl.searchParams.delete('discord_code')
newUrl.searchParams.delete('discord_state')
newUrl.searchParams.delete('redirect')
router.replace(newUrl.pathname)
const newUrl = new URL(window.location.href);
newUrl.searchParams.delete('discord_code');
newUrl.searchParams.delete('discord_state');
newUrl.searchParams.delete('redirect');
router.replace(newUrl.pathname);
},
})
});
}
}, [])
}, []);
const handleDiscordLogin = () => {
try {
// 生成随机state用于安全验证
const state = Math.random().toString(36).substring(2, 15)
const state = Math.random().toString(36).substring(2, 15);
// 获取Discord授权URL
const authUrl = discordOAuth.getAuthUrl(state)
const authUrl = discordOAuth.getAuthUrl(state);
// 将state保存到sessionStorage用于后续验证
if (typeof window !== 'undefined') {
sessionStorage.setItem('discord_oauth_state', state)
sessionStorage.setItem('login_redirect_url', redirect || '')
sessionStorage.setItem('discord_oauth_state', state);
sessionStorage.setItem('login_redirect_url', redirect || '');
}
// 跳转到Discord授权页面
window.location.href = authUrl
window.location.href = authUrl;
} catch (error) {
console.error('Discord login error:', error)
toast.error('Discord login failed')
}
console.error('Discord login error:', error);
toast.error('Discord login failed');
}
};
return (
<SocialButton
@ -115,7 +115,7 @@ const DiscordButton = () => {
>
{login.isPending ? 'Signing in...' : 'Continue with Discord'}
</SocialButton>
)
}
);
};
export default DiscordButton
export default DiscordButton;

View File

@ -1,6 +1,7 @@
import React, { ReactNode, useMemo } from 'react';
import React, { ReactNode, useEffect, useMemo, useRef } from 'react';
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
import { cn } from '@/lib/utils';
import { useSize } from 'ahooks';
interface InfiniteScrollListProps<T> {
/**
@ -42,7 +43,8 @@ interface InfiniteScrollListProps<T> {
lg?: number;
xl?: number;
}
| number;
| number
| ((width: number) => number);
/**
*
*/
@ -104,6 +106,8 @@ export function InfiniteScrollList<T>({
threshold = 200,
enabled = true,
}: InfiniteScrollListProps<T>) {
const ref = useRef<HTMLDivElement>(null);
const size = useSize(ref);
const { loadMoreRef, isFetching } = useInfiniteScroll({
hasNextPage,
isLoading,
@ -115,7 +119,6 @@ export function InfiniteScrollList<T>({
// 生成网格列数的CSS类名映射
const gridColsClass = useMemo(() => {
if (typeof columns === 'number') {
const gridClassMap: Record<number, string> = {
1: 'grid-cols-1',
2: 'grid-cols-2',
@ -124,8 +127,13 @@ export function InfiniteScrollList<T>({
5: 'grid-cols-5',
6: 'grid-cols-6',
};
if (typeof columns === 'number') {
return gridClassMap[columns] || 'grid-cols-4';
}
if (typeof columns === 'function') {
const col = columns(size?.width || 0);
return gridClassMap[col] || 'grid-cols-4';
}
// 使用完整的类名字符串,让 Tailwind 能够正确识别
const classes: string[] = [];
@ -171,7 +179,7 @@ export function InfiniteScrollList<T>({
if (columns.xl === 6) classes.push('xl:grid-cols-6');
return classes.join(' ');
}, [columns]);
}, [columns, size?.width]);
// 生成间距类名
const gapClass = useMemo(() => {
@ -187,49 +195,14 @@ export function InfiniteScrollList<T>({
return gapClassMap[gap] || 'gap-4';
}, [gap]);
// 错误状态
if (hasError && ErrorComponent && onRetry) {
return <ErrorComponent onRetry={onRetry} />;
}
// 首次加载状态
if (isLoading && items.length === 0) {
if (LoadingSkeleton) {
return (
<div className={cn('grid', gridColsClass, gapClass, className)}>
{Array.from({ length: 8 }).map((_, index) => (
<LoadingSkeleton key={index} />
))}
</div>
);
}
return (
<div className={cn('grid', gridColsClass, gapClass, className)}>
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="bg-surface-nest-normal aspect-[3/4] animate-pulse rounded-2xl"
/>
))}
</div>
);
}
// 空状态
if (items.length === 0 && EmptyComponent) {
return <EmptyComponent />;
}
return (
<div className="w-full">
let finalDom = (
<>
{/* 主要内容 */}
<div className={cn('grid', gridColsClass, gapClass, className)}>
{items.map((item, index) => (
<React.Fragment key={getItemKey(item, index)}>{renderItem(item, index)}</React.Fragment>
))}
</div>
{/* 加载更多触发器 - 只在没有错误时显示 */}
{hasNextPage && !hasError && (
<div ref={loadMoreRef} className="mt-8 flex justify-center">
@ -247,6 +220,46 @@ export function InfiniteScrollList<T>({
)}
</div>
)}
</>
);
// 错误状态
if (hasError && ErrorComponent && onRetry) {
finalDom = <ErrorComponent onRetry={onRetry} />;
}
// 首次加载状态
if (isLoading && items.length === 0) {
if (LoadingSkeleton) {
finalDom = (
<div className={cn('grid', gridColsClass, gapClass, className)}>
{Array.from({ length: 8 }).map((_, index) => (
<LoadingSkeleton key={index} />
))}
</div>
);
}
finalDom = (
<div className={cn('grid', gridColsClass, gapClass, className)}>
{Array.from({ length: 8 }).map((_, index) => (
<div
key={index}
className="bg-surface-nest-normal aspect-[3/4] animate-pulse rounded-2xl"
/>
))}
</div>
);
}
// 空状态
if (items.length === 0 && EmptyComponent) {
finalDom = <EmptyComponent />;
}
return (
<div className="w-full" ref={ref}>
{finalDom}
</div>
);
}

View File

@ -1,4 +1,4 @@
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
authService,
CheckNicknameRequest,
@ -6,28 +6,29 @@ import {
UpdateUserInfoRequest,
type LoginRequest,
type LoginResponse,
} from '@/services/auth'
import { authKeys, userKeys } from '@/lib/query-keys'
import { tokenManager } from '@/lib/auth/token'
import { toast } from 'sonner'
import type { ApiError } from '@/types/api'
import { useRouter } from 'next/navigation'
import { userService } from '@/services/user'
} from '@/services/auth';
import { authKeys, userKeys } from '@/lib/query-keys';
import { tokenManager } from '@/lib/auth/token';
import { toast } from 'sonner';
import type { ApiError } from '@/types/api';
import { useRouter } from 'next/navigation';
import { userService } from '@/services/user';
export function useLogin() {
const queryClient = useQueryClient()
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: LoginRequest): Promise<LoginResponse> => authService.login(data),
onSuccess: (response: LoginResponse) => {
console.log('useLogin onSuccess save token', response.token);
// 保存token到cookie
tokenManager.setToken(response.token)
tokenManager.setToken(response.token);
// 刷新当前用户信息
queryClient.invalidateQueries({
queryKey: authKeys.currentUser(),
})
});
},
})
});
}
export function useToken() {
@ -35,33 +36,33 @@ export function useToken() {
isLogin: tokenManager.isAuthenticated(),
token: tokenManager.getToken(),
getLoginStatus: () => {
return tokenManager.isAuthenticated()
return tokenManager.isAuthenticated();
},
}
};
}
export function useLogout() {
const queryClient = useQueryClient()
const router = useRouter()
const queryClient = useQueryClient();
const router = useRouter();
return useMutation({
mutationFn: () => authService.logout(),
onSuccess: () => {
// 清除token
tokenManager.removeToken()
tokenManager.removeToken();
// 显示成功提示
toast.success('Log out successful!')
toast.success('Log out successful!');
// 清除所有查询缓存
queryClient.clear()
router.push('/')
queryClient.clear();
router.push('/');
},
onError: (error: ApiError) => {
// 即使登出接口失败也要清除本地token
tokenManager.removeToken()
queryClient.clear()
tokenManager.removeToken();
queryClient.clear();
// 跳转到登录页
router.push('/')
router.push('/');
},
})
});
}
export function useCurrentUser() {
@ -78,104 +79,104 @@ export function useCurrentUser() {
error.errorCode === 'AUTH_TOKEN_INVALID' ||
error.errorCode === 'AUTH_UNAUTHORIZED'
) {
tokenManager.removeToken()
return false
tokenManager.removeToken();
return false;
}
return failureCount < 1
return failureCount < 1;
},
staleTime: 60 * 1000, // 60秒后缓存过期
})
});
}
export function useCompleteUser() {
const queryClient = useQueryClient()
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CompleteUserInfoRequest) => authService.completeUserInfo(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: authKeys.currentUser(),
})
});
},
})
});
}
export function useUpdateUser() {
const queryClient = useQueryClient()
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateUserInfoRequest) => authService.updateUserInfo(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: authKeys.currentUser(),
})
});
},
})
});
}
export function useDeleteUser() {
const queryClient = useQueryClient()
const router = useRouter()
const queryClient = useQueryClient();
const router = useRouter();
return useMutation({
mutationFn: () => authService.deleteUser(),
onSuccess: () => {
tokenManager.removeToken()
queryClient.clear()
router.push('/login')
tokenManager.removeToken();
queryClient.clear();
router.push('/login');
},
})
});
}
// 注册功能暂未实现,保留接口定义
export function useRegister() {
const queryClient = useQueryClient()
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: any) => {
// TODO: 实现注册接口
throw new Error('注册功能暂未实现')
throw new Error('注册功能暂未实现');
},
onSuccess: (response: LoginResponse) => {
// 注册成功后自动登录
tokenManager.setToken(response.token)
toast.success('Successful registration!')
tokenManager.setToken(response.token);
toast.success('Successful registration!');
queryClient.invalidateQueries({
queryKey: authKeys.currentUser(),
})
});
},
onError: (error: ApiError) => {
console.error('注册失败:', {
errorCode: error.errorCode,
errorMsg: error.errorMsg,
traceId: error.traceId,
})
});
toast.error('Registration failed.', {
description: error.errorMsg || '请稍后重试',
})
});
},
})
});
}
// 检查是否已登录的hook
export function useIsAuthenticated() {
return tokenManager.isAuthenticated()
return tokenManager.isAuthenticated();
}
export function useCheckNickname({ onError }: { onError?: (error: ApiError) => void }) {
return useMutation({
mutationFn: (data: CheckNicknameRequest) => authService.checkNickname(data),
onError: onError,
})
});
}
export function useCheckText() {
return useMutation({
mutationFn: async (data: { content: string }) => {
const result = await authService.checkText(data)
const result = await authService.checkText(data);
if (result) {
return `We found some words that might not be allowed: ${result}`
return `We found some words that might not be allowed: ${result}`;
}
return ''
return '';
},
})
});
}
export function useUserNoticeStat() {
@ -183,7 +184,7 @@ export function useUserNoticeStat() {
queryKey: userKeys.noticeStat(),
queryFn: () => userService.getUserNoticeStat(),
enabled: tokenManager.isAuthenticated(),
})
});
}
export function useUserNoticeListInfinite(pageSize: number = 20, enabled: boolean = true) {
@ -192,11 +193,11 @@ export function useUserNoticeListInfinite(pageSize: number = 20, enabled: boolea
queryFn: ({ pageParam = 1 }) =>
userService.getUserNoticeList({ page: { pn: pageParam, ps: pageSize } }),
getNextPageParam: (lastPage, allPages) => {
const totalPages = Math.ceil((lastPage.tc || 0) / pageSize)
const nextPage = allPages.length + 1
return nextPage <= totalPages ? nextPage : undefined
const totalPages = Math.ceil((lastPage.tc || 0) / pageSize);
const nextPage = allPages.length + 1;
return nextPage <= totalPages ? nextPage : undefined;
},
initialPageParam: 1,
enabled, // 只有在启用时才执行查询
})
});
}

View File

@ -10,17 +10,39 @@ import { cn } from '@/lib/utils';
import CreateReachedLimitDialog from '../components/features/create-reached-limit-dialog';
import { useMedia } from '@/hooks/tools';
import BottomBar from './BottomBar';
import { useStreamChatStore } from '@/stores/stream-chat';
import { useLogin } from '@/hooks/auth';
interface ConditionalLayoutProps {
children: React.ReactNode;
}
const useInitChat = () => {
const { data } = useLogin();
const connect = useStreamChatStore((state) => state.connect);
const queryChannels = useStreamChatStore((state) => state.queryChannels);
const initChat = async () => {
if (data) {
await connect(data);
await queryChannels({});
}
};
useEffect(() => {
initChat();
}, [data]);
};
export default function ConditionalLayout({ children }: ConditionalLayoutProps) {
const pathname = usePathname();
const mainContentRef = useRef<HTMLDivElement>(null);
const prevPathnameRef = useRef<string>(pathname);
const response = useMedia();
// 初始化聊天
useInitChat();
// 路由切换时重置滚动位置
useEffect(() => {
if (prevPathnameRef.current !== pathname) {

View File

@ -6,11 +6,7 @@ import Image from 'next/image';
import { cn } from '@/lib/utils';
import { useMedia } from '@/hooks/tools';
export default function BottomBar() {
const pathname = usePathname();
const response = useMedia({ hide: 500 });
const items = [
export const items = [
{
label: 'Explore',
path: '/home',
@ -35,10 +31,18 @@ export default function BottomBar() {
icon: '/images/layout/me.svg',
selectedIcon: '/images/layout/me_active.svg',
},
];
];
export default function BottomBar() {
const pathname = usePathname();
const response = useMedia({ hide: 500 });
if (!items.some((i) => i.path === pathname)) {
return null;
}
return (
<div className="h-20 z-100 flex items-center justify-between">
<div className="h-20 border-outline-normal bg-[rgba(10,14,43,1)] z-100 flex border-t items-center justify-between">
{items.map((item) => {
const isSelected = pathname === item.path;
return (

View File

@ -48,7 +48,7 @@ function Sidebar() {
return (
<aside
className={cn(
'bg-background-default border-outline-normal sticky top-0 bottom-0 left-0 z-10 h-screen flex-shrink-0 border-r transition-all duration-300 ease-in-out',
'bg-[rgba(10,14,43,1)] border-outline-normal sticky top-0 bottom-0 left-0 z-10 h-screen flex-shrink-0 border-r transition-all duration-300 ease-in-out',
isSidebarExpanded ? 'w-80' : 'w-20'
)}
>

View File

@ -2,11 +2,13 @@
import React, { useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
import Image from 'next/image';
import { Button } from '../components/ui/button';
import { Button, IconButton } from '../components/ui/button';
import { useCurrentUser } from '@/hooks/auth';
import { Avatar, AvatarFallback, AvatarImage } from '../components/ui/avatar';
import Link from 'next/link';
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
import { useMedia } from '@/hooks/tools';
import { items } from './BottomBar';
function Topbar() {
const [isBlur, setIsBlur] = useState(false);
@ -14,7 +16,7 @@ function Topbar() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const response = useMedia();
const searchParamsString = searchParams.toString();
const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}`;
const loginHref = `/login?redirect=${encodeURIComponent(redirectURL)}`;
@ -46,23 +48,35 @@ function Topbar() {
}
}, [user]);
const leftDomRender = () => {
if (!response) return null;
if (response.sm || items.some((item) => item.path === pathname)) {
return (
<header
className={cn(
'absolute z-40 flex h-16 w-full items-center justify-between px-8 transition-all',
{
'backdrop-blur-[10px]': isBlur,
}
)}
>
{isBlur && <div className="bg-background-default absolute inset-0 opacity-85" />}
<div className="relative inset-0 flex w-full items-center justify-between">
<div className="h-8 w-[103.6px]">
<Link href="/">
<Image src="/logo.svg" alt="logo" width={103.6} height={32} />
</Link>
</div>
{user ? (
);
}
return (
<IconButton
variant="ghost"
size="large"
onClick={() => router.back()}
iconfont="icon-arrow-left"
/>
);
};
const rightDomRender = () => {
if (!user)
return (
<Link href={loginHref} prefetch>
<Button size="small">Login in / Sign up</Button>
</Link>
);
return (
<Link href="/profile" prefetch>
<Avatar className="size-8 cursor-pointer">
<AvatarImage
@ -75,11 +89,22 @@ function Topbar() {
<AvatarFallback>{user.nickname?.slice(0, 1)}</AvatarFallback>
</Avatar>
</Link>
) : (
<Link href={loginHref} prefetch>
<Button size="small">Login in / Sign up</Button>
</Link>
);
};
return (
<header
className={cn(
'absolute z-40 flex h-16 w-full items-center justify-between px-4 sm:px-8 transition-all',
{
'backdrop-blur-[10px]': isBlur,
}
)}
>
{isBlur && <div className="bg-background-default absolute inset-0 opacity-85" />}
<div className="relative inset-0 flex w-full items-center justify-between">
{leftDomRender()}
{rightDomRender()}
</div>
</header>
);

View File

@ -16,11 +16,6 @@ const ChatSidebar = () => {
const datas = Array.from(channels.values()).sort((a, b) => {
return false;
// // 获取会话的最后活跃时间(优先使用最后一条消息的时间,否则使用会话更新时间)
// const aTime = a.lastMessage?.messageRefer?.createTime || a.updateTime || 0;
// const bTime = b.lastMessage?.messageRefer?.createTime || b.updateTime || 0;
// // 按时间倒序排列(最新的在前面)
// return bTime - aTime;
});
// 当侧边栏收缩时,取消搜索功能

View File

@ -1,20 +1,20 @@
import Cookies from 'js-cookie'
import Cookies from 'js-cookie';
const TOKEN_COOKIE_NAME = 'st'
const DEVICE_ID_COOKIE_NAME = 'sd'
const TOKEN_COOKIE_NAME = 'st';
const DEVICE_ID_COOKIE_NAME = 'sd';
// 生成设备ID的函数
function generateDeviceId(): string {
const timestamp = Date.now().toString(36)
const randomStr = Math.random().toString(36).substring(2, 15)
const timestamp = Date.now().toString(36);
const randomStr = Math.random().toString(36).substring(2, 15);
const browserInfo =
typeof window !== 'undefined'
? `${window.navigator.userAgent}${window.screen.width}${window.screen.height}`
.replace(/\s/g, '')
.substring(0, 10)
: 'server'
: 'server';
return `did_${timestamp}_${randomStr}_${browserInfo}`.toLowerCase()
return `did_${timestamp}_${randomStr}_${browserInfo}`.toLowerCase();
}
export const tokenManager = {
@ -22,61 +22,61 @@ export const tokenManager = {
getToken: (cookieString?: string): string | null => {
// 服务端环境从传入的cookie字符串中解析
if (typeof window === 'undefined' && cookieString) {
const cookies = parseCookieString(cookieString)
return cookies[TOKEN_COOKIE_NAME] || null
const cookies = parseCookieString(cookieString);
return cookies[TOKEN_COOKIE_NAME] || null;
}
// 客户端环境从document.cookie或localStorage获取
if (typeof window !== 'undefined') {
// 优先从cookie获取
const cookieToken = Cookies.get(TOKEN_COOKIE_NAME)
const cookieToken = Cookies.get(TOKEN_COOKIE_NAME);
if (cookieToken) {
return cookieToken
return cookieToken;
}
// 兼容原有的localStorage存储
const localToken = window.localStorage.getItem('token')
const localToken = window.localStorage.getItem('token');
if (localToken) {
// 迁移到cookie
tokenManager.setToken(localToken)
window.localStorage.removeItem('token')
return localToken
tokenManager.setToken(localToken);
window.localStorage.removeItem('token');
return localToken;
}
}
return null
return null;
},
// 获取设备ID - 支持客户端和服务端
getDeviceId: (cookieString?: string): string => {
// 服务端环境从传入的cookie字符串中解析
if (typeof window === 'undefined' && cookieString) {
const cookies = parseCookieString(cookieString)
let deviceId = cookies[DEVICE_ID_COOKIE_NAME]
const cookies = parseCookieString(cookieString);
let deviceId = cookies[DEVICE_ID_COOKIE_NAME];
// 如果服务端没有设备ID生成一个临时的
if (!deviceId) {
deviceId = generateDeviceId()
deviceId = generateDeviceId();
}
return deviceId
return deviceId;
}
// 客户端环境
if (typeof window !== 'undefined') {
let deviceId = Cookies.get(DEVICE_ID_COOKIE_NAME)
let deviceId = Cookies.get(DEVICE_ID_COOKIE_NAME);
// 如果没有设备ID生成一个新的
if (!deviceId) {
deviceId = generateDeviceId()
tokenManager.setDeviceId(deviceId)
deviceId = generateDeviceId();
tokenManager.setDeviceId(deviceId);
}
return deviceId
return deviceId;
}
// 兜底情况生成临时设备ID
return generateDeviceId()
return generateDeviceId();
},
// 设置token
@ -87,7 +87,7 @@ export const tokenManager = {
expires: 30,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
})
});
}
},
@ -99,16 +99,17 @@ export const tokenManager = {
expires: 365,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
})
});
}
},
// 清除token但保留设备ID
removeToken: (): void => {
if (typeof window !== 'undefined') {
Cookies.remove(TOKEN_COOKIE_NAME)
console.log('remove token');
Cookies.remove(TOKEN_COOKIE_NAME);
// 同时清除可能存在的localStorage token
window.localStorage.removeItem('token')
window.localStorage.removeItem('token');
// 注意这里不清除设备ID
}
},
@ -116,35 +117,35 @@ export const tokenManager = {
// 清除所有数据包括设备ID
clearAll: (): void => {
if (typeof window !== 'undefined') {
Cookies.remove(TOKEN_COOKIE_NAME)
Cookies.remove(DEVICE_ID_COOKIE_NAME)
window.localStorage.removeItem('token')
Cookies.remove(TOKEN_COOKIE_NAME);
Cookies.remove(DEVICE_ID_COOKIE_NAME);
window.localStorage.removeItem('token');
}
},
// 检查是否已登录
isAuthenticated: (cookieString?: string): boolean => {
return !!tokenManager.getToken(cookieString)
return !!tokenManager.getToken(cookieString);
},
// 初始化设备ID确保用户第一次访问时就有设备ID
initializeDeviceId: (): void => {
if (typeof window !== 'undefined') {
tokenManager.getDeviceId() // 这会自动生成并保存设备ID如果不存在的话
tokenManager.getDeviceId(); // 这会自动生成并保存设备ID如果不存在的话
}
},
}
};
// 解析cookie字符串的辅助函数
function parseCookieString(cookieString: string): Record<string, string> {
const cookies: Record<string, string> = {}
const cookies: Record<string, string> = {};
cookieString.split(';').forEach((cookie) => {
const [name, ...rest] = cookie.trim().split('=')
const [name, ...rest] = cookie.trim().split('=');
if (name) {
cookies[name] = rest.join('=')
cookies[name] = rest.join('=');
}
})
});
return cookies
return cookies;
}

View File

@ -72,9 +72,9 @@ export function Providers({ children }: ProvidersProps) {
queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() });
}
if (EXPIRED_ERROR_CODES.includes(error.errorCode)) {
// 清除 cookie 中的 st
tokenManager.removeToken();
router.push('/login?redirect=' + encodeURIComponent(redirectURL));
// TODO: 清除 cookie 中的 st
// tokenManager.removeToken();
// router.push('/login?redirect=' + encodeURIComponent(redirectURL));
return; // 对于登录过期错误不显示错误toast直接跳转
}
if (error.ignoreError) {

View File

@ -1,5 +0,0 @@
import { setupWorker } from 'msw/browser'
import { handlers } from './handlers'
// 设置浏览器端的 Service Worker
export const worker = setupWorker(...handlers)

View File

@ -1,593 +0,0 @@
import { http, HttpResponse } from 'msw'
import type { User } from '@/services/auth'
import type { ApiResponse } from '@/types/api'
import { API_STATUS } from '@/types/api'
import { Gender } from '@/types/user'
import { AiUserInfoOutput, ContentStatus } from '@/services/create'
import { LikedStatus, LockStatus } from '@/services/user'
let cpUserInfo = false
// Mock 数据
const mockUsers: User[] = [
{
userId: 1,
nickname: '张三',
headImage: 'https://global-oss.epal.gg/data/cover/2230384/17031872250492121.jpeg',
sex: Gender.MALE,
birthday: new Date('1990-01-01').getTime(),
cpUserInfo: cpUserInfo,
},
{
userId: 2,
nickname: '李四',
headImage: undefined,
sex: Gender.OTHER,
birthday: new Date('1992-05-15').getTime(),
cpUserInfo: false,
},
]
// 模拟的 token
const MOCK_TOKEN = 'mock-jwt-token-12345'
// 验证设备ID的辅助函数
function validateDeviceId(request: Request): { deviceId: string | null; isValid: boolean } {
const deviceId = request.headers.get('AUTH_DID')
if (!deviceId) {
console.warn('⚠️ 请求缺少设备ID (AUTH_DID)')
return { deviceId: null, isValid: false }
}
// 简单验证设备ID格式
if (!deviceId.startsWith('did_')) {
console.warn('⚠️ 设备ID格式不正确:', deviceId)
return { deviceId, isValid: false }
}
console.log('✅ 设备ID验证通过:', deviceId)
return { deviceId, isValid: true }
}
// 创建成功响应的辅助函数
function createSuccessResponse<T>(content: T): ApiResponse<T> {
return {
content,
status: API_STATUS.OK,
errorCode: '',
errorMsg: '',
traceId: `trace-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
}
}
// 创建错误响应的辅助函数
function createErrorResponse(errorCode: string, errorMsg: string): ApiResponse<null> {
return {
content: null,
status: 'ERROR',
errorCode,
errorMsg,
traceId: `trace-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
}
}
export const handlers = [
// 认证服务 Mock - 第三方登录Discord、Google、Apple
http.post('*/web/third/login', async ({ request }) => {
console.log('🔐 第三方登录请求')
// 验证设备ID
const { deviceId, isValid } = validateDeviceId(request)
if (!isValid) {
return HttpResponse.json(createErrorResponse('DEVICE_ID_REQUIRED', '设备ID无效或缺失'))
}
const body = (await request.json()) as any
const { thirdType, thirdToken, appClient } = body
console.log(`📱 第三方登录类型: ${thirdType}, 客户端: ${appClient}`)
const loginResponse = {
token: MOCK_TOKEN,
}
console.log(`${thirdType}登录成功设备ID: ${deviceId}`)
return HttpResponse.json(createSuccessResponse(loginResponse))
}),
http.get('*/web/user/base-info', async ({ request }) => {
console.log('👤 获取用户信息')
const authToken = request.headers.get('AUTH_TK')
const { deviceId, isValid } = validateDeviceId(request)
if (!isValid) {
return HttpResponse.json(createErrorResponse('DEVICE_ID_REQUIRED', '设备ID无效或缺失'))
}
if (!authToken || authToken !== MOCK_TOKEN) {
return HttpResponse.json(createErrorResponse('AUTH_TOKEN_INVALID', '认证令牌无效或已过期'))
}
return HttpResponse.json(createSuccessResponse(mockUsers[0]))
}),
http.post('*/web/user/complete-user-info', async ({ request }) => {
console.log('👤 完善用户信息')
cpUserInfo = true
const authToken = request.headers.get('AUTH_TK')
const { deviceId, isValid } = validateDeviceId(request)
if (!isValid) {
return HttpResponse.json(createErrorResponse('DEVICE_ID_REQUIRED', '设备ID无效或缺失'))
}
if (!authToken || authToken !== MOCK_TOKEN) {
return HttpResponse.json(createErrorResponse('AUTH_TOKEN_INVALID', '认证令牌无效或已过期'))
}
const body = (await request.json()) as any
const { nickname, sex, birthday } = body
return HttpResponse.json(createSuccessResponse({ success: true }))
}),
http.post('*/web/user/update-user-info', async ({ request }) => {
console.log('👤 更新用户信息')
const authToken = request.headers.get('AUTH_TK')
const { deviceId, isValid } = validateDeviceId(request)
if (!isValid) {
return HttpResponse.json(createErrorResponse('DEVICE_ID_REQUIRED', '设备ID无效或缺失'))
}
if (!authToken || authToken !== MOCK_TOKEN) {
return HttpResponse.json(createErrorResponse('AUTH_TOKEN_INVALID', '认证令牌无效或已过期'))
}
const body = (await request.json()) as any
const { nickname, birthday, userId } = body
console.log(nickname, birthday, userId)
mockUsers[0].nickname = nickname
mockUsers[0].birthday = birthday
mockUsers[0].userId = userId
return HttpResponse.json(createSuccessResponse({ success: true }))
}),
http.post('*/web/user/logout', async ({ request }) => {
console.log('🚪 退出登录')
const authToken = request.headers.get('AUTH_TK')
const { deviceId, isValid } = validateDeviceId(request)
if (!isValid) {
return HttpResponse.json(createErrorResponse('DEVICE_ID_REQUIRED', '设备ID无效或缺失'))
}
if (!authToken || authToken !== MOCK_TOKEN) {
return HttpResponse.json(createErrorResponse('AUTH_TOKEN_INVALID', '认证令牌无效或已过期'))
}
return HttpResponse.json(createSuccessResponse({ success: true }))
}),
http.post('*/web/user/del', async ({ request }) => {
console.log('👤 删除用户')
const authToken = request.headers.get('AUTH_TK')
const { deviceId, isValid } = validateDeviceId(request)
if (!isValid) {
return HttpResponse.json(createErrorResponse('DEVICE_ID_REQUIRED', '设备ID无效或缺失'))
}
if (!authToken || authToken !== MOCK_TOKEN) {
return HttpResponse.json(createErrorResponse('AUTH_TOKEN_INVALID', '认证令牌无效或已过期'))
}
return HttpResponse.json(createSuccessResponse({ success: true }))
}),
http.post('*/web/get-ai-dict', async ({ request }) => {
console.log('👤 获取AI字典信息')
return HttpResponse.json(
createSuccessResponse({
roleDictList: [
{
code: '1',
name: '原创',
childDictList: [
{
code: '1-1',
name: '原创',
},
],
},
{
code: '2',
name: '同人',
childDictList: [
{
code: '2-1',
name: '动漫',
},
{
code: '2-2',
name: '游戏',
},
{
code: '2-3',
name: '影视',
},
],
},
],
characterDictList: [
{
code: '1',
name: '性格1',
},
{
code: '2',
name: '性格2',
},
],
tagDictList: [
{
code: '1',
name: '标签1',
},
{
code: '2',
name: '标签2',
},
],
imageStyleDictList: [
{
id: 1,
name: '风格1',
url: 'https://picsum.photos/108/108?random=1',
},
{
id: 2,
name: '风格2',
url: 'https://picsum.photos/108/108?random=2',
},
{
id: 3,
name: '风格3',
url: 'https://picsum.photos/108/108?random=3',
},
{
id: 4,
name: '风格4',
url: 'https://picsum.photos/108/108?random=4',
},
{
id: 5,
name: '风格5',
url: 'https://picsum.photos/108/108?random=5',
},
{
id: 6,
name: '风格6',
url: 'https://picsum.photos/108/108?random=6',
},
{
id: 7,
name: '风格7',
url: 'https://picsum.photos/108/108?random=7',
},
{
id: 8,
name: '风格8',
url: 'https://picsum.photos/108/108?random=8',
},
{
id: 9,
name: '风格9',
url: 'https://picsum.photos/108/108?random=9',
},
],
timbreDictList: [
{
id: '1',
name: '男音色1',
description: '男音色1描述',
type: 1,
url: 'https://sound-oss.epal.gg/data/sound/2781558/17489556667515289.mp3',
},
{
id: '2',
name: '男音色2',
description: '男音色2描述',
type: 1,
url: 'https://sound-oss.epal.gg/data/sound/2781558/17489556667515289.mp3',
},
{
id: '3',
name: '男音色3',
description: '男音色3描述',
type: 1,
url: 'https://sound-oss.epal.gg/data/sound/2781558/17489556667515289.mp3',
},
{
id: '4',
name: '男音色4',
description: '男音色4描述',
type: 1,
url: 'https://sound-oss.epal.gg/data/sound/2781558/17489556667515289.mp3',
},
{
id: '5',
name: '女音色1',
description: '女音色1描述',
type: 2,
url: 'https://sound-oss.epal.gg/data/sound/2781558/17489556667515289.mp3',
},
{
id: '6',
name: '女音色2',
description: '女音色2描述',
type: 2,
url: 'https://sound-oss.epal.gg/data/sound/2781558/17489556667515289.mp3',
},
{
id: '7',
name: '女音色3',
description: '女音色3描述',
type: 2,
url: 'https://sound-oss.epal.gg/data/sound/2781558/17489556667515289.mp3',
},
{
id: '8',
name: '女音色4',
description: '女音色4描述',
type: 2,
url: 'https://sound-oss.epal.gg/data/sound/2781558/17489556667515289.mp3',
},
],
})
)
}),
http.post('*/web/gen/user-content', async ({ request }) => {
console.log('👤 使用AI生成内容')
return HttpResponse.json(
createSuccessResponse({
appearance: 'appearance: AI生成内容',
dialogue: 'dialogue: AI生成内容',
figure: 'figure: AI生成内容',
introduction: 'introduction: AI生成内容',
profile: 'profile: AI生成内容',
prologue: 'prologue: AI生成内容',
})
)
}),
http.post('*/web/gen/image-ct', async ({ request }) => {
console.log('👤 生成图片')
return HttpResponse.json(
createSuccessResponse({
batchNo: Date.now().toString(),
})
)
}),
http.post('*/web/gen/image-pl', async ({ request }) => {
console.log('👤 轮询查询图片生成结果')
return HttpResponse.json(
createSuccessResponse([
{
imageUrl: 'https://picsum.photos/400/600?random=1',
status: ContentStatus.Completed,
},
{
imageUrl: 'https://picsum.photos/400/600?random=2',
status: ContentStatus.Completed,
},
{
imageUrl: 'https://picsum.photos/108/108?random=2',
status: ContentStatus.Pending,
},
{
imageUrl: 'https://picsum.photos/108/108?random=4',
status: ContentStatus.Pending,
},
{
imageUrl: 'https://picsum.photos/108/108?random=5',
status: ContentStatus.Pending,
},
{
imageUrl: '',
status: ContentStatus.Pending,
},
])
)
}),
http.post('*/web/ai-user/create-edit', async ({ request }) => {
console.log('👤 创建或编辑AI')
return HttpResponse.json(createSuccessResponse({ success: true }))
}),
http.post('*/web/ai-user-search/base-info', async ({ request }) => {
console.log('👤 获取AI用户基础信息')
return HttpResponse.json(
createSuccessResponse({
age: 20,
aiId: 1,
userId: 1,
characterName: '性格1',
headImg: 'https://hhb.crushlevel.ai/dev/role/17543853962426729.jpg',
homeImageUrl: 'https://hhb.crushlevel.ai/dev/role/17543853962426729.jpg',
introduction: '简介简介简介简介简介简介',
nickname: '张三',
roleName: '角色1',
sex: 1,
tagName: '标签1',
})
)
}),
http.post('*/web/ai-user/stat', async ({ request }) => {
console.log('👤 获取AI用户统计信息')
return HttpResponse.json(
createSuccessResponse({
chatNum: 0,
coinNum: 10000,
conversationNum: 2000,
likedNum: 100,
})
)
}),
http.post('*/web/ai-user-gift/list', async ({ request }) => {
console.log('👤 获取AI用户礼物信息')
return HttpResponse.json(
createSuccessResponse({
datas: new Array(30).fill(0).map((_, index) => ({
id: index + 1,
name: `礼物${index + 1}`,
icon: `https://picsum.photos/108/108?random=${index + 1}`,
getNum: index + 1,
})),
pn: 1,
ps: 30,
tc: 30,
})
)
}),
// 获取AI用户相册列表
http.post('*/web/ai-user/album-list', async ({ request }) => {
console.log('📸 获取AI用户相册列表')
const body = (await request.json()) as { aiId: number; page: { pn: number; ps: number } }
const { page } = body
const { pn = 1, ps = 20 } = page
// 生成模拟相册数据
const albumItems = new Array(Math.min(ps, 15)).fill(0).map((_, index) => {
const id = (pn - 1) * ps + index + 1
const tagTypes = ['default', 'private', 'public', 'premium'] as const
const tagType = tagTypes[index % tagTypes.length]
return {
albumId: id,
width: '108',
height: '108',
img1: `https://picsum.photos/108/108?random=${index + 1}`,
img3: `https://picsum.photos/108/108?random=${index + 1}`,
imgOrder: index + 1,
imgUrl: `https://picsum.photos/108/108?random=${index + 1}`,
isDefault: index === 0,
likedCount: Math.floor(Math.random() * 100),
likedStatus: Math.random() > 0.7 ? LikedStatus.Liked : LikedStatus.Canceled,
lockStatus: Math.random() > 0.7 ? LockStatus.Lock : LockStatus.Unlock,
unlockPrice: Math.floor(Math.random() * 50) + 10,
}
})
return HttpResponse.json(
createSuccessResponse({
datas: albumItems,
pn,
ps,
tc: 100, // 总共45张图片
})
)
}),
// 点赞/取消点赞相册图片
http.post('*/web/album/like_or_cancel', async ({ request }) => {
console.log('❤️ 点赞/取消点赞相册图片')
return HttpResponse.json(createSuccessResponse(null))
}),
// 解锁付费相册内容
http.post('*/web/ai-user/album/unlock', async ({ request }) => {
console.log('🔓 解锁付费相册内容')
return HttpResponse.json(createSuccessResponse(null))
}),
// 设置默认相册图片
http.post('*/web/ai-user/set-default-album', async ({ request }) => {
console.log('👤 设置默认相册图片')
// 延迟2秒返回
await new Promise((resolve) => setTimeout(resolve, 2000))
return HttpResponse.json(createSuccessResponse({ success: true }))
}),
// 设置相册图片解锁方式
http.post('*/web/ai-user/set-album-unlock-price', async ({ request }) => {
console.log('👤 设置相册图片解锁方式')
return HttpResponse.json(createSuccessResponse({ success: true }))
}),
// 删除相册图片
http.post('*/web/ai-user/album-del', async ({ request }) => {
console.log('👤 删除相册图片')
return HttpResponse.json(createSuccessResponse({ success: true }))
}),
http.post('*/web/ai-user-search/base-list', async ({ request }) => {
console.log('👤 获取AI角色列表')
return HttpResponse.json(
createSuccessResponse(
new Array(5).fill(0).map((_, index) => ({
aiId: index + 1,
nickname: `角色${index + 1}`,
headImg: `https://picsum.photos/108/108?random=${index + 1}`,
homeImageUrl: `https://picsum.photos/108/108?random=${index + 1}`,
idCard: `idCard${index + 1}`,
introduction: `简介${index + 1}`,
permission: 1,
roleName: `角色${index + 1}`,
sex: 1,
tagName: `标签${index + 1}`,
birthday: `1990-01-01`,
characterName: `性格${index + 1}`,
roleCode: `roleCode${index + 1}`,
tagCode: `tagCode${index + 1}`,
characterCode: `characterCode${index + 1}`,
}))
)
)
}),
http.post('*/web/ai-user/get-my-ai-user/info', async ({ request }) => {
console.log('👤 获取AI用户信息')
// AiUserInfoOutput
return HttpResponse.json(
createSuccessResponse({
aiId: 1,
nickname: '张三',
headImg: 'https://hhb.crushlevel.ai/dev/role/17543853962426729.jpg',
imageUrl: 'https://hhb.crushlevel.ai/dev/role/17543853962426729.jpg',
introduction: '简介简介简介简介简介简介',
permission: 1,
roleName: '角色1',
sex: 1,
tagName: '标签1',
roleCode: 'roleCode1',
tagCode: 'tagCode1',
characterCode: 'characterCode1',
characterName: '性格1',
birthday: '1990-01-01',
aiUserExt: {
profile: 'profile: AI生成内容',
imageDesc: 'imageDesc: AI生成内容',
imageReferenceUrl: 'https://hhb.crushlevel.ai/dev/role/17543853962426729.jpg',
imageStyleCode: 'imageStyleCode1',
imageStyleUrl: 'https://hhb.crushlevel.ai/dev/role/17543853962426729.jpg',
dialoguePrologue: 'prologue: AI生成内容',
dialogueStyle: 'style: AI生成内容',
dialogueTimbreCode: 'timbreCode1',
dialogueTimbreUrl: 'https://hhb.crushlevel.ai/dev/role/17543853962426729.jpg',
},
} as AiUserInfoOutput)
)
}),
]

View File

@ -1,28 +0,0 @@
export async function enableMocking() {
// 只在开发环境启用 mock
if (process.env.NODE_ENV !== 'development') {
return
}
// 检查是否启用 mock
const shouldMock = process.env.NEXT_PUBLIC_ENABLE_MOCK === 'true'
console.log('shouldMock', shouldMock)
if (!shouldMock) {
return
}
if (typeof window === 'undefined') {
// 服务端环境
const { server } = await import('./server')
server.listen({
onUnhandledRequest: 'bypass',
})
} else {
// 客户端环境
const { worker } = await import('./browser')
await worker.start({
onUnhandledRequest: 'bypass',
})
}
}

View File

@ -1,5 +0,0 @@
import { setupServer } from 'msw/node'
import { handlers } from './handlers'
// 设置 Node.js 端的 MSW 服务器
export const server = setupServer(...handlers)

View File

@ -1,11 +1,11 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// 需要认证的路由
const protectedRoutes = [
'/profile',
'/profile/account',
'/profile/edit',
// '/profile',
// '/profile/account',
// '/profile/edit',
'/create',
'/settings',
'/login/fields',
@ -15,50 +15,50 @@ const protectedRoutes = [
'/wallet',
'/wallet/transactions',
'/crushcoin',
]
];
// 已登录用户不应该访问的路由
const authRoutes = ['/login']
const authRoutes = ['/login'];
const DEVICE_ID_COOKIE_NAME = 'sd'
const DEVICE_ID_COOKIE_NAME = 'sd';
// 生成设备ID
function generateDeviceId(userAgent?: string): string {
const timestamp = Date.now().toString(36)
const randomStr = Math.random().toString(36).substring(2, 15)
const browserInfo = userAgent ? userAgent.replace(/\s/g, '').substring(0, 10) : 'server'
const timestamp = Date.now().toString(36);
const randomStr = Math.random().toString(36).substring(2, 15);
const browserInfo = userAgent ? userAgent.replace(/\s/g, '').substring(0, 10) : 'server';
return `did_${timestamp}_${randomStr}_${browserInfo}`.toLowerCase()
return `did_${timestamp}_${randomStr}_${browserInfo}`.toLowerCase();
}
export default function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const { pathname } = request.nextUrl;
// console.log('🔄 [MIDDLEWARE] 开始处理路径:', pathname)
// console.log('🔄 [MIDDLEWARE] 请求方法:', request.method)
// console.log('🔄 [MIDDLEWARE] User-Agent:', request.headers.get('user-agent')?.substring(0, 50))
// console.log('🔄 [MIDDLEWARE] 请求头:', Object.fromEntries(request.headers.entries()))
// 获取现有设备ID
let deviceId = request.cookies.get(DEVICE_ID_COOKIE_NAME)?.value
let needSetCookie = false
let deviceId = request.cookies.get(DEVICE_ID_COOKIE_NAME)?.value;
let needSetCookie = false;
if (!deviceId) {
// 生成新的设备ID
const userAgent = request.headers.get('user-agent') || undefined
deviceId = generateDeviceId(userAgent)
needSetCookie = true
const userAgent = request.headers.get('user-agent') || undefined;
deviceId = generateDeviceId(userAgent);
needSetCookie = true;
// console.log('🆕 [MIDDLEWARE] 生成新设备ID:', deviceId)
} else {
console.log('✅ [MIDDLEWARE] 获取现有设备ID:', deviceId)
console.log('✅ [MIDDLEWARE] 获取现有设备ID:', deviceId);
}
// 认证逻辑
const token = request.cookies.get('st')?.value
const isAuthenticated = !!token
const isProtectedRoute = protectedRoutes.some((route) => pathname.startsWith(route))
const token = request.cookies.get('st')?.value;
const isAuthenticated = !!token;
const isProtectedRoute = protectedRoutes.some((route) => pathname.startsWith(route));
const isAuthRoute = authRoutes.some(
(route) => pathname.startsWith(route) && !pathname.startsWith('/login/fields')
)
);
// console.log('🔑 [MIDDLEWARE] 认证状态:', {
// isAuthenticated,
@ -70,32 +70,32 @@ export default function proxy(request: NextRequest) {
// 如果是受保护的路由但用户未登录,重定向到登录页
if (isProtectedRoute && !isAuthenticated) {
console.log('🚫 [MIDDLEWARE] 重定向到登录页:', pathname)
const loginUrl = new URL('/login', request.url)
loginUrl.searchParams.set('redirect', pathname)
return NextResponse.redirect(loginUrl)
console.log('🚫 [MIDDLEWARE] 重定向到登录页:', pathname);
const loginUrl = new URL('/login', request.url);
loginUrl.searchParams.set('redirect', pathname);
return NextResponse.redirect(loginUrl);
}
// 如果已登录用户访问认证页面,重定向到首页
if (isAuthRoute && isAuthenticated) {
console.log('🔄 [MIDDLEWARE] 已登录用户重定向到首页:', pathname)
return NextResponse.redirect(new URL('/', request.url))
console.log('🔄 [MIDDLEWARE] 已登录用户重定向到首页:', pathname);
return NextResponse.redirect(new URL('/', request.url));
}
// 在请求头中添加认证状态和设备ID供服务端组件使用
const requestHeaders = new Headers(request.headers)
requestHeaders.set('x-authenticated', isAuthenticated.toString())
requestHeaders.set('x-device-id', deviceId) // 确保设备ID被传递
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-authenticated', isAuthenticated.toString());
requestHeaders.set('x-device-id', deviceId); // 确保设备ID被传递
if (token) {
requestHeaders.set('x-auth-token', token)
requestHeaders.set('x-auth-token', token);
}
// 创建响应
const response = NextResponse.next({
request: {
headers: requestHeaders,
},
})
});
// 如果需要设置设备ID cookie
if (needSetCookie) {
@ -105,16 +105,16 @@ export default function proxy(request: NextRequest) {
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
})
});
}
if (pathname.startsWith('/@')) {
const userId = pathname.slice(2) // 去掉/@
return NextResponse.rewrite(new URL(`/user/${userId}`, request.url))
const userId = pathname.slice(2); // 去掉/@
return NextResponse.rewrite(new URL(`/user/${userId}`, request.url));
}
console.log('✅ [MIDDLEWARE] 成功处理完毕:', pathname)
return response
console.log('✅ [MIDDLEWARE] 成功处理完毕:', pathname);
return response;
}
export const config = {
@ -122,4 +122,4 @@ export const config = {
// 匹配所有路径除了静态文件和API路由
'/((?!api|_next/static|_next/image|favicon.ico|public).*)',
],
}
};

View File

@ -18,7 +18,8 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
channels: [],
currentChannel: null,
async connect(user) {
const user = {};
if (client) return;
console.log('connecting stream chat', user);
const { data } = await getUserToken(user);
client = new StreamChat(process.env.NEXT_PUBLIC_STREAM_CHAT_API_KEY || '');
await client.connectUser(