feat: 增加一些变更
This commit is contained in:
parent
506e702040
commit
8b524fbf79
15
.env
15
.env
|
|
@ -1,17 +1,20 @@
|
||||||
NEXT_PUBLIC_AUTH_API_URL=https://localhost:3000/api/mock
|
NEXT_PUBLIC_AUTH_API_URL=https://localhost:3000/api/mock
|
||||||
NEXT_PUBLIC_FROG_API_URL=http://35.82.37.117:8082/frog
|
NEXT_PUBLIC_FROG_API_URL=http://35.82.37.117:8082/frog
|
||||||
NEXT_PUBLIC_BEAR_API_URL=https://test-bear.crushlevel.ai
|
NEXT_PUBLIC_BEAR_API_URL=http://35.82.37.117:8082/bear
|
||||||
NEXT_PUBLIC_LION_API_URL=https://test-lion.crushlevel.ai
|
NEXT_PUBLIC_LION_API_URL=http://35.82.37.117:8082/lion
|
||||||
NEXT_PUBLIC_SHARK_API_URL=https://test-shark.crushlevel.ai
|
NEXT_PUBLIC_SHARK_API_URL=http://35.82.37.117:8082/shark
|
||||||
NEXT_PUBLIC_COW_API_URL=https://test-cow.crushlevel.ai
|
NEXT_PUBLIC_COW_API_URL=http://35.82.37.117:8082/cow
|
||||||
NEXT_PUBLIC_PIGEON_API_URL=https://test-pigeon.crushlevel.ai
|
NEXT_PUBLIC_PIGEON_API_URL=http://35.82.37.117:8082/pigeon
|
||||||
|
|
||||||
# A18 服务
|
# 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
|
NEXT_PUBLIC_DISCORD_CLIENT_ID=1448143535609217076
|
||||||
|
|
||||||
|
# STREAM_CHAT
|
||||||
|
NEXT_PUBLIC_STREAM_CHAT_API_KEY=rpwwpq5gvq3h
|
||||||
|
|
||||||
# S3
|
# S3
|
||||||
NEXT_PUBLIC_S3_URI=https://hhb.crushlevel.ai
|
NEXT_PUBLIC_S3_URI=https://hhb.crushlevel.ai
|
||||||
NEXT_PUBLIC_S3_IM_URI=https://img.crushlevel.ai
|
NEXT_PUBLIC_S3_IM_URI=https://img.crushlevel.ai
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ const PolicyLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
<div className="relative flex min-w-0 flex-1 flex-col">
|
<div className="relative flex min-w-0 flex-1 flex-col">
|
||||||
<header
|
<header
|
||||||
className={cn(
|
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,
|
'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" />}
|
{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="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
|
<IconButton
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="large"
|
size="large"
|
||||||
|
|
@ -60,7 +60,7 @@ const PolicyLayout = ({ children }: { children: React.ReactNode }) => {
|
||||||
iconfont="icon-arrow-left"
|
iconfont="icon-arrow-left"
|
||||||
/>
|
/>
|
||||||
<div className="txt-title-m">{title}</div>
|
<div className="txt-title-m">{title}</div>
|
||||||
<div></div>
|
<div className="w-12"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
|
||||||
|
|
@ -1,36 +1,36 @@
|
||||||
'use client'
|
'use client';
|
||||||
|
|
||||||
import { IconButton } from '@/components/ui/button'
|
import { IconButton } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils';
|
||||||
import { useState, useRef, useEffect } from 'react'
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeight?: number }) => {
|
const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeight?: number }) => {
|
||||||
const { maxHeight = 200, className, value, onChange, ...restProps } = props
|
const { maxHeight = 200, className, value, onChange, ...restProps } = props;
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
// 调整高度的函数
|
// 调整高度的函数
|
||||||
const adjustHeight = () => {
|
const adjustHeight = () => {
|
||||||
const textarea = textareaRef.current
|
const textarea = textareaRef.current;
|
||||||
if (!textarea) return
|
if (!textarea) return;
|
||||||
|
|
||||||
// 先重置高度为 0,这样才能获取真实的 scrollHeight
|
// 先重置高度为 0,这样才能获取真实的 scrollHeight
|
||||||
textarea.style.height = '0px'
|
textarea.style.height = '0px';
|
||||||
|
|
||||||
// 获取内容实际需要的高度
|
// 获取内容实际需要的高度
|
||||||
const scrollHeight = textarea.scrollHeight
|
const scrollHeight = textarea.scrollHeight;
|
||||||
|
|
||||||
// 计算新高度:取 scrollHeight 和 maxHeight 的较小值
|
// 计算新高度:取 scrollHeight 和 maxHeight 的较小值
|
||||||
const newHeight = Math.min(scrollHeight, maxHeight)
|
const newHeight = Math.min(scrollHeight, maxHeight);
|
||||||
textarea.style.height = `${newHeight}px`
|
textarea.style.height = `${newHeight}px`;
|
||||||
|
|
||||||
// 如果内容超过最大高度,显示滚动条
|
// 如果内容超过最大高度,显示滚动条
|
||||||
textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden'
|
textarea.style.overflowY = scrollHeight > maxHeight ? 'auto' : 'hidden';
|
||||||
}
|
};
|
||||||
|
|
||||||
// 监听内容变化,自动调整高度
|
// 监听内容变化,自动调整高度
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
adjustHeight()
|
adjustHeight();
|
||||||
}, [value, maxHeight])
|
}, [value, maxHeight]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<textarea
|
<textarea
|
||||||
|
|
@ -53,15 +53,15 @@ const AuthHeightTextarea = (props: React.ComponentProps<'textarea'> & { maxHeigh
|
||||||
}}
|
}}
|
||||||
{...restProps}
|
{...restProps}
|
||||||
/>
|
/>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function Input() {
|
export default function Input() {
|
||||||
const [isRecording, setIsRecording] = useState(false)
|
const [isRecording, setIsRecording] = useState(false);
|
||||||
const [inputValue, setInputValue] = useState('')
|
const [inputValue, setInputValue] = useState('');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex mb-6 items-end gap-4">
|
<div className="flex flex-col mb-6 items-end gap-4">
|
||||||
<div></div>
|
<div></div>
|
||||||
<div className="flex w-full items-end gap-4">
|
<div className="flex w-full items-end gap-4">
|
||||||
{/* 打电话按钮 */}
|
{/* 打电话按钮 */}
|
||||||
|
|
@ -100,5 +100,5 @@ export default function Input() {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export default function ChatPage() {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full">
|
<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">
|
<div className="max-w-[752px] w-full h-full flex flex-col">
|
||||||
<MessageList />
|
<MessageList />
|
||||||
<Input />
|
<Input />
|
||||||
|
|
|
||||||
|
|
@ -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 }> }) {
|
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 (
|
return (
|
||||||
<div>
|
<div className="flex px-4 pt-10">
|
||||||
<h1>Character: {id}</h1>
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,13 +23,7 @@ const Character = () => {
|
||||||
<div className="mt-8">
|
<div className="mt-8">
|
||||||
<InfiniteScrollList<any>
|
<InfiniteScrollList<any>
|
||||||
items={dataSource}
|
items={dataSource}
|
||||||
columns={{
|
columns={(width) => Math.floor(width / 213)}
|
||||||
xs: 2,
|
|
||||||
sm: 3,
|
|
||||||
md: 4,
|
|
||||||
lg: 5,
|
|
||||||
xl: 6,
|
|
||||||
}}
|
|
||||||
renderItem={(character) => <AIStandardCard character={character} />}
|
renderItem={(character) => <AIStandardCard character={character} />}
|
||||||
getItemKey={(character) => character.id}
|
getItemKey={(character) => character.id}
|
||||||
hasNextPage={!noMoreData}
|
hasNextPage={!noMoreData}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
'use client'
|
'use client';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
|
|
@ -10,53 +10,53 @@ import {
|
||||||
AlertDialogHeader,
|
AlertDialogHeader,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from '@/components/ui/alert-dialog'
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { IconButton, Button } from '@/components/ui/button'
|
import { IconButton, Button } from '@/components/ui/button';
|
||||||
import { useCurrentUser, useDeleteUser } from '@/hooks/auth'
|
import { useCurrentUser, useDeleteUser } from '@/hooks/auth';
|
||||||
import { ThirdType } from '@/services/auth'
|
import { ThirdType } from '@/services/auth';
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react'
|
import { useState } from 'react';
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
const AccountPage = () => {
|
const AccountPage = () => {
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const { data: user } = useCurrentUser()
|
const { data: user } = useCurrentUser();
|
||||||
const [isDisabling, setIsDisabling] = useState(false)
|
const [isDisabling, setIsDisabling] = useState(false);
|
||||||
const { mutateAsync: deleteUser } = useDeleteUser()
|
const { mutateAsync: deleteUser } = useDeleteUser();
|
||||||
|
|
||||||
const handleBack = () => {
|
const handleBack = () => {
|
||||||
router.back()
|
router.back();
|
||||||
}
|
};
|
||||||
|
|
||||||
const handleDisableAccount = async () => {
|
const handleDisableAccount = async () => {
|
||||||
setIsDisabling(true)
|
setIsDisabling(true);
|
||||||
try {
|
try {
|
||||||
await deleteUser()
|
await deleteUser();
|
||||||
toast.success('Account disabled successfully')
|
toast.success('Account disabled successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('禁用账户失败:', error)
|
console.error('禁用账户失败:', error);
|
||||||
} finally {
|
} finally {
|
||||||
setIsDisabling(false)
|
setIsDisabling(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const renderAvatar = () => {
|
const renderAvatar = () => {
|
||||||
const iconfonts = {
|
const iconfonts = {
|
||||||
[ThirdType.Apple]: 'icon-social-apple',
|
[ThirdType.Apple]: 'icon-social-apple',
|
||||||
[ThirdType.Discord]: 'icon-social-discord',
|
[ThirdType.Discord]: 'icon-social-discord',
|
||||||
[ThirdType.Google]: 'icon-social-google',
|
[ThirdType.Google]: 'icon-social-google',
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-surface-element-normal flex size-12 items-center justify-center rounded-full">
|
<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]`} />
|
<i className={`iconfont ${iconfonts[user?.thirdType || ThirdType.Google]} !text-[24px]`} />
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div className="mb-6 flex items-center gap-2">
|
||||||
<IconButton variant="ghost" size="large" onClick={handleBack} className="p-2">
|
<IconButton variant="ghost" size="large" onClick={handleBack} className="p-2">
|
||||||
|
|
@ -115,7 +115,7 @@ const AccountPage = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default AccountPage
|
export default AccountPage;
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import {
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { useLogout } from '@/hooks/auth';
|
import { useLogout } from '@/hooks/auth';
|
||||||
import { useNimChat, useNimConversation } from '@/context/NimChat/useNimChat';
|
|
||||||
import { useSetAtom } from 'jotai';
|
import { useSetAtom } from 'jotai';
|
||||||
import {
|
import {
|
||||||
conversationListAtom,
|
conversationListAtom,
|
||||||
|
|
@ -50,8 +49,7 @@ const ProfileDropdownItem = ({
|
||||||
|
|
||||||
const ProfileDropdown = () => {
|
const ProfileDropdown = () => {
|
||||||
const { mutateAsync: logout } = useLogout();
|
const { mutateAsync: logout } = useLogout();
|
||||||
const { nim } = useNimChat();
|
// const { clearAllConversations } = useNimConversation();
|
||||||
const { clearAllConversations } = useNimConversation();
|
|
||||||
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false);
|
const [isLogoutDialogOpen, setIsLogoutDialogOpen] = useState(false);
|
||||||
const [isLogoutDialogLoading, setIsLogoutDialogLoading] = useState(false);
|
const [isLogoutDialogLoading, setIsLogoutDialogLoading] = useState(false);
|
||||||
const { isSidebarExpanded, setSidebarExpanded } = useLayoutStore();
|
const { isSidebarExpanded, setSidebarExpanded } = useLayoutStore();
|
||||||
|
|
@ -71,7 +69,7 @@ const ProfileDropdown = () => {
|
||||||
// 1. 断开IM连接
|
// 1. 断开IM连接
|
||||||
try {
|
try {
|
||||||
console.log('开始断开IM连接...');
|
console.log('开始断开IM连接...');
|
||||||
await nim.V2NIMLoginService.logout();
|
// await nim.V2NIMLoginService.logout();
|
||||||
console.log('IM连接已断开');
|
console.log('IM连接已断开');
|
||||||
} catch (imError) {
|
} catch (imError) {
|
||||||
console.error('断开IM连接失败:', imError);
|
console.error('断开IM连接失败:', imError);
|
||||||
|
|
@ -81,7 +79,7 @@ const ProfileDropdown = () => {
|
||||||
// 2. 清除所有聊天数据
|
// 2. 清除所有聊天数据
|
||||||
try {
|
try {
|
||||||
console.log('开始清除聊天历史数据...');
|
console.log('开始清除聊天历史数据...');
|
||||||
await clearAllConversations();
|
// await clearAllConversations();
|
||||||
console.log('聊天历史数据已清除');
|
console.log('聊天历史数据已清除');
|
||||||
} catch (clearError) {
|
} catch (clearError) {
|
||||||
console.error('清除聊天数据失败:', clearError);
|
console.error('清除聊天数据失败:', clearError);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Image from 'next/image'
|
import Image from 'next/image';
|
||||||
import Link from 'next/link'
|
import Link from 'next/link';
|
||||||
|
|
||||||
const ProfileFeatureList = () => {
|
const ProfileFeatureList = () => {
|
||||||
const items = [
|
const items = [
|
||||||
|
|
@ -20,10 +20,10 @@ const ProfileFeatureList = () => {
|
||||||
),
|
),
|
||||||
href: '/wallet',
|
href: '/wallet',
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
return (
|
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) => (
|
{items.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
|
|
@ -47,7 +47,7 @@ const ProfileFeatureList = () => {
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default ProfileFeatureList
|
export default ProfileFeatureList;
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -1,7 +1,343 @@
|
||||||
import EditPage from './edit-page'
|
'use client';
|
||||||
|
|
||||||
const Page = () => {
|
import { useForm } from 'react-hook-form';
|
||||||
return <EditPage />
|
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;
|
||||||
|
|
|
||||||
|
|
@ -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 (
|
return (
|
||||||
<div className="container mx-auto py-8">
|
<div className="px-4 sm:px-12 py-8">
|
||||||
<ProfilePage />
|
<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>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
@ -1,111 +1,111 @@
|
||||||
'use client'
|
'use client';
|
||||||
|
|
||||||
import { discordOAuth } from '@/lib/oauth/discord'
|
import { discordOAuth } from '@/lib/oauth/discord';
|
||||||
import { SocialButton } from './SocialButton'
|
import { SocialButton } from './SocialButton';
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner';
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react';
|
||||||
import { useLogin } from '@/hooks/auth'
|
import { useLogin } from '@/hooks/auth';
|
||||||
import { useRouter, useSearchParams } from 'next/navigation'
|
import { useRouter, useSearchParams } from 'next/navigation';
|
||||||
import { tokenManager } from '@/lib/auth/token'
|
import { tokenManager } from '@/lib/auth/token';
|
||||||
import { AppClient, ThirdType } from '@/services/auth'
|
import { AppClient, ThirdType } from '@/services/auth';
|
||||||
|
|
||||||
const DiscordButton = () => {
|
const DiscordButton = () => {
|
||||||
const login = useLogin()
|
const login = useLogin();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams()
|
const searchParams = useSearchParams();
|
||||||
const redirect = searchParams.get('redirect')
|
const redirect = searchParams.get('redirect');
|
||||||
|
|
||||||
// 处理Discord OAuth回调
|
// 处理Discord OAuth回调
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const discordCode = searchParams.get('discord_code')
|
const discordCode = searchParams.get('discord_code');
|
||||||
const discordState = searchParams.get('discord_state')
|
const discordState = searchParams.get('discord_state');
|
||||||
const error = searchParams.get('error')
|
const error = searchParams.get('error');
|
||||||
|
|
||||||
// 处理错误情况
|
// 处理错误情况
|
||||||
if (error) {
|
if (error) {
|
||||||
toast.error('Discord login failed')
|
toast.error('Discord login failed');
|
||||||
|
|
||||||
// 清理URL参数
|
// 清理URL参数
|
||||||
const newUrl = new URL(window.location.href)
|
const newUrl = new URL(window.location.href);
|
||||||
newUrl.searchParams.delete('error')
|
newUrl.searchParams.delete('error');
|
||||||
router.replace(newUrl.pathname)
|
router.replace(newUrl.pathname);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理Discord授权码
|
// 处理Discord授权码
|
||||||
if (discordCode) {
|
if (discordCode) {
|
||||||
// 验证state参数(可选的安全检查)
|
// 验证state参数(可选的安全检查)
|
||||||
const savedState = sessionStorage.getItem('discord_oauth_state')
|
const savedState = sessionStorage.getItem('discord_oauth_state');
|
||||||
if (savedState && discordState && savedState !== discordState) {
|
if (savedState && discordState && savedState !== discordState) {
|
||||||
toast.error('Discord login failed')
|
toast.error('Discord login failed');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 使用code调用后端登录接口
|
// 使用code调用后端登录接口
|
||||||
const deviceId = tokenManager.getDeviceId()
|
const deviceId = tokenManager.getDeviceId();
|
||||||
const loginData = {
|
const loginData = {
|
||||||
appClient: AppClient.Web,
|
appClient: AppClient.Web,
|
||||||
deviceCode: deviceId,
|
deviceCode: deviceId,
|
||||||
thirdToken: discordCode, // 直接传递Discord授权码
|
thirdToken: discordCode, // 直接传递Discord授权码
|
||||||
thirdType: ThirdType.Discord,
|
thirdType: ThirdType.Discord,
|
||||||
}
|
};
|
||||||
|
|
||||||
login.mutate(loginData, {
|
login.mutate(loginData, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success('Login successful')
|
toast.success('Login successful');
|
||||||
|
|
||||||
// 清除 Next.js 路由缓存,避免使用登录前 prefetch 的重定向响应
|
// 清除 Next.js 路由缓存,避免使用登录前 prefetch 的重定向响应
|
||||||
router.refresh()
|
router.refresh();
|
||||||
|
|
||||||
// 清理URL参数和sessionStorage
|
// 清理URL参数和sessionStorage
|
||||||
sessionStorage.removeItem('discord_oauth_state')
|
sessionStorage.removeItem('discord_oauth_state');
|
||||||
const newUrl = new URL(window.location.href)
|
const newUrl = new URL(window.location.href);
|
||||||
newUrl.searchParams.delete('discord_code')
|
newUrl.searchParams.delete('discord_code');
|
||||||
newUrl.searchParams.delete('discord_state')
|
newUrl.searchParams.delete('discord_state');
|
||||||
router.replace(newUrl.pathname)
|
router.replace(newUrl.pathname);
|
||||||
|
|
||||||
const loginRedirectUrl = sessionStorage.getItem('login_redirect_url')
|
const loginRedirectUrl = sessionStorage.getItem('login_redirect_url');
|
||||||
|
|
||||||
// 重定向到首页或指定页面
|
// 重定向到首页或指定页面
|
||||||
if (loginRedirectUrl) {
|
if (loginRedirectUrl) {
|
||||||
router.push(loginRedirectUrl)
|
router.push(loginRedirectUrl);
|
||||||
} else {
|
} else {
|
||||||
router.push('/')
|
router.push('/');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
// 清理URL参数
|
// 清理URL参数
|
||||||
const newUrl = new URL(window.location.href)
|
const newUrl = new URL(window.location.href);
|
||||||
newUrl.searchParams.delete('discord_code')
|
newUrl.searchParams.delete('discord_code');
|
||||||
newUrl.searchParams.delete('discord_state')
|
newUrl.searchParams.delete('discord_state');
|
||||||
newUrl.searchParams.delete('redirect')
|
newUrl.searchParams.delete('redirect');
|
||||||
router.replace(newUrl.pathname)
|
router.replace(newUrl.pathname);
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
}, [])
|
}, []);
|
||||||
|
|
||||||
const handleDiscordLogin = () => {
|
const handleDiscordLogin = () => {
|
||||||
try {
|
try {
|
||||||
// 生成随机state用于安全验证
|
// 生成随机state用于安全验证
|
||||||
const state = Math.random().toString(36).substring(2, 15)
|
const state = Math.random().toString(36).substring(2, 15);
|
||||||
|
|
||||||
// 获取Discord授权URL
|
// 获取Discord授权URL
|
||||||
const authUrl = discordOAuth.getAuthUrl(state)
|
const authUrl = discordOAuth.getAuthUrl(state);
|
||||||
|
|
||||||
// 将state保存到sessionStorage用于后续验证
|
// 将state保存到sessionStorage用于后续验证
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
sessionStorage.setItem('discord_oauth_state', state)
|
sessionStorage.setItem('discord_oauth_state', state);
|
||||||
sessionStorage.setItem('login_redirect_url', redirect || '')
|
sessionStorage.setItem('login_redirect_url', redirect || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 跳转到Discord授权页面
|
// 跳转到Discord授权页面
|
||||||
window.location.href = authUrl
|
window.location.href = authUrl;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Discord login error:', error)
|
console.error('Discord login error:', error);
|
||||||
toast.error('Discord login failed')
|
toast.error('Discord login failed');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SocialButton
|
<SocialButton
|
||||||
|
|
@ -115,7 +115,7 @@ const DiscordButton = () => {
|
||||||
>
|
>
|
||||||
{login.isPending ? 'Signing in...' : 'Continue with Discord'}
|
{login.isPending ? 'Signing in...' : 'Continue with Discord'}
|
||||||
</SocialButton>
|
</SocialButton>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|
||||||
export default DiscordButton
|
export default DiscordButton;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import React, { ReactNode, useMemo } from 'react';
|
import React, { ReactNode, useEffect, useMemo, useRef } from 'react';
|
||||||
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
|
import { useInfiniteScroll } from '@/hooks/useInfiniteScroll';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useSize } from 'ahooks';
|
||||||
|
|
||||||
interface InfiniteScrollListProps<T> {
|
interface InfiniteScrollListProps<T> {
|
||||||
/**
|
/**
|
||||||
|
|
@ -42,7 +43,8 @@ interface InfiniteScrollListProps<T> {
|
||||||
lg?: number;
|
lg?: number;
|
||||||
xl?: number;
|
xl?: number;
|
||||||
}
|
}
|
||||||
| number;
|
| number
|
||||||
|
| ((width: number) => number);
|
||||||
/**
|
/**
|
||||||
* 网格间距
|
* 网格间距
|
||||||
*/
|
*/
|
||||||
|
|
@ -104,6 +106,8 @@ export function InfiniteScrollList<T>({
|
||||||
threshold = 200,
|
threshold = 200,
|
||||||
enabled = true,
|
enabled = true,
|
||||||
}: InfiniteScrollListProps<T>) {
|
}: InfiniteScrollListProps<T>) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const size = useSize(ref);
|
||||||
const { loadMoreRef, isFetching } = useInfiniteScroll({
|
const { loadMoreRef, isFetching } = useInfiniteScroll({
|
||||||
hasNextPage,
|
hasNextPage,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
@ -115,17 +119,21 @@ export function InfiniteScrollList<T>({
|
||||||
|
|
||||||
// 生成网格列数的CSS类名映射
|
// 生成网格列数的CSS类名映射
|
||||||
const gridColsClass = useMemo(() => {
|
const gridColsClass = useMemo(() => {
|
||||||
|
const gridClassMap: Record<number, string> = {
|
||||||
|
1: 'grid-cols-1',
|
||||||
|
2: 'grid-cols-2',
|
||||||
|
3: 'grid-cols-3',
|
||||||
|
4: 'grid-cols-4',
|
||||||
|
5: 'grid-cols-5',
|
||||||
|
6: 'grid-cols-6',
|
||||||
|
};
|
||||||
if (typeof columns === 'number') {
|
if (typeof columns === 'number') {
|
||||||
const gridClassMap: Record<number, string> = {
|
|
||||||
1: 'grid-cols-1',
|
|
||||||
2: 'grid-cols-2',
|
|
||||||
3: 'grid-cols-3',
|
|
||||||
4: 'grid-cols-4',
|
|
||||||
5: 'grid-cols-5',
|
|
||||||
6: 'grid-cols-6',
|
|
||||||
};
|
|
||||||
return gridClassMap[columns] || 'grid-cols-4';
|
return gridClassMap[columns] || 'grid-cols-4';
|
||||||
}
|
}
|
||||||
|
if (typeof columns === 'function') {
|
||||||
|
const col = columns(size?.width || 0);
|
||||||
|
return gridClassMap[col] || 'grid-cols-4';
|
||||||
|
}
|
||||||
|
|
||||||
// 使用完整的类名字符串,让 Tailwind 能够正确识别
|
// 使用完整的类名字符串,让 Tailwind 能够正确识别
|
||||||
const classes: string[] = [];
|
const classes: string[] = [];
|
||||||
|
|
@ -171,7 +179,7 @@ export function InfiniteScrollList<T>({
|
||||||
if (columns.xl === 6) classes.push('xl:grid-cols-6');
|
if (columns.xl === 6) classes.push('xl:grid-cols-6');
|
||||||
|
|
||||||
return classes.join(' ');
|
return classes.join(' ');
|
||||||
}, [columns]);
|
}, [columns, size?.width]);
|
||||||
|
|
||||||
// 生成间距类名
|
// 生成间距类名
|
||||||
const gapClass = useMemo(() => {
|
const gapClass = useMemo(() => {
|
||||||
|
|
@ -187,49 +195,14 @@ export function InfiniteScrollList<T>({
|
||||||
return gapClassMap[gap] || 'gap-4';
|
return gapClassMap[gap] || 'gap-4';
|
||||||
}, [gap]);
|
}, [gap]);
|
||||||
|
|
||||||
// 错误状态
|
let finalDom = (
|
||||||
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">
|
|
||||||
{/* 主要内容 */}
|
{/* 主要内容 */}
|
||||||
<div className={cn('grid', gridColsClass, gapClass, className)}>
|
<div className={cn('grid', gridColsClass, gapClass, className)}>
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<React.Fragment key={getItemKey(item, index)}>{renderItem(item, index)}</React.Fragment>
|
<React.Fragment key={getItemKey(item, index)}>{renderItem(item, index)}</React.Fragment>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 加载更多触发器 - 只在没有错误时显示 */}
|
{/* 加载更多触发器 - 只在没有错误时显示 */}
|
||||||
{hasNextPage && !hasError && (
|
{hasNextPage && !hasError && (
|
||||||
<div ref={loadMoreRef} className="mt-8 flex justify-center">
|
<div ref={loadMoreRef} className="mt-8 flex justify-center">
|
||||||
|
|
@ -247,6 +220,46 @@ export function InfiniteScrollList<T>({
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
authService,
|
authService,
|
||||||
CheckNicknameRequest,
|
CheckNicknameRequest,
|
||||||
|
|
@ -6,28 +6,29 @@ import {
|
||||||
UpdateUserInfoRequest,
|
UpdateUserInfoRequest,
|
||||||
type LoginRequest,
|
type LoginRequest,
|
||||||
type LoginResponse,
|
type LoginResponse,
|
||||||
} from '@/services/auth'
|
} from '@/services/auth';
|
||||||
import { authKeys, userKeys } from '@/lib/query-keys'
|
import { authKeys, userKeys } from '@/lib/query-keys';
|
||||||
import { tokenManager } from '@/lib/auth/token'
|
import { tokenManager } from '@/lib/auth/token';
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner';
|
||||||
import type { ApiError } from '@/types/api'
|
import type { ApiError } from '@/types/api';
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation';
|
||||||
import { userService } from '@/services/user'
|
import { userService } from '@/services/user';
|
||||||
|
|
||||||
export function useLogin() {
|
export function useLogin() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: LoginRequest): Promise<LoginResponse> => authService.login(data),
|
mutationFn: (data: LoginRequest): Promise<LoginResponse> => authService.login(data),
|
||||||
onSuccess: (response: LoginResponse) => {
|
onSuccess: (response: LoginResponse) => {
|
||||||
|
console.log('useLogin onSuccess save token', response.token);
|
||||||
// 保存token到cookie
|
// 保存token到cookie
|
||||||
tokenManager.setToken(response.token)
|
tokenManager.setToken(response.token);
|
||||||
// 刷新当前用户信息
|
// 刷新当前用户信息
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: authKeys.currentUser(),
|
queryKey: authKeys.currentUser(),
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useToken() {
|
export function useToken() {
|
||||||
|
|
@ -35,33 +36,33 @@ export function useToken() {
|
||||||
isLogin: tokenManager.isAuthenticated(),
|
isLogin: tokenManager.isAuthenticated(),
|
||||||
token: tokenManager.getToken(),
|
token: tokenManager.getToken(),
|
||||||
getLoginStatus: () => {
|
getLoginStatus: () => {
|
||||||
return tokenManager.isAuthenticated()
|
return tokenManager.isAuthenticated();
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLogout() {
|
export function useLogout() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => authService.logout(),
|
mutationFn: () => authService.logout(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// 清除token
|
// 清除token
|
||||||
tokenManager.removeToken()
|
tokenManager.removeToken();
|
||||||
// 显示成功提示
|
// 显示成功提示
|
||||||
toast.success('Log out successful!')
|
toast.success('Log out successful!');
|
||||||
// 清除所有查询缓存
|
// 清除所有查询缓存
|
||||||
queryClient.clear()
|
queryClient.clear();
|
||||||
router.push('/')
|
router.push('/');
|
||||||
},
|
},
|
||||||
onError: (error: ApiError) => {
|
onError: (error: ApiError) => {
|
||||||
// 即使登出接口失败,也要清除本地token
|
// 即使登出接口失败,也要清除本地token
|
||||||
tokenManager.removeToken()
|
tokenManager.removeToken();
|
||||||
queryClient.clear()
|
queryClient.clear();
|
||||||
// 跳转到登录页
|
// 跳转到登录页
|
||||||
router.push('/')
|
router.push('/');
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCurrentUser() {
|
export function useCurrentUser() {
|
||||||
|
|
@ -78,104 +79,104 @@ export function useCurrentUser() {
|
||||||
error.errorCode === 'AUTH_TOKEN_INVALID' ||
|
error.errorCode === 'AUTH_TOKEN_INVALID' ||
|
||||||
error.errorCode === 'AUTH_UNAUTHORIZED'
|
error.errorCode === 'AUTH_UNAUTHORIZED'
|
||||||
) {
|
) {
|
||||||
tokenManager.removeToken()
|
tokenManager.removeToken();
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
return failureCount < 1
|
return failureCount < 1;
|
||||||
},
|
},
|
||||||
staleTime: 60 * 1000, // 60秒后缓存过期
|
staleTime: 60 * 1000, // 60秒后缓存过期
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCompleteUser() {
|
export function useCompleteUser() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: CompleteUserInfoRequest) => authService.completeUserInfo(data),
|
mutationFn: (data: CompleteUserInfoRequest) => authService.completeUserInfo(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: authKeys.currentUser(),
|
queryKey: authKeys.currentUser(),
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUpdateUser() {
|
export function useUpdateUser() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: UpdateUserInfoRequest) => authService.updateUserInfo(data),
|
mutationFn: (data: UpdateUserInfoRequest) => authService.updateUserInfo(data),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: authKeys.currentUser(),
|
queryKey: authKeys.currentUser(),
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useDeleteUser() {
|
export function useDeleteUser() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
const router = useRouter()
|
const router = useRouter();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => authService.deleteUser(),
|
mutationFn: () => authService.deleteUser(),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
tokenManager.removeToken()
|
tokenManager.removeToken();
|
||||||
queryClient.clear()
|
queryClient.clear();
|
||||||
router.push('/login')
|
router.push('/login');
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注册功能暂未实现,保留接口定义
|
// 注册功能暂未实现,保留接口定义
|
||||||
export function useRegister() {
|
export function useRegister() {
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: any) => {
|
mutationFn: (data: any) => {
|
||||||
// TODO: 实现注册接口
|
// TODO: 实现注册接口
|
||||||
throw new Error('注册功能暂未实现')
|
throw new Error('注册功能暂未实现');
|
||||||
},
|
},
|
||||||
onSuccess: (response: LoginResponse) => {
|
onSuccess: (response: LoginResponse) => {
|
||||||
// 注册成功后自动登录
|
// 注册成功后自动登录
|
||||||
tokenManager.setToken(response.token)
|
tokenManager.setToken(response.token);
|
||||||
toast.success('Successful registration!')
|
toast.success('Successful registration!');
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: authKeys.currentUser(),
|
queryKey: authKeys.currentUser(),
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
onError: (error: ApiError) => {
|
onError: (error: ApiError) => {
|
||||||
console.error('注册失败:', {
|
console.error('注册失败:', {
|
||||||
errorCode: error.errorCode,
|
errorCode: error.errorCode,
|
||||||
errorMsg: error.errorMsg,
|
errorMsg: error.errorMsg,
|
||||||
traceId: error.traceId,
|
traceId: error.traceId,
|
||||||
})
|
});
|
||||||
toast.error('Registration failed.', {
|
toast.error('Registration failed.', {
|
||||||
description: error.errorMsg || '请稍后重试',
|
description: error.errorMsg || '请稍后重试',
|
||||||
})
|
});
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否已登录的hook
|
// 检查是否已登录的hook
|
||||||
export function useIsAuthenticated() {
|
export function useIsAuthenticated() {
|
||||||
return tokenManager.isAuthenticated()
|
return tokenManager.isAuthenticated();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCheckNickname({ onError }: { onError?: (error: ApiError) => void }) {
|
export function useCheckNickname({ onError }: { onError?: (error: ApiError) => void }) {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: CheckNicknameRequest) => authService.checkNickname(data),
|
mutationFn: (data: CheckNicknameRequest) => authService.checkNickname(data),
|
||||||
onError: onError,
|
onError: onError,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCheckText() {
|
export function useCheckText() {
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (data: { content: string }) => {
|
mutationFn: async (data: { content: string }) => {
|
||||||
const result = await authService.checkText(data)
|
const result = await authService.checkText(data);
|
||||||
if (result) {
|
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() {
|
export function useUserNoticeStat() {
|
||||||
|
|
@ -183,7 +184,7 @@ export function useUserNoticeStat() {
|
||||||
queryKey: userKeys.noticeStat(),
|
queryKey: userKeys.noticeStat(),
|
||||||
queryFn: () => userService.getUserNoticeStat(),
|
queryFn: () => userService.getUserNoticeStat(),
|
||||||
enabled: tokenManager.isAuthenticated(),
|
enabled: tokenManager.isAuthenticated(),
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useUserNoticeListInfinite(pageSize: number = 20, enabled: boolean = true) {
|
export function useUserNoticeListInfinite(pageSize: number = 20, enabled: boolean = true) {
|
||||||
|
|
@ -192,11 +193,11 @@ export function useUserNoticeListInfinite(pageSize: number = 20, enabled: boolea
|
||||||
queryFn: ({ pageParam = 1 }) =>
|
queryFn: ({ pageParam = 1 }) =>
|
||||||
userService.getUserNoticeList({ page: { pn: pageParam, ps: pageSize } }),
|
userService.getUserNoticeList({ page: { pn: pageParam, ps: pageSize } }),
|
||||||
getNextPageParam: (lastPage, allPages) => {
|
getNextPageParam: (lastPage, allPages) => {
|
||||||
const totalPages = Math.ceil((lastPage.tc || 0) / pageSize)
|
const totalPages = Math.ceil((lastPage.tc || 0) / pageSize);
|
||||||
const nextPage = allPages.length + 1
|
const nextPage = allPages.length + 1;
|
||||||
return nextPage <= totalPages ? nextPage : undefined
|
return nextPage <= totalPages ? nextPage : undefined;
|
||||||
},
|
},
|
||||||
initialPageParam: 1,
|
initialPageParam: 1,
|
||||||
enabled, // 只有在启用时才执行查询
|
enabled, // 只有在启用时才执行查询
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,17 +10,39 @@ import { cn } from '@/lib/utils';
|
||||||
import CreateReachedLimitDialog from '../components/features/create-reached-limit-dialog';
|
import CreateReachedLimitDialog from '../components/features/create-reached-limit-dialog';
|
||||||
import { useMedia } from '@/hooks/tools';
|
import { useMedia } from '@/hooks/tools';
|
||||||
import BottomBar from './BottomBar';
|
import BottomBar from './BottomBar';
|
||||||
|
import { useStreamChatStore } from '@/stores/stream-chat';
|
||||||
|
import { useLogin } from '@/hooks/auth';
|
||||||
|
|
||||||
interface ConditionalLayoutProps {
|
interface ConditionalLayoutProps {
|
||||||
children: React.ReactNode;
|
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) {
|
export default function ConditionalLayout({ children }: ConditionalLayoutProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const mainContentRef = useRef<HTMLDivElement>(null);
|
const mainContentRef = useRef<HTMLDivElement>(null);
|
||||||
const prevPathnameRef = useRef<string>(pathname);
|
const prevPathnameRef = useRef<string>(pathname);
|
||||||
const response = useMedia();
|
const response = useMedia();
|
||||||
|
|
||||||
|
// 初始化聊天
|
||||||
|
useInitChat();
|
||||||
|
|
||||||
// 路由切换时重置滚动位置
|
// 路由切换时重置滚动位置
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (prevPathnameRef.current !== pathname) {
|
if (prevPathnameRef.current !== pathname) {
|
||||||
|
|
|
||||||
|
|
@ -6,39 +6,43 @@ import Image from 'next/image';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useMedia } from '@/hooks/tools';
|
import { useMedia } from '@/hooks/tools';
|
||||||
|
|
||||||
|
export const items = [
|
||||||
|
{
|
||||||
|
label: 'Explore',
|
||||||
|
path: '/home',
|
||||||
|
icon: '/images/layout/explore.svg',
|
||||||
|
selectedIcon: '/images/layout/explore_active.svg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Search',
|
||||||
|
path: '/search',
|
||||||
|
icon: '/images/layout/search.svg',
|
||||||
|
selectedIcon: '/images/layout/search_active.svg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Chat',
|
||||||
|
path: '/chat-history',
|
||||||
|
icon: '/images/layout/chat.svg',
|
||||||
|
selectedIcon: '/images/layout/chat_active.svg',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Me',
|
||||||
|
path: '/profile',
|
||||||
|
icon: '/images/layout/me.svg',
|
||||||
|
selectedIcon: '/images/layout/me_active.svg',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
export default function BottomBar() {
|
export default function BottomBar() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const response = useMedia({ hide: 500 });
|
const response = useMedia({ hide: 500 });
|
||||||
|
|
||||||
const items = [
|
if (!items.some((i) => i.path === pathname)) {
|
||||||
{
|
return null;
|
||||||
label: 'Explore',
|
}
|
||||||
path: '/home',
|
|
||||||
icon: '/images/layout/explore.svg',
|
|
||||||
selectedIcon: '/images/layout/explore_active.svg',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Search',
|
|
||||||
path: '/search',
|
|
||||||
icon: '/images/layout/search.svg',
|
|
||||||
selectedIcon: '/images/layout/search_active.svg',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Chat',
|
|
||||||
path: '/chat-history',
|
|
||||||
icon: '/images/layout/chat.svg',
|
|
||||||
selectedIcon: '/images/layout/chat_active.svg',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: 'Me',
|
|
||||||
path: '/profile',
|
|
||||||
icon: '/images/layout/me.svg',
|
|
||||||
selectedIcon: '/images/layout/me_active.svg',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
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) => {
|
{items.map((item) => {
|
||||||
const isSelected = pathname === item.path;
|
const isSelected = pathname === item.path;
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ function Sidebar() {
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
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'
|
isSidebarExpanded ? 'w-80' : 'w-20'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,13 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Button } from '../components/ui/button';
|
import { Button, IconButton } from '../components/ui/button';
|
||||||
import { useCurrentUser } from '@/hooks/auth';
|
import { useCurrentUser } from '@/hooks/auth';
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '../components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '../components/ui/avatar';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
|
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
|
||||||
|
import { useMedia } from '@/hooks/tools';
|
||||||
|
import { items } from './BottomBar';
|
||||||
|
|
||||||
function Topbar() {
|
function Topbar() {
|
||||||
const [isBlur, setIsBlur] = useState(false);
|
const [isBlur, setIsBlur] = useState(false);
|
||||||
|
|
@ -14,7 +16,7 @@ function Topbar() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
const response = useMedia();
|
||||||
const searchParamsString = searchParams.toString();
|
const searchParamsString = searchParams.toString();
|
||||||
const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}`;
|
const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}`;
|
||||||
const loginHref = `/login?redirect=${encodeURIComponent(redirectURL)}`;
|
const loginHref = `/login?redirect=${encodeURIComponent(redirectURL)}`;
|
||||||
|
|
@ -46,10 +48,54 @@ function Topbar() {
|
||||||
}
|
}
|
||||||
}, [user]);
|
}, [user]);
|
||||||
|
|
||||||
|
const leftDomRender = () => {
|
||||||
|
if (!response) return null;
|
||||||
|
if (response.sm || items.some((item) => item.path === pathname)) {
|
||||||
|
return (
|
||||||
|
<div className="h-8 w-[103.6px]">
|
||||||
|
<Link href="/">
|
||||||
|
<Image src="/logo.svg" alt="logo" width={103.6} height={32} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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
|
||||||
|
className="object-cover"
|
||||||
|
src={user.headImage}
|
||||||
|
alt={user.nickname}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
/>
|
||||||
|
<AvatarFallback>{user.nickname?.slice(0, 1)}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
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-4 sm:px-8 transition-all',
|
||||||
{
|
{
|
||||||
'backdrop-blur-[10px]': isBlur,
|
'backdrop-blur-[10px]': isBlur,
|
||||||
}
|
}
|
||||||
|
|
@ -57,29 +103,8 @@ function Topbar() {
|
||||||
>
|
>
|
||||||
{isBlur && <div className="bg-background-default absolute inset-0 opacity-85" />}
|
{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="relative inset-0 flex w-full items-center justify-between">
|
||||||
<div className="h-8 w-[103.6px]">
|
{leftDomRender()}
|
||||||
<Link href="/">
|
{rightDomRender()}
|
||||||
<Image src="/logo.svg" alt="logo" width={103.6} height={32} />
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
{user ? (
|
|
||||||
<Link href="/profile" prefetch>
|
|
||||||
<Avatar className="size-8 cursor-pointer">
|
|
||||||
<AvatarImage
|
|
||||||
className="object-cover"
|
|
||||||
src={user.headImage}
|
|
||||||
alt={user.nickname}
|
|
||||||
width={32}
|
|
||||||
height={32}
|
|
||||||
/>
|
|
||||||
<AvatarFallback>{user.nickname?.slice(0, 1)}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<Link href={loginHref} prefetch>
|
|
||||||
<Button size="small">Login in / Sign up</Button>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,6 @@ const ChatSidebar = () => {
|
||||||
|
|
||||||
const datas = Array.from(channels.values()).sort((a, b) => {
|
const datas = Array.from(channels.values()).sort((a, b) => {
|
||||||
return false;
|
return false;
|
||||||
// // 获取会话的最后活跃时间(优先使用最后一条消息的时间,否则使用会话更新时间)
|
|
||||||
// const aTime = a.lastMessage?.messageRefer?.createTime || a.updateTime || 0;
|
|
||||||
// const bTime = b.lastMessage?.messageRefer?.createTime || b.updateTime || 0;
|
|
||||||
// // 按时间倒序排列(最新的在前面)
|
|
||||||
// return bTime - aTime;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 当侧边栏收缩时,取消搜索功能
|
// 当侧边栏收缩时,取消搜索功能
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
import Cookies from 'js-cookie'
|
import Cookies from 'js-cookie';
|
||||||
|
|
||||||
const TOKEN_COOKIE_NAME = 'st'
|
const TOKEN_COOKIE_NAME = 'st';
|
||||||
const DEVICE_ID_COOKIE_NAME = 'sd'
|
const DEVICE_ID_COOKIE_NAME = 'sd';
|
||||||
|
|
||||||
// 生成设备ID的函数
|
// 生成设备ID的函数
|
||||||
function generateDeviceId(): string {
|
function generateDeviceId(): string {
|
||||||
const timestamp = Date.now().toString(36)
|
const timestamp = Date.now().toString(36);
|
||||||
const randomStr = Math.random().toString(36).substring(2, 15)
|
const randomStr = Math.random().toString(36).substring(2, 15);
|
||||||
const browserInfo =
|
const browserInfo =
|
||||||
typeof window !== 'undefined'
|
typeof window !== 'undefined'
|
||||||
? `${window.navigator.userAgent}${window.screen.width}${window.screen.height}`
|
? `${window.navigator.userAgent}${window.screen.width}${window.screen.height}`
|
||||||
.replace(/\s/g, '')
|
.replace(/\s/g, '')
|
||||||
.substring(0, 10)
|
.substring(0, 10)
|
||||||
: 'server'
|
: 'server';
|
||||||
|
|
||||||
return `did_${timestamp}_${randomStr}_${browserInfo}`.toLowerCase()
|
return `did_${timestamp}_${randomStr}_${browserInfo}`.toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const tokenManager = {
|
export const tokenManager = {
|
||||||
|
|
@ -22,61 +22,61 @@ export const tokenManager = {
|
||||||
getToken: (cookieString?: string): string | null => {
|
getToken: (cookieString?: string): string | null => {
|
||||||
// 服务端环境,从传入的cookie字符串中解析
|
// 服务端环境,从传入的cookie字符串中解析
|
||||||
if (typeof window === 'undefined' && cookieString) {
|
if (typeof window === 'undefined' && cookieString) {
|
||||||
const cookies = parseCookieString(cookieString)
|
const cookies = parseCookieString(cookieString);
|
||||||
return cookies[TOKEN_COOKIE_NAME] || null
|
return cookies[TOKEN_COOKIE_NAME] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 客户端环境,从document.cookie或localStorage获取
|
// 客户端环境,从document.cookie或localStorage获取
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
// 优先从cookie获取
|
// 优先从cookie获取
|
||||||
const cookieToken = Cookies.get(TOKEN_COOKIE_NAME)
|
const cookieToken = Cookies.get(TOKEN_COOKIE_NAME);
|
||||||
if (cookieToken) {
|
if (cookieToken) {
|
||||||
return cookieToken
|
return cookieToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兼容原有的localStorage存储
|
// 兼容原有的localStorage存储
|
||||||
const localToken = window.localStorage.getItem('token')
|
const localToken = window.localStorage.getItem('token');
|
||||||
if (localToken) {
|
if (localToken) {
|
||||||
// 迁移到cookie
|
// 迁移到cookie
|
||||||
tokenManager.setToken(localToken)
|
tokenManager.setToken(localToken);
|
||||||
window.localStorage.removeItem('token')
|
window.localStorage.removeItem('token');
|
||||||
return localToken
|
return localToken;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取设备ID - 支持客户端和服务端
|
// 获取设备ID - 支持客户端和服务端
|
||||||
getDeviceId: (cookieString?: string): string => {
|
getDeviceId: (cookieString?: string): string => {
|
||||||
// 服务端环境,从传入的cookie字符串中解析
|
// 服务端环境,从传入的cookie字符串中解析
|
||||||
if (typeof window === 'undefined' && cookieString) {
|
if (typeof window === 'undefined' && cookieString) {
|
||||||
const cookies = parseCookieString(cookieString)
|
const cookies = parseCookieString(cookieString);
|
||||||
let deviceId = cookies[DEVICE_ID_COOKIE_NAME]
|
let deviceId = cookies[DEVICE_ID_COOKIE_NAME];
|
||||||
|
|
||||||
// 如果服务端没有设备ID,生成一个临时的
|
// 如果服务端没有设备ID,生成一个临时的
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
deviceId = generateDeviceId()
|
deviceId = generateDeviceId();
|
||||||
}
|
}
|
||||||
|
|
||||||
return deviceId
|
return deviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 客户端环境
|
// 客户端环境
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
let deviceId = Cookies.get(DEVICE_ID_COOKIE_NAME)
|
let deviceId = Cookies.get(DEVICE_ID_COOKIE_NAME);
|
||||||
|
|
||||||
// 如果没有设备ID,生成一个新的
|
// 如果没有设备ID,生成一个新的
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
deviceId = generateDeviceId()
|
deviceId = generateDeviceId();
|
||||||
tokenManager.setDeviceId(deviceId)
|
tokenManager.setDeviceId(deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return deviceId
|
return deviceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 兜底情况,生成临时设备ID
|
// 兜底情况,生成临时设备ID
|
||||||
return generateDeviceId()
|
return generateDeviceId();
|
||||||
},
|
},
|
||||||
|
|
||||||
// 设置token
|
// 设置token
|
||||||
|
|
@ -87,7 +87,7 @@ export const tokenManager = {
|
||||||
expires: 30,
|
expires: 30,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
@ -99,16 +99,17 @@ export const tokenManager = {
|
||||||
expires: 365,
|
expires: 365,
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 清除token(但保留设备ID)
|
// 清除token(但保留设备ID)
|
||||||
removeToken: (): void => {
|
removeToken: (): void => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
Cookies.remove(TOKEN_COOKIE_NAME)
|
console.log('remove token');
|
||||||
|
Cookies.remove(TOKEN_COOKIE_NAME);
|
||||||
// 同时清除可能存在的localStorage token
|
// 同时清除可能存在的localStorage token
|
||||||
window.localStorage.removeItem('token')
|
window.localStorage.removeItem('token');
|
||||||
// 注意:这里不清除设备ID
|
// 注意:这里不清除设备ID
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
@ -116,35 +117,35 @@ export const tokenManager = {
|
||||||
// 清除所有数据(包括设备ID)
|
// 清除所有数据(包括设备ID)
|
||||||
clearAll: (): void => {
|
clearAll: (): void => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
Cookies.remove(TOKEN_COOKIE_NAME)
|
Cookies.remove(TOKEN_COOKIE_NAME);
|
||||||
Cookies.remove(DEVICE_ID_COOKIE_NAME)
|
Cookies.remove(DEVICE_ID_COOKIE_NAME);
|
||||||
window.localStorage.removeItem('token')
|
window.localStorage.removeItem('token');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// 检查是否已登录
|
// 检查是否已登录
|
||||||
isAuthenticated: (cookieString?: string): boolean => {
|
isAuthenticated: (cookieString?: string): boolean => {
|
||||||
return !!tokenManager.getToken(cookieString)
|
return !!tokenManager.getToken(cookieString);
|
||||||
},
|
},
|
||||||
|
|
||||||
// 初始化设备ID(确保用户第一次访问时就有设备ID)
|
// 初始化设备ID(确保用户第一次访问时就有设备ID)
|
||||||
initializeDeviceId: (): void => {
|
initializeDeviceId: (): void => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
tokenManager.getDeviceId() // 这会自动生成并保存设备ID(如果不存在的话)
|
tokenManager.getDeviceId(); // 这会自动生成并保存设备ID(如果不存在的话)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
|
|
||||||
// 解析cookie字符串的辅助函数
|
// 解析cookie字符串的辅助函数
|
||||||
function parseCookieString(cookieString: string): Record<string, string> {
|
function parseCookieString(cookieString: string): Record<string, string> {
|
||||||
const cookies: Record<string, string> = {}
|
const cookies: Record<string, string> = {};
|
||||||
|
|
||||||
cookieString.split(';').forEach((cookie) => {
|
cookieString.split(';').forEach((cookie) => {
|
||||||
const [name, ...rest] = cookie.trim().split('=')
|
const [name, ...rest] = cookie.trim().split('=');
|
||||||
if (name) {
|
if (name) {
|
||||||
cookies[name] = rest.join('=')
|
cookies[name] = rest.join('=');
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
return cookies
|
return cookies;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -72,9 +72,9 @@ export function Providers({ children }: ProvidersProps) {
|
||||||
queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() });
|
queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() });
|
||||||
}
|
}
|
||||||
if (EXPIRED_ERROR_CODES.includes(error.errorCode)) {
|
if (EXPIRED_ERROR_CODES.includes(error.errorCode)) {
|
||||||
// 清除 cookie 中的 st
|
// TODO: 清除 cookie 中的 st
|
||||||
tokenManager.removeToken();
|
// tokenManager.removeToken();
|
||||||
router.push('/login?redirect=' + encodeURIComponent(redirectURL));
|
// router.push('/login?redirect=' + encodeURIComponent(redirectURL));
|
||||||
return; // 对于登录过期错误,不显示错误toast,直接跳转
|
return; // 对于登录过期错误,不显示错误toast,直接跳转
|
||||||
}
|
}
|
||||||
if (error.ignoreError) {
|
if (error.ignoreError) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { setupWorker } from 'msw/browser'
|
|
||||||
import { handlers } from './handlers'
|
|
||||||
|
|
||||||
// 设置浏览器端的 Service Worker
|
|
||||||
export const worker = setupWorker(...handlers)
|
|
||||||
|
|
@ -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)
|
|
||||||
)
|
|
||||||
}),
|
|
||||||
]
|
|
||||||
|
|
@ -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',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +0,0 @@
|
||||||
import { setupServer } from 'msw/node'
|
|
||||||
import { handlers } from './handlers'
|
|
||||||
|
|
||||||
// 设置 Node.js 端的 MSW 服务器
|
|
||||||
export const server = setupServer(...handlers)
|
|
||||||
80
src/proxy.ts
80
src/proxy.ts
|
|
@ -1,11 +1,11 @@
|
||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server';
|
||||||
import type { NextRequest } from 'next/server'
|
import type { NextRequest } from 'next/server';
|
||||||
|
|
||||||
// 需要认证的路由
|
// 需要认证的路由
|
||||||
const protectedRoutes = [
|
const protectedRoutes = [
|
||||||
'/profile',
|
// '/profile',
|
||||||
'/profile/account',
|
// '/profile/account',
|
||||||
'/profile/edit',
|
// '/profile/edit',
|
||||||
'/create',
|
'/create',
|
||||||
'/settings',
|
'/settings',
|
||||||
'/login/fields',
|
'/login/fields',
|
||||||
|
|
@ -15,50 +15,50 @@ const protectedRoutes = [
|
||||||
'/wallet',
|
'/wallet',
|
||||||
'/wallet/transactions',
|
'/wallet/transactions',
|
||||||
'/crushcoin',
|
'/crushcoin',
|
||||||
]
|
];
|
||||||
|
|
||||||
// 已登录用户不应该访问的路由
|
// 已登录用户不应该访问的路由
|
||||||
const authRoutes = ['/login']
|
const authRoutes = ['/login'];
|
||||||
|
|
||||||
const DEVICE_ID_COOKIE_NAME = 'sd'
|
const DEVICE_ID_COOKIE_NAME = 'sd';
|
||||||
|
|
||||||
// 生成设备ID
|
// 生成设备ID
|
||||||
function generateDeviceId(userAgent?: string): string {
|
function generateDeviceId(userAgent?: string): string {
|
||||||
const timestamp = Date.now().toString(36)
|
const timestamp = Date.now().toString(36);
|
||||||
const randomStr = Math.random().toString(36).substring(2, 15)
|
const randomStr = Math.random().toString(36).substring(2, 15);
|
||||||
const browserInfo = userAgent ? userAgent.replace(/\s/g, '').substring(0, 10) : 'server'
|
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) {
|
export default function proxy(request: NextRequest) {
|
||||||
const { pathname } = request.nextUrl
|
const { pathname } = request.nextUrl;
|
||||||
// console.log('🔄 [MIDDLEWARE] 开始处理路径:', pathname)
|
// console.log('🔄 [MIDDLEWARE] 开始处理路径:', pathname)
|
||||||
// console.log('🔄 [MIDDLEWARE] 请求方法:', request.method)
|
// console.log('🔄 [MIDDLEWARE] 请求方法:', request.method)
|
||||||
// console.log('🔄 [MIDDLEWARE] User-Agent:', request.headers.get('user-agent')?.substring(0, 50))
|
// console.log('🔄 [MIDDLEWARE] User-Agent:', request.headers.get('user-agent')?.substring(0, 50))
|
||||||
// console.log('🔄 [MIDDLEWARE] 请求头:', Object.fromEntries(request.headers.entries()))
|
// console.log('🔄 [MIDDLEWARE] 请求头:', Object.fromEntries(request.headers.entries()))
|
||||||
|
|
||||||
// 获取现有设备ID
|
// 获取现有设备ID
|
||||||
let deviceId = request.cookies.get(DEVICE_ID_COOKIE_NAME)?.value
|
let deviceId = request.cookies.get(DEVICE_ID_COOKIE_NAME)?.value;
|
||||||
let needSetCookie = false
|
let needSetCookie = false;
|
||||||
|
|
||||||
if (!deviceId) {
|
if (!deviceId) {
|
||||||
// 生成新的设备ID
|
// 生成新的设备ID
|
||||||
const userAgent = request.headers.get('user-agent') || undefined
|
const userAgent = request.headers.get('user-agent') || undefined;
|
||||||
deviceId = generateDeviceId(userAgent)
|
deviceId = generateDeviceId(userAgent);
|
||||||
needSetCookie = true
|
needSetCookie = true;
|
||||||
// console.log('🆕 [MIDDLEWARE] 生成新设备ID:', deviceId)
|
// console.log('🆕 [MIDDLEWARE] 生成新设备ID:', deviceId)
|
||||||
} else {
|
} else {
|
||||||
console.log('✅ [MIDDLEWARE] 获取现有设备ID:', deviceId)
|
console.log('✅ [MIDDLEWARE] 获取现有设备ID:', deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 认证逻辑
|
// 认证逻辑
|
||||||
const token = request.cookies.get('st')?.value
|
const token = request.cookies.get('st')?.value;
|
||||||
const isAuthenticated = !!token
|
const isAuthenticated = !!token;
|
||||||
const isProtectedRoute = protectedRoutes.some((route) => pathname.startsWith(route))
|
const isProtectedRoute = protectedRoutes.some((route) => pathname.startsWith(route));
|
||||||
const isAuthRoute = authRoutes.some(
|
const isAuthRoute = authRoutes.some(
|
||||||
(route) => pathname.startsWith(route) && !pathname.startsWith('/login/fields')
|
(route) => pathname.startsWith(route) && !pathname.startsWith('/login/fields')
|
||||||
)
|
);
|
||||||
|
|
||||||
// console.log('🔑 [MIDDLEWARE] 认证状态:', {
|
// console.log('🔑 [MIDDLEWARE] 认证状态:', {
|
||||||
// isAuthenticated,
|
// isAuthenticated,
|
||||||
|
|
@ -70,32 +70,32 @@ export default function proxy(request: NextRequest) {
|
||||||
|
|
||||||
// 如果是受保护的路由但用户未登录,重定向到登录页
|
// 如果是受保护的路由但用户未登录,重定向到登录页
|
||||||
if (isProtectedRoute && !isAuthenticated) {
|
if (isProtectedRoute && !isAuthenticated) {
|
||||||
console.log('🚫 [MIDDLEWARE] 重定向到登录页:', pathname)
|
console.log('🚫 [MIDDLEWARE] 重定向到登录页:', pathname);
|
||||||
const loginUrl = new URL('/login', request.url)
|
const loginUrl = new URL('/login', request.url);
|
||||||
loginUrl.searchParams.set('redirect', pathname)
|
loginUrl.searchParams.set('redirect', pathname);
|
||||||
return NextResponse.redirect(loginUrl)
|
return NextResponse.redirect(loginUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果已登录用户访问认证页面,重定向到首页
|
// 如果已登录用户访问认证页面,重定向到首页
|
||||||
if (isAuthRoute && isAuthenticated) {
|
if (isAuthRoute && isAuthenticated) {
|
||||||
console.log('🔄 [MIDDLEWARE] 已登录用户重定向到首页:', pathname)
|
console.log('🔄 [MIDDLEWARE] 已登录用户重定向到首页:', pathname);
|
||||||
return NextResponse.redirect(new URL('/', request.url))
|
return NextResponse.redirect(new URL('/', request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 在请求头中添加认证状态和设备ID,供服务端组件使用
|
// 在请求头中添加认证状态和设备ID,供服务端组件使用
|
||||||
const requestHeaders = new Headers(request.headers)
|
const requestHeaders = new Headers(request.headers);
|
||||||
requestHeaders.set('x-authenticated', isAuthenticated.toString())
|
requestHeaders.set('x-authenticated', isAuthenticated.toString());
|
||||||
requestHeaders.set('x-device-id', deviceId) // 确保设备ID被传递
|
requestHeaders.set('x-device-id', deviceId); // 确保设备ID被传递
|
||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
requestHeaders.set('x-auth-token', token)
|
requestHeaders.set('x-auth-token', token);
|
||||||
}
|
}
|
||||||
// 创建响应
|
// 创建响应
|
||||||
const response = NextResponse.next({
|
const response = NextResponse.next({
|
||||||
request: {
|
request: {
|
||||||
headers: requestHeaders,
|
headers: requestHeaders,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
|
|
||||||
// 如果需要设置设备ID cookie
|
// 如果需要设置设备ID cookie
|
||||||
if (needSetCookie) {
|
if (needSetCookie) {
|
||||||
|
|
@ -105,16 +105,16 @@ export default function proxy(request: NextRequest) {
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
path: '/',
|
path: '/',
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.startsWith('/@')) {
|
if (pathname.startsWith('/@')) {
|
||||||
const userId = pathname.slice(2) // 去掉/@
|
const userId = pathname.slice(2); // 去掉/@
|
||||||
return NextResponse.rewrite(new URL(`/user/${userId}`, request.url))
|
return NextResponse.rewrite(new URL(`/user/${userId}`, request.url));
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('✅ [MIDDLEWARE] 成功处理完毕:', pathname)
|
console.log('✅ [MIDDLEWARE] 成功处理完毕:', pathname);
|
||||||
return response
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
|
|
@ -122,4 +122,4 @@ export const config = {
|
||||||
// 匹配所有路径,除了静态文件和API路由
|
// 匹配所有路径,除了静态文件和API路由
|
||||||
'/((?!api|_next/static|_next/image|favicon.ico|public).*)',
|
'/((?!api|_next/static|_next/image|favicon.ico|public).*)',
|
||||||
],
|
],
|
||||||
}
|
};
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,8 @@ export const useStreamChatStore = create<StreamChatStore>((set, get) => ({
|
||||||
channels: [],
|
channels: [],
|
||||||
currentChannel: null,
|
currentChannel: null,
|
||||||
async connect(user) {
|
async connect(user) {
|
||||||
const user = {};
|
if (client) return;
|
||||||
|
console.log('connecting stream chat', user);
|
||||||
const { data } = await getUserToken(user);
|
const { data } = await getUserToken(user);
|
||||||
client = new StreamChat(process.env.NEXT_PUBLIC_STREAM_CHAT_API_KEY || '');
|
client = new StreamChat(process.env.NEXT_PUBLIC_STREAM_CHAT_API_KEY || '');
|
||||||
await client.connectUser(
|
await client.connectUser(
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue