Compare commits

...

5 Commits

66 changed files with 10068 additions and 12971 deletions

2
.env
View File

@ -22,7 +22,7 @@ NEXT_PUBLIC_RTC_APP_ID=689ade491323ae01797818e0
# 启用 mock # 启用 mock
NEXT_PUBLIC_ENABLE_MOCK=false 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_USER_SUFFIX=@u@t
NEXT_PUBLIC_IM_AI_SUFFIX=@r@t NEXT_PUBLIC_IM_AI_SUFFIX=@r@t

8
.npmrc Normal file
View File

@ -0,0 +1,8 @@
# 使用 pnpm
shamefully-hoist=true
strict-peer-dependencies=false
auto-install-peers=true
# 国内镜像加速(可选,如果需要的话)
# registry=https://registry.npmmirror.com

43
.prettierignore Normal file
View File

@ -0,0 +1,43 @@
# 依赖
node_modules
.pnpm-store
pnpm-lock.yaml
# 构建产物
.next
out
dist
build
# 配置文件
package-lock.json
yarn.lock
# 日志
*.log
# 环境变量
.env
.env.*
# 文档和报告
docs/copy-audit.xlsx
docs/i18n-scan-report.xlsx
scripts/translates.xlsx
scripts/translation-conflicts.xlsx
# 字体文件
*.ttf
*.woff
*.woff2
# 公共静态资源
public/mockServiceWorker.js
public/font/
# 其他
.vscode
.idea
*.min.js
*.min.css

10
.prettierrc Normal file
View File

@ -0,0 +1,10 @@
{
"semi": true,
"singleQuote": false,
"tabWidth": 2,
"trailingComma": "es5",
"printWidth": 100,
"arrowParens": "always",
"endOfLine": "lf",
"plugins": ["prettier-plugin-tailwindcss"]
}

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

@ -1,16 +1,13 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import nextTypescript from "eslint-config-next/typescript";
import { dirname } from "path"; import { dirname } from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
const compat = new FlatCompat({ const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
baseDirectory: __dirname, ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts"]
}); }];
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
];
export default eslintConfig; export default eslintConfig;

12302
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -3,10 +3,12 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"i18n:scan": "i18next-scanner", "i18n:scan": "i18next-scanner",
"i18n:scan-custom": "tsx scripts/i18n-scan.ts", "i18n:scan-custom": "tsx scripts/i18n-scan.ts",
"i18n:convert": "node scripts/convert-to-i18n.js" "i18n:convert": "node scripts/convert-to-i18n.js"
@ -49,13 +51,13 @@
"keen-slider": "^6.8.6", "keen-slider": "^6.8.6",
"lamejs": "^1.2.1", "lamejs": "^1.2.1",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next": "15.3.5", "next": "16.0.3",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nim-web-sdk-ng": "^10.9.41", "nim-web-sdk-ng": "^10.9.41",
"numeral": "^2.0.6", "numeral": "^2.0.6",
"qs": "^6.14.0", "qs": "^6.14.0",
"react": "^19.0.0", "react": "19.2.0",
"react-dom": "^19.0.0", "react-dom": "19.2.0",
"react-easy-crop": "^5.5.0", "react-easy-crop": "^5.5.0",
"react-hook-form": "^7.60.0", "react-hook-form": "^7.60.0",
"react-photo-view": "^1.2.7", "react-photo-view": "^1.2.7",
@ -67,22 +69,24 @@
"zod": "^4.0.5" "zod": "^4.0.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^20", "@types/node": "^20",
"@types/numeral": "^2.0.5", "@types/numeral": "^2.0.5",
"@types/qs": "^6.14.0", "@types/qs": "^6.14.0",
"@types/react": "^19", "@types/react": "19.2.5",
"@types/react-dom": "^19", "@types/react-dom": "19.2.3",
"acorn": "^8.15.0", "acorn": "^8.15.0",
"acorn-jsx": "^5.3.2", "acorn-jsx": "^5.3.2",
"acorn-typescript": "^1.4.13", "acorn-typescript": "^1.4.13",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.3.5", "eslint-config-next": "16.0.3",
"eslint-config-prettier": "^9.1.0",
"globby": "^15.0.0", "globby": "^15.0.0",
"i18next-scanner": "^4.6.0", "i18next-scanner": "^4.6.0",
"msw": "^2.10.4", "msw": "^2.10.4",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4", "tailwindcss": "^4",
"ts-morph": "^27.0.2", "ts-morph": "^27.0.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@ -95,5 +99,9 @@
"workerDirectory": [ "workerDirectory": [
"public" "public"
] ]
},
"overrides": {
"@types/react": "19.2.5",
"@types/react-dom": "19.2.3"
} }
} }

8692
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -51,8 +51,16 @@ export function LeftPanel({ scrollBg, images }: LeftPanelProps) {
{/* 滚动背景 */} {/* 滚动背景 */}
<ScrollingBackground imageSrc={scrollBg} /> <ScrollingBackground imageSrc={scrollBg} />
{/* 内容层 */} {/* 内容层 */}
<div className="relative z-10 flex flex-col justify-end h-full"> <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 <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 ${ 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

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

View File

@ -8,6 +8,7 @@ import Image from 'next/image';
interface ReplySuggestion { interface ReplySuggestion {
id: string; id: string;
text: string; text: string;
isSkeleton?: boolean;
} }
interface AiReplySuggestionsProps { interface AiReplySuggestionsProps {
@ -33,6 +34,8 @@ export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
onClose, onClose,
className className
}) => { }) => {
// 检查是否显示骨架屏:当前页的建议中有骨架屏标记
const showSkeleton = suggestions.some(s => s.isSkeleton);
return ( return (
<div className={cn( <div className={cn(
@ -54,22 +57,22 @@ export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
</div> </div>
{/* 建议列表 */} {/* 建议列表 */}
{isLoading ? ( {showSkeleton ? (
// 骨架屏 - 固定显示2条建议的布局 // 骨架屏 - 固定显示3条建议的布局
[1, 2, 3].map((index) => ( suggestions.map((suggestion) => (
<div <div
key={`skeleton-${index}`} 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]" 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="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>
<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> </div>
)) ))
) : ( ) : (
// 实际建议内容 // 实际建议内容
suggestions.slice(0, 3).map((suggestion) => ( suggestions.map((suggestion) => (
<div <div
key={suggestion.id} 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]" 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

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

View File

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

View File

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

View File

@ -36,8 +36,10 @@ import { isCreateAiLimitReachedDialogOpenAtom } from "@/atoms/global"
const imageFormSchema = z.object({ const imageFormSchema = z.object({
imageUrl: z.string(), imageUrl: z.string(),
avatarUrl: 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(), isPublic: z.boolean(),
imageStyleCode: z.number().optional(),
imageDesc: z.string().optional(),
}).refine((data) => { }).refine((data) => {
return !!data.imageUrl && !!data.avatarUrl return !!data.imageUrl && !!data.avatarUrl
}, { }, {
@ -50,6 +52,8 @@ type ImageFormValues = {
avatarUrl: string avatarUrl: string
intro: string intro: string
isPublic: boolean isPublic: boolean
imageStyleCode?: number
imageDesc?: string
} }
type ImageFormSchemaType = z.infer<typeof imageFormSchema> type ImageFormSchemaType = z.infer<typeof imageFormSchema>
@ -81,6 +85,8 @@ export default function ImageForm() {
avatarUrl: savedData?.avatarUrl || undefined, avatarUrl: savedData?.avatarUrl || undefined,
intro: savedData?.intro || "", intro: savedData?.intro || "",
isPublic: savedData?.isPublic ?? true, isPublic: savedData?.isPublic ?? true,
imageStyleCode: savedData?.imageStyleCode,
imageDesc: savedData?.imageDesc,
} }
}) })
const watchImageUrl = form.watch('imageUrl') const watchImageUrl = form.watch('imageUrl')
@ -92,6 +98,26 @@ export default function ImageForm() {
} }
}, [isFormDataEmpty]) }, [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(() => { useEffect(() => {
const subscription = form.watch((data) => { const subscription = form.watch((data) => {
@ -101,8 +127,8 @@ export default function ImageForm() {
avatarUrl: data.avatarUrl || "", avatarUrl: data.avatarUrl || "",
intro: data.intro || "", intro: data.intro || "",
isPublic: data.isPublic ?? true, isPublic: data.isPublic ?? true,
imageStyleCode: savedData?.imageStyleCode, imageStyleCode: data.imageStyleCode,
imageDesc: savedData?.imageDesc, imageDesc: data.imageDesc,
}) })
} }
}) })
@ -354,7 +380,7 @@ export default function ImageForm() {
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="Introduce the virtual character to the user" placeholder="Introduce the virtual character to the user"
maxLength={300} maxLength={400}
showCount showCount
error={!!form.formState.errors.intro} error={!!form.formState.errors.intro}
{...field} {...field}

View File

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

View File

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

View File

@ -27,8 +27,10 @@ import { useCheckText } from "@/hooks/auth"
const imageFormSchema = z.object({ const imageFormSchema = z.object({
imageUrl: z.string(), imageUrl: z.string(),
avatarUrl: 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(), isPublic: z.boolean(),
imageStyleCode: z.number().optional(),
imageDesc: z.string().optional(),
}).refine((data) => { }).refine((data) => {
return !!data.imageUrl && !!data.avatarUrl return !!data.imageUrl && !!data.avatarUrl
}, { }, {
@ -41,6 +43,8 @@ type ImageFormValues = {
avatarUrl: string avatarUrl: string
intro: string intro: string
isPublic: boolean isPublic: boolean
imageStyleCode?: number
imageDesc?: string
} }
type ImageFormSchemaType = z.infer<typeof imageFormSchema> type ImageFormSchemaType = z.infer<typeof imageFormSchema>
@ -69,6 +73,8 @@ export default function ImageForm() {
avatarUrl: savedData?.avatarUrl || undefined, avatarUrl: savedData?.avatarUrl || undefined,
intro: savedData?.intro || "", intro: savedData?.intro || "",
isPublic: savedData?.isPublic ?? true, isPublic: savedData?.isPublic ?? true,
imageStyleCode: Number(savedData?.imageStyleCode) || undefined,
imageDesc: savedData?.imageDesc || undefined,
} }
}) })
const watchImageUrl = form.watch('imageUrl') const watchImageUrl = form.watch('imageUrl')
@ -76,19 +82,24 @@ export default function ImageForm() {
useEffect(() => { useEffect(() => {
if (ai && isFormDataEmpty({ checkEnum: "image" })) { if (ai && isFormDataEmpty({ checkEnum: "image" })) {
const imageStyleCode = ai?.aiUserExt?.imageStyleCode ? Number(ai.aiUserExt.imageStyleCode) : undefined;
const imageDesc = ai?.aiUserExt?.imageDesc || undefined;
form.reset({ form.reset({
imageUrl: ai.imageUrl || "", imageUrl: ai.imageUrl || "",
avatarUrl: ai.headImg || "", avatarUrl: ai.headImg || "",
intro: ai.introduction || "", intro: ai.introduction || "",
isPublic: ai.permission === AIPermission.Public ? true : false, isPublic: ai.permission === AIPermission.Public ? true : false,
imageStyleCode,
imageDesc,
}) })
updateImageData({ updateImageData({
imageUrl: ai.imageUrl || "", imageUrl: ai.imageUrl || "",
avatarUrl: ai.headImg || "", avatarUrl: ai.headImg || "",
intro: ai.introduction || "", intro: ai.introduction || "",
isPublic: ai.permission === AIPermission.Public ? true : false, isPublic: ai.permission === AIPermission.Public ? true : false,
imageStyleCode: Number(ai?.aiUserExt?.imageStyleCode) || undefined, imageStyleCode,
imageDesc: ai?.aiUserExt?.imageDesc ?? undefined, imageDesc,
}) })
} }
}, [ai, isFormDataEmpty]) }, [ai, isFormDataEmpty])
@ -99,6 +110,26 @@ export default function ImageForm() {
} }
}, [isFormDataEmpty]) }, [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(() => { useEffect(() => {
const subscription = form.watch((data) => { const subscription = form.watch((data) => {
@ -108,7 +139,8 @@ export default function ImageForm() {
avatarUrl: data.avatarUrl || "", avatarUrl: data.avatarUrl || "",
intro: data.intro || "", intro: data.intro || "",
isPublic: data.isPublic ?? true, 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 不同需要重新生成
ptType: PtType.ExtractJsonContent, let extractJsonResp = null;
figure: character?.aiUserExt?.userProfile || '', if (!isSameUserProfile) {
}) const resp = await generateContent({
ptType: PtType.ExtractJsonContent,
figure: character?.aiUserExt?.userProfile,
})
extractJsonResp = resp?.content;
}
const result = { const result = {
aiId: Number(aiId), aiId: Number(aiId),
@ -181,7 +218,7 @@ export default function ImageForm() {
dialogueTimbreUrl: url, dialogueTimbreUrl: url,
imageStyleCode, imageStyleCode,
imageDesc, imageDesc,
userProfileExtJson: extractJsonResp?.content || '', ...(extractJsonResp && { userProfileExtJson: extractJsonResp }),
} }
} }
await createAI(result as unknown as CreateOrEditAiRequest) await createAI(result as unknown as CreateOrEditAiRequest)
@ -315,7 +352,7 @@ export default function ImageForm() {
<FormControl> <FormControl>
<Textarea <Textarea
placeholder="Introduce the virtual character to the user" placeholder="Introduce the virtual character to the user"
maxLength={300} maxLength={400}
showCount showCount
error={!!form.formState.errors.intro} error={!!form.formState.errors.intro}
{...field} {...field}

View File

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

View File

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

View File

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

View File

@ -49,7 +49,7 @@ const AGE_OPTIONS = [
const TYPE_OPTIONS = [ const TYPE_OPTIONS = [
{ {
code: 'R00001', code: 'R00002',
name: 'Original', name: 'Original',
image: '/images/home/icon-original.png', image: '/images/home/icon-original.png',
background: "linear-gradient(263deg, #62E1F2 0%, #6296F2 100%), #211A2B" 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); const filterCount = (filters.gender?.length || 0) + (filters.age?.length || 0) + (filters.type?.length || 0);
// 使用 sticky hook 检测粘性状态 // 使用 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 const leftOffset = isSidebarExpanded ? 320 : 80; // w-80 = 320px, w-20 = 80px
@ -192,8 +195,13 @@ const MeetHeader = ({
{/* Fixed 定位的筛选栏 - sticky 时显示 */} {/* Fixed 定位的筛选栏 - sticky 时显示 */}
{isSticky && ( {isSticky && (
<div <div
className="fixed top-16 right-0 py-6 z-50 border-b border-outline-normal backdrop-blur-[10px]" 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 }} style={{
left: `${leftOffset}px`,
right: 0,
opacity: isSticky ? 1 : 0,
willChange: 'opacity'
}}
> >
{/* 半透明背景 */} {/* 半透明背景 */}
<div className="absolute inset-0 bg-background-default opacity-85" /> <div className="absolute inset-0 bg-background-default opacity-85" />

View File

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

View File

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

View File

@ -2,7 +2,7 @@
import Image from "next/image"; import Image from "next/image";
import type { AiChatRankOutput } from "@/services/home/types"; import type { AiChatRankOutput } from "@/services/home/types";
import { calculateAgeByBirthday, formatNumberToKMB } from "@/lib/utils"; import { formatNumberToKMB } from "@/lib/utils";
import Link from "next/link"; import Link from "next/link";
interface MostChatItemProps { interface MostChatItemProps {
@ -12,10 +12,8 @@ interface MostChatItemProps {
const MostChatItem = ({ character, onClick }: MostChatItemProps) => { const MostChatItem = ({ character, onClick }: MostChatItemProps) => {
const age = calculateAgeByBirthday(character.birthday);
return ( 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 <div
onClick={onClick} onClick={onClick}
className="relative rounded-lg overflow-hidden bg-surface-base-normal cursor-pointer transition-transform duration-300 border border-solid border-outline-normal" 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; export default MostChatItem;

View File

@ -1,20 +1,27 @@
"use client"; "use client";
import useEmblaCarousel from "embla-carousel-react" 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 Link from "next/link"
import { RankType } from "@/types/global" import { RankType } from "@/types/global"
import { useHomeData } from "../../context/HomeDataContext" import { useHomeData } from "../../context/HomeDataContext"
import MostChatItem from "./MostChatItem" import MostChatItem from "./MostChatItem"
import MostChatSkeleton from "./MostChatSkeleton" import MostChatSkeleton from "./MostChatSkeleton"
import { IconButton } from "@/components/ui/button"; import { IconButton } from "@/components/ui/button";
import { usePrefetchRoutes } from "@/hooks/useGlobalPrefetchRoutes";
const MostChat = () => { const MostChat = () => {
// 从 Context 获取数据 // 从 Context 获取数据
const { data: homeData, isLoading } = useHomeData() const { data: homeData, isLoading } = useHomeData()
const displayData = useMemo(
// 只取前20条数据 () => homeData?.mostChat?.slice(0, 20) || [],
const displayData = 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 = () => { const getSlidesToScroll = () => {
@ -90,7 +97,7 @@ const MostChat = () => {
<span className="text-2xl">👑</span> <span className="text-2xl">👑</span>
<h2 className="txt-headline-s">Most Chatted</h2> <h2 className="txt-headline-s">Most Chatted</h2>
</div> </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 More
</Link> </Link>
</div> </div>
@ -138,4 +145,4 @@ const MostChat = () => {
) )
} }
export default MostChat export default MostChat

View File

@ -2,7 +2,6 @@
import Image from "next/image"; import Image from "next/image";
import type { AiHeartbeatRankOutput } from "@/services/home/types"; import type { AiHeartbeatRankOutput } from "@/services/home/types";
import { calculateAgeByBirthday } from "@/lib/utils";
import Link from "next/link"; import Link from "next/link";
interface MostCrushItemProps { interface MostCrushItemProps {
@ -12,10 +11,8 @@ interface MostCrushItemProps {
const MostCrushItem = ({ character, onClick }: MostCrushItemProps) => { const MostCrushItem = ({ character, onClick }: MostCrushItemProps) => {
const age = calculateAgeByBirthday(character.birthday);
return ( 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 <div
onClick={onClick} onClick={onClick}
className="relative rounded-lg overflow-hidden bg-surface-base-normal cursor-pointer transition-transform duration-300 border border-solid border-outline-normal" 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; export default MostCrushItem;

View File

@ -1,20 +1,27 @@
"use client"; "use client";
import useEmblaCarousel from "embla-carousel-react" 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 Link from "next/link"
import { RankType } from "@/types/global" import { RankType } from "@/types/global"
import { useHomeData } from "../../context/HomeDataContext" import { useHomeData } from "../../context/HomeDataContext"
import MostCrushItem from "./MostCrushItem" import MostCrushItem from "./MostCrushItem"
import MostCrushSkeleton from "./MostCrushSkeleton" import MostCrushSkeleton from "./MostCrushSkeleton"
import { IconButton } from "@/components/ui/button"; import { IconButton } from "@/components/ui/button";
import { usePrefetchRoutes } from "@/hooks/useGlobalPrefetchRoutes";
const MostCrush = () => { const MostCrush = () => {
// 从 Context 获取数据 // 从 Context 获取数据
const { data: homeData, isLoading } = useHomeData() const { data: homeData, isLoading } = useHomeData()
const displayData = useMemo(
// 只取前20条数据 () => homeData?.mustCrush?.slice(0, 20) || [],
const displayData = 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 = () => { const getSlidesToScroll = () => {
@ -90,7 +97,7 @@ const MostCrush = () => {
<span className="text-2xl">💖</span> <span className="text-2xl">💖</span>
<h2 className="txt-headline-s">Most Crushes</h2> <h2 className="txt-headline-s">Most Crushes</h2>
</div> </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 More
</Link> </Link>
</div> </div>
@ -139,4 +146,3 @@ const MostCrush = () => {
} }
export default MostCrush export default MostCrush

View File

@ -2,7 +2,7 @@
import Image from "next/image"; import Image from "next/image";
import type { AiGiftRankOutput } from "@/services/home/types"; import type { AiGiftRankOutput } from "@/services/home/types";
import { calculateAgeByBirthday, formatNumberToKMB } from "@/lib/utils"; import { formatNumberToKMB } from "@/lib/utils";
import Link from "next/link"; import Link from "next/link";
interface MostGiftedItemProps { interface MostGiftedItemProps {
@ -12,10 +12,8 @@ interface MostGiftedItemProps {
const MostGiftedItem = ({ character, onClick }: MostGiftedItemProps) => { const MostGiftedItem = ({ character, onClick }: MostGiftedItemProps) => {
const age = calculateAgeByBirthday(character.birthday);
return ( 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 <div
onClick={onClick} onClick={onClick}
className="relative rounded-lg overflow-hidden bg-surface-base-normal cursor-pointer transition-transform duration-300 border border-solid border-outline-normal" 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; export default MostGiftedItem;

View File

@ -1,20 +1,27 @@
"use client"; "use client";
import useEmblaCarousel from "embla-carousel-react" 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 Link from "next/link"
import { RankType } from "@/types/global" import { RankType } from "@/types/global"
import { useHomeData } from "../../context/HomeDataContext" import { useHomeData } from "../../context/HomeDataContext"
import MostGiftedItem from "./MostGiftedItem" import MostGiftedItem from "./MostGiftedItem"
import MostGiftedSkeleton from "./MostGiftedSkeleton" import MostGiftedSkeleton from "./MostGiftedSkeleton"
import { IconButton } from "@/components/ui/button"; import { IconButton } from "@/components/ui/button";
import { usePrefetchRoutes } from "@/hooks/useGlobalPrefetchRoutes";
const MostGifted = () => { const MostGifted = () => {
// 从 Context 获取数据 // 从 Context 获取数据
const { data: homeData, isLoading } = useHomeData() const { data: homeData, isLoading } = useHomeData()
const displayData = useMemo(
// 只取前20条数据 () => homeData?.mustGifted?.slice(0, 20) || [],
const displayData = 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 = () => { const getSlidesToScroll = () => {
@ -90,7 +97,7 @@ const MostGifted = () => {
<span className="text-2xl">🎁</span> <span className="text-2xl">🎁</span>
<h2 className="txt-headline-s">Most Gifted</h2> <h2 className="txt-headline-s">Most Gifted</h2>
</div> </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 More
</Link> </Link>
</div> </div>
@ -139,4 +146,3 @@ const MostGifted = () => {
} }
export default 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="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"> <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"> <Avatar className="w-20 h-20">
<AvatarImage <AvatarImage
src={character.headImg || ""} src={character.headImg || ""}
@ -71,7 +71,7 @@ const StartChatItem = ({ character }: StartChatItemProps) => {
</div> </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"> <h3 className="txt-title-m text-center truncate w-full mb-2">
{character.nickname} {character.nickname}
</h3> </h3>
@ -90,6 +90,7 @@ const StartChatItem = ({ character }: StartChatItemProps) => {
<Link <Link
href={`/chat/${character.aiId}?text=${encodeURIComponent(suggestion)}`} href={`/chat/${character.aiId}?text=${encodeURIComponent(suggestion)}`}
key={index} 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" 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"> <span className="txt-body-m text-txt-secondary-normal truncate flex-1">
@ -110,4 +111,3 @@ const StartChatItem = ({ character }: StartChatItemProps) => {
}; };
export default StartChatItem; export default StartChatItem;

View File

@ -1,19 +1,29 @@
"use client"; "use client";
import useEmblaCarousel from "embla-carousel-react"; 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 { useHomeData } from "../../context/HomeDataContext";
import { AudioPlayerProvider } from "../../context/AudioPlayerContext"; import { AudioPlayerProvider } from "../../context/AudioPlayerContext";
import StartChatItem from "./StartChatItem"; import StartChatItem from "./StartChatItem";
import StartChatSkeleton from "./StartChatSkeleton"; import StartChatSkeleton from "./StartChatSkeleton";
import { IconButton } from "@/components/ui/button"; import { IconButton } from "@/components/ui/button";
import { usePrefetchRoutes } from "@/hooks/useGlobalPrefetchRoutes";
const StartChat = () => { const StartChat = () => {
// 从 Context 获取数据 // 从 Context 获取数据
const { data: homeData, isLoading } = useHomeData(); const { data: homeData, isLoading } = useHomeData();
// 只取前20条数据 // 只取前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 = () => { const getSlidesToScroll = () => {
@ -135,4 +145,3 @@ const StartChat = () => {
}; };
export default StartChat; export default StartChat;

View File

@ -50,7 +50,7 @@ export default function LargeRankCard({
} }
return ( 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 <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
@ -70,4 +70,4 @@ export default function LargeRankCard({
</div> </div>
</Link> </Link>
); );
} }

View File

@ -1,10 +1,12 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { useMemo } from "react";
import { AiChatRankOutput, AiHeartbeatRankOutput, AiGiftRankOutput } from "@/services/home/types"; import { AiChatRankOutput, AiHeartbeatRankOutput, AiGiftRankOutput } from "@/services/home/types";
import { RankType } from "@/types/global"; import { RankType } from "@/types/global";
import { formatNumberToKMB } from "@/lib/utils"; import { formatNumberToKMB } from "@/lib/utils";
import Link from "next/link"; import Link from "next/link";
import { usePrefetchRoutes } from "@/hooks/useGlobalPrefetchRoutes";
interface RankingListProps { interface RankingListProps {
rankData: (AiChatRankOutput | AiHeartbeatRankOutput | AiGiftRankOutput)[]; rankData: (AiChatRankOutput | AiHeartbeatRankOutput | AiGiftRankOutput)[];
@ -17,10 +19,16 @@ const RankingList: React.FC<RankingListProps> = ({
rankType, rankType,
startFromRank = 4 startFromRank = 4
}) => { }) => {
// 过滤出从指定排名开始的数据 const filteredData = useMemo(
const filteredData = rankData.filter((item) => () =>
item.rankNo && item.rankNo >= startFromRank 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) => { 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) { if (filteredData.length === 0) {
return null; return null;
} }
@ -60,7 +75,7 @@ const RankingList: React.FC<RankingListProps> = ({
const displayValue = getDisplayValue(item); const displayValue = getDisplayValue(item);
return ( return (
<Link href={`/chat/${item.aiId}`} key={item.aiId}> <Link href={`/chat/${item.aiId}`} key={item.aiId} prefetch>
<div <div
key={item.aiId} key={item.aiId}
className="box-border flex gap-4 items-center justify-center p-2 w-full" className="box-border flex gap-4 items-center justify-center p-2 w-full"
@ -92,10 +107,10 @@ const RankingList: React.FC<RankingListProps> = ({
{/* 喜欢数 */} {/* 喜欢数 */}
<div className="flex gap-2 items-center w-full"> <div className="flex gap-2 items-center w-full">
<div className="flex gap-1 items-center text-txt-secondary-normal"> <div className="flex gap-1 items-center text-txt-secondary-normal">
<i className="iconfont icon-Like-fill !text-[12px]" /> <i className="iconfont icon-Like-fill !text-[12px]" />
<div className="txt-numMonotype-s"> <div className="txt-numMonotype-s">
{formatNumberToKMB((item as any).likedNum || 0)} {formatNumberToKMB(getLikedCount(item) || 0)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -53,7 +53,7 @@ export default function SmallRankCard({
const rankNo = rank || item.rankNo || 1; const rankNo = rank || item.rankNo || 1;
return ( 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="flex-1 py-12">
<div className="w-full aspect-[240/360] rounded-b-lg relative overflow-hidden"> <div className="w-full aspect-[240/360] rounded-b-lg relative overflow-hidden">
<div <div
@ -77,4 +77,4 @@ export default function SmallRankCard({
</div> </div>
</Link> </Link>
); );
} }

View File

@ -1,9 +1,11 @@
"use client"; "use client";
import { useMemo } from "react";
import LargeRankCard from "./LargeRankCard"; import LargeRankCard from "./LargeRankCard";
import SmallRankCard from "./SmallRankCard"; import SmallRankCard from "./SmallRankCard";
import { AiChatRankOutput, AiHeartbeatRankOutput, AiGiftRankOutput } from "@/services/home/types"; import { AiChatRankOutput, AiHeartbeatRankOutput, AiGiftRankOutput } from "@/services/home/types";
import { RankType } from "@/types/global"; import { RankType } from "@/types/global";
import { usePrefetchRoutes } from "@/hooks/useGlobalPrefetchRoutes";
interface TopHeaderProps { interface TopHeaderProps {
rankData: AiChatRankOutput[] | AiHeartbeatRankOutput[] | AiGiftRankOutput[]; rankData: AiChatRankOutput[] | AiHeartbeatRankOutput[] | AiGiftRankOutput[];
@ -12,6 +14,13 @@ interface TopHeaderProps {
} }
export default function TopHeader({ rankData, rankType, isLoading }: 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) { if (isLoading) {
return ( return (
<div className="max-w-[624px] mx-auto mt-6"> <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 firstPlace = topThree[0];
const secondPlace = topThree[1]; const secondPlace = topThree[1];
const thirdPlace = topThree[2]; const thirdPlace = topThree[2];
@ -69,4 +76,4 @@ export default function TopHeader({ rankData, rankType, isLoading }: TopHeaderPr
</div> </div>
</div> </div>
); );
} }

View File

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

View File

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

View File

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

View File

@ -16,14 +16,12 @@ const AIStandardCard: React.FC<AIStandardCardProps> = ({ character, disableHover
const { const {
aiId, aiId,
nickname, nickname,
birthday,
characterName, characterName,
tagName, tagName,
headImg, headImg,
homeImageUrl, homeImageUrl,
introduction, introduction,
likedNum, likedNum,
sex
} = character; } = character;
const introContainerRef = useRef<HTMLDivElement>(null); const introContainerRef = useRef<HTMLDivElement>(null);
@ -50,27 +48,16 @@ const AIStandardCard: React.FC<AIStandardCardProps> = ({ character, disableHover
return () => window.removeEventListener('resize', calculateMaxLines); 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 tags = tagName ? tagName.split(',').filter(tag => tag.trim()) : [];
// 获取显示的背景图片 // 获取显示的背景图片
const displayImage = homeImageUrl || headImg; const displayImage = homeImageUrl || headImg;
const age = getAge(birthday);
const displayName = `${nickname}`; const displayName = `${nickname}`;
return ( return (
<Link href={`/chat/${aiId}`} className="w-full h-full"> <Link href={`/chat/${aiId}`} className="w-full h-full" prefetch>
<div <div
className={`basis-0 content-stretch flex flex-col gap-3 grow items-start justify-start relative shrink-0 cursor-pointer ${disableHover ? '' : 'group'}`} 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"> <div className="flex flex-wrap gap-1">
{/* 性格标签 */} {/* 性格标签 */}
{character.characterName && ( {characterName && (
<Tag size="small">{character.characterName}</Tag> <Tag size="small">{characterName}</Tag>
)} )}
{tags.length > 0 && ( {tags.length > 0 && (
@ -180,4 +167,4 @@ const AIStandardCard: React.FC<AIStandardCardProps> = ({ character, disableHover
); );
}; };
export default AIStandardCard; export default AIStandardCard;

View File

@ -120,7 +120,7 @@ const AlbumPriceSetting = ({
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent className="bg-surface-base-normal p-6 rounded-2xl w-[400px]"> <AlertDialogContent className="bg-surface-base-normal p-6 rounded-2xl w-[400px]">
<AlertDialogHeader> <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 How to Unlock
</AlertDialogTitle> </AlertDialogTitle>
</AlertDialogHeader> </AlertDialogHeader>

View File

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

View File

@ -1,5 +1,5 @@
"use client" "use client"
import React, { useState, useEffect } from "react" import React, { useState, useEffect, useMemo } from "react"
import { MenuItem } from "@/types/global" import { MenuItem } from "@/types/global"
import Image from "next/image" import Image from "next/image"
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils"
@ -34,40 +34,57 @@ function Sidebar() {
const isImageCreatePage = ['/generate/image', '/generate/image-2-image', '/generate/image-edit', '/generate/image-2-background'].includes(pathname) const isImageCreatePage = ['/generate/image', '/generate/image-2-image', '/generate/image-edit', '/generate/image-2-background'].includes(pathname)
const actualIsExpanded = isImageCreatePage ? false : isSidebarExpanded const actualIsExpanded = isImageCreatePage ? false : isSidebarExpanded
const menuItems: IMenuItem[] = [ const menuItems: IMenuItem[] = useMemo(
{ () => [
id: MenuItem.FOR_YOU, {
icon: "/icons/explore.svg", id: MenuItem.FOR_YOU,
selectedIcon: "/icons/explore_selected.svg", icon: "/icons/explore.svg",
label: "Home", selectedIcon: "/icons/explore_selected.svg",
link: '/', label: "Home",
isSelected: pathname === '/', link: '/',
}, isSelected: pathname === '/',
{ },
id: MenuItem.CREATE, {
icon: "/icons/create.svg", id: MenuItem.CREATE,
selectedIcon: "/icons/create_selected.svg", icon: "/icons/create.svg",
label: "Create a Character", selectedIcon: "/icons/create_selected.svg",
link: '/create', label: "Create a Character",
isSelected: pathname.startsWith('/create'), link: '/create',
}, isSelected: pathname.startsWith('/create'),
// { },
// id: MenuItem.EXPLORE, // {
// icon: "/icons/explore.svg", // id: MenuItem.EXPLORE,
// selectedIcon: "/icons/explore_selected.svg", // icon: "/icons/explore.svg",
// label: "Explore", // selectedIcon: "/icons/explore_selected.svg",
// link: '/explore', // label: "Explore",
// isSelected: pathname.startsWith('/explore'), // link: '/explore',
// }, // isSelected: pathname.startsWith('/explore'),
{ // },
id: MenuItem.CHAT, {
icon: "/icons/contact.svg", id: MenuItem.CHAT,
selectedIcon: "/icons/contact_selected.svg", icon: "/icons/contact.svg",
label: "My Crushes", selectedIcon: "/icons/contact_selected.svg",
link: '/contact', label: "My Crushes",
isSelected: pathname.startsWith('/contact'), 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 = () => { const toggleExpanded = () => {
// 在 /create/image 页面时禁用展开功能 // 在 /create/image 页面时禁用展开功能
@ -139,7 +156,7 @@ function Sidebar() {
// 其他链接使用 Link 组件以便 SEO // 其他链接使用 Link 组件以便 SEO
return ( return (
<Link href={item.link} key={item.id}> <Link href={item.link} key={item.id} prefetch>
{menuItemContent} {menuItemContent}
</Link> </Link>
); );
@ -153,4 +170,4 @@ function Sidebar() {
) )
} }
export default Sidebar export default Sidebar

View File

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

View File

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

View File

@ -1,4 +1,5 @@
"use client" "use client"
import { useMemo } from "react"
import AIRelationTag from "@/components/features/AIRelationTag" import AIRelationTag from "@/components/features/AIRelationTag"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
@ -8,6 +9,7 @@ import { CustomMessageType } from "@/types/im"
import Image from "next/image" import Image from "next/image"
import { useRouter } from "next/navigation" import { useRouter } from "next/navigation"
import { V2NIMConversation } from "nim-web-sdk-ng/dist/v2/NIM_BROWSER_SDK/V2NIMConversationService" 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 }) => { const HighlightText = ({ text, keyword }: { text: string; keyword?: string }) => {
@ -45,13 +47,26 @@ export default function ChatSidebarItem({
const { text, attachment } = lastMessage || {}; const { text, attachment } = lastMessage || {};
const router = useRouter(); const router = useRouter();
const { nim } = useNimChat(); 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 { heartbeatVal, heartbeatLevel, isShow } = JSON.parse(serverExtension || '{}') || {};
const handleChat = () => { const handleChat = () => {
const targetId = nim.V2NIMConversationIdUtil.parseConversationTargetId(conversation.conversationId); if (chatHref) {
const aiid = targetId.split('@')[0]; router.push(chatHref);
router.push(`/chat/${aiid}`); }
} }
const renderText = () => { const renderText = () => {
@ -130,4 +145,4 @@ export default function ChatSidebarItem({
)} )}
</div> </div>
) )
} }

View File

@ -120,11 +120,11 @@ function AlertDialogTitle({
...props ...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) { }: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return ( return (
<div className="flex items-start justify-between px-10"> <div className="flex items-start justify-between pr-10">
<AlertDialogPrimitive.Title <AlertDialogPrimitive.Title
data-slot="alert-dialog-title" data-slot="alert-dialog-title"
className={cn( 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 className
)} )}
{...props} {...props}

View File

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

View File

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

View File

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

View File

@ -151,7 +151,7 @@ export function useCreateFormStorage() {
voice: { content: '', tone: 5, speed: 5 }, voice: { content: '', tone: 5, speed: 5 },
}, },
}, },
image: { imageUrl: '', avatarUrl: '', intro: '', isPublic: true }, image: { imageUrl: '', avatarUrl: '', intro: '', isPublic: true, imageStyleCode: undefined, imageDesc: undefined },
} }
} catch (error) { } catch (error) {
console.error('读取表单数据失败:', error) console.error('读取表单数据失败:', error)
@ -165,7 +165,7 @@ export function useCreateFormStorage() {
voice: { content: '', tone: 5, speed: 5 }, 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 }, 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 getTypeData = useCallback(() => formData.type, [formData.type])
const getCharacterData = useCallback(() => formData.character, [formData.character]) const getCharacterData = useCallback(() => formData.character, [formData.character])
const getDialogueData = useCallback(() => formData.dialogue, [formData.dialogue]) 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 getFormData = useCallback(() => localStorage.getItem(CREATE_FORM_STORAGE_KEY), [])
const isFormDataEmpty = useCallback(() => { const isFormDataEmpty = useCallback(() => {
@ -349,7 +363,7 @@ export function useEditFormStorage() {
voice: { content: '', tone: 5, speed: 5 }, voice: { content: '', tone: 5, speed: 5 },
}, },
}, },
image: { imageUrl: '', avatarUrl: '', intro: '', isPublic: true }, image: { imageUrl: '', avatarUrl: '', intro: '', isPublic: true, imageStyleCode: undefined, imageDesc: undefined },
} }
} catch (error) { } catch (error) {
console.error('读取表单数据失败:', error) console.error('读取表单数据失败:', error)
@ -363,7 +377,7 @@ export function useEditFormStorage() {
voice: { content: '', tone: 5, speed: 5 }, 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 }, 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 getTypeData = useCallback(() => formData.type, [formData.type])
const getCharacterData = useCallback(() => formData.character, [formData.character]) const getCharacterData = useCallback(() => formData.character, [formData.character])
const getDialogueData = useCallback(() => formData.dialogue, [formData.dialogue]) 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 getFormData = useCallback(() => localStorage.getItem(EDIT_FORM_STORAGE_KEY), [])
const isFormDataEmpty = useCallback(({ checkEnum }: { checkEnum: "all" | "type" | "character" | "dialogue" | "image" }) => { const isFormDataEmpty = useCallback(({ checkEnum }: { checkEnum: "all" | "type" | "character" | "dialogue" | "image" }) => {

View File

@ -21,36 +21,18 @@ interface UseAiReplySuggestionsProps {
export const useAiReplySuggestions = ({ export const useAiReplySuggestions = ({
aiId aiId
}: UseAiReplySuggestionsProps = {}) => { }: UseAiReplySuggestionsProps = {}) => {
const [allSuggestions, setAllSuggestions] = useState<ReplySuggestion[]>([]); // 保存所有建议数据 // 简化状态管理 - 按页存储数据
const [pageData, setPageData] = useState<Map<number, ReplySuggestion[]>>(new Map());
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(3); // 固定3页每页3条数据 const [totalPages] = useState(3); // 固定3页
const [isLoading, setIsLoading] = useState(false);
const [isVisible, setIsVisible] = useState(false); const [isVisible, setIsVisible] = useState(false);
const [lastAiMessageId, setLastAiMessageId] = useState<string>(''); // 记录最后一条AI消息的ID const [lastAiMessageId, setLastAiMessageId] = useState<string>(''); // 记录最后一条AI消息的ID
const [loadedPages, setLoadedPages] = useState<Set<number>>(new Set([1])); // 记录已加载的页面默认第1页已加载 const [batchNo, setBatchNo] = useState<string>(''); // 当前批次号
const [batchNo, setBatchNo] = useState<string>(''); // 保存批次号 const [excContentList, setExcContentList] = useState<string[]>([]); // 已排除的内容列表
const [excContentList, setExcContentList] = useState<string[]>([]); // 保存排除内容列表 const [loadingPages, setLoadingPages] = useState<Set<number>>(new Set()); // 正在加载的页面
const [isPageLoading, setIsPageLoading] = useState(false); // 页面级别的加载状态
const [isRequesting, setIsRequesting] = useState(false); // 防止重复请求的标志
const setIsCoinInsufficient = useSetAtom(isCoinInsufficientAtom); const setIsCoinInsufficient = useSetAtom(isCoinInsufficientAtom);
// 每页显示的建议数量 const { mutateAsync: genSupContentV2 } = useGenSupContentV2();
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 { nim } = useNimChat(); const { nim } = useNimChat();
const msgList = useAtomValue(msgListAtom); const msgList = useAtomValue(msgListAtom);
const selectedConversationId = useAtomValue(selectedConversationIdAtom); const selectedConversationId = useAtomValue(selectedConversationIdAtom);
@ -71,6 +53,133 @@ export const useAiReplySuggestions = ({
return targetId === message.senderId; return targetId === message.senderId;
}, [nim, selectedConversationId]); }, [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 shouldFetchNewSuggestions = useCallback((): boolean => {
const lastMessage = getLastMessage(); const lastMessage = getLastMessage();
@ -82,230 +191,65 @@ export const useAiReplySuggestions = ({
return isFromAI && isNewAIMessage; return isFromAI && isNewAIMessage;
}, [getLastMessage, isMessageFromAI, lastAiMessageId]); }, [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(() => { const showSuggestions = useCallback(() => {
setIsVisible(true); setIsVisible(true);
// 检查是否需要获取新的建议 // 检查是否需要获取新的建议
if (shouldFetchNewSuggestions()) { if (shouldFetchNewSuggestions()) {
console.log('🔄 检测到新的AI消息获取新建议'); console.log('🔄 检测到新的AI消息重置到第1页并获取新建议');
// 不重置页面,保持用户当前的页面位置 setCurrentPage(1); // 重置到第1页
fetchSuggestions(); fetchAllData(); // 重新获取所有数据
} else if (allSuggestions.length === 0) { } else if (pageData.size === 0) {
console.log('📝 没有建议数据,获取建议'); console.log('📝 没有建议数据重置到第1页并获取建议');
// 只有在完全没有数据时才重置到第一页 setCurrentPage(1); // 重置到第1页
setCurrentPage(1); fetchAllData();
fetchSuggestions();
} else { } else {
console.log('♻️ 使用已有的建议数据,当前页:', currentPage, '总页数:', totalPages); console.log('♻️ 使用已有的建议数据,保持当前页:', currentPage);
// 如果已有数据,不重置页面,保持用户当前的页面位置 // 如果已有数据且没有新消息,保持用户当前的页面位置
} }
}, [shouldFetchNewSuggestions, allSuggestions.length, currentPage, totalPages, fetchSuggestions]); }, [shouldFetchNewSuggestions, pageData.size, currentPage, fetchAllData]);
// 隐藏建议面板 // 隐藏建议面板
const hideSuggestions = useCallback(() => { const hideSuggestions = useCallback(() => {
setIsVisible(false); setIsVisible(false);
}, []); }, []);
// 刷新建议 // 监听消息变化当收到新AI消息且面板已打开时自动刷新
const refreshSuggestions = useCallback(() => {
fetchSuggestions();
}, [fetchSuggestions]);
// 监听消息变化,当用户已打开建议面板时自动刷新
useEffect(() => { useEffect(() => {
if (!isVisible) return;
const lastMessage = getLastMessage(); const lastMessage = getLastMessage();
if (lastMessage && isMessageFromAI(lastMessage)) { if (lastMessage && isMessageFromAI(lastMessage)) {
if (isVisible && lastMessage.messageServerId !== lastAiMessageId && allSuggestions.length > 0) { if (lastMessage.messageServerId !== lastAiMessageId) {
// 只有在面板已打开、收到新AI消息、且已有建议数据时才自动刷新
// 这样避免了在首次打开面板时与 showSuggestions 中的调用冲突
console.log('🔄 面板已打开检测到新AI消息自动刷新建议'); console.log('🔄 面板已打开检测到新AI消息自动刷新建议');
fetchSuggestions(); // 不重置页面,保持用户当前浏览位置
} else if (!isVisible) { fetchAllData();
// 面板未打开时只记录,不获取
console.log('📨 检测到新的AI消息但面板未打开待用户打开时获取建议');
} }
} }
}, [getLastMessage, isMessageFromAI, isVisible, lastAiMessageId, allSuggestions.length, fetchSuggestions]); }, [getLastMessage, isMessageFromAI, isVisible, lastAiMessageId, fetchAllData]);
// 监听当前页面数据加载完成,清除页面加载状态 // 获取当前页的建议数据
useEffect(() => { const currentPageSuggestions = pageData.get(currentPage);
if (isPageLoading && loadedPages.has(currentPage)) { const isCurrentPageLoading = loadingPages.has(currentPage);
console.log('✅ 第', currentPage, '页数据加载完成,清除加载状态');
setIsPageLoading(false); // 如果页面正在加载或没有数据,显示骨架屏
} const suggestions = isCurrentPageLoading || !currentPageSuggestions
}, [isPageLoading, loadedPages, currentPage]); ? Array.from({ length: 3 }, (_, index) => ({
id: `skeleton-${currentPage}-${index}`,
text: '',
isSkeleton: true
}))
: currentPageSuggestions;
return { return {
suggestions: displaySuggestions, // 使用处理后的建议数据(包含骨架屏) suggestions,
currentPage, currentPage,
totalPages, totalPages,
isLoading, isLoading: loadingPages.size > 0, // 只要有页面在加载就返回true
isVisible, isVisible,
isPageLoading, // 页面级别的加载状态
isCurrentPageLoaded, // 当前页面是否已加载
showSuggestions, showSuggestions,
hideSuggestions, hideSuggestions,
handlePageChange, handlePageChange,
refreshSuggestions,
}; };
}; };

View File

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

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

View File

@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState, useRef, RefObject } from 'react'; import { useEffect, useState, useRef, RefObject, useCallback } from 'react';
interface UseStickyOptions { interface UseStickyOptions {
/** /**
@ -34,17 +34,53 @@ export function useSticky<T extends HTMLElement = HTMLDivElement>(
const elementRef = useRef<T>(null); const elementRef = useRef<T>(null);
const [isSticky, setIsSticky] = useState(false); const [isSticky, setIsSticky] = useState(false);
const rafIdRef = useRef<number | null>(null); 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(() => { useEffect(() => {
const element = elementRef.current; const element = elementRef.current;
if (!element) return; if (!element) return;
// 如果没有指定 root尝试自动查找滚动容器 // 如果没有指定 root尝试自动查找滚动容器
let scrollContainer = root; let scrollContainer = root as HTMLElement | null;
if (!scrollContainer) { if (!scrollContainer) {
// 查找具有 overflow 属性的父容器或 main-content 容器 // 查找具有 overflow 属性的父容器或 main-content 容器
scrollContainer = document.getElementById('main-content') || null; scrollContainer = document.getElementById('main-content') || null;
} }
scrollContainerRef.current = scrollContainer;
// 创建一个哨兵元素来检测 sticky 状态 // 创建一个哨兵元素来检测 sticky 状态
const sentinel = document.createElement('div'); const sentinel = document.createElement('div');
@ -59,6 +95,7 @@ export function useSticky<T extends HTMLElement = HTMLDivElement>(
// 将哨兵元素插入到目标元素之前 // 将哨兵元素插入到目标元素之前
element.parentNode?.insertBefore(sentinel, element); element.parentNode?.insertBefore(sentinel, element);
// IntersectionObserver 作为主要检测机制
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
([entry]) => { ([entry]) => {
// 使用 rAF 合并更新,避免高频切换 // 使用 rAF 合并更新,避免高频切换
@ -66,25 +103,7 @@ export function useSticky<T extends HTMLElement = HTMLDivElement>(
cancelAnimationFrame(rafIdRef.current); cancelAnimationFrame(rafIdRef.current);
} }
rafIdRef.current = requestAnimationFrame(() => { rafIdRef.current = requestAnimationFrame(() => {
// 获取滚动容器的滚动位置 checkStickyState();
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));
}); });
}, },
{ {
@ -97,15 +116,44 @@ export function useSticky<T extends HTMLElement = HTMLDivElement>(
observer.observe(sentinel); 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 () => { return () => {
observer.disconnect(); observer.disconnect();
sentinel.remove(); sentinel.remove();
scrollTarget.removeEventListener('scroll', handleScroll);
if (rafIdRef.current !== null) { if (rafIdRef.current !== null) {
cancelAnimationFrame(rafIdRef.current); cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null; rafIdRef.current = null;
} }
if (scrollTimeout) {
clearTimeout(scrollTimeout);
}
}; };
}, [offset, hysteresis, root]); }, [offset, hysteresis, root, checkStickyState]);
return [elementRef, isSticky]; return [elementRef, isSticky];
} }

View File

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

View File

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

View File

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

View File

@ -1,7 +1,11 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2017", "target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"], "lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"strict": true, "strict": true,
@ -11,7 +15,7 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"jsx": "preserve", "jsx": "react-jsx",
"incremental": true, "incremental": true,
"plugins": [ "plugins": [
{ {
@ -19,9 +23,20 @@
} }
], ],
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": [
"./src/*"
]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", "src/types/**/*.d.ts"], "include": [
"exclude": ["node_modules"] "next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"src/types/**/*.d.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
} }