Compare commits

...

2 Commits

Author SHA1 Message Date
liuyonghe0111 48455e084f feat: min-proxy 2025-11-24 11:48:57 +08:00
liuyonghe0111 6851c8d22d feat: 优化代码 2025-11-24 11:47:20 +08:00
60 changed files with 1333 additions and 701 deletions

2
.env
View File

@ -22,7 +22,7 @@ NEXT_PUBLIC_RTC_APP_ID=689ade491323ae01797818e0
# 启用 mock
NEXT_PUBLIC_ENABLE_MOCK=false
NEXT_PUBLIC_APP_URL=https://test.crushlevel.ai
NEXT_PUBLIC_APP_URL=http://localhost:3000
NEXT_PUBLIC_IM_USER_SUFFIX=@u@t
NEXT_PUBLIC_IM_AI_SUFFIX=@r@t

View File

@ -0,0 +1,238 @@
# AI 建议回复功能重构说明
## 重构日期
2025-11-17
## 最新更新
2025-11-17 - 优化请求逻辑,确保每次只发送 3 次 API 请求
## 问题描述
重构前的 AI 建议功能存在以下问题:
1. **状态管理复杂**:维护了大量状态(`allSuggestions`、`loadedPages`、`batchNo`、`excContentList`、`isPageLoading`、`isRequesting` 等)
2. **页面切换逻辑复杂**:需要跟踪哪些页面已加载,切换时判断是否显示骨架屏
3. **显示逻辑不清晰**`isLoading` 只在首次加载时为 true分页切换时骨架屏显示不正确
4. **用户体验问题**:会出现 AI 辅助提示为空的情况(如图所示)
## 重构目标
1. **简化状态管理**:只保留必要的状态
2. **统一交互逻辑**每次打开建议面板时重置到第1页并重新获取数据
3. **清晰的骨架屏显示**:数据未加载完成时始终显示骨架屏
## 重构内容
### 1. `useAiReplySuggestions` Hook 重构
#### 状态简化
**重构前:**
```typescript
const [allSuggestions, setAllSuggestions] = useState<ReplySuggestion[]>([]);
const [loadedPages, setLoadedPages] = useState<Set<number>>(new Set([1]));
const [isPageLoading, setIsPageLoading] = useState(false);
const [isRequesting, setIsRequesting] = useState(false);
// ... 其他状态
```
**重构后:**
```typescript
const [pageData, setPageData] = useState<Map<number, ReplySuggestion[]>>(new Map());
const [loadingPages, setLoadingPages] = useState<Set<number>>(new Set());
// ... 其他必要状态
```
#### 核心逻辑变化
**重构前:**
- `showSuggestions` 函数会根据多种条件判断是否需要获取数据
- 首次获取第1页数据然后静默获取第2、3页
- 页面切换时复杂的加载状态判断
**重构后:**
- **每次调用 `showSuggestions` 都重置到第1页并重新获取所有数据**
- 按页存储数据到 `Map` 中,逻辑更清晰
- 页面切换时自动检查并加载缺失的页面数据
### 2. 数据获取流程优化
#### 重构前流程
1. 获取第1页 → 立即展示
2. 静默获取第2页 → 更新 `loadedPages`
3. 静默获取第3页 → 更新 `loadedPages`
4. 用户切换页面时检查 `loadedPages`
#### 重构后流程
1. 清空旧数据
2. 获取第1页 → 存入 `pageData.set(1, ...)`
3. 获取第2页 → 存入 `pageData.set(2, ...)`
4. 获取第3页 → 存入 `pageData.set(3, ...)`
5. 用户切换页面时从 `pageData` 读取,无数据则显示骨架屏
### 3. 骨架屏显示逻辑
**重构前:**
```typescript
const displaySuggestions = isCurrentPageLoaded ? suggestions : Array.from({ length: 3 }, ...);
```
**重构后:**
```typescript
const suggestions = isCurrentPageLoading || !currentPageSuggestions
? Array.from({ length: 3 }, (_, index) => ({
id: `skeleton-${currentPage}-${index}`,
text: '',
isSkeleton: true
}))
: currentPageSuggestions;
```
UI 组件根据 `isSkeleton` 标志渲染骨架屏或真实内容。
### 4. UI 组件更新
`AiReplySuggestions.tsx` 更新:
- 添加 `isSkeleton` 字段到 `ReplySuggestion` 接口
- 检查 `suggestions.some(s => s.isSkeleton)` 决定是否显示骨架屏
- 骨架屏添加 `animate-pulse` 动画效果
## 重构优势
### 1. **简化的状态管理**
- 使用 `Map<number, ReplySuggestion[]>` 按页存储数据,结构清晰
- 移除了 `isRequesting`、`isPageLoading`、`isCurrentPageLoaded` 等冗余状态
- 只需维护 `loadingPages: Set<number>` 来跟踪正在加载的页面
### 2. **智能的数据管理**
- **有新 AI 消息时**重置到第1页并重新获取数据
- **没有新消息时**:保留之前的建议内容和当前页面位置
- 避免了"空白建议"的问题
- 数据加载状态清晰可见(骨架屏动画)
### 3. **更可靠的数据加载**
- 智能判断是否需要重新获取数据(基于最后一条 AI 消息 ID
- 首次打开或数据为空时自动获取
- 减少了因状态不同步导致的 bug
### 4. **更好的代码可维护性**
- 代码从 312 行减少到 200 行
- 逻辑流程更清晰直观
- 减少了状态依赖和副作用
## 使用示例
### 场景1首次打开建议面板渐进式加载
1. **点击建议按钮** → 面板打开重置到第1页显示骨架屏
2. **~500ms 后第1页数据返回** → ✨ **立即展示第1页的3条建议**(用户可以开始浏览)
3. **后台继续获取第2、3页** → 不阻塞用户交互
4. **点击下一页** → 切换到第2页
- 如果第2页数据已加载 → 立即显示
- 如果第2页数据未加载 → 显示骨架屏通常第2页已在后台加载完成
### 场景2关闭后再次打开无新消息
1. **关闭建议面板**
2. **再次点击建议按钮** → 面板打开
3. **保留上次的内容和页面位置**比如之前在第2页现在仍在第2页
4. **无需重新加载数据**,立即显示缓存的建议
### 场景3收到新 AI 消息后打开
1. **用户发送消息AI 回复**
2. **点击建议按钮** → 面板打开
3. **检测到新的 AI 消息** → 重置到第1页显示骨架屏
4. **重新获取所有建议数据**(基于最新的对话内容)
### 场景4面板已打开时收到新 AI 消息
1. **建议面板已打开**比如用户正在浏览第2页
2. **用户发送消息AI 回复**
3. **自动刷新建议数据****保持在当前页面**第2页不强制切回第1页
4. **渐进式加载新数据**,用户可以继续浏览当前页
## API 返回值变化
Hook 返回的接口保持不变,确保向后兼容:
```typescript
return {
suggestions, // ReplySuggestion[] (包含 isSkeleton 标志)
currentPage, // number
totalPages, // number
isLoading, // boolean (只要有页面在加载就为 true)
isVisible, // boolean
showSuggestions, // () => void
hideSuggestions, // () => void
handlePageChange, // (page: number) => void
};
```
移除的返回值:
- `isPageLoading`
- `isCurrentPageLoaded`
- `refreshSuggestions`
## 关键优化点
### 避免多次请求的设计
**问题**:原始实现会触发多次重复请求
**解决方案**
1. **统一的 `fetchAllData` 函数**一次性顺序获取3页数据使用局部变量传递 `batchNo``excContentList`
2. **防重复调用保护**:在 `fetchAllData` 开始时检查 `loadingPages.size > 0`,如果已有加载则跳过
3. **移除分页独立加载**:删除了 `fetchPageData` 函数和 `handlePageChange` 中的数据获取逻辑
4. **简化页面切换**`handlePageChange` 只负责切换 `currentPage`,不触发数据加载
### 渐进式加载流程
采用**渐进式加载**策略,让用户尽早看到数据,提升体验:
```typescript
fetchAllData() {
// 1. 检查防重复
if (loadingPages.size > 0) return;
// 2. 标记所有页面为加载中
setLoadingPages(new Set([1, 2, 3]));
// 3. 获取第1页 → 立即展示
const response1 = await genSupContentV2({ aiId, excContentList: [] });
setPageData(new Map([[1, response1]])); // ✨ 立即展示第1页
setLoadingPages(new Set([2, 3])); // 标记第1页已完成
// 4. 获取第2页 → 追加展示
const response2 = await genSupContentV2({ aiId, batchNo, excContentList: [response1] });
setPageData(prev => prev.set(2, response2)); // ✨ 追加第2页
setLoadingPages(new Set([3])); // 标记第2页已完成
// 5. 获取第3页 → 追加展示
const response3 = await genSupContentV2({ aiId, batchNo, excContentList: [response1, response2] });
setPageData(prev => prev.set(3, response3)); // ✨ 追加第3页
setLoadingPages(new Set()); // 全部完成
}
```
**关键优势**
- 🚀 **第1页数据到达后立即展示**,用户无需等待所有数据
- 📊 **后续页面数据追加展示**不影响用户浏览第1页
- ⏱️ **感知加载时间更短**,提升用户体验
- 🔄 **页面2、3可以并行渲染**,用户切换时自动显示骨架屏
## 注意事项
1. **精确的请求次数**:每次调用 `fetchAllData` 只会发送 **3 次** API 请求第1、2、3页
2. **智能缓存策略**:没有新 AI 消息时,复用已有数据,不发送请求
3. **网络失败处理**:任一页面加载失败会中断整个流程并清空数据
4. **Coin 不足处理**:任何页面触发 Coin 不足错误都会关闭整个建议面板
5. **防重复保护**:通过 `loadingPages.size` 检查防止并发调用
## 测试建议
1. 测试正常流程:打开建议 → 浏览3页 → 关闭 → 再次打开
2. 测试网络慢场景:确认骨架屏正确显示
3. 测试 Coin 不足场景:确认面板正确关闭
4. 测试新消息场景:发送消息后面板已打开时自动刷新

View File

@ -15,6 +15,7 @@ const GoogleButton = () => {
const searchParams = useSearchParams()
const redirect = searchParams.get('redirect');
const [isLoading, setIsLoading] = useState(false)
const buttonRef = useRef<HTMLDivElement>(null)
const isInitializedRef = useRef(false)
// 处理 Google ID Token 回调
@ -22,7 +23,7 @@ const GoogleButton = () => {
try {
setIsLoading(true)
// 使用 ID token (JWT) 调用后端登录接口
// 使用 ID Token (JWT) 调用后端登录接口
const deviceId = tokenManager.getDeviceId()
const loginData = {
appClient: AppClient.Web,
@ -58,19 +59,25 @@ const GoogleButton = () => {
}
}
// 加载 Google Identity Services SDK 并初始化
// 加载 Google Identity Services SDK 并渲染按钮
useEffect(() => {
const loadAndInitGoogleSDK = async () => {
const loadAndInitGoogleButton = async () => {
try {
if (isInitializedRef.current) return
if (isInitializedRef.current || !buttonRef.current) return
await googleOAuth.loadScript()
// 初始化 Google Identity
if (window.google?.accounts?.id) {
googleOAuth.initGoogleId(handleGoogleResponse)
// 使用 Google 提供的标准按钮,这种方式会自动处理未登录的情况
if (window.google?.accounts?.id && buttonRef.current) {
googleOAuth.renderButton(buttonRef.current, handleGoogleResponse, {
type: 'standard',
theme: 'outline',
size: 'large',
text: 'continue_with',
width: buttonRef.current.offsetWidth.toString()
})
isInitializedRef.current = true
console.log('Google Identity Services initialized')
console.log('Google Sign-In button rendered')
}
} catch (error) {
console.error('Failed to load Google SDK:', error)
@ -78,49 +85,48 @@ const GoogleButton = () => {
}
}
loadAndInitGoogleSDK()
}, [handleGoogleResponse])
const handleGoogleLogin = async () => {
try {
setIsLoading(true)
loadAndInitGoogleButton()
}, [])
const handleGoogleLogin = () => {
// 保存重定向 URL
if (typeof window !== 'undefined') {
sessionStorage.setItem('login_redirect_url', redirect || '')
}
// 确保 SDK 已加载并初始化
if (!window.google?.accounts?.id) {
await googleOAuth.loadScript()
googleOAuth.initGoogleId(handleGoogleResponse)
}
// 触发 One Tap 登录流程
if (window.google?.accounts?.id) {
window.google.accounts.id.prompt((notification) => {
if (notification.isNotDisplayed() || notification.isSkippedMoment()) {
console.log('One Tap not displayed:', notification.getNotDisplayedReason() || notification.getSkippedReason())
// One Tap 无法显示时,使用备选方案 (可选)
setIsLoading(false)
}
})
}
} catch (error) {
console.error('Google login error:', error)
toast.error("Failed to initialize Google login")
setIsLoading(false)
// 如果 Google 按钮已渲染,点击会自动触发
// 如果未渲染,显示提示
if (!isInitializedRef.current) {
toast.error("Google login is not ready yet")
}
}
return (
<>
{/* 隐藏的 Google 标准按钮容器 */}
<div
ref={buttonRef}
style={{ display: 'none' }}
/>
{/* 自定义样式的按钮,点击时触发隐藏的 Google 按钮 */}
<SocialButton
icon={<i className="iconfont icon-social-google !text-[20px] sm:!text-[24px]"></i>}
onClick={handleGoogleLogin}
disabled={login.isPending}
onClick={() => {
handleGoogleLogin()
// 触发隐藏的 Google 按钮
if (buttonRef.current) {
const googleButton = buttonRef.current.querySelector('div[role="button"]') as HTMLElement
if (googleButton) {
googleButton.click()
}
}
}}
disabled={login.isPending || isLoading}
>
{login.isPending ? "Signing in..." : "Continue with Google"}
{login.isPending || isLoading ? "Signing in..." : "Continue with Google"}
</SocialButton>
</>
);
}

View File

@ -51,8 +51,16 @@ export function LeftPanel({ scrollBg, images }: LeftPanelProps) {
{/* 滚动背景 */}
<ScrollingBackground imageSrc={scrollBg} />
{/* 内容层 */}
<div className="relative z-10 flex flex-col justify-end h-full">
{/* 底部遮罩层 - 铺满背景底部高度500px */}
<div className="absolute bottom-0 left-0 right-0 h-[500px] z-[5]" style={{
background: "linear-gradient(180deg, rgba(33, 26, 43, 0) 0%, #211A2B 100%)",
boxShadow: "0px 4px 4px 0px #00000040"
}} />
{/* 文字内容 - 在图片上方 */}
<div
className={`text-center px-4 lg:px-8 mb-6 lg:mb-8 transition-opacity duration-700 absolute left-0 right-0 bottom-16 lg:bottom-20 z-10 ${

View File

@ -29,6 +29,9 @@ const ChatPage = () => {
refreshBeforeExpireMinutes: 5
});
const handleOpenChatProfileDrawer = () => {
setIsChatProfileDrawerOpen(true);
};
const isShowRedDot = hasRedDot(RED_DOT_KEYS.CHAT_BACKGROUND) || hasRedDot(RED_DOT_KEYS.CHAT_BUBBLE);
@ -44,7 +47,7 @@ const ChatPage = () => {
<ChatMessageList />
<ChatMessageAction />
<div className="absolute right-6 top-6 w-8 h-8">
<IconButton iconfont="icon-icon_chatroom_more" variant="ghost" size="small" onClick={() => setIsChatProfileDrawerOpen(true)} />
<IconButton iconfont="icon-icon_chatroom_more" variant="ghost" size="small" onClick={handleOpenChatProfileDrawer} />
{isShowRedDot && <Badge variant="dot" className="absolute top-0 right-0" />}
</div>
</div>

View File

@ -374,7 +374,7 @@ const ChatCallContainer = () => {
if (newState.isAiThinking) {
setTimeout(() => {
setSubtitleState({ ...newState });
// rtc.current?.changeAudioState(false);
rtc.current?.changeAudioState(false);
}, 200);
return;
}

View File

@ -8,6 +8,7 @@ import Image from 'next/image';
interface ReplySuggestion {
id: string;
text: string;
isSkeleton?: boolean;
}
interface AiReplySuggestionsProps {
@ -33,6 +34,8 @@ export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
onClose,
className
}) => {
// 检查是否显示骨架屏:当前页的建议中有骨架屏标记
const showSkeleton = suggestions.some(s => s.isSkeleton);
return (
<div className={cn(
@ -54,22 +57,22 @@ export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
</div>
{/* 建议列表 */}
{isLoading ? (
// 骨架屏 - 固定显示2条建议的布局
[1, 2, 3].map((index) => (
{showSkeleton ? (
// 骨架屏 - 固定显示3条建议的布局
suggestions.map((suggestion) => (
<div
key={`skeleton-${index}`}
className="flex gap-4 items-end justify-start overflow-hidden pl-4 pr-4 py-2 rounded-xl w-full cursor-pointer bg-surface-element-light-normal hover:bg-surface-element-light-hover transition-colors backdrop-blur-[16px]"
key={suggestion.id}
className="flex gap-4 items-end justify-start overflow-hidden pl-4 pr-4 py-2 rounded-xl w-full bg-surface-element-light-normal backdrop-blur-[16px]"
>
<div className="flex-1 px-0 py-1">
<div className="h-6 bg-surface-element-normal rounded w-full"></div>
<div className="h-6 bg-surface-element-normal rounded w-full animate-pulse"></div>
</div>
<div className="size-8 bg-surface-element-normal rounded-full flex-shrink-0"></div>
<div className="size-8 bg-surface-element-normal rounded-full flex-shrink-0 animate-pulse"></div>
</div>
))
) : (
// 实际建议内容
suggestions.slice(0, 3).map((suggestion) => (
suggestions.map((suggestion) => (
<div
key={suggestion.id}
className="flex gap-4 items-end justify-start overflow-hidden pl-4 pr-4 py-2 rounded-xl w-full cursor-pointer bg-surface-element-light-normal hover:bg-surface-element-light-hover transition-colors backdrop-blur-[16px]"

View File

@ -35,15 +35,21 @@ const ChatMessageList = () => {
} = useMessageState();
// 使用加载状态Hook
const { loadingState, initializeLoading, fetchNextPage, completeInitialLoad } = useMessageLoading(
{
const {
loadingState,
initializeLoading,
fetchNextPage,
completeInitialLoad,
} = useMessageLoading({
selectedConversationId,
getHistoryMsgActive,
}
);
});
// 使用滚动Hook
const { containerRef, handleInitialLoadComplete } = useMessageScrolling({
const {
containerRef,
handleInitialLoadComplete,
} = useMessageScrolling({
messages,
selectedConversationId,
isInitialLoad: loadingState.isInitialLoad,
@ -79,10 +85,10 @@ const ChatMessageList = () => {
// 首次加载完成后的处理
useEffect(() => {
if (loadingState.isInitialLoad && (messages.length > 0 || loadingState.apiCallCount > 0)) {
if (process.env.NODE_ENV === "development") {
console.log("📨 检测到首批消息加载完成,准备隐藏骨架屏", {
if (process.env.NODE_ENV === 'development') {
console.log('📨 检测到首批消息加载完成,准备隐藏骨架屏', {
messagesLength: messages.length,
apiCallCount: loadingState.apiCallCount,
apiCallCount: loadingState.apiCallCount
});
}
@ -91,29 +97,21 @@ const ChatMessageList = () => {
completeInitialLoad(messages.length);
handleInitialLoadComplete();
if (process.env.NODE_ENV === "development") {
console.log("🦴 隐藏骨架屏,显示真实消息");
if (process.env.NODE_ENV === 'development') {
console.log('🦴 隐藏骨架屏,显示真实消息');
}
}, 200);
}
}, [
messages.length,
loadingState.isInitialLoad,
loadingState.apiCallCount,
completeInitialLoad,
handleInitialLoadComplete,
]);
}, [messages.length, loadingState.isInitialLoad, loadingState.apiCallCount, completeInitialLoad, handleInitialLoadComplete]);
console.log(
"messages",
messages,
selectedConversationId,
nim?.V2NIMConversationIdUtil.parseConversationTargetId(selectedConversationId || "")
);
console.log('messages', messages, selectedConversationId, nim?.V2NIMConversationIdUtil.parseConversationTargetId(selectedConversationId || ''));
return (
<div ref={containerRef} className="min-h-0 flex-1 overflow-y-auto pt-12 pb-20">
<div className="mx-auto max-w-[752px]">
<div
ref={containerRef}
className="flex-1 min-h-0 overflow-y-auto pt-12 pb-20"
>
<div className="max-w-[752px] mx-auto">
{/* 用户信息头部始终显示在顶部 */}
<div ref={userHeaderRef}>
<ChatMessageUserHeader />
@ -124,7 +122,7 @@ const ChatMessageList = () => {
<div
ref={loadMoreRef}
className="h-1 w-full opacity-0"
style={{ backgroundColor: "red" }} // 临时调试样式,方便查看位置
style={{ backgroundColor: 'red' }} // 临时调试样式,方便查看位置
/>
)}
@ -138,24 +136,23 @@ const ChatMessageList = () => {
{/* CrushLevelAction - 使用visibility和opacity控制显示/隐藏,避免图片重复加载 */}
<div
style={{
visibility: showCrushLevelAction ? "visible" : "hidden",
visibility: showCrushLevelAction ? 'visible' : 'hidden',
opacity: showCrushLevelAction ? 1 : 0,
pointerEvents: showCrushLevelAction ? "auto" : "none",
transition: "opacity 0.2s ease-in-out",
pointerEvents: showCrushLevelAction ? 'auto' : 'none',
transition: 'opacity 0.2s ease-in-out'
}}
>
<CrushLevelAction />
</div>
{/* 骨架屏 - 仅在首次访问且无数据且尚未完成加载时显示 */}
{loadingState.showSkeleton &&
messages.length === 0 &&
loadingState.isInitialLoad &&
!loadingState.hasLoadedOnce && <ChatMessageSkeleton />}
{loadingState.showSkeleton && messages.length === 0 && loadingState.isInitialLoad && !loadingState.hasLoadedOnce && (
<ChatMessageSkeleton />
)}
{/* 消息列表 */}
{!loadingState.showSkeleton && (
<div className="mt-8 space-y-4">
<div className="space-y-4 mt-8">
{/* 持久化开场白 - 始终显示在消息列表顶部 */}
<ChatPrologueMessage />
@ -166,7 +163,10 @@ const ChatMessageList = () => {
return (
<div key={message.messageClientId || message.messageServerId}>
<ChatMessageItems isUser={isUser} message={message} />
<ChatMessageItems
isUser={isUser}
message={message}
/>
</div>
);
})}
@ -174,7 +174,10 @@ const ChatMessageList = () => {
{/* Loading消息始终显示在最后 */}
{loadingMessages.map((message) => (
<div key={message.messageClientId}>
<ChatMessageItems isUser={false} message={message} />
<ChatMessageItems
isUser={false}
message={message}
/>
</div>
))}
</div>
@ -182,6 +185,6 @@ const ChatMessageList = () => {
</div>
</div>
);
};
}
export default ChatMessageList;

View File

@ -2,16 +2,16 @@
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { TagBadge } from "@/components/ui/badge"
import { InfiniteScrollList } from "@/components/ui/infinite-scroll-list"
import RenderContactStatusText from "./components/RenderContactStatusText"
import { useHeartbeatRelationListInfinite } from "@/hooks/useIm"
import { HeartbeatRelationListOutput } from "@/services/im/types"
import { useMemo } from "react"
import { useRouter } from "next/navigation"
import { Tag } from "@/components/ui/tag"
import AIRelationTag from "@/components/features/AIRelationTag"
import Link from "next/link"
import { usePrefetchRoutes } from "@/hooks/useGlobalPrefetchRoutes"
import Image from "next/image"
// 联系人数据类型现在使用API返回的数据结构
type ContactItem = HeartbeatRelationListOutput
@ -28,16 +28,6 @@ const ContactCard = ({ contact }: { contact: ContactItem }) => {
return currentYear - birthYear;
}, [contact.birthday]);
// 获取性别标识
const getGenderText = (sex?: number) => {
switch (sex) {
case 0: return "Male";
case 1: return "Female";
case 2: return "Custom";
default: return "";
}
};
// 跳转到聊天页面
const handleChatClick = () => {
if (contact.aiId) {
@ -50,7 +40,7 @@ const ContactCard = ({ contact }: { contact: ContactItem }) => {
{/* 用户信息部分 */}
<div className="flex items-center gap-4 flex-1">
{/* 头像 */}
<Link href={`/@${contact.aiId}`}>
<Link href={`/@${contact.aiId}`} prefetch>
<Avatar className="size-16">
<AvatarImage src={contact.headImg} alt={contact.nickname || contact.roleName} />
<AvatarFallback className="bg-surface-element-normal text-txt-primary-normal txt-title-m">
@ -63,7 +53,7 @@ const ContactCard = ({ contact }: { contact: ContactItem }) => {
<div className="flex flex-col gap-2 flex-1">
{/* 名字和标签 */}
<div className="flex items-center gap-2">
<Link href={`/@${contact.aiId}`}>
<Link href={`/@${contact.aiId}`} prefetch>
<h3 className="txt-title-m text-white">
{contact.nickname || contact.roleName}
</h3>
@ -75,9 +65,11 @@ const ContactCard = ({ contact }: { contact: ContactItem }) => {
<div className="flex items-center gap-2">
{/* 心动值 */}
<div className="flex items-center gap-1">
<img
<Image
src="/icons/heart.svg"
alt="Heart"
width={12}
height={12}
className="w-3 h-3"
/>
<span className="txt-numMonotype-s">
@ -123,6 +115,11 @@ const ContactsPage = () => {
const allContacts = useMemo(() => {
return data?.pages.flatMap(page => page.datas || []) || [];
}, [data]);
const chatRoutes = useMemo(
() => allContacts.slice(0, 20).map((contact) => contact?.aiId ? `/chat/${contact.aiId}` : null),
[allContacts]
)
usePrefetchRoutes(chatRoutes)
// 加载状态骨架屏组件
const ContactSkeleton = () => (
@ -144,9 +141,11 @@ const ContactsPage = () => {
// 空状态组件
const EmptyState = () => (
<div className="flex flex-col items-center justify-center py-16 text-center">
<img
<Image
src="/icons/empty.svg"
alt="Empty"
width={64}
height={64}
className="w-16 h-16 mb-4 opacity-50"
/>
<h3 className="txt-title-m text-txt-secondary-normal mb-2">

View File

@ -125,6 +125,7 @@ export default function CharacterForm() {
// 检测名字是否重复
const isExist = await checkNickname({
nickname: nickname.trim(),
isAiCheck: true,
})
if (isExist) {
form.setError("nickname", {
@ -132,15 +133,15 @@ export default function CharacterForm() {
})
return;
}
const resp = await checkText({
content: userProfile,
})
if (resp) {
form.setError("userProfile", {
message: resp,
})
return;
}
// const resp = await checkText({
// content: userProfile,
// })
// if (resp) {
// form.setError("userProfile", {
// message: resp,
// })
// return;
// }
router.push("/create/dialogue")
} catch (error) {
console.error(error)

View File

@ -25,7 +25,7 @@ import { useCheckText } from "@/hooks/auth"
const dialogueFormSchema = z.object({
userDialogueStyle: z.string().trim().optional(),
// .min(1, "Please enter style").min(10, "Please enter at least 10 characters").max(300, "Please enter less than 300 characters"),
prologue: z.string().trim().min(1, "Please enter opening").min(10, "Please enter at least 10 characters").max(150, "Please enter less than 150 characters"),
prologue: z.string().trim().min(1, "Please enter opening").min(10, "Please enter at least 10 characters").max(250, "Please enter less than 250 characters"),
voice: z.object({
content: z.string().optional(),
tone: z.number().min(-50).max(100),
@ -38,13 +38,13 @@ const dialogueFormSchema = z.object({
path: ["voice"],
}).refine((data) => {
if (data.userDialogueStyle) {
if (data.userDialogueStyle.trim().length > 300) {
if (data.userDialogueStyle.trim().length > 400) {
return false;
}
}
return true;
}, {
message: "Please enter less than 300 characters",
message: "Please enter less than 400 characters",
path: ["userDialogueStyle"],
}).refine((data) => {
if (data.userDialogueStyle) {
@ -170,28 +170,28 @@ export default function DialogueForm() {
}
setLoading(true)
try {
if (data.prologue) {
const resp = await checkText({
content: data.prologue,
})
if (resp) {
form.setError("prologue", {
message: resp,
})
return;
}
}
if (data.userDialogueStyle) {
const resp = await checkText({
content: data.userDialogueStyle,
})
if (resp) {
form.setError("userDialogueStyle", {
message: resp,
})
return;
}
}
// if (data.prologue) {
// const resp = await checkText({
// content: data.prologue,
// })
// if (resp) {
// form.setError("prologue", {
// message: resp,
// })
// return;
// }
// }
// if (data.userDialogueStyle) {
// const resp = await checkText({
// content: data.userDialogueStyle,
// })
// if (resp) {
// form.setError("userDialogueStyle", {
// message: resp,
// })
// return;
// }
// }
if (!formData.image?.intro) {
const resp = await generateContent({
ptType: PtType.GenAIIntroduction,
@ -323,14 +323,14 @@ export default function DialogueForm() {
figure: formData.character?.aiUserExt?.userProfile ?? undefined,
} as GenerateContentRequest;
const resp = await generateContent(result)
form.setValue("prologue", (resp.content || "").slice(0, 150))
form.setValue("prologue", resp.content)
}}
/>
</div>
<FormControl>
<Textarea
placeholder="Set the character's greeting"
maxLength={150}
maxLength={250}
showCount
rows={4}
error={!!form.formState.errors.prologue}
@ -362,7 +362,7 @@ export default function DialogueForm() {
figure: formData.character?.aiUserExt?.userProfile ?? undefined,
} as GenerateContentRequest;
const resp = await generateContent(result)
form.setValue("userDialogueStyle", (resp.content || "").slice(0, 300))
form.setValue("userDialogueStyle", resp.content)
updateHasStyleByAI(true)
}}
/>
@ -370,7 +370,7 @@ export default function DialogueForm() {
<FormControl>
<Textarea
placeholder="Describe the characters conversation style"
maxLength={300}
maxLength={400}
showCount
rows={4}
error={!!form.formState.errors.userDialogueStyle}

View File

@ -36,8 +36,10 @@ import { isCreateAiLimitReachedDialogOpenAtom } from "@/atoms/global"
const imageFormSchema = z.object({
imageUrl: z.string(),
avatarUrl: z.string(),
intro: z.string().min(10, "Please enter at least 10 characters").max(300, "Please enter less than 300 characters"),
intro: z.string().min(10, "Please enter at least 10 characters").max(400, "Please enter less than 400 characters"),
isPublic: z.boolean(),
imageStyleCode: z.number().optional(),
imageDesc: z.string().optional(),
}).refine((data) => {
return !!data.imageUrl && !!data.avatarUrl
}, {
@ -50,6 +52,8 @@ type ImageFormValues = {
avatarUrl: string
intro: string
isPublic: boolean
imageStyleCode?: number
imageDesc?: string
}
type ImageFormSchemaType = z.infer<typeof imageFormSchema>
@ -81,6 +85,8 @@ export default function ImageForm() {
avatarUrl: savedData?.avatarUrl || undefined,
intro: savedData?.intro || "",
isPublic: savedData?.isPublic ?? true,
imageStyleCode: savedData?.imageStyleCode,
imageDesc: savedData?.imageDesc,
}
})
const watchImageUrl = form.watch('imageUrl')
@ -92,6 +98,26 @@ export default function ImageForm() {
}
}, [isFormDataEmpty])
// 当从图片生成页面返回时,更新表单的 imageUrl, avatarUrl, imageStyleCode, imageDesc
useEffect(() => {
const currentFormImageUrl = form.getValues('imageUrl');
const currentFormAvatarUrl = form.getValues('avatarUrl');
// 如果 localStorage 中的图片 URL 与表单中的不一致,说明用户刚从图片生成页面返回
if (savedData?.imageUrl && savedData.imageUrl !== currentFormImageUrl) {
form.setValue('imageUrl', savedData.imageUrl);
}
if (savedData?.avatarUrl && savedData.avatarUrl !== currentFormAvatarUrl) {
form.setValue('avatarUrl', savedData.avatarUrl);
}
if (savedData?.imageStyleCode !== undefined) {
form.setValue('imageStyleCode', savedData.imageStyleCode);
}
if (savedData?.imageDesc !== undefined) {
form.setValue('imageDesc', savedData.imageDesc);
}
}, [savedData?.imageUrl, savedData?.avatarUrl, savedData?.imageStyleCode, savedData?.imageDesc])
// 监听表单变化,自动暂存数据
useEffect(() => {
const subscription = form.watch((data) => {
@ -101,8 +127,8 @@ export default function ImageForm() {
avatarUrl: data.avatarUrl || "",
intro: data.intro || "",
isPublic: data.isPublic ?? true,
imageStyleCode: savedData?.imageStyleCode,
imageDesc: savedData?.imageDesc,
imageStyleCode: data.imageStyleCode,
imageDesc: data.imageDesc,
})
}
})
@ -354,7 +380,7 @@ export default function ImageForm() {
<FormControl>
<Textarea
placeholder="Introduce the virtual character to the user"
maxLength={300}
maxLength={400}
showCount
error={!!form.formState.errors.intro}
{...field}

View File

@ -127,6 +127,7 @@ export default function CharacterForm() {
const isExist = await checkNickname({
nickname: nickname.trim(),
exUserId: Number(aiId),
isAiCheck: true,
})
if (isExist) {
form.setError("nickname", {
@ -135,17 +136,17 @@ export default function CharacterForm() {
return;
}
}
if (data.userProfile !== ai?.aiUserExt?.userProfile) {
const resp = await checkText({
content: data.userProfile,
})
if (resp) {
form.setError("userProfile", {
message: resp,
})
return;
}
}
// if (data.userProfile !== ai?.aiUserExt?.userProfile) {
// const resp = await checkText({
// content: data.userProfile,
// })
// if (resp) {
// form.setError("userProfile", {
// message: resp,
// })
// return;
// }
// }
router.push(`/edit/${aiId}/dialogue`)
} catch (error) {
console.error(error)

View File

@ -23,7 +23,7 @@ import { useCheckText } from "@/hooks/auth"
const dialogueFormSchema = z.object({
userDialogueStyle: z.string().trim().optional(),
prologue: z.string().trim().min(1, "Please enter opening").min(10, "Please enter at least 10 characters").max(150, "Please enter less than 150 characters"),
prologue: z.string().trim().min(1, "Please enter opening").min(10, "Please enter at least 10 characters").max(250, "Please enter less than 250 characters"),
voice: z.object({
content: z.string().optional(),
tone: z.number().min(-50).max(100),
@ -36,13 +36,13 @@ const dialogueFormSchema = z.object({
path: ["voice"],
}).refine((data) => {
if (data.userDialogueStyle) {
if (data.userDialogueStyle.trim().length > 300) {
if (data.userDialogueStyle.trim().length > 400) {
return false;
}
}
return true;
}, {
message: "Please enter less than 300 characters",
message: "Please enter less than 400 characters",
path: ["userDialogueStyle"],
}).refine((data) => {
if (data.userDialogueStyle) {
@ -189,30 +189,30 @@ export default function DialogueForm() {
// })
// }
// }
if (data.prologue !== ai?.aiUserExt?.dialoguePrologue && data.prologue.trim() !== "") {
const resp = await checkText({
content: data.prologue,
})
if (resp) {
form.setError("prologue", {
message: resp,
})
setLoading(false)
return;
}
}
if (data.userDialogueStyle !== ai?.aiUserExt?.dialogueStyle && data.userDialogueStyle?.trim() !== "") {
const resp = await checkText({
content: data.userDialogueStyle || "",
})
if (resp) {
form.setError("userDialogueStyle", {
message: resp,
})
setLoading(false)
return;
}
}
// if (data.prologue !== ai?.aiUserExt?.dialoguePrologue && data.prologue.trim() !== "") {
// const resp = await checkText({
// content: data.prologue,
// })
// if (resp) {
// form.setError("prologue", {
// message: resp,
// })
// setLoading(false)
// return;
// }
// }
// if (data.userDialogueStyle !== ai?.aiUserExt?.dialogueStyle && data.userDialogueStyle?.trim() !== "") {
// const resp = await checkText({
// content: data.userDialogueStyle || "",
// })
// if (resp) {
// form.setError("userDialogueStyle", {
// message: resp,
// })
// setLoading(false)
// return;
// }
// }
updateDialogueData({
aiUserExt: {
userDialogueStyle: data.userDialogueStyle,
@ -322,14 +322,14 @@ export default function DialogueForm() {
figure: formData.character?.aiUserExt?.userProfile ?? undefined,
} as GenerateContentRequest;
const resp = await generateContent(result)
form.setValue("prologue", (resp.content || "").slice(0, 150))
form.setValue("prologue", resp.content)
}}
/>
</div>
<FormControl>
<Textarea
placeholder="Set the character's greeting"
maxLength={150}
maxLength={250}
showCount
rows={4}
error={!!form.formState.errors.prologue}
@ -361,7 +361,7 @@ export default function DialogueForm() {
figure: formData.character?.aiUserExt?.userProfile ?? undefined,
} as GenerateContentRequest;
const resp = await generateContent(result)
form.setValue("userDialogueStyle", (resp.content || "").slice(0, 300))
form.setValue("userDialogueStyle", resp.content)
updateHasStyleByAI(true)
}}
/>
@ -369,7 +369,7 @@ export default function DialogueForm() {
<FormControl>
<Textarea
placeholder="Describe the characters conversation style"
maxLength={300}
maxLength={400}
showCount
rows={4}
error={!!form.formState.errors.userDialogueStyle}

View File

@ -27,8 +27,10 @@ import { useCheckText } from "@/hooks/auth"
const imageFormSchema = z.object({
imageUrl: z.string(),
avatarUrl: z.string(),
intro: z.string().min(10, "Please enter at least 10 characters").max(300, "Please enter less than 300 characters"),
intro: z.string().min(10, "Please enter at least 10 characters").max(400, "Please enter less than 400 characters"),
isPublic: z.boolean(),
imageStyleCode: z.number().optional(),
imageDesc: z.string().optional(),
}).refine((data) => {
return !!data.imageUrl && !!data.avatarUrl
}, {
@ -41,6 +43,8 @@ type ImageFormValues = {
avatarUrl: string
intro: string
isPublic: boolean
imageStyleCode?: number
imageDesc?: string
}
type ImageFormSchemaType = z.infer<typeof imageFormSchema>
@ -69,6 +73,8 @@ export default function ImageForm() {
avatarUrl: savedData?.avatarUrl || undefined,
intro: savedData?.intro || "",
isPublic: savedData?.isPublic ?? true,
imageStyleCode: Number(savedData?.imageStyleCode) || undefined,
imageDesc: savedData?.imageDesc || undefined,
}
})
const watchImageUrl = form.watch('imageUrl')
@ -76,19 +82,24 @@ export default function ImageForm() {
useEffect(() => {
if (ai && isFormDataEmpty({ checkEnum: "image" })) {
const imageStyleCode = ai?.aiUserExt?.imageStyleCode ? Number(ai.aiUserExt.imageStyleCode) : undefined;
const imageDesc = ai?.aiUserExt?.imageDesc || undefined;
form.reset({
imageUrl: ai.imageUrl || "",
avatarUrl: ai.headImg || "",
intro: ai.introduction || "",
isPublic: ai.permission === AIPermission.Public ? true : false,
imageStyleCode,
imageDesc,
})
updateImageData({
imageUrl: ai.imageUrl || "",
avatarUrl: ai.headImg || "",
intro: ai.introduction || "",
isPublic: ai.permission === AIPermission.Public ? true : false,
imageStyleCode: Number(ai?.aiUserExt?.imageStyleCode) || undefined,
imageDesc: ai?.aiUserExt?.imageDesc ?? undefined,
imageStyleCode,
imageDesc,
})
}
}, [ai, isFormDataEmpty])
@ -99,6 +110,26 @@ export default function ImageForm() {
}
}, [isFormDataEmpty])
// 当从图片编辑页面返回时,更新表单的 imageUrl, avatarUrl, imageStyleCode, imageDesc
useEffect(() => {
const currentFormImageUrl = form.getValues('imageUrl');
const currentFormAvatarUrl = form.getValues('avatarUrl');
// 如果 localStorage 中的图片 URL 与表单中的不一致,说明用户刚从图片编辑页面返回
if (savedData?.imageUrl && savedData.imageUrl !== currentFormImageUrl) {
form.setValue('imageUrl', savedData.imageUrl);
}
if (savedData?.avatarUrl && savedData.avatarUrl !== currentFormAvatarUrl) {
form.setValue('avatarUrl', savedData.avatarUrl);
}
if (savedData?.imageStyleCode !== undefined) {
form.setValue('imageStyleCode', savedData.imageStyleCode);
}
if (savedData?.imageDesc !== undefined) {
form.setValue('imageDesc', savedData.imageDesc);
}
}, [savedData?.imageUrl, savedData?.avatarUrl, savedData?.imageStyleCode, savedData?.imageDesc])
// 监听表单变化,自动暂存数据
useEffect(() => {
const subscription = form.watch((data) => {
@ -108,7 +139,8 @@ export default function ImageForm() {
avatarUrl: data.avatarUrl || "",
intro: data.intro || "",
isPublic: data.isPublic ?? true,
imageStyleCode: savedData?.imageStyleCode,
imageStyleCode: data.imageStyleCode,
imageDesc: data.imageDesc,
})
}
})
@ -153,10 +185,15 @@ export default function ImageForm() {
}
}
const extractJsonResp = await generateContent({
// 当 profile 不同需要重新生成
let extractJsonResp = null;
if (!isSameUserProfile) {
const resp = await generateContent({
ptType: PtType.ExtractJsonContent,
figure: character?.aiUserExt?.userProfile || '',
figure: character?.aiUserExt?.userProfile,
})
extractJsonResp = resp?.content;
}
const result = {
aiId: Number(aiId),
@ -181,7 +218,7 @@ export default function ImageForm() {
dialogueTimbreUrl: url,
imageStyleCode,
imageDesc,
userProfileExtJson: extractJsonResp?.content || '',
...(extractJsonResp && { userProfileExtJson: extractJsonResp }),
}
}
await createAI(result as unknown as CreateOrEditAiRequest)
@ -315,7 +352,7 @@ export default function ImageForm() {
<FormControl>
<Textarea
placeholder="Introduce the virtual character to the user"
maxLength={300}
maxLength={400}
showCount
error={!!form.formState.errors.intro}
{...field}

View File

@ -16,7 +16,6 @@ import { useImageViewer } from "@/hooks/useImageViewer"
import { ImageViewer } from "@/components/ui/image-viewer"
import MultipleViewerAction from "../components/MultipleViewerAction"
import { useAddAlbumImage } from "@/hooks/aiUser"
import { toast } from "sonner"
import dayjs from "dayjs"
import { GenerateContentRequest, PtType } from "@/services/create"
import { useImageGenerationGuard } from "@/hooks/useImageGenerationGuard"
@ -250,7 +249,7 @@ const ImagePage = () => {
{/* 底部固定按钮区域 */}
<div className="mt-6 space-y-2 px-6 pb-6">
<GeneralImageWithCountButton isGenerating={loading} disabled={!form.formState.isValid} />
<GeneralImageWithCountButton isGenerating={loading} disabled={!form.formState.isValid || loading || (!!batchNo && !allImagesCompleted)} />
</div>
</div>
</form>

View File

@ -1,8 +1,9 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState, useEffect, useRef, useMemo, useCallback } from "react";
import { useGetHomeAiCarouselList } from "@/hooks/useHome";
import AIStandardCard from "@/components/features/ai-standard-card";
import { usePrefetchRoutes } from "@/hooks/useGlobalPrefetchRoutes";
const HeaderSlide = () => {
const [activeIndex, setActiveIndex] = useState(0);
@ -11,12 +12,17 @@ const HeaderSlide = () => {
const transitionLockRef = useRef(false);
const { data, isLoading } = useGetHomeAiCarouselList();
const characters = (data || []).map(item => ({
const characters = useMemo(() => (data || []).map(item => ({
...item,
likedNum: item.likedCount || 0,
}));
})), [data]);
const chatRoutes = useMemo(
() => characters.slice(0, 5).map((character) => character?.aiId ? `/chat/${character.aiId}` : null),
[characters]
)
usePrefetchRoutes(chatRoutes)
const handleNext = () => {
const handleNext = useCallback(() => {
if (transitionLockRef.current || characters.length === 0) return;
transitionLockRef.current = true;
setIsTransitioning(true);
@ -25,9 +31,9 @@ const HeaderSlide = () => {
setIsTransitioning(false);
transitionLockRef.current = false;
}, 300);
};
}, [characters.length]);
const handlePrev = () => {
const handlePrev = useCallback(() => {
if (transitionLockRef.current || characters.length === 0) return;
transitionLockRef.current = true;
setIsTransitioning(true);
@ -36,7 +42,7 @@ const HeaderSlide = () => {
setIsTransitioning(false);
transitionLockRef.current = false;
}, 300);
};
}, [characters.length]);
// 自动轮播
useEffect(() => {
@ -47,7 +53,7 @@ const HeaderSlide = () => {
}, 4000);
return () => clearInterval(interval);
}, [isPaused, characters.length]);
}, [handleNext, isPaused, characters.length]);
const handleSlideClick = (index: number) => {
if (isTransitioning || characters.length === 0) return;
@ -135,8 +141,6 @@ const HeaderSlide = () => {
})();
const isCenter = position === 0;
const isVisible = Math.abs(position) <= 2;
return (
<div
key={character.aiId || index}

View File

@ -16,13 +16,11 @@ const HeaderSlideItem: React.FC<AIStandardCardProps> = ({ character }) => {
aiId,
nickname,
birthday,
characterName,
tagName,
headImg,
homeImageUrl,
introduction,
likedNum,
sex
} = character;
const introContainerRef = useRef<HTMLDivElement>(null);
@ -69,7 +67,7 @@ const HeaderSlideItem: React.FC<AIStandardCardProps> = ({ character }) => {
const displayName = `${nickname}${age ? `, ${age}` : ''}`;
return (
<Link href={`/chat/${aiId}`}>
<Link href={`/chat/${aiId}`} prefetch>
<div
className="basis-0 content-stretch flex flex-col gap-3 grow items-start justify-start relative shrink-0 cursor-pointer group"
>

View File

@ -49,7 +49,7 @@ const AGE_OPTIONS = [
const TYPE_OPTIONS = [
{
code: 'R00001',
code: 'R00002',
name: 'Original',
image: '/images/home/icon-original.png',
background: "linear-gradient(263deg, #62E1F2 0%, #6296F2 100%), #211A2B"

View File

@ -50,7 +50,10 @@ const MeetHeader = ({
const filterCount = (filters.gender?.length || 0) + (filters.age?.length || 0) + (filters.type?.length || 0);
// 使用 sticky hook 检测粘性状态
const [stickyRef, isSticky] = useSticky<HTMLDivElement>({ offset: 64 }); // offset 为 top-16 (64px)
const [stickyRef, isSticky] = useSticky<HTMLDivElement>({
offset: 64, // offset 为 top-16 (64px)
hysteresis: 15 // 增加滞后容差,避免边界抖动和快速滚动时的状态闪烁
});
// 计算左侧偏移量:侧边栏宽度
const leftOffset = isSidebarExpanded ? 320 : 80; // w-80 = 320px, w-20 = 80px
@ -192,8 +195,13 @@ const MeetHeader = ({
{/* Fixed 定位的筛选栏 - sticky 时显示 */}
{isSticky && (
<div
className="fixed top-16 right-0 py-6 z-50 border-b border-outline-normal backdrop-blur-[10px]"
style={{ left: `${leftOffset}px`, right: 0 }}
className="fixed top-16 right-0 py-6 z-50 border-b border-outline-normal backdrop-blur-[10px] transition-opacity duration-200 ease-in-out"
style={{
left: `${leftOffset}px`,
right: 0,
opacity: isSticky ? 1 : 0,
willChange: 'opacity'
}}
>
{/* 半透明背景 */}
<div className="absolute inset-0 bg-background-default opacity-85" />

View File

@ -15,6 +15,8 @@ interface MeetListProps {
fetchNextPage: () => void;
onCharacterClick?: (character: GetMeetListResponse) => void;
className?: string;
hasError?: boolean;
onRetry?: () => void;
}
// 骨架屏组件
@ -83,7 +85,9 @@ const MeetList: React.FC<MeetListProps> = ({
isLoading,
fetchNextPage,
onCharacterClick,
className
className,
hasError = false,
onRetry,
}) => {
return (
<div className={cn("w-full", className)}>
@ -109,6 +113,8 @@ const MeetList: React.FC<MeetListProps> = ({
LoadingSkeleton={MeetCardSkeleton}
EmptyComponent={EmptyState}
ErrorComponent={ErrorState}
hasError={hasError}
onRetry={onRetry}
threshold={200}
enabled={true}
/>

View File

@ -10,6 +10,7 @@ import { useHomeData } from '../../context/HomeDataContext';
import { GetMeetListRequest, GetMeetListResponse } from '@/services/home/types';
import { useRouter } from 'next/navigation';
import { FilterOptions } from './FilterDrawer';
import { usePrefetchRoutes } from '@/hooks/useGlobalPrefetchRoutes';
const MoreType = () => {
const router = useRouter();
@ -33,7 +34,7 @@ const MoreType = () => {
filters.age.length > 0 ||
filters.type.length > 0;
const result: any = {
const result: Record<string, unknown> = {
characterCodeList: selectedRole && selectedRole !== 'ALL' ? [selectedRole] : [],
tagCodeList: selectedTags,
roleCodeList: filters.type, // 使用类型筛选作为 tagCodeList
@ -46,7 +47,7 @@ const MoreType = () => {
// 过滤掉空字符串和空数组字段
const filteredResult = Object.fromEntries(
Object.entries(result).filter(([key, value]) => {
Object.entries(result).filter(([, value]) => {
if (Array.isArray(value)) {
return value.length > 0;
}
@ -97,6 +98,14 @@ const MoreType = () => {
if (!meetData?.pages) return [];
return meetData.pages.flat();
}, [meetData]);
const meetChatRoutes = React.useMemo(
() =>
allMeetItems.map((character) =>
character?.aiId ? `/chat/${character.aiId}` : null
),
[allMeetItems]
);
usePrefetchRoutes(meetChatRoutes, { limit: 16 });
// 当角色数据加载后默认选中ALL
React.useEffect(() => {
@ -162,6 +171,8 @@ const MoreType = () => {
isLoading={isLoading || isFetchingNextPage}
fetchNextPage={fetchNextPage}
onCharacterClick={handleCharacterClick}
hasError={!!error}
onRetry={refetch}
/>
</div>
</div>

View File

@ -2,7 +2,7 @@
import Image from "next/image";
import type { AiChatRankOutput } from "@/services/home/types";
import { calculateAgeByBirthday, formatNumberToKMB } from "@/lib/utils";
import { formatNumberToKMB } from "@/lib/utils";
import Link from "next/link";
interface MostChatItemProps {
@ -12,10 +12,8 @@ interface MostChatItemProps {
const MostChatItem = ({ character, onClick }: MostChatItemProps) => {
const age = calculateAgeByBirthday(character.birthday);
return (
<Link href={`/chat/${character.aiId}`} className="flex-[0_0_100%] sm:flex-[0_0_calc(0_0_100%)] md:flex-[0_0_calc(50%-16px)] lg:flex-[0_0_calc(50%-16px)] xl:flex-[0_0_calc(33.333%-16px)] min-w-0 h-full">
<Link href={`/chat/${character.aiId}`} prefetch className="flex-[0_0_100%] sm:flex-[0_0_calc(0_0_100%)] md:flex-[0_0_calc(50%-16px)] lg:flex-[0_0_calc(50%-16px)] xl:flex-[0_0_calc(33.333%-16px)] min-w-0 h-full">
<div
onClick={onClick}
className="relative rounded-lg overflow-hidden bg-surface-base-normal cursor-pointer transition-transform duration-300 border border-solid border-outline-normal"
@ -56,4 +54,3 @@ const MostChatItem = ({ character, onClick }: MostChatItemProps) => {
};
export default MostChatItem;

View File

@ -1,20 +1,27 @@
"use client";
import useEmblaCarousel from "embla-carousel-react"
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { RankType } from "@/types/global"
import { useHomeData } from "../../context/HomeDataContext"
import MostChatItem from "./MostChatItem"
import MostChatSkeleton from "./MostChatSkeleton"
import { IconButton } from "@/components/ui/button";
import { usePrefetchRoutes } from "@/hooks/useGlobalPrefetchRoutes";
const MostChat = () => {
// 从 Context 获取数据
const { data: homeData, isLoading } = useHomeData()
// 只取前20条数据
const displayData = homeData?.mostChat?.slice(0, 20) || []
const displayData = useMemo(
() => homeData?.mostChat?.slice(0, 20) || [],
[homeData?.mostChat]
)
const chatRoutes = useMemo(
() => displayData.slice(0, 12).map((character) => character?.aiId ? `/chat/${character.aiId}` : null),
[displayData]
)
usePrefetchRoutes(chatRoutes)
// 根据屏幕宽度计算每次滑动的卡片数量
const getSlidesToScroll = () => {
@ -90,7 +97,7 @@ const MostChat = () => {
<span className="text-2xl">👑</span>
<h2 className="txt-headline-s">Most Chatted</h2>
</div>
<Link href={`/leaderboard?type=${RankType.CHAT}`} className="txt-label-m text-primary-variant-normal">
<Link href={`/leaderboard?type=${RankType.CHAT}`} className="txt-label-m text-primary-variant-normal" prefetch>
More
</Link>
</div>

View File

@ -2,7 +2,6 @@
import Image from "next/image";
import type { AiHeartbeatRankOutput } from "@/services/home/types";
import { calculateAgeByBirthday } from "@/lib/utils";
import Link from "next/link";
interface MostCrushItemProps {
@ -12,10 +11,8 @@ interface MostCrushItemProps {
const MostCrushItem = ({ character, onClick }: MostCrushItemProps) => {
const age = calculateAgeByBirthday(character.birthday);
return (
<Link href={`/chat/${character.aiId}`} className="flex-[0_0_100%] sm:flex-[0_0_calc(50%-12px)] lg:flex-[0_0_calc(50%-16px)] xl:flex-[0_0_calc(33.333%-16px)] min-w-0 h-full">
<Link href={`/chat/${character.aiId}`} prefetch className="flex-[0_0_100%] sm:flex-[0_0_calc(50%-12px)] lg:flex-[0_0_calc(50%-16px)] xl:flex-[0_0_calc(33.333%-16px)] min-w-0 h-full">
<div
onClick={onClick}
className="relative rounded-lg overflow-hidden bg-surface-base-normal cursor-pointer transition-transform duration-300 border border-solid border-outline-normal"
@ -56,4 +53,3 @@ const MostCrushItem = ({ character, onClick }: MostCrushItemProps) => {
};
export default MostCrushItem;

View File

@ -1,20 +1,27 @@
"use client";
import useEmblaCarousel from "embla-carousel-react"
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { RankType } from "@/types/global"
import { useHomeData } from "../../context/HomeDataContext"
import MostCrushItem from "./MostCrushItem"
import MostCrushSkeleton from "./MostCrushSkeleton"
import { IconButton } from "@/components/ui/button";
import { usePrefetchRoutes } from "@/hooks/useGlobalPrefetchRoutes";
const MostCrush = () => {
// 从 Context 获取数据
const { data: homeData, isLoading } = useHomeData()
// 只取前20条数据
const displayData = homeData?.mustCrush?.slice(0, 20) || []
const displayData = useMemo(
() => homeData?.mustCrush?.slice(0, 20) || [],
[homeData?.mustCrush]
)
const chatRoutes = useMemo(
() => displayData.slice(0, 12).map((character) => character?.aiId ? `/chat/${character.aiId}` : null),
[displayData]
)
usePrefetchRoutes(chatRoutes)
// 根据屏幕宽度计算每次滑动的卡片数量
const getSlidesToScroll = () => {
@ -90,7 +97,7 @@ const MostCrush = () => {
<span className="text-2xl">💖</span>
<h2 className="txt-headline-s">Most Crushes</h2>
</div>
<Link href={`/leaderboard?type=${RankType.CRUSH}`} className="txt-label-m text-primary-variant-normal">
<Link href={`/leaderboard?type=${RankType.CRUSH}`} className="txt-label-m text-primary-variant-normal" prefetch>
More
</Link>
</div>
@ -139,4 +146,3 @@ const MostCrush = () => {
}
export default MostCrush

View File

@ -2,7 +2,7 @@
import Image from "next/image";
import type { AiGiftRankOutput } from "@/services/home/types";
import { calculateAgeByBirthday, formatNumberToKMB } from "@/lib/utils";
import { formatNumberToKMB } from "@/lib/utils";
import Link from "next/link";
interface MostGiftedItemProps {
@ -12,10 +12,8 @@ interface MostGiftedItemProps {
const MostGiftedItem = ({ character, onClick }: MostGiftedItemProps) => {
const age = calculateAgeByBirthday(character.birthday);
return (
<Link href={`/chat/${character.aiId}`} className="flex-[0_0_100%] sm:flex-[0_0_calc(50%-12px)] lg:flex-[0_0_calc(50%-16px)] xl:flex-[0_0_calc(33.333%-16px)] min-w-0 h-full">
<Link href={`/chat/${character.aiId}`} prefetch className="flex-[0_0_100%] sm:flex-[0_0_calc(50%-12px)] lg:flex-[0_0_calc(50%-16px)] xl:flex-[0_0_calc(33.333%-16px)] min-w-0 h-full">
<div
onClick={onClick}
className="relative rounded-lg overflow-hidden bg-surface-base-normal cursor-pointer transition-transform duration-300 border border-solid border-outline-normal"
@ -56,4 +54,3 @@ const MostGiftedItem = ({ character, onClick }: MostGiftedItemProps) => {
};
export default MostGiftedItem;

View File

@ -1,20 +1,27 @@
"use client";
import useEmblaCarousel from "embla-carousel-react"
import { useCallback, useEffect, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import Link from "next/link"
import { RankType } from "@/types/global"
import { useHomeData } from "../../context/HomeDataContext"
import MostGiftedItem from "./MostGiftedItem"
import MostGiftedSkeleton from "./MostGiftedSkeleton"
import { IconButton } from "@/components/ui/button";
import { usePrefetchRoutes } from "@/hooks/useGlobalPrefetchRoutes";
const MostGifted = () => {
// 从 Context 获取数据
const { data: homeData, isLoading } = useHomeData()
// 只取前20条数据
const displayData = homeData?.mustGifted?.slice(0, 20) || []
const displayData = useMemo(
() => homeData?.mustGifted?.slice(0, 20) || [],
[homeData?.mustGifted]
)
const chatRoutes = useMemo(
() => displayData.slice(0, 12).map((character) => character?.aiId ? `/chat/${character.aiId}` : null),
[displayData]
)
usePrefetchRoutes(chatRoutes)
// 根据屏幕宽度计算每次滑动的卡片数量
const getSlidesToScroll = () => {
@ -90,7 +97,7 @@ const MostGifted = () => {
<span className="text-2xl">🎁</span>
<h2 className="txt-headline-s">Most Gifted</h2>
</div>
<Link href={`/leaderboard?type=${RankType.GIFTS}`} className="txt-label-m text-primary-variant-normal">
<Link href={`/leaderboard?type=${RankType.GIFTS}`} className="txt-label-m text-primary-variant-normal" prefetch>
More
</Link>
</div>
@ -139,4 +146,3 @@ const MostGifted = () => {
}
export default MostGifted

View File

@ -42,7 +42,7 @@ const StartChatItem = ({ character }: StartChatItemProps) => {
<div className="px-4 pb-4 pt-16 flex flex-col items-center h-full border border-solid border-outline-normal rounded-lg relative">
{/* 头像 - 带播放按钮 */}
<div className="absolute top-0 -translate-y-1/2 z-10">
<Link href={`/chat/${character.aiId}`}>
<Link href={`/chat/${character.aiId}`} prefetch>
<Avatar className="w-20 h-20">
<AvatarImage
src={character.headImg || ""}
@ -71,7 +71,7 @@ const StartChatItem = ({ character }: StartChatItemProps) => {
</div>
{/* 名字 */}
<Link className="block w-full" href={`/chat/${character.aiId}`}>
<Link className="block w-full" href={`/chat/${character.aiId}`} prefetch>
<h3 className="txt-title-m text-center truncate w-full mb-2">
{character.nickname}
</h3>
@ -90,6 +90,7 @@ const StartChatItem = ({ character }: StartChatItemProps) => {
<Link
href={`/chat/${character.aiId}?text=${encodeURIComponent(suggestion)}`}
key={index}
prefetch
className="flex items-center justify-between gap-1 p-2 rounded-sm bg-surface-element-normal hover:bg-surface-element-hovered transition-colors group"
>
<span className="txt-body-m text-txt-secondary-normal truncate flex-1">
@ -110,4 +111,3 @@ const StartChatItem = ({ character }: StartChatItemProps) => {
};
export default StartChatItem;

View File

@ -1,19 +1,29 @@
"use client";
import useEmblaCarousel from "embla-carousel-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useHomeData } from "../../context/HomeDataContext";
import { AudioPlayerProvider } from "../../context/AudioPlayerContext";
import StartChatItem from "./StartChatItem";
import StartChatSkeleton from "./StartChatSkeleton";
import { IconButton } from "@/components/ui/button";
import { usePrefetchRoutes } from "@/hooks/useGlobalPrefetchRoutes";
const StartChat = () => {
// 从 Context 获取数据
const { data: homeData, isLoading } = useHomeData();
// 只取前20条数据
const displayData = homeData?.starAChat?.slice(0, 20) || [];
const starAChat = homeData?.starAChat;
const displayData = useMemo(() => starAChat?.slice(0, 20) || [], [starAChat]);
const chatRoutes = useMemo(
() =>
displayData
.slice(0, 8)
.map((character) => (character?.aiId ? `/chat/${character.aiId}` : null)),
[displayData]
)
usePrefetchRoutes(chatRoutes)
// 根据屏幕宽度计算每次滑动的卡片数量
const getSlidesToScroll = () => {
@ -135,4 +145,3 @@ const StartChat = () => {
};
export default StartChat;

View File

@ -50,7 +50,7 @@ export default function LargeRankCard({
}
return (
<Link href={`/chat/${item.aiId}`} key={item.aiId} className="w-[38.5%] aspect-[240/360] rounded-b-lg relative overflow-hidden">
<Link href={`/chat/${item.aiId}`} prefetch key={item.aiId} className="w-[38.5%] aspect-[240/360] rounded-b-lg relative overflow-hidden">
<div
className="absolute inset-0"
style={{

View File

@ -1,10 +1,12 @@
"use client";
import Image from "next/image";
import { useMemo } from "react";
import { AiChatRankOutput, AiHeartbeatRankOutput, AiGiftRankOutput } from "@/services/home/types";
import { RankType } from "@/types/global";
import { formatNumberToKMB } from "@/lib/utils";
import Link from "next/link";
import { usePrefetchRoutes } from "@/hooks/useGlobalPrefetchRoutes";
interface RankingListProps {
rankData: (AiChatRankOutput | AiHeartbeatRankOutput | AiGiftRankOutput)[];
@ -17,10 +19,16 @@ const RankingList: React.FC<RankingListProps> = ({
rankType,
startFromRank = 4
}) => {
// 过滤出从指定排名开始的数据
const filteredData = rankData.filter((item) =>
item.rankNo && item.rankNo >= startFromRank
const filteredData = useMemo(
() =>
rankData.filter((item) => item.rankNo && item.rankNo >= startFromRank),
[rankData, startFromRank]
);
const chatRoutes = useMemo(
() => filteredData.map((item) => (item?.aiId ? `/chat/${item.aiId}` : null)),
[filteredData]
);
usePrefetchRoutes(chatRoutes, { limit: 20 });
// 根据排行榜类型获取格式化后的显示值
const getDisplayValue = (item: AiChatRankOutput | AiHeartbeatRankOutput | AiGiftRankOutput) => {
@ -50,6 +58,13 @@ const RankingList: React.FC<RankingListProps> = ({
}
};
const getLikedCount = (item: AiChatRankOutput | AiHeartbeatRankOutput | AiGiftRankOutput) => {
if ("likedNum" in item && typeof item.likedNum === "number") {
return item.likedNum;
}
return 0;
};
if (filteredData.length === 0) {
return null;
}
@ -60,7 +75,7 @@ const RankingList: React.FC<RankingListProps> = ({
const displayValue = getDisplayValue(item);
return (
<Link href={`/chat/${item.aiId}`} key={item.aiId}>
<Link href={`/chat/${item.aiId}`} key={item.aiId} prefetch>
<div
key={item.aiId}
className="box-border flex gap-4 items-center justify-center p-2 w-full"
@ -95,7 +110,7 @@ const RankingList: React.FC<RankingListProps> = ({
<div className="flex gap-1 items-center text-txt-secondary-normal">
<i className="iconfont icon-Like-fill !text-[12px]" />
<div className="txt-numMonotype-s">
{formatNumberToKMB((item as any).likedNum || 0)}
{formatNumberToKMB(getLikedCount(item) || 0)}
</div>
</div>
</div>

View File

@ -53,7 +53,7 @@ export default function SmallRankCard({
const rankNo = rank || item.rankNo || 1;
return (
<Link href={`/chat/${item.aiId}`} key={item.aiId} className="flex-1">
<Link href={`/chat/${item.aiId}`} prefetch key={item.aiId} className="flex-1">
<div className="flex-1 py-12">
<div className="w-full aspect-[240/360] rounded-b-lg relative overflow-hidden">
<div

View File

@ -1,9 +1,11 @@
"use client";
import { useMemo } from "react";
import LargeRankCard from "./LargeRankCard";
import SmallRankCard from "./SmallRankCard";
import { AiChatRankOutput, AiHeartbeatRankOutput, AiGiftRankOutput } from "@/services/home/types";
import { RankType } from "@/types/global";
import { usePrefetchRoutes } from "@/hooks/useGlobalPrefetchRoutes";
interface TopHeaderProps {
rankData: AiChatRankOutput[] | AiHeartbeatRankOutput[] | AiGiftRankOutput[];
@ -12,6 +14,13 @@ interface TopHeaderProps {
}
export default function TopHeader({ rankData, rankType, isLoading }: TopHeaderProps) {
const topThree = useMemo(() => (rankData || []).slice(0, 3), [rankData]);
const chatRoutes = useMemo(
() => topThree.map((item) => (item?.aiId ? `/chat/${item.aiId}` : null)),
[topThree]
);
usePrefetchRoutes(chatRoutes, { limit: 3 });
if (isLoading) {
return (
<div className="max-w-[624px] mx-auto mt-6">
@ -38,8 +47,6 @@ export default function TopHeader({ rankData, rankType, isLoading }: TopHeaderPr
);
}
// 获取前3名数据
const topThree = rankData.slice(0, 3);
const firstPlace = topThree[0];
const secondPlace = topThree[1];
const thirdPlace = topThree[2];

View File

@ -35,6 +35,8 @@ const AlbumList = () => {
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isError,
refetch,
} = useGetAIUserAlbumInfinite(Number(userId), pageSize);
const likeMutation = useLikeAlbumImage();
const unlockMutation = useUnlockImage();
@ -120,6 +122,8 @@ const AlbumList = () => {
<Empty title="No photos yet" />
</div>
)}
hasError={isError}
onRetry={refetch}
threshold={300}
enabled={true}
/>

View File

@ -4,16 +4,26 @@ import { useGetAIUserBaseInfo } from "@/hooks/aiUser";
import { useDoAiUserLiked } from "@/hooks/useCommon";
import { aiUserKeys, imKeys } from "@/lib/query-keys";
import { useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { useToken } from "@/hooks/auth";
const UserLikeButton = () => {
const { userId } = useParams();
const router = useRouter();
const { isLogin } = useToken();
const { data } = useGetAIUserBaseInfo({ aiId: Number(userId) });
const { mutateAsync: doAiUserLiked } = useDoAiUserLiked();
const { liked } = data || {};
const queryClient = useQueryClient();
const handleLike = () => {
// 检查是否登录,如果未登录则跳转到登录页面
if (!isLogin) {
const currentPath = `/@${userId}`;
router.push(`/login?redirect=${encodeURIComponent(currentPath)}`);
return;
}
doAiUserLiked({ aiId: Number(userId), likedStatus: liked ? 'CANCELED' : 'LIKED' });
queryClient.setQueryData(aiUserKeys.baseInfo({ aiId: Number(userId) }), (oldData: any) => {
return {

View File

@ -6,7 +6,7 @@ import "./globals.css";
import { Providers } from "@/lib/providers";
import { MockProvider } from "@/components/mock-provider";
import { DeviceIdProvider } from "@/components/device-id-provider";
import "@/lib/server-mock"; // 导入服务端 mock 初始化
// import "@/lib/server-mock"; // 导入服务端 mock 初始化
import ProgressBar from "@/context/progress";
import { MainLayoutProvider } from "@/context/mainLayout";
import ConditionalLayout from "@/components/layout/ConditionalLayout";
@ -54,7 +54,7 @@ export default async function RootLayout({
className={`${poppins.variable} ${oleoScriptSwashCaps.variable} ${NumDisplay.variable} antialiased`}
>
<DeviceIdProvider>
<MockProvider>
{/* <MockProvider> */}
<Providers>
<ProgressBar>
<MainLayoutProvider>
@ -64,7 +64,7 @@ export default async function RootLayout({
</MainLayoutProvider>
</ProgressBar>
</Providers>
</MockProvider>
{/* </MockProvider> */}
</DeviceIdProvider>
</body>
</html>

View File

@ -16,14 +16,12 @@ const AIStandardCard: React.FC<AIStandardCardProps> = ({ character, disableHover
const {
aiId,
nickname,
birthday,
characterName,
tagName,
headImg,
homeImageUrl,
introduction,
likedNum,
sex
} = character;
const introContainerRef = useRef<HTMLDivElement>(null);
@ -50,27 +48,16 @@ const AIStandardCard: React.FC<AIStandardCardProps> = ({ character, disableHover
return () => window.removeEventListener('resize', calculateMaxLines);
}, []);
// 计算年龄
const getAge = (birthday?: string) => {
if (!birthday) return '';
const birthDate = new Date(birthday);
const today = new Date();
const age = today.getFullYear() - birthDate.getFullYear();
return age;
};
// 解析标签(假设是逗号分隔的字符串)
const tags = tagName ? tagName.split(',').filter(tag => tag.trim()) : [];
// 获取显示的背景图片
const displayImage = homeImageUrl || headImg;
const age = getAge(birthday);
const displayName = `${nickname}`;
return (
<Link href={`/chat/${aiId}`} className="w-full h-full">
<Link href={`/chat/${aiId}`} className="w-full h-full" prefetch>
<div
className={`basis-0 content-stretch flex flex-col gap-3 grow items-start justify-start relative shrink-0 cursor-pointer ${disableHover ? '' : 'group'}`}
>
@ -100,8 +87,8 @@ const AIStandardCard: React.FC<AIStandardCardProps> = ({ character, disableHover
<div className="flex flex-wrap gap-1">
{/* 性格标签 */}
{character.characterName && (
<Tag size="small">{character.characterName}</Tag>
{characterName && (
<Tag size="small">{characterName}</Tag>
)}
{tags.length > 0 && (

View File

@ -120,7 +120,7 @@ const AlbumPriceSetting = ({
</AlertDialogTrigger>
<AlertDialogContent className="bg-surface-base-normal p-6 rounded-2xl w-[400px]">
<AlertDialogHeader>
<AlertDialogTitle className="txt-title-l text-txt-primary-normal text-center">
<AlertDialogTitle className="txt-title-l text-txt-primary-normal text-center pl-10">
How to Unlock
</AlertDialogTitle>
</AlertDialogHeader>

View File

@ -10,6 +10,7 @@ import ChargeDrawer from "../features/charge-drawer"
import SubscribeVipDrawer from "@/app/(main)/vip/components/SubscribeVipDrawer"
import { cn } from "@/lib/utils"
import CreateReachedLimitDialog from "../features/create-reached-limit-dialog"
import { useGlobalPrefetchRoutes } from "@/hooks/useGlobalPrefetchRoutes"
interface ConditionalLayoutProps {
children: React.ReactNode
@ -19,6 +20,7 @@ export default function ConditionalLayout({ children }: ConditionalLayoutProps)
const pathname = usePathname()
const mainContentRef = useRef<HTMLDivElement>(null)
const prevPathnameRef = useRef<string>(pathname)
useGlobalPrefetchRoutes()
// 路由切换时重置滚动位置
useEffect(() => {

View File

@ -1,5 +1,5 @@
"use client"
import React, { useState, useEffect } from "react"
import React, { useState, useEffect, useMemo } from "react"
import { MenuItem } from "@/types/global"
import Image from "next/image"
import { cn } from "@/lib/utils"
@ -34,7 +34,8 @@ function Sidebar() {
const isImageCreatePage = ['/generate/image', '/generate/image-2-image', '/generate/image-edit', '/generate/image-2-background'].includes(pathname)
const actualIsExpanded = isImageCreatePage ? false : isSidebarExpanded
const menuItems: IMenuItem[] = [
const menuItems: IMenuItem[] = useMemo(
() => [
{
id: MenuItem.FOR_YOU,
icon: "/icons/explore.svg",
@ -67,7 +68,23 @@ function Sidebar() {
link: '/contact',
isSelected: pathname.startsWith('/contact'),
},
]
],
[pathname]
)
useEffect(() => {
menuItems.forEach(item => {
// 跳过 /create 路由的 prefetch(使用自定义导航逻辑)
if (item.link.startsWith('/create')) {
return
}
// /contact 路由只在登录时 prefetch
if (item.link.startsWith('/contact') && !user) {
return
}
router.prefetch(item.link)
})
}, [router, menuItems, user])
const toggleExpanded = () => {
// 在 /create/image 页面时禁用展开功能
@ -139,7 +156,7 @@ function Sidebar() {
// 其他链接使用 Link 组件以便 SEO
return (
<Link href={item.link} key={item.id}>
<Link href={item.link} key={item.id} prefetch>
{menuItemContent}
</Link>
);

View File

@ -19,6 +19,7 @@ function Topbar () {
const searchParamsString = searchParams.toString()
const redirectURL = `${pathname}${searchParamsString ? `?${searchParamsString}` : ''}`
const loginHref = `/login?redirect=${encodeURIComponent(redirectURL)}`
const isImageCreatePage = ['/generate/image', '/generate/image-2-image', '/generate/image-edit', '/generate/image-2-background'].includes(pathname);
@ -44,6 +45,18 @@ function Topbar () {
}
}, [user])
useEffect(() => {
if (user) {
router.prefetch("/profile")
}
}, [router, user])
useEffect(() => {
if (!user) {
router.prefetch(loginHref)
}
}, [router, user, loginHref])
return (
<header className={cn("fixed top-0 left-20 right-0 z-40 flex h-16 items-center justify-between px-8 transition-all", {
"backdrop-blur-[10px]": isBlur,
@ -58,14 +71,14 @@ function Topbar () {
</Link>
</div>
{user ? (
<Link href="/profile">
<Link href="/profile" prefetch>
<Avatar className="cursor-pointer size-8">
<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={`/login?redirect=${encodeURIComponent(redirectURL)}`}>
<Link href={loginHref} prefetch>
<Button size="small">Login in or Sign up</Button>
</Link>
)}

View File

@ -7,16 +7,16 @@ import { imService } from "@/services/im";
import { imKeys } from "@/lib/query-keys";
import ChatSidebarItem from "./ChatSidebarItem";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { InfiniteScrollList } from "@/components/ui/infinite-scroll-list";
import { formatHeartbeatLevel, getAge } from "@/lib/utils";
import { getAge } from "@/lib/utils";
import { useRouter } from "next/navigation";
import { HeartbeatRelationListOutput } from "@/services/im/types";
import Empty from "@/components/ui/empty";
import AIRelationTag from "@/components/features/AIRelationTag";
import Image from "next/image";
import { IconButton } from "@/components/ui/button";
import { usePrefetchRoutes } from "@/hooks/useGlobalPrefetchRoutes";
interface ChatSearchResultsProps {
searchKeyword: string;
@ -48,19 +48,22 @@ const HighlightText = ({ text, keyword }: { text: string; keyword: string }) =>
const PersonItem = ({
person,
keyword,
isExpanded,
onCloseSearch
}: {
person: HeartbeatRelationListOutput;
keyword: string;
isExpanded: boolean;
onCloseSearch: () => void;
}) => {
const router = useRouter();
const chatHref = useMemo(
() => (person.aiId ? `/chat/${person.aiId}` : null),
[person.aiId]
)
usePrefetchRoutes(chatHref ? [chatHref] : undefined)
const handleClick = () => {
if (person.aiId) {
router.push(`/chat/${person.aiId}`);
if (chatHref) {
router.push(chatHref);
onCloseSearch();
}
};
@ -175,7 +178,7 @@ const ChatSearchResults = ({ searchKeyword, isExpanded, onCloseSearch }: ChatSea
const conversations = Array.from(conversationList.values());
return conversations.filter((conversation) => {
const { name, lastMessage } = conversation;
const { name } = conversation;
const keyword = searchKeyword.toLowerCase();
// 搜索用户名
@ -284,7 +287,6 @@ const ChatSearchResults = ({ searchKeyword, isExpanded, onCloseSearch }: ChatSea
<PersonItem
person={person}
keyword={searchKeyword}
isExpanded={isExpanded}
onCloseSearch={onCloseSearch}
/>
)}

View File

@ -1,4 +1,5 @@
"use client"
import { useMemo } from "react"
import AIRelationTag from "@/components/features/AIRelationTag"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
@ -8,6 +9,7 @@ import { CustomMessageType } from "@/types/im"
import Image from "next/image"
import { useRouter } from "next/navigation"
import { V2NIMConversation } from "nim-web-sdk-ng/dist/v2/NIM_BROWSER_SDK/V2NIMConversationService"
import { usePrefetchRoutes } from "@/hooks/useGlobalPrefetchRoutes"
// 高亮搜索关键词的组件
const HighlightText = ({ text, keyword }: { text: string; keyword?: string }) => {
@ -45,13 +47,26 @@ export default function ChatSidebarItem({
const { text, attachment } = lastMessage || {};
const router = useRouter();
const { nim } = useNimChat();
const chatHref = useMemo(() => {
if (!nim?.V2NIMConversationIdUtil || !conversation?.conversationId) {
return null
}
try {
const targetId = nim.V2NIMConversationIdUtil.parseConversationTargetId(conversation.conversationId);
const aiid = targetId.split('@')[0];
return `/chat/${aiid}`
} catch {
return null
}
}, [conversation?.conversationId, nim])
usePrefetchRoutes(chatHref ? [chatHref] : undefined)
const { heartbeatVal, heartbeatLevel, isShow } = JSON.parse(serverExtension || '{}') || {};
const handleChat = () => {
const targetId = nim.V2NIMConversationIdUtil.parseConversationTargetId(conversation.conversationId);
const aiid = targetId.split('@')[0];
router.push(`/chat/${aiid}`);
if (chatHref) {
router.push(chatHref);
}
}
const renderText = () => {

View File

@ -120,11 +120,11 @@ function AlertDialogTitle({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<div className="flex items-start justify-between px-10">
<div className="flex items-start justify-between pr-10">
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn(
"txt-title-l text-txt-primary-normal break-words w-full text-center",
"txt-title-l text-txt-primary-normal break-words w-full",
className
)}
{...props}

View File

@ -53,7 +53,7 @@ const iconButtonVariants = cva(
variant: {
default: "bg-primary-gradient-normal text-txt-primary-normal disabled:bg-primary-gradient-disabled disabled:bg-none disabled:text-txt-primary-disabled hover:bg-primary-gradient-hover active:bg-primary-gradient-press",
primary: "bg-primary-normal text-txt-primary-normal disabled:bg-primary-disabled disabled:text-txt-primary-disabled hover:bg-primary-hover active:bg-primary-press",
tertiary: "bg-surface-element-normal text-txt-primary-normal disabled:bg-surface-element-disabled disabled:text-txt-primary-disabled hover:bg-surface-element-hover active:bg-surface-element-press",
tertiary: "bg-surface-element-normal text-txt-primary-normal backdrop-blur-lg disabled:bg-surface-element-disabled disabled:text-txt-primary-disabled hover:bg-surface-element-hover active:bg-surface-element-press",
tertiaryDark: "bg-surface-element-dark-normal text-txt-primary-normal disabled:bg-surface-element-dark-disabled disabled:text-txt-primary-specialmap-disable hover:bg-surface-element-dark-hover active:bg-surface-element-dark-press",
contrast: "bg-surface-element-light-normal text-txt-primary-normal disabled:bg-surface-element-light-disabled disabled:text-txt-primary-disabled hover:bg-surface-element-light-hover active:bg-surface-element-light-press",
ghost: "bg-transparent text-txt-primary-normal disabled:text-txt-primary-disabled hover:bg-surface-element-hover hover:backdrop-blur-[16px] active:bg-surface-element-press",

View File

@ -108,6 +108,7 @@ export function InfiniteScrollList<T>({
fetchNextPage,
threshold,
enabled,
isError: hasError,
});
// 生成网格列数的CSS类名映射
@ -202,8 +203,8 @@ export function InfiniteScrollList<T>({
))}
</div>
{/* 加载更多触发器 */}
{hasNextPage && (
{/* 加载更多触发器 - 只在没有错误时显示 */}
{hasNextPage && !hasError && (
<div ref={loadMoreRef} className="mt-8 flex justify-center">
{LoadingMore ? (
<LoadingMore />

View File

@ -1,35 +1,53 @@
"use client";
import { createContext, useEffect, useState } from "react";
import V2NIM from "nim-web-sdk-ng";
import { useCurrentUser } from "@/hooks/auth";
import { V2NIMLoginStatus } from "nim-web-sdk-ng/dist/v2/NIM_BROWSER_SDK/V2NIMLoginService";
import { NimLoginProvider } from "./NimLoginContext";
import { useGetIMAccount, useIMAccount } from "@/hooks/useIm";
import { ConversationProvider } from "./ConversationContext";
import { NimMsgProvider } from "./NimMsgContext";
import { NimUserProvider } from "./NimUserContext";
const nim = V2NIM.getInstance({
// 动态导入类型
type V2NIM = any;
type V2NIMLoginStatus = any;
// 延迟加载的 NIM 实例
let nimInstance: V2NIM | null = null;
// 初始化 NIM 实例(仅在客户端)
const getNimInstance = () => {
if (typeof window === 'undefined') {
return null;
}
if (!nimInstance) {
// 动态导入 SDK
const V2NIM = require("nim-web-sdk-ng").default;
nimInstance = V2NIM.getInstance({
appkey: process.env.NEXT_PUBLIC_NIM_APP_KEY || "2d6abc076f434fc52320c7118de5fead",
debugLevel: 'error', // 设置日志 level
apiVersion: 'v2',
enableV2CloudConversation: true,
}, {
}, {
V2NIMLoginServiceConfig: {
lbsUrls: [
"https://lbs.netease.im/lbs/webconf"
],
linkUrl: "weblink01-sg.netease.im:443"
}
})
});
}
return nimInstance;
}
const NimChatContext = createContext<{
nim: V2NIM;
nim: V2NIM | null;
isNimLoggedIn: boolean;
nimLoginStatus: V2NIMLoginStatus | null;
}>({
nim: nim,
nim: null,
isNimLoggedIn: false,
nimLoginStatus: null,
});
@ -38,13 +56,20 @@ export const NimChatProvider = ({ children }: { children: React.ReactNode }) =>
const [isNimLoggedIn, setIsNimLoggedIn] = useState(false);
const [nimLoginStatus, setNimLoginStatus] = useState<V2NIMLoginStatus | null>(null);
const [hasInitialized, setHasInitialized] = useState(false);
const [nim, setNim] = useState<V2NIM | null>(null);
const { data: imAccount } = useGetIMAccount();
const { data: user } = useCurrentUser();
// 初始化 NIM 实例(仅在客户端)
useEffect(() => {
const instance = getNimInstance();
setNim(instance);
}, []);
// 初始化和检查登录状态
useEffect(() => {
if (hasInitialized || !user || !imAccount) return;
if (!nim || hasInitialized || !user || !imAccount) return;
const init = async () => {
// 只有在未登录状态下才执行登录
@ -71,7 +96,12 @@ export const NimChatProvider = ({ children }: { children: React.ReactNode }) =>
}
init();
}, [hasInitialized, user, imAccount])
}, [nim, hasInitialized, user, imAccount])
// 在 nim 初始化之前不渲染子组件
if (!nim) {
return null;
}
return (
<NimUserProvider nim={nim}>

View File

@ -165,7 +165,7 @@ export function useCheckText() {
mutationFn: async (data: { content: string }) => {
const result = await authService.checkText(data)
if (result) {
return `"${result}" 词汇涉嫌违规,请修改后再试`
return `We found some words that might not be allowed: ${result}`
}
return ''
},

View File

@ -151,7 +151,7 @@ export function useCreateFormStorage() {
voice: { content: '', tone: 5, speed: 5 },
},
},
image: { imageUrl: '', avatarUrl: '', intro: '', isPublic: true },
image: { imageUrl: '', avatarUrl: '', intro: '', isPublic: true, imageStyleCode: undefined, imageDesc: undefined },
}
} catch (error) {
console.error('读取表单数据失败:', error)
@ -165,7 +165,7 @@ export function useCreateFormStorage() {
voice: { content: '', tone: 5, speed: 5 },
},
},
image: { imageUrl: '', avatarUrl: '', intro: '', isPublic: true },
image: { imageUrl: '', avatarUrl: '', intro: '', isPublic: true, imageStyleCode: undefined, imageDesc: undefined },
}
}
}
@ -179,7 +179,7 @@ export function useCreateFormStorage() {
voice: { content: '', tone: 5, speed: 5 },
},
},
image: { imageUrl: '', avatarUrl: '', intro: '', isPublic: true },
image: { imageUrl: '', avatarUrl: '', intro: '', isPublic: true, imageStyleCode: undefined, imageDesc: undefined },
}
})
@ -233,7 +233,21 @@ export function useCreateFormStorage() {
const getTypeData = useCallback(() => formData.type, [formData.type])
const getCharacterData = useCallback(() => formData.character, [formData.character])
const getDialogueData = useCallback(() => formData.dialogue, [formData.dialogue])
const getImageData = useCallback(() => formData.image, [formData.image])
const getImageData = useCallback(() => {
// 直接从 localStorage 读取最新数据,避免 React state 更新延迟
if (typeof window !== 'undefined') {
try {
const saved = localStorage.getItem(CREATE_FORM_STORAGE_KEY)
if (saved) {
const data = JSON.parse(saved) as CreateFormData
return data.image
}
} catch (error) {
console.error('读取 image 数据失败:', error)
}
}
return formData.image
}, [formData.image])
const getFormData = useCallback(() => localStorage.getItem(CREATE_FORM_STORAGE_KEY), [])
const isFormDataEmpty = useCallback(() => {
@ -349,7 +363,7 @@ export function useEditFormStorage() {
voice: { content: '', tone: 5, speed: 5 },
},
},
image: { imageUrl: '', avatarUrl: '', intro: '', isPublic: true },
image: { imageUrl: '', avatarUrl: '', intro: '', isPublic: true, imageStyleCode: undefined, imageDesc: undefined },
}
} catch (error) {
console.error('读取表单数据失败:', error)
@ -363,7 +377,7 @@ export function useEditFormStorage() {
voice: { content: '', tone: 5, speed: 5 },
},
},
image: { imageUrl: '', avatarUrl: '', intro: '', isPublic: true },
image: { imageUrl: '', avatarUrl: '', intro: '', isPublic: true, imageStyleCode: undefined, imageDesc: undefined },
}
}
}
@ -377,7 +391,7 @@ export function useEditFormStorage() {
voice: { content: '', tone: 5, speed: 5 },
},
},
image: { imageUrl: '', avatarUrl: '', intro: '', isPublic: true },
image: { imageUrl: '', avatarUrl: '', intro: '', isPublic: true, imageStyleCode: undefined, imageDesc: undefined },
}
})
@ -431,7 +445,21 @@ export function useEditFormStorage() {
const getTypeData = useCallback(() => formData.type, [formData.type])
const getCharacterData = useCallback(() => formData.character, [formData.character])
const getDialogueData = useCallback(() => formData.dialogue, [formData.dialogue])
const getImageData = useCallback(() => formData.image, [formData.image])
const getImageData = useCallback(() => {
// 直接从 localStorage 读取最新数据,避免 React state 更新延迟
if (typeof window !== 'undefined') {
try {
const saved = localStorage.getItem(EDIT_FORM_STORAGE_KEY)
if (saved) {
const data = JSON.parse(saved) as EditFormData
return data.image
}
} catch (error) {
console.error('读取 image 数据失败:', error)
}
}
return formData.image
}, [formData.image])
const getFormData = useCallback(() => localStorage.getItem(EDIT_FORM_STORAGE_KEY), [])
const isFormDataEmpty = useCallback(({ checkEnum }: { checkEnum: "all" | "type" | "character" | "dialogue" | "image" }) => {

View File

@ -21,36 +21,18 @@ interface UseAiReplySuggestionsProps {
export const useAiReplySuggestions = ({
aiId
}: UseAiReplySuggestionsProps = {}) => {
const [allSuggestions, setAllSuggestions] = useState<ReplySuggestion[]>([]); // 保存所有建议数据
// 简化状态管理 - 按页存储数据
const [pageData, setPageData] = useState<Map<number, ReplySuggestion[]>>(new Map());
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(3); // 固定3页每页3条数据
const [isLoading, setIsLoading] = useState(false);
const [totalPages] = useState(3); // 固定3页
const [isVisible, setIsVisible] = useState(false);
const [lastAiMessageId, setLastAiMessageId] = useState<string>(''); // 记录最后一条AI消息的ID
const [loadedPages, setLoadedPages] = useState<Set<number>>(new Set([1])); // 记录已加载的页面默认第1页已加载
const [batchNo, setBatchNo] = useState<string>(''); // 保存批次号
const [excContentList, setExcContentList] = useState<string[]>([]); // 保存排除内容列表
const [isPageLoading, setIsPageLoading] = useState(false); // 页面级别的加载状态
const [isRequesting, setIsRequesting] = useState(false); // 防止重复请求的标志
const [batchNo, setBatchNo] = useState<string>(''); // 当前批次号
const [excContentList, setExcContentList] = useState<string[]>([]); // 已排除的内容列表
const [loadingPages, setLoadingPages] = useState<Set<number>>(new Set()); // 正在加载的页面
const setIsCoinInsufficient = useSetAtom(isCoinInsufficientAtom);
// 每页显示的建议数量
const pageSize = 3;
// 根据当前页码获取当前页的建议
const suggestions = allSuggestions.slice((currentPage - 1) * pageSize, currentPage * pageSize);
// 检查当前页面是否已加载数据
const isCurrentPageLoaded = loadedPages.has(currentPage);
// 如果当前页面未加载完成,返回骨架屏数据
const displaySuggestions = isCurrentPageLoaded ? suggestions : Array.from({ length: pageSize }, (_, index) => ({
id: `skeleton-${currentPage}-${index}`,
text: '', // 空文本表示骨架屏
isSkeleton: true
}));
const { mutateAsync: genSupContentV2, isPending: isGenSupContentV2Pending } = useGenSupContentV2();
const { mutateAsync: genSupContentV2 } = useGenSupContentV2();
const { nim } = useNimChat();
const msgList = useAtomValue(msgListAtom);
const selectedConversationId = useAtomValue(selectedConversationIdAtom);
@ -71,6 +53,133 @@ export const useAiReplySuggestions = ({
return targetId === message.senderId;
}, [nim, selectedConversationId]);
// 获取所有数据第1、2、3页 - 渐进式加载,让用户尽早看到数据
const fetchAllData = useCallback(async () => {
if (!aiId) return;
// 防止重复调用
if (loadingPages.size > 0) {
console.log('🚫 已有页面正在加载中,跳过重复请求');
return;
}
// 标记所有页面为加载中
setLoadingPages(new Set([1, 2, 3]));
// 清空旧数据
setPageData(new Map());
setExcContentList([]);
setBatchNo('');
try {
console.log('🤖 开始渐进式获取建议数据3次请求');
let currentBatchNo = '';
let currentExcList: string[] = [];
// 获取第1页 - 立即展示
console.log('🤖 [1/3] 获取第1页数据');
const firstResponse = await genSupContentV2({
aiId,
excContentList: []
});
if (firstResponse && firstResponse.contentList && Array.isArray(firstResponse.contentList)) {
const firstSuggestions = firstResponse.contentList.map((text, index) => ({
id: `${firstResponse.batchNo}-0-${index}`,
text
}));
// 立即展示第1页数据
setPageData(new Map([[1, firstSuggestions]]));
setLoadingPages(new Set([2, 3])); // 第1页已加载完成
currentBatchNo = firstResponse.batchNo;
currentExcList = [...firstResponse.contentList];
setBatchNo(currentBatchNo);
setExcContentList(currentExcList);
console.log('✅ 第1页数据加载完成并立即展示');
} else {
throw new Error('第1页数据获取失败');
}
// 获取第2页 - 追加展示
console.log('🤖 [2/3] 获取第2页数据');
const secondResponse = await genSupContentV2({
aiId,
batchNo: currentBatchNo,
excContentList: currentExcList
});
if (secondResponse && secondResponse.contentList && Array.isArray(secondResponse.contentList)) {
const secondSuggestions = secondResponse.contentList.map((text, index) => ({
id: `${currentBatchNo}-1-${index}`,
text
}));
// 追加第2页数据
setPageData(prev => new Map(prev).set(2, secondSuggestions));
setLoadingPages(new Set([3])); // 第2页已加载完成
currentExcList = [...currentExcList, ...secondResponse.contentList];
setExcContentList(currentExcList);
console.log('✅ 第2页数据加载完成并追加展示');
} else {
console.warn('⚠️ 第2页数据获取失败继续获取第3页');
}
// 获取第3页 - 追加展示
console.log('🤖 [3/3] 获取第3页数据');
const thirdResponse = await genSupContentV2({
aiId,
batchNo: currentBatchNo,
excContentList: currentExcList
});
if (thirdResponse && thirdResponse.contentList && Array.isArray(thirdResponse.contentList)) {
const thirdSuggestions = thirdResponse.contentList.map((text, index) => ({
id: `${currentBatchNo}-2-${index}`,
text
}));
// 追加第3页数据
setPageData(prev => new Map(prev).set(3, thirdSuggestions));
console.log('✅ 第3页数据加载完成并追加展示');
} else {
console.warn('⚠️ 第3页数据获取失败');
}
// 记录最后一条AI消息的ID
const lastMessage = getLastMessage();
if (lastMessage && isMessageFromAI(lastMessage)) {
setLastAiMessageId(lastMessage.messageServerId);
}
console.log('🎉 所有建议数据加载完成');
} catch (error) {
if (error instanceof ApiError && error.errorCode === COIN_INSUFFICIENT_ERROR_CODE) {
setIsCoinInsufficient(true);
setIsVisible(false);
} else {
console.error('❌ 获取建议数据失败:', error);
}
// 清空数据
setPageData(new Map());
} finally {
// 清除所有加载状态
setLoadingPages(new Set());
}
}, [aiId, genSupContentV2, loadingPages.size, getLastMessage, isMessageFromAI, setIsCoinInsufficient]);
// 切换页面
const handlePageChange = useCallback((page: number) => {
if (page >= 1 && page <= totalPages && page !== currentPage) {
console.log('📄 切换到第', page, '页');
setCurrentPage(page);
}
}, [currentPage, totalPages]);
// 检查是否需要获取新的建议
const shouldFetchNewSuggestions = useCallback((): boolean => {
const lastMessage = getLastMessage();
@ -82,230 +191,65 @@ export const useAiReplySuggestions = ({
return isFromAI && isNewAIMessage;
}, [getLastMessage, isMessageFromAI, lastAiMessageId]);
// 获取AI建议 - 先获取第一批数据展示,然后静默获取剩余数据
const fetchSuggestions = useCallback(async () => {
if (!aiId) return;
// 防止重复调用 - 如果正在加载中或正在请求中则直接返回
if (isLoading || isGenSupContentV2Pending || isRequesting) {
console.log('🚫 正在加载中或请求中,跳过重复请求');
return;
}
setIsRequesting(true);
setIsLoading(true);
try {
console.log('🤖 开始获取AI建议aiId:', aiId);
const batchNum = new Date().getTime();
// 第一次调用API获取3条数据并立即展示
console.log('🤖 第1次调用APIbatchNo:', batchNo, 'excContentList: []');
const firstResponse = await genSupContentV2({
aiId,
excContentList: []
});
console.log('🤖 第1次API返回结果:', firstResponse);
if (firstResponse && firstResponse.contentList && Array.isArray(firstResponse.contentList)) {
// 将第一批数据转换为建议对象
const firstBatchSuggestions = firstResponse.contentList.map((text, index) => ({
id: `${batchNum}-0-${index}`,
text
}));
// 立即保存第一批数据并展示
setAllSuggestions(firstBatchSuggestions);
setTotalPages(3); // 固定3页
// 不强制跳转到第一页,保持用户当前页面位置
setBatchNo(firstResponse.batchNo);
setExcContentList([...firstResponse.contentList]);
// 记录最后一条AI消息的ID
const lastMessage = getLastMessage();
if (lastMessage && isMessageFromAI(lastMessage)) {
setLastAiMessageId(lastMessage.messageServerId);
}
// 静默获取剩余数据
fetchRemainingSuggestions(firstResponse.batchNo, firstResponse.contentList, firstBatchSuggestions);
} else {
setAllSuggestions([]);
setTotalPages(1);
setCurrentPage(1);
}
} catch (error) {
if (error instanceof ApiError) {
if (error.errorCode === COIN_INSUFFICIENT_ERROR_CODE) {
setIsCoinInsufficient(true);
setAllSuggestions([]);
setTotalPages(1);
setCurrentPage(1);
setIsVisible(false);
return;
}
}
console.error('❌ Failed to fetch AI suggestions:', error);
setAllSuggestions([]);
setTotalPages(1);
setCurrentPage(1);
} finally {
setIsLoading(false);
setIsRequesting(false);
}
}, [aiId, genSupContentV2, getLastMessage, isMessageFromAI, isLoading, isGenSupContentV2Pending, isRequesting]);
// 静默获取剩余的建议数据
const fetchRemainingSuggestions = useCallback(async (batchNo: string, currentExcContentList: string[], currentSuggestions: ReplySuggestion[]) => {
if (!aiId) return;
try {
let allSuggestions = [...currentSuggestions];
let excContentList = [...currentExcContentList];
// 获取第2批数据
console.log('🤖 静默获取第2批数据batchNo:', batchNo, 'excContentList:', excContentList);
const secondResponse = await genSupContentV2({
aiId,
batchNo,
excContentList
});
if (secondResponse && secondResponse.contentList && Array.isArray(secondResponse.contentList)) {
const secondBatchSuggestions = secondResponse.contentList.map((text, index) => ({
id: `${batchNo}-1-${index}`,
text
}));
allSuggestions.push(...secondBatchSuggestions);
excContentList.push(...secondResponse.contentList);
// 更新数据
setAllSuggestions(allSuggestions);
setExcContentList(excContentList);
// 标记第2页已加载
setLoadedPages(prev => new Set([...prev, 2]));
}
// 获取第3批数据
console.log('🤖 静默获取第3批数据batchNo:', batchNo, 'excContentList:', excContentList);
const thirdResponse = await genSupContentV2({
aiId,
batchNo,
excContentList
});
if (thirdResponse && thirdResponse.contentList && Array.isArray(thirdResponse.contentList)) {
const thirdBatchSuggestions = thirdResponse.contentList.map((text, index) => ({
id: `${batchNo}-2-${index}`,
text
}));
allSuggestions.push(...thirdBatchSuggestions);
// 更新最终数据
setAllSuggestions(allSuggestions);
// 标记第3页已加载
setLoadedPages(prev => new Set([...prev, 3]));
}
} catch (error) {
console.error('❌ 静默获取剩余数据失败:', error);
}
}, [aiId, genSupContentV2]);
// 切换页面 - 检查数据是否已加载,未加载则显示骨架屏
const handlePageChange = useCallback((page: number) => {
if (page >= 1 && page <= totalPages && page !== currentPage) {
console.log('📄 切换到第', page, '页');
// 检查目标页面是否已加载数据
const isTargetPageLoaded = loadedPages.has(page);
if (isTargetPageLoaded) {
console.log('✅ 第', page, '页数据已加载,直接切换');
setCurrentPage(page);
setIsPageLoading(false); // 清除页面加载状态
} else {
console.log('⏳ 第', page, '页数据未加载,显示骨架屏');
setIsPageLoading(true);
setCurrentPage(page);
// 如果数据正在后台加载,等待数据加载完成
// 这里不需要额外处理因为数据加载完成后会自动更新UI
}
}
}, [currentPage, totalPages, loadedPages]);
// 显示建议面板
const showSuggestions = useCallback(() => {
setIsVisible(true);
// 检查是否需要获取新的建议
if (shouldFetchNewSuggestions()) {
console.log('🔄 检测到新的AI消息获取新建议');
// 不重置页面,保持用户当前的页面位置
fetchSuggestions();
} else if (allSuggestions.length === 0) {
console.log('📝 没有建议数据,获取建议');
// 只有在完全没有数据时才重置到第一页
setCurrentPage(1);
fetchSuggestions();
console.log('🔄 检测到新的AI消息重置到第1页并获取新建议');
setCurrentPage(1); // 重置到第1页
fetchAllData(); // 重新获取所有数据
} else if (pageData.size === 0) {
console.log('📝 没有建议数据重置到第1页并获取建议');
setCurrentPage(1); // 重置到第1页
fetchAllData();
} else {
console.log('♻️ 使用已有的建议数据,当前页:', currentPage, '总页数:', totalPages);
// 如果已有数据,不重置页面,保持用户当前的页面位置
console.log('♻️ 使用已有的建议数据,保持当前页:', currentPage);
// 如果已有数据且没有新消息,保持用户当前的页面位置
}
}, [shouldFetchNewSuggestions, allSuggestions.length, currentPage, totalPages, fetchSuggestions]);
}, [shouldFetchNewSuggestions, pageData.size, currentPage, fetchAllData]);
// 隐藏建议面板
const hideSuggestions = useCallback(() => {
setIsVisible(false);
}, []);
// 刷新建议
const refreshSuggestions = useCallback(() => {
fetchSuggestions();
}, [fetchSuggestions]);
// 监听消息变化,当用户已打开建议面板时自动刷新
// 监听消息变化当收到新AI消息且面板已打开时自动刷新
useEffect(() => {
if (!isVisible) return;
const lastMessage = getLastMessage();
if (lastMessage && isMessageFromAI(lastMessage)) {
if (isVisible && lastMessage.messageServerId !== lastAiMessageId && allSuggestions.length > 0) {
// 只有在面板已打开、收到新AI消息、且已有建议数据时才自动刷新
// 这样避免了在首次打开面板时与 showSuggestions 中的调用冲突
if (lastMessage.messageServerId !== lastAiMessageId) {
console.log('🔄 面板已打开检测到新AI消息自动刷新建议');
fetchSuggestions();
} else if (!isVisible) {
// 面板未打开时只记录,不获取
console.log('📨 检测到新的AI消息但面板未打开待用户打开时获取建议');
// 不重置页面,保持用户当前浏览位置
fetchAllData();
}
}
}, [getLastMessage, isMessageFromAI, isVisible, lastAiMessageId, allSuggestions.length, fetchSuggestions]);
}, [getLastMessage, isMessageFromAI, isVisible, lastAiMessageId, fetchAllData]);
// 监听当前页面数据加载完成,清除页面加载状态
useEffect(() => {
if (isPageLoading && loadedPages.has(currentPage)) {
console.log('✅ 第', currentPage, '页数据加载完成,清除加载状态');
setIsPageLoading(false);
}
}, [isPageLoading, loadedPages, currentPage]);
// 获取当前页的建议数据
const currentPageSuggestions = pageData.get(currentPage);
const isCurrentPageLoading = loadingPages.has(currentPage);
// 如果页面正在加载或没有数据,显示骨架屏
const suggestions = isCurrentPageLoading || !currentPageSuggestions
? Array.from({ length: 3 }, (_, index) => ({
id: `skeleton-${currentPage}-${index}`,
text: '',
isSkeleton: true
}))
: currentPageSuggestions;
return {
suggestions: displaySuggestions, // 使用处理后的建议数据(包含骨架屏)
suggestions,
currentPage,
totalPages,
isLoading,
isLoading: loadingPages.size > 0, // 只要有页面在加载就返回true
isVisible,
isPageLoading, // 页面级别的加载状态
isCurrentPageLoaded, // 当前页面是否已加载
showSuggestions,
hideSuggestions,
handlePageChange,
refreshSuggestions,
};
};

View File

@ -1,3 +1,4 @@
import { useEffect } from "react";
import { usePathname, useRouter } from "next/navigation";
import { useCurrentUser, useToken } from "./auth";
import { useSetAtom } from "jotai";
@ -5,6 +6,8 @@ import { isCreateAiLimitReachedDialogOpenAtom } from "@/atoms/global";
import { VipType } from "@/services/wallet";
import { isVipDrawerOpenAtom } from "@/atoms/im";
const CREATE_CHARACTER_ROUTE = "/create/type";
const useCreatorNavigation = () => {
const router = useRouter();
const pathname = usePathname();
@ -14,11 +17,17 @@ const useCreatorNavigation = () => {
const setIsCreateAiLimitReachedDialogOpen = useSetAtom(isCreateAiLimitReachedDialogOpenAtom)
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom)
const loginRedirectHref = `/login?redirect=${encodeURIComponent(CREATE_CHARACTER_ROUTE)}`;
useEffect(() => {
router.prefetch(CREATE_CHARACTER_ROUTE);
router.prefetch(loginRedirectHref);
}, [router, loginRedirectHref]);
const routerToCreate = () => {
// 检查是否登录,如果未登录则跳转到登录页面
if (!isLogin) {
const redirectUrl = `/create/type`;
router.push(`/login?redirect=${encodeURIComponent(redirectUrl)}`);
router.push(loginRedirectHref);
return;
}
@ -33,7 +42,7 @@ const useCreatorNavigation = () => {
}
localStorage.setItem('before_creator_navigation', pathname);
router.push('/create/type');
router.push(CREATE_CHARACTER_ROUTE);
}
return {

View File

@ -0,0 +1,86 @@
"use client"
import { useEffect, useMemo } from "react"
import { useRouter } from "next/navigation"
import { useCurrentUser } from "@/hooks/auth"
const PUBLIC_PREFETCH_TARGETS = ["/"]
const AUTH_PREFETCH_TARGETS = [
"/contact",
"/profile",
"/profile/edit",
"/profile/account",
"/vip",
"/wallet",
"/wallet/charge",
"/wallet/charge/result",
"/wallet/transactions",
"/leaderboard",
"/crushcoin",
"/generate/image",
"/generate/image-2-image",
"/generate/image-edit",
"/generate/image-2-background",
"/explore",
"/creator",
"/create/type",
"/create/dialogue",
"/create/character",
"/create/image",
]
const DEFAULT_PREFETCH_TARGETS = [
...PUBLIC_PREFETCH_TARGETS,
...AUTH_PREFETCH_TARGETS,
]
type NullableRoute = string | null | undefined
const prefetchedRouteCache = new Set<string>()
export function usePrefetchRoutes(
routes?: NullableRoute[],
options?: {
limit?: number
}
) {
const router = useRouter()
const normalizedRoutes = useMemo(() => {
if (!routes || routes.length === 0) return []
return routes.filter(Boolean) as string[]
}, [routes])
const limit = options?.limit ?? Infinity
useEffect(() => {
if (!normalizedRoutes.length) return
let count = 0
for (const href of normalizedRoutes) {
if (!href || prefetchedRouteCache.has(href)) continue
prefetchedRouteCache.add(href)
router.prefetch(href)
count += 1
if (count >= limit) break
}
}, [limit, normalizedRoutes, router])
}
export function useGlobalPrefetchRoutes(extraRoutes?: NullableRoute[]) {
const { data: user } = useCurrentUser()
const isAuthenticated = !!user
const protectedTargets = useMemo(() => {
const routes = new Set(AUTH_PREFETCH_TARGETS)
extraRoutes?.forEach((href) => {
if (href && href !== "/") {
routes.add(href)
}
})
return Array.from(routes)
}, [extraRoutes])
usePrefetchRoutes(PUBLIC_PREFETCH_TARGETS)
usePrefetchRoutes(isAuthenticated ? protectedTargets : [])
}
export const GLOBAL_PREFETCH_ROUTES = DEFAULT_PREFETCH_TARGETS

View File

@ -21,6 +21,10 @@ interface UseInfiniteScrollOptions {
* true
*/
enabled?: boolean;
/**
*
*/
isError?: boolean;
}
/**
@ -33,6 +37,7 @@ export function useInfiniteScroll({
fetchNextPage,
threshold = 200,
enabled = true,
isError = false,
}: UseInfiniteScrollOptions) {
const [isFetching, setIsFetching] = useState(false);
const observerRef = useRef<IntersectionObserver | null>(null);
@ -40,7 +45,8 @@ export function useInfiniteScroll({
// 加载更多数据
const loadMore = useCallback(async () => {
if (!hasNextPage || isLoading || isFetching) return;
// 如果有错误,不继续加载
if (!hasNextPage || isLoading || isFetching || isError) return;
setIsFetching(true);
try {
@ -51,7 +57,7 @@ export function useInfiniteScroll({
setIsFetching(false);
}, 100);
}
}, [hasNextPage, isLoading, isFetching, fetchNextPage]);
}, [hasNextPage, isLoading, isFetching, isError, fetchNextPage]);
// 设置Intersection Observer
useEffect(() => {

View File

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useRef, RefObject } from 'react';
import { useEffect, useState, useRef, RefObject, useCallback } from 'react';
interface UseStickyOptions {
/**
@ -34,17 +34,53 @@ export function useSticky<T extends HTMLElement = HTMLDivElement>(
const elementRef = useRef<T>(null);
const [isSticky, setIsSticky] = useState(false);
const rafIdRef = useRef<number | null>(null);
const lastCheckTimeRef = useRef<number>(0);
const scrollContainerRef = useRef<HTMLElement | null>(null);
// 检查并更新 sticky 状态
const checkStickyState = useCallback(() => {
const element = elementRef.current;
const scrollContainer = scrollContainerRef.current;
if (!element) return;
// 获取滚动容器的滚动位置
const scrollTop = scrollContainer ? scrollContainer.scrollTop : window.scrollY;
// 获取元素相对于容器的位置
const elementRect = element.getBoundingClientRect();
const containerRect = scrollContainer ? scrollContainer.getBoundingClientRect() : { top: 0 };
// 计算元素相对于容器顶部的距离
const elementTopRelativeToContainer = elementRect.top - containerRect.top;
// 滚动位置阈值:避免在接近顶部时还保持吸顶
const scrollThreshold = 20;
// 判断是否应该吸顶
// 向下滚动时:元素顶部到达或超过 offset 位置时触发吸顶
// 向上滚动时:元素顶部回到 offset + hysteresis 以上时取消吸顶
const shouldBeSticky = elementTopRelativeToContainer <= offset && scrollTop > scrollThreshold;
const shouldNotBeSticky = elementTopRelativeToContainer > offset + hysteresis;
setIsSticky(prev => {
if (shouldNotBeSticky) return false;
if (shouldBeSticky) return true;
return prev; // 保持当前状态
});
}, [offset, hysteresis]);
useEffect(() => {
const element = elementRef.current;
if (!element) return;
// 如果没有指定 root尝试自动查找滚动容器
let scrollContainer = root;
let scrollContainer = root as HTMLElement | null;
if (!scrollContainer) {
// 查找具有 overflow 属性的父容器或 main-content 容器
scrollContainer = document.getElementById('main-content') || null;
}
scrollContainerRef.current = scrollContainer;
// 创建一个哨兵元素来检测 sticky 状态
const sentinel = document.createElement('div');
@ -59,6 +95,7 @@ export function useSticky<T extends HTMLElement = HTMLDivElement>(
// 将哨兵元素插入到目标元素之前
element.parentNode?.insertBefore(sentinel, element);
// IntersectionObserver 作为主要检测机制
const observer = new IntersectionObserver(
([entry]) => {
// 使用 rAF 合并更新,避免高频切换
@ -66,25 +103,7 @@ export function useSticky<T extends HTMLElement = HTMLDivElement>(
cancelAnimationFrame(rafIdRef.current);
}
rafIdRef.current = requestAnimationFrame(() => {
// 获取滚动容器的滚动位置
const container = scrollContainer as HTMLElement;
const scrollTop = container ? container.scrollTop : window.scrollY;
// 获取元素相对于容器的位置
const elementRect = element.getBoundingClientRect();
const containerRect = container ? container.getBoundingClientRect() : { top: 0 };
// 计算元素相对于容器顶部的距离
const elementTopRelativeToContainer = elementRect.top - containerRect.top;
// 只有当元素顶部到达或超过 offset 位置,并且哨兵不可见时,才认为是 sticky
// 同时确保滚动位置大于元素初始位置
const nextIsSticky = !entry.isIntersecting &&
elementTopRelativeToContainer <= offset + hysteresis &&
scrollTop > 0;
// 仅在状态变化时更新,减少重复渲染
setIsSticky(prev => (prev !== nextIsSticky ? nextIsSticky : prev));
checkStickyState();
});
},
{
@ -97,15 +116,44 @@ export function useSticky<T extends HTMLElement = HTMLDivElement>(
observer.observe(sentinel);
// 添加 scroll 事件监听作为后备方案
// 使用节流来优化性能,避免过于频繁的调用
let scrollTimeout: NodeJS.Timeout | null = null;
const handleScroll = () => {
const now = Date.now();
// 节流:至少间隔 50ms 才执行一次检查
if (now - lastCheckTimeRef.current < 50) {
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
// 设置一个延迟检查,确保滚动停止后也会执行一次
scrollTimeout = setTimeout(() => {
lastCheckTimeRef.current = Date.now();
checkStickyState();
}, 50);
return;
}
lastCheckTimeRef.current = now;
checkStickyState();
};
const scrollTarget = scrollContainer || window;
scrollTarget.addEventListener('scroll', handleScroll, { passive: true });
return () => {
observer.disconnect();
sentinel.remove();
scrollTarget.removeEventListener('scroll', handleScroll);
if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
};
}, [offset, hysteresis, root]);
}, [offset, hysteresis, root, checkStickyState]);
return [elementRef, isSticky];
}

View File

@ -71,6 +71,8 @@ export function createHttpClient(config: CreateHttpClientConfig): ExtendedAxiosI
config.headers["AUTH_TK"] = token
}
config.headers["Accept-Language"] = "en";
// 获取设备ID - 支持服务端和客户端
const deviceId = tokenManager.getDeviceId(cookieString)
if (deviceId) {

View File

@ -195,6 +195,33 @@ export const googleOAuth = {
}
})
}
},
// 使用 FedCM (Federated Credential Management) 方式获取 ID Token
// 这种方式会弹出标准的 Google 登录窗口,无需用户预先登录
renderButton: (parent: HTMLElement, callback: (response: GoogleCredentialResponse) => void, options?: Partial<GsiButtonConfiguration>) => {
if (!window.google?.accounts?.id) {
throw new Error('Google Identity Services SDK not loaded')
}
// 先初始化
window.google.accounts.id.initialize({
client_id: GOOGLE_CLIENT_ID,
callback,
auto_select: false,
cancel_on_tap_outside: true
})
// 渲染 Google 标准按钮
window.google.accounts.id.renderButton(parent, {
type: 'standard',
theme: 'outline',
size: 'large',
text: 'continue_with',
shape: 'rectangular',
logo_alignment: 'left',
...options
})
}
}

View File

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

View File

@ -146,4 +146,8 @@ export interface CheckNicknameRequest {
* ID
*/
exUserId?: number;
/**
* AI检测
*/
isAiCheck?: boolean;
}

View File

@ -168,6 +168,10 @@ export interface GenAiUserContentOutput {
* AiUserExtInput
*/
export interface AiUserExtInput {
/**
* json
*/
userProfileExtJson?: string;
/**
*
*/
@ -275,6 +279,10 @@ export interface AiUserInfoOutput {
* AiUserExtOutput
*/
export interface AiUserExtOutput {
/**
* json
*/
userProfileExtJson?: string;
/**
*
*/