Compare commits

...

2 Commits
dev ... main

477 changed files with 40773 additions and 34441 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=http://localhost:3000 NEXT_PUBLIC_APP_URL=https://test.crushlevel.ai
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

2
.gitignore vendored
View File

@ -31,7 +31,7 @@ yarn-error.log*
.pnpm-debug.log* .pnpm-debug.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
# .env* .env*
# vercel # vercel
.vercel .vercel

8
.npmrc
View File

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

View File

@ -1,43 +1,6 @@
# 依赖
node_modules node_modules
.pnpm-store
pnpm-lock.yaml
# 构建产物
.next .next
out
dist dist
build
# 配置文件
package-lock.json package-lock.json
pnpm-lock.yaml
yarn.lock 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

View File

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

View File

@ -1,12 +1,14 @@
# Repository Guidelines # Repository Guidelines
## Project Structure & Module Organization ## Project Structure & Module Organization
- `src/app` hosts the App Router; use `(auth)` and `(main)` route groups and keep route-only components inside their segment folders. - `src/app` hosts the App Router; use `(auth)` and `(main)` route groups and keep route-only components inside their segment folders.
- `src/components` holds shared UI; keep primitives in `components/ui` and group feature widgets under clear folder names. - `src/components` holds shared UI; keep primitives in `components/ui` and group feature widgets under clear folder names.
- `src/lib`, `src/services`, and `src/utils` house shared logic, API clients, and helpers; extend an existing module before adding a new directory. - `src/lib`, `src/services`, and `src/utils` house shared logic, API clients, and helpers; extend an existing module before adding a new directory.
- Mock handlers live in `src/mocks`, MSWs worker sits in `public/mockServiceWorker.js`, localization bundles under `public/locales`, and generated docs go to `docs/`. - Mock handlers live in `src/mocks`, MSWs worker sits in `public/mockServiceWorker.js`, localization bundles under `public/locales`, and generated docs go to `docs/`.
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- `npm run dev` starts the Turbopack dev server with MSW auto-registration when `NEXT_PUBLIC_ENABLE_MOCK=true`. - `npm run dev` starts the Turbopack dev server with MSW auto-registration when `NEXT_PUBLIC_ENABLE_MOCK=true`.
- `npm run build` compiles the production bundle; run after routing or configuration changes. - `npm run build` compiles the production bundle; run after routing or configuration changes.
- `npm run start` serves the built app and mirrors production runtime. - `npm run start` serves the built app and mirrors production runtime.
@ -14,17 +16,20 @@
- `npm run i18n:scan`, `npm run i18n:scan-custom`, and `npm run i18n:convert` refresh translation keys and write `docs/copy-audit.xlsx`. - `npm run i18n:scan`, `npm run i18n:scan-custom`, and `npm run i18n:convert` refresh translation keys and write `docs/copy-audit.xlsx`.
## Coding Style & Naming Conventions ## Coding Style & Naming Conventions
- TypeScript is required; keep strict types at module boundaries and define payload interfaces explicitly. - TypeScript is required; keep strict types at module boundaries and define payload interfaces explicitly.
- Follow the house formatting: two-space indentation, double quotes, no semicolons, and Tailwind classes composed with `cn`. - Follow the house formatting: two-space indentation, double quotes, no semicolons, and Tailwind classes composed with `cn`.
- Use PascalCase for React components, camelCase for utilities, `use` prefix for hooks, and kebab-case file names in routes. - Use PascalCase for React components, camelCase for utilities, `use` prefix for hooks, and kebab-case file names in routes.
- Reuse theme tokens and shared icons through design-system helpers; avoid ad-hoc color values or inline SVG copies. - Reuse theme tokens and shared icons through design-system helpers; avoid ad-hoc color values or inline SVG copies.
## Testing Guidelines ## Testing Guidelines
- There is no global Jest/Vitest runner; smoke tests such as `src/utils/textParser.test.ts` execute with `npx tsx <path>`—mirror that pattern for quick unit checks. - There is no global Jest/Vitest runner; smoke tests such as `src/utils/textParser.test.ts` execute with `npx tsx <path>`—mirror that pattern for quick unit checks.
- Keep exploratory scripts or `.test.ts` files beside the code they exercise and strip console noise before shipping. - Keep exploratory scripts or `.test.ts` files beside the code they exercise and strip console noise before shipping.
- Prioritize integration checks through the dev server plus MSW, and document manual test steps in the PR when automation is absent. - Prioritize integration checks through the dev server plus MSW, and document manual test steps in the PR when automation is absent.
## Commit & Pull Request Guidelines ## Commit & Pull Request Guidelines
- Commit subjects are present-tense, sentence-style summaries (see `git log`); add rationale in the body when touching multiple areas. - Commit subjects are present-tense, sentence-style summaries (see `git log`); add rationale in the body when touching multiple areas.
- Scope each branch to a vertical slice (`feature/`, `fix/`, or `chore/`) and rebase on `main` before review. - Scope each branch to a vertical slice (`feature/`, `fix/`, or `chore/`) and rebase on `main` before review.
- PRs need a concise summary, screenshots for UI updates, environment-variable callouts, and links to related issues or docs. - PRs need a concise summary, screenshots for UI updates, environment-variable callouts, and links to related issues or docs.

View File

@ -73,22 +73,27 @@ npm run dev
## Discord登录流程 ## Discord登录流程
### 1. 用户点击Discord登录按钮 ### 1. 用户点击Discord登录按钮
- 系统生成随机state参数用于安全验证 - 系统生成随机state参数用于安全验证
- 跳转到Discord授权页面 - 跳转到Discord授权页面
### 2. Discord授权 ### 2. Discord授权
用户在Discord页面授权后Discord重定向到回调URL并携带授权码 用户在Discord页面授权后Discord重定向到回调URL并携带授权码
### 3. 回调处理 ### 3. 回调处理
- API路由 `/api/auth/discord/callback` 接收授权码 - API路由 `/api/auth/discord/callback` 接收授权码
- 将授权码作为URL参数重定向到前端登录页面 - 将授权码作为URL参数重定向到前端登录页面
### 4. 前端登录处理 ### 4. 前端登录处理
- 前端登录页面检测到URL中的`discord_code`参数 - 前端登录页面检测到URL中的`discord_code`参数
- 使用授权码调用后端API: `POST /web/third/login` - 使用授权码调用后端API: `POST /web/third/login`
- 后端验证授权码并返回认证token - 后端验证授权码并返回认证token
### 5. 登录完成 ### 5. 登录完成
- 前端保存token并重定向到首页 - 前端保存token并重定向到首页
- 完成整个登录流程 - 完成整个登录流程
@ -131,6 +136,7 @@ src/
### 认证相关API ### 认证相关API
#### 第三方登录 #### 第三方登录
``` ```
POST /web/third/login POST /web/third/login
Content-Type: application/json Content-Type: application/json
@ -144,6 +150,7 @@ Content-Type: application/json
``` ```
#### 获取用户信息 #### 获取用户信息
``` ```
GET /web/user/base-info GET /web/user/base-info
Headers: Headers:
@ -152,6 +159,7 @@ Headers:
``` ```
#### 登出 #### 登出
``` ```
POST /web/user/logout POST /web/user/logout
Headers: Headers:
@ -176,12 +184,14 @@ Headers:
为便于产品/运营统一校对当前所有展示文案,项目提供静态扫描脚本,自动抽取源码中的用户可见与可感知文案并导出为 Excel。 为便于产品/运营统一校对当前所有展示文案,项目提供静态扫描脚本,自动抽取源码中的用户可见与可感知文案并导出为 Excel。
### 覆盖范围 ### 覆盖范围
- JSX 文本节点与按钮/链接文案 - JSX 文本节点与按钮/链接文案
- 属性文案:`placeholder` / `title` / `alt` / `aria-*` / `label` - 属性文案:`placeholder` / `title` / `alt` / `aria-*` / `label`
- 交互文案:`toast.*` / `message.*` / `alert` / `confirm` / `Dialog`/`Tooltip` 等常见调用 - 交互文案:`toast.*` / `message.*` / `alert` / `confirm` / `Dialog`/`Tooltip` 等常见调用
- 表单校验与错误提示:`form.setError(..., { message })`、校验链条中的 `{ message: '...' }` - 表单校验与错误提示:`form.setError(..., { message })`、校验链条中的 `{ message: '...' }`
### 运行 ### 运行
```bash ```bash
# 生成 docs/copy-audit.xlsx # 生成 docs/copy-audit.xlsx
npx ts-node scripts/extract-copy.ts # 若 ESM 运行报错,请改用下行 npx ts-node scripts/extract-copy.ts # 若 ESM 运行报错,请改用下行
@ -191,6 +201,7 @@ node scripts/extract-copy.cjs
输出文件:`docs/copy-audit.xlsx` 输出文件:`docs/copy-audit.xlsx`
### Excel 字段说明Sheet: copy ### Excel 字段说明Sheet: copy
- `route`: Next.js App Router 路由(如 `(main)/home`)或 `shared` - `route`: Next.js App Router 路由(如 `(main)/home`)或 `shared`
- `file`: 文案所在文件(相对仓库根路径) - `file`: 文案所在文件(相对仓库根路径)
- `componentOrFn`: 组件或函数名(无法解析时为文件名) - `componentOrFn`: 组件或函数名(无法解析时为文件名)
@ -202,6 +213,7 @@ node scripts/extract-copy.cjs
- `notes`: 预留备注 - `notes`: 预留备注
### 说明与边界 ### 说明与边界
- 仅提取可静态分析到的硬编码字符串;运行时拼接(仅变量)无法还原将被忽略 - 仅提取可静态分析到的硬编码字符串;运行时拼接(仅变量)无法还原将被忽略
- 会过滤明显的“代码样”字符串(如过长的标识符) - 会过滤明显的“代码样”字符串(如过长的标识符)
- 扫描目录为 `src/`,忽略 `node_modules/.next/__tests__/mocks` - 扫描目录为 `src/`,忽略 `node_modules/.next/__tests__/mocks`

View File

@ -18,4 +18,4 @@
"hooks": "@/hooks" "hooks": "@/hooks"
}, },
"iconLibrary": "lucide" "iconLibrary": "lucide"
} }

View File

@ -1,9 +1,11 @@
# AI 建议回复功能重构说明 # AI 建议回复功能重构说明
## 重构日期 ## 重构日期
2025-11-17 2025-11-17
## 最新更新 ## 最新更新
2025-11-17 - 优化请求逻辑,确保每次只发送 3 次 API 请求 2025-11-17 - 优化请求逻辑,确保每次只发送 3 次 API 请求
## 问题描述 ## 问题描述
@ -28,29 +30,33 @@
#### 状态简化 #### 状态简化
**重构前:** **重构前:**
```typescript ```typescript
const [allSuggestions, setAllSuggestions] = useState<ReplySuggestion[]>([]); const [allSuggestions, setAllSuggestions] = useState<ReplySuggestion[]>([])
const [loadedPages, setLoadedPages] = useState<Set<number>>(new Set([1])); const [loadedPages, setLoadedPages] = useState<Set<number>>(new Set([1]))
const [isPageLoading, setIsPageLoading] = useState(false); const [isPageLoading, setIsPageLoading] = useState(false)
const [isRequesting, setIsRequesting] = useState(false); const [isRequesting, setIsRequesting] = useState(false)
// ... 其他状态 // ... 其他状态
``` ```
**重构后:** **重构后:**
```typescript ```typescript
const [pageData, setPageData] = useState<Map<number, ReplySuggestion[]>>(new Map()); const [pageData, setPageData] = useState<Map<number, ReplySuggestion[]>>(new Map())
const [loadingPages, setLoadingPages] = useState<Set<number>>(new Set()); const [loadingPages, setLoadingPages] = useState<Set<number>>(new Set())
// ... 其他必要状态 // ... 其他必要状态
``` ```
#### 核心逻辑变化 #### 核心逻辑变化
**重构前:** **重构前:**
- `showSuggestions` 函数会根据多种条件判断是否需要获取数据 - `showSuggestions` 函数会根据多种条件判断是否需要获取数据
- 首次获取第1页数据然后静默获取第2、3页 - 首次获取第1页数据然后静默获取第2、3页
- 页面切换时复杂的加载状态判断 - 页面切换时复杂的加载状态判断
**重构后:** **重构后:**
- **每次调用 `showSuggestions` 都重置到第1页并重新获取所有数据** - **每次调用 `showSuggestions` 都重置到第1页并重新获取所有数据**
- 按页存储数据到 `Map` 中,逻辑更清晰 - 按页存储数据到 `Map` 中,逻辑更清晰
- 页面切换时自动检查并加载缺失的页面数据 - 页面切换时自动检查并加载缺失的页面数据
@ -58,12 +64,14 @@ const [loadingPages, setLoadingPages] = useState<Set<number>>(new Set());
### 2. 数据获取流程优化 ### 2. 数据获取流程优化
#### 重构前流程 #### 重构前流程
1. 获取第1页 → 立即展示 1. 获取第1页 → 立即展示
2. 静默获取第2页 → 更新 `loadedPages` 2. 静默获取第2页 → 更新 `loadedPages`
3. 静默获取第3页 → 更新 `loadedPages` 3. 静默获取第3页 → 更新 `loadedPages`
4. 用户切换页面时检查 `loadedPages` 4. 用户切换页面时检查 `loadedPages`
#### 重构后流程 #### 重构后流程
1. 清空旧数据 1. 清空旧数据
2. 获取第1页 → 存入 `pageData.set(1, ...)` 2. 获取第1页 → 存入 `pageData.set(1, ...)`
3. 获取第2页 → 存入 `pageData.set(2, ...)` 3. 获取第2页 → 存入 `pageData.set(2, ...)`
@ -73,19 +81,22 @@ const [loadingPages, setLoadingPages] = useState<Set<number>>(new Set());
### 3. 骨架屏显示逻辑 ### 3. 骨架屏显示逻辑
**重构前:** **重构前:**
```typescript ```typescript
const displaySuggestions = isCurrentPageLoaded ? suggestions : Array.from({ length: 3 }, ...); const displaySuggestions = isCurrentPageLoaded ? suggestions : Array.from({ length: 3 }, ...);
``` ```
**重构后:** **重构后:**
```typescript ```typescript
const suggestions = isCurrentPageLoading || !currentPageSuggestions const suggestions =
? Array.from({ length: 3 }, (_, index) => ({ isCurrentPageLoading || !currentPageSuggestions
id: `skeleton-${currentPage}-${index}`, ? Array.from({ length: 3 }, (_, index) => ({
text: '', id: `skeleton-${currentPage}-${index}`,
isSkeleton: true text: '',
})) isSkeleton: true,
: currentPageSuggestions; }))
: currentPageSuggestions
``` ```
UI 组件根据 `isSkeleton` 标志渲染骨架屏或真实内容。 UI 组件根据 `isSkeleton` 标志渲染骨架屏或真实内容。
@ -93,6 +104,7 @@ UI 组件根据 `isSkeleton` 标志渲染骨架屏或真实内容。
### 4. UI 组件更新 ### 4. UI 组件更新
`AiReplySuggestions.tsx` 更新: `AiReplySuggestions.tsx` 更新:
- 添加 `isSkeleton` 字段到 `ReplySuggestion` 接口 - 添加 `isSkeleton` 字段到 `ReplySuggestion` 接口
- 检查 `suggestions.some(s => s.isSkeleton)` 决定是否显示骨架屏 - 检查 `suggestions.some(s => s.isSkeleton)` 决定是否显示骨架屏
- 骨架屏添加 `animate-pulse` 动画效果 - 骨架屏添加 `animate-pulse` 动画效果
@ -100,22 +112,26 @@ UI 组件根据 `isSkeleton` 标志渲染骨架屏或真实内容。
## 重构优势 ## 重构优势
### 1. **简化的状态管理** ### 1. **简化的状态管理**
- 使用 `Map<number, ReplySuggestion[]>` 按页存储数据,结构清晰 - 使用 `Map<number, ReplySuggestion[]>` 按页存储数据,结构清晰
- 移除了 `isRequesting`、`isPageLoading`、`isCurrentPageLoaded` 等冗余状态 - 移除了 `isRequesting`、`isPageLoading`、`isCurrentPageLoaded` 等冗余状态
- 只需维护 `loadingPages: Set<number>` 来跟踪正在加载的页面 - 只需维护 `loadingPages: Set<number>` 来跟踪正在加载的页面
### 2. **智能的数据管理** ### 2. **智能的数据管理**
- **有新 AI 消息时**重置到第1页并重新获取数据 - **有新 AI 消息时**重置到第1页并重新获取数据
- **没有新消息时**:保留之前的建议内容和当前页面位置 - **没有新消息时**:保留之前的建议内容和当前页面位置
- 避免了"空白建议"的问题 - 避免了"空白建议"的问题
- 数据加载状态清晰可见(骨架屏动画) - 数据加载状态清晰可见(骨架屏动画)
### 3. **更可靠的数据加载** ### 3. **更可靠的数据加载**
- 智能判断是否需要重新获取数据(基于最后一条 AI 消息 ID - 智能判断是否需要重新获取数据(基于最后一条 AI 消息 ID
- 首次打开或数据为空时自动获取 - 首次打开或数据为空时自动获取
- 减少了因状态不同步导致的 bug - 减少了因状态不同步导致的 bug
### 4. **更好的代码可维护性** ### 4. **更好的代码可维护性**
- 代码从 312 行减少到 200 行 - 代码从 312 行减少到 200 行
- 逻辑流程更清晰直观 - 逻辑流程更清晰直观
- 减少了状态依赖和副作用 - 减少了状态依赖和副作用
@ -158,18 +174,19 @@ Hook 返回的接口保持不变,确保向后兼容:
```typescript ```typescript
return { return {
suggestions, // ReplySuggestion[] (包含 isSkeleton 标志) suggestions, // ReplySuggestion[] (包含 isSkeleton 标志)
currentPage, // number currentPage, // number
totalPages, // number totalPages, // number
isLoading, // boolean (只要有页面在加载就为 true) isLoading, // boolean (只要有页面在加载就为 true)
isVisible, // boolean isVisible, // boolean
showSuggestions, // () => void showSuggestions, // () => void
hideSuggestions, // () => void hideSuggestions, // () => void
handlePageChange, // (page: number) => void handlePageChange, // (page: number) => void
}; }
``` ```
移除的返回值: 移除的返回值:
- `isPageLoading` - `isPageLoading`
- `isCurrentPageLoaded` - `isCurrentPageLoaded`
- `refreshSuggestions` - `refreshSuggestions`
@ -181,6 +198,7 @@ return {
**问题**:原始实现会触发多次重复请求 **问题**:原始实现会触发多次重复请求
**解决方案** **解决方案**
1. **统一的 `fetchAllData` 函数**一次性顺序获取3页数据使用局部变量传递 `batchNo``excContentList` 1. **统一的 `fetchAllData` 函数**一次性顺序获取3页数据使用局部变量传递 `batchNo``excContentList`
2. **防重复调用保护**:在 `fetchAllData` 开始时检查 `loadingPages.size > 0`,如果已有加载则跳过 2. **防重复调用保护**:在 `fetchAllData` 开始时检查 `loadingPages.size > 0`,如果已有加载则跳过
3. **移除分页独立加载**:删除了 `fetchPageData` 函数和 `handlePageChange` 中的数据获取逻辑 3. **移除分页独立加载**:删除了 `fetchPageData` 函数和 `handlePageChange` 中的数据获取逻辑
@ -194,20 +212,20 @@ return {
fetchAllData() { fetchAllData() {
// 1. 检查防重复 // 1. 检查防重复
if (loadingPages.size > 0) return; if (loadingPages.size > 0) return;
// 2. 标记所有页面为加载中 // 2. 标记所有页面为加载中
setLoadingPages(new Set([1, 2, 3])); setLoadingPages(new Set([1, 2, 3]));
// 3. 获取第1页 → 立即展示 // 3. 获取第1页 → 立即展示
const response1 = await genSupContentV2({ aiId, excContentList: [] }); const response1 = await genSupContentV2({ aiId, excContentList: [] });
setPageData(new Map([[1, response1]])); // ✨ 立即展示第1页 setPageData(new Map([[1, response1]])); // ✨ 立即展示第1页
setLoadingPages(new Set([2, 3])); // 标记第1页已完成 setLoadingPages(new Set([2, 3])); // 标记第1页已完成
// 4. 获取第2页 → 追加展示 // 4. 获取第2页 → 追加展示
const response2 = await genSupContentV2({ aiId, batchNo, excContentList: [response1] }); const response2 = await genSupContentV2({ aiId, batchNo, excContentList: [response1] });
setPageData(prev => prev.set(2, response2)); // ✨ 追加第2页 setPageData(prev => prev.set(2, response2)); // ✨ 追加第2页
setLoadingPages(new Set([3])); // 标记第2页已完成 setLoadingPages(new Set([3])); // 标记第2页已完成
// 5. 获取第3页 → 追加展示 // 5. 获取第3页 → 追加展示
const response3 = await genSupContentV2({ aiId, batchNo, excContentList: [response1, response2] }); const response3 = await genSupContentV2({ aiId, batchNo, excContentList: [response1, response2] });
setPageData(prev => prev.set(3, response3)); // ✨ 追加第3页 setPageData(prev => prev.set(3, response3)); // ✨ 追加第3页
@ -216,6 +234,7 @@ fetchAllData() {
``` ```
**关键优势** **关键优势**
- 🚀 **第1页数据到达后立即展示**,用户无需等待所有数据 - 🚀 **第1页数据到达后立即展示**,用户无需等待所有数据
- 📊 **后续页面数据追加展示**不影响用户浏览第1页 - 📊 **后续页面数据追加展示**不影响用户浏览第1页
- ⏱️ **感知加载时间更短**,提升用户体验 - ⏱️ **感知加载时间更短**,提升用户体验
@ -235,4 +254,3 @@ fetchAllData() {
2. 测试网络慢场景:确认骨架屏正确显示 2. 测试网络慢场景:确认骨架屏正确显示
3. 测试 Coin 不足场景:确认面板正确关闭 3. 测试 Coin 不足场景:确认面板正确关闭
4. 测试新消息场景:发送消息后面板已打开时自动刷新 4. 测试新消息场景:发送消息后面板已打开时自动刷新

View File

@ -7,21 +7,27 @@
## 实现组件 ## 实现组件
### 1. AiReplySuggestions.tsx ### 1. AiReplySuggestions.tsx
主要的AI建议回复组件包含以下功能 主要的AI建议回复组件包含以下功能
- 显示多个AI建议选项 - 显示多个AI建议选项
- 支持编辑建议内容 - 支持编辑建议内容
- VIP解锁更多功能入口 - VIP解锁更多功能入口
- 分页导航控制 - 分页导航控制
### 2. useAiReplySuggestions.ts ### 2. useAiReplySuggestions.ts
状态管理Hook处理 状态管理Hook处理
- AI建议的获取和管理 - AI建议的获取和管理
- 分页逻辑 - 分页逻辑
- 面板显示/隐藏控制 - 面板显示/隐藏控制
- **自动刷新机制**当面板打开时收到新AI消息会自动刷新建议 - **自动刷新机制**当面板打开时收到新AI消息会自动刷新建议
### 3. ChatInput.tsx更新 ### 3. ChatInput.tsx更新
集成AI建议功能到聊天输入组件 集成AI建议功能到聊天输入组件
- 添加提示词按钮来触发建议面板 - 添加提示词按钮来触发建议面板
- 处理建议选择和应用 - 处理建议选择和应用
- 管理面板状态 - 管理面板状态
@ -29,12 +35,14 @@
## 设计细节 ## 设计细节
### 视觉设计 ### 视觉设计
- 遵循Figma设计稿的视觉样式 - 遵循Figma设计稿的视觉样式
- 使用毛玻璃效果和圆角设计 - 使用毛玻璃效果和圆角设计
- 渐变色彩搭配 - 渐变色彩搭配
- 响应式布局 - 响应式布局
### 交互设计 ### 交互设计
- 点击提示词按钮显示/隐藏建议面板 - 点击提示词按钮显示/隐藏建议面板
- **点击建议卡片:直接发送该建议作为消息** - **点击建议卡片:直接发送该建议作为消息**
- **点击编辑图标:将建议文案放入输入框进行编辑** - **点击编辑图标:将建议文案放入输入框进行编辑**
@ -60,22 +68,26 @@
## 核心逻辑 ## 核心逻辑
### 建议获取时机 ### 建议获取时机
- 只有当最后一条消息来自AI对方才会在打开面板时获取新建议 - 只有当最后一条消息来自AI对方才会在打开面板时获取新建议
- 如果最后一条消息来自用户,则显示之前缓存的建议或骨架屏 - 如果最后一条消息来自用户,则显示之前缓存的建议或骨架屏
- 每次检测到新的AI消息后第一次打开面板会重新获取建议 - 每次检测到新的AI消息后第一次打开面板会重新获取建议
### 骨架屏显示 ### 骨架屏显示
- **骨架屏已集成到建议弹窗内部**,不再是独立组件 - **骨架屏已集成到建议弹窗内部**,不再是独立组件
- 在API调用期间显示骨架屏提升用户体验 - 在API调用期间显示骨架屏提升用户体验
- 骨架屏固定显示2条建议的布局结构 - 骨架屏固定显示2条建议的布局结构
### 分页机制 ### 分页机制
- **API一次性返回所有建议数据**,不是分页请求 - **API一次性返回所有建议数据**,不是分页请求
- 每页显示2条建议 - 每页显示2条建议
- 根据API返回的总数自动计算页数 - 根据API返回的总数自动计算页数
- **点击左右切换只是前端切换显示,不会重新请求接口** - **点击左右切换只是前端切换显示,不会重新请求接口**
### 缓存策略 ### 缓存策略
- 建议会被缓存避免重复API调用 - 建议会被缓存避免重复API调用
- 只有检测到新的AI消息时才会清空缓存重新获取 - 只有检测到新的AI消息时才会清空缓存重新获取
- **自动刷新**当面板已打开且收到新AI消息时自动刷新建议 - **自动刷新**当面板已打开且收到新AI消息时自动刷新建议

View File

@ -15,11 +15,11 @@
## 使用方法 ## 使用方法
```tsx ```tsx
import AvatarSetting from "@/app/(main)/profile/components/AvatarSetting" import AvatarSetting from '@/app/(main)/profile/components/AvatarSetting'
function ProfilePage() { function ProfilePage() {
const [isAvatarSettingOpen, setIsAvatarSettingOpen] = useState(false) const [isAvatarSettingOpen, setIsAvatarSettingOpen] = useState(false)
const [currentAvatar, setCurrentAvatar] = useState<string>("") const [currentAvatar, setCurrentAvatar] = useState<string>('')
const handleAvatarChange = (avatarUrl: string) => { const handleAvatarChange = (avatarUrl: string) => {
setCurrentAvatar(avatarUrl) setCurrentAvatar(avatarUrl)
@ -27,7 +27,7 @@ function ProfilePage() {
} }
const handleAvatarDelete = () => { const handleAvatarDelete = () => {
setCurrentAvatar("") setCurrentAvatar('')
// 这里可以调用API删除头像 // 这里可以调用API删除头像
} }
@ -42,9 +42,7 @@ function ProfilePage() {
return ( return (
<div> <div>
{/* 触发按钮 */} {/* 触发按钮 */}
<button onClick={openAvatarSetting}> <button onClick={openAvatarSetting}>设置头像</button>
设置头像
</button>
{/* 头像设置模态框 */} {/* 头像设置模态框 */}
<AvatarSetting <AvatarSetting
@ -61,14 +59,14 @@ function ProfilePage() {
## Props ## Props
| 属性 | 类型 | 默认值 | 说明 | | 属性 | 类型 | 默认值 | 说明 |
|------|------|--------|------| | ---------------- | ----------------------------- | ----------- | ------------------- |
| `isOpen` | `boolean` | `false` | 控制模态框显示/隐藏 | | `isOpen` | `boolean` | `false` | 控制模态框显示/隐藏 |
| `onClose` | `() => void` | `undefined` | 关闭模态框的回调 | | `onClose` | `() => void` | `undefined` | 关闭模态框的回调 |
| `currentAvatar` | `string \| undefined` | `undefined` | 当前头像URL | | `currentAvatar` | `string \| undefined` | `undefined` | 当前头像URL |
| `onAvatarChange` | `(avatarUrl: string) => void` | `undefined` | 头像变更回调 | | `onAvatarChange` | `(avatarUrl: string) => void` | `undefined` | 头像变更回调 |
| `onAvatarDelete` | `() => void` | `undefined` | 头像删除回调 | | `onAvatarDelete` | `() => void` | `undefined` | 头像删除回调 |
| `className` | `string` | `undefined` | 自定义CSS类名 | | `className` | `string` | `undefined` | 自定义CSS类名 |
## 层级关系 ## 层级关系
@ -100,4 +98,4 @@ function ProfilePage() {
3. 文件上传使用原生的 `input[type="file"]` 实现 3. 文件上传使用原生的 `input[type="file"]` 实现
4. 组件会自动验证文件类型和大小 4. 组件会自动验证文件类型和大小
5. 裁剪后的图片会自动转换为圆形 5. 裁剪后的图片会自动转换为圆形
6. 裁剪弹窗的层级比头像设置模态框更高 6. 裁剪弹窗的层级比头像设置模态框更高

View File

@ -7,16 +7,19 @@
## 功能特性 ## 功能特性
### 1. 状态处理 ### 1. 状态处理
- **无等级状态**只显示AI头像和昵称 - **无等级状态**只显示AI头像和昵称
- **有等级状态**显示AI和用户双头像包含心动等级信息 - **有等级状态**显示AI和用户双头像包含心动等级信息
### 2. 视觉设计 ### 2. 视觉设计
- **双头像布局**AI头像和用户头像并排显示带有白色边框和阴影 - **双头像布局**AI头像和用户头像并排显示带有白色边框和阴影
- **多层背景装饰**:三层渐变圆形背景,从外到内颜色递进 - **多层背景装饰**:三层渐变圆形背景,从外到内颜色递进
- **心动等级徽章**:居中显示的心形徽章,包含等级数字 - **心动等级徽章**:居中显示的心形徽章,包含等级数字
- **角色信息展示**:角色名称和心动温度标签 - **角色信息展示**:角色名称和心动温度标签
### 3. 动画效果 ### 3. 动画效果
- **等级变化动画**:心形背景从大到小消失,数字等级渐变切换 - **等级变化动画**:心形背景从大到小消失,数字等级渐变切换
- **分层延迟**三层心形背景依次消失0ms, 100ms, 200ms延迟 - **分层延迟**三层心形背景依次消失0ms, 100ms, 200ms延迟
- **数字切换**:背景完全消失后,等级数字淡出淡入切换到新等级 - **数字切换**:背景完全消失后,等级数字淡出淡入切换到新等级
@ -25,8 +28,8 @@
```typescript ```typescript
interface CrushLevelAvatarProps { interface CrushLevelAvatarProps {
size?: "large" | "small"; // 头像尺寸 size?: 'large' | 'small' // 头像尺寸
showAnimation?: boolean; // 是否显示等级变化动画 showAnimation?: boolean // 是否显示等级变化动画
} }
``` ```
@ -39,9 +42,9 @@ import CrushLevelAvatar from './components/CrushLevelAvatar';
<CrushLevelAvatar /> <CrushLevelAvatar />
// 大尺寸带动画 // 大尺寸带动画
<CrushLevelAvatar <CrushLevelAvatar
size="large" size="large"
showAnimation={true} showAnimation={true}
/> />
``` ```
@ -63,10 +66,12 @@ import CrushLevelAvatar from './components/CrushLevelAvatar';
## 样式系统 ## 样式系统
### CSS 动画类 ### CSS 动画类
- `.animate-scale-down` - 缩放动画 - `.animate-scale-down` - 缩放动画
- `.animate-delay-100/200/300` - 动画延迟 - `.animate-delay-100/200/300` - 动画延迟
### 颜色设计 ### 颜色设计
- 外层背景:`from-purple-500/20` - 外层背景:`from-purple-500/20`
- 中层背景:`from-pink-500/30` - 中层背景:`from-pink-500/30`
- 内层背景:`from-magenta-500/40` - 内层背景:`from-magenta-500/40`
@ -75,12 +80,17 @@ import CrushLevelAvatar from './components/CrushLevelAvatar';
## 实现细节 ## 实现细节
### 背景装饰层 ### 背景装饰层
```tsx ```tsx
{/* 心形背景层 - 使用SVG图标 */} {
<div className={cn( /* 心形背景层 - 使用SVG图标 */
"absolute left-1/2 top-[-63px] -translate-x-1/2 w-60 h-[210px] pointer-events-none", }
isLevelChanging && showAnimation && "animate-scale-fade-out" ;<div
)}> className={cn(
'pointer-events-none absolute top-[-63px] left-1/2 h-[210px] w-60 -translate-x-1/2',
isLevelChanging && showAnimation && 'animate-scale-fade-out'
)}
>
<Image <Image
src="/icons/crushlevel_heart.svg" src="/icons/crushlevel_heart.svg"
alt="heart background" alt="heart background"
@ -91,19 +101,17 @@ import CrushLevelAvatar from './components/CrushLevelAvatar';
``` ```
### 心动等级徽章 ### 心动等级徽章
```tsx ```tsx
{/* 心形背景 + 等级数字 */} {
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 size-10 z-10"> /* 心形背景 + 等级数字 */
<Image }
src="/icons/crushlevel_heart.svg" ;<div className="absolute top-1/2 left-1/2 z-10 size-10 -translate-x-1/2 -translate-y-1/2">
alt="heart" <Image src="/icons/crushlevel_heart.svg" alt="heart" width={40} height={40} />
width={40} <div
height={40}
/>
<div
className={cn( className={cn(
"relative z-10 font-bold text-white text-base transition-all duration-300", 'relative z-10 text-base font-bold text-white transition-all duration-300',
isLevelChanging && "animate-level-change" isLevelChanging && 'animate-level-change'
)} )}
key={displayLevel} key={displayLevel}
> >
@ -113,35 +121,52 @@ import CrushLevelAvatar from './components/CrushLevelAvatar';
``` ```
### 动画触发机制 ### 动画触发机制
```tsx ```tsx
// 监听等级变化触发动画 // 监听等级变化触发动画
useEffect(() => { useEffect(() => {
if (showAnimation && heartbeatLevel && heartbeatLevel !== displayLevel) { if (showAnimation && heartbeatLevel && heartbeatLevel !== displayLevel) {
setIsLevelChanging(true); setIsLevelChanging(true)
setAnimationKey(prev => prev + 1); setAnimationKey((prev) => prev + 1)
// 背景消失后更新等级数字 // 背景消失后更新等级数字
setTimeout(() => { setTimeout(() => {
setDisplayLevel(heartbeatLevel); setDisplayLevel(heartbeatLevel)
setIsLevelChanging(false); setIsLevelChanging(false)
}, 600); // 背景消失动画时长 }, 600) // 背景消失动画时长
} }
}, [heartbeatLevel, showAnimation, displayLevel]); }, [heartbeatLevel, showAnimation, displayLevel])
``` ```
### CSS 动画定义 ### CSS 动画定义
```css ```css
/* 心形背景消失动画 */ /* 心形背景消失动画 */
@keyframes scale-fade-out { @keyframes scale-fade-out {
0% { transform: scale(1); opacity: 1; } 0% {
100% { transform: scale(0.3); opacity: 0; } transform: scale(1);
opacity: 1;
}
100% {
transform: scale(0.3);
opacity: 0;
}
} }
/* 等级数字变化动画 */ /* 等级数字变化动画 */
@keyframes level-change { @keyframes level-change {
0% { opacity: 1; transform: scale(1); } 0% {
50% { opacity: 0; transform: scale(0.8); } opacity: 1;
100% { opacity: 1; transform: scale(1); } transform: scale(1);
}
50% {
opacity: 0;
transform: scale(0.8);
}
100% {
opacity: 1;
transform: scale(1);
}
} }
``` ```
@ -158,9 +183,11 @@ useEffect(() => {
可以通过访问 `/test-crush-level-avatar` 页面查看组件的不同状态和配置效果。 可以通过访问 `/test-crush-level-avatar` 页面查看组件的不同状态和配置效果。
### 调试功能 ### 调试功能
在浏览器控制台中可以调用以下函数来手动触发动画: 在浏览器控制台中可以调用以下函数来手动触发动画:
```javascript ```javascript
window.triggerLevelAnimation(); // 触发等级变化动画 window.triggerLevelAnimation() // 触发等级变化动画
``` ```
## 更新历史 ## 更新历史

View File

@ -6,399 +6,399 @@ This document outlines the design tokens defined in the `Tokens.xlsx` file, incl
The `Global tokens` sheet defines foundational design tokens, including colors, transparency, angles, typography, ratios, radii, borders, spacing, and breakpoints. These serve as the base values referenced by other tokens. The `Global tokens` sheet defines foundational design tokens, including colors, transparency, angles, typography, ratios, radii, borders, spacing, and breakpoints. These serve as the base values referenced by other tokens.
| Type | Token | Value | 移动端约定值 | 有调整会标黄 | 新增会标蓝 | 可不录入的文字会变红 | | Type | Token | Value | 移动端约定值 | 有调整会标黄 | 新增会标蓝 | 可不录入的文字会变红 |
|-------------|--------------------------------|----------------------------------------|--------------|--------------|------------|----------------------| | ----------- | -------------------------- | ---------------------- | ------------ | ------------ | ---------- | -------------------- |
| color | glo.color.orange.0 | #FFECDE | | | | | | color | glo.color.orange.0 | #FFECDE | | | | |
| | glo.color.orange.10 | #FFD7B8 | | | | | | | glo.color.orange.10 | #FFD7B8 | | | | |
| | glo.color.orange.20 | #FFBF8F | | | | | | | glo.color.orange.20 | #FFBF8F | | | | |
| | glo.color.orange.30 | #FFA264 | | | | | | | glo.color.orange.30 | #FFA264 | | | | |
| | glo.color.orange.40 | #FD8239 | | | | | | | glo.color.orange.40 | #FD8239 | | | | |
| | glo.color.orange.50 | #F25E0F | | | | | | | glo.color.orange.50 | #F25E0F | | | | |
| | glo.color.orange.60 | #D04500 | | | | | | | glo.color.orange.60 | #D04500 | | | | |
| | glo.color.orange.70 | #A83400 | | | | | | | glo.color.orange.70 | #A83400 | | | | |
| | glo.color.orange.80 | #7B2300 | | | | | | | glo.color.orange.80 | #7B2300 | | | | |
| | glo.color.orange.90 | #4D1400 | | | | | | | glo.color.orange.90 | #4D1400 | | | | |
| | glo.color.yellow.0 | #FFF8DE | | | | | | | glo.color.yellow.0 | #FFF8DE | | | | |
| | glo.color.yellow.10 | #FFEFB3 | | | | | | | glo.color.yellow.10 | #FFEFB3 | | | | |
| | glo.color.yellow.20 | #FFE386 | | | | | | | glo.color.yellow.20 | #FFE386 | | | | |
| | glo.color.yellow.30 | #FCD258 | | | | | | | glo.color.yellow.30 | #FCD258 | | | | |
| | glo.color.yellow.40 | #F3BC2A | | | | | | | glo.color.yellow.40 | #F3BC2A | | | | |
| | glo.color.yellow.50 | #E6A100 | | | | | | | glo.color.yellow.50 | #E6A100 | | | | |
| | glo.color.yellow.60 | #C78800 | | | | | | | glo.color.yellow.60 | #C78800 | | | | |
| | glo.color.yellow.70 | #A26B00 | | | | | | | glo.color.yellow.70 | #A26B00 | | | | |
| | glo.color.yellow.80 | #784D00 | | | | | | | glo.color.yellow.80 | #784D00 | | | | |
| | glo.color.yellow.90 | #4D2F00 | | | | | | | glo.color.yellow.90 | #4D2F00 | | | | |
| | glo.color.grass.0 | #F8FFDE | | | | | | | glo.color.grass.0 | #F8FFDE | | | | |
| | glo.color.grass.10 | #EDFCB8 | | | | | | | glo.color.grass.10 | #EDFCB8 | | | | |
| | glo.color.grass.20 | #E0F68F | | | | | | | glo.color.grass.20 | #E0F68F | | | | |
| | glo.color.grass.30 | #CFED67 | | | | | | | glo.color.grass.30 | #CFED67 | | | | |
| | glo.color.grass.40 | #BAE041 | | | | | | | glo.color.grass.40 | #BAE041 | | | | |
| | glo.color.grass.50 | #A0CD1E | | | | | | | glo.color.grass.50 | #A0CD1E | | | | |
| | glo.color.grass.60 | #82B500 | | | | | | | glo.color.grass.60 | #82B500 | | | | |
| | glo.color.grass.70 | #689600 | | | | | | | glo.color.grass.70 | #689600 | | | | |
| | glo.color.grass.80 | #4B7200 | | | | | | | glo.color.grass.80 | #4B7200 | | | | |
| | glo.color.grass.90 | #304D00 | | | | | | | glo.color.grass.90 | #304D00 | | | | |
| | glo.color.green.0 | #DEFFE7 | | | | | | | glo.color.green.0 | #DEFFE7 | | | | |
| | glo.color.green.10 | #B9FCCD | | | | | | | glo.color.green.10 | #B9FCCD | | | | |
| | glo.color.green.20 | #94F7B1 | | | | | | | glo.color.green.20 | #94F7B1 | | | | |
| | glo.color.green.30 | #6FEE96 | | | | | | | glo.color.green.30 | #6FEE96 | | | | |
| | glo.color.green.40 | #4AE27B | | | | | | | glo.color.green.40 | #4AE27B | | | | |
| | glo.color.green.50 | #28D061 | | | | | | | glo.color.green.50 | #28D061 | | | | |
| | glo.color.green.60 | #0BB84A | | | | | | | glo.color.green.60 | #0BB84A | | | | |
| | glo.color.green.70 | #00983C | | | | | | | glo.color.green.70 | #00983C | | | | |
| | glo.color.green.80 | #007331 | | | | | | | glo.color.green.80 | #007331 | | | | |
| | glo.color.green.90 | #004D22 | | | | | | | glo.color.green.90 | #004D22 | | | | |
| | glo.color.mint.0 | #DEFFF8 | | | | | | | glo.color.mint.0 | #DEFFF8 | | | | |
| | glo.color.mint.10 | #B6FBED | | | | | | | glo.color.mint.10 | #B6FBED | | | | |
| | glo.color.mint.20 | #8DF3E2 | | | | | | | glo.color.mint.20 | #8DF3E2 | | | | |
| | glo.color.mint.30 | #65E9D5 | | | | | | | glo.color.mint.30 | #65E9D5 | | | | |
| | glo.color.mint.40 | #3FDAC4 | | | | | | | glo.color.mint.40 | #3FDAC4 | | | | |
| | glo.color.mint.50 | #1DC7B0 | | | | | | | glo.color.mint.50 | #1DC7B0 | | | | |
| | glo.color.mint.60 | #00AD96 | | | | | | | glo.color.mint.60 | #00AD96 | | | | |
| | glo.color.mint.70 | #009182 | | | | | | | glo.color.mint.70 | #009182 | | | | |
| | glo.color.mint.80 | #006F67 | | | | | | | glo.color.mint.80 | #006F67 | | | | |
| | glo.color.mint.90 | #004D49 | | | | | | | glo.color.mint.90 | #004D49 | | | | |
| | glo.color.sky.0 | #DEECFF | | | | | | | glo.color.sky.0 | #DEECFF | | | | |
| | glo.color.sky.10 | #B5D2FD | | | | | | | glo.color.sky.10 | #B5D2FD | | | | |
| | glo.color.sky.20 | #8CB5F9 | | | | | | | glo.color.sky.20 | #8CB5F9 | | | | |
| | glo.color.sky.30 | #6296F2 | | | | | | | glo.color.sky.30 | #6296F2 | | | | |
| | glo.color.sky.40 | #3A76E6 | | | | | | | glo.color.sky.40 | #3A76E6 | | | | |
| | glo.color.sky.50 | #1E58D2 | | | | | | | glo.color.sky.50 | #1E58D2 | | | | |
| | glo.color.sky.60 | #063BB8 | | | | | | | glo.color.sky.60 | #063BB8 | | | | |
| | glo.color.sky.70 | #002A98 | | | | | | | glo.color.sky.70 | #002A98 | | | | |
| | glo.color.sky.80 | #001E73 | | | | | | | glo.color.sky.80 | #001E73 | | | | |
| | glo.color.sky.90 | #00134D | | | | | | | glo.color.sky.90 | #00134D | | | | |
| | glo.color.blue.0 | #DEE0FF | | | | | | | glo.color.blue.0 | #DEE0FF | | | | |
| | glo.color.blue.10 | #BCBEFF | | | | | | | glo.color.blue.10 | #BCBEFF | | | | |
| | glo.color.blue.20 | #9797FF | | | | | | | glo.color.blue.20 | #9797FF | | | | |
| | glo.color.blue.30 | #7370FF | | | | | | | glo.color.blue.30 | #7370FF | | | | |
| | glo.color.blue.40 | #4E48FF | | | | | | | glo.color.blue.40 | #4E48FF | | | | |
| | glo.color.blue.50 | #3126E6 | | | | | | | glo.color.blue.50 | #3126E6 | | | | |
| | glo.color.blue.60 | #180AC7 | | | | | | | glo.color.blue.60 | #180AC7 | | | | |
| | glo.color.blue.70 | #0F00A2 | | | | | | | glo.color.blue.70 | #0F00A2 | | | | |
| | glo.color.blue.80 | #0D0078 | | | | | | | glo.color.blue.80 | #0D0078 | | | | |
| | glo.color.blue.90 | #09004D | | | | | | | glo.color.blue.90 | #09004D | | | | |
| | glo.color.violet.0 | #E4DEFF | | | | | | | glo.color.violet.0 | #E4DEFF | | | | |
| | glo.color.violet.10 | #C7B7FD | | | | | | | glo.color.violet.10 | #C7B7FD | | | | |
| | glo.color.violet.20 | #AA90F9 | | | | | | | glo.color.violet.20 | #AA90F9 | | | | |
| | glo.color.violet.30 | #8D68F2 | | | | | | | glo.color.violet.30 | #8D68F2 | | | | |
| | glo.color.violet.40 | #7B47FF | | | | | | | glo.color.violet.40 | #7B47FF | | | | |
| | glo.color.violet.50 | #5923D2 | | | | | | | glo.color.violet.50 | #5923D2 | | | | |
| | glo.color.violet.60 | #4309B8 | | | | | | | glo.color.violet.60 | #4309B8 | | | | |
| | glo.color.violet.70 | #340098 | | | | | | | glo.color.violet.70 | #340098 | | | | |
| | glo.color.violet.80 | #290073 | | | | | | | glo.color.violet.80 | #290073 | | | | |
| | glo.color.violet.90 | #1C004D | | | | | | | glo.color.violet.90 | #1C004D | | | | |
| | glo.color.purple.0 | #FBDEFF | | | | | | | glo.color.purple.0 | #FBDEFF | | | | |
| | glo.color.purple.10 | #F2B7FD | | | | | | | glo.color.purple.10 | #F2B7FD | | | | |
| | glo.color.purple.20 | #E690F9 | | | | | | | glo.color.purple.20 | #E690F9 | | | | |
| | glo.color.purple.30 | #D668F2 | | | | | | | glo.color.purple.30 | #D668F2 | | | | |
| | glo.color.purple.40 | #C241E6 | | | | | | | glo.color.purple.40 | #C241E6 | | | | |
| | glo.color.purple.50 | #A823D2 | | | | | | | glo.color.purple.50 | #A823D2 | | | | |
| | glo.color.purple.60 | #8A09B8 | | | | | | | glo.color.purple.60 | #8A09B8 | | | | |
| | glo.color.purple.70 | #6E0098 | | | | | | | glo.color.purple.70 | #6E0098 | | | | |
| | glo.color.purple.80 | #520073 | | | | | | | glo.color.purple.80 | #520073 | | | | |
| | glo.color.purple.90 | #36004D | | | | | | | glo.color.purple.90 | #36004D | | | | |
| | glo.color.magenta.0 | #FBDEFF | | | | | | | glo.color.magenta.0 | #FBDEFF | | | | |
| | glo.color.magenta.10 | #FDB6D3 | | | | | | | glo.color.magenta.10 | #FDB6D3 | | | | |
| | glo.color.magenta.20 | #F98DBC | | | | | | | glo.color.magenta.20 | #F98DBC | | | | |
| | glo.color.magenta.30 | #F264A4 | | | | | | | glo.color.magenta.30 | #F264A4 | | | | |
| | glo.color.magenta.40 | #E63C8B | | | | | | | glo.color.magenta.40 | #E63C8B | | | | |
| | glo.color.magenta.50 | #D21F77 | | | | | | | glo.color.magenta.50 | #D21F77 | | | | |
| | glo.color.magenta.60 | #B80761 | | | | | | | glo.color.magenta.60 | #B80761 | | | | |
| | glo.color.magenta.70 | #980050 | | | | | | | glo.color.magenta.70 | #980050 | | | | |
| | glo.color.magenta.80 | #73003E | | | | | | | glo.color.magenta.80 | #73003E | | | | |
| | glo.color.magenta.90 | #4D002A | | | | | | | glo.color.magenta.90 | #4D002A | | | | |
| | glo.color.red.0 | #FFDEDE | | | | | | | glo.color.red.0 | #FFDEDE | | | | |
| | glo.color.red.10 | #FFBCBC | | | | | | | glo.color.red.10 | #FFBCBC | | | | |
| | glo.color.red.20 | #FF9696 | | | | | | | glo.color.red.20 | #FF9696 | | | | |
| | glo.color.red.30 | #F97372 | | | | | | | glo.color.red.30 | #F97372 | | | | |
| | glo.color.red.40 | #EF4E4D | | | | | | | glo.color.red.40 | #EF4E4D | | | | |
| | glo.color.red.50 | #E12A2A | | | | | | | glo.color.red.50 | #E12A2A | | | | |
| | glo.color.red.60 | #C2110E | | | | | | | glo.color.red.60 | #C2110E | | | | |
| | glo.color.red.70 | #A00700 | | | | | | | glo.color.red.70 | #A00700 | | | | |
| | glo.color.red.80 | #770800 | | | | | | | glo.color.red.80 | #770800 | | | | |
| | glo.color.red.90 | #4D0600 | | | | | | | glo.color.red.90 | #4D0600 | | | | |
| | glo.color.grey.0 | #E8E4EB | | | | | | | glo.color.grey.0 | #E8E4EB | | | | |
| | glo.color.grey.10 | #D4D0D8 | | | | | | | glo.color.grey.10 | #D4D0D8 | | | | |
| | glo.color.grey.20 | #AAA3B1 | | | | | | | glo.color.grey.20 | #AAA3B1 | | | | |
| | glo.color.grey.30 | #958E9E | | | | | | | glo.color.grey.30 | #958E9E | | | | |
| | glo.color.grey.40 | #847D8B | | | | | | | glo.color.grey.40 | #847D8B | | | | |
| | glo.color.grey.50 | #706A78 | | | | | | | glo.color.grey.50 | #706A78 | | | | |
| | glo.color.grey.60 | #5C5565 | | | | | | | glo.color.grey.60 | #5C5565 | | | | |
| | glo.color.grey.70 | #484151 | | | | | | | glo.color.grey.70 | #484151 | | | | |
| | glo.color.grey.80 | #352E3E | | | | | | | glo.color.grey.80 | #352E3E | | | | |
| | glo.color.grey.90 | #282233 | | | | | | | glo.color.grey.90 | #282233 | | | | |
| | glo.color.grey.100 | #211A2B | | | | | | | glo.color.grey.100 | #211A2B | | | | |
| | glo.color.white | #FFFFFF | | | | | | | glo.color.white | #FFFFFF | | | | |
| | glo.color.black | #000000 | | | | | | | glo.color.black | #000000 | | | | |
| Transparent | glo.transparent.t0 | 0 | | | | | | Transparent | glo.transparent.t0 | 0 | | | | |
| | glo.transparent.t2 | 0.02 | | | | | | | glo.transparent.t2 | 0.02 | | | | |
| | glo.transparent.t4 | 0.04 | | | | | | | glo.transparent.t4 | 0.04 | | | | |
| | glo.transparent.t6 | 0.06 | | | | | | | glo.transparent.t6 | 0.06 | | | | |
| | glo.transparent.t8 | 0.08 | | | | | | | glo.transparent.t8 | 0.08 | | | | |
| | glo.transparent.t12 | 0.12 | | | | | | | glo.transparent.t12 | 0.12 | | | | |
| | glo.transparent.t15 | 0.15 | | | | | | | glo.transparent.t15 | 0.15 | | | | |
| | glo.transparent.t20 | 0.2 | | | | | | | glo.transparent.t20 | 0.2 | | | | |
| | glo.transparent.t25 | 0.25 | | | | | | | glo.transparent.t25 | 0.25 | | | | |
| | glo.transparent.t30 | 0.3 | | | | | | | glo.transparent.t30 | 0.3 | | | | |
| | glo.transparent.t45 | 0.45 | | | | | | | glo.transparent.t45 | 0.45 | | | | |
| | glo.transparent.t65 | 0.65 | | | | | | | glo.transparent.t65 | 0.65 | | | | |
| | glo.transparent.t85 | 0.85 | | | | | | | glo.transparent.t85 | 0.85 | | | | |
| Degree | glo.deg.ltr | to Right | | | | | | Degree | glo.deg.ltr | to Right | | | | |
| | glo.deg.ttb | to Bottom | | | | | | | glo.deg.ttb | to Bottom | | | | |
| | glo.deg.lttrb | to Bottom Right | | | | | | | glo.deg.lttrb | to Bottom Right | | | | |
| Typeface | glo.font.family.sys | Poppins | | | | | | Typeface | glo.font.family.sys | Poppins | | | | |
| | glo.font.family.numDisplay | DIN Alternate | | | | | | | glo.font.family.numDisplay | DIN Alternate | | | | |
| | glo.font.family.num | Poppins | | | | | | | glo.font.family.num | Poppins | | | | |
| | glo.font.family.display | Oleo Script Swash Caps | | | | | | | glo.font.family.display | Oleo Script Swash Caps | | | | |
| | glo.font.style.italic | Italic | | | | | | | glo.font.style.italic | Italic | | | | |
| | glo.font.size.64 | 64 | | | | | | | glo.font.size.64 | 64 | | | | |
| | glo.font.size.48 | 48 | | | | | | | glo.font.size.48 | 48 | | | | |
| | glo.font.size.36 | 36 | | | | | | | glo.font.size.36 | 36 | | | | |
| | glo.font.size.24 | 24 | | | | | | | glo.font.size.24 | 24 | | | | |
| | glo.font.size.20 | 20 | | | | | | | glo.font.size.20 | 20 | | | | |
| | glo.font.size.18 | 18 | | | | | | | glo.font.size.18 | 18 | | | | |
| | glo.font.size.16 | 16 | | | | | | | glo.font.size.16 | 16 | | | | |
| | glo.font.size.14 | 14 | | | | | | | glo.font.size.14 | 14 | | | | |
| | glo.font.size.12 | 12 | | | | | | | glo.font.size.12 | 12 | | | | |
| | glo.font.weight.regular | 400 | | | | | | | glo.font.weight.regular | 400 | | | | |
| | glo.font.weight.medium | 500 | | | | | | | glo.font.weight.medium | 500 | | | | |
| | glo.font.weight.semibold | 600 | | | | | | | glo.font.weight.semibold | 600 | | | | |
| | glo.font.weight.bold | 700 | | | | | | | glo.font.weight.bold | 700 | | | | |
| | glo.font.lineheight.size64 | 80px | | | | | | | glo.font.lineheight.size64 | 80px | | | | |
| | glo.font.lineheight.size48 | 56px | | | | | | | glo.font.lineheight.size48 | 56px | | | | |
| | glo.font.lineheight.size36 | 48px | | | | | | | glo.font.lineheight.size36 | 48px | | | | |
| | glo.font.lineheight.size24 | 28px | | | | | | | glo.font.lineheight.size24 | 28px | | | | |
| | glo.font.lineheight.size20 | 24px | | | | | | | glo.font.lineheight.size20 | 24px | | | | |
| | glo.font.lineheight.size18 | 24px | | | | | | | glo.font.lineheight.size18 | 24px | | | | |
| | glo.font.lineheight.size16 | 24px | | | | | | | glo.font.lineheight.size16 | 24px | | | | |
| | glo.font.lineheight.size14 | 20px | | | | | | | glo.font.lineheight.size14 | 20px | | | | |
| | glo.font.lineheight.size12 | 20px | | | | | | | glo.font.lineheight.size12 | 20px | | | | |
| | glo.font.lineheight.size0 | 1 | | | | | | | glo.font.lineheight.size0 | 1 | | | | |
| radio | glo.radio.1.1 | 1:1 | | | | | | radio | glo.radio.1.1 | 1:1 | | | | |
| | glo.radio.4.3 | 4:3 | | | | | | | glo.radio.4.3 | 4:3 | | | | |
| | glo.radio.3.2 | 3:2 | | | | | | | glo.radio.3.2 | 3:2 | | | | |
| | glo.radio.2.1 | 2:1 | | | | | | | glo.radio.2.1 | 2:1 | | | | |
| | glo.radio.16.9 | 16:9 | | | | | | | glo.radio.16.9 | 16:9 | | | | |
| radius | glo.radius.4 | 4 | | | | | | radius | glo.radius.4 | 4 | | | | |
| | glo.radius.8 | 8 | | | | | | | glo.radius.8 | 8 | | | | |
| | glo.radius.12 | 12 | | | | | | | glo.radius.12 | 12 | | | | |
| | glo.radius.16 | 16 | | | | | | | glo.radius.16 | 16 | | | | |
| | glo.radius.24 | 24 | | | | | | | glo.radius.24 | 24 | | | | |
| | glo.radius.round | 0.5 | | | | | | | glo.radius.round | 0.5 | | | | |
| border | glo.border.1 | 1px | | | | | | border | glo.border.1 | 1px | | | | |
| | glo.border.2 | 2px | | | | | | | glo.border.2 | 2px | | | | |
| | glo.border.4 | 4px | | | | | | | glo.border.4 | 4px | | | | |
| space | glo.spacing.4 | 4px | | | | | | space | glo.spacing.4 | 4px | | | | |
| | glo.spacing.8 | 8px | | | | | | | glo.spacing.8 | 8px | | | | |
| | glo.spacing.12 | 12px | | | | | | | glo.spacing.12 | 12px | | | | |
| | glo.spacing.16 | 16px | | | | | | | glo.spacing.16 | 16px | | | | |
| | glo.spacing.24 | 24px | | | | | | | glo.spacing.24 | 24px | | | | |
| | glo.spacing.32 | 32px | | | | | | | glo.spacing.32 | 32px | | | | |
| | glo.spacing.48 | 48px | | | | | | | glo.spacing.48 | 48px | | | | |
| | glo.spacing.64 | 64px | | | | | | | glo.spacing.64 | 64px | | | | |
| | glo.spacing.80 | 80px | | | | | | | glo.spacing.80 | 80px | | | | |
| | glo.spacing.128 | 128px | | | | | | | glo.spacing.128 | 128px | | | | |
| breackpoint | breackpoint.xs | <768px | | | | | | breackpoint | breackpoint.xs | <768px | | | | |
| | breackpoint.s | ≥768px | | | | | | | breackpoint.s | ≥768px | | | | |
| | breackpoint.m | ≥1024px | | | | | | | breackpoint.m | ≥1024px | | | | |
| | breackpoint.l | ≥1280px | | | | | | | breackpoint.l | ≥1280px | | | | |
| | breackpoint.xl | ≥1536 | | | | | | | breackpoint.xl | ≥1536 | | | | |
## Web System Tokens ## Web System Tokens
The `Web sys tokens` sheet defines tokens specifically for web use, including colors, typography, shadows, radii, and borders. These are intended for integration with Tailwind CSS, particularly for color definitions in dark and light themes. The `Web sys tokens` sheet defines tokens specifically for web use, including colors, typography, shadows, radii, and borders. These are intended for integration with Tailwind CSS, particularly for color definitions in dark and light themes.
| Type | | Token | Value@Dark Theme(Default) | Value on White | | Type | | Token | Value@Dark Theme(Default) | Value on White |
|-----------------|---------------|------------------------------------------|-------------------------------------------------------------------------------------------|---------------------------------------------| | ------------ | ---------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -------------------- |
| color | primary | color.primary.normal | $glo.color.magenta.50 | | | color | primary | color.primary.normal | $glo.color.magenta.50 | |
| | | color.primary.hover | $glo.color.magenta.40 | | | | | color.primary.hover | $glo.color.magenta.40 | |
| | | color.primary.press | $glo.color.magenta.60 | | | | | color.primary.press | $glo.color.magenta.60 | |
| | | color.primary.disabled | $color.surface.nest.disabled | | | | | color.primary.disabled | $color.surface.nest.disabled | |
| | | color.primary.variant.normal | $glo.color.magenta.40 | | | | | color.primary.variant.normal | $glo.color.magenta.40 | |
| | | color.primary.variant.hover | $glo.color.magenta.30 | | | | | color.primary.variant.hover | $glo.color.magenta.30 | |
| | | color.primary.variant.press | $glo.color.magenta.50 | | | | | color.primary.variant.press | $glo.color.magenta.50 | |
| | | color.primary.variant.disabled | $color.surface.nest.disabled | | | | | color.primary.variant.disabled | $color.surface.nest.disabled | |
| | | color.primary.gradient.normal | linear-gradient($glo.deg.ltr,$glo.color.magenta.30,$glo.color.purple.40) | | | | | color.primary.gradient.normal | linear-gradient($glo.deg.ltr,$glo.color.magenta.30,$glo.color.purple.40) | |
| | | color.primary.gradient.hover | linear-gradient($glo.deg.ltr,$glo.color.magenta.20,$glo.color.purple.30) | | | | | color.primary.gradient.hover | linear-gradient($glo.deg.ltr,$glo.color.magenta.20,$glo.color.purple.30) | |
| | | color.primary.gradient.press | linear-gradient($glo.deg.ltr,$glo.color.magenta.40,$glo.color.purple.50) | | | | | color.primary.gradient.press | linear-gradient($glo.deg.ltr,$glo.color.magenta.40,$glo.color.purple.50) | |
| | | color.primary.gradient.disabled | $color.surface.nest.disabled | | | | | color.primary.gradient.disabled | $color.surface.nest.disabled | |
| | | color.primary.onpic.normal | $glo.color.violet.40 | $glo.transparent.t85 | | | | color.primary.onpic.normal | $glo.color.violet.40 | $glo.transparent.t85 |
| | | color.primary.onpic.hover | $glo.color.violet.30 | $glo.transparent.t85 | | | | color.primary.onpic.hover | $glo.color.violet.30 | $glo.transparent.t85 |
| | | color.primary.onpic.press | $glo.color.violet.50 | $glo.transparent.t85 | | | | color.primary.onpic.press | $glo.color.violet.50 | $glo.transparent.t85 |
| | Important | color.important.normal | $glo.color.red.50 | | | | Important | color.important.normal | $glo.color.red.50 | |
| | | color.important.hover | $glo.color.red.40 | | | | | color.important.hover | $glo.color.red.40 | |
| | | color.important.press | $glo.color.red.60 | | | | | color.important.press | $glo.color.red.60 | |
| | | color.important.disabled | $color.surface.nest.disabled | | | | | color.important.disabled | $color.surface.nest.disabled | |
| | | color.important.variant.normal | $glo.color.red.40 | | | | | color.important.variant.normal | $glo.color.red.40 | |
| | | color.important.variant.hover | $glo.color.red.30 | | | | | color.important.variant.hover | $glo.color.red.30 | |
| | | color.important.variant.press | $glo.color.red.50 | | | | | color.important.variant.press | $glo.color.red.50 | |
| | | color.important.variant.disabled | $glo.color.blue.10 | $glo.transparent.t25 | | | | color.important.variant.disabled | $glo.color.blue.10 | $glo.transparent.t25 |
| | | color.important.gradient.normal | sha | | | | | color.important.gradient.normal | sha | |
| | | color.important.gradient.hover | linear-gradient($glo.deg.ltr,$glo.color.orange.40,$glo.color.red.40) | | | | | color.important.gradient.hover | linear-gradient($glo.deg.ltr,$glo.color.orange.40,$glo.color.red.40) | |
| | | color.important.gradient.press | linear-gradient($glo.deg.ltr,$glo.color.orange.60,$glo.color.red.60) | | | | | color.important.gradient.press | linear-gradient($glo.deg.ltr,$glo.color.orange.60,$glo.color.red.60) | |
| | | color.important.gradient.disabled | $color.surface.nest.disabled | | | | | color.important.gradient.disabled | $color.surface.nest.disabled | |
| | | color.important.onpic.normal | $glo.color.red.50 | $glo.transparent.t85 | | | | color.important.onpic.normal | $glo.color.red.50 | $glo.transparent.t85 |
| | positive | color.positive.normal | $glo.color.mint.60 | | | | positive | color.positive.normal | $glo.color.mint.60 | |
| | | color.positive.hover | $glo.color.mint.50 | | | | | color.positive.hover | $glo.color.mint.50 | |
| | | color.positive.press | $glo.color.mint.70 | | | | | color.positive.press | $glo.color.mint.70 | |
| | | color.positive.disabled | $color.surface.nest.disabled | | | | | color.positive.disabled | $color.surface.nest.disabled | |
| | | color.positive.variant.normal | $glo.color.mint.40 | | | | | color.positive.variant.normal | $glo.color.mint.40 | |
| | | color.positive.variant.hover | $glo.color.mint.30 | | | | | color.positive.variant.hover | $glo.color.mint.30 | |
| | | color.positive.variant.press | $glo.color.mint.50 | | | | | color.positive.variant.press | $glo.color.mint.50 | |
| | | color.positive.variant.disabled | $glo.color.blue.10 | $glo.transparent.t25 | | | | color.positive.variant.disabled | $glo.color.blue.10 | $glo.transparent.t25 |
| | | color.positive.gradient.normal | linear-gradient($glo.deg.ltr,$glo.color.green.40,$glo.color.mint.60) | | | | | color.positive.gradient.normal | linear-gradient($glo.deg.ltr,$glo.color.green.40,$glo.color.mint.60) | |
| | | color.positive.gradient.hover | linear-gradient($glo.deg.ltr,$glo.color.green.40,$glo.color.mint.50) | | | | | color.positive.gradient.hover | linear-gradient($glo.deg.ltr,$glo.color.green.40,$glo.color.mint.50) | |
| | | color.positive.gradient.press | | | | | | color.positive.gradient.press | | |
| | | color.positive.gradient.disabled | $color.surface.nest.disabled | | | | | color.positive.gradient.disabled | $color.surface.nest.disabled | |
| | | color.positive.onpic.normal | $glo.color.mint.60 | $glo.transparent.t85 | | | | color.positive.onpic.normal | $glo.color.mint.60 | $glo.transparent.t85 |
| | warning | color.warning.normal | $glo.color.orange.50 | | | | warning | color.warning.normal | $glo.color.orange.50 | |
| | | color.warning.hover | $glo.color.orange.40 | | | | | color.warning.hover | $glo.color.orange.40 | |
| | | color.warning.press | $glo.color.orange.60 | | | | | color.warning.press | $glo.color.orange.60 | |
| | | color.warning.disabled | $color.surface.nest.disabled | | | | | color.warning.disabled | $color.surface.nest.disabled | |
| | | color.warning.variant.normal | $glo.color.orange.40 | | | | | color.warning.variant.normal | $glo.color.orange.40 | |
| | | color.warning.variant.hover | $glo.color.orange.30 | | | | | color.warning.variant.hover | $glo.color.orange.30 | |
| | | color.warning.variant.press | $glo.color.orange.50 | | | | | color.warning.variant.press | $glo.color.orange.50 | |
| | | color.warning.variant.disabled | $glo.color.blue.10 | $glo.transparent.t25 | | | | color.warning.variant.disabled | $glo.color.blue.10 | $glo.transparent.t25 |
| | | color.warning.gradient.normal | linear-gradient($glo.deg.ltr,$glo.color.yellow.40,$glo.color.orange.50) | | | | | color.warning.gradient.normal | linear-gradient($glo.deg.ltr,$glo.color.yellow.40,$glo.color.orange.50) | |
| | | color.warning.gradient.hover | linear-gradient($glo.deg.ltr,$glo.color.yellow.30,$glo.color.orange40) | | | | | color.warning.gradient.hover | linear-gradient($glo.deg.ltr,$glo.color.yellow.30,$glo.color.orange40) | |
| | | color.warning.gradient.press | linear-gradient($glo.deg.ltr,$glo.color.yellow.50,$glo.color.orange.60) | | | | | color.warning.gradient.press | linear-gradient($glo.deg.ltr,$glo.color.yellow.50,$glo.color.orange.60) | |
| | | color.warning.gradient.disabled | $color.surface.nest.disabled | | | | | color.warning.gradient.disabled | $color.surface.nest.disabled | |
| | | color.warning.onpic.normal | $glo.color.orange.50 | $glo.transparent.t85 | | | | color.warning.onpic.normal | $glo.color.orange.50 | $glo.transparent.t85 |
| | emphasis | color.emphasis.normal | $glo.color.blue.40 | | | | emphasis | color.emphasis.normal | $glo.color.blue.40 | |
| | | color.emphasis.hover | $glo.color.blue.30 | | | | | color.emphasis.hover | $glo.color.blue.30 | |
| | | color.emphasis.press | $glo.color.blue.50 | | | | | color.emphasis.press | $glo.color.blue.50 | |
| | | color.emphasis.disabled | $color.surface.nest.disabled | | | | | color.emphasis.disabled | $color.surface.nest.disabled | |
| | | color.emphasis.variant.normal | $glo.color.blue.30 | | | | | color.emphasis.variant.normal | $glo.color.blue.30 | |
| | | color.emphasis.variant.hover | $glo.color.blue.20 | | | | | color.emphasis.variant.hover | $glo.color.blue.20 | |
| | | color.emphasis.variant.press | $glo.color.blue.40 | | | | | color.emphasis.variant.press | $glo.color.blue.40 | |
| | | color.emphasis.variant.disabled | $glo.color.blue.10 | $glo.transparent.t25 | | | | color.emphasis.variant.disabled | $glo.color.blue.10 | $glo.transparent.t25 |
| | | color.emphasis.gradient.normal | linear-gradient($glo.deg.ltr,$glo.color.sky.30,$glo.color.blue.40) | | | | | color.emphasis.gradient.normal | linear-gradient($glo.deg.ltr,$glo.color.sky.30,$glo.color.blue.40) | |
| | | color.emphasis.gradient.hover | linear-gradient($glo.deg.ltr,$glo.color.sky.20,$glo.color.blue.30) | | | | | color.emphasis.gradient.hover | linear-gradient($glo.deg.ltr,$glo.color.sky.20,$glo.color.blue.30) | |
| | | color.emphasis.gradient.press | linear-gradient($glo.deg.ltr,$glo.color.sky.40,$glo.color.blue.50) | | | | | color.emphasis.gradient.press | linear-gradient($glo.deg.ltr,$glo.color.sky.40,$glo.color.blue.50) | |
| | | color.emphasis.gradient.disabled | $color.surface.nest.disabled | | | | | color.emphasis.gradient.disabled | $color.surface.nest.disabled | |
| | | color.emphasis.onpic.normal | $glo.color.blue.40 | $glo.transparent.t85 | | | | color.emphasis.onpic.normal | $glo.color.blue.40 | $glo.transparent.t85 |
| | Background | color.background.default | $glo.color.grey.100 | | | | Background | color.background.default | $glo.color.grey.100 | |
| | | color.background.specialmap | $glo.color.grey.100 | | | | | color.background.specialmap | $glo.color.grey.100 | |
| | | color.background.district | $glo.color.black | $glo.transparent.t30 | | | | color.background.district | $glo.color.black | $glo.transparent.t30 |
| | Surface | color.surface.base.normal | $glo.color.grey.80 | | | | Surface | color.surface.base.normal | $glo.color.grey.80 | |
| | | color.surface.base.hover | $glo.color.grey.70 | | | | | color.surface.base.hover | $glo.color.grey.70 | |
| | | color.surface.base.press | $glo.color.grey.90 | | | | | color.surface.base.press | $glo.color.grey.90 | |
| | | color.surface.base.disabled | $glo.color.grey.90 | | | | | color.surface.base.disabled | $glo.color.grey.90 | |
| | | color.surface.base.specialmap.normal | $glo.color.grey.80 | | | | | color.surface.base.specialmap.normal | $glo.color.grey.80 | |
| | | color.surface.base.specialmap.hover | $glo.color.grey.70 | | | | | color.surface.base.specialmap.hover | $glo.color.grey.70 | |
| | | color.surface.base.specialmap.press | $glo.color.grey.90 | $glo.transparent.t30 | | | | color.surface.base.specialmap.press | $glo.color.grey.90 | $glo.transparent.t30 |
| | | color.surface.base.specialmap.disabled | $glo.color.white | $glo.transparent.t8 | | | | color.surface.base.specialmap.disabled | $glo.color.white | $glo.transparent.t8 |
| | | color.surface.float.normal | $glo.color.grey.70 | | | | | color.surface.float.normal | $glo.color.grey.70 | |
| | | color.surface.float.hover | $glo.color.grey.60 | | | | | color.surface.float.hover | $glo.color.grey.60 | |
| | | color.surface.float.press | $glo.color.grey.80 | | | | | color.surface.float.press | $glo.color.grey.80 | |
| | | color.surface.float.disabled | $glo.color.grey.80 | | | | | color.surface.float.disabled | $glo.color.grey.80 | |
| | | color.surface.top.normal | $glo.color.black | $glo.transparent.t65 | | | | color.surface.top.normal | $glo.color.black | $glo.transparent.t65 |
| | | color.surface.top.hover | $glo.color.black | $glo.transparent.t45 | | | | color.surface.top.hover | $glo.color.black | $glo.transparent.t45 |
| | | color.surface.top.press | $glo.color.black | $glo.transparent.t85 | | | | color.surface.top.press | $glo.color.black | $glo.transparent.t85 |
| | | color.surface.top.disabled | $glo.color.black | $glo.transparent.t30 | | | | color.surface.top.disabled | $glo.color.black | $glo.transparent.t30 |
| | | color.surface.district.normal | $glo.color.purple.0 | $glo.transparent.t4 | | | | color.surface.district.normal | $glo.color.purple.0 | $glo.transparent.t4 |
| | | color.surface.district.hover | $glo.color.purple.0 | $glo.transparent.t12 | | | | color.surface.district.hover | $glo.color.purple.0 | $glo.transparent.t12 |
| | | color.surface.district.press | $glo.color.black | $glo.transparent.t25 | | | | color.surface.district.press | $glo.color.black | $glo.transparent.t25 |
| | | color.surface.district.disabled | $glo.color.black | $glo.transparent.t25 | | | | color.surface.district.disabled | $glo.color.black | $glo.transparent.t25 |
| | | color.surface.nest.normal | $glo.color.purple.0 | $glo.transparent.t8 | | | | color.surface.nest.normal | $glo.color.purple.0 | $glo.transparent.t8 |
| | | color.surface.nest.hover | $glo.color.purple.0 | $glo.transparent.t12 | | | | color.surface.nest.hover | $glo.color.purple.0 | $glo.transparent.t12 |
| | | color.surface.nest.press | $glo.color.purple.0 | $glo.transparent.t4 | | | | color.surface.nest.press | $glo.color.purple.0 | $glo.transparent.t4 |
| | | color.surface.nest.disabled | $glo.color.purple.0 | $glo.transparent.t4 | | | | color.surface.nest.disabled | $glo.color.purple.0 | $glo.transparent.t4 |
| | | color.surface.element.normal | $color.surface.nest.normal | | | | | color.surface.element.normal | $color.surface.nest.normal | |
| | | color.surface.element.hover | $color.surface.nest.hover | | | | | color.surface.element.hover | $color.surface.nest.hover | |
| | | color.surface.element.press | $color.surface.nest.press | | | | | color.surface.element.press | $color.surface.nest.press | |
| | | color.surface.element.disabled | $color.surface.nest.disabled | | | | | color.surface.element.disabled | $color.surface.nest.disabled | |
| | | color.surface.element.dark.normal | $glo.color.black | $glo.transparent.t65 | | | | color.surface.element.dark.normal | $glo.color.black | $glo.transparent.t65 |
| | | color.surface.element.dark.hover | $glo.color.black | $glo.transparent.t45 | | | | color.surface.element.dark.hover | $glo.color.black | $glo.transparent.t45 |
| | | color.surface.element.dark.press | $glo.color.black | $glo.transparent.t85 | | | | color.surface.element.dark.press | $glo.color.black | $glo.transparent.t85 |
| | | color.surface.element.dark.disabled | $glo.color.black | $glo.transparent.t45 | | | | color.surface.element.dark.disabled | $glo.color.black | $glo.transparent.t45 |
| | | color.surface.element.light.normal | $glo.color.white | $glo.transparent.t15 | | | | color.surface.element.light.normal | $glo.color.white | $glo.transparent.t15 |
| | | color.surface.element.light.hover | $glo.color.white | $glo.transparent.t25 | | | | color.surface.element.light.hover | $glo.color.white | $glo.transparent.t25 |
| | | color.surface.element.light.press | $glo.color.white | $glo.transparent.t8 | | | | color.surface.element.light.press | $glo.color.white | $glo.transparent.t8 |
| | | color.surface.element.light.disabled | $glo.color.white | $glo.transparent.t8 | | | | color.surface.element.light.disabled | $glo.color.white | $glo.transparent.t8 |
| | | color.surface.white.normal | $glo.color.white | | | | | color.surface.white.normal | $glo.color.white | |
| | | color.surface.white.hover | $glo.color.white | $glo.transparent.t85 | | | | color.surface.white.hover | $glo.color.white | $glo.transparent.t85 |
| | | color.surface.white.press | $glo.color.white | $glo.transparent.t65 | | | | color.surface.white.press | $glo.color.white | $glo.transparent.t65 |
| | | color.surface.white.disabled | $glo.color.white | $glo.transparent.t45 | | | | color.surface.white.disabled | $glo.color.white | $glo.transparent.t45 |
| | | color.surface.black.normal | $glo.color.black | | | | | color.surface.black.normal | $glo.color.black | |
| | Outline | color.outline.normal | $glo.color.purple.0 | $glo.transparent.t20 | | | Outline | color.outline.normal | $glo.color.purple.0 | $glo.transparent.t20 |
| | | color.outline.hover | $glo.color.purple.0 | $glo.transparent.t30 | | | | color.outline.hover | $glo.color.purple.0 | $glo.transparent.t30 |
| | | color.outline.press | $glo.color.purple.0 | $glo.transparent.t8 | | | | color.outline.press | $glo.color.purple.0 | $glo.transparent.t8 |
| | | color.outline.disabled | $color.surface.element.disabled | | | | | color.outline.disabled | $color.surface.element.disabled | |
| | Overlay | color.overlay.primary | $glo.color.violet.30 | $glo.transparent.t30 | | | Overlay | color.overlay.primary | $glo.color.violet.30 | $glo.transparent.t30 |
| | | color.overlay.gradient | linear-gradient($glo.deg.ttb,$glo.color.black $glo.transparent.t0,$glo.color.black $glo.transparent.t100) | | | | | color.overlay.gradient | linear-gradient($glo.deg.ttb,$glo.color.black $glo.transparent.t0,$glo.color.black $glo.transparent.t100) | |
| | | color.overlay.dark | $glo.color.black | $glo.transparent.t65 | | | | color.overlay.dark | $glo.color.black | $glo.transparent.t65 |
| | | color.overlay.background | linear-gradient($glo.deg.ttb,$color.background.default $glo.transparent.t0,$color.background.default $glo.transparent.t100) | | | | | color.overlay.background | linear-gradient($glo.deg.ttb,$color.background.default $glo.transparent.t0,$color.background.default $glo.transparent.t100) | |
| | | color.overlay.base | linear-gradient($glo.deg.ttb,$color.surface.base.normal $glo.transparent.t100,$color.surface.base.normal $glo.transparent.t0) | | | | | color.overlay.base | linear-gradient($glo.deg.ttb,$color.surface.base.normal $glo.transparent.t100,$color.surface.base.normal $glo.transparent.t0) | |
| | Context | color.context.subscribe.normal | linear-gradient($glo.deg.ltr,$glo.color.purple.50,$glo.color.violet.50) | | | | Context | color.context.subscribe.normal | linear-gradient($glo.deg.ltr,$glo.color.purple.50,$glo.color.violet.50) | |
| | | color.context.subscribe.hover | linear-gradient($glo.deg.ltr,$glo.color.purple.30,$glo.color.violet.30) | | | | | color.context.subscribe.hover | linear-gradient($glo.deg.ltr,$glo.color.purple.30,$glo.color.violet.30) | |
| | | color.context.subscribe.press | linear-gradient($glo.deg.ltr,$glo.color.purple.70,$glo.color.violet.70) | | | | | color.context.subscribe.press | linear-gradient($glo.deg.ltr,$glo.color.purple.70,$glo.color.violet.70) | |
| | | color.context.subscribe.disabled | $color.surface.nest.disabled | | | | | color.context.subscribe.disabled | $color.surface.nest.disabled | |
| | | color.context.legends.normal | linear-gradient($glo.deg.ltr,$glo.color.yellow.20,$glo.color.yellow.60) | | | | | color.context.legends.normal | linear-gradient($glo.deg.ltr,$glo.color.yellow.20,$glo.color.yellow.60) | |
| | | color.context.legends.hover | linear-gradient($glo.deg.ltr,$glo.color.yellow.10,$glo.color.yellow.40) | | | | | color.context.legends.hover | linear-gradient($glo.deg.ltr,$glo.color.yellow.10,$glo.color.yellow.40) | |
| | | color.context.legends.press | linear-gradient($glo.deg.ltr,$glo.color.yellow.60,$glo.color.yellow.90) | | | | | color.context.legends.press | linear-gradient($glo.deg.ltr,$glo.color.yellow.60,$glo.color.yellow.90) | |
| | | color.context.legends.disabled | $color.surface.nest.disabled | | | | | color.context.legends.disabled | $color.surface.nest.disabled | |
| | | color.context.legends.variant.normal | $glo.color.yellow.20 | | | | | color.context.legends.variant.normal | $glo.color.yellow.20 | |
| | | color.context.legends.variant.hover | $glo.color.yellow.10 | | | | | color.context.legends.variant.hover | $glo.color.yellow.10 | |
| | | color.context.legends.variant.press | $glo.color.yellow.40 | | | | | color.context.legends.variant.press | $glo.color.yellow.40 | |
| | | color.context.legends.variant.disabled | $color.surface.nest.disabled | | | | | color.context.legends.variant.disabled | $color.surface.nest.disabled | |
| | | color.context.vip.normal | linear-gradient($glo.deg.ltr,$glo.color.sky.20 0%,$glo.color.violet.40 60%,$glo.color.purple.30 100%) | | | | | color.context.vip.normal | linear-gradient($glo.deg.ltr,$glo.color.sky.20 0%,$glo.color.violet.40 60%,$glo.color.purple.30 100%) | |
| | | color.context.recharge.normal | linear-gradient($glo.deg.ltr, $glo.color.yellow.0, $glo.color.yellow.70) | | | | | color.context.recharge.normal | linear-gradient($glo.deg.ltr, $glo.color.yellow.0, $glo.color.yellow.70) | |
| | Text&icon | color.txt.primary.normal | $glo.color.white | | | | Text&icon | color.txt.primary.normal | $glo.color.white | |
| | | color.txt.primary.hover | $glo.color.magenta.30 | | | | | color.txt.primary.hover | $glo.color.magenta.30 | |
| | | color.txt.primary.press | $glo.color.magenta.40 | | | | | color.txt.primary.press | $glo.color.magenta.40 | |
| | | color.txt.primary.disabled | $color.txt.disabled | | | | | color.txt.primary.disabled | $color.txt.disabled | |
| | | color.txt.primary.specialmap.normal | $glo.color.white | | | | | color.txt.primary.specialmap.normal | $glo.color.white | |
| | | color.txt.primary.specialmap.hover | $glo.color.white | $glo.transparent.t85 | | | | color.txt.primary.specialmap.hover | $glo.color.white | $glo.transparent.t85 |
| | | color.txt.primary.specialmap.press | $glo.color.white | $glo.transparent.t65 | | | | color.txt.primary.specialmap.press | $glo.color.white | $glo.transparent.t65 |
| | | color.txt.primary.specialmap.disable | $glo.color.white | $glo.transparent.t45 | | | | color.txt.primary.specialmap.disable | $glo.color.white | $glo.transparent.t45 |
| | | color.txt.secondary.normal | $glo.color.grey.30 | | | | | color.txt.secondary.normal | $glo.color.grey.30 | |
| | | color.txt.secondary.hover | $glo.color.magenta.30 | | | | | color.txt.secondary.hover | $glo.color.magenta.30 | |
| | | color.txt.secondary.press | $glo.color.magenta.40 | | | | | color.txt.secondary.press | $glo.color.magenta.40 | |
| | | color.txt.secondary.disabled | $color.txt.disabled | | | | | color.txt.secondary.disabled | $color.txt.disabled | |
| | | color.txt.tertiary.normal | $glo.color.grey.40 | | | | | color.txt.tertiary.normal | $glo.color.grey.40 | |
| | | color.txt.tertiary.hover | $glo.color.grey.30 | | | | | color.txt.tertiary.hover | $glo.color.grey.30 | |
| | | color.txt.tertiary | $glo.color.grey.50 | | | | | color.txt.tertiary | $glo.color.grey.50 | |
| | | color.txt.tertiary.disabled | $color.txt.disabled | | | | | color.txt.tertiary.disabled | $color.txt.disabled | |
| | | color.txt.grass | $glo.color.grass.40 | | | | | color.txt.grass | $glo.color.grass.40 | |
| | | color.txt.disabled | $glo.color.grey.50 | | | | | color.txt.disabled | $glo.color.grey.50 | |
| Typography | display | txt.display.l | $glo.font.family.display,$glo.font.size.64,$glo.font.weight.regular,$glo.font.lineheight.size64 | | | Typography | display | txt.display.l | $glo.font.family.display,$glo.font.size.64,$glo.font.weight.regular,$glo.font.lineheight.size64 | |
| | | txt.display.m | $glo.font.family.display,$glo.font.size.48,$glo.font.weight.regular,$glo.font.lineheight.size48 | | | | | txt.display.m | $glo.font.family.display,$glo.font.size.48,$glo.font.weight.regular,$glo.font.lineheight.size48 | |
| | | txt.display.s | $glo.font.family.display,$glo.font.size.24,$glo.font.weight.regular,$glo.font.lineheight.size24 | | | | | txt.display.s | $glo.font.family.display,$glo.font.size.24,$glo.font.weight.regular,$glo.font.lineheight.size24 | |
| | headline | txt.headline.l | $glo.font.family.sys,$glo.font.size.48,$glo.font.weight.bold,$glo.font.lineheight.size48 | | | | headline | txt.headline.l | $glo.font.family.sys,$glo.font.size.48,$glo.font.weight.bold,$glo.font.lineheight.size48 | |
| | | txt.headline.m | $glo.font.family.sys,$glo.font.size.36,$glo.font.weight.bold,$glo.font.lineheight.size36 | | | | | txt.headline.m | $glo.font.family.sys,$glo.font.size.36,$glo.font.weight.bold,$glo.font.lineheight.size36 | |
| | title | txt.title.l | $glo.font.family.sys,$glo.font.size.24,$glo.font.weight.semibold,$glo.font.lineheight.size24 | | | | title | txt.title.l | $glo.font.family.sys,$glo.font.size.24,$glo.font.weight.semibold,$glo.font.lineheight.size24 | |
| | | txt.title.m | $glo.font.family.sys,$glo.font.size.20,$glo.font.weight.semibold,$glo.font.lineheight.size20 | | | | | txt.title.m | $glo.font.family.sys,$glo.font.size.20,$glo.font.weight.semibold,$glo.font.lineheight.size20 | |
| | | txt.title.s | $glo.font.family.sys,$glo.font.size.16,$glo.font.weight.semibold,$glo.font.lineheight.size16 | | | | | txt.title.s | $glo.font.family.sys,$glo.font.size.16,$glo.font.weight.semibold,$glo.font.lineheight.size16 | |
| | body | txt.bodySemibold.l | $glo.font.family.sys,$glo.font.size.16,$glo.font.weight.semibold,$glo.font.lineheight.size16 | | | | body | txt.bodySemibold.l | $glo.font.family.sys,$glo.font.size.16,$glo.font.weight.semibold,$glo.font.lineheight.size16 | |
| | | txt.bodySemibold.m | $glo.font.family.sys,$glo.font.size.14,$glo.font.weight.semibold,$glo.font.lineheight.size14 | | | | | txt.bodySemibold.m | $glo.font.family.sys,$glo.font.size.14,$glo.font.weight.semibold,$glo.font.lineheight.size14 | |
| | | txt.bodySemibold.s | $glo.font.family.sys,$glo.font.size.12,$glo.font.weight.semibold,$glo.font.lineheight.size12 | | | | | txt.bodySemibold.s | $glo.font.family.sys,$glo.font.size.12,$glo.font.weight.semibold,$glo.font.lineheight.size12 | |
| | | txt.body.l | $glo.font.family.sys,$glo.font.size.16,$glo.font.weight.regular,$glo.font.lineheight.size16 | | | | | txt.body.l | $glo.font.family.sys,$glo.font.size.16,$glo.font.weight.regular,$glo.font.lineheight.size16 | |
| | | txt.body.m | $glo.font.family.sys,$glo.font.size.14,$glo.font.weight.regular,$glo.font.lineheight.size14 | | | | | txt.body.m | $glo.font.family.sys,$glo.font.size.14,$glo.font.weight.regular,$glo.font.lineheight.size14 | |
| | | txt.body.s | $glo.font.family.sys,$glo.font.size.12,$glo.font.weight.regular,$glo.font.lineheight.size12 | | | | | txt.body.s | $glo.font.family.sys,$glo.font.size.12,$glo.font.weight.regular,$glo.font.lineheight.size12 | |
| | | txt.bodyItalic.l | $glo.font.family.sys,$glo.font.size.16,$glo.font.weight.regular,$glo.font.lineheight.size16,$glo.font.style.italic | | | | | txt.bodyItalic.l | $glo.font.family.sys,$glo.font.size.16,$glo.font.weight.regular,$glo.font.lineheight.size16,$glo.font.style.italic | |
| | label | txt.label.l | $glo.font.family.sys,$glo.font.size.16,$glo.font.weight.medium,$glo.font.lineheight.size16 | | | | label | txt.label.l | $glo.font.family.sys,$glo.font.size.16,$glo.font.weight.medium,$glo.font.lineheight.size16 | |
| | | txt.label.m | $glo.font.family.sys,$glo.font.size.14,$glo.font.weight.medium,$glo.font.lineheight.size14 | | | | | txt.label.m | $glo.font.family.sys,$glo.font.size.14,$glo.font.weight.medium,$glo.font.lineheight.size14 | |
| | | txt.label.s | $glo.font.family.sys,$glo.font.size.12,$glo.font.weight.medium,$glo.font.lineheight.size12 | | | | | txt.label.s | $glo.font.family.sys,$glo.font.size.12,$glo.font.weight.medium,$glo.font.lineheight.size12 | |
| | number | txt.numDisplay.xl | $glo.font.family.numDisplay,$glo.font.size.64,$glo.font.weight.bold,$glo.font.lineheight.size64 | | | | number | txt.numDisplay.xl | $glo.font.family.numDisplay,$glo.font.size.64,$glo.font.weight.bold,$glo.font.lineheight.size64 | |
| | | txt.numDisplay.l | $glo.font.family.numDisplay,$glo.font.size.48,$glo.font.weight.bold,$glo.font.lineheight.size48 | | | | | txt.numDisplay.l | $glo.font.family.numDisplay,$glo.font.size.48,$glo.font.weight.bold,$glo.font.lineheight.size48 | |
| | | txt.numMonotype.xl | $glo.font.family.sys,$glo.font.size.24,$glo.font.weight.bold,$glo.font.lineheight.size24 | | | | | txt.numMonotype.xl | $glo.font.family.sys,$glo.font.size.24,$glo.font.weight.bold,$glo.font.lineheight.size24 | |
| | | txt.numMonotype.l | $glo.font.family.sys,$glo.font.size.20,$glo.font.weight.bold,$glo.font.lineheight.size20 | | | | | txt.numMonotype.l | $glo.font.family.sys,$glo.font.size.20,$glo.font.weight.bold,$glo.font.lineheight.size20 | |
| | | txt.numMonotype.m | $glo.font.family.sys,$glo.font.size.16,$glo.font.weight.bold,$glo.font.lineheight.size16 | | | | | txt.numMonotype.m | $glo.font.family.sys,$glo.font.size.16,$glo.font.weight.bold,$glo.font.lineheight.size16 | |
| | | txt.numMonotype.s | $glo.font.family.sys,$glo.font.size.14,$glo.font.weight.medium,$glo.font.lineheight.size14 | | | | | txt.numMonotype.s | $glo.font.family.sys,$glo.font.size.14,$glo.font.weight.medium,$glo.font.lineheight.size14 | |
| | | txt.numMonotype.xs | $glo.font.family.sys,$glo.font.size.12,$glo.font.weight.regular,$glo.font.lineheight.size12 | | | | | txt.numMonotype.xs | $glo.font.family.sys,$glo.font.size.12,$glo.font.weight.regular,$glo.font.lineheight.size12 | |
| visual style | shadow | shadow.s | @SHA:$glo.color.black,$$glo.transparent.t45,0&0,4,渐变色,ShadowOpacity,便宜,半径 | | | visual style | shadow | shadow.s | @SHA:$glo.color.black,$$glo.transparent.t45,0&0,4,渐变色,ShadowOpacity,便宜,半径 | |
| | | shadow.m | @SHA:$glo.color.black,$$glo.transparent.t45,0&0,8 | | | | | shadow.m | @SHA:$glo.color.black,$$glo.transparent.t45,0&0,8 | |
| | | shadow.l | @SHA:$glo.color.black,$$glo.transparent.t45,0&0,16 | | | | | shadow.l | @SHA:$glo.color.black,$$glo.transparent.t45,0&0,16 | |
| | radius | radius.xs | $glo.radius.4 | | | | radius | radius.xs | $glo.radius.4 | |
| | | radius.s | $glo.radius.8 | | | | | radius.s | $glo.radius.8 | |
| | | radius.m | $glo.radius.12 | | | | | radius.m | $glo.radius.12 | |
| | | radius.l | $glo.radius.16 | | | | | radius.l | $glo.radius.16 | |
| | | radius.xl | $glo.radius.24 | | | | | radius.xl | $glo.radius.24 | |
| | | radius.round | $glo.radius.round | | | | | radius.round | $glo.radius.round | |
| | | radius.pill | $glo.radius.round | | | | | radius.pill | $glo.radius.round | |
| | border | border.divider | $glo.border.1 | | | | border | border.divider | $glo.border.1 | |
| | | border.s | $glo.border.1 | | | | | border.s | $glo.border.1 | |
| | | border.m | $glo.border.2 | | | | | border.m | $glo.border.2 | |
| | | border.l | $glo.border.4 | | | | | border.l | $glo.border.4 | |

View File

@ -25,6 +25,7 @@ NEXT_PUBLIC_DISCORD_CLIENT_ID=your_discord_client_id_here
``` ```
**获取步骤**: **获取步骤**:
1. 访问 [Discord Developer Portal](https://discord.com/developers/applications) 1. 访问 [Discord Developer Portal](https://discord.com/developers/applications)
2. 创建或选择应用 2. 创建或选择应用
3. 在 OAuth2 设置中配置回调 URL 3. 在 OAuth2 设置中配置回调 URL
@ -42,6 +43,7 @@ NEXT_PUBLIC_GOOGLE_CLIENT_ID=your_google_client_id_here
``` ```
**获取步骤**: **获取步骤**:
1. 访问 [Google Cloud Console](https://console.cloud.google.com/) 1. 访问 [Google Cloud Console](https://console.cloud.google.com/)
2. 创建或选择项目 2. 创建或选择项目
3. 启用 Google+ API 3. 启用 Google+ API
@ -123,6 +125,7 @@ console.log('Google Client ID:', process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID)
**问题 1**: 环境变量未生效 **问题 1**: 环境变量未生效
**解决方案**: **解决方案**:
- 重启开发服务器 (`npm run dev`) - 重启开发服务器 (`npm run dev`)
- 确保文件名正确 (`.env.local`) - 确保文件名正确 (`.env.local`)
- 检查变量名拼写 - 检查变量名拼写
@ -130,6 +133,7 @@ console.log('Google Client ID:', process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID)
**问题 2**: OAuth 回调失败 **问题 2**: OAuth 回调失败
**解决方案**: **解决方案**:
- 检查 `NEXT_PUBLIC_APP_URL` 是否正确 - 检查 `NEXT_PUBLIC_APP_URL` 是否正确
- 确保回调 URL 在 OAuth 提供商处正确配置 - 确保回调 URL 在 OAuth 提供商处正确配置
- 开发环境使用 `http://localhost:3000`,生产环境使用实际域名 - 开发环境使用 `http://localhost:3000`,生产环境使用实际域名
@ -137,6 +141,7 @@ console.log('Google Client ID:', process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID)
**问题 3**: 客户端 ID 无效 **问题 3**: 客户端 ID 无效
**解决方案**: **解决方案**:
- 确认复制的是 Client ID 而不是 Client Secret - 确认复制的是 Client ID 而不是 Client Secret
- 检查是否有多余的空格或换行符 - 检查是否有多余的空格或换行符
- 确认 OAuth 应用状态为已发布/激活 - 确认 OAuth 应用状态为已发布/激活
@ -170,4 +175,3 @@ console.log('Google Client ID:', process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID)
- [Next.js 环境变量文档](https://nextjs.org/docs/app/building-your-application/configuring/environment-variables) - [Next.js 环境变量文档](https://nextjs.org/docs/app/building-your-application/configuring/environment-variables)
- [Discord OAuth 文档](https://discord.com/developers/docs/topics/oauth2) - [Discord OAuth 文档](https://discord.com/developers/docs/topics/oauth2)
- [Google OAuth 文档](https://developers.google.com/identity/protocols/oauth2) - [Google OAuth 文档](https://developers.google.com/identity/protocols/oauth2)

View File

@ -7,21 +7,25 @@
## 新旧方式对比 ## 新旧方式对比
### 旧方式OAuth 2.0 重定向流程) ### 旧方式OAuth 2.0 重定向流程)
``` ```
用户点击按钮 → 跳转到 Google 授权页面 → 授权后重定向回应用 用户点击按钮 → 跳转到 Google 授权页面 → 授权后重定向回应用
``` ```
❌ 需要页面跳转 ❌ 需要页面跳转
❌ 需要配置回调路由 ❌ 需要配置回调路由
❌ 用户体验不连贯 ❌ 用户体验不连贯
### 新方式Google Identity Services ### 新方式Google Identity Services
``` ```
用户点击按钮 → 弹出 Google 授权窗口 → 授权后直接回调 用户点击按钮 → 弹出 Google 授权窗口 → 授权后直接回调
``` ```
✅ 无需页面跳转 ✅ 无需页面跳转
✅ 无需回调路由 ✅ 无需回调路由
✅ 用户体验流畅 ✅ 用户体验流畅
✅ 更安全(弹窗隔离) ✅ 更安全(弹窗隔离)
## 实现架构 ## 实现架构
@ -50,11 +54,13 @@
### 1. Google OAuth 配置 (`src/lib/oauth/google.ts`) ### 1. Google OAuth 配置 (`src/lib/oauth/google.ts`)
**主要功能**: **主要功能**:
- 定义 Google Identity Services 的 TypeScript 类型 - 定义 Google Identity Services 的 TypeScript 类型
- 提供 SDK 加载方法 - 提供 SDK 加载方法
- 提供 Code Client 初始化方法 - 提供 Code Client 初始化方法
**关键代码**: **关键代码**:
```typescript ```typescript
export const googleOAuth = { export const googleOAuth = {
// 加载 Google Identity Services SDK // 加载 Google Identity Services SDK
@ -71,7 +77,7 @@ export const googleOAuth = {
script.defer = true script.defer = true
script.onload = () => resolve() script.onload = () => resolve()
script.onerror = () => reject(new Error('Failed to load SDK')) script.onerror = () => reject(new Error('Failed to load SDK'))
document.head.appendChild(script) document.head.appendChild(script)
}) })
}, },
@ -83,15 +89,16 @@ export const googleOAuth = {
scope: GOOGLE_SCOPES, scope: GOOGLE_SCOPES,
ux_mode: 'popup', ux_mode: 'popup',
callback, callback,
error_callback: errorCallback error_callback: errorCallback,
}) })
} },
} }
``` ```
### 2. GoogleButton 组件 (`src/app/(auth)/login/components/GoogleButton.tsx`) ### 2. GoogleButton 组件 (`src/app/(auth)/login/components/GoogleButton.tsx`)
**主要功能**: **主要功能**:
- 加载 Google Identity Services SDK - 加载 Google Identity Services SDK
- 初始化 Code Client - 初始化 Code Client
- 处理授权码回调 - 处理授权码回调
@ -100,6 +107,7 @@ export const googleOAuth = {
**关键实现**: **关键实现**:
#### SDK 加载 #### SDK 加载
```typescript ```typescript
useEffect(() => { useEffect(() => {
const loadGoogleSDK = async () => { const loadGoogleSDK = async () => {
@ -108,7 +116,7 @@ useEffect(() => {
console.log('Google Identity Services SDK loaded') console.log('Google Identity Services SDK loaded')
} catch (error) { } catch (error) {
console.error('Failed to load Google SDK:', error) console.error('Failed to load Google SDK:', error)
toast.error("Failed to load Google login") toast.error('Failed to load Google login')
} }
} }
@ -117,6 +125,7 @@ useEffect(() => {
``` ```
#### 授权码处理 #### 授权码处理
```typescript ```typescript
const handleGoogleResponse = async (response: GoogleCodeResponse) => { const handleGoogleResponse = async (response: GoogleCodeResponse) => {
const deviceId = tokenManager.getDeviceId() const deviceId = tokenManager.getDeviceId()
@ -124,22 +133,23 @@ const handleGoogleResponse = async (response: GoogleCodeResponse) => {
appClient: AppClient.Web, appClient: AppClient.Web,
deviceCode: deviceId, deviceCode: deviceId,
thirdToken: response.code, // Google 授权码 thirdToken: response.code, // Google 授权码
thirdType: ThirdType.Google thirdType: ThirdType.Google,
} }
login.mutate(loginData, { login.mutate(loginData, {
onSuccess: () => { onSuccess: () => {
toast.success("Login successful") toast.success('Login successful')
router.push('/') router.push('/')
}, },
onError: (error) => { onError: (error) => {
toast.error("Login failed") toast.error('Login failed')
} },
}) })
} }
``` ```
#### 登录按钮点击 #### 登录按钮点击
```typescript ```typescript
const handleGoogleLogin = async () => { const handleGoogleLogin = async () => {
// 确保 SDK 已加载 // 确保 SDK 已加载
@ -149,10 +159,7 @@ const handleGoogleLogin = async () => {
// 初始化 Code Client // 初始化 Code Client
if (!codeClientRef.current) { if (!codeClientRef.current) {
codeClientRef.current = googleOAuth.initCodeClient( codeClientRef.current = googleOAuth.initCodeClient(handleGoogleResponse, handleGoogleError)
handleGoogleResponse,
handleGoogleError
)
} }
// 请求授权码(弹出授权窗口) // 请求授权码(弹出授权窗口)
@ -200,6 +207,7 @@ POST /api/auth/login
``` ```
后端需要: 后端需要:
1. 使用授权码向 Google 交换 access_token 1. 使用授权码向 Google 交换 access_token
2. 使用 access_token 获取用户信息 2. 使用 access_token 获取用户信息
3. 创建或更新用户 3. 创建或更新用户
@ -208,74 +216,87 @@ POST /api/auth/login
## 优势 ## 优势
### 1. 更好的用户体验 ### 1. 更好的用户体验
- ✅ 无需离开当前页面 - ✅ 无需离开当前页面
- ✅ 弹窗授权,快速完成 - ✅ 弹窗授权,快速完成
- ✅ 不打断用户操作流程 - ✅ 不打断用户操作流程
### 2. 更简单的实现 ### 2. 更简单的实现
- ✅ 不需要回调路由 - ✅ 不需要回调路由
- ✅ 不需要处理 URL 参数 - ✅ 不需要处理 URL 参数
- ✅ 不需要 state 验证 - ✅ 不需要 state 验证
- ✅ 代码更简洁 - ✅ 代码更简洁
### 3. 更安全 ### 3. 更安全
- ✅ 弹窗隔离,防止钓鱼 - ✅ 弹窗隔离,防止钓鱼
- ✅ SDK 自动处理安全验证 - ✅ SDK 自动处理安全验证
- ✅ 支持 CORS 和 CSP - ✅ 支持 CORS 和 CSP
### 4. 更现代 ### 4. 更现代
- ✅ Google 官方推荐方式 - ✅ Google 官方推荐方式
- ✅ 持续维护和更新 - ✅ 持续维护和更新
- ✅ 更好的浏览器兼容性 - ✅ 更好的浏览器兼容性
## 与旧实现的对比 ## 与旧实现的对比
| 特性 | 旧方式(重定向) | 新方式GIS | | 特性 | 旧方式(重定向) | 新方式GIS |
|------|----------------|--------------| | ------------ | ---------------- | --------------- |
| 页面跳转 | ✅ 需要 | ❌ 不需要 | | 页面跳转 | ✅ 需要 | ❌ 不需要 |
| 回调路由 | ✅ 需要 | ❌ 不需要 | | 回调路由 | ✅ 需要 | ❌ 不需要 |
| State 验证 | ✅ 需要手动实现 | ❌ SDK 自动处理 | | State 验证 | ✅ 需要手动实现 | ❌ SDK 自动处理 |
| URL 参数处理 | ✅ 需要 | ❌ 不需要 | | URL 参数处理 | ✅ 需要 | ❌ 不需要 |
| 用户体验 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | | 用户体验 | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 代码复杂度 | 高 | 低 | | 代码复杂度 | 高 | 低 |
| 维护成本 | 高 | 低 | | 维护成本 | 高 | 低 |
## 常见问题 ## 常见问题
### Q: SDK 加载失败怎么办? ### Q: SDK 加载失败怎么办?
A:
A:
- 检查网络连接 - 检查网络连接
- 确认没有被广告拦截器阻止 - 确认没有被广告拦截器阻止
- 检查浏览器控制台错误信息 - 检查浏览器控制台错误信息
### Q: 弹窗被浏览器拦截? ### Q: 弹窗被浏览器拦截?
A:
A:
- 确保在用户点击事件中调用 `requestCode()` - 确保在用户点击事件中调用 `requestCode()`
- 不要在异步操作后调用 - 不要在异步操作后调用
- 检查浏览器弹窗设置 - 检查浏览器弹窗设置
### Q: 授权后没有回调? ### Q: 授权后没有回调?
A:
A:
- 检查回调函数是否正确绑定 - 检查回调函数是否正确绑定
- 查看浏览器控制台是否有错误 - 查看浏览器控制台是否有错误
- 确认 Client ID 配置正确 - 确认 Client ID 配置正确
### Q: 用户取消授权如何处理? ### Q: 用户取消授权如何处理?
A:
A:
```typescript ```typescript
const handleGoogleError = (error: any) => { const handleGoogleError = (error: any) => {
// 用户取消授权不显示错误提示 // 用户取消授权不显示错误提示
if (error.type === 'popup_closed') { if (error.type === 'popup_closed') {
return return
} }
toast.error("Google login failed") toast.error('Google login failed')
} }
``` ```
## 测试清单 ## 测试清单
### 本地测试 ### 本地测试
- [ ] SDK 正常加载 - [ ] SDK 正常加载
- [ ] 点击按钮弹出授权窗口 - [ ] 点击按钮弹出授权窗口
- [ ] 授权后正确回调 - [ ] 授权后正确回调
@ -285,6 +306,7 @@ const handleGoogleError = (error: any) => {
- [ ] 错误情况的处理 - [ ] 错误情况的处理
### 生产环境测试 ### 生产环境测试
- [ ] 配置正确的 JavaScript 来源 - [ ] 配置正确的 JavaScript 来源
- [ ] HTTPS 证书有效 - [ ] HTTPS 证书有效
- [ ] 环境变量配置正确 - [ ] 环境变量配置正确
@ -294,6 +316,7 @@ const handleGoogleError = (error: any) => {
## 浏览器兼容性 ## 浏览器兼容性
Google Identity Services 支持: Google Identity Services 支持:
- ✅ Chrome 90+ - ✅ Chrome 90+
- ✅ Firefox 88+ - ✅ Firefox 88+
- ✅ Safari 14+ - ✅ Safari 14+
@ -302,17 +325,21 @@ Google Identity Services 支持:
## 安全注意事项 ## 安全注意事项
### 1. 客户端 ID 保护 ### 1. 客户端 ID 保护
虽然客户端 ID 是公开的,但仍需注意: 虽然客户端 ID 是公开的,但仍需注意:
- 限制授权的 JavaScript 来源 - 限制授权的 JavaScript 来源
- 定期检查使用情况 - 定期检查使用情况
- 发现异常及时更换 - 发现异常及时更换
### 2. 授权码处理 ### 2. 授权码处理
- 授权码只能使用一次 - 授权码只能使用一次
- 及时传递给后端 - 及时传递给后端
- 不要在客户端存储 - 不要在客户端存储
### 3. HTTPS 要求 ### 3. HTTPS 要求
- 生产环境必须使用 HTTPS - 生产环境必须使用 HTTPS
- 本地开发可以使用 HTTP - 本地开发可以使用 HTTP
@ -340,36 +367,39 @@ Google Identity Services 支持:
## 扩展功能 ## 扩展功能
### 1. One Tap 登录 ### 1. One Tap 登录
可以添加 Google One Tap 功能,自动显示登录提示: 可以添加 Google One Tap 功能,自动显示登录提示:
```typescript ```typescript
window.google.accounts.id.initialize({ window.google.accounts.id.initialize({
client_id: GOOGLE_CLIENT_ID, client_id: GOOGLE_CLIENT_ID,
callback: handleCredentialResponse callback: handleCredentialResponse,
}) })
window.google.accounts.id.prompt() window.google.accounts.id.prompt()
``` ```
### 2. 自动登录 ### 2. 自动登录
可以实现自动登录功能: 可以实现自动登录功能:
```typescript ```typescript
window.google.accounts.id.initialize({ window.google.accounts.id.initialize({
client_id: GOOGLE_CLIENT_ID, client_id: GOOGLE_CLIENT_ID,
callback: handleCredentialResponse, callback: handleCredentialResponse,
auto_select: true auto_select: true,
}) })
``` ```
### 3. 自定义按钮样式 ### 3. 自定义按钮样式
可以使用 Google 提供的标准按钮: 可以使用 Google 提供的标准按钮:
```typescript ```typescript
window.google.accounts.id.renderButton( window.google.accounts.id.renderButton(document.getElementById('buttonDiv'), {
document.getElementById('buttonDiv'), theme: 'outline',
{ theme: 'outline', size: 'large' } size: 'large',
) })
``` ```
## 相关文档 ## 相关文档
@ -385,7 +415,6 @@ window.google.accounts.id.renderButton(
**用户体验更好** - 无需页面跳转 **用户体验更好** - 无需页面跳转
**实现更简单** - 代码量更少 **实现更简单** - 代码量更少
**维护更容易** - 无需处理复杂的回调 **维护更容易** - 无需处理复杂的回调
**更加安全** - SDK 自动处理安全验证 **更加安全** - SDK 自动处理安全验证
强烈建议新项目直接使用这种方式! 强烈建议新项目直接使用这种方式!

View File

@ -40,6 +40,7 @@ npm run dev
## 文件清单 ## 文件清单
已创建的文件: 已创建的文件:
- ✅ `src/lib/oauth/google.ts` - Google OAuth 配置 - ✅ `src/lib/oauth/google.ts` - Google OAuth 配置
- ✅ `src/app/(auth)/login/components/GoogleButton.tsx` - Google 登录按钮组件 - ✅ `src/app/(auth)/login/components/GoogleButton.tsx` - Google 登录按钮组件
- ✅ `src/app/api/auth/google/callback/route.ts` - OAuth 回调路由 - ✅ `src/app/api/auth/google/callback/route.ts` - OAuth 回调路由
@ -83,6 +84,7 @@ POST /api/auth/login
``` ```
后端需要: 后端需要:
1. 使用授权码向 Google 交换 access_token 1. 使用授权码向 Google 交换 access_token
2. 使用 access_token 获取用户信息 2. 使用 access_token 获取用户信息
3. 创建或更新用户 3. 创建或更新用户
@ -91,12 +93,15 @@ POST /api/auth/login
## 常见问题 ## 常见问题
### Q: 点击按钮后没有跳转? ### Q: 点击按钮后没有跳转?
A: 检查浏览器控制台是否有错误,确认环境变量已正确配置。 A: 检查浏览器控制台是否有错误,确认环境变量已正确配置。
### Q: 回调后显示错误? ### Q: 回调后显示错误?
A: 检查 Google Cloud Console 中的回调 URL 配置是否正确。 A: 检查 Google Cloud Console 中的回调 URL 配置是否正确。
### Q: 登录接口调用失败? ### Q: 登录接口调用失败?
A: 确认后端接口已实现并支持 Google 登录。 A: 确认后端接口已实现并支持 Google 登录。
## 生产环境部署 ## 生产环境部署
@ -104,6 +109,7 @@ A: 确认后端接口已实现并支持 Google 登录。
### 1. 更新 Google OAuth 配置 ### 1. 更新 Google OAuth 配置
在 Google Cloud Console 添加生产环境回调 URL 在 Google Cloud Console 添加生产环境回调 URL
``` ```
https://your-domain.com/api/auth/google/callback https://your-domain.com/api/auth/google/callback
``` ```
@ -130,7 +136,7 @@ NEXT_PUBLIC_GOOGLE_CLIENT_ID=生产环境客户端ID
## 技术支持 ## 技术支持
如有问题,请参考: 如有问题,请参考:
- [完整文档](./GoogleOAuth.md) - [完整文档](./GoogleOAuth.md)
- [环境变量配置](./EnvironmentVariables.md) - [环境变量配置](./EnvironmentVariables.md)
- [Google OAuth 官方文档](https://developers.google.com/identity/protocols/oauth2) - [Google OAuth 官方文档](https://developers.google.com/identity/protocols/oauth2)

View File

@ -56,11 +56,12 @@ export const googleOAuth = {
getAuthUrl: (state?: string): string => { getAuthUrl: (state?: string): string => {
// 构建 Google OAuth 授权 URL // 构建 Google OAuth 授权 URL
// 包含 client_id, redirect_uri, scope 等参数 // 包含 client_id, redirect_uri, scope 等参数
} },
} }
``` ```
**配置参数**: **配置参数**:
- `client_id`: Google OAuth 客户端 ID - `client_id`: Google OAuth 客户端 ID
- `redirect_uri`: 授权后的回调 URL - `redirect_uri`: 授权后的回调 URL
- `scope`: 请求的权限范围email, profile - `scope`: 请求的权限范围email, profile
@ -70,6 +71,7 @@ export const googleOAuth = {
### 2. GoogleButton 组件 (`src/app/(auth)/login/components/GoogleButton.tsx`) ### 2. GoogleButton 组件 (`src/app/(auth)/login/components/GoogleButton.tsx`)
**功能**: **功能**:
- 处理 Google 登录按钮点击事件 - 处理 Google 登录按钮点击事件
- 生成随机 state 用于安全验证 - 生成随机 state 用于安全验证
- 跳转到 Google 授权页面 - 跳转到 Google 授权页面
@ -78,28 +80,30 @@ export const googleOAuth = {
- 处理登录成功/失败的重定向 - 处理登录成功/失败的重定向
**关键方法**: **关键方法**:
```typescript ```typescript
const handleGoogleLogin = () => { const handleGoogleLogin = () => {
// 1. 生成 state // 1. 生成 state
const state = Math.random().toString(36).substring(2, 15) const state = Math.random().toString(36).substring(2, 15)
// 2. 获取授权 URL // 2. 获取授权 URL
const authUrl = googleOAuth.getAuthUrl(state) const authUrl = googleOAuth.getAuthUrl(state)
// 3. 保存 state 到 sessionStorage // 3. 保存 state 到 sessionStorage
sessionStorage.setItem('google_oauth_state', state) sessionStorage.setItem('google_oauth_state', state)
// 4. 跳转到 Google 授权页面 // 4. 跳转到 Google 授权页面
window.location.href = authUrl window.location.href = authUrl
} }
``` ```
**OAuth 回调处理**: **OAuth 回调处理**:
```typescript ```typescript
useEffect(() => { useEffect(() => {
const googleCode = searchParams.get('google_code') const googleCode = searchParams.get('google_code')
const googleState = searchParams.get('google_state') const googleState = searchParams.get('google_state')
if (googleCode) { if (googleCode) {
// 验证 state // 验证 state
// 调用后端登录接口 // 调用后端登录接口
@ -111,6 +115,7 @@ useEffect(() => {
### 3. Google 回调路由 (`src/app/api/auth/google/callback/route.ts`) ### 3. Google 回调路由 (`src/app/api/auth/google/callback/route.ts`)
**功能**: **功能**:
- 接收 Google OAuth 回调 - 接收 Google OAuth 回调
- 提取授权码 (code) 和 state - 提取授权码 (code) 和 state
- 重定向回登录页面,并将参数传递给前端 - 重定向回登录页面,并将参数传递给前端
@ -119,11 +124,11 @@ useEffect(() => {
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const code = searchParams.get('code') const code = searchParams.get('code')
const state = searchParams.get('state') const state = searchParams.get('state')
// 重定向到登录页,携带 google_code 和 google_state // 重定向到登录页,携带 google_code 和 google_state
redirectUrl.searchParams.set('google_code', code) redirectUrl.searchParams.set('google_code', code)
redirectUrl.searchParams.set('google_state', state) redirectUrl.searchParams.set('google_state', state)
return NextResponse.redirect(redirectUrl) return NextResponse.redirect(redirectUrl)
} }
``` ```
@ -158,13 +163,14 @@ NEXT_PUBLIC_APP_URL=https://test.crushlevel.ai
```typescript ```typescript
interface LoginRequest { interface LoginRequest {
appClient: AppClient.Web appClient: AppClient.Web
deviceCode: string // 设备唯一标识 deviceCode: string // 设备唯一标识
thirdToken: string // Google 授权码 thirdToken: string // Google 授权码
thirdType: ThirdType.Google // 第三方类型 thirdType: ThirdType.Google // 第三方类型
} }
``` ```
后端需要: 后端需要:
1. 使用授权码向 Google 交换 access_token 1. 使用授权码向 Google 交换 access_token
2. 使用 access_token 获取用户信息 2. 使用 access_token 获取用户信息
3. 创建或更新用户账号 3. 创建或更新用户账号
@ -173,22 +179,26 @@ interface LoginRequest {
## 安全特性 ## 安全特性
### 1. State 参数验证 ### 1. State 参数验证
- 前端生成随机 state 并保存到 sessionStorage - 前端生成随机 state 并保存到 sessionStorage
- 回调时验证 state 是否匹配 - 回调时验证 state 是否匹配
- 防止 CSRF 攻击 - 防止 CSRF 攻击
### 2. 授权码模式 ### 2. 授权码模式
- 使用 OAuth 2.0 授权码流程 - 使用 OAuth 2.0 授权码流程
- 授权码只能使用一次 - 授权码只能使用一次
- Token 交换在后端进行,更安全 - Token 交换在后端进行,更安全
### 3. URL 参数清理 ### 3. URL 参数清理
- 登录成功后清理 URL 中的敏感参数 - 登录成功后清理 URL 中的敏感参数
- 防止参数泄露 - 防止参数泄露
## 用户体验优化 ## 用户体验优化
### 1. 重定向保持 ### 1. 重定向保持
```typescript ```typescript
// 保存登录前的页面 // 保存登录前的页面
sessionStorage.setItem('login_redirect_url', redirect || '') sessionStorage.setItem('login_redirect_url', redirect || '')
@ -201,17 +211,20 @@ if (loginRedirectUrl) {
``` ```
### 2. 错误处理 ### 2. 错误处理
- 授权失败时显示友好的错误提示 - 授权失败时显示友好的错误提示
- 自动清理 URL 参数 - 自动清理 URL 参数
- 不影响用户继续尝试登录 - 不影响用户继续尝试登录
### 3. 加载状态 ### 3. 加载状态
- 使用 `useLogin` Hook 的 loading 状态 - 使用 `useLogin` Hook 的 loading 状态
- 可以添加 loading 动画提升体验 - 可以添加 loading 动画提升体验
## 测试清单 ## 测试清单
### 本地测试 ### 本地测试
- [ ] 点击 Google 登录按钮跳转到 Google 授权页面 - [ ] 点击 Google 登录按钮跳转到 Google 授权页面
- [ ] 授权后正确回调到应用 - [ ] 授权后正确回调到应用
- [ ] 授权码正确传递给后端 - [ ] 授权码正确传递给后端
@ -220,6 +233,7 @@ if (loginRedirectUrl) {
- [ ] 错误情况处理正确 - [ ] 错误情况处理正确
### 生产环境测试 ### 生产环境测试
- [ ] 配置正确的回调 URL - [ ] 配置正确的回调 URL
- [ ] HTTPS 证书有效 - [ ] HTTPS 证书有效
- [ ] 环境变量配置正确 - [ ] 环境变量配置正确
@ -228,50 +242,60 @@ if (loginRedirectUrl) {
## 常见问题 ## 常见问题
### 1. 回调 URL 不匹配 ### 1. 回调 URL 不匹配
**错误**: `redirect_uri_mismatch` **错误**: `redirect_uri_mismatch`
**解决方案**: **解决方案**:
- 检查 Google Cloud Console 中配置的回调 URL - 检查 Google Cloud Console 中配置的回调 URL
- 确保 `NEXT_PUBLIC_APP_URL` 环境变量正确 - 确保 `NEXT_PUBLIC_APP_URL` 环境变量正确
- 开发环境和生产环境需要分别配置 - 开发环境和生产环境需要分别配置
### 2. State 验证失败 ### 2. State 验证失败
**错误**: "Google login failed" **错误**: "Google login failed"
**解决方案**: **解决方案**:
- 检查 sessionStorage 是否正常工作 - 检查 sessionStorage 是否正常工作
- 确保没有跨域问题 - 确保没有跨域问题
- 检查浏览器是否禁用了 cookie/storage - 检查浏览器是否禁用了 cookie/storage
### 3. 授权码已使用 ### 3. 授权码已使用
**错误**: 后端返回授权码无效 **错误**: 后端返回授权码无效
**解决方案**: **解决方案**:
- 授权码只能使用一次 - 授权码只能使用一次
- 避免重复调用登录接口 - 避免重复调用登录接口
- 清理 URL 参数防止页面刷新时重复使用 - 清理 URL 参数防止页面刷新时重复使用
## 与 Discord 登录的对比 ## 与 Discord 登录的对比
| 特性 | Discord | Google | | 特性 | Discord | Google |
|------|---------|--------| | -------------- | -------------------------------- | ------------------------------------ |
| OAuth Provider | Discord | Google | | OAuth Provider | Discord | Google |
| Scopes | identify, email | userinfo.email, userinfo.profile | | Scopes | identify, email | userinfo.email, userinfo.profile |
| 授权 URL | discord.com/api/oauth2/authorize | accounts.google.com/o/oauth2/v2/auth | | 授权 URL | discord.com/api/oauth2/authorize | accounts.google.com/o/oauth2/v2/auth |
| 回调路由 | /api/auth/discord/callback | /api/auth/google/callback | | 回调路由 | /api/auth/discord/callback | /api/auth/google/callback |
| URL 参数 | discord_code, discord_state | google_code, google_state | | URL 参数 | discord_code, discord_state | google_code, google_state |
| ThirdType | Discord | Google | | ThirdType | Discord | Google |
## 扩展建议 ## 扩展建议
### 1. 添加 Apple 登录 ### 1. 添加 Apple 登录
参考 Google 登录的实现,创建: 参考 Google 登录的实现,创建:
- `src/lib/oauth/apple.ts` - `src/lib/oauth/apple.ts`
- `src/app/(auth)/login/components/AppleButton.tsx` - `src/app/(auth)/login/components/AppleButton.tsx`
- `src/app/api/auth/apple/callback/route.ts` - `src/app/api/auth/apple/callback/route.ts`
### 2. 统一 OAuth 处理 ### 2. 统一 OAuth 处理
可以创建通用的 OAuth Hook 可以创建通用的 OAuth Hook
```typescript ```typescript
const useOAuthLogin = (provider: 'google' | 'discord' | 'apple') => { const useOAuthLogin = (provider: 'google' | 'discord' | 'apple') => {
// 通用的 OAuth 登录逻辑 // 通用的 OAuth 登录逻辑
@ -279,6 +303,7 @@ const useOAuthLogin = (provider: 'google' | 'discord' | 'apple') => {
``` ```
### 3. 添加登录统计 ### 3. 添加登录统计
记录不同登录方式的使用情况,优化用户体验。 记录不同登录方式的使用情况,优化用户体验。
## 相关文档 ## 相关文档
@ -286,4 +311,3 @@ const useOAuthLogin = (provider: 'google' | 'discord' | 'apple') => {
- [Google OAuth 2.0 文档](https://developers.google.com/identity/protocols/oauth2) - [Google OAuth 2.0 文档](https://developers.google.com/identity/protocols/oauth2)
- [Next.js API Routes](https://nextjs.org/docs/app/building-your-application/routing/route-handlers) - [Next.js API Routes](https://nextjs.org/docs/app/building-your-application/routing/route-handlers)
- Discord OAuth 实现参考 - Discord OAuth 实现参考

View File

@ -20,22 +20,25 @@
### 1. 数据结构 ### 1. 数据结构
#### MessageLikeStatus 枚举 #### MessageLikeStatus 枚举
```typescript ```typescript
export enum MessageLikeStatus { export enum MessageLikeStatus {
None = 'none', // 未点赞/踩 None = 'none', // 未点赞/踩
Liked = 'liked', // 已点赞 Liked = 'liked', // 已点赞
Disliked = 'disliked' // 已踩 Disliked = 'disliked', // 已踩
} }
``` ```
#### MessageServerExtension 接口(简化版) #### MessageServerExtension 接口(简化版)
```typescript ```typescript
export interface MessageServerExtension { export interface MessageServerExtension {
[userId: string]: MessageLikeStatus; // 用户ID -> 点赞状态的直接映射 [userId: string]: MessageLikeStatus // 用户ID -> 点赞状态的直接映射
} }
``` ```
#### 工具函数 #### 工具函数
```typescript ```typescript
// 解析消息的serverExtension字段 // 解析消息的serverExtension字段
export const parseMessageServerExtension = (serverExtension?: string): MessageServerExtension export const parseMessageServerExtension = (serverExtension?: string): MessageServerExtension
@ -50,54 +53,56 @@ export const getUserLikeStatus = (message: ExtendedMessage, userId: string): Mes
### 2. 核心功能 ### 2. 核心功能
#### NimMsgContext 扩展 #### NimMsgContext 扩展
`NimMsgContext` 中添加了 `updateMessageLikeStatus` 方法,使用 NIM SDK 的 `modifyMessage` API `NimMsgContext` 中添加了 `updateMessageLikeStatus` 方法,使用 NIM SDK 的 `modifyMessage` API
```typescript ```typescript
const updateMessageLikeStatus = useCallback(async ( const updateMessageLikeStatus = useCallback(
conversationId: string, async (conversationId: string, messageClientId: string, likeStatus: MessageLikeStatus) => {
messageClientId: string, // 1. 获取当前登录用户ID
likeStatus: MessageLikeStatus const currentUserId = nim.V2NIMLoginService.getLoginUser()
) => {
// 1. 获取当前登录用户ID // 2. 解析当前消息的serverExtension
const currentUserId = nim.V2NIMLoginService.getLoginUser(); const currentServerExt = parseMessageServerExtension(targetMessage.serverExtension)
// 2. 解析当前消息的serverExtension // 3. 更新用户的点赞状态(简化版)
const currentServerExt = parseMessageServerExtension(targetMessage.serverExtension); const newServerExt = { ...currentServerExt }
if (likeStatus === MessageLikeStatus.None) {
// 3. 更新用户的点赞状态(简化版) delete newServerExt[currentUserId] // 移除点赞状态
const newServerExt = { ...currentServerExt }; } else {
if (likeStatus === MessageLikeStatus.None) { newServerExt[currentUserId] = likeStatus // 设置新状态
delete newServerExt[currentUserId]; // 移除点赞状态 }
} else {
newServerExt[currentUserId] = likeStatus; // 设置新状态 // 4. 调用NIM SDK更新消息
} const modifyResult = await nim.V2NIMMessageService.modifyMessage(targetMessage, {
serverExtension: stringifyMessageServerExtension(newServerExt),
// 4. 调用NIM SDK更新消息 })
const modifyResult = await nim.V2NIMMessageService.modifyMessage(targetMessage, {
serverExtension: stringifyMessageServerExtension(newServerExt) // 5. 更新本地状态
}); addMsg(conversationId, [modifyResult.message], false)
},
// 5. 更新本地状态 [addMsg]
addMsg(conversationId, [modifyResult.message], false); )
}, [addMsg]);
``` ```
#### useMessageLike Hook #### useMessageLike Hook
提供便捷的点赞操作方法: 提供便捷的点赞操作方法:
```typescript ```typescript
const { const {
likeMessage, // 点赞消息 likeMessage, // 点赞消息
dislikeMessage, // 踩消息 dislikeMessage, // 踩消息
cancelLikeMessage, // 取消点赞/踩 cancelLikeMessage, // 取消点赞/踩
toggleLike, // 切换点赞状态 toggleLike, // 切换点赞状态
toggleDislike, // 切换踩状态 toggleDislike, // 切换踩状态
} = useMessageLike(); } = useMessageLike()
``` ```
### 3. UI组件 ### 3. UI组件
#### ChatOtherTextContainer #### ChatOtherTextContainer
AI消息容器组件已集成点赞功能 AI消息容器组件已集成点赞功能
- 鼠标悬停显示操作按钮 - 鼠标悬停显示操作按钮
@ -117,28 +122,28 @@ import { useNimChat } from '@/context/NimChat/useNimChat';
const MyComponent = ({ message }: { message: ExtendedMessage }) => { const MyComponent = ({ message }: { message: ExtendedMessage }) => {
const { toggleLike, toggleDislike } = useMessageLike(); const { toggleLike, toggleDislike } = useMessageLike();
const { nim } = useNimChat(); const { nim } = useNimChat();
// 获取当前用户的点赞状态 // 获取当前用户的点赞状态
const currentUserId = nim.V2NIMLoginService.getLoginUser(); const currentUserId = nim.V2NIMLoginService.getLoginUser();
const currentStatus = getUserLikeStatus(message, currentUserId || ''); const currentStatus = getUserLikeStatus(message, currentUserId || '');
const handleLike = async () => { const handleLike = async () => {
await toggleLike(message.conversationId, message.messageClientId, currentStatus); await toggleLike(message.conversationId, message.messageClientId, currentStatus);
}; };
const handleDislike = async () => { const handleDislike = async () => {
await toggleDislike(message.conversationId, message.messageClientId, currentStatus); await toggleDislike(message.conversationId, message.messageClientId, currentStatus);
}; };
return ( return (
<div> <div>
<button <button
onClick={handleLike} onClick={handleLike}
className={currentStatus === MessageLikeStatus.Liked ? 'active' : ''} className={currentStatus === MessageLikeStatus.Liked ? 'active' : ''}
> >
👍 👍
</button> </button>
<button <button
onClick={handleDislike} onClick={handleDislike}
className={currentStatus === MessageLikeStatus.Disliked ? 'active' : ''} className={currentStatus === MessageLikeStatus.Disliked ? 'active' : ''}
> >
@ -153,13 +158,13 @@ const MyComponent = ({ message }: { message: ExtendedMessage }) => {
```typescript ```typescript
// 直接设置点赞状态 // 直接设置点赞状态
await likeMessage(conversationId, messageClientId); await likeMessage(conversationId, messageClientId)
// 直接设置踩状态 // 直接设置踩状态
await dislikeMessage(conversationId, messageClientId); await dislikeMessage(conversationId, messageClientId)
// 取消所有状态 // 取消所有状态
await cancelLikeMessage(conversationId, messageClientId); await cancelLikeMessage(conversationId, messageClientId)
``` ```
## 状态管理 ## 状态管理
@ -175,22 +180,24 @@ await cancelLikeMessage(conversationId, messageClientId);
## 扩展建议 ## 扩展建议
### 1. 消息更新监听 ### 1. 消息更新监听
由于使用了 NIM SDK 的 `modifyMessage` API建议监听消息更新事件 由于使用了 NIM SDK 的 `modifyMessage` API建议监听消息更新事件
```typescript ```typescript
// 监听消息修改事件 // 监听消息修改事件
nim.V2NIMMessageService.on('onMessageUpdated', (messages: V2NIMMessage[]) => { nim.V2NIMMessageService.on('onMessageUpdated', (messages: V2NIMMessage[]) => {
messages.forEach(message => { messages.forEach((message) => {
// 处理点赞状态更新 // 处理点赞状态更新
const serverExt = parseMessageServerExtension(message.serverExtension); const serverExt = parseMessageServerExtension(message.serverExtension)
if (serverExt.likes) { if (serverExt.likes) {
console.log('消息点赞状态已更新:', message.messageClientId, serverExt); console.log('消息点赞状态已更新:', message.messageClientId, serverExt)
} }
}); })
}); })
``` ```
### 2. 错误处理 ### 2. 错误处理
为点赞操作添加错误处理和重试机制: 为点赞操作添加错误处理和重试机制:
```typescript ```typescript
@ -201,30 +208,38 @@ const updateMessageLikeStatusWithRetry = async (
retryCount = 3 retryCount = 3
) => { ) => {
try { try {
await updateMessageLikeStatus(conversationId, messageClientId, likeStatus); await updateMessageLikeStatus(conversationId, messageClientId, likeStatus)
} catch (error) { } catch (error) {
if (retryCount > 0) { if (retryCount > 0) {
console.log(`点赞失败,剩余重试次数: ${retryCount}`, error); console.log(`点赞失败,剩余重试次数: ${retryCount}`, error)
await new Promise(resolve => setTimeout(resolve, 1000)); // 等待1秒 await new Promise((resolve) => setTimeout(resolve, 1000)) // 等待1秒
return updateMessageLikeStatusWithRetry(conversationId, messageClientId, likeStatus, retryCount - 1); return updateMessageLikeStatusWithRetry(
conversationId,
messageClientId,
likeStatus,
retryCount - 1
)
} else { } else {
throw error; throw error
} }
} }
}; }
``` ```
### 3. 批量操作 ### 3. 批量操作
对于大量消息的点赞状态批量更新: 对于大量消息的点赞状态批量更新:
```typescript ```typescript
const batchUpdateLikes = (updates: Array<{ const batchUpdateLikes = (
conversationId: string; updates: Array<{
messageClientId: string; conversationId: string
likeStatus: MessageLikeStatus; messageClientId: string
}>) => { likeStatus: MessageLikeStatus
}>
) => {
// 批量更新逻辑 // 批量更新逻辑
}; }
``` ```
## 注意事项 ## 注意事项

View File

@ -11,16 +11,15 @@
**文件位置**: `src/app/(main)/home/components/StartChat/StartChatItem.tsx` **文件位置**: `src/app/(main)/home/components/StartChat/StartChatItem.tsx`
**功能**: **功能**:
- 对话建议列表中的每一项都是一个链接 - 对话建议列表中的每一项都是一个链接
- 点击时跳转到聊天页面,并将建议文本作为 URL 参数传递 - 点击时跳转到聊天页面,并将建议文本作为 URL 参数传递
- 使用 `encodeURIComponent()` 对文本进行编码,确保特殊字符正确传递 - 使用 `encodeURIComponent()` 对文本进行编码,确保特殊字符正确传递
**示例**: **示例**:
```tsx ```tsx
<Link <Link href={`/chat/${character.aiId}?text=${encodeURIComponent(suggestion)}`} className="...">
href={`/chat/${character.aiId}?text=${encodeURIComponent(suggestion)}`}
className="..."
>
<span>{suggestion}</span> <span>{suggestion}</span>
</Link> </Link>
``` ```
@ -30,36 +29,42 @@
**文件位置**: `src/app/(main)/chat/[aiId]/components/ChatMessageAction/ChatInput.tsx` **文件位置**: `src/app/(main)/chat/[aiId]/components/ChatMessageAction/ChatInput.tsx`
**功能**: **功能**:
- 使用 `useSearchParams()` Hook 获取 URL 参数 - 使用 `useSearchParams()` Hook 获取 URL 参数
- 在组件挂载时检查是否有 `text` 参数 - 在组件挂载时检查是否有 `text` 参数
- 如果有,自动填充到输入框并聚焦 - 如果有,自动填充到输入框并聚焦
**实现代码**: **实现代码**:
```tsx ```tsx
const searchParams = useSearchParams(); const searchParams = useSearchParams()
useEffect(() => { useEffect(() => {
const textFromUrl = searchParams.get('text'); const textFromUrl = searchParams.get('text')
if (textFromUrl) { if (textFromUrl) {
setMessage(textFromUrl); setMessage(textFromUrl)
// 聚焦到输入框 // 聚焦到输入框
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.focus(); textareaRef.current.focus()
} }
} }
}, [searchParams]); }, [searchParams])
``` ```
## 使用场景 ## 使用场景
### 场景 1: 对话建议快捷回复 ### 场景 1: 对话建议快捷回复
用户在首页看到 AI 角色的对话建议,点击后: 用户在首页看到 AI 角色的对话建议,点击后:
1. 跳转到聊天页面 1. 跳转到聊天页面
2. 建议文本自动填充到输入框 2. 建议文本自动填充到输入框
3. 用户可以直接发送或修改后发送 3. 用户可以直接发送或修改后发送
### 场景 2: 外部链接跳转 ### 场景 2: 外部链接跳转
可以通过外部链接直接跳转到聊天页面并预填充文本: 可以通过外部链接直接跳转到聊天页面并预填充文本:
``` ```
https://your-domain.com/chat/123?text=Hello%20there! https://your-domain.com/chat/123?text=Hello%20there!
``` ```
@ -68,7 +73,7 @@ https://your-domain.com/chat/123?text=Hello%20there!
- **参数名**: `text` - **参数名**: `text`
- **编码方式**: URL 编码 (使用 `encodeURIComponent`) - **编码方式**: URL 编码 (使用 `encodeURIComponent`)
- **示例**: - **示例**:
- 原始文本: `How is your day ?` - 原始文本: `How is your day ?`
- 编码后: `How%20is%20your%20day%20%3F` - 编码后: `How%20is%20your%20day%20%3F`
- 完整 URL: `/chat/123?text=How%20is%20your%20day%20%3F` - 完整 URL: `/chat/123?text=How%20is%20your%20day%20%3F`
@ -85,13 +90,15 @@ https://your-domain.com/chat/123?text=Hello%20there!
### 可能的增强功能: ### 可能的增强功能:
1. **清除 URL 参数**: 填充文本后清除 URL 中的 `text` 参数,避免刷新页面时重复填充 1. **清除 URL 参数**: 填充文本后清除 URL 中的 `text` 参数,避免刷新页面时重复填充
```tsx ```tsx
const router = useRouter(); const router = useRouter()
// 填充后清除参数 // 填充后清除参数
router.replace(`/chat/${aiId}`, { scroll: false }); router.replace(`/chat/${aiId}`, { scroll: false })
``` ```
2. **支持多个参数**: 可以扩展支持其他参数,如 `image`、`voice` 等 2. **支持多个参数**: 可以扩展支持其他参数,如 `image`、`voice` 等
``` ```
/chat/123?text=Hello&image=https://... /chat/123?text=Hello&image=https://...
``` ```
@ -99,7 +106,7 @@ https://your-domain.com/chat/123?text=Hello%20there!
3. **参数验证**: 添加文本长度限制和内容验证 3. **参数验证**: 添加文本长度限制和内容验证
```tsx ```tsx
if (textFromUrl && textFromUrl.length <= 1000) { if (textFromUrl && textFromUrl.length <= 1000) {
setMessage(textFromUrl); setMessage(textFromUrl)
} }
``` ```
@ -108,4 +115,3 @@ https://your-domain.com/chat/123?text=Hello%20there!
- `src/app/(main)/home/components/StartChat/StartChatItem.tsx` - 发起跳转的组件 - `src/app/(main)/home/components/StartChat/StartChatItem.tsx` - 发起跳转的组件
- `src/app/(main)/chat/[aiId]/components/ChatMessageAction/ChatInput.tsx` - 接收参数的组件 - `src/app/(main)/chat/[aiId]/components/ChatMessageAction/ChatInput.tsx` - 接收参数的组件
- `src/app/(main)/home/context/AudioPlayerContext.tsx` - 音频播放上下文(相关功能) - `src/app/(main)/home/context/AudioPlayerContext.tsx` - 音频播放上下文(相关功能)

View File

@ -7,16 +7,19 @@
## 核心功能 ## 核心功能
### 1. 智能缓存 ### 1. 智能缓存
- 基于配置参数生成唯一哈希值作为缓存键 - 基于配置参数生成唯一哈希值作为缓存键
- 相同配置的语音只生成一次,存储在内存中 - 相同配置的语音只生成一次,存储在内存中
- 支持手动清除缓存 - 支持手动清除缓存
### 2. 参数映射 ### 2. 参数映射
- `tone` (音调) → `loudnessRate` (音量) - `tone` (音调) → `loudnessRate` (音量)
- `speed` (语速) → `speechRate` (语速) - `speed` (语速) → `speechRate` (语速)
- 参数范围:[-50, 100] - 参数范围:[-50, 100]
### 3. 播放逻辑 ### 3. 播放逻辑
1. **优先级**TTS 生成的语音 > 预设语音文件 1. **优先级**TTS 生成的语音 > 预设语音文件
2. **错误回退**TTS 失败时自动使用预设语音 2. **错误回退**TTS 失败时自动使用预设语音
3. **状态管理**:生成中、播放中、已缓存等状态 3. **状态管理**:生成中、播放中、已缓存等状态
@ -51,19 +54,16 @@ import { useVoiceTTS } from '@/hooks/useVoiceTTS'
function MyComponent() { function MyComponent() {
const { generateAndPlay, isPlaying, isGenerating } = useVoiceTTS() const { generateAndPlay, isPlaying, isGenerating } = useVoiceTTS()
const config = { const config = {
text: '你好,这是测试语音', text: '你好,这是测试语音',
voiceType: 'S_zh_xiaoxiao_emotion', voiceType: 'S_zh_xiaoxiao_emotion',
speechRate: 0, speechRate: 0,
loudnessRate: 0 loudnessRate: 0,
} }
return ( return (
<button <button onClick={() => generateAndPlay(config)} disabled={isGenerating}>
onClick={() => generateAndPlay(config)}
disabled={isGenerating}
>
{isGenerating ? '生成中...' : isPlaying(config) ? '播放中' : '播放'} {isGenerating ? '生成中...' : isPlaying(config) ? '播放中' : '播放'}
</button> </button>
) )
@ -77,16 +77,16 @@ import VoiceSelector from '@/app/(main)/create/components/Voice/VoiceSelector'
function CreateForm() { function CreateForm() {
const [voiceConfig, setVoiceConfig] = useState({ const [voiceConfig, setVoiceConfig] = useState({
tone: 0, // 音调 [-50, 100] tone: 0, // 音调 [-50, 100]
speed: 0, // 语速 [-50, 100] speed: 0, // 语速 [-50, 100]
content: 'voice_id' // 语音类型ID content: 'voice_id', // 语音类型ID
}) })
return ( return (
<VoiceSelector <VoiceSelector
value={voiceConfig} value={voiceConfig}
onChange={setVoiceConfig} onChange={setVoiceConfig}
onPlay={handlePlayVoice} // 预设语音播放回调 onPlay={handlePlayVoice} // 预设语音播放回调
playing={isPlayingPreset} // 预设语音播放状态 playing={isPlayingPreset} // 预设语音播放状态
/> />
) )
@ -99,10 +99,10 @@ function CreateForm() {
```typescript ```typescript
interface FetchVoiceTtsV2Request { interface FetchVoiceTtsV2Request {
text?: string // 文本内容 text?: string // 文本内容
voiceType?: string // 语音类型 (以 S_ 开头) voiceType?: string // 语音类型 (以 S_ 开头)
speechRate?: number // 语速 [-50, 100] speechRate?: number // 语速 [-50, 100]
loudnessRate?: number // 音量 [-50, 100] loudnessRate?: number // 音量 [-50, 100]
} }
``` ```
@ -110,7 +110,7 @@ interface FetchVoiceTtsV2Request {
```typescript ```typescript
interface UseVoiceTTSOptions { interface UseVoiceTTSOptions {
autoPlay?: boolean // 生成完成后自动播放,默认 true autoPlay?: boolean // 生成完成后自动播放,默认 true
cacheEnabled?: boolean // 启用缓存,默认 true cacheEnabled?: boolean // 启用缓存,默认 true
onPlayStart?: (configHash: string) => void onPlayStart?: (configHash: string) => void
onPlayEnd?: (configHash: string) => void onPlayEnd?: (configHash: string) => void

View File

@ -74,4 +74,4 @@
"debug_mock.debugmockpage.text.h2", "debug_mock.debugmockpage.text.h2",
"debug_mock.debugmockpage.text.button" "debug_mock.debugmockpage.text.button"
] ]
} }

View File

@ -1,66 +1,67 @@
# 项目概述 # 项目概述
这是一个使用 Next.js App Router 的 Web 应用. 这是一个使用 Next.js App Router 的 Web 应用.
crushlevel-next/ crushlevel-next/
├── app/ ├── app/
├── (auth)/ # 路由组:认证相关页面 │ ├── (auth)/ # 路由组:认证相关页面
├── login/ │ │ ├── login/
└── page.tsx # /login 页面 │ │ │ └── page.tsx # /login 页面
├── register/ │ │ ├── register/
└── page.tsx # /register 页面 │ │ │ └── page.tsx # /register 页面
└── layout.tsx # 认证页面共享布局 │ │ └── layout.tsx # 认证页面共享布局
├── (dashboard)/ # 路由组:仪表盘相关页面 │ ├── (dashboard)/ # 路由组:仪表盘相关页面
├── page.tsx # /dashboard 页面 │ │ ├── page.tsx # /dashboard 页面
├── settings/ │ │ ├── settings/
└── page.tsx # /dashboard/settings 页面 │ │ │ └── page.tsx # /dashboard/settings 页面
└── layout.tsx # 仪表盘页面共享布局 │ │ └── layout.tsx # 仪表盘页面共享布局
├── api/ # API 路由 │ ├── api/ # API 路由
├── auth/ │ │ ├── auth/
└── route.ts # /api/auth 路由 │ │ │ └── route.ts # /api/auth 路由
└── users/ │ │ └── users/
└── route.ts # /api/users 路由 │ │ └── route.ts # /api/users 路由
├── layout.tsx # 根布局(全局布局) │ ├── layout.tsx # 根布局(全局布局)
├── page.tsx # 根页面(/ 路由) │ ├── page.tsx # 根页面(/ 路由)
├── globals.css # 全局样式 │ ├── globals.css # 全局样式
├── favicon.ico # 网站图标 │ ├── favicon.ico # 网站图标
└── not-found.tsx # 404 页面 │ └── not-found.tsx # 404 页面
├── components/ # 可复用组件 ├── components/ # 可复用组件
├── ui/ # UI 组件(如按钮、卡片等) │ ├── ui/ # UI 组件(如按钮、卡片等)
├── Button.tsx │ │ ├── Button.tsx
└── Card.tsx │ │ └── Card.tsx
├── layout/ # 布局相关组件 │ ├── layout/ # 布局相关组件
├── Navbar.tsx │ │ ├── Navbar.tsx
└── Footer.tsx │ │ └── Footer.tsx
└── features/ # 功能相关组件 │ └── features/ # 功能相关组件
├── AuthForm.tsx │ ├── AuthForm.tsx
└── DashboardChart.tsx │ └── DashboardChart.tsx
├── lib/ # 工具函数和库 ├── lib/ # 工具函数和库
├── api.ts # API 调用封装 │ ├── api.ts # API 调用封装
├── auth.ts # 认证相关逻辑 │ ├── auth.ts # 认证相关逻辑
└── db/ # 数据库连接和查询 │ └── db/ # 数据库连接和查询
├── prisma.ts │ ├── prisma.ts
└── models.ts │ └── models.ts
├── types/ # TypeScript 类型定义 ├── types/ # TypeScript 类型定义
├── user.ts │ ├── user.ts
└── post.ts │ └── post.ts
├── public/ # 静态资源 ├── public/ # 静态资源
├── images/ │ ├── images/
└── fonts/ │ └── fonts/
├── styles/ # 样式文件(如果不使用 globals.css ├── styles/ # 样式文件(如果不使用 globals.css
├── tailwind.css │ ├── tailwind.css
└── components/ │ └── components/
├── middleware.ts # 中间件(如认证、国际化) ├── middleware.ts # 中间件(如认证、国际化)
├── next.config.mjs # Next.js 配置文件 ├── next.config.mjs # Next.js 配置文件
├── tsconfig.json # TypeScript 配置文件 ├── tsconfig.json # TypeScript 配置文件
├── package.json ├── package.json
├── docs # 文档 ├── docs # 文档
└── README.md └── README.md
## UI库 ## UI库
使用Shadcn/U作为UI的基础组件结合tailwindcss实现。 使用Shadcn/U作为UI的基础组件结合tailwindcss实现。
token都存放在global.css中。 token都存放在global.css中。
## 组件库 ## 组件库
基础组件库components/ui
基础组件库components/ui

View File

@ -1,13 +1,26 @@
import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; import { dirname } from 'path'
import nextTypescript from "eslint-config-next/typescript"; import { fileURLToPath } from 'url'
import { dirname } from "path"; import { FlatCompat } from '@eslint/eslintrc'
import { fileURLToPath } from "url"; import prettier from 'eslint-config-prettier'
import importQuotes from 'eslint-plugin-import-quotes'
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename); const __dirname = dirname(__filename)
const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, { const compat = new FlatCompat({
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts"] baseDirectory: __dirname,
}]; })
export default eslintConfig; const eslintConfig = [
...compat.extends('next/core-web-vitals', 'next/typescript'),
prettier,
{
plugins: { 'import-quotes': importQuotes },
rules: {
// 👇 import 使用双引号
'import-quotes/import-quotes': ['error', 'double'],
},
},
]
export default eslintConfig

View File

@ -14,7 +14,7 @@ module.exports = {
// 禁用默认的 i18next 函数扫描,因为项目还没有使用 i18next // 禁用默认的 i18next 函数扫描,因为项目还没有使用 i18next
func: { func: {
list: [], list: [],
extensions: [] extensions: [],
}, },
trans: { trans: {
component: 'Trans', component: 'Trans',
@ -24,36 +24,36 @@ module.exports = {
acorn: { acorn: {
ecmaVersion: 2020, ecmaVersion: 2020,
sourceType: 'module', sourceType: 'module',
plugins: ['typescript', 'jsx'] plugins: ['typescript', 'jsx'],
},
fallbackKey: function (ns, value) {
return value
}, },
fallbackKey: function(ns, value) {
return value;
}
}, },
lngs: ['en'], lngs: ['en'],
defaultLng: 'en', defaultLng: 'en',
defaultNs: 'translation', defaultNs: 'translation',
defaultValue: function(lng, ns, key) { defaultValue: function (lng, ns, key) {
return key; return key
}, },
resource: { resource: {
loadPath: 'public/locales/{{lng}}/{{ns}}.json', loadPath: 'public/locales/{{lng}}/{{ns}}.json',
savePath: 'public/locales/{{lng}}/{{ns}}.json', savePath: 'public/locales/{{lng}}/{{ns}}.json',
jsonIndent: 2, jsonIndent: 2,
lineEnding: '\n' lineEnding: '\n',
}, },
nsSeparator: ':', nsSeparator: ':',
keySeparator: '.', keySeparator: '.',
interpolation: { interpolation: {
prefix: '{{', prefix: '{{',
suffix: '}}' suffix: '}}',
}, },
// 自定义提取规则,用于扫描未使用 i18next 的文本 // 自定义提取规则,用于扫描未使用 i18next 的文本
customTransComponents: [ customTransComponents: [
{ {
name: 'Trans', name: 'Trans',
props: ['i18nKey', 'defaults'] props: ['i18nKey', 'defaults'],
} },
], ],
// 扫描 JSX 文本和属性 // 扫描 JSX 文本和属性
detect: { detect: {
@ -64,7 +64,7 @@ module.exports = {
// 扫描函数调用中的字符串 // 扫描函数调用中的字符串
func: ['toast', 'alert', 'confirm', 'message', 'console.log', 'console.error'], func: ['toast', 'alert', 'confirm', 'message', 'console.log', 'console.error'],
// 扫描对象字面量中的 message 属性 // 扫描对象字面量中的 message 属性
object: ['message', 'error', 'warning', 'success'] object: ['message', 'error', 'warning', 'success'],
} },
} },
}; }

View File

@ -1,4 +1,4 @@
import type { NextConfig } from "next"; import type { NextConfig } from 'next'
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ /* config options here */
@ -8,27 +8,27 @@ const nextConfig: NextConfig = {
images: { images: {
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", protocol: 'https',
hostname: "picsum.photos", hostname: 'picsum.photos',
}, },
{ {
protocol: "https", protocol: 'https',
hostname: "public-pictures.epal.gg", hostname: 'public-pictures.epal.gg',
}, },
{ {
protocol: "https", protocol: 'https',
hostname: "hhb.crushlevel.ai", hostname: 'hhb.crushlevel.ai',
}, },
{ {
protocol: "https", protocol: 'https',
hostname: "sub.crushlevel.ai", hostname: 'sub.crushlevel.ai',
}, },
{ {
protocol: "https", protocol: 'https',
hostname: "img.crushlevel.ai", hostname: 'img.crushlevel.ai',
} },
], ],
}, },
}; }
export default nextConfig; export default nextConfig

12521
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,15 +3,14 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev", "dev": "next dev --turbopack",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint .", "lint": "next lint",
"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",
"format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/client-s3": "^3.850.0",
@ -51,13 +50,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": "16.0.3", "next": "15.3.5",
"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.2.0", "react": "^19.0.0",
"react-dom": "19.2.0", "react-dom": "^19.0.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",
@ -69,24 +68,27 @@
"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.2.5", "@types/react": "^19",
"@types/react-dom": "19.2.3", "@types/react-dom": "^19",
"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": "16.0.3", "eslint-config-next": "15.3.5",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^10.1.8",
"eslint-plugin-import-quotes": "^0.0.1",
"eslint-plugin-prettier": "^5.5.4",
"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": "^3.7.1",
"prettier-plugin-tailwindcss": "^0.6.11", "prettier-plugin-tailwindcss": "^0.7.1",
"tailwindcss": "^4", "tailwindcss": "^4",
"ts-morph": "^27.0.2", "ts-morph": "^27.0.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@ -99,9 +101,5 @@
"workerDirectory": [ "workerDirectory": [
"public" "public"
] ]
},
"overrides": {
"@types/react": "19.2.5",
"@types/react-dom": "19.2.3"
} }
} }

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,5 @@
const config = { const config = {
plugins: ["@tailwindcss/postcss"], plugins: ['@tailwindcss/postcss'],
}; }
export default config; export default config

1
public/ads.txt Normal file
View File

@ -0,0 +1 @@
google.com, pub-6468790746781495, DIRECT, f08c47fec0942fa0

View File

@ -1,15 +1,18 @@
/* Logo 字体 */ /* Logo 字体 */
@font-face { @font-face {
font-family: "iconfont logo"; font-family: 'iconfont logo';
src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834'); src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834');
src: url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834#iefix') format('embedded-opentype'), src:
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834#iefix')
format('embedded-opentype'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.woff?t=1545807318834') format('woff'), url('https://at.alicdn.com/t/font_985780_km7mi63cihi.woff?t=1545807318834') format('woff'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.ttf?t=1545807318834') format('truetype'), url('https://at.alicdn.com/t/font_985780_km7mi63cihi.ttf?t=1545807318834') format('truetype'),
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.svg?t=1545807318834#iconfont') format('svg'); url('https://at.alicdn.com/t/font_985780_km7mi63cihi.svg?t=1545807318834#iconfont')
format('svg');
} }
.logo { .logo {
font-family: "iconfont logo"; font-family: 'iconfont logo';
font-size: 160px; font-size: 160px;
font-style: normal; font-style: normal;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
@ -48,7 +51,6 @@
color: #666; color: #666;
} }
#tabs .active { #tabs .active {
border-bottom-color: #f00; border-bottom-color: #f00;
color: #222; color: #222;
@ -119,9 +121,15 @@
font-size: 42px; font-size: 42px;
margin: 10px auto; margin: 10px auto;
color: #333; color: #333;
-webkit-transition: font-size 0.25s linear, width 0.25s linear; -webkit-transition:
-moz-transition: font-size 0.25s linear, width 0.25s linear; font-size 0.25s linear,
transition: font-size 0.25s linear, width 0.25s linear; width 0.25s linear;
-moz-transition:
font-size 0.25s linear,
width 0.25s linear;
transition:
font-size 0.25s linear,
width 0.25s linear;
} }
.icon_lists .icon:hover { .icon_lists .icon:hover {
@ -215,35 +223,35 @@
margin: 1em 0; margin: 1em 0;
} }
.markdown>p, .markdown > p,
.markdown>blockquote, .markdown > blockquote,
.markdown>.highlight, .markdown > .highlight,
.markdown>ol, .markdown > ol,
.markdown>ul { .markdown > ul {
width: 80%; width: 80%;
} }
.markdown ul>li { .markdown ul > li {
list-style: circle; list-style: circle;
} }
.markdown>ul li, .markdown > ul li,
.markdown blockquote ul>li { .markdown blockquote ul > li {
margin-left: 20px; margin-left: 20px;
padding-left: 4px; padding-left: 4px;
} }
.markdown>ul li p, .markdown > ul li p,
.markdown>ol li p { .markdown > ol li p {
margin: 0.6em 0; margin: 0.6em 0;
} }
.markdown ol>li { .markdown ol > li {
list-style: decimal; list-style: decimal;
} }
.markdown>ol li, .markdown > ol li,
.markdown blockquote ol>li { .markdown blockquote ol > li {
margin-left: 20px; margin-left: 20px;
padding-left: 4px; padding-left: 4px;
} }
@ -260,7 +268,7 @@
font-weight: 600; font-weight: 600;
} }
.markdown>table { .markdown > table {
border-collapse: collapse; border-collapse: collapse;
border-spacing: 0px; border-spacing: 0px;
empty-cells: show; empty-cells: show;
@ -269,21 +277,21 @@
margin-bottom: 24px; margin-bottom: 24px;
} }
.markdown>table th { .markdown > table th {
white-space: nowrap; white-space: nowrap;
color: #333; color: #333;
font-weight: 600; font-weight: 600;
} }
.markdown>table th, .markdown > table th,
.markdown>table td { .markdown > table td {
border: 1px solid #e9e9e9; border: 1px solid #e9e9e9;
padding: 8px 16px; padding: 8px 16px;
text-align: left; text-align: left;
} }
.markdown>table th { .markdown > table th {
background: #F7F7F7; background: #f7f7f7;
} }
.markdown blockquote { .markdown blockquote {
@ -318,12 +326,11 @@
display: inline-block; display: inline-block;
} }
.markdown>br, .markdown > br,
.markdown>p>br { .markdown > p > br {
clear: both; clear: both;
} }
.hljs { .hljs {
display: block; display: block;
background: white; background: white;
@ -399,8 +406,8 @@ https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javasc
* Based on dabblet (http://dabblet.com) * Based on dabblet (http://dabblet.com)
* @author Lea Verou * @author Lea Verou
*/ */
code[class*="language-"], code[class*='language-'],
pre[class*="language-"] { pre[class*='language-'] {
color: black; color: black;
background: none; background: none;
text-shadow: 0 1px white; text-shadow: 0 1px white;
@ -422,46 +429,45 @@ pre[class*="language-"] {
hyphens: none; hyphens: none;
} }
pre[class*="language-"]::-moz-selection, pre[class*='language-']::-moz-selection,
pre[class*="language-"] ::-moz-selection, pre[class*='language-'] ::-moz-selection,
code[class*="language-"]::-moz-selection, code[class*='language-']::-moz-selection,
code[class*="language-"] ::-moz-selection { code[class*='language-'] ::-moz-selection {
text-shadow: none; text-shadow: none;
background: #b3d4fc; background: #b3d4fc;
} }
pre[class*="language-"]::selection, pre[class*='language-']::selection,
pre[class*="language-"] ::selection, pre[class*='language-'] ::selection,
code[class*="language-"]::selection, code[class*='language-']::selection,
code[class*="language-"] ::selection { code[class*='language-'] ::selection {
text-shadow: none; text-shadow: none;
background: #b3d4fc; background: #b3d4fc;
} }
@media print { @media print {
code[class*='language-'],
code[class*="language-"], pre[class*='language-'] {
pre[class*="language-"] {
text-shadow: none; text-shadow: none;
} }
} }
/* Code blocks */ /* Code blocks */
pre[class*="language-"] { pre[class*='language-'] {
padding: 1em; padding: 1em;
margin: .5em 0; margin: 0.5em 0;
overflow: auto; overflow: auto;
} }
:not(pre)>code[class*="language-"], :not(pre) > code[class*='language-'],
pre[class*="language-"] { pre[class*='language-'] {
background: #f5f2f0; background: #f5f2f0;
} }
/* Inline code */ /* Inline code */
:not(pre)>code[class*="language-"] { :not(pre) > code[class*='language-'] {
padding: .1em; padding: 0.1em;
border-radius: .3em; border-radius: 0.3em;
white-space: normal; white-space: normal;
} }
@ -477,7 +483,7 @@ pre[class*="language-"] {
} }
.namespace { .namespace {
opacity: .7; opacity: 0.7;
} }
.token.property, .token.property,
@ -505,7 +511,7 @@ pre[class*="language-"] {
.language-css .token.string, .language-css .token.string,
.style .token.string { .style .token.string {
color: #9a6e3a; color: #9a6e3a;
background: hsla(0, 0%, 100%, .5); background: hsla(0, 0%, 100%, 0.5);
} }
.token.atrule, .token.atrule,
@ -516,7 +522,7 @@ pre[class*="language-"] {
.token.function, .token.function,
.token.class-name { .token.class-name {
color: #DD4A68; color: #dd4a68;
} }
.token.regex, .token.regex,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@ -848,4 +848,4 @@
"_main__chat__aiid_.chatimagecontainer.text.div": "×", "_main__chat__aiid_.chatimagecontainer.text.div": "×",
"_main__chat__aiid_.handleunlock.toast.toast_success": "Unlock success", "_main__chat__aiid_.handleunlock.toast.toast_success": "Unlock success",
"_main__chat__aiid_.handleunlock.toast.toast_error": "Unlock failed" "_main__chat__aiid_.handleunlock.toast.toast_error": "Unlock failed"
} }

View File

@ -101,10 +101,7 @@ addEventListener('fetch', function (event) {
// Opening the DevTools triggers the "only-if-cached" request // Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests. // that cannot be handled by the worker. Bypass such requests.
if ( if (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') {
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return return
} }
@ -156,7 +153,7 @@ async function handleRequest(event, requestId) {
}, },
}, },
}, },
responseClone.body ? [serializedRequest.body, responseClone.body] : [], responseClone.body ? [serializedRequest.body, responseClone.body] : []
) )
} }
@ -220,9 +217,7 @@ async function getResponse(event, client, requestId) {
const acceptHeader = headers.get('accept') const acceptHeader = headers.get('accept')
if (acceptHeader) { if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim()) const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter( const filteredValues = values.filter((value) => value !== 'msw/passthrough')
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) { if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', ')) headers.set('accept', filteredValues.join(', '))
@ -258,7 +253,7 @@ async function getResponse(event, client, requestId) {
...serializedRequest, ...serializedRequest,
}, },
}, },
[serializedRequest.body], [serializedRequest.body]
) )
switch (clientMessage.type) { switch (clientMessage.type) {
@ -292,10 +287,7 @@ function sendToClient(client, message, transferrables = []) {
resolve(event.data) resolve(event.data)
} }
client.postMessage(message, [ client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)])
channel.port2,
...transferrables.filter(Boolean),
])
}) })
} }

View File

@ -5,41 +5,49 @@
✅ **成功完成文案翻译覆盖任务** ✅ **成功完成文案翻译覆盖任务**
### 统计数据 ### 统计数据
- **总翻译条目**: 378 条(去重后) - **总翻译条目**: 378 条(去重后)
- **成功替换**: 334 条 - **成功替换**: 334 条
- **成功率**: 88.4% - **成功率**: 88.4%
- **剩余冲突**: 44 条 - **剩余冲突**: 44 条
### 冲突分析 ### 冲突分析
- **文本未找到**: 24 条 - 主要是包含特殊字符(如 emoji的文本 - **文本未找到**: 24 条 - 主要是包含特殊字符(如 emoji的文本
- **多处匹配**: 20 条 - 同一文本在文件中出现多次,需要人工确认 - **多处匹配**: 20 条 - 同一文本在文件中出现多次,需要人工确认
## 实现的功能 ## 实现的功能
### 1. 智能文案替换 ### 1. 智能文案替换
- 基于 `ts-morph` AST 解析,精确定位不同类型的文案 - 基于 `ts-morph` AST 解析,精确定位不同类型的文案
- 支持 JSX 文本、属性值、函数参数等多种文案类型 - 支持 JSX 文本、属性值、函数参数等多种文案类型
- 保持代码格式和缩进不变 - 保持代码格式和缩进不变
### 2. 冲突检测机制 ### 2. 冲突检测机制
- 自动检测文件不存在、文本未找到、多处匹配等冲突 - 自动检测文件不存在、文本未找到、多处匹配等冲突
- 生成详细的冲突报告,便于人工处理 - 生成详细的冲突报告,便于人工处理
### 3. 去重处理 ### 3. 去重处理
- 自动去除翻译数据中的重复条目 - 自动去除翻译数据中的重复条目
- 避免重复替换导致的错误 - 避免重复替换导致的错误
### 4. 报告生成 ### 4. 报告生成
- 成功替换报告:`scripts/translation-report.json` - 成功替换报告:`scripts/translation-report.json`
- 冲突报告:`scripts/translation-conflicts.xlsx` - 冲突报告:`scripts/translation-conflicts.xlsx`
## 使用的脚本 ## 使用的脚本
### 主要脚本 ### 主要脚本
- `scripts/apply-translations.cjs` - 基础翻译应用脚本 - `scripts/apply-translations.cjs` - 基础翻译应用脚本
- `scripts/reset-and-apply-translations.cjs` - 重置并应用翻译脚本(推荐使用) - `scripts/reset-and-apply-translations.cjs` - 重置并应用翻译脚本(推荐使用)
### 使用方法 ### 使用方法
```bash ```bash
# 重置文件并应用翻译(推荐) # 重置文件并应用翻译(推荐)
node scripts/reset-and-apply-translations.cjs node scripts/reset-and-apply-translations.cjs
@ -51,10 +59,12 @@ node scripts/apply-translations.cjs
## 处理建议 ## 处理建议
### 对于剩余冲突 ### 对于剩余冲突
1. **文本未找到的条目**:检查是否包含特殊字符或格式问题 1. **文本未找到的条目**:检查是否包含特殊字符或格式问题
2. **多处匹配的条目**:需要人工确认具体替换哪个位置 2. **多处匹配的条目**:需要人工确认具体替换哪个位置
### 后续优化 ### 后续优化
1. 可以针对特殊字符emoji的匹配进行优化 1. 可以针对特殊字符emoji的匹配进行优化
2. 可以添加更智能的多处匹配处理逻辑 2. 可以添加更智能的多处匹配处理逻辑
3. 可以添加翻译质量验证机制 3. 可以添加翻译质量验证机制
@ -62,6 +72,7 @@ node scripts/apply-translations.cjs
## 文件变更 ## 文件变更
所有成功替换的文案已直接修改到源代码文件中,包括: 所有成功替换的文案已直接修改到源代码文件中,包括:
- React 组件中的 JSX 文本 - React 组件中的 JSX 文本
- 属性值title、placeholder、alt 等) - 属性值title、placeholder、alt 等)
- 函数调用中的字符串参数 - 函数调用中的字符串参数

View File

@ -1,15 +1,15 @@
/* /*
CommonJS runtime for applying translations from Excel to source code. CommonJS runtime for applying translations from Excel to source code.
*/ */
const path = require('node:path'); const path = require('node:path')
const fs = require('node:fs'); const fs = require('node:fs')
const { Project, SyntaxKind, Node } = require('ts-morph'); const { Project, SyntaxKind, Node } = require('ts-morph')
const XLSX = require('xlsx'); const XLSX = require('xlsx')
const WORKDIR = process.cwd(); const WORKDIR = process.cwd()
const TRANSLATES_FILE = path.join(WORKDIR, 'scripts', 'translates.xlsx'); const TRANSLATES_FILE = path.join(WORKDIR, 'scripts', 'translates.xlsx')
const REPORT_FILE = path.join(WORKDIR, 'scripts', 'translation-report.json'); const REPORT_FILE = path.join(WORKDIR, 'scripts', 'translation-report.json')
const CONFLICTS_FILE = path.join(WORKDIR, 'scripts', 'translation-conflicts.xlsx'); const CONFLICTS_FILE = path.join(WORKDIR, 'scripts', 'translation-conflicts.xlsx')
// 统计信息 // 统计信息
const stats = { const stats = {
@ -18,309 +18,306 @@ const stats = {
conflicts: 0, conflicts: 0,
fileNotFound: 0, fileNotFound: 0,
textNotFound: 0, textNotFound: 0,
multipleMatches: 0 multipleMatches: 0,
}; }
// 冲突列表 // 冲突列表
const conflicts = []; const conflicts = []
// 成功替换列表 // 成功替换列表
const successfulReplacements = []; const successfulReplacements = []
function loadTranslations() { function loadTranslations() {
console.log('📖 读取翻译数据...'); console.log('📖 读取翻译数据...')
const wb = XLSX.readFile(TRANSLATES_FILE); const wb = XLSX.readFile(TRANSLATES_FILE)
const ws = wb.Sheets[wb.SheetNames[0]]; const ws = wb.Sheets[wb.SheetNames[0]]
const data = XLSX.utils.sheet_to_json(ws, { defval: '' }); const data = XLSX.utils.sheet_to_json(ws, { defval: '' })
// 筛选出需要替换的条目 // 筛选出需要替换的条目
let translations = data.filter(row => let translations = data.filter(
row.text && (row) => row.text && row.corrected_text && row.text !== row.corrected_text
row.corrected_text && )
row.text !== row.corrected_text
);
// 去重:按 file + line + text 去重,保留第一个 // 去重:按 file + line + text 去重,保留第一个
const seen = new Set(); const seen = new Set()
translations = translations.filter(row => { translations = translations.filter((row) => {
const key = `${row.file}:${row.line}:${row.text}`; const key = `${row.file}:${row.line}:${row.text}`
if (seen.has(key)) { if (seen.has(key)) {
return false; return false
} }
seen.add(key); seen.add(key)
return true; return true
}); })
console.log(`📊 找到 ${translations.length} 条需要替换的翻译(已去重)`); console.log(`📊 找到 ${translations.length} 条需要替换的翻译(已去重)`)
stats.total = translations.length; stats.total = translations.length
return translations; return translations
} }
function groupByFile(translations) { function groupByFile(translations) {
const groups = new Map(); const groups = new Map()
for (const translation of translations) { for (const translation of translations) {
const filePath = path.join(WORKDIR, translation.file); const filePath = path.join(WORKDIR, translation.file)
if (!groups.has(filePath)) { if (!groups.has(filePath)) {
groups.set(filePath, []); groups.set(filePath, [])
} }
groups.get(filePath).push(translation); groups.get(filePath).push(translation)
} }
return groups; return groups
} }
function findTextInNode(node, targetText, kind) { function findTextInNode(node, targetText, kind) {
if (!node) return null; if (!node) return null
// 处理 JSX 文本节点 // 处理 JSX 文本节点
if (Node.isJsxText(node)) { if (Node.isJsxText(node)) {
const text = node.getText().replace(/\s+/g, ' ').trim(); const text = node.getText().replace(/\s+/g, ' ').trim()
if (text === targetText) return node; if (text === targetText) return node
} }
// 处理字符串字面量 // 处理字符串字面量
if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) { if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
const text = node.getLiteralText(); const text = node.getLiteralText()
if (text === targetText) return node; if (text === targetText) return node
} }
// 处理 JSX 表达式中的字符串 // 处理 JSX 表达式中的字符串
if (Node.isJsxExpression(node)) { if (Node.isJsxExpression(node)) {
const expr = node.getExpression(); const expr = node.getExpression()
if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) { if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) {
const text = expr.getLiteralText(); const text = expr.getLiteralText()
if (text === targetText) return node; if (text === targetText) return node
} }
} }
return null; return null
} }
function findTextInFile(sourceFile, translation) { function findTextInFile(sourceFile, translation) {
const { text, line, kind } = translation; const { text, line, kind } = translation
const matches = []; const matches = []
sourceFile.forEachDescendant((node) => { sourceFile.forEachDescendant((node) => {
// 根据 kind 类型进行不同的匹配 // 根据 kind 类型进行不同的匹配
if (kind === 'text') { if (kind === 'text') {
// 查找 JSX 文本节点 // 查找 JSX 文本节点
if (Node.isJsxText(node)) { if (Node.isJsxText(node)) {
const nodeText = node.getText().replace(/\s+/g, ' ').trim(); const nodeText = node.getText().replace(/\s+/g, ' ').trim()
if (nodeText === text) { if (nodeText === text) {
matches.push({ node, type: 'jsx-text', line: node.getStartLineNumber() }); matches.push({ node, type: 'jsx-text', line: node.getStartLineNumber() })
} }
} }
// 查找 JSX 表达式中的字符串 // 查找 JSX 表达式中的字符串
if (Node.isJsxExpression(node)) { if (Node.isJsxExpression(node)) {
const expr = node.getExpression(); const expr = node.getExpression()
if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) { if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) {
const nodeText = expr.getLiteralText(); const nodeText = expr.getLiteralText()
if (nodeText === text) { if (nodeText === text) {
matches.push({ node: expr, type: 'jsx-expression', line: node.getStartLineNumber() }); matches.push({ node: expr, type: 'jsx-expression', line: node.getStartLineNumber() })
} }
} }
} }
} else if (['placeholder', 'title', 'alt', 'label', 'aria'].includes(kind)) { } else if (['placeholder', 'title', 'alt', 'label', 'aria'].includes(kind)) {
// 查找 JSX 属性 // 查找 JSX 属性
if (Node.isJsxAttribute(node)) { if (Node.isJsxAttribute(node)) {
const name = node.getNameNode().getText().toLowerCase(); const name = node.getNameNode().getText().toLowerCase()
const value = getStringFromInitializer(node); const value = getStringFromInitializer(node)
if (value === text) { if (value === text) {
matches.push({ node, type: 'jsx-attribute', line: node.getStartLineNumber() }); matches.push({ node, type: 'jsx-attribute', line: node.getStartLineNumber() })
} }
} }
} else if (['toast', 'dialog', 'error', 'validation'].includes(kind)) { } else if (['toast', 'dialog', 'error', 'validation'].includes(kind)) {
// 查找函数调用中的字符串参数 // 查找函数调用中的字符串参数
if (Node.isCallExpression(node)) { if (Node.isCallExpression(node)) {
const args = node.getArguments(); const args = node.getArguments()
for (const arg of args) { for (const arg of args) {
if (Node.isStringLiteral(arg) || Node.isNoSubstitutionTemplateLiteral(arg)) { if (Node.isStringLiteral(arg) || Node.isNoSubstitutionTemplateLiteral(arg)) {
const nodeText = arg.getLiteralText(); const nodeText = arg.getLiteralText()
if (nodeText === text) { if (nodeText === text) {
matches.push({ node: arg, type: 'function-arg', line: node.getStartLineNumber() }); matches.push({ node: arg, type: 'function-arg', line: node.getStartLineNumber() })
} }
} }
} }
} }
} }
}); })
return matches; return matches
} }
function getStringFromInitializer(attr) { function getStringFromInitializer(attr) {
const init = attr.getInitializer(); const init = attr.getInitializer()
if (!init) return undefined; if (!init) return undefined
if (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init)) { if (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init)) {
return init.getLiteralText(); return init.getLiteralText()
} }
if (Node.isJsxExpression(init)) { if (Node.isJsxExpression(init)) {
const expr = init.getExpression(); const expr = init.getExpression()
if (!expr) return undefined; if (!expr) return undefined
if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) { if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) {
return expr.getLiteralText(); return expr.getLiteralText()
} }
} }
return undefined; return undefined
} }
function replaceText(node, newText, type) { function replaceText(node, newText, type) {
try { try {
if (type === 'jsx-text') { if (type === 'jsx-text') {
// JSX 文本节点需要特殊处理,保持空白字符 // JSX 文本节点需要特殊处理,保持空白字符
const originalText = node.getText(); const originalText = node.getText()
const newTextWithWhitespace = originalText.replace(/\S+/g, newText); const newTextWithWhitespace = originalText.replace(/\S+/g, newText)
node.replaceWithText(newTextWithWhitespace); node.replaceWithText(newTextWithWhitespace)
} else if (type === 'jsx-expression' || type === 'function-arg') { } else if (type === 'jsx-expression' || type === 'function-arg') {
// 字符串字面量 // 字符串字面量
if (Node.isStringLiteral(node)) { if (Node.isStringLiteral(node)) {
node.replaceWithText(`"${newText}"`); node.replaceWithText(`"${newText}"`)
} else if (Node.isNoSubstitutionTemplateLiteral(node)) { } else if (Node.isNoSubstitutionTemplateLiteral(node)) {
node.replaceWithText(`\`${newText}\``); node.replaceWithText(`\`${newText}\``)
} }
} else if (type === 'jsx-attribute') { } else if (type === 'jsx-attribute') {
// JSX 属性值 // JSX 属性值
const init = node.getInitializer(); const init = node.getInitializer()
if (init) { if (init) {
if (Node.isStringLiteral(init)) { if (Node.isStringLiteral(init)) {
init.replaceWithText(`"${newText}"`); init.replaceWithText(`"${newText}"`)
} else if (Node.isNoSubstitutionTemplateLiteral(init)) { } else if (Node.isNoSubstitutionTemplateLiteral(init)) {
init.replaceWithText(`\`${newText}\``); init.replaceWithText(`\`${newText}\``)
} else if (Node.isJsxExpression(init)) { } else if (Node.isJsxExpression(init)) {
const expr = init.getExpression(); const expr = init.getExpression()
if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) { if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) {
if (Node.isStringLiteral(expr)) { if (Node.isStringLiteral(expr)) {
expr.replaceWithText(`"${newText}"`); expr.replaceWithText(`"${newText}"`)
} else { } else {
expr.replaceWithText(`\`${newText}\``); expr.replaceWithText(`\`${newText}\``)
} }
} }
} }
} }
} }
return true; return true
} catch (error) { } catch (error) {
console.error(`❌ 替换失败: ${error.message}`); console.error(`❌ 替换失败: ${error.message}`)
return false; return false
} }
} }
function processFile(filePath, translations) { function processFile(filePath, translations) {
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
console.log(`❌ 文件不存在: ${path.relative(WORKDIR, filePath)}`); console.log(`❌ 文件不存在: ${path.relative(WORKDIR, filePath)}`)
translations.forEach(t => { translations.forEach((t) => {
conflicts.push({ conflicts.push({
...t, ...t,
conflictType: 'FILE_NOT_FOUND', conflictType: 'FILE_NOT_FOUND',
conflictReason: '文件不存在' conflictReason: '文件不存在',
}); })
}); })
stats.fileNotFound += translations.length; stats.fileNotFound += translations.length
return; return
} }
console.log(`📝 处理文件: ${path.relative(WORKDIR, filePath)}`); console.log(`📝 处理文件: ${path.relative(WORKDIR, filePath)}`)
try { try {
const project = new Project({ const project = new Project({
tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'), tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'),
skipAddingFilesFromTsConfig: true skipAddingFilesFromTsConfig: true,
}); })
const sourceFile = project.addSourceFileAtPath(filePath); const sourceFile = project.addSourceFileAtPath(filePath)
for (const translation of translations) { for (const translation of translations) {
const { text, corrected_text, line, kind } = translation; const { text, corrected_text, line, kind } = translation
// 首先在指定行附近查找 // 首先在指定行附近查找
let matches = findTextInFile(sourceFile, translation); let matches = findTextInFile(sourceFile, translation)
// 如果没找到,在整个文件中搜索 // 如果没找到,在整个文件中搜索
if (matches.length === 0) { if (matches.length === 0) {
matches = findTextInFile(sourceFile, translation); matches = findTextInFile(sourceFile, translation)
} }
if (matches.length === 0) { if (matches.length === 0) {
conflicts.push({ conflicts.push({
...translation, ...translation,
conflictType: 'TEXT_NOT_FOUND_IN_FILE', conflictType: 'TEXT_NOT_FOUND_IN_FILE',
conflictReason: '在文件中找不到匹配的文本' conflictReason: '在文件中找不到匹配的文本',
}); })
stats.textNotFound++; stats.textNotFound++
continue; continue
} }
if (matches.length > 1) { if (matches.length > 1) {
conflicts.push({ conflicts.push({
...translation, ...translation,
conflictType: 'MULTIPLE_MATCHES', conflictType: 'MULTIPLE_MATCHES',
conflictReason: `找到 ${matches.length} 个匹配项,需要人工确认` conflictReason: `找到 ${matches.length} 个匹配项,需要人工确认`,
}); })
stats.multipleMatches++; stats.multipleMatches++
continue; continue
} }
// 执行替换 // 执行替换
const match = matches[0]; const match = matches[0]
const success = replaceText(match.node, corrected_text, match.type); const success = replaceText(match.node, corrected_text, match.type)
if (success) { if (success) {
successfulReplacements.push({ successfulReplacements.push({
...translation, ...translation,
actualLine: match.line, actualLine: match.line,
replacementType: match.type replacementType: match.type,
}); })
stats.success++; stats.success++
console.log(`✅ 替换成功: "${text}" -> "${corrected_text}" (行 ${match.line})`); console.log(`✅ 替换成功: "${text}" -> "${corrected_text}" (行 ${match.line})`)
} else { } else {
conflicts.push({ conflicts.push({
...translation, ...translation,
conflictType: 'REPLACEMENT_FAILED', conflictType: 'REPLACEMENT_FAILED',
conflictReason: '替换操作失败' conflictReason: '替换操作失败',
}); })
stats.conflicts++; stats.conflicts++
} }
} }
// 保存修改后的文件 // 保存修改后的文件
sourceFile.saveSync(); sourceFile.saveSync()
} catch (error) { } catch (error) {
console.error(`❌ 处理文件失败: ${filePath}`, error.message); console.error(`❌ 处理文件失败: ${filePath}`, error.message)
translations.forEach(t => { translations.forEach((t) => {
conflicts.push({ conflicts.push({
...t, ...t,
conflictType: 'PARSE_ERROR', conflictType: 'PARSE_ERROR',
conflictReason: `文件解析失败: ${error.message}` conflictReason: `文件解析失败: ${error.message}`,
}); })
}); })
stats.conflicts += translations.length; stats.conflicts += translations.length
} }
} }
function generateReport() { function generateReport() {
console.log('\n📊 生成报告...'); console.log('\n📊 生成报告...')
// 生成成功替换报告 // 生成成功替换报告
const report = { const report = {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
stats, stats,
successfulReplacements, successfulReplacements,
conflicts: conflicts.map(c => ({ conflicts: conflicts.map((c) => ({
file: c.file, file: c.file,
line: c.line, line: c.line,
text: c.text, text: c.text,
corrected_text: c.corrected_text, corrected_text: c.corrected_text,
conflictType: c.conflictType, conflictType: c.conflictType,
conflictReason: c.conflictReason conflictReason: c.conflictReason,
})) })),
}; }
fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2)); fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2))
console.log(`📄 成功替换报告已保存: ${REPORT_FILE}`); console.log(`📄 成功替换报告已保存: ${REPORT_FILE}`)
// 生成冲突报告 Excel // 生成冲突报告 Excel
if (conflicts.length > 0) { if (conflicts.length > 0) {
const conflictRows = conflicts.map(c => ({ const conflictRows = conflicts.map((c) => ({
file: c.file, file: c.file,
line: c.line, line: c.line,
text: c.text, text: c.text,
@ -330,53 +327,52 @@ function generateReport() {
route: c.route, route: c.route,
componentOrFn: c.componentOrFn, componentOrFn: c.componentOrFn,
kind: c.kind, kind: c.kind,
keyOrLocator: c.keyOrLocator keyOrLocator: c.keyOrLocator,
})); }))
const ws = XLSX.utils.json_to_sheet(conflictRows); const ws = XLSX.utils.json_to_sheet(conflictRows)
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'conflicts'); XLSX.utils.book_append_sheet(wb, ws, 'conflicts')
XLSX.writeFile(wb, CONFLICTS_FILE); XLSX.writeFile(wb, CONFLICTS_FILE)
console.log(`📄 冲突报告已保存: ${CONFLICTS_FILE}`); console.log(`📄 冲突报告已保存: ${CONFLICTS_FILE}`)
} }
} }
function printSummary() { function printSummary() {
console.log('\n📈 处理完成!'); console.log('\n📈 处理完成!')
console.log(`总翻译条目: ${stats.total}`); console.log(`总翻译条目: ${stats.total}`)
console.log(`✅ 成功替换: ${stats.success}`); console.log(`✅ 成功替换: ${stats.success}`)
console.log(`❌ 文件不存在: ${stats.fileNotFound}`); console.log(`❌ 文件不存在: ${stats.fileNotFound}`)
console.log(`❌ 文本未找到: ${stats.textNotFound}`); console.log(`❌ 文本未找到: ${stats.textNotFound}`)
console.log(`❌ 多处匹配: ${stats.multipleMatches}`); console.log(`❌ 多处匹配: ${stats.multipleMatches}`)
console.log(`❌ 其他冲突: ${stats.conflicts}`); console.log(`❌ 其他冲突: ${stats.conflicts}`)
console.log(`\n成功率: ${((stats.success / stats.total) * 100).toFixed(1)}%`); console.log(`\n成功率: ${((stats.success / stats.total) * 100).toFixed(1)}%`)
} }
async function main() { async function main() {
console.log('🚀 开始应用翻译...\n'); console.log('🚀 开始应用翻译...\n')
try { try {
// 1. 读取翻译数据 // 1. 读取翻译数据
const translations = loadTranslations(); const translations = loadTranslations()
// 2. 按文件分组 // 2. 按文件分组
const fileGroups = groupByFile(translations); const fileGroups = groupByFile(translations)
// 3. 处理每个文件 // 3. 处理每个文件
for (const [filePath, fileTranslations] of fileGroups) { for (const [filePath, fileTranslations] of fileGroups) {
processFile(filePath, fileTranslations); processFile(filePath, fileTranslations)
} }
// 4. 生成报告 // 4. 生成报告
generateReport(); generateReport()
// 5. 打印总结 // 5. 打印总结
printSummary(); printSummary()
} catch (error) { } catch (error) {
console.error('❌ 执行失败:', error); console.error('❌ 执行失败:', error)
process.exitCode = 1; process.exitCode = 1
} }
} }
main(); main()

View File

@ -2,104 +2,104 @@
将现有的 copy-audit.xlsx 转换为 i18next 格式的翻译文件 将现有的 copy-audit.xlsx 转换为 i18next 格式的翻译文件
*/ */
const fs = require('fs'); const fs = require('fs')
const path = require('path'); const path = require('path')
const XLSX = require('xlsx'); const XLSX = require('xlsx')
const WORKDIR = process.cwd(); const WORKDIR = process.cwd()
function generateI18nKey(item) { function generateI18nKey(item) {
// 生成 i18next 格式的键名 // 生成 i18next 格式的键名
const route = item.route === "shared" ? "common" : item.route.replace(/[^a-zA-Z0-9]/g, "_"); const route = item.route === 'shared' ? 'common' : item.route.replace(/[^a-zA-Z0-9]/g, '_')
const component = item.componentOrFn.replace(/[^a-zA-Z0-9]/g, "_"); const component = item.componentOrFn.replace(/[^a-zA-Z0-9]/g, '_')
const kind = item.kind; const kind = item.kind
const locator = item.keyOrLocator.replace(/[^a-zA-Z0-9]/g, "_"); const locator = item.keyOrLocator.replace(/[^a-zA-Z0-9]/g, '_')
return `${route}.${component}.${kind}.${locator}`.toLowerCase(); return `${route}.${component}.${kind}.${locator}`.toLowerCase()
} }
function main() { function main() {
// 读取现有的 Excel 文件 // 读取现有的 Excel 文件
const excelFile = path.join(WORKDIR, 'docs', 'copy-audit.xlsx'); const excelFile = path.join(WORKDIR, 'docs', 'copy-audit.xlsx')
if (!fs.existsSync(excelFile)) { if (!fs.existsSync(excelFile)) {
console.error('❌ 找不到 copy-audit.xlsx 文件,请先运行 extract-copy 脚本'); console.error('❌ 找不到 copy-audit.xlsx 文件,请先运行 extract-copy 脚本')
process.exit(1); process.exit(1)
} }
const workbook = XLSX.readFile(excelFile); const workbook = XLSX.readFile(excelFile)
const sheetName = workbook.SheetNames[0]; const sheetName = workbook.SheetNames[0]
const worksheet = workbook.Sheets[sheetName]; const worksheet = workbook.Sheets[sheetName]
const data = XLSX.utils.sheet_to_json(worksheet); const data = XLSX.utils.sheet_to_json(worksheet)
console.log(`📊 读取到 ${data.length} 条记录`); console.log(`📊 读取到 ${data.length} 条记录`)
// 生成 i18next 格式的翻译文件 // 生成 i18next 格式的翻译文件
const translation = {}; const translation = {}
const i18nKeys = []; const i18nKeys = []
data.forEach((item, index) => { data.forEach((item, index) => {
if (item.text && item.text.trim()) { if (item.text && item.text.trim()) {
const key = generateI18nKey(item); const key = generateI18nKey(item)
translation[key] = item.text; translation[key] = item.text
i18nKeys.push({ i18nKeys.push({
key, key,
value: item.text, value: item.text,
route: item.route, route: item.route,
file: item.file, file: item.file,
line: item.line, line: item.line,
kind: item.kind kind: item.kind,
}); })
} }
}); })
// 确保目录存在 // 确保目录存在
const localesDir = path.join(WORKDIR, 'public', 'locales', 'en'); const localesDir = path.join(WORKDIR, 'public', 'locales', 'en')
if (!fs.existsSync(localesDir)) { if (!fs.existsSync(localesDir)) {
fs.mkdirSync(localesDir, { recursive: true }); fs.mkdirSync(localesDir, { recursive: true })
} }
// 写入翻译文件 // 写入翻译文件
const translationFile = path.join(localesDir, 'translation.json'); const translationFile = path.join(localesDir, 'translation.json')
fs.writeFileSync(translationFile, JSON.stringify(translation, null, 2)); fs.writeFileSync(translationFile, JSON.stringify(translation, null, 2))
// 生成详细的 i18n 扫描报告 // 生成详细的 i18n 扫描报告
const report = { const report = {
totalItems: data.length, totalItems: data.length,
uniqueTexts: new Set(data.map(item => item.text)).size, uniqueTexts: new Set(data.map((item) => item.text)).size,
translationKeys: Object.keys(translation).length, translationKeys: Object.keys(translation).length,
byRoute: data.reduce((acc, item) => { byRoute: data.reduce((acc, item) => {
acc[item.route] = (acc[item.route] || 0) + 1; acc[item.route] = (acc[item.route] || 0) + 1
return acc; return acc
}, {}), }, {}),
byKind: data.reduce((acc, item) => { byKind: data.reduce((acc, item) => {
acc[item.kind] = (acc[item.kind] || 0) + 1; acc[item.kind] = (acc[item.kind] || 0) + 1
return acc; return acc
}, {}), }, {}),
sampleKeys: Object.keys(translation).slice(0, 10) sampleKeys: Object.keys(translation).slice(0, 10),
}; }
// 生成 Excel 格式的 i18n 报告 // 生成 Excel 格式的 i18n 报告
const i18nWorkbook = XLSX.utils.book_new(); const i18nWorkbook = XLSX.utils.book_new()
const i18nSheet = XLSX.utils.json_to_sheet(i18nKeys); const i18nSheet = XLSX.utils.json_to_sheet(i18nKeys)
XLSX.utils.book_append_sheet(i18nWorkbook, i18nSheet, 'i18n-keys'); XLSX.utils.book_append_sheet(i18nWorkbook, i18nSheet, 'i18n-keys')
const i18nReportFile = path.join(WORKDIR, 'docs', 'i18n-scan-report.xlsx'); const i18nReportFile = path.join(WORKDIR, 'docs', 'i18n-scan-report.xlsx')
XLSX.writeFile(i18nWorkbook, i18nReportFile); XLSX.writeFile(i18nWorkbook, i18nReportFile)
// 生成 JSON 报告 // 生成 JSON 报告
const reportFile = path.join(WORKDIR, 'docs', 'i18n-scan-report.json'); const reportFile = path.join(WORKDIR, 'docs', 'i18n-scan-report.json')
fs.writeFileSync(reportFile, JSON.stringify(report, null, 2)); fs.writeFileSync(reportFile, JSON.stringify(report, null, 2))
console.log('✅ i18next 扫描转换完成!'); console.log('✅ i18next 扫描转换完成!')
console.log(`📊 总扫描条目: ${data.length}`); console.log(`📊 总扫描条目: ${data.length}`)
console.log(`🔑 生成翻译键: ${Object.keys(translation).length}`); console.log(`🔑 生成翻译键: ${Object.keys(translation).length}`)
console.log(`📁 翻译文件: ${translationFile}`); console.log(`📁 翻译文件: ${translationFile}`)
console.log(`📋 i18n Excel 报告: ${i18nReportFile}`); console.log(`📋 i18n Excel 报告: ${i18nReportFile}`)
console.log(`📄 JSON 报告: ${reportFile}`); console.log(`📄 JSON 报告: ${reportFile}`)
console.log('\n📝 示例翻译键:'); console.log('\n📝 示例翻译键:')
report.sampleKeys.forEach(key => { report.sampleKeys.forEach((key) => {
console.log(` ${key}: "${translation[key]}"`); console.log(` ${key}: "${translation[key]}"`)
}); })
} }
main(); main()

View File

@ -1,105 +1,114 @@
/* /*
CommonJS runtime for extracting user-facing copy into Excel. CommonJS runtime for extracting user-facing copy into Excel.
*/ */
const path = require('node:path'); const path = require('node:path')
const fs = require('node:fs'); const fs = require('node:fs')
const { globby } = require('globby'); const { globby } = require('globby')
const { Project, SyntaxKind, Node } = require('ts-morph'); const { Project, SyntaxKind, Node } = require('ts-morph')
const XLSX = require('xlsx'); const XLSX = require('xlsx')
const WORKDIR = process.cwd(); const WORKDIR = process.cwd()
const SRC_DIR = path.join(WORKDIR, 'src'); const SRC_DIR = path.join(WORKDIR, 'src')
const APP_DIR = path.join(SRC_DIR, 'app'); const APP_DIR = path.join(SRC_DIR, 'app')
function ensureExcelDir() { function ensureExcelDir() {
const docsDir = path.join(WORKDIR, 'docs'); const docsDir = path.join(WORKDIR, 'docs')
if (!fs.existsSync(docsDir)) fs.mkdirSync(docsDir, { recursive: true }); if (!fs.existsSync(docsDir)) fs.mkdirSync(docsDir, { recursive: true })
} }
function isMeaningfulText(value) { function isMeaningfulText(value) {
if (!value) return false; if (!value) return false
const trimmed = String(value).replace(/\s+/g, ' ').trim(); const trimmed = String(value).replace(/\s+/g, ' ').trim()
if (!trimmed) return false; if (!trimmed) return false
if (/^[A-Za-z0-9_.$-]+$/.test(trimmed) && trimmed.length > 24) return false; if (/^[A-Za-z0-9_.$-]+$/.test(trimmed) && trimmed.length > 24) return false
return true; return true
} }
function getRouteForFile(absFilePath) { function getRouteForFile(absFilePath) {
if (!absFilePath.startsWith(APP_DIR)) return 'shared'; if (!absFilePath.startsWith(APP_DIR)) return 'shared'
let dir = path.dirname(absFilePath); let dir = path.dirname(absFilePath)
while (dir.startsWith(APP_DIR)) { while (dir.startsWith(APP_DIR)) {
const pageTsx = path.join(dir, 'page.tsx'); const pageTsx = path.join(dir, 'page.tsx')
const pageTs = path.join(dir, 'page.ts'); const pageTs = path.join(dir, 'page.ts')
if (fs.existsSync(pageTsx) || fs.existsSync(pageTs)) { if (fs.existsSync(pageTsx) || fs.existsSync(pageTs)) {
const rel = path.relative(APP_DIR, dir); const rel = path.relative(APP_DIR, dir)
return rel || '/'; return rel || '/'
} }
const parent = path.dirname(dir); const parent = path.dirname(dir)
if (parent === dir) break; if (parent === dir) break
dir = parent; dir = parent
} }
const relToApp = path.relative(APP_DIR, absFilePath); const relToApp = path.relative(APP_DIR, absFilePath)
const parts = relToApp.split(path.sep); const parts = relToApp.split(path.sep)
return parts.length > 0 ? parts[0] : 'shared'; return parts.length > 0 ? parts[0] : 'shared'
} }
function getComponentOrFnName(node) { function getComponentOrFnName(node) {
const fn = node.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration); const fn = node.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration)
if (fn && fn.getName && fn.getName()) return fn.getName(); if (fn && fn.getName && fn.getName()) return fn.getName()
const varDecl = node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration); const varDecl = node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration)
if (varDecl && varDecl.getName) return varDecl.getName(); if (varDecl && varDecl.getName) return varDecl.getName()
const cls = node.getFirstAncestorByKind(SyntaxKind.ClassDeclaration); const cls = node.getFirstAncestorByKind(SyntaxKind.ClassDeclaration)
if (cls && cls.getName && cls.getName()) return cls.getName(); if (cls && cls.getName && cls.getName()) return cls.getName()
const sf = node.getSourceFile(); const sf = node.getSourceFile()
return path.basename(sf.getFilePath()); return path.basename(sf.getFilePath())
} }
function getNodeLine(node) { function getNodeLine(node) {
const pos = node.getStartLineNumber && node.getStartLineNumber(); const pos = node.getStartLineNumber && node.getStartLineNumber()
return pos || 1; return pos || 1
} }
function getAttrName(attr) { function getAttrName(attr) {
return attr.getNameNode().getText(); return attr.getNameNode().getText()
} }
function getStringFromInitializer(attr) { function getStringFromInitializer(attr) {
const init = attr.getInitializer(); const init = attr.getInitializer()
if (!init) return undefined; if (!init) return undefined
if (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init)) return init.getLiteralText(); if (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
return init.getLiteralText()
if (Node.isJsxExpression(init)) { if (Node.isJsxExpression(init)) {
const expr = init.getExpression(); const expr = init.getExpression()
if (!expr) return undefined; if (!expr) return undefined
if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) return expr.getLiteralText(); if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))
return expr.getLiteralText()
} }
return undefined; return undefined
} }
async function collectFiles() { async function collectFiles() {
const patterns = ['src/**/*.{ts,tsx}']; const patterns = ['src/**/*.{ts,tsx}']
const ignore = ['**/node_modules/**','**/.next/**','**/__tests__/**','**/mocks/**','**/mock/**','**/*.d.ts']; const ignore = [
return await globby(patterns, { gitignore: true, ignore }); '**/node_modules/**',
'**/.next/**',
'**/__tests__/**',
'**/mocks/**',
'**/mock/**',
'**/*.d.ts',
]
return await globby(patterns, { gitignore: true, ignore })
} }
function pushItem(items, item) { function pushItem(items, item) {
if (!isMeaningfulText(item.text)) return; if (!isMeaningfulText(item.text)) return
items.push(item); items.push(item)
} }
function extractFromSourceFile(abs, items, project) { function extractFromSourceFile(abs, items, project) {
const sf = project.addSourceFileAtPath(abs); const sf = project.addSourceFileAtPath(abs)
sf.forEachDescendant((node) => { sf.forEachDescendant((node) => {
// JSX text nodes // JSX text nodes
if (Node.isJsxElement(node)) { if (Node.isJsxElement(node)) {
const opening = node.getOpeningElement(); const opening = node.getOpeningElement()
const componentOrFn = getComponentOrFnName(node); const componentOrFn = getComponentOrFnName(node)
const route = getRouteForFile(abs); const route = getRouteForFile(abs)
const tagName = opening.getTagNameNode().getText(); const tagName = opening.getTagNameNode().getText()
// 递归抓取所有子层级文本节点 // 递归抓取所有子层级文本节点
const textNodes = node.getDescendantsOfKind(SyntaxKind.JsxText); const textNodes = node.getDescendantsOfKind(SyntaxKind.JsxText)
textNodes.forEach((t) => { textNodes.forEach((t) => {
const text = t.getText(); const text = t.getText()
const cleaned = text.replace(/\s+/g, ' ').trim(); const cleaned = text.replace(/\s+/g, ' ').trim()
if (isMeaningfulText(cleaned)) { if (isMeaningfulText(cleaned)) {
pushItem(items, { pushItem(items, {
route, route,
@ -109,15 +118,15 @@ function extractFromSourceFile(abs, items, project) {
keyOrLocator: tagName, keyOrLocator: tagName,
text: cleaned, text: cleaned,
line: getNodeLine(t), line: getNodeLine(t),
}); })
} }
}); })
// 抓取 {'...'} 这类表达式中的字符串字面量 // 抓取 {'...'} 这类表达式中的字符串字面量
const exprs = node.getDescendantsOfKind(SyntaxKind.JsxExpression); const exprs = node.getDescendantsOfKind(SyntaxKind.JsxExpression)
exprs.forEach((expr) => { exprs.forEach((expr) => {
const inner = expr.getExpression(); const inner = expr.getExpression()
if (inner && (Node.isStringLiteral(inner) || Node.isNoSubstitutionTemplateLiteral(inner))) { if (inner && (Node.isStringLiteral(inner) || Node.isNoSubstitutionTemplateLiteral(inner))) {
const cleaned = inner.getLiteralText().replace(/\s+/g, ' ').trim(); const cleaned = inner.getLiteralText().replace(/\s+/g, ' ').trim()
if (isMeaningfulText(cleaned)) { if (isMeaningfulText(cleaned)) {
pushItem(items, { pushItem(items, {
route, route,
@ -127,29 +136,29 @@ function extractFromSourceFile(abs, items, project) {
keyOrLocator: tagName, keyOrLocator: tagName,
text: cleaned, text: cleaned,
line: getNodeLine(expr), line: getNodeLine(expr),
}); })
} }
} }
}); })
} }
// JSX attributes // JSX attributes
if (Node.isJsxOpeningElement(node) || Node.isJsxSelfClosingElement(node)) { if (Node.isJsxOpeningElement(node) || Node.isJsxSelfClosingElement(node)) {
const route = getRouteForFile(abs); const route = getRouteForFile(abs)
const componentOrFn = getComponentOrFnName(node); const componentOrFn = getComponentOrFnName(node)
const tag = node.getTagNameNode().getText(); const tag = node.getTagNameNode().getText()
const attrs = node.getAttributes().filter(Node.isJsxAttribute); const attrs = node.getAttributes().filter(Node.isJsxAttribute)
attrs.forEach((attr) => { attrs.forEach((attr) => {
const name = getAttrName(attr); const name = getAttrName(attr)
const lower = name.toLowerCase(); const lower = name.toLowerCase()
const value = getStringFromInitializer(attr); const value = getStringFromInitializer(attr)
if (!value) return; if (!value) return
let kind = null; let kind = null
if (lower === 'placeholder') kind = 'placeholder'; if (lower === 'placeholder') kind = 'placeholder'
else if (lower === 'title') kind = 'title'; else if (lower === 'title') kind = 'title'
else if (lower === 'alt') kind = 'alt'; else if (lower === 'alt') kind = 'alt'
else if (lower.startsWith('aria-')) kind = 'aria'; else if (lower.startsWith('aria-')) kind = 'aria'
else if (lower === 'label') kind = 'label'; else if (lower === 'label') kind = 'label'
if (kind) { if (kind) {
pushItem(items, { pushItem(items, {
route, route,
@ -159,47 +168,75 @@ function extractFromSourceFile(abs, items, project) {
keyOrLocator: `${tag}.${name}`, keyOrLocator: `${tag}.${name}`,
text: value, text: value,
line: getNodeLine(attr), line: getNodeLine(attr),
}); })
} }
}); })
} }
// Interaction messages // Interaction messages
if (Node.isCallExpression(node)) { if (Node.isCallExpression(node)) {
const route = getRouteForFile(abs); const route = getRouteForFile(abs)
const componentOrFn = getComponentOrFnName(node); const componentOrFn = getComponentOrFnName(node)
const expr = node.getExpression(); const expr = node.getExpression()
let kind = null; let kind = null
let keyOrLocator = ''; let keyOrLocator = ''
if (Node.isPropertyAccessExpression(expr)) { if (Node.isPropertyAccessExpression(expr)) {
const left = expr.getExpression().getText(); const left = expr.getExpression().getText()
const name = expr.getName(); const name = expr.getName()
if (left === 'toast' || left === 'message') { kind = 'toast'; keyOrLocator = `${left}.${name}`; } if (left === 'toast' || left === 'message') {
if ((left || '').toLowerCase().includes('dialog')) { kind = 'dialog'; keyOrLocator = `${left}.${name}`; } kind = 'toast'
keyOrLocator = `${left}.${name}`
}
if ((left || '').toLowerCase().includes('dialog')) {
kind = 'dialog'
keyOrLocator = `${left}.${name}`
}
} else if (Node.isIdentifier(expr)) { } else if (Node.isIdentifier(expr)) {
const id = expr.getText(); const id = expr.getText()
if (id === 'alert' || id === 'confirm') { kind = 'dialog'; keyOrLocator = id; } if (id === 'alert' || id === 'confirm') {
kind = 'dialog'
keyOrLocator = id
}
} }
if (kind) { if (kind) {
const arg0 = node.getArguments()[0]; const arg0 = node.getArguments()[0]
if (arg0 && (Node.isStringLiteral(arg0) || Node.isNoSubstitutionTemplateLiteral(arg0))) { if (arg0 && (Node.isStringLiteral(arg0) || Node.isNoSubstitutionTemplateLiteral(arg0))) {
const text = arg0.getLiteralText(); const text = arg0.getLiteralText()
pushItem(items, { route, file: path.relative(WORKDIR, abs), componentOrFn, kind, keyOrLocator, text, line: getNodeLine(node) }); pushItem(items, {
route,
file: path.relative(WORKDIR, abs),
componentOrFn,
kind,
keyOrLocator,
text,
line: getNodeLine(node),
})
} }
} }
// form.setError("field", { message: "..." }) // form.setError("field", { message: "..." })
if (Node.isPropertyAccessExpression(expr) && expr.getName() === 'setError') { if (Node.isPropertyAccessExpression(expr) && expr.getName() === 'setError') {
const args = node.getArguments(); const args = node.getArguments()
if (args.length >= 2) { if (args.length >= 2) {
const second = args[1]; const second = args[1]
if (Node.isObjectLiteralExpression(second)) { if (Node.isObjectLiteralExpression(second)) {
const msgProp = second.getProperty('message'); const msgProp = second.getProperty('message')
if (msgProp && Node.isPropertyAssignment(msgProp)) { if (msgProp && Node.isPropertyAssignment(msgProp)) {
const init = msgProp.getInitializer(); const init = msgProp.getInitializer()
if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) { if (
const text = init.getLiteralText(); init &&
pushItem(items, { route, file: path.relative(WORKDIR, abs), componentOrFn, kind: 'error', keyOrLocator: 'form.setError', text, line: getNodeLine(msgProp) }); (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
) {
const text = init.getLiteralText()
pushItem(items, {
route,
file: path.relative(WORKDIR, abs),
componentOrFn,
kind: 'error',
keyOrLocator: 'form.setError',
text,
line: getNodeLine(msgProp),
})
} }
} }
} }
@ -207,35 +244,46 @@ function extractFromSourceFile(abs, items, project) {
} }
// Generic validation object { message: "..." } // Generic validation object { message: "..." }
const args = node.getArguments(); const args = node.getArguments()
for (const a of args) { for (const a of args) {
if (Node.isObjectLiteralExpression(a)) { if (Node.isObjectLiteralExpression(a)) {
const prop = a.getProperty('message'); const prop = a.getProperty('message')
if (prop && Node.isPropertyAssignment(prop)) { if (prop && Node.isPropertyAssignment(prop)) {
const init = prop.getInitializer(); const init = prop.getInitializer()
if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) { if (
const text = init.getLiteralText(); init &&
pushItem(items, { route, file: path.relative(WORKDIR, abs), componentOrFn, kind: 'validation', keyOrLocator: 'message', text, line: getNodeLine(prop) }); (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
) {
const text = init.getLiteralText()
pushItem(items, {
route,
file: path.relative(WORKDIR, abs),
componentOrFn,
kind: 'validation',
keyOrLocator: 'message',
text,
line: getNodeLine(prop),
})
} }
} }
} }
} }
} }
}); })
} }
function aggregate(items) { function aggregate(items) {
const map = new Map(); const map = new Map()
for (const it of items) { for (const it of items) {
const key = `${it.route}__${it.kind}__${it.keyOrLocator}__${it.text}`; const key = `${it.route}__${it.kind}__${it.keyOrLocator}__${it.text}`
if (!map.has(key)) map.set(key, { item: it, count: 1 }); if (!map.has(key)) map.set(key, { item: it, count: 1 })
else map.get(key).count += 1; else map.get(key).count += 1
} }
const result = []; const result = []
for (const { item, count } of map.values()) { for (const { item, count } of map.values()) {
result.push({ ...item, count }); result.push({ ...item, count })
} }
return result; return result
} }
function toWorkbook(items) { function toWorkbook(items) {
@ -249,36 +297,37 @@ function toWorkbook(items) {
line: it.line, line: it.line,
count: it.count || 1, count: it.count || 1,
notes: it.notes || '', notes: it.notes || '',
})); }))
const ws = XLSX.utils.json_to_sheet(rows, { skipHeader: false }); const ws = XLSX.utils.json_to_sheet(rows, { skipHeader: false })
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'copy'); XLSX.utils.book_append_sheet(wb, ws, 'copy')
return wb; return wb
} }
async function main() { async function main() {
ensureExcelDir(); ensureExcelDir()
const files = await collectFiles(); const files = await collectFiles()
const project = new Project({ tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'), skipAddingFilesFromTsConfig: true }); const project = new Project({
const items = []; tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'),
skipAddingFilesFromTsConfig: true,
})
const items = []
for (const rel of files) { for (const rel of files) {
const abs = path.join(WORKDIR, rel); const abs = path.join(WORKDIR, rel)
try { try {
extractFromSourceFile(abs, items, project); extractFromSourceFile(abs, items, project)
} catch (e) { } catch (e) {
// continue on parse errors // continue on parse errors
} }
} }
const aggregated = aggregate(items); const aggregated = aggregate(items)
const wb = toWorkbook(aggregated); const wb = toWorkbook(aggregated)
const out = path.join(WORKDIR, 'docs', 'copy-audit.xlsx'); const out = path.join(WORKDIR, 'docs', 'copy-audit.xlsx')
XLSX.writeFile(wb, out); XLSX.writeFile(wb, out)
console.log(`Wrote ${aggregated.length} rows to ${out}`); console.log(`Wrote ${aggregated.length} rows to ${out}`)
} }
main().catch((err) => { main().catch((err) => {
console.error(err); console.error(err)
process.exitCode = 1; process.exitCode = 1
}); })

View File

@ -3,193 +3,198 @@
Scans TS/TSX under src/, groups by Next.js App Router route, and writes docs/copy-audit.xlsx. Scans TS/TSX under src/, groups by Next.js App Router route, and writes docs/copy-audit.xlsx.
*/ */
import path from "node:path"; import path from 'node:path'
import fs from "node:fs"; import fs from 'node:fs'
import { globby } from "globby"; import { globby } from 'globby'
import { Project, SyntaxKind, Node, JsxAttribute, StringLiteral, NoSubstitutionTemplateLiteral } from "ts-morph"; import {
import * as XLSX from "xlsx"; Project,
SyntaxKind,
Node,
JsxAttribute,
StringLiteral,
NoSubstitutionTemplateLiteral,
} from 'ts-morph'
import * as XLSX from 'xlsx'
type CopyKind = type CopyKind =
| "text" | 'text'
| "placeholder" | 'placeholder'
| "title" | 'title'
| "alt" | 'alt'
| "aria" | 'aria'
| "label" | 'label'
| "toast" | 'toast'
| "dialog" | 'dialog'
| "error" | 'error'
| "validation"; | 'validation'
interface CopyItem { interface CopyItem {
route: string; route: string
file: string; file: string
componentOrFn: string; componentOrFn: string
kind: CopyKind; kind: CopyKind
keyOrLocator: string; keyOrLocator: string
text: string; text: string
line: number; line: number
notes?: string; notes?: string
} }
const WORKDIR = process.cwd(); const WORKDIR = process.cwd()
const SRC_DIR = path.join(WORKDIR, "src"); const SRC_DIR = path.join(WORKDIR, 'src')
const APP_DIR = path.join(SRC_DIR, "app"); const APP_DIR = path.join(SRC_DIR, 'app')
function ensureExcelDir() { function ensureExcelDir() {
const docsDir = path.join(WORKDIR, "docs"); const docsDir = path.join(WORKDIR, 'docs')
if (!fs.existsSync(docsDir)) fs.mkdirSync(docsDir, { recursive: true }); if (!fs.existsSync(docsDir)) fs.mkdirSync(docsDir, { recursive: true })
} }
function isMeaningfulText(value: string | undefined | null): value is string { function isMeaningfulText(value: string | undefined | null): value is string {
if (!value) return false; if (!value) return false
const trimmed = value.replace(/\s+/g, " ").trim(); const trimmed = value.replace(/\s+/g, ' ').trim()
if (!trimmed) return false; if (!trimmed) return false
// Filter obvious code-like tokens // Filter obvious code-like tokens
if (/^[A-Za-z0-9_.$-]+$/.test(trimmed) && trimmed.length > 24) return false; if (/^[A-Za-z0-9_.$-]+$/.test(trimmed) && trimmed.length > 24) return false
return true; return true
} }
function getRouteForFile(absFilePath: string): string { function getRouteForFile(absFilePath: string): string {
if (!absFilePath.startsWith(APP_DIR)) return "shared"; if (!absFilePath.startsWith(APP_DIR)) return 'shared'
let dir = path.dirname(absFilePath); let dir = path.dirname(absFilePath)
// Walk up to find nearest folder that contains a page.tsx (or page.ts) // Walk up to find nearest folder that contains a page.tsx (or page.ts)
while (dir.startsWith(APP_DIR)) { while (dir.startsWith(APP_DIR)) {
const pageTsx = path.join(dir, "page.tsx"); const pageTsx = path.join(dir, 'page.tsx')
const pageTs = path.join(dir, "page.ts"); const pageTs = path.join(dir, 'page.ts')
if (fs.existsSync(pageTsx) || fs.existsSync(pageTs)) { if (fs.existsSync(pageTsx) || fs.existsSync(pageTs)) {
const rel = path.relative(APP_DIR, dir); const rel = path.relative(APP_DIR, dir)
return rel || "/"; return rel || '/'
} }
const parent = path.dirname(dir); const parent = path.dirname(dir)
if (parent === dir) break; if (parent === dir) break
dir = parent; dir = parent
} }
// Fallback: route is the first app subfolder segment // Fallback: route is the first app subfolder segment
const relToApp = path.relative(APP_DIR, absFilePath); const relToApp = path.relative(APP_DIR, absFilePath)
const parts = relToApp.split(path.sep); const parts = relToApp.split(path.sep)
return parts.length > 0 ? parts[0] : "shared"; return parts.length > 0 ? parts[0] : 'shared'
} }
function getComponentOrFnName(node: Node): string { function getComponentOrFnName(node: Node): string {
const fn = node.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration); const fn = node.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration)
if (fn?.getName()) return fn.getName()!; if (fn?.getName()) return fn.getName()!
const varDecl = node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration); const varDecl = node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration)
if (varDecl?.getName()) return varDecl.getName(); if (varDecl?.getName()) return varDecl.getName()
const cls = node.getFirstAncestorByKind(SyntaxKind.ClassDeclaration); const cls = node.getFirstAncestorByKind(SyntaxKind.ClassDeclaration)
if (cls?.getName()) return cls.getName()!; if (cls?.getName()) return cls.getName()!
const sf = node.getSourceFile(); const sf = node.getSourceFile()
return path.basename(sf.getFilePath()); return path.basename(sf.getFilePath())
} }
function getNodeLine(node: Node): number { function getNodeLine(node: Node): number {
const pos = node.getStartLineNumber(); const pos = node.getStartLineNumber()
return pos ?? 1; return pos ?? 1
} }
function getAttrName(attr: JsxAttribute): string { function getAttrName(attr: JsxAttribute): string {
return attr.getNameNode().getText(); return attr.getNameNode().getText()
} }
function getStringFromInitializer(attr: JsxAttribute): string | undefined { function getStringFromInitializer(attr: JsxAttribute): string | undefined {
const init = attr.getInitializer(); const init = attr.getInitializer()
if (!init) return undefined; if (!init) return undefined
if (Node.isStringLiteral(init)) return init.getLiteralText(); if (Node.isStringLiteral(init)) return init.getLiteralText()
if (Node.isNoSubstitutionTemplateLiteral(init)) return init.getLiteralText(); if (Node.isNoSubstitutionTemplateLiteral(init)) return init.getLiteralText()
if (Node.isJsxExpression(init)) { if (Node.isJsxExpression(init)) {
const expr = init.getExpression(); const expr = init.getExpression()
if (!expr) return undefined; if (!expr) return undefined
if (Node.isStringLiteral(expr)) return expr.getLiteralText(); if (Node.isStringLiteral(expr)) return expr.getLiteralText()
if (Node.isNoSubstitutionTemplateLiteral(expr)) return expr.getLiteralText(); if (Node.isNoSubstitutionTemplateLiteral(expr)) return expr.getLiteralText()
} }
return undefined; return undefined
} }
function pushItem(items: CopyItem[], item: CopyItem) { function pushItem(items: CopyItem[], item: CopyItem) {
if (!isMeaningfulText(item.text)) return; if (!isMeaningfulText(item.text)) return
items.push(item); items.push(item)
} }
async function collectFiles(): Promise<string[]> { async function collectFiles(): Promise<string[]> {
const patterns = [ const patterns = ['src/**/*.{ts,tsx}']
"src/**/*.{ts,tsx}",
];
const ignore = [ const ignore = [
"**/node_modules/**", '**/node_modules/**',
"**/.next/**", '**/.next/**',
"**/__tests__/**", '**/__tests__/**',
"**/mocks/**", '**/mocks/**',
"**/mock/**", '**/mock/**',
"**/*.d.ts", '**/*.d.ts',
]; ]
return await globby(patterns, { gitignore: true, ignore }); return await globby(patterns, { gitignore: true, ignore })
} }
function extractFromSourceFile(abs: string, items: CopyItem[], project: Project) { function extractFromSourceFile(abs: string, items: CopyItem[], project: Project) {
const sf = project.addSourceFileAtPath(abs); const sf = project.addSourceFileAtPath(abs)
// JSX text nodes // JSX text nodes
sf.forEachDescendant((node) => { sf.forEachDescendant((node) => {
if (Node.isJsxElement(node)) { if (Node.isJsxElement(node)) {
const opening = node.getOpeningElement(); const opening = node.getOpeningElement()
const componentOrFn = getComponentOrFnName(node); const componentOrFn = getComponentOrFnName(node)
const route = getRouteForFile(abs); const route = getRouteForFile(abs)
// 递归提取所有 JsxText 与 {'...'} 字面量 // 递归提取所有 JsxText 与 {'...'} 字面量
const tagName = opening.getTagNameNode().getText(); const tagName = opening.getTagNameNode().getText()
const textNodes = node.getDescendantsOfKind(SyntaxKind.JsxText); const textNodes = node.getDescendantsOfKind(SyntaxKind.JsxText)
textNodes.forEach((t) => { textNodes.forEach((t) => {
const text = t.getText(); const text = t.getText()
const cleaned = text.replace(/\s+/g, " ").trim(); const cleaned = text.replace(/\s+/g, ' ').trim()
if (isMeaningfulText(cleaned)) { if (isMeaningfulText(cleaned)) {
pushItem(items, { pushItem(items, {
route, route,
file: path.relative(WORKDIR, abs), file: path.relative(WORKDIR, abs),
componentOrFn, componentOrFn,
kind: "text", kind: 'text',
keyOrLocator: tagName, keyOrLocator: tagName,
text: cleaned, text: cleaned,
line: getNodeLine(t), line: getNodeLine(t),
}); })
} }
}); })
const exprs = node.getDescendantsOfKind(SyntaxKind.JsxExpression); const exprs = node.getDescendantsOfKind(SyntaxKind.JsxExpression)
exprs.forEach((expr) => { exprs.forEach((expr) => {
const inner = expr.getExpression(); const inner = expr.getExpression()
if (inner && (Node.isStringLiteral(inner) || Node.isNoSubstitutionTemplateLiteral(inner))) { if (inner && (Node.isStringLiteral(inner) || Node.isNoSubstitutionTemplateLiteral(inner))) {
const cleaned = inner.getLiteralText().replace(/\s+/g, " ").trim(); const cleaned = inner.getLiteralText().replace(/\s+/g, ' ').trim()
if (isMeaningfulText(cleaned)) { if (isMeaningfulText(cleaned)) {
pushItem(items, { pushItem(items, {
route, route,
file: path.relative(WORKDIR, abs), file: path.relative(WORKDIR, abs),
componentOrFn, componentOrFn,
kind: "text", kind: 'text',
keyOrLocator: tagName, keyOrLocator: tagName,
text: cleaned, text: cleaned,
line: getNodeLine(expr), line: getNodeLine(expr),
}); })
} }
} }
}); })
} }
// JSX attributes // JSX attributes
if (Node.isJsxOpeningElement(node) || Node.isJsxSelfClosingElement(node)) { if (Node.isJsxOpeningElement(node) || Node.isJsxSelfClosingElement(node)) {
const route = getRouteForFile(abs); const route = getRouteForFile(abs)
const componentOrFn = getComponentOrFnName(node); const componentOrFn = getComponentOrFnName(node)
const tag = Node.isJsxOpeningElement(node) const tag = Node.isJsxOpeningElement(node)
? node.getTagNameNode().getText() ? node.getTagNameNode().getText()
: node.getTagNameNode().getText(); : node.getTagNameNode().getText()
const attrs = node.getAttributes().filter(Node.isJsxAttribute); const attrs = node.getAttributes().filter(Node.isJsxAttribute)
attrs.forEach((attr) => { attrs.forEach((attr) => {
const name = getAttrName(attr); const name = getAttrName(attr)
const lower = name.toLowerCase(); const lower = name.toLowerCase()
const value = getStringFromInitializer(attr); const value = getStringFromInitializer(attr)
if (!value) return; if (!value) return
let kind: CopyKind | null = null; let kind: CopyKind | null = null
if (lower === "placeholder") kind = "placeholder"; if (lower === 'placeholder') kind = 'placeholder'
else if (lower === "title") kind = "title"; else if (lower === 'title') kind = 'title'
else if (lower === "alt") kind = "alt"; else if (lower === 'alt') kind = 'alt'
else if (lower.startsWith("aria-")) kind = "aria"; else if (lower.startsWith('aria-')) kind = 'aria'
else if (lower === "label") kind = "label"; else if (lower === 'label') kind = 'label'
if (kind) { if (kind) {
pushItem(items, { pushItem(items, {
route, route,
@ -199,40 +204,40 @@ function extractFromSourceFile(abs: string, items: CopyItem[], project: Project)
keyOrLocator: `${tag}.${name}`, keyOrLocator: `${tag}.${name}`,
text: value, text: value,
line: getNodeLine(attr), line: getNodeLine(attr),
}); })
} }
}); })
} }
// Interaction messages: toast.*, alert, confirm, message.* // Interaction messages: toast.*, alert, confirm, message.*
if (Node.isCallExpression(node)) { if (Node.isCallExpression(node)) {
const route = getRouteForFile(abs); const route = getRouteForFile(abs)
const componentOrFn = getComponentOrFnName(node); const componentOrFn = getComponentOrFnName(node)
const expr = node.getExpression(); const expr = node.getExpression()
let kind: CopyKind | null = null; let kind: CopyKind | null = null
let keyOrLocator = ""; let keyOrLocator = ''
if (Node.isPropertyAccessExpression(expr)) { if (Node.isPropertyAccessExpression(expr)) {
const left = expr.getExpression().getText(); const left = expr.getExpression().getText()
const name = expr.getName(); const name = expr.getName()
if (left === "toast" || left === "message") { if (left === 'toast' || left === 'message') {
kind = "toast"; kind = 'toast'
keyOrLocator = `${left}.${name}`; keyOrLocator = `${left}.${name}`
} }
if (left.toLowerCase().includes("dialog")) { if (left.toLowerCase().includes('dialog')) {
kind = "dialog"; kind = 'dialog'
keyOrLocator = `${left}.${name}`; keyOrLocator = `${left}.${name}`
} }
} else if (Node.isIdentifier(expr)) { } else if (Node.isIdentifier(expr)) {
const id = expr.getText(); const id = expr.getText()
if (id === "alert" || id === "confirm") { if (id === 'alert' || id === 'confirm') {
kind = "dialog"; kind = 'dialog'
keyOrLocator = id; keyOrLocator = id
} }
} }
if (kind) { if (kind) {
const arg0 = node.getArguments()[0]; const arg0 = node.getArguments()[0]
if (arg0 && (Node.isStringLiteral(arg0) || Node.isNoSubstitutionTemplateLiteral(arg0))) { if (arg0 && (Node.isStringLiteral(arg0) || Node.isNoSubstitutionTemplateLiteral(arg0))) {
const text = (arg0 as StringLiteral | NoSubstitutionTemplateLiteral).getLiteralText(); const text = (arg0 as StringLiteral | NoSubstitutionTemplateLiteral).getLiteralText()
pushItem(items, { pushItem(items, {
route, route,
file: path.relative(WORKDIR, abs), file: path.relative(WORKDIR, abs),
@ -241,30 +246,33 @@ function extractFromSourceFile(abs: string, items: CopyItem[], project: Project)
keyOrLocator, keyOrLocator,
text, text,
line: getNodeLine(node), line: getNodeLine(node),
}); })
} }
} }
// form.setError("field", { message: "..." }) // form.setError("field", { message: "..." })
if (Node.isPropertyAccessExpression(expr) && expr.getName() === "setError") { if (Node.isPropertyAccessExpression(expr) && expr.getName() === 'setError') {
const args = node.getArguments(); const args = node.getArguments()
if (args.length >= 2) { if (args.length >= 2) {
const second = args[1]; const second = args[1]
if (Node.isObjectLiteralExpression(second)) { if (Node.isObjectLiteralExpression(second)) {
const msgProp = second.getProperty("message"); const msgProp = second.getProperty('message')
if (msgProp && Node.isPropertyAssignment(msgProp)) { if (msgProp && Node.isPropertyAssignment(msgProp)) {
const init = msgProp.getInitializer(); const init = msgProp.getInitializer()
if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) { if (
const text = init.getLiteralText(); init &&
(Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
) {
const text = init.getLiteralText()
pushItem(items, { pushItem(items, {
route, route,
file: path.relative(WORKDIR, abs), file: path.relative(WORKDIR, abs),
componentOrFn, componentOrFn,
kind: "error", kind: 'error',
keyOrLocator: "form.setError", keyOrLocator: 'form.setError',
text, text,
line: getNodeLine(msgProp), line: getNodeLine(msgProp),
}); })
} }
} }
} }
@ -272,48 +280,51 @@ function extractFromSourceFile(abs: string, items: CopyItem[], project: Project)
} }
// Generic validation: any object literal { message: "..." } inside chained calls // Generic validation: any object literal { message: "..." } inside chained calls
const args = node.getArguments(); const args = node.getArguments()
for (const a of args) { for (const a of args) {
if (Node.isObjectLiteralExpression(a)) { if (Node.isObjectLiteralExpression(a)) {
const prop = a.getProperty("message"); const prop = a.getProperty('message')
if (prop && Node.isPropertyAssignment(prop)) { if (prop && Node.isPropertyAssignment(prop)) {
const init = prop.getInitializer(); const init = prop.getInitializer()
if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) { if (
const text = init.getLiteralText(); init &&
(Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
) {
const text = init.getLiteralText()
pushItem(items, { pushItem(items, {
route, route,
file: path.relative(WORKDIR, abs), file: path.relative(WORKDIR, abs),
componentOrFn, componentOrFn,
kind: "validation", kind: 'validation',
keyOrLocator: "message", keyOrLocator: 'message',
text, text,
line: getNodeLine(prop), line: getNodeLine(prop),
}); })
} }
} }
} }
} }
} }
}); })
} }
function aggregate(items: CopyItem[]): CopyItem[] { function aggregate(items: CopyItem[]): CopyItem[] {
// Deduplicate by route+kind+text+keyOrLocator to keep first occurrence, count separately // Deduplicate by route+kind+text+keyOrLocator to keep first occurrence, count separately
const map = new Map<string, { item: CopyItem; count: number }>(); const map = new Map<string, { item: CopyItem; count: number }>()
for (const it of items) { for (const it of items) {
const key = `${it.route}__${it.kind}__${it.keyOrLocator}__${it.text}`; const key = `${it.route}__${it.kind}__${it.keyOrLocator}__${it.text}`
if (!map.has(key)) { if (!map.has(key)) {
map.set(key, { item: it, count: 1 }); map.set(key, { item: it, count: 1 })
} else { } else {
map.get(key)!.count += 1; map.get(key)!.count += 1
} }
} }
const result: CopyItem[] = []; const result: CopyItem[] = []
for (const { item, count } of map.values()) { for (const { item, count } of map.values()) {
(item as any).count = count; ;(item as any).count = count
result.push(item); result.push(item)
} }
return result; return result
} }
function toWorkbook(items: CopyItem[]): XLSX.WorkBook { function toWorkbook(items: CopyItem[]): XLSX.WorkBook {
@ -326,42 +337,40 @@ function toWorkbook(items: CopyItem[]): XLSX.WorkBook {
text: it.text, text: it.text,
line: it.line, line: it.line,
count: (it as any).count ?? 1, count: (it as any).count ?? 1,
notes: it.notes ?? "", notes: it.notes ?? '',
})); }))
const ws = XLSX.utils.json_to_sheet(rows, { skipHeader: false }); const ws = XLSX.utils.json_to_sheet(rows, { skipHeader: false })
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, "copy"); XLSX.utils.book_append_sheet(wb, ws, 'copy')
return wb; return wb
} }
async function main() { async function main() {
ensureExcelDir(); ensureExcelDir()
const files = await collectFiles(); const files = await collectFiles()
const project = new Project({ const project = new Project({
tsConfigFilePath: path.join(WORKDIR, "tsconfig.json"), tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'),
skipAddingFilesFromTsConfig: true, skipAddingFilesFromTsConfig: true,
}); })
const items: CopyItem[] = []; const items: CopyItem[] = []
for (const rel of files) { for (const rel of files) {
const abs = path.join(WORKDIR, rel); const abs = path.join(WORKDIR, rel)
try { try {
extractFromSourceFile(abs, items, project); extractFromSourceFile(abs, items, project)
} catch (e) { } catch (e) {
// swallow parse errors but continue // swallow parse errors but continue
} }
} }
const aggregated = aggregate(items); const aggregated = aggregate(items)
const wb = toWorkbook(aggregated); const wb = toWorkbook(aggregated)
const out = path.join(WORKDIR, "docs", "copy-audit.xlsx"); const out = path.join(WORKDIR, 'docs', 'copy-audit.xlsx')
XLSX.writeFile(wb, out); XLSX.writeFile(wb, out)
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`Wrote ${aggregated.length} rows to ${out}`); console.log(`Wrote ${aggregated.length} rows to ${out}`)
} }
main().catch((err) => { main().catch((err) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(err); console.error(err)
process.exitCode = 1; process.exitCode = 1
}); })

View File

@ -3,211 +3,216 @@
extract-copy.ts i18next extract-copy.ts i18next
*/ */
import path from "node:path"; import path from 'node:path'
import fs from "node:fs"; import fs from 'node:fs'
import { globby } from "globby"; import { globby } from 'globby'
import { Project, SyntaxKind, Node, JsxAttribute, StringLiteral, NoSubstitutionTemplateLiteral } from "ts-morph"; import {
import * as XLSX from "xlsx"; Project,
SyntaxKind,
Node,
JsxAttribute,
StringLiteral,
NoSubstitutionTemplateLiteral,
} from 'ts-morph'
import * as XLSX from 'xlsx'
type CopyKind = type CopyKind =
| "text" | 'text'
| "placeholder" | 'placeholder'
| "title" | 'title'
| "alt" | 'alt'
| "aria" | 'aria'
| "label" | 'label'
| "toast" | 'toast'
| "dialog" | 'dialog'
| "error" | 'error'
| "validation"; | 'validation'
interface CopyItem { interface CopyItem {
route: string; route: string
file: string; file: string
componentOrFn: string; componentOrFn: string
kind: CopyKind; kind: CopyKind
keyOrLocator: string; keyOrLocator: string
text: string; text: string
line: number; line: number
notes?: string; notes?: string
} }
interface I18nKey { interface I18nKey {
key: string; key: string
value: string; value: string
context?: string; context?: string
file: string; file: string
line: number; line: number
} }
const WORKDIR = process.cwd(); const WORKDIR = process.cwd()
const SRC_DIR = path.join(WORKDIR, "src"); const SRC_DIR = path.join(WORKDIR, 'src')
const APP_DIR = path.join(SRC_DIR, "app"); const APP_DIR = path.join(SRC_DIR, 'app')
function ensureExcelDir() { function ensureExcelDir() {
const docsDir = path.join(WORKDIR, "docs"); const docsDir = path.join(WORKDIR, 'docs')
if (!fs.existsSync(docsDir)) fs.mkdirSync(docsDir, { recursive: true }); if (!fs.existsSync(docsDir)) fs.mkdirSync(docsDir, { recursive: true })
} }
function isMeaningfulText(value: string | undefined | null): value is string { function isMeaningfulText(value: string | undefined | null): value is string {
if (!value) return false; if (!value) return false
const trimmed = value.replace(/\s+/g, " ").trim(); const trimmed = value.replace(/\s+/g, ' ').trim()
if (!trimmed) return false; if (!trimmed) return false
// Filter obvious code-like tokens // Filter obvious code-like tokens
if (/^[A-Za-z0-9_.$-]+$/.test(trimmed) && trimmed.length > 24) return false; if (/^[A-Za-z0-9_.$-]+$/.test(trimmed) && trimmed.length > 24) return false
return true; return true
} }
function getRouteForFile(absFilePath: string): string { function getRouteForFile(absFilePath: string): string {
if (!absFilePath.startsWith(APP_DIR)) return "shared"; if (!absFilePath.startsWith(APP_DIR)) return 'shared'
let dir = path.dirname(absFilePath); let dir = path.dirname(absFilePath)
// Walk up to find nearest folder that contains a page.tsx (or page.ts) // Walk up to find nearest folder that contains a page.tsx (or page.ts)
while (dir.startsWith(APP_DIR)) { while (dir.startsWith(APP_DIR)) {
const pageTsx = path.join(dir, "page.tsx"); const pageTsx = path.join(dir, 'page.tsx')
const pageTs = path.join(dir, "page.ts"); const pageTs = path.join(dir, 'page.ts')
if (fs.existsSync(pageTsx) || fs.existsSync(pageTs)) { if (fs.existsSync(pageTsx) || fs.existsSync(pageTs)) {
const rel = path.relative(APP_DIR, dir); const rel = path.relative(APP_DIR, dir)
return rel || "/"; return rel || '/'
} }
const parent = path.dirname(dir); const parent = path.dirname(dir)
if (parent === dir) break; if (parent === dir) break
dir = parent; dir = parent
} }
// Fallback: route is the first app subfolder segment // Fallback: route is the first app subfolder segment
const relToApp = path.relative(APP_DIR, absFilePath); const relToApp = path.relative(APP_DIR, absFilePath)
const parts = relToApp.split(path.sep); const parts = relToApp.split(path.sep)
return parts.length > 0 ? parts[0] : "shared"; return parts.length > 0 ? parts[0] : 'shared'
} }
function getComponentOrFnName(node: Node): string { function getComponentOrFnName(node: Node): string {
const fn = node.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration); const fn = node.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration)
if (fn?.getName()) return fn.getName()!; if (fn?.getName()) return fn.getName()!
const varDecl = node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration); const varDecl = node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration)
if (varDecl?.getName()) return varDecl.getName(); if (varDecl?.getName()) return varDecl.getName()
const cls = node.getFirstAncestorByKind(SyntaxKind.ClassDeclaration); const cls = node.getFirstAncestorByKind(SyntaxKind.ClassDeclaration)
if (cls?.getName()) return cls.getName()!; if (cls?.getName()) return cls.getName()!
const sf = node.getSourceFile(); const sf = node.getSourceFile()
return path.basename(sf.getFilePath()); return path.basename(sf.getFilePath())
} }
function getNodeLine(node: Node): number { function getNodeLine(node: Node): number {
const pos = node.getStartLineNumber(); const pos = node.getStartLineNumber()
return pos ?? 1; return pos ?? 1
} }
function getAttrName(attr: JsxAttribute): string { function getAttrName(attr: JsxAttribute): string {
return attr.getNameNode().getText(); return attr.getNameNode().getText()
} }
function getStringFromInitializer(attr: JsxAttribute): string | undefined { function getStringFromInitializer(attr: JsxAttribute): string | undefined {
const init = attr.getInitializer(); const init = attr.getInitializer()
if (!init) return undefined; if (!init) return undefined
if (Node.isStringLiteral(init)) return init.getLiteralText(); if (Node.isStringLiteral(init)) return init.getLiteralText()
if (Node.isNoSubstitutionTemplateLiteral(init)) return init.getLiteralText(); if (Node.isNoSubstitutionTemplateLiteral(init)) return init.getLiteralText()
if (Node.isJsxExpression(init)) { if (Node.isJsxExpression(init)) {
const expr = init.getExpression(); const expr = init.getExpression()
if (!expr) return undefined; if (!expr) return undefined
if (Node.isStringLiteral(expr)) return expr.getLiteralText(); if (Node.isStringLiteral(expr)) return expr.getLiteralText()
if (Node.isNoSubstitutionTemplateLiteral(expr)) return expr.getLiteralText(); if (Node.isNoSubstitutionTemplateLiteral(expr)) return expr.getLiteralText()
} }
return undefined; return undefined
} }
function pushItem(items: CopyItem[], item: CopyItem) { function pushItem(items: CopyItem[], item: CopyItem) {
if (!isMeaningfulText(item.text)) return; if (!isMeaningfulText(item.text)) return
items.push(item); items.push(item)
} }
function generateI18nKey(item: CopyItem): string { function generateI18nKey(item: CopyItem): string {
// 生成 i18next 格式的键名 // 生成 i18next 格式的键名
const route = item.route === "shared" ? "common" : item.route.replace(/[^a-zA-Z0-9]/g, "_"); const route = item.route === 'shared' ? 'common' : item.route.replace(/[^a-zA-Z0-9]/g, '_')
const component = item.componentOrFn.replace(/[^a-zA-Z0-9]/g, "_"); const component = item.componentOrFn.replace(/[^a-zA-Z0-9]/g, '_')
const kind = item.kind; const kind = item.kind
const locator = item.keyOrLocator.replace(/[^a-zA-Z0-9]/g, "_"); const locator = item.keyOrLocator.replace(/[^a-zA-Z0-9]/g, '_')
return `${route}.${component}.${kind}.${locator}`.toLowerCase(); return `${route}.${component}.${kind}.${locator}`.toLowerCase()
} }
async function collectFiles(): Promise<string[]> { async function collectFiles(): Promise<string[]> {
const patterns = [ const patterns = ['src/**/*.{ts,tsx}']
"src/**/*.{ts,tsx}",
];
const ignore = [ const ignore = [
"**/node_modules/**", '**/node_modules/**',
"**/.next/**", '**/.next/**',
"**/__tests__/**", '**/__tests__/**',
"**/mocks/**", '**/mocks/**',
"**/mock/**", '**/mock/**',
"**/*.d.ts", '**/*.d.ts',
]; ]
return await globby(patterns, { gitignore: true, ignore }); return await globby(patterns, { gitignore: true, ignore })
} }
function extractFromSourceFile(abs: string, items: CopyItem[], project: Project) { function extractFromSourceFile(abs: string, items: CopyItem[], project: Project) {
const sf = project.addSourceFileAtPath(abs); const sf = project.addSourceFileAtPath(abs)
// JSX text nodes // JSX text nodes
sf.forEachDescendant((node) => { sf.forEachDescendant((node) => {
if (Node.isJsxElement(node)) { if (Node.isJsxElement(node)) {
const opening = node.getOpeningElement(); const opening = node.getOpeningElement()
const componentOrFn = getComponentOrFnName(node); const componentOrFn = getComponentOrFnName(node)
const route = getRouteForFile(abs); const route = getRouteForFile(abs)
// 递归提取所有 JsxText 与 {'...'} 字面量 // 递归提取所有 JsxText 与 {'...'} 字面量
const tagName = opening.getTagNameNode().getText(); const tagName = opening.getTagNameNode().getText()
const textNodes = node.getDescendantsOfKind(SyntaxKind.JsxText); const textNodes = node.getDescendantsOfKind(SyntaxKind.JsxText)
textNodes.forEach((t) => { textNodes.forEach((t) => {
const text = t.getText(); const text = t.getText()
const cleaned = text.replace(/\s+/g, " ").trim(); const cleaned = text.replace(/\s+/g, ' ').trim()
if (isMeaningfulText(cleaned)) { if (isMeaningfulText(cleaned)) {
pushItem(items, { pushItem(items, {
route, route,
file: path.relative(WORKDIR, abs), file: path.relative(WORKDIR, abs),
componentOrFn, componentOrFn,
kind: "text", kind: 'text',
keyOrLocator: tagName, keyOrLocator: tagName,
text: cleaned, text: cleaned,
line: getNodeLine(t), line: getNodeLine(t),
}); })
} }
}); })
const exprs = node.getDescendantsOfKind(SyntaxKind.JsxExpression); const exprs = node.getDescendantsOfKind(SyntaxKind.JsxExpression)
exprs.forEach((expr) => { exprs.forEach((expr) => {
const inner = expr.getExpression(); const inner = expr.getExpression()
if (inner && (Node.isStringLiteral(inner) || Node.isNoSubstitutionTemplateLiteral(inner))) { if (inner && (Node.isStringLiteral(inner) || Node.isNoSubstitutionTemplateLiteral(inner))) {
const cleaned = inner.getLiteralText().replace(/\s+/g, " ").trim(); const cleaned = inner.getLiteralText().replace(/\s+/g, ' ').trim()
if (isMeaningfulText(cleaned)) { if (isMeaningfulText(cleaned)) {
pushItem(items, { pushItem(items, {
route, route,
file: path.relative(WORKDIR, abs), file: path.relative(WORKDIR, abs),
componentOrFn, componentOrFn,
kind: "text", kind: 'text',
keyOrLocator: tagName, keyOrLocator: tagName,
text: cleaned, text: cleaned,
line: getNodeLine(expr), line: getNodeLine(expr),
}); })
} }
} }
}); })
} }
// JSX attributes // JSX attributes
if (Node.isJsxOpeningElement(node) || Node.isJsxSelfClosingElement(node)) { if (Node.isJsxOpeningElement(node) || Node.isJsxSelfClosingElement(node)) {
const route = getRouteForFile(abs); const route = getRouteForFile(abs)
const componentOrFn = getComponentOrFnName(node); const componentOrFn = getComponentOrFnName(node)
const tag = Node.isJsxOpeningElement(node) const tag = Node.isJsxOpeningElement(node)
? node.getTagNameNode().getText() ? node.getTagNameNode().getText()
: node.getTagNameNode().getText(); : node.getTagNameNode().getText()
const attrs = node.getAttributes().filter(Node.isJsxAttribute); const attrs = node.getAttributes().filter(Node.isJsxAttribute)
attrs.forEach((attr) => { attrs.forEach((attr) => {
const name = getAttrName(attr); const name = getAttrName(attr)
const lower = name.toLowerCase(); const lower = name.toLowerCase()
const value = getStringFromInitializer(attr); const value = getStringFromInitializer(attr)
if (!value) return; if (!value) return
let kind: CopyKind | null = null; let kind: CopyKind | null = null
if (lower === "placeholder") kind = "placeholder"; if (lower === 'placeholder') kind = 'placeholder'
else if (lower === "title") kind = "title"; else if (lower === 'title') kind = 'title'
else if (lower === "alt") kind = "alt"; else if (lower === 'alt') kind = 'alt'
else if (lower.startsWith("aria-")) kind = "aria"; else if (lower.startsWith('aria-')) kind = 'aria'
else if (lower === "label") kind = "label"; else if (lower === 'label') kind = 'label'
if (kind) { if (kind) {
pushItem(items, { pushItem(items, {
route, route,
@ -217,40 +222,40 @@ function extractFromSourceFile(abs: string, items: CopyItem[], project: Project)
keyOrLocator: `${tag}.${name}`, keyOrLocator: `${tag}.${name}`,
text: value, text: value,
line: getNodeLine(attr), line: getNodeLine(attr),
}); })
} }
}); })
} }
// Interaction messages: toast.*, alert, confirm, message.* // Interaction messages: toast.*, alert, confirm, message.*
if (Node.isCallExpression(node)) { if (Node.isCallExpression(node)) {
const route = getRouteForFile(abs); const route = getRouteForFile(abs)
const componentOrFn = getComponentOrFnName(node); const componentOrFn = getComponentOrFnName(node)
const expr = node.getExpression(); const expr = node.getExpression()
let kind: CopyKind | null = null; let kind: CopyKind | null = null
let keyOrLocator = ""; let keyOrLocator = ''
if (Node.isPropertyAccessExpression(expr)) { if (Node.isPropertyAccessExpression(expr)) {
const left = expr.getExpression().getText(); const left = expr.getExpression().getText()
const name = expr.getName(); const name = expr.getName()
if (left === "toast" || left === "message") { if (left === 'toast' || left === 'message') {
kind = "toast"; kind = 'toast'
keyOrLocator = `${left}.${name}`; keyOrLocator = `${left}.${name}`
} }
if (left.toLowerCase().includes("dialog")) { if (left.toLowerCase().includes('dialog')) {
kind = "dialog"; kind = 'dialog'
keyOrLocator = `${left}.${name}`; keyOrLocator = `${left}.${name}`
} }
} else if (Node.isIdentifier(expr)) { } else if (Node.isIdentifier(expr)) {
const id = expr.getText(); const id = expr.getText()
if (id === "alert" || id === "confirm") { if (id === 'alert' || id === 'confirm') {
kind = "dialog"; kind = 'dialog'
keyOrLocator = id; keyOrLocator = id
} }
} }
if (kind) { if (kind) {
const arg0 = node.getArguments()[0]; const arg0 = node.getArguments()[0]
if (arg0 && (Node.isStringLiteral(arg0) || Node.isNoSubstitutionTemplateLiteral(arg0))) { if (arg0 && (Node.isStringLiteral(arg0) || Node.isNoSubstitutionTemplateLiteral(arg0))) {
const text = (arg0 as StringLiteral | NoSubstitutionTemplateLiteral).getLiteralText(); const text = (arg0 as StringLiteral | NoSubstitutionTemplateLiteral).getLiteralText()
pushItem(items, { pushItem(items, {
route, route,
file: path.relative(WORKDIR, abs), file: path.relative(WORKDIR, abs),
@ -259,30 +264,33 @@ function extractFromSourceFile(abs: string, items: CopyItem[], project: Project)
keyOrLocator, keyOrLocator,
text, text,
line: getNodeLine(node), line: getNodeLine(node),
}); })
} }
} }
// form.setError("field", { message: "..." }) // form.setError("field", { message: "..." })
if (Node.isPropertyAccessExpression(expr) && expr.getName() === "setError") { if (Node.isPropertyAccessExpression(expr) && expr.getName() === 'setError') {
const args = node.getArguments(); const args = node.getArguments()
if (args.length >= 2) { if (args.length >= 2) {
const second = args[1]; const second = args[1]
if (Node.isObjectLiteralExpression(second)) { if (Node.isObjectLiteralExpression(second)) {
const msgProp = second.getProperty("message"); const msgProp = second.getProperty('message')
if (msgProp && Node.isPropertyAssignment(msgProp)) { if (msgProp && Node.isPropertyAssignment(msgProp)) {
const init = msgProp.getInitializer(); const init = msgProp.getInitializer()
if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) { if (
const text = init.getLiteralText(); init &&
(Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
) {
const text = init.getLiteralText()
pushItem(items, { pushItem(items, {
route, route,
file: path.relative(WORKDIR, abs), file: path.relative(WORKDIR, abs),
componentOrFn, componentOrFn,
kind: "error", kind: 'error',
keyOrLocator: "form.setError", keyOrLocator: 'form.setError',
text, text,
line: getNodeLine(msgProp), line: getNodeLine(msgProp),
}); })
} }
} }
} }
@ -290,59 +298,62 @@ function extractFromSourceFile(abs: string, items: CopyItem[], project: Project)
} }
// Generic validation: any object literal { message: "..." } inside chained calls // Generic validation: any object literal { message: "..." } inside chained calls
const args = node.getArguments(); const args = node.getArguments()
for (const a of args) { for (const a of args) {
if (Node.isObjectLiteralExpression(a)) { if (Node.isObjectLiteralExpression(a)) {
const prop = a.getProperty("message"); const prop = a.getProperty('message')
if (prop && Node.isPropertyAssignment(prop)) { if (prop && Node.isPropertyAssignment(prop)) {
const init = prop.getInitializer(); const init = prop.getInitializer()
if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) { if (
const text = init.getLiteralText(); init &&
(Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
) {
const text = init.getLiteralText()
pushItem(items, { pushItem(items, {
route, route,
file: path.relative(WORKDIR, abs), file: path.relative(WORKDIR, abs),
componentOrFn, componentOrFn,
kind: "validation", kind: 'validation',
keyOrLocator: "message", keyOrLocator: 'message',
text, text,
line: getNodeLine(prop), line: getNodeLine(prop),
}); })
} }
} }
} }
} }
} }
}); })
} }
function aggregate(items: CopyItem[]): CopyItem[] { function aggregate(items: CopyItem[]): CopyItem[] {
// Deduplicate by route+kind+text+keyOrLocator to keep first occurrence, count separately // Deduplicate by route+kind+text+keyOrLocator to keep first occurrence, count separately
const map = new Map<string, { item: CopyItem; count: number }>(); const map = new Map<string, { item: CopyItem; count: number }>()
for (const it of items) { for (const it of items) {
const key = `${it.route}__${it.kind}__${it.keyOrLocator}__${it.text}`; const key = `${it.route}__${it.kind}__${it.keyOrLocator}__${it.text}`
if (!map.has(key)) { if (!map.has(key)) {
map.set(key, { item: it, count: 1 }); map.set(key, { item: it, count: 1 })
} else { } else {
map.get(key)!.count += 1; map.get(key)!.count += 1
} }
} }
const result: CopyItem[] = []; const result: CopyItem[] = []
for (const { item, count } of map.values()) { for (const { item, count } of map.values()) {
(item as any).count = count; ;(item as any).count = count
result.push(item); result.push(item)
} }
return result; return result
} }
function generateI18nTranslation(items: CopyItem[]): Record<string, string> { function generateI18nTranslation(items: CopyItem[]): Record<string, string> {
const translation: Record<string, string> = {}; const translation: Record<string, string> = {}
items.forEach((item) => { items.forEach((item) => {
const key = generateI18nKey(item); const key = generateI18nKey(item)
translation[key] = item.text; translation[key] = item.text
}); })
return translation; return translation
} }
function toWorkbook(items: CopyItem[]): XLSX.WorkBook { function toWorkbook(items: CopyItem[]): XLSX.WorkBook {
@ -356,75 +367,81 @@ function toWorkbook(items: CopyItem[]): XLSX.WorkBook {
line: it.line, line: it.line,
count: (it as any).count ?? 1, count: (it as any).count ?? 1,
i18nKey: generateI18nKey(it), i18nKey: generateI18nKey(it),
notes: it.notes ?? "", notes: it.notes ?? '',
})); }))
const ws = XLSX.utils.json_to_sheet(rows, { skipHeader: false }); const ws = XLSX.utils.json_to_sheet(rows, { skipHeader: false })
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, "i18n-scan"); XLSX.utils.book_append_sheet(wb, ws, 'i18n-scan')
return wb; return wb
} }
async function main() { async function main() {
ensureExcelDir(); ensureExcelDir()
const files = await collectFiles(); const files = await collectFiles()
const project = new Project({ const project = new Project({
tsConfigFilePath: path.join(WORKDIR, "tsconfig.json"), tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'),
skipAddingFilesFromTsConfig: true, skipAddingFilesFromTsConfig: true,
}); })
const items: CopyItem[] = []; const items: CopyItem[] = []
for (const rel of files) { for (const rel of files) {
const abs = path.join(WORKDIR, rel); const abs = path.join(WORKDIR, rel)
try { try {
extractFromSourceFile(abs, items, project); extractFromSourceFile(abs, items, project)
} catch (e) { } catch (e) {
// swallow parse errors but continue // swallow parse errors but continue
} }
} }
const aggregated = aggregate(items); const aggregated = aggregate(items)
// 生成 i18next 格式的翻译文件 // 生成 i18next 格式的翻译文件
const translation = generateI18nTranslation(aggregated); const translation = generateI18nTranslation(aggregated)
const localesDir = path.join(WORKDIR, "public", "locales", "en"); const localesDir = path.join(WORKDIR, 'public', 'locales', 'en')
if (!fs.existsSync(localesDir)) { if (!fs.existsSync(localesDir)) {
fs.mkdirSync(localesDir, { recursive: true }); fs.mkdirSync(localesDir, { recursive: true })
} }
const translationFile = path.join(localesDir, "translation.json"); const translationFile = path.join(localesDir, 'translation.json')
fs.writeFileSync(translationFile, JSON.stringify(translation, null, 2)); fs.writeFileSync(translationFile, JSON.stringify(translation, null, 2))
// 生成 Excel 报告 // 生成 Excel 报告
const wb = toWorkbook(aggregated); const wb = toWorkbook(aggregated)
const out = path.join(WORKDIR, "docs", "i18n-scan-report.xlsx"); const out = path.join(WORKDIR, 'docs', 'i18n-scan-report.xlsx')
XLSX.writeFile(wb, out); XLSX.writeFile(wb, out)
// 生成扫描报告 // 生成扫描报告
const report = { const report = {
totalItems: aggregated.length, totalItems: aggregated.length,
uniqueTexts: new Set(aggregated.map(item => item.text)).size, uniqueTexts: new Set(aggregated.map((item) => item.text)).size,
byRoute: aggregated.reduce((acc, item) => { byRoute: aggregated.reduce(
acc[item.route] = (acc[item.route] || 0) + 1; (acc, item) => {
return acc; acc[item.route] = (acc[item.route] || 0) + 1
}, {} as Record<string, number>), return acc
byKind: aggregated.reduce((acc, item) => { },
acc[item.kind] = (acc[item.kind] || 0) + 1; {} as Record<string, number>
return acc; ),
}, {} as Record<string, number>), byKind: aggregated.reduce(
translationKeys: Object.keys(translation).length (acc, item) => {
}; acc[item.kind] = (acc[item.kind] || 0) + 1
return acc
const reportFile = path.join(WORKDIR, "docs", "i18n-scan-report.json"); },
fs.writeFileSync(reportFile, JSON.stringify(report, null, 2)); {} as Record<string, number>
),
translationKeys: Object.keys(translation).length,
}
const reportFile = path.join(WORKDIR, 'docs', 'i18n-scan-report.json')
fs.writeFileSync(reportFile, JSON.stringify(report, null, 2))
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.log(`✅ i18next 扫描完成!`); console.log(`✅ i18next 扫描完成!`)
console.log(`📊 总扫描条目: ${aggregated.length}`); console.log(`📊 总扫描条目: ${aggregated.length}`)
console.log(`🔑 生成翻译键: ${Object.keys(translation).length}`); console.log(`🔑 生成翻译键: ${Object.keys(translation).length}`)
console.log(`📁 翻译文件: ${translationFile}`); console.log(`📁 翻译文件: ${translationFile}`)
console.log(`📋 Excel 报告: ${out}`); console.log(`📋 Excel 报告: ${out}`)
console.log(`📄 JSON 报告: ${reportFile}`); console.log(`📄 JSON 报告: ${reportFile}`)
} }
main().catch((err) => { main().catch((err) => {
// eslint-disable-next-line no-console // eslint-disable-next-line no-console
console.error(err); console.error(err)
process.exitCode = 1; process.exitCode = 1
}); })

View File

@ -2,16 +2,16 @@
CommonJS runtime for resetting files and applying translations from Excel to source code. CommonJS runtime for resetting files and applying translations from Excel to source code.
This script first resets files to their original state, then applies translations. This script first resets files to their original state, then applies translations.
*/ */
const path = require('node:path'); const path = require('node:path')
const fs = require('node:fs'); const fs = require('node:fs')
const { execSync } = require('child_process'); const { execSync } = require('child_process')
const { Project, SyntaxKind, Node } = require('ts-morph'); const { Project, SyntaxKind, Node } = require('ts-morph')
const XLSX = require('xlsx'); const XLSX = require('xlsx')
const WORKDIR = process.cwd(); const WORKDIR = process.cwd()
const TRANSLATES_FILE = path.join(WORKDIR, 'scripts', 'translates.xlsx'); const TRANSLATES_FILE = path.join(WORKDIR, 'scripts', 'translates.xlsx')
const REPORT_FILE = path.join(WORKDIR, 'scripts', 'translation-report.json'); const REPORT_FILE = path.join(WORKDIR, 'scripts', 'translation-report.json')
const CONFLICTS_FILE = path.join(WORKDIR, 'scripts', 'translation-conflicts.xlsx'); const CONFLICTS_FILE = path.join(WORKDIR, 'scripts', 'translation-conflicts.xlsx')
// 统计信息 // 统计信息
const stats = { const stats = {
@ -20,289 +20,286 @@ const stats = {
conflicts: 0, conflicts: 0,
fileNotFound: 0, fileNotFound: 0,
textNotFound: 0, textNotFound: 0,
multipleMatches: 0 multipleMatches: 0,
}; }
// 冲突列表 // 冲突列表
const conflicts = []; const conflicts = []
// 成功替换列表 // 成功替换列表
const successfulReplacements = []; const successfulReplacements = []
function resetFiles() { function resetFiles() {
console.log('🔄 重置文件到原始状态...'); console.log('🔄 重置文件到原始状态...')
try { try {
// 使用 git 重置所有修改的文件 // 使用 git 重置所有修改的文件
execSync('git checkout -- .', { cwd: WORKDIR, stdio: 'inherit' }); execSync('git checkout -- .', { cwd: WORKDIR, stdio: 'inherit' })
console.log('✅ 文件重置完成'); console.log('✅ 文件重置完成')
} catch (error) { } catch (error) {
console.error('❌ 重置文件失败:', error.message); console.error('❌ 重置文件失败:', error.message)
process.exit(1); process.exit(1)
} }
} }
function loadTranslations() { function loadTranslations() {
console.log('📖 读取翻译数据...'); console.log('📖 读取翻译数据...')
const wb = XLSX.readFile(TRANSLATES_FILE); const wb = XLSX.readFile(TRANSLATES_FILE)
const ws = wb.Sheets[wb.SheetNames[0]]; const ws = wb.Sheets[wb.SheetNames[0]]
const data = XLSX.utils.sheet_to_json(ws, { defval: '' }); const data = XLSX.utils.sheet_to_json(ws, { defval: '' })
// 筛选出需要替换的条目 // 筛选出需要替换的条目
let translations = data.filter(row => let translations = data.filter(
row.text && (row) => row.text && row.corrected_text && row.text !== row.corrected_text
row.corrected_text && )
row.text !== row.corrected_text
);
// 去重:按 file + line + text 去重,保留第一个 // 去重:按 file + line + text 去重,保留第一个
const seen = new Set(); const seen = new Set()
translations = translations.filter(row => { translations = translations.filter((row) => {
const key = `${row.file}:${row.line}:${row.text}`; const key = `${row.file}:${row.line}:${row.text}`
if (seen.has(key)) { if (seen.has(key)) {
return false; return false
} }
seen.add(key); seen.add(key)
return true; return true
}); })
console.log(`📊 找到 ${translations.length} 条需要替换的翻译(已去重)`); console.log(`📊 找到 ${translations.length} 条需要替换的翻译(已去重)`)
stats.total = translations.length; stats.total = translations.length
return translations; return translations
} }
function groupByFile(translations) { function groupByFile(translations) {
const groups = new Map(); const groups = new Map()
for (const translation of translations) { for (const translation of translations) {
const filePath = path.join(WORKDIR, translation.file); const filePath = path.join(WORKDIR, translation.file)
if (!groups.has(filePath)) { if (!groups.has(filePath)) {
groups.set(filePath, []); groups.set(filePath, [])
} }
groups.get(filePath).push(translation); groups.get(filePath).push(translation)
} }
return groups; return groups
} }
function findTextInFile(sourceFile, translation) { function findTextInFile(sourceFile, translation) {
const { text, kind } = translation; const { text, kind } = translation
const matches = []; const matches = []
sourceFile.forEachDescendant((node) => { sourceFile.forEachDescendant((node) => {
// 根据 kind 类型进行不同的匹配 // 根据 kind 类型进行不同的匹配
if (kind === 'text') { if (kind === 'text') {
// 查找 JSX 文本节点 // 查找 JSX 文本节点
if (Node.isJsxText(node)) { if (Node.isJsxText(node)) {
const nodeText = node.getText().replace(/\s+/g, ' ').trim(); const nodeText = node.getText().replace(/\s+/g, ' ').trim()
if (nodeText === text) { if (nodeText === text) {
matches.push({ node, type: 'jsx-text', line: node.getStartLineNumber() }); matches.push({ node, type: 'jsx-text', line: node.getStartLineNumber() })
} }
} }
// 查找 JSX 表达式中的字符串 // 查找 JSX 表达式中的字符串
if (Node.isJsxExpression(node)) { if (Node.isJsxExpression(node)) {
const expr = node.getExpression(); const expr = node.getExpression()
if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) { if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) {
const nodeText = expr.getLiteralText(); const nodeText = expr.getLiteralText()
if (nodeText === text) { if (nodeText === text) {
matches.push({ node: expr, type: 'jsx-expression', line: node.getStartLineNumber() }); matches.push({ node: expr, type: 'jsx-expression', line: node.getStartLineNumber() })
} }
} }
} }
} else if (['placeholder', 'title', 'alt', 'label', 'aria'].includes(kind)) { } else if (['placeholder', 'title', 'alt', 'label', 'aria'].includes(kind)) {
// 查找 JSX 属性 // 查找 JSX 属性
if (Node.isJsxAttribute(node)) { if (Node.isJsxAttribute(node)) {
const name = node.getNameNode().getText().toLowerCase(); const name = node.getNameNode().getText().toLowerCase()
const value = getStringFromInitializer(node); const value = getStringFromInitializer(node)
if (value === text) { if (value === text) {
matches.push({ node, type: 'jsx-attribute', line: node.getStartLineNumber() }); matches.push({ node, type: 'jsx-attribute', line: node.getStartLineNumber() })
} }
} }
} else if (['toast', 'dialog', 'error', 'validation'].includes(kind)) { } else if (['toast', 'dialog', 'error', 'validation'].includes(kind)) {
// 查找函数调用中的字符串参数 // 查找函数调用中的字符串参数
if (Node.isCallExpression(node)) { if (Node.isCallExpression(node)) {
const args = node.getArguments(); const args = node.getArguments()
for (const arg of args) { for (const arg of args) {
if (Node.isStringLiteral(arg) || Node.isNoSubstitutionTemplateLiteral(arg)) { if (Node.isStringLiteral(arg) || Node.isNoSubstitutionTemplateLiteral(arg)) {
const nodeText = arg.getLiteralText(); const nodeText = arg.getLiteralText()
if (nodeText === text) { if (nodeText === text) {
matches.push({ node: arg, type: 'function-arg', line: node.getStartLineNumber() }); matches.push({ node: arg, type: 'function-arg', line: node.getStartLineNumber() })
} }
} }
} }
} }
} }
}); })
return matches; return matches
} }
function getStringFromInitializer(attr) { function getStringFromInitializer(attr) {
const init = attr.getInitializer(); const init = attr.getInitializer()
if (!init) return undefined; if (!init) return undefined
if (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init)) { if (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init)) {
return init.getLiteralText(); return init.getLiteralText()
} }
if (Node.isJsxExpression(init)) { if (Node.isJsxExpression(init)) {
const expr = init.getExpression(); const expr = init.getExpression()
if (!expr) return undefined; if (!expr) return undefined
if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) { if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) {
return expr.getLiteralText(); return expr.getLiteralText()
} }
} }
return undefined; return undefined
} }
function replaceText(node, newText, type) { function replaceText(node, newText, type) {
try { try {
if (type === 'jsx-text') { if (type === 'jsx-text') {
// JSX 文本节点需要特殊处理,保持空白字符 // JSX 文本节点需要特殊处理,保持空白字符
const originalText = node.getText(); const originalText = node.getText()
const newTextWithWhitespace = originalText.replace(/\S+/g, newText); const newTextWithWhitespace = originalText.replace(/\S+/g, newText)
node.replaceWithText(newTextWithWhitespace); node.replaceWithText(newTextWithWhitespace)
} else if (type === 'jsx-expression' || type === 'function-arg') { } else if (type === 'jsx-expression' || type === 'function-arg') {
// 字符串字面量 // 字符串字面量
if (Node.isStringLiteral(node)) { if (Node.isStringLiteral(node)) {
node.replaceWithText(`"${newText}"`); node.replaceWithText(`"${newText}"`)
} else if (Node.isNoSubstitutionTemplateLiteral(node)) { } else if (Node.isNoSubstitutionTemplateLiteral(node)) {
node.replaceWithText(`\`${newText}\``); node.replaceWithText(`\`${newText}\``)
} }
} else if (type === 'jsx-attribute') { } else if (type === 'jsx-attribute') {
// JSX 属性值 // JSX 属性值
const init = node.getInitializer(); const init = node.getInitializer()
if (init) { if (init) {
if (Node.isStringLiteral(init)) { if (Node.isStringLiteral(init)) {
init.replaceWithText(`"${newText}"`); init.replaceWithText(`"${newText}"`)
} else if (Node.isNoSubstitutionTemplateLiteral(init)) { } else if (Node.isNoSubstitutionTemplateLiteral(init)) {
init.replaceWithText(`\`${newText}\``); init.replaceWithText(`\`${newText}\``)
} else if (Node.isJsxExpression(init)) { } else if (Node.isJsxExpression(init)) {
const expr = init.getExpression(); const expr = init.getExpression()
if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) { if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) {
if (Node.isStringLiteral(expr)) { if (Node.isStringLiteral(expr)) {
expr.replaceWithText(`"${newText}"`); expr.replaceWithText(`"${newText}"`)
} else { } else {
expr.replaceWithText(`\`${newText}\``); expr.replaceWithText(`\`${newText}\``)
} }
} }
} }
} }
} }
return true; return true
} catch (error) { } catch (error) {
console.error(`❌ 替换失败: ${error.message}`); console.error(`❌ 替换失败: ${error.message}`)
return false; return false
} }
} }
function processFile(filePath, translations) { function processFile(filePath, translations) {
if (!fs.existsSync(filePath)) { if (!fs.existsSync(filePath)) {
console.log(`❌ 文件不存在: ${path.relative(WORKDIR, filePath)}`); console.log(`❌ 文件不存在: ${path.relative(WORKDIR, filePath)}`)
translations.forEach(t => { translations.forEach((t) => {
conflicts.push({ conflicts.push({
...t, ...t,
conflictType: 'FILE_NOT_FOUND', conflictType: 'FILE_NOT_FOUND',
conflictReason: '文件不存在' conflictReason: '文件不存在',
}); })
}); })
stats.fileNotFound += translations.length; stats.fileNotFound += translations.length
return; return
} }
console.log(`📝 处理文件: ${path.relative(WORKDIR, filePath)}`); console.log(`📝 处理文件: ${path.relative(WORKDIR, filePath)}`)
try { try {
const project = new Project({ const project = new Project({
tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'), tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'),
skipAddingFilesFromTsConfig: true skipAddingFilesFromTsConfig: true,
}); })
const sourceFile = project.addSourceFileAtPath(filePath); const sourceFile = project.addSourceFileAtPath(filePath)
for (const translation of translations) { for (const translation of translations) {
const { text, corrected_text, line, kind } = translation; const { text, corrected_text, line, kind } = translation
// 在文件中查找匹配的文本 // 在文件中查找匹配的文本
const matches = findTextInFile(sourceFile, translation); const matches = findTextInFile(sourceFile, translation)
if (matches.length === 0) { if (matches.length === 0) {
conflicts.push({ conflicts.push({
...translation, ...translation,
conflictType: 'TEXT_NOT_FOUND_IN_FILE', conflictType: 'TEXT_NOT_FOUND_IN_FILE',
conflictReason: '在文件中找不到匹配的文本' conflictReason: '在文件中找不到匹配的文本',
}); })
stats.textNotFound++; stats.textNotFound++
continue; continue
} }
if (matches.length > 1) { if (matches.length > 1) {
conflicts.push({ conflicts.push({
...translation, ...translation,
conflictType: 'MULTIPLE_MATCHES', conflictType: 'MULTIPLE_MATCHES',
conflictReason: `找到 ${matches.length} 个匹配项,需要人工确认` conflictReason: `找到 ${matches.length} 个匹配项,需要人工确认`,
}); })
stats.multipleMatches++; stats.multipleMatches++
continue; continue
} }
// 执行替换 // 执行替换
const match = matches[0]; const match = matches[0]
const success = replaceText(match.node, corrected_text, match.type); const success = replaceText(match.node, corrected_text, match.type)
if (success) { if (success) {
successfulReplacements.push({ successfulReplacements.push({
...translation, ...translation,
actualLine: match.line, actualLine: match.line,
replacementType: match.type replacementType: match.type,
}); })
stats.success++; stats.success++
console.log(`✅ 替换成功: "${text}" -> "${corrected_text}" (行 ${match.line})`); console.log(`✅ 替换成功: "${text}" -> "${corrected_text}" (行 ${match.line})`)
} else { } else {
conflicts.push({ conflicts.push({
...translation, ...translation,
conflictType: 'REPLACEMENT_FAILED', conflictType: 'REPLACEMENT_FAILED',
conflictReason: '替换操作失败' conflictReason: '替换操作失败',
}); })
stats.conflicts++; stats.conflicts++
} }
} }
// 保存修改后的文件 // 保存修改后的文件
sourceFile.saveSync(); sourceFile.saveSync()
} catch (error) { } catch (error) {
console.error(`❌ 处理文件失败: ${filePath}`, error.message); console.error(`❌ 处理文件失败: ${filePath}`, error.message)
translations.forEach(t => { translations.forEach((t) => {
conflicts.push({ conflicts.push({
...t, ...t,
conflictType: 'PARSE_ERROR', conflictType: 'PARSE_ERROR',
conflictReason: `文件解析失败: ${error.message}` conflictReason: `文件解析失败: ${error.message}`,
}); })
}); })
stats.conflicts += translations.length; stats.conflicts += translations.length
} }
} }
function generateReport() { function generateReport() {
console.log('\n📊 生成报告...'); console.log('\n📊 生成报告...')
// 生成成功替换报告 // 生成成功替换报告
const report = { const report = {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
stats, stats,
successfulReplacements, successfulReplacements,
conflicts: conflicts.map(c => ({ conflicts: conflicts.map((c) => ({
file: c.file, file: c.file,
line: c.line, line: c.line,
text: c.text, text: c.text,
corrected_text: c.corrected_text, corrected_text: c.corrected_text,
conflictType: c.conflictType, conflictType: c.conflictType,
conflictReason: c.conflictReason conflictReason: c.conflictReason,
})) })),
}; }
fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2)); fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2))
console.log(`📄 成功替换报告已保存: ${REPORT_FILE}`); console.log(`📄 成功替换报告已保存: ${REPORT_FILE}`)
// 生成冲突报告 Excel // 生成冲突报告 Excel
if (conflicts.length > 0) { if (conflicts.length > 0) {
const conflictRows = conflicts.map(c => ({ const conflictRows = conflicts.map((c) => ({
file: c.file, file: c.file,
line: c.line, line: c.line,
text: c.text, text: c.text,
@ -312,56 +309,55 @@ function generateReport() {
route: c.route, route: c.route,
componentOrFn: c.componentOrFn, componentOrFn: c.componentOrFn,
kind: c.kind, kind: c.kind,
keyOrLocator: c.keyOrLocator keyOrLocator: c.keyOrLocator,
})); }))
const ws = XLSX.utils.json_to_sheet(conflictRows); const ws = XLSX.utils.json_to_sheet(conflictRows)
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new()
XLSX.utils.book_append_sheet(wb, ws, 'conflicts'); XLSX.utils.book_append_sheet(wb, ws, 'conflicts')
XLSX.writeFile(wb, CONFLICTS_FILE); XLSX.writeFile(wb, CONFLICTS_FILE)
console.log(`📄 冲突报告已保存: ${CONFLICTS_FILE}`); console.log(`📄 冲突报告已保存: ${CONFLICTS_FILE}`)
} }
} }
function printSummary() { function printSummary() {
console.log('\n📈 处理完成!'); console.log('\n📈 处理完成!')
console.log(`总翻译条目: ${stats.total}`); console.log(`总翻译条目: ${stats.total}`)
console.log(`✅ 成功替换: ${stats.success}`); console.log(`✅ 成功替换: ${stats.success}`)
console.log(`❌ 文件不存在: ${stats.fileNotFound}`); console.log(`❌ 文件不存在: ${stats.fileNotFound}`)
console.log(`❌ 文本未找到: ${stats.textNotFound}`); console.log(`❌ 文本未找到: ${stats.textNotFound}`)
console.log(`❌ 多处匹配: ${stats.multipleMatches}`); console.log(`❌ 多处匹配: ${stats.multipleMatches}`)
console.log(`❌ 其他冲突: ${stats.conflicts}`); console.log(`❌ 其他冲突: ${stats.conflicts}`)
console.log(`\n成功率: ${((stats.success / stats.total) * 100).toFixed(1)}%`); console.log(`\n成功率: ${((stats.success / stats.total) * 100).toFixed(1)}%`)
} }
async function main() { async function main() {
console.log('🚀 开始重置并应用翻译...\n'); console.log('🚀 开始重置并应用翻译...\n')
try { try {
// 1. 重置文件到原始状态 // 1. 重置文件到原始状态
resetFiles(); resetFiles()
// 2. 读取翻译数据 // 2. 读取翻译数据
const translations = loadTranslations(); const translations = loadTranslations()
// 3. 按文件分组 // 3. 按文件分组
const fileGroups = groupByFile(translations); const fileGroups = groupByFile(translations)
// 4. 处理每个文件 // 4. 处理每个文件
for (const [filePath, fileTranslations] of fileGroups) { for (const [filePath, fileTranslations] of fileGroups) {
processFile(filePath, fileTranslations); processFile(filePath, fileTranslations)
} }
// 5. 生成报告 // 5. 生成报告
generateReport(); generateReport()
// 6. 打印总结 // 6. 打印总结
printSummary(); printSummary()
} catch (error) { } catch (error) {
console.error('❌ 执行失败:', error); console.error('❌ 执行失败:', error)
process.exitCode = 1; process.exitCode = 1
} }
} }
main(); main()

View File

@ -5040,4 +5040,4 @@
"conflictReason": "在文件中找不到匹配的文本" "conflictReason": "在文件中找不到匹配的文本"
} }
] ]
} }

View File

@ -1,20 +1,17 @@
const AboutPage = () => { const AboutPage = () => {
return ( return (
<div className="flex flex-col items-center justify-start relative size-full"> <div className="relative flex size-full flex-col items-center justify-start">
<div className="flex gap-1 grow items-start justify-center content-stretch min-h-px min-w-px pb-0 pt-28 px-6 md:px-12 relative shrink-0 w-full"> <div className="relative flex min-h-px w-full min-w-px shrink-0 grow content-stretch items-start justify-center gap-1 px-6 pt-28 pb-0 md:px-12">
<div className="max-w-[752px]"> <div className="max-w-[752px]">
<div className="pb-[27.26%] w-full relative"> <div className="relative w-full pb-[27.26%]">
<img <img
src="/images/about/banner.png" src="/images/about/banner.png"
alt="Banner" alt="Banner"
className="inset-0 object-cover absolute" className="absolute inset-0 object-cover"
/> />
</div> </div>
<div className="mt-12 txt-body-l"> <div className="txt-body-l mt-12">
<div> <div>
Grow your love story with CrushLevel AIFrom "Hi" to "I Do", sparked by every chat Grow your love story with CrushLevel AIFrom "Hi" to "I Do", sparked by every chat
</div> </div>
@ -23,23 +20,18 @@ const AboutPage = () => {
At CrushLevel AI, every chat writes a new verse in your love epic At CrushLevel AI, every chat writes a new verse in your love epic
</div> </div>
<div> <div>
From that tentative "Hi" to the trembling "I do", find a home for the flirts you never sent, From that tentative "Hi" to the trembling "I do", find a home for the flirts you never
</div> sent,
<div>
the responses you longed for,
</div>
<div>
and the risky emotional gambles you feared to take.
</div> </div>
<div>the responses you longed for,</div>
<div>and the risky emotional gambles you feared to take.</div>
<div className="mt-8"> <div className="mt-8">{`Contact Us: ${process.env.NEXT_PUBLIC_EMAIL_CONTACT_US}`}</div>
{`Contact Us: ${process.env.NEXT_PUBLIC_EMAIL_CONTACT_US}`}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); )
} }
export default AboutPage; export default AboutPage

View File

@ -1,13 +1,11 @@
import { ReactNode } from "react"; import { ReactNode } from 'react'
const AuthLayout = ({ children }: { children: ReactNode }) => { const AuthLayout = ({ children }: { children: ReactNode }) => {
return ( return (
<div className="flex items-center justify-center relative bg-[url('/common-bg.png')] bg-cover bg-top bg-no-repeat bg-fixed"> <div className="relative flex items-center justify-center bg-[url('/common-bg.png')] bg-cover bg-fixed bg-top bg-no-repeat">
<div className="min-h-screen w-full"> <div className="min-h-screen w-full">{children}</div>
{children}
</div>
</div> </div>
); )
} }
export default AuthLayout; export default AuthLayout

View File

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

View File

@ -1,19 +1,19 @@
"use client" 'use client'
import { googleOAuth, type GoogleCredentialResponse } from "@/lib/oauth/google"; import { googleOAuth, type GoogleCredentialResponse } from '@/lib/oauth/google'
import { SocialButton } from "./SocialButton"; import { SocialButton } from './SocialButton'
import { toast } from "sonner"; import { toast } from 'sonner'
import { useEffect, useState, useRef } from "react"; import { useEffect, useState, useRef } from 'react'
import { useLogin } from "@/hooks/auth"; import { useLogin } from '@/hooks/auth'
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from 'next/navigation'
import { tokenManager } from "@/lib/auth/token"; import { tokenManager } from '@/lib/auth/token'
import { AppClient, ThirdType } from "@/services/auth"; import { AppClient, ThirdType } from '@/services/auth'
const GoogleButton = () => { const GoogleButton = () => {
const login = useLogin() const login = useLogin()
const router = useRouter() const router = useRouter()
const searchParams = useSearchParams() const searchParams = useSearchParams()
const redirect = searchParams.get('redirect'); const redirect = searchParams.get('redirect')
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const buttonRef = useRef<HTMLDivElement>(null) const buttonRef = useRef<HTMLDivElement>(null)
const isInitializedRef = useRef(false) const isInitializedRef = useRef(false)
@ -22,20 +22,23 @@ const GoogleButton = () => {
const handleGoogleResponse = async (response: GoogleCredentialResponse) => { const handleGoogleResponse = async (response: GoogleCredentialResponse) => {
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,
deviceCode: deviceId, deviceCode: deviceId,
thirdToken: response.credential, // Google ID Token (JWT) thirdToken: response.credential, // Google ID Token (JWT)
thirdType: ThirdType.Google thirdType: ThirdType.Google,
} }
login.mutate(loginData, { login.mutate(loginData, {
onSuccess: () => { onSuccess: () => {
toast.success("Login successful") toast.success('Login successful')
// 清除 Next.js 路由缓存,避免使用登录前 prefetch 的重定向响应
router.refresh()
const loginRedirectUrl = sessionStorage.getItem('login_redirect_url') const loginRedirectUrl = sessionStorage.getItem('login_redirect_url')
sessionStorage.removeItem('login_redirect_url') sessionStorage.removeItem('login_redirect_url')
@ -48,13 +51,13 @@ const GoogleButton = () => {
}, },
onError: (error) => { onError: (error) => {
console.error('Login error:', error) console.error('Login error:', error)
toast.error("Login failed") toast.error('Login failed')
setIsLoading(false) setIsLoading(false)
} },
}) })
} catch (error) { } catch (error) {
console.error('Google login error:', error) console.error('Google login error:', error)
toast.error("Google login failed") toast.error('Google login failed')
setIsLoading(false) setIsLoading(false)
} }
} }
@ -64,9 +67,9 @@ const GoogleButton = () => {
const loadAndInitGoogleButton = async () => { const loadAndInitGoogleButton = async () => {
try { try {
if (isInitializedRef.current || !buttonRef.current) return if (isInitializedRef.current || !buttonRef.current) return
await googleOAuth.loadScript() await googleOAuth.loadScript()
// 使用 Google 提供的标准按钮,这种方式会自动处理未登录的情况 // 使用 Google 提供的标准按钮,这种方式会自动处理未登录的情况
if (window.google?.accounts?.id && buttonRef.current) { if (window.google?.accounts?.id && buttonRef.current) {
googleOAuth.renderButton(buttonRef.current, handleGoogleResponse, { googleOAuth.renderButton(buttonRef.current, handleGoogleResponse, {
@ -74,14 +77,14 @@ const GoogleButton = () => {
theme: 'outline', theme: 'outline',
size: 'large', size: 'large',
text: 'continue_with', text: 'continue_with',
width: buttonRef.current.offsetWidth.toString() width: buttonRef.current.offsetWidth.toString(),
}) })
isInitializedRef.current = true isInitializedRef.current = true
console.log('Google Sign-In button rendered') 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)
toast.error("Failed to load Google login") toast.error('Failed to load Google login')
} }
} }
@ -93,22 +96,19 @@ const GoogleButton = () => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
sessionStorage.setItem('login_redirect_url', redirect || '') sessionStorage.setItem('login_redirect_url', redirect || '')
} }
// 如果 Google 按钮已渲染,点击会自动触发 // 如果 Google 按钮已渲染,点击会自动触发
// 如果未渲染,显示提示 // 如果未渲染,显示提示
if (!isInitializedRef.current) { if (!isInitializedRef.current) {
toast.error("Google login is not ready yet") toast.error('Google login is not ready yet')
} }
} }
return ( return (
<> <>
{/* 隐藏的 Google 标准按钮容器 */} {/* 隐藏的 Google 标准按钮容器 */}
<div <div ref={buttonRef} style={{ display: 'none' }} />
ref={buttonRef}
style={{ display: 'none' }}
/>
{/* 自定义样式的按钮,点击时触发隐藏的 Google 按钮 */} {/* 自定义样式的按钮,点击时触发隐藏的 Google 按钮 */}
<SocialButton <SocialButton
icon={<i className="iconfont icon-social-google !text-[20px] sm:!text-[24px]"></i>} icon={<i className="iconfont icon-social-google !text-[20px] sm:!text-[24px]"></i>}
@ -116,7 +116,9 @@ const GoogleButton = () => {
handleGoogleLogin() handleGoogleLogin()
// 触发隐藏的 Google 按钮 // 触发隐藏的 Google 按钮
if (buttonRef.current) { if (buttonRef.current) {
const googleButton = buttonRef.current.querySelector('div[role="button"]') as HTMLElement const googleButton = buttonRef.current.querySelector(
'div[role="button"]'
) as HTMLElement
if (googleButton) { if (googleButton) {
googleButton.click() googleButton.click()
} }
@ -124,11 +126,10 @@ const GoogleButton = () => {
}} }}
disabled={login.isPending || isLoading} disabled={login.isPending || isLoading}
> >
{login.isPending || isLoading ? "Signing in..." : "Continue with Google"} {login.isPending || isLoading ? 'Signing in...' : 'Continue with Google'}
</SocialButton> </SocialButton>
</> </>
); )
} }
export default GoogleButton; export default GoogleButton

View File

@ -1,7 +1,7 @@
'use client' 'use client'
import Image from "next/image" import Image from 'next/image'
import { useState, useEffect } from "react" import { useState, useEffect } from 'react'
interface ImageCarouselProps { interface ImageCarouselProps {
images: string[] images: string[]
@ -10,11 +10,11 @@ interface ImageCarouselProps {
interval?: number interval?: number
} }
export function ImageCarousel({ export function ImageCarousel({
images, images,
className = "", className = '',
autoPlay = true, autoPlay = true,
interval = 3000 interval = 3000,
}: ImageCarouselProps) { }: ImageCarouselProps) {
const [currentIndex, setCurrentIndex] = useState(0) const [currentIndex, setCurrentIndex] = useState(0)
const [isTransitioning, setIsTransitioning] = useState(false) const [isTransitioning, setIsTransitioning] = useState(false)
@ -25,9 +25,7 @@ export function ImageCarousel({
const timer = setInterval(() => { const timer = setInterval(() => {
setIsTransitioning(true) setIsTransitioning(true)
setTimeout(() => { setTimeout(() => {
setCurrentIndex((prevIndex) => setCurrentIndex((prevIndex) => (prevIndex === images.length - 1 ? 0 : prevIndex + 1))
prevIndex === images.length - 1 ? 0 : prevIndex + 1
)
setIsTransitioning(false) setIsTransitioning(false)
}, 250) // 渐隐时间的一半 }, 250) // 渐隐时间的一半
}, interval) }, interval)
@ -40,19 +38,17 @@ export function ImageCarousel({
} }
return ( return (
<div className={`relative group ${className}`}> <div className={`group relative ${className}`}>
{/* 主图片容器 */} {/* 主图片容器 */}
<div className="relative w-full h-full overflow-hidden"> <div className="relative h-full w-full overflow-hidden">
{images.map((image, index) => ( {images.map((image, index) => (
<Image <Image
key={`${image}-${index}`} key={`${image}-${index}`}
src={`/images${image}`} src={`/images${image}`}
alt={`Slide image ${index + 1}`} alt={`Slide image ${index + 1}`}
fill fill
className={`object-cover object-top transition-opacity duration-500 absolute inset-0 ${ className={`absolute inset-0 object-cover object-top transition-opacity duration-500 ${
index === currentIndex && !isTransitioning index === currentIndex && !isTransitioning ? 'opacity-100' : 'opacity-0'
? 'opacity-100'
: 'opacity-0'
}`} }`}
priority={index === 0} priority={index === 0}
/> />

View File

@ -1,92 +1,95 @@
"use client"; 'use client'
import Image from "next/image"; import Image from 'next/image'
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from 'react'
import { ScrollingBackground } from "./ScrollingBackground"; import { ScrollingBackground } from './ScrollingBackground'
interface LeftPanelProps { interface LeftPanelProps {
scrollBg: string; scrollBg: string
images: string[]; images: string[]
} }
// 基础文字内容 // 基础文字内容
const baseTexts = [ const baseTexts = [
{ title: "AI Date", subtitle: "From 'Hi' to 'I Do', sparked by every chat." }, { title: 'AI Date', subtitle: "From 'Hi' to 'I Do', sparked by every chat." },
{ title: "Crush", subtitle: "From 'Hi' to 'I Do', sparked by every chat." }, { title: 'Crush', subtitle: "From 'Hi' to 'I Do', sparked by every chat." },
{ title: "Chat", subtitle: "From 'Hi' to 'I Do', sparked by every chat." }, { title: 'Chat', subtitle: "From 'Hi' to 'I Do', sparked by every chat." },
]; ]
// 根据图片数量循环生成文字内容 // 根据图片数量循环生成文字内容
const generateImageTexts = (count: number) => { const generateImageTexts = (count: number) => {
return Array.from({ length: count }, (_, i) => baseTexts[i % baseTexts.length]); return Array.from({ length: count }, (_, i) => baseTexts[i % baseTexts.length])
}; }
export function LeftPanel({ scrollBg, images }: LeftPanelProps) { export function LeftPanel({ scrollBg, images }: LeftPanelProps) {
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0)
const [isTransitioning, setIsTransitioning] = useState(false); const [isTransitioning, setIsTransitioning] = useState(false)
// 根据图片数量动态生成文案(使用 useMemo 优化性能) // 根据图片数量动态生成文案(使用 useMemo 优化性能)
const imageTexts = useMemo(() => generateImageTexts(images.length), [images.length]); const imageTexts = useMemo(() => generateImageTexts(images.length), [images.length])
useEffect(() => { useEffect(() => {
if (images.length <= 1) return; if (images.length <= 1) return
const timer = setInterval(() => { const timer = setInterval(() => {
setIsTransitioning(true); setIsTransitioning(true)
setTimeout(() => { setTimeout(() => {
setCurrentIndex((prevIndex) => setCurrentIndex((prevIndex) => (prevIndex === images.length - 1 ? 0 : prevIndex + 1))
prevIndex === images.length - 1 ? 0 : prevIndex + 1 setIsTransitioning(false)
); }, 300)
setIsTransitioning(false); }, 3000) // 每3秒切换一次
}, 300);
}, 3000); // 每3秒切换一次
return () => clearInterval(timer); return () => clearInterval(timer)
}, [images.length]); }, [images.length])
const currentText = imageTexts[currentIndex] || imageTexts[0]; const currentText = imageTexts[currentIndex] || imageTexts[0]
return ( return (
<div className="relative w-full h-full overflow-hidden"> <div className="relative h-full w-full overflow-hidden">
{/* 滚动背景 */} {/* 滚动背景 */}
<ScrollingBackground imageSrc={scrollBg} /> <ScrollingBackground imageSrc={scrollBg} />
{/* 内容层 */} {/* 内容层 */}
<div className="relative z-10 flex flex-col justify-end h-full"> <div className="relative z-10 flex h-full flex-col justify-end">
{/* 底部遮罩层 - 铺满背景底部高度500px */} {/* 底部遮罩层 - 铺满背景底部高度500px */}
<div className="absolute bottom-0 left-0 right-0 h-[500px] z-[5]" style={{ <div
background: "linear-gradient(180deg, rgba(33, 26, 43, 0) 0%, #211A2B 100%)", className="absolute right-0 bottom-0 left-0 z-[5] h-[500px]"
boxShadow: "0px 4px 4px 0px #00000040" 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={`absolute right-0 bottom-16 left-0 z-10 mb-6 px-4 text-center transition-opacity duration-700 lg:bottom-20 lg:mb-8 lg:px-8 ${
isTransitioning ? "opacity-0" : "opacity-100" isTransitioning ? 'opacity-0' : 'opacity-100'
}`} }`}
> >
<div className="flex items-center justify-center px-4 lg:px-6 py-2 lg:py-3 mx-auto"> <div className="mx-auto flex items-center justify-center px-4 py-2 lg:px-6 lg:py-3">
<h2 className="txt-headline-m lg:txt-headline-l text-white flex items-center gap-2 relative"> <h2 className="txt-headline-m lg:txt-headline-l relative flex items-center gap-2 text-white">
{currentText.title} {currentText.title}
<Image src="/images/login/v1/icon-star-right.svg" alt="logo" width={38} height={36} className="absolute -top-[18px] -right-[38px]" /> <Image
src="/images/login/v1/icon-star-right.svg"
alt="logo"
width={38}
height={36}
className="absolute -top-[18px] -right-[38px]"
/>
</h2> </h2>
</div> </div>
<p className="txt-body-m lg:txt-body-l max-w-[320px] lg:max-w-[380px] mx-auto"> <p className="txt-body-m lg:txt-body-l mx-auto max-w-[320px] lg:max-w-[380px]">
{currentText.subtitle} {currentText.subtitle}
</p> </p>
</div> </div>
{/* 角色图片 - 尽可能放大,紧贴底部 */} {/* 角色图片 - 尽可能放大,紧贴底部 */}
<div className="relative w-full h-[80vh] lg:h-[85vh]"> <div className="relative h-[80vh] w-full lg:h-[85vh]">
{images.map((image, index) => ( {images.map((image, index) => (
<div <div
key={`${image}-${index}`} key={`${image}-${index}`}
className={`absolute inset-0 transition-opacity duration-700 ${ className={`absolute inset-0 transition-opacity duration-700 ${
index === currentIndex && !isTransitioning index === currentIndex && !isTransitioning ? 'opacity-100' : 'opacity-0'
? "opacity-100"
: "opacity-0"
}`} }`}
> >
<Image <Image
@ -101,6 +104,5 @@ export function LeftPanel({ scrollBg, images }: LeftPanelProps) {
</div> </div>
</div> </div>
</div> </div>
); )
} }

View File

@ -1,47 +1,47 @@
"use client"; 'use client'
import { useEffect, useRef } from "react"; import { useEffect, useRef } from 'react'
interface ScrollingBackgroundProps { interface ScrollingBackgroundProps {
imageSrc: string; imageSrc: string
} }
export function ScrollingBackground({ imageSrc }: ScrollingBackgroundProps) { export function ScrollingBackground({ imageSrc }: ScrollingBackgroundProps) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null)
useEffect(() => { useEffect(() => {
const container = containerRef.current; const container = containerRef.current
if (!container) return; if (!container) return
let scrollPosition = 0; let scrollPosition = 0
const speed = 0.5; // 滚动速度,数字越大滚动越快 const speed = 0.5 // 滚动速度,数字越大滚动越快
const animate = () => { const animate = () => {
scrollPosition += speed; scrollPosition += speed
// 当滚动到一半时重置(因为我们有两张图片) // 当滚动到一半时重置(因为我们有两张图片)
if (scrollPosition >= container.scrollHeight / 2) { if (scrollPosition >= container.scrollHeight / 2) {
scrollPosition = 0; scrollPosition = 0
} }
container.scrollTop = scrollPosition;
requestAnimationFrame(animate);
};
const animationId = requestAnimationFrame(animate); container.scrollTop = scrollPosition
requestAnimationFrame(animate)
}
const animationId = requestAnimationFrame(animate)
return () => { return () => {
cancelAnimationFrame(animationId); cancelAnimationFrame(animationId)
}; }
}, []); }, [])
return ( return (
<div <div
ref={containerRef} ref={containerRef}
className="absolute inset-0 overflow-hidden" className="absolute inset-0 overflow-hidden"
style={{ style={{
scrollbarWidth: "none", scrollbarWidth: 'none',
msOverflowStyle: "none", msOverflowStyle: 'none',
}} }}
> >
{/* 隐藏滚动条的样式 */} {/* 隐藏滚动条的样式 */}
@ -50,21 +50,12 @@ export function ScrollingBackground({ imageSrc }: ScrollingBackgroundProps) {
display: none; display: none;
} }
`}</style> `}</style>
{/* 两张相同的图片,用于无缝循环 */} {/* 两张相同的图片,用于无缝循环 */}
<div className="relative"> <div className="relative">
<img <img src={imageSrc} alt="Background" className="block h-auto w-full" />
src={imageSrc} <img src={imageSrc} alt="Background" className="block h-auto w-full" />
alt="Background"
className="w-full h-auto block"
/>
<img
src={imageSrc}
alt="Background"
className="w-full h-auto block"
/>
</div> </div>
</div> </div>
); )
} }

View File

@ -1,6 +1,6 @@
"use client" 'use client'
import { Button } from "@/components/ui/button" import { Button } from '@/components/ui/button'
import type { ReactNode } from "react" import type { ReactNode } from 'react'
interface SocialButtonProps { interface SocialButtonProps {
icon: ReactNode icon: ReactNode
@ -12,13 +12,7 @@ interface SocialButtonProps {
export function SocialButton({ icon, children, loading, onClick, disabled }: SocialButtonProps) { export function SocialButton({ icon, children, loading, onClick, disabled }: SocialButtonProps) {
return ( return (
<Button <Button variant="tertiary" block onClick={onClick} disabled={disabled} loading={loading}>
variant="tertiary"
block
onClick={onClick}
disabled={disabled}
loading={loading}
>
{icon} {icon}
{children} {children}
</Button> </Button>

View File

@ -1,45 +1,44 @@
"use client" 'use client'
import { SocialButton } from "./SocialButton" import { SocialButton } from './SocialButton'
import Link from "next/link" import Link from 'next/link'
import { toast } from "sonner" import { toast } from 'sonner'
import DiscordButton from "./DiscordButton" import DiscordButton from './DiscordButton'
import GoogleButton from "./GoogleButton" import GoogleButton from './GoogleButton'
export function LoginForm() { export function LoginForm() {
const handleAppleLogin = () => { const handleAppleLogin = () => {
toast.info("Apple Sign In", { toast.info('Apple Sign In', {
description: "Apple登录功能正在开发中..." description: 'Apple登录功能正在开发中...',
}) })
} }
return ( return (
<div className="w-full space-y-3 sm:space-y-4"> <div className="w-full space-y-3 sm:space-y-4">
<div className="text-center mb-4 sm:mb-6"> <div className="mb-4 text-center sm:mb-6">
<h2 className="txt-title-m sm:txt-title-l">Log in/Sign up</h2> <h2 className="txt-title-m sm:txt-title-l">Log in/Sign up</h2>
<p className="text-gradient mt-3 sm:mt-4 text-sm sm:text-base">Chat, Crush, AI Date</p> <p className="text-gradient mt-3 text-sm sm:mt-4 sm:text-base">Chat, Crush, AI Date</p>
</div> </div>
<div className="space-y-3 sm:space-y-4 mt-4 sm:mt-6"> <div className="mt-4 space-y-3 sm:mt-6 sm:space-y-4">
<DiscordButton /> <DiscordButton />
<GoogleButton /> <GoogleButton />
<SocialButton {/* <SocialButton
icon={<i className="iconfont icon-social-apple !text-[20px] sm:!text-[24px]"></i>} icon={<i className="iconfont icon-social-apple !text-[20px] sm:!text-[24px]"></i>}
onClick={handleAppleLogin} onClick={handleAppleLogin}
> >
Continue with Apple Continue with Apple
</SocialButton> </SocialButton> */}
</div> </div>
<div className="text-center mt-4 sm:mt-6"> <div className="mt-4 text-center sm:mt-6">
<p className="txt-body-s sm:txt-body-m text-txt-secondary-normal"> <p className="txt-body-s sm:txt-body-m text-txt-secondary-normal">
By continuing, you agree to CrushLevel's{" "} By continuing, you agree to CrushLevel's{' '}
<Link href="/policy/tos" target="_blank" className="text-primary-variant-normal"> <Link href="/policy/tos" target="_blank" className="text-primary-variant-normal">
User Agreement User Agreement
</Link>{" "} </Link>{' '}
and{" "} and{' '}
<Link href="/policy/privacy" target="_blank" className="text-primary-variant-normal"> <Link href="/policy/privacy" target="_blank" className="text-primary-variant-normal">
Privacy Policy Privacy Policy
</Link> </Link>

View File

@ -1,29 +1,41 @@
"use client" 'use client'
import Image from "next/image" import Image from 'next/image'
import { useForm } from "react-hook-form" import { useForm } from 'react-hook-form'
import { Input } from "@/components/ui/input" import { Input } from '@/components/ui/input'
import { Button } from "@/components/ui/button" import { Button } from '@/components/ui/button'
import { Label } from "@/components/ui/label" import { Label } from '@/components/ui/label'
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select" import {
import { Form, FormField, FormControl, FormItem, FormLabel, FormMessage } from "@/components/ui/form" Select,
import GenderInput from "@/components/features/genderInput" SelectTrigger,
import { zodResolver } from "@hookform/resolvers/zod" SelectValue,
import * as z from "zod" SelectContent,
import { Gender } from "@/types/user" SelectItem,
import { useEffect, useState } from "react" } from '@/components/ui/select'
import dayjs from "dayjs" import {
import { useCheckNickname, useCompleteUser, useCurrentUser } from "@/hooks/auth" Form,
import { useRouter, useSearchParams } from "next/navigation" FormField,
FormControl,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import GenderInput from '@/components/features/genderInput'
import { zodResolver } from '@hookform/resolvers/zod'
import * as z from 'zod'
import { Gender } from '@/types/user'
import { useEffect, useState } from 'react'
import dayjs from 'dayjs'
import { useCheckNickname, useCompleteUser, useCurrentUser } from '@/hooks/auth'
import { useRouter, useSearchParams } from 'next/navigation'
const currentYear = dayjs().year() const currentYear = dayjs().year()
const years = Array.from({ length: currentYear - 1950 + 1 }, (_, i) => `${1950 + i}`) const years = Array.from({ length: currentYear - 1950 + 1 }, (_, i) => `${1950 + i}`)
const months = Array.from({ length: 12 }, (_, i) => `${i + 1}`.padStart(2, "0")) const months = Array.from({ length: 12 }, (_, i) => `${i + 1}`.padStart(2, '0'))
const monthTexts = Array.from({ length: 12 }, (_, i) => dayjs().month(i).format('MMM')) const monthTexts = Array.from({ length: 12 }, (_, i) => dayjs().month(i).format('MMM'))
function getDaysInMonth(year: string, month: string) { function getDaysInMonth(year: string, month: string) {
return Array.from( return Array.from({ length: dayjs(`${year}-${month}`).daysInMonth() }, (_, i) =>
{ length: dayjs(`${year}-${month}`).daysInMonth() }, `${i + 1}`.padStart(2, '0')
(_, i) => `${i + 1}`.padStart(2, "0")
) )
} }
@ -33,19 +45,28 @@ function calculateAge(year: string, month: string, day: string) {
return today.diff(birthDate, 'year') return today.diff(birthDate, 'year')
} }
const schema = z.object({ const schema = z
nickname: z.string().trim().min(1, "Please Enter nickname").min(2, "Nickname must be between 2 and 20 characters"), .object({
gender: z.enum(Gender, { message: "Please select your gender" }), nickname: z
year: z.string().min(1, "Please select your birthday"), .string()
month: z.string().min(1, "Please select your birthday"), .trim()
day: z.string().min(1, "Please select your birthday") .min(1, 'Please Enter nickname')
}).refine((data) => { .min(2, 'Nickname must be between 2 and 20 characters'),
const age = calculateAge(data.year, data.month, data.day) gender: z.enum(Gender, { message: 'Please select your gender' }),
return age >= 18 year: z.string().min(1, 'Please select your birthday'),
}, { month: z.string().min(1, 'Please select your birthday'),
message: "Character age must be at least 18 years old", day: z.string().min(1, 'Please select your birthday'),
path: ["year"] })
}) .refine(
(data) => {
const age = calculateAge(data.year, data.month, data.day)
return age >= 18
},
{
message: 'Character age must be at least 18 years old',
path: ['year'],
}
)
type FormValues = z.infer<typeof schema> type FormValues = z.infer<typeof schema>
@ -53,51 +74,50 @@ export default function FieldsPage() {
const form = useForm<FormValues>({ const form = useForm<FormValues>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
nickname: "", nickname: '',
gender: undefined, gender: undefined,
year: "2000", year: '2000',
month: "01", month: '01',
day: "01" day: '01',
}, },
mode: "all", mode: 'all',
}, })
); const { mutateAsync } = useCompleteUser()
const { mutateAsync } = useCompleteUser(); const { data: user, refetch } = useCurrentUser()
const { data: user, refetch } = useCurrentUser(); const router = useRouter()
const router = useRouter(); const searchParams = useSearchParams()
const searchParams = useSearchParams(); const redirect = searchParams.get('redirect')
const redirect = searchParams.get("redirect"); const [loading, setLoading] = useState(false)
const [loading, setLoading] = useState(false);
const { mutateAsync: checkNickname } = useCheckNickname({ const { mutateAsync: checkNickname } = useCheckNickname({
onError: (error) => { onError: (error) => {
form.setError("nickname", { form.setError('nickname', {
message: error.errorMsg, message: error.errorMsg,
}) })
} },
}); })
const selectedYear = form.watch("year") const selectedYear = form.watch('year')
const selectedMonth = form.watch("month") const selectedMonth = form.watch('month')
const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : [] const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : []
useEffect(() => { useEffect(() => {
if (!user?.cpUserInfo) { if (!user?.cpUserInfo) {
router.push("/") router.push('/')
} }
}, [user?.cpUserInfo]) }, [user?.cpUserInfo])
useEffect(() => { useEffect(() => {
const currentDay = form.getValues("day") const currentDay = form.getValues('day')
const maxDay = days[days.length - 1] const maxDay = days[days.length - 1]
if (parseInt(currentDay) > parseInt(maxDay)) { if (parseInt(currentDay) > parseInt(maxDay)) {
form.setValue("day", maxDay) form.setValue('day', maxDay)
} }
}, [selectedYear, selectedMonth, days, form]) }, [selectedYear, selectedMonth, days, form])
async function onSubmit(data: FormValues) { async function onSubmit(data: FormValues) {
if (!user?.userId) { if (!user?.userId) {
return; return
} }
setLoading(true) setLoading(true)
try { try {
@ -105,10 +125,10 @@ export default function FieldsPage() {
nickname: data.nickname.trim(), nickname: data.nickname.trim(),
}) })
if (isExist) { if (isExist) {
form.setError("nickname", { form.setError('nickname', {
message: "This nickname is already taken", message: 'This nickname is already taken',
}) })
return; return
} }
await mutateAsync({ await mutateAsync({
nickname: data.nickname.trim(), nickname: data.nickname.trim(),
@ -116,11 +136,11 @@ export default function FieldsPage() {
birthday: `${data.year}-${data.month}-${data.day}`, birthday: `${data.year}-${data.month}-${data.day}`,
userId: user?.userId, userId: user?.userId,
}) })
await refetch(); await refetch()
if (redirect) { if (redirect) {
router.push(decodeURIComponent(redirect)) router.push(decodeURIComponent(redirect))
} else { } else {
router.push("/") router.push('/')
} }
} catch (error) { } catch (error) {
console.error(error) console.error(error)
@ -131,12 +151,19 @@ export default function FieldsPage() {
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="w-[221px] h-[88px] relative mx-auto mt-[20vh]"> <div className="relative mx-auto mt-[20vh] h-[88px] w-[221px]">
<Image src="/images/login/logo.svg" alt="Anime character" width={221} height={88} className="object-cover" priority /> <Image
src="/images/login/logo.svg"
alt="Anime character"
width={221}
height={88}
className="object-cover"
priority
/>
</div> </div>
<div className="mt-20 w-[752px]"> <div className="mt-20 w-[752px]">
<div className="bg-surface-element-normal mt-6 rounded-lg p-6"> <div className="bg-surface-element-normal mt-6 rounded-lg p-6">
<h2 className="txt-title-l text-center py-4">Personal Information</h2> <h2 className="txt-title-l py-4 text-center">Personal Information</h2>
<Form {...form}> <Form {...form}>
<form className="flex flex-col gap-6" onSubmit={form.handleSubmit(onSubmit)}> <form className="flex flex-col gap-6" onSubmit={form.handleSubmit(onSubmit)}>
<FormField <FormField
@ -167,19 +194,18 @@ export default function FieldsPage() {
<FormItem> <FormItem>
<FormLabel className="txt-label-m">Gender</FormLabel> <FormLabel className="txt-label-m">Gender</FormLabel>
<FormControl> <FormControl>
<GenderInput <GenderInput value={field.value} onChange={field.onChange} />
value={field.value}
onChange={field.onChange}
/>
</FormControl> </FormControl>
<div className="txt-body-s text-txt-secondary-normal mt-1">Please note: gender cannot be changed after setting</div> <div className="txt-body-s text-txt-secondary-normal mt-1">
Please note: gender cannot be changed after setting
</div>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<div> <div>
<Label className="block txt-label-m mb-3">Birthday</Label> <Label className="txt-label-m mb-3 block">Birthday</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<FormField <FormField
control={form.control} control={form.control}
@ -187,9 +213,15 @@ export default function FieldsPage() {
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<Select onValueChange={field.onChange} value={field.value}> <Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full" error={!!form.formState.errors.year}><SelectValue placeholder="Year" /></SelectTrigger> <SelectTrigger className="w-full" error={!!form.formState.errors.year}>
<SelectValue placeholder="Year" />
</SelectTrigger>
<SelectContent> <SelectContent>
{years.map(y => <SelectItem key={y} value={y}>{y}</SelectItem>)} {years.map((y) => (
<SelectItem key={y} value={y}>
{y}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</FormItem> </FormItem>
@ -202,9 +234,15 @@ export default function FieldsPage() {
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<Select onValueChange={field.onChange} value={field.value}> <Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full" error={!!form.formState.errors.year}><SelectValue placeholder="Month" /></SelectTrigger> <SelectTrigger className="w-full" error={!!form.formState.errors.year}>
<SelectValue placeholder="Month" />
</SelectTrigger>
<SelectContent> <SelectContent>
{months.map((m, index) => <SelectItem key={m} value={m}>{monthTexts[index]}</SelectItem>)} {months.map((m, index) => (
<SelectItem key={m} value={m}>
{monthTexts[index]}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</FormItem> </FormItem>
@ -217,9 +255,18 @@ export default function FieldsPage() {
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<Select onValueChange={field.onChange} value={field.value}> <Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full" error={!!form.formState.errors.year || !!form.formState.errors.day}><SelectValue placeholder="Day" /></SelectTrigger> <SelectTrigger
className="w-full"
error={!!form.formState.errors.year || !!form.formState.errors.day}
>
<SelectValue placeholder="Day" />
</SelectTrigger>
<SelectContent> <SelectContent>
{days.map(d => <SelectItem key={d} value={d}>{d}</SelectItem>)} {days.map((d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</FormItem> </FormItem>
@ -227,15 +274,15 @@ export default function FieldsPage() {
/> />
</div> </div>
<FormMessage> <FormMessage>
{form.formState.errors.year?.message || form.formState.errors.month?.message || form.formState.errors.day?.message} {form.formState.errors.year?.message ||
form.formState.errors.month?.message ||
form.formState.errors.day?.message}
</FormMessage> </FormMessage>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<div /> <div />
<Button type="submit" <Button type="submit" loading={loading}>
loading={loading}
>
Submit Submit
</Button> </Button>
</div> </div>

View File

@ -1,10 +1,7 @@
import FieldsPage from "./fields-page"; import FieldsPage from './fields-page'
const Page = () => { const Page = () => {
return <FieldsPage />
return (
<FieldsPage />
);
} }
export default Page; export default Page

View File

@ -1,37 +1,37 @@
"use client" 'use client'
import Image from "next/image" import Image from 'next/image'
import { LoginForm } from "./components/login-form" import { LoginForm } from './components/login-form'
import { LeftPanel } from "./components/LeftPanel" import { LeftPanel } from './components/LeftPanel'
import { IconButton } from "@/components/ui/button"; import { IconButton } from '@/components/ui/button'
import { useRouter } from "next/navigation"; import { useRouter } from 'next/navigation'
const scrollBg = "/images/login/v1/bg.png"; const scrollBg = '/images/login/v1/bg.png'
const images = [ const images = [
"/images/login/v1/1.png", '/images/login/v1/1.png',
"/images/login/v1/2.png", '/images/login/v1/2.png',
"/images/login/v1/3.png", '/images/login/v1/3.png',
"/images/login/v1/4.png", '/images/login/v1/4.png',
"/images/login/v1/5.png", '/images/login/v1/5.png',
"/images/login/v1/6.png", '/images/login/v1/6.png',
"/images/login/v1/7.png", '/images/login/v1/7.png',
"/images/login/v1/8.png", '/images/login/v1/8.png',
"/images/login/v1/9.png", '/images/login/v1/9.png',
"/images/login/v1/10.png", '/images/login/v1/10.png',
] ]
export default function LoginPage() { export default function LoginPage() {
const router = useRouter(); const router = useRouter()
const handleClose = () => { const handleClose = () => {
router.replace('/'); router.replace('/')
} }
return ( return (
<div className="flex h-screen w-screen overflow-hidden"> <div className="flex h-screen w-screen overflow-hidden">
{/* 左侧 - 滚动背景 + 图片轮播 (桌面端显示) */} {/* 左侧 - 滚动背景 + 图片轮播 (桌面端显示) */}
<div className="hidden lg:block lg:w-1/2 relative"> <div className="relative hidden lg:block lg:w-1/2">
<LeftPanel scrollBg={scrollBg} images={images} /> <LeftPanel scrollBg={scrollBg} images={images} />
{/* 关闭按钮 - 桌面端 */} {/* 关闭按钮 - 桌面端 */}
<IconButton <IconButton
iconfont="icon-close" iconfont="icon-close"
@ -43,25 +43,19 @@ export default function LoginPage() {
</div> </div>
{/* 右侧 - 登录表单 */} {/* 右侧 - 登录表单 */}
<div className="w-full lg:w-1/2 flex flex-col items-center justify-center px-6 sm:px-12 relative"> <div className="relative flex w-full flex-col items-center justify-center px-6 sm:px-12 lg:w-1/2">
{/* 关闭按钮 - 移动端 */} {/* 关闭按钮 - 移动端 */}
<IconButton <IconButton
iconfont="icon-close" iconfont="icon-close"
variant="tertiary" variant="tertiary"
size="large" size="large"
className="absolute top-4 right-4 lg:hidden z-20" className="absolute top-4 right-4 z-20 lg:hidden"
onClick={handleClose} onClick={handleClose}
/> />
{/* Logo */} {/* Logo */}
<div className="w-[120px] h-[48px] sm:w-[160px] sm:h-[64px] relative mb-8 sm:mb-12"> <div className="relative mb-8 h-[48px] w-[120px] sm:mb-12 sm:h-[64px] sm:w-[160px]">
<Image <Image src="/logo.svg" alt="Crush Level" fill className="object-contain" priority />
src="/logo.svg"
alt="Crush Level"
fill
className="object-contain"
priority
/>
</div> </div>
{/* 登录表单 */} {/* 登录表单 */}

View File

@ -1,10 +1,61 @@
import LoginPage from "./login-page"; import type { Metadata } from 'next'
import LoginPage from './login-page'
export const metadata: Metadata = {
title: 'Login - CrushLevel AI',
description:
'Sign in to CrushLevel AI to start your love story. Login with Discord, Google, or Apple to connect with AI companions and begin chatting.',
keywords: [
'CrushLevel login',
'CrushLevel sign in',
'AI companion login',
'Discord login',
'Google login',
'Apple login',
'CrushLevel account',
],
openGraph: {
title: 'Login - CrushLevel AI',
description:
'Sign in to CrushLevel AI to start your love story. Login with Discord, Google, or Apple to connect with AI companions.',
url: 'https://www.crushlevel.com/login',
siteName: 'CrushLevel AI',
images: [
{
url: '/logo.svg',
width: 1200,
height: 630,
alt: 'CrushLevel AI Login',
},
],
locale: 'en_US',
type: 'website',
},
twitter: {
card: 'summary_large_image',
title: 'Login - CrushLevel AI',
description:
'Sign in to CrushLevel AI to start your love story. Login with Discord, Google, or Apple.',
images: ['/logo.svg'],
},
robots: {
index: true,
follow: true,
googleBot: {
index: true,
follow: true,
'max-video-preview': -1,
'max-image-preview': 'large',
'max-snippet': -1,
},
},
alternates: {
canonical: 'https://www.crushlevel.com/login',
},
}
const Page = () => { const Page = () => {
return <LoginPage />
return (
<LoginPage />
);
} }
export default Page; export default Page

View File

@ -1,8 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from 'next/navigation'
const Policy = () => { const Policy = () => {
redirect('/policy/privacy')
redirect("/policy/privacy")
} }
export default Policy; export default Policy

View File

@ -1,223 +1,268 @@
export default function PrivacyPolicyPage() { export default function PrivacyPolicyPage() {
return ( return (
<div className="flex flex-col items-center justify-start relative size-full min-h-screen"> <div className="relative flex size-full min-h-screen flex-col items-center justify-start">
<div className="flex gap-1 grow items-start justify-center max-w-[1232px] min-h-px min-w-px pb-0 pt-28 px-6 md:px-12 relative shrink-0 w-full"> <div className="relative flex min-h-px w-full max-w-[1232px] min-w-px shrink-0 grow items-start justify-center gap-1 px-6 pt-28 pb-0 md:px-12">
<div className="flex flex-col gap-12 grow items-center justify-start max-w-[752px] min-h-px min-w-px relative shrink-0 w-full"> <div className="relative flex min-h-px w-full max-w-[752px] min-w-px shrink-0 grow flex-col items-center justify-start gap-12">
{/* 主标题 */} {/* 主标题 */}
<div className="txt-headline-s text-center text-white w-full"> <div className="txt-headline-s w-full text-center text-white">
<p className="whitespace-pre-wrap"> <p className="whitespace-pre-wrap">Crushlevel Privacy Policy</p>
Crushlevel Privacy Policy
</p>
</div> </div>
{/* 前言 */} {/* 前言 */}
<div className="txt-body-l text-white w-full"> <div className="txt-body-l w-full text-white">
<p className="mb-4"> <p className="mb-4">
Welcome to the Crushlevel application ("this App") and related website (Crushlevel.ai, "this Website"). We recognize the importance of your personal information and are committed to protecting your privacy rights and information security. This Privacy Policy ("Policy") explains the principles and practices we follow when collecting, using, storing, and safeguarding your personal information. Please read this Policy carefully before using our services. By registering or using our services, you acknowledge full acceptance of this Policy. For any inquiries, contact us through the provided channels. Welcome to the Crushlevel application ("this App") and related website (Crushlevel.ai,
"this Website"). We recognize the importance of your personal information and are
committed to protecting your privacy rights and information security. This Privacy
Policy ("Policy") explains the principles and practices we follow when collecting,
using, storing, and safeguarding your personal information. Please read this Policy
carefully before using our services. By registering or using our services, you
acknowledge full acceptance of this Policy. For any inquiries, contact us through the
provided channels.
</p> </p>
</div> </div>
{/* 隐私政策条款 */} {/* 隐私政策条款 */}
<div className="flex flex-col gap-6 items-start justify-start text-white w-full"> <div className="flex w-full flex-col items-start justify-start gap-6 text-white">
{/* 1. Scope of Application */} {/* 1. Scope of Application */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>1. Scope of Application</p> <p>1. Scope of Application</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
This Policy applies to all personal information processing activities (including collection, use, storage, and protection) during your use of this App and Website. We comply with applicable laws in your country/region and international data protection standards. This Policy applies to all personal information processing activities (including
collection, use, storage, and protection) during your use of this App and Website.
We comply with applicable laws in your country/region and international data
protection standards.
</p> </p>
</div> </div>
</div> </div>
{/* 2. Collection of Personal Information */} {/* 2. Collection of Personal Information */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>2. Collection of Personal Information</p> <p>2. Collection of Personal Information</p>
</div> </div>
{/* a) Registration Information */} {/* a) Registration Information */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>a) Registration Information</p> <p>a) Registration Information</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
When registering an account, we collect your mobile number or email address to create your account, facilitate login, and deliver service notifications. When registering an account, we collect your mobile number or email address to
create your account, facilitate login, and deliver service notifications.
</p> </p>
</div> </div>
</div> </div>
{/* b) Service Usage Information */} {/* b) Service Usage Information */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>b) Service Usage Information</p> <p>b) Service Usage Information</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
<strong>Virtual Character Creation:</strong> Descriptions you provide for AI characters (excluding non-personal information). <strong>Virtual Character Creation:</strong> Descriptions you provide for AI
characters (excluding non-personal information).
</p> </p>
<p className="mb-4"> <p className="mb-4">
<strong>Chat Interactions:</strong> Text, images, voice messages, and other content exchanged with AI characters to enable service functionality, store chat history, and optimize features. <strong>Chat Interactions:</strong> Text, images, voice messages, and other
content exchanged with AI characters to enable service functionality, store chat
history, and optimize features.
</p> </p>
<p className="mb-4"> <p className="mb-4">
<strong>Feature Engagement:</strong> Records related to relationship upgrades, unlocked features, and rewards to monitor service usage and ensure performance. <strong>Feature Engagement:</strong> Records related to relationship upgrades,
unlocked features, and rewards to monitor service usage and ensure performance.
</p> </p>
</div> </div>
</div> </div>
{/* c) Payment Information */} {/* c) Payment Information */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>c) Payment Information</p> <p>c) Payment Information</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
For transactions (pay-per-message, subscriptions, virtual currency purchases), we collect payment methods, amounts, and timestamps to complete transactions and deliver paid services. For transactions (pay-per-message, subscriptions, virtual currency purchases),
we collect payment methods, amounts, and timestamps to complete transactions and
deliver paid services.
</p> </p>
</div> </div>
</div> </div>
{/* d) Device & Log Information */} {/* d) Device & Log Information */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>d) Device & Log Information</p> <p>d) Device & Log Information</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
To ensure service stability and security, we may collect device model, OS version, IP address, browser type, timestamps, and access records. To ensure service stability and security, we may collect device model, OS
version, IP address, browser type, timestamps, and access records.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* 3. Use of Personal Information */} {/* 3. Use of Personal Information */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>3. Use of Personal Information</p> <p>3. Use of Personal Information</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">We use your personal information to:</p>
We use your personal information to: <ul className="mb-4 ml-6 list-disc">
</p> <li className="mb-2">
<ul className="list-disc ml-6 mb-4"> Provide services (account management, character creation, chat interactions,
<li className="mb-2">Provide services (account management, character creation, chat interactions, paid features, etc.).</li> paid features, etc.).
<li className="mb-2">Optimize services by analyzing usage patterns to enhance functionality.</li> </li>
<li className="mb-2">Conduct promotional activities (with your consent or where permitted by law), without disclosing sensitive data.</li> <li className="mb-2">
<li className="mb-2">Troubleshoot issues, maintain service integrity, and protect your rights.</li> Optimize services by analyzing usage patterns to enhance functionality.
</li>
<li className="mb-2">
Conduct promotional activities (with your consent or where permitted by law),
without disclosing sensitive data.
</li>
<li className="mb-2">
Troubleshoot issues, maintain service integrity, and protect your rights.
</li>
</ul> </ul>
</div> </div>
</div> </div>
{/* 4. Storage of Personal Information */} {/* 4. Storage of Personal Information */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>4. Storage of Personal Information</p> <p>4. Storage of Personal Information</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
a) Your data is stored on secure servers with robust technical/administrative measures to prevent loss, leakage, tampering, or misuse. a) Your data is stored on secure servers with robust technical/administrative
measures to prevent loss, leakage, tampering, or misuse.
</p> </p>
<p className="mb-4"> <p className="mb-4">
b) Retention periods align with service needs and legal requirements. Post-expiry, data is deleted or anonymized. b) Retention periods align with service needs and legal requirements. Post-expiry,
data is deleted or anonymized.
</p> </p>
<p className="mb-4"> <p className="mb-4">
c) Data is primarily stored in your country/region. Cross-border transfers (if any) comply with applicable laws and implement safeguards (e.g., standard contractual clauses). c) Data is primarily stored in your country/region. Cross-border transfers (if
any) comply with applicable laws and implement safeguards (e.g., standard
contractual clauses).
</p> </p>
</div> </div>
</div> </div>
{/* 5. Protection of Personal Information */} {/* 5. Protection of Personal Information */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>5. Protection of Personal Information</p> <p>5. Protection of Personal Information</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
a) We implement strict security protocols, including encryption and access controls, to prevent unauthorized access or disclosure. a) We implement strict security protocols, including encryption and access
controls, to prevent unauthorized access or disclosure.
</p> </p>
<p className="mb-4"> <p className="mb-4">
b) Only authorized personnel bound by confidentiality obligations may access your data. b) Only authorized personnel bound by confidentiality obligations may access your
data.
</p> </p>
<p className="mb-4"> <p className="mb-4">
c) In case of a data breach, we will take remedial actions and notify you/regulators as required by law. c) In case of a data breach, we will take remedial actions and notify
you/regulators as required by law.
</p> </p>
</div> </div>
</div> </div>
{/* 6. Sharing, Transfer, and Disclosure */} {/* 6. Sharing, Transfer, and Disclosure */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>6. Sharing, Transfer, and Disclosure</p> <p>6. Sharing, Transfer, and Disclosure</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
a) We do not share your data with third parties without your explicit consent, except where mandated by law or to protect public/personal interests. a) We do not share your data with third parties without your explicit consent,
except where mandated by law or to protect public/personal interests.
</p> </p>
<p className="mb-4"> <p className="mb-4">
b) We do not transfer your data unless: (i) you consent; or (ii) during corporate restructuring (mergers, acquisitions), where recipients must adhere to this Policy. b) We do not transfer your data unless: (i) you consent; or (ii) during corporate
restructuring (mergers, acquisitions), where recipients must adhere to this
Policy.
</p> </p>
<p className="mb-4"> <p className="mb-4">
c) Public disclosure occurs only when legally required or requested by authorities, with efforts to minimize exposure. c) Public disclosure occurs only when legally required or requested by
authorities, with efforts to minimize exposure.
</p> </p>
</div> </div>
</div> </div>
{/* 7. Your Rights */} {/* 7. Your Rights */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>7. Your Rights</p> <p>7. Your Rights</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
a) <strong>Access & Correction:</strong> Request to view or correct inaccurate/incomplete data. a) <strong>Access & Correction:</strong> Request to view or correct
inaccurate/incomplete data.
</p> </p>
<p className="mb-4"> <p className="mb-4">
b) <strong>Deletion:</strong> Request deletion where legally permissible or upon service termination. b) <strong>Deletion:</strong> Request deletion where legally permissible or upon
service termination.
</p> </p>
<p className="mb-4"> <p className="mb-4">
c) <strong>Consent Withdrawal:</strong> Withdraw consent for data processing (note: may affect service functionality). c) <strong>Consent Withdrawal:</strong> Withdraw consent for data processing
(note: may affect service functionality).
</p> </p>
<p className="mb-4"> <p className="mb-4">
d) <strong>Account Deactivation:</strong> Deactivate your account; data will be processed per relevant policies. d) <strong>Account Deactivation:</strong> Deactivate your account; data will be
processed per relevant policies.
</p> </p>
</div> </div>
</div> </div>
{/* 8. Minor Protection */} {/* 8. Minor Protection */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>8. Minor Protection</p> <p>8. Minor Protection</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
Users under 18 must obtain parental/guardian consent before using our services. We prioritize minors' data protection. Parents/guardians may contact us to review or delete minors' data improperly collected. We strictly comply with minor protection laws in your jurisdiction. Users under 18 must obtain parental/guardian consent before using our services. We
prioritize minors' data protection. Parents/guardians may contact us to review or
delete minors' data improperly collected. We strictly comply with minor protection
laws in your jurisdiction.
</p> </p>
</div> </div>
</div> </div>
{/* 9. Policy Updates */} {/* 9. Policy Updates */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>9. Policy Updates</p> <p>9. Policy Updates</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
We may revise this Policy due to legal changes or operational needs. Revised versions will be published on this App/Website and take effect after the notice period. Continued use constitutes acceptance of changes. Revisions will comply with local legal requirements. We may revise this Policy due to legal changes or operational needs. Revised
versions will be published on this App/Website and take effect after the notice
period. Continued use constitutes acceptance of changes. Revisions will comply
with local legal requirements.
</p> </p>
</div> </div>
</div> </div>
{/* 10. Contact Us */} {/* 10. Contact Us */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>10. Contact Us</p> <p>10. Contact Us</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
For questions or to exercise your rights, contact us via provided channels. We will respond within a reasonable timeframe. For questions or to exercise your rights, contact us via provided channels. We
will respond within a reasonable timeframe.
</p> </p>
<p className="mb-4"> <p className="mb-4">
Thank you for trusting Crushlevel. We strive to protect your information security! Thank you for trusting Crushlevel. We strive to protect your information security!
@ -228,5 +273,5 @@ export default function PrivacyPolicyPage() {
</div> </div>
</div> </div>
</div> </div>
); )
} }

View File

@ -1,5 +1,4 @@
 Crushlevel Privacy Policy
Crushlevel Privacy Policy
Welcome to the Crushlevel application ("this App") and related website (Crushlevel.ai, "this Website"). We recognize the importance of your personal information and are committed to protecting your privacy rights and information security. This Privacy Policy ("Policy") explains the principles and practices we follow when collecting, using, storing, and safeguarding your personal information. Please read this Policy carefully before using our services. By registering or using our services, you acknowledge full acceptance of this Policy. For any inquiries, contact us through the provided channels. Welcome to the Crushlevel application ("this App") and related website (Crushlevel.ai, "this Website"). We recognize the importance of your personal information and are committed to protecting your privacy rights and information security. This Privacy Policy ("Policy") explains the principles and practices we follow when collecting, using, storing, and safeguarding your personal information. Please read this Policy carefully before using our services. By registering or using our services, you acknowledge full acceptance of this Policy. For any inquiries, contact us through the provided channels.
@ -91,5 +90,3 @@ Thank you for trusting Crushlevel. We strive to protect your information securit
\ \
\ \

View File

@ -1,13 +1,19 @@
# **Crushlevel Recharge Service Agreement** # **Crushlevel Recharge Service Agreement**
October 2025 October 2025
## **Preamble** ## **Preamble**
Welcome to use the recharge-related services of "Crushlevel"! Welcome to use the recharge-related services of "Crushlevel"!
This Recharge Service Agreement (hereinafter referred to as "this Agreement") is entered into between you and the operator of Crushlevel (hereinafter referred to as the "Platform") and/or its affiliates (hereinafter referred to as the "Company"). The Platform shall provide services to you in accordance with the provisions of this Agreement and the operating rules issued from time to time (hereinafter referred to as the "Services"). For the purpose of providing better services to users, you, as the service user (i.e., the account user who places an order to purchase the Platform's virtual currency, hereinafter referred to as "you"), shall carefully read and fully understand this Agreement before starting to use the Services. Among them, clauses that exempt or limit the Platform's liability, dispute resolution methods, jurisdiction and other important contents will be highlighted in **bold** to draw your attention, and you shall focus on reading these parts. If you do not agree to this Agreement, please do not take any further actions (including but not limited to clicking the operation buttons such as purchasing virtual currency, making payments) or use the Services. This Recharge Service Agreement (hereinafter referred to as "this Agreement") is entered into between you and the operator of Crushlevel (hereinafter referred to as the "Platform") and/or its affiliates (hereinafter referred to as the "Company"). The Platform shall provide services to you in accordance with the provisions of this Agreement and the operating rules issued from time to time (hereinafter referred to as the "Services"). For the purpose of providing better services to users, you, as the service user (i.e., the account user who places an order to purchase the Platform's virtual currency, hereinafter referred to as "you"), shall carefully read and fully understand this Agreement before starting to use the Services. Among them, clauses that exempt or limit the Platform's liability, dispute resolution methods, jurisdiction and other important contents will be highlighted in **bold** to draw your attention, and you shall focus on reading these parts. If you do not agree to this Agreement, please do not take any further actions (including but not limited to clicking the operation buttons such as purchasing virtual currency, making payments) or use the Services.
Minors are prohibited from using the recharge services. The Platform hereby kindly reminds that if you are the guardian of a minor, you shall assume guardianship responsibilities for the minor under your guardianship. When the minor uses the relevant products and services of this Platform, you shall enable the youth mode and/or other minor protection tools, supervise and guide the minor to use the relevant products and services correctly, and at the same time strengthen the restriction and management of online payment methods to jointly create a sound environment for the healthy growth of minors. This Agreement also complies with the provisions on the protection of minors in the U.S. Children's Online Privacy Protection Act (COPPA) and the European General Data Protection Regulation (GDPR) to ensure that the rights and interests of minors are not infringed. Minors are prohibited from using the recharge services. The Platform hereby kindly reminds that if you are the guardian of a minor, you shall assume guardianship responsibilities for the minor under your guardianship. When the minor uses the relevant products and services of this Platform, you shall enable the youth mode and/or other minor protection tools, supervise and guide the minor to use the relevant products and services correctly, and at the same time strengthen the restriction and management of online payment methods to jointly create a sound environment for the healthy growth of minors. This Agreement also complies with the provisions on the protection of minors in the U.S. Children's Online Privacy Protection Act (COPPA) and the European General Data Protection Regulation (GDPR) to ensure that the rights and interests of minors are not infringed.
## **I. Service Content** ## **I. Service Content**
### **1.1 Definition and Purpose of Virtual Currency** ### **1.1 Definition and Purpose of Virtual Currency**
The virtual currency provided by the Platform to you (hereinafter referred to as "Virtual Currency") is a virtual tool limited to relevant consumption within the Crushlevel Platform. It is not a token, legal tender or advance payment certificate, and does not have the circulation and advance payment value of legal tender. After purchasing the Virtual Currency, you may, in accordance with the instructions and guidelines on the relevant pages of the Platform, use it for the following consumption scenarios, including but not limited to: The virtual currency provided by the Platform to you (hereinafter referred to as "Virtual Currency") is a virtual tool limited to relevant consumption within the Crushlevel Platform. It is not a token, legal tender or advance payment certificate, and does not have the circulation and advance payment value of legal tender. After purchasing the Virtual Currency, you may, in accordance with the instructions and guidelines on the relevant pages of the Platform, use it for the following consumption scenarios, including but not limited to:
- Paid chat with AI virtual characters; - Paid chat with AI virtual characters;
@ -16,60 +22,110 @@ The virtual currency provided by the Platform to you (hereinafter referred to as
- Recharging for Platform membership to enjoy exclusive membership benefits; - Recharging for Platform membership to enjoy exclusive membership benefits;
- Sending virtual gifts to AI virtual characters; - Sending virtual gifts to AI virtual characters;
- Unlocking more different types of virtual lovers (AI virtual characters). - Unlocking more different types of virtual lovers (AI virtual characters).
### **1.2 Restrictions on the Use of Virtual Currency** ### **1.2 Restrictions on the Use of Virtual Currency**
After purchasing the Virtual Currency, you may only use it for the consumption scenarios stipulated in Clause 1.1 of this Agreement. You shall not use it beyond the scope of products/services provided by the Company, nor transfer, trade, sell or gift it between different Crushlevel accounts. After purchasing the Virtual Currency, you may only use it for the consumption scenarios stipulated in Clause 1.1 of this Agreement. You shall not use it beyond the scope of products/services provided by the Company, nor transfer, trade, sell or gift it between different Crushlevel accounts.
### **1.3 Official Purchase Channels** ### **1.3 Official Purchase Channels**
You shall purchase the Virtual Currency through the official channels designated by the Platform, including but not limited to the Platform's official website, official mobile application (APP) and third-party payment cooperation channels authorized by the Platform. The Platform does not recognize any third-party channels not authorized by the Company (such as unofficial purchasing agents, private transactions, etc.). If you purchase the Virtual Currency through unauthorized channels, the Platform cannot guarantee that such Virtual Currency can be successfully credited to your account. Moreover, such acts may be accompanied by risks such as fraud, money laundering and account theft, causing irreparable losses or damages to you, the Platform and relevant third parties. Therefore, purchasing through unauthorized channels shall be deemed as a violation. The Platform has the right to deduct or clear the Virtual Currency in your account, restrict all or part of the functions of your account, or temporarily or permanently ban your account. You shall bear all losses caused thereby; if your violation of the aforementioned provisions causes losses to the Platform or other third parties, you shall be liable for full compensation. You shall purchase the Virtual Currency through the official channels designated by the Platform, including but not limited to the Platform's official website, official mobile application (APP) and third-party payment cooperation channels authorized by the Platform. The Platform does not recognize any third-party channels not authorized by the Company (such as unofficial purchasing agents, private transactions, etc.). If you purchase the Virtual Currency through unauthorized channels, the Platform cannot guarantee that such Virtual Currency can be successfully credited to your account. Moreover, such acts may be accompanied by risks such as fraud, money laundering and account theft, causing irreparable losses or damages to you, the Platform and relevant third parties. Therefore, purchasing through unauthorized channels shall be deemed as a violation. The Platform has the right to deduct or clear the Virtual Currency in your account, restrict all or part of the functions of your account, or temporarily or permanently ban your account. You shall bear all losses caused thereby; if your violation of the aforementioned provisions causes losses to the Platform or other third parties, you shall be liable for full compensation.
### **1.4 Fee Collection and Channel Differences** ### **1.4 Fee Collection and Channel Differences**
The fees for your purchase of the Virtual Currency shall be collected by the Company or a cooperating party designated by the Company. The Platform specially reminds you that relevant service providers of different purchase channels (such as third-party payment institutions, app stores, etc.) may charge channel service fees when you make payments in accordance with their own operating strategies. This may result in differences in the amount of fees required to purchase the same amount of Virtual Currency through different channels, or differences in the amount of Virtual Currency that can be purchased with the same amount of fees. The specific details shall be subject to the page display when you purchase the Virtual Currency. Please carefully confirm the relevant page information (including but not limited to price, quantity, service fee description, etc.) and choose the Virtual Currency purchase channel reasonably. The fees for your purchase of the Virtual Currency shall be collected by the Company or a cooperating party designated by the Company. The Platform specially reminds you that relevant service providers of different purchase channels (such as third-party payment institutions, app stores, etc.) may charge channel service fees when you make payments in accordance with their own operating strategies. This may result in differences in the amount of fees required to purchase the same amount of Virtual Currency through different channels, or differences in the amount of Virtual Currency that can be purchased with the same amount of fees. The specific details shall be subject to the page display when you purchase the Virtual Currency. Please carefully confirm the relevant page information (including but not limited to price, quantity, service fee description, etc.) and choose the Virtual Currency purchase channel reasonably.
### **1.5 Provisions on Proxy Recharge Services** ### **1.5 Provisions on Proxy Recharge Services**
The Platform does not provide any proxy recharge services. If you intend to purchase the Virtual Currency for another person's account, you shall confirm the identity and will of the account user by yourself. Any disputes arising from proxy recharge (including but not limited to the account user denying receipt of the Virtual Currency, requesting a refund, etc.) shall be resolved through negotiation between you and the account user. The Platform shall not bear any liability to you or the account user in this regard. The Platform does not provide any proxy recharge services. If you intend to purchase the Virtual Currency for another person's account, you shall confirm the identity and will of the account user by yourself. Any disputes arising from proxy recharge (including but not limited to the account user denying receipt of the Virtual Currency, requesting a refund, etc.) shall be resolved through negotiation between you and the account user. The Platform shall not bear any liability to you or the account user in this regard.
## **II. Rational Consumption** ## **II. Rational Consumption**
### **2.1 Advocacy of Rational Consumption** ### **2.1 Advocacy of Rational Consumption**
The Platform advocates rational consumption and spending within one's means. You must purchase and use the Virtual Currency and relevant services reasonably according to your own consumption capacity and actual needs to avoid excessive consumption. When the amount of Virtual Currency you purchase is relatively large or the purchase frequency is abnormal, the Platform has the right to remind you of rational consumption through pop-up prompts, SMS notifications, etc. You shall attach importance to such reminders and make prudent decisions. The Platform advocates rational consumption and spending within one's means. You must purchase and use the Virtual Currency and relevant services reasonably according to your own consumption capacity and actual needs to avoid excessive consumption. When the amount of Virtual Currency you purchase is relatively large or the purchase frequency is abnormal, the Platform has the right to remind you of rational consumption through pop-up prompts, SMS notifications, etc. You shall attach importance to such reminders and make prudent decisions.
### **2.2 Requirements for the Legitimacy of Funds** ### **2.2 Requirements for the Legitimacy of Funds**
The funds you use to purchase the Virtual Currency shall be legally obtained and you shall have the right to use such funds (in compliance with relevant laws, regulations and tax provisions); if you violate the provisions of this Clause, any disputes or controversies arising therefrom (including but not limited to account freezing, tax penalties due to illegal source of funds, etc.) shall be resolved by yourself and you shall bear all legal consequences. If your acts cause losses to the Platform or third parties, you shall also make full compensation. If the Platform discovers (including but not limited to active discovery, receipt of third-party complaints, notifications from regulatory authorities or judicial organs, etc.) that you are suspected of violating the aforementioned provisions, the Platform has the right to deduct or clear the Virtual Currency in your account, restrict all or part of the functions of your account, or even permanently ban your account; at the same time, the Platform has the right to keep relevant information and report to relevant regulatory authorities and judicial organs. The funds you use to purchase the Virtual Currency shall be legally obtained and you shall have the right to use such funds (in compliance with relevant laws, regulations and tax provisions); if you violate the provisions of this Clause, any disputes or controversies arising therefrom (including but not limited to account freezing, tax penalties due to illegal source of funds, etc.) shall be resolved by yourself and you shall bear all legal consequences. If your acts cause losses to the Platform or third parties, you shall also make full compensation. If the Platform discovers (including but not limited to active discovery, receipt of third-party complaints, notifications from regulatory authorities or judicial organs, etc.) that you are suspected of violating the aforementioned provisions, the Platform has the right to deduct or clear the Virtual Currency in your account, restrict all or part of the functions of your account, or even permanently ban your account; at the same time, the Platform has the right to keep relevant information and report to relevant regulatory authorities and judicial organs.
### **2.3 Resistance to Irregular Consumption Behaviors** ### **2.3 Resistance to Irregular Consumption Behaviors**
The Platform strictly resists behaviors that induce, stimulate or incite users to consume irrationally (including but not limited to excessive recharge, frequent purchase of virtual gifts, etc.) and behaviors that induce or instigate minors to recharge with false identity information. If you discover the aforementioned irregular behaviors, you may report to the Platform through the publicized channels of the Platform (such as the official customer service email, the report entrance in the APP, etc.). The Platform will take disciplinary measures in accordance with laws and regulations (including but not limited to warning the irregular account, restricting the account functions, banning the account, etc.). We look forward to working with you to build a healthy and orderly Platform ecosystem. The Platform strictly resists behaviors that induce, stimulate or incite users to consume irrationally (including but not limited to excessive recharge, frequent purchase of virtual gifts, etc.) and behaviors that induce or instigate minors to recharge with false identity information. If you discover the aforementioned irregular behaviors, you may report to the Platform through the publicized channels of the Platform (such as the official customer service email, the report entrance in the APP, etc.). The Platform will take disciplinary measures in accordance with laws and regulations (including but not limited to warning the irregular account, restricting the account functions, banning the account, etc.). We look forward to working with you to build a healthy and orderly Platform ecosystem.
## **III. Your Rights and Obligations** ## **III. Your Rights and Obligations**
### **3.1 Obligation of Authenticity of Information and Cooperation in Investigations** ### **3.1 Obligation of Authenticity of Information and Cooperation in Investigations**
The personal information or materials you provide in the process of using the Services (including but not limited to name, email, payment account information, etc.) shall be true, accurate and complete, and shall comply with the requirements of relevant laws and regulations on personal information protection, such as the U.S. Fair Credit Reporting Act (FCRA) and the European General Data Protection Regulation (GDPR). If laws, regulations or regulatory authorities require you to cooperate in investigations, you shall provide relevant materials and assist in the investigations in accordance with the Platform's requirements. The personal information or materials you provide in the process of using the Services (including but not limited to name, email, payment account information, etc.) shall be true, accurate and complete, and shall comply with the requirements of relevant laws and regulations on personal information protection, such as the U.S. Fair Credit Reporting Act (FCRA) and the European General Data Protection Regulation (GDPR). If laws, regulations or regulatory authorities require you to cooperate in investigations, you shall provide relevant materials and assist in the investigations in accordance with the Platform's requirements.
### **3.2 Responsibility for Purchase Operations** ### **3.2 Responsibility for Purchase Operations**
When purchasing the Virtual Currency, you shall carefully select and/or enter key information such as your account information (e.g., account ID, bound email/mobile phone number) and the quantity of Virtual Currency to be purchased. If due to factors such as your own input errors, improper operations, insufficient understanding of the charging method or failure to confirm the purchase information, there are purchase errors such as wrong account, wrong quantity of Virtual Currency, repeated purchases, etc., resulting in your losses or additional expenses, the Platform has the right not to make compensation or indemnification. When purchasing the Virtual Currency, you shall carefully select and/or enter key information such as your account information (e.g., account ID, bound email/mobile phone number) and the quantity of Virtual Currency to be purchased. If due to factors such as your own input errors, improper operations, insufficient understanding of the charging method or failure to confirm the purchase information, there are purchase errors such as wrong account, wrong quantity of Virtual Currency, repeated purchases, etc., resulting in your losses or additional expenses, the Platform has the right not to make compensation or indemnification.
### **3.3 Responsibility for Account Safekeeping** ### **3.3 Responsibility for Account Safekeeping**
You shall properly keep your Crushlevel account (including account ID, password, bound email/mobile phone number and verification code, etc.) and be responsible for all operation behaviors and consequences under this account. If the Platform is unable to provide the Services or makes errors in providing the Services due to the following circumstances of yours, resulting in your losses, the Platform shall not bear legal liability unless otherwise explicitly required by laws and regulations: You shall properly keep your Crushlevel account (including account ID, password, bound email/mobile phone number and verification code, etc.) and be responsible for all operation behaviors and consequences under this account. If the Platform is unable to provide the Services or makes errors in providing the Services due to the following circumstances of yours, resulting in your losses, the Platform shall not bear legal liability unless otherwise explicitly required by laws and regulations:
- Your account becomes invalid, lost, stolen or banned; - Your account becomes invalid, lost, stolen or banned;
- The third-party payment institution account or bank account bound to your account is frozen, sealed up or has other abnormalities, or you use an uncertified account or an account that does not belong to you; - The third-party payment institution account or bank account bound to your account is frozen, sealed up or has other abnormalities, or you use an uncertified account or an account that does not belong to you;
- You disclose your account password to others or allow others to log in and use your account in other ways; - You disclose your account password to others or allow others to log in and use your account in other ways;
- Other circumstances where you have intent or gross negligence (such as failure to update account security settings in a timely manner, ignoring account abnormal login reminders, etc.). - Other circumstances where you have intent or gross negligence (such as failure to update account security settings in a timely manner, ignoring account abnormal login reminders, etc.).
### **3.4 Obligation of Compliant Use** ### **3.4 Obligation of Compliant Use**
You shall use the Services in a legal and compliant manner, and shall not use the Services for any purposes that are illegal or criminal, violate public order and good customs, harm social ethics (in line with the standards of public order and good customs in the United States and Europe), interfere with the normal operation of the Platform or infringe the legitimate rights and interests of third parties. Your use of the Services shall also not violate any documents or other requirements that are binding on you (if any). The Platform specially reminds you not to lend, transfer or provide your account to others for use in other ways, and to reasonably prevent others from committing acts that violate the aforementioned provisions through your account, so as to protect the security of your account and property. You shall use the Services in a legal and compliant manner, and shall not use the Services for any purposes that are illegal or criminal, violate public order and good customs, harm social ethics (in line with the standards of public order and good customs in the United States and Europe), interfere with the normal operation of the Platform or infringe the legitimate rights and interests of third parties. Your use of the Services shall also not violate any documents or other requirements that are binding on you (if any). The Platform specially reminds you not to lend, transfer or provide your account to others for use in other ways, and to reasonably prevent others from committing acts that violate the aforementioned provisions through your account, so as to protect the security of your account and property.
### **3.5 Specifications for Minor Refund Services** ### **3.5 Specifications for Minor Refund Services**
The Platform provides minor consumption refund services in accordance with laws and regulations to protect the legitimate rights and interests of minors and their guardians (in compliance with the provisions on the protection of minor consumption in the U.S. COPPA and the European GDPR); you shall not use this service for illegal purposes or in improper ways, including but not limited to adults pretending to be minors to defraud refunds, inducing minors to consume and then applying for refunds, etc. The aforementioned acts shall constitute a serious violation of this Agreement. After reasonable confirmation, the Platform has the right to refuse the refund and reserve the right to further pursue your legal liability in accordance with the law (including but not limited to reporting to regulatory authorities, filing a lawsuit, etc.). The Platform provides minor consumption refund services in accordance with laws and regulations to protect the legitimate rights and interests of minors and their guardians (in compliance with the provisions on the protection of minor consumption in the U.S. COPPA and the European GDPR); you shall not use this service for illegal purposes or in improper ways, including but not limited to adults pretending to be minors to defraud refunds, inducing minors to consume and then applying for refunds, etc. The aforementioned acts shall constitute a serious violation of this Agreement. After reasonable confirmation, the Platform has the right to refuse the refund and reserve the right to further pursue your legal liability in accordance with the law (including but not limited to reporting to regulatory authorities, filing a lawsuit, etc.).
### **3.6 Provisions on Third-Party Services** ### **3.6 Provisions on Third-Party Services**
If the use of the Services involves relevant services provided by third parties (such as payment services, third-party login services, etc.), in addition to complying with the provisions of this Agreement, you shall also agree to and comply with the service agreements and relevant rules of such third parties. Under no circumstances shall any disputes arising from such third parties and their provided relevant services (including but not limited to payment failures, account security issues, etc.) be resolved by you and the third party on your own. The Platform shall not bear any liability to you or the third party in this regard. If the use of the Services involves relevant services provided by third parties (such as payment services, third-party login services, etc.), in addition to complying with the provisions of this Agreement, you shall also agree to and comply with the service agreements and relevant rules of such third parties. Under no circumstances shall any disputes arising from such third parties and their provided relevant services (including but not limited to payment failures, account security issues, etc.) be resolved by you and the third party on your own. The Platform shall not bear any liability to you or the third party in this regard.
## **IV. Rights and Obligations of the Platform** ## **IV. Rights and Obligations of the Platform**
### **4.1 Right to Adjust Service Rules** ### **4.1 Right to Adjust Service Rules**
Based on factors such as revisions to laws and regulations, requirements of regulatory authorities in the United States and Europe, transaction security guarantees, updates to operating strategies, and changes in market environment, the Platform has the right to set relevant restrictions and reminders on the Virtual Currency services from time to time, including but not limited to restricting the transaction limit and/or transaction frequency of all or part of the users, prohibiting specific users from using the Services, and adding transaction verification steps (such as identity verification, SMS verification, etc.). The Platform will notify you of the aforementioned adjustments through reasonable methods such as APP pop-ups, official website announcements, and email notifications. If you do not agree to the adjustments, you may stop using the Services; if you continue to use the Services, it shall be deemed that you agree to such adjustments. Based on factors such as revisions to laws and regulations, requirements of regulatory authorities in the United States and Europe, transaction security guarantees, updates to operating strategies, and changes in market environment, the Platform has the right to set relevant restrictions and reminders on the Virtual Currency services from time to time, including but not limited to restricting the transaction limit and/or transaction frequency of all or part of the users, prohibiting specific users from using the Services, and adding transaction verification steps (such as identity verification, SMS verification, etc.). The Platform will notify you of the aforementioned adjustments through reasonable methods such as APP pop-ups, official website announcements, and email notifications. If you do not agree to the adjustments, you may stop using the Services; if you continue to use the Services, it shall be deemed that you agree to such adjustments.
### **4.2 Right to Risk Monitoring and Account Management** ### **4.2 Right to Risk Monitoring and Account Management**
To ensure transaction security and the stability of the Platform ecosystem, the Platform has the right to monitor your use of the Services (in compliance with relevant laws and regulations on data security and privacy protection in the United States and Europe). For users or accounts that are reasonably identified as high-risk (including but not limited to those suspected of money laundering, fraud, abnormal account login, large-scale purchase of Virtual Currency followed by rapid consumption, etc.), the Platform may take necessary measures to prevent the expansion of risks and protect the property of users and the ecological security of the Platform. Such necessary measures include deducting or clearing the Virtual Currency in your account, restricting all or part of the functions of your account, or temporarily or permanently banning your account. Before taking the aforementioned measures, the Platform will notify you through reasonable methods as much as possible, unless it is impossible to notify due to emergency situations (such as suspected illegal crimes requiring immediate handling). To ensure transaction security and the stability of the Platform ecosystem, the Platform has the right to monitor your use of the Services (in compliance with relevant laws and regulations on data security and privacy protection in the United States and Europe). For users or accounts that are reasonably identified as high-risk (including but not limited to those suspected of money laundering, fraud, abnormal account login, large-scale purchase of Virtual Currency followed by rapid consumption, etc.), the Platform may take necessary measures to prevent the expansion of risks and protect the property of users and the ecological security of the Platform. Such necessary measures include deducting or clearing the Virtual Currency in your account, restricting all or part of the functions of your account, or temporarily or permanently banning your account. Before taking the aforementioned measures, the Platform will notify you through reasonable methods as much as possible, unless it is impossible to notify due to emergency situations (such as suspected illegal crimes requiring immediate handling).
### **4.3 Right to Correct Errors** ### **4.3 Right to Correct Errors**
When the Platform discovers errors in the processing of Virtual Currency (including but not limited to errors in the quantity of Virtual Currency issued or deducted) caused by system failures, network problems, human operation errors or any other reasons, whether the error is beneficial to the Platform or you, the Platform has the right to correct the error. In this case, if the actual quantity of Virtual Currency you receive is less than the quantity you should receive, the Platform will make up the difference to your account as soon as possible after confirming the processing error; if the actual quantity of Virtual Currency you receive is more than the quantity you should receive, the Platform has the right to directly deduct the difference from your account without prior notice. If the Virtual Currency in your account is insufficient to offset the difference, the Platform has the right to require you to make up the difference. You shall fulfill this obligation within the reasonable time limit notified by the Platform; otherwise, the Platform has the right to take measures such as restricting account functions and banning the account. When the Platform discovers errors in the processing of Virtual Currency (including but not limited to errors in the quantity of Virtual Currency issued or deducted) caused by system failures, network problems, human operation errors or any other reasons, whether the error is beneficial to the Platform or you, the Platform has the right to correct the error. In this case, if the actual quantity of Virtual Currency you receive is less than the quantity you should receive, the Platform will make up the difference to your account as soon as possible after confirming the processing error; if the actual quantity of Virtual Currency you receive is more than the quantity you should receive, the Platform has the right to directly deduct the difference from your account without prior notice. If the Virtual Currency in your account is insufficient to offset the difference, the Platform has the right to require you to make up the difference. You shall fulfill this obligation within the reasonable time limit notified by the Platform; otherwise, the Platform has the right to take measures such as restricting account functions and banning the account.
### **4.4 Right to Change, Suspend or Terminate Services** ### **4.4 Right to Change, Suspend or Terminate Services**
The Platform has the right to change, interrupt, suspend or terminate the Services based on specific circumstances such as transaction security, operation plans, national laws and regulations or the requirements of regulatory authorities in the United States and Europe. If the Platform decides to change, interrupt, suspend or terminate the Services, it will notify you in advance through reasonable methods such as APP pop-ups, official website announcements, and email notifications (except for emergency situations such as force majeure and sudden system failures where advance notification is impossible), and handle the unused Virtual Currency balance in your account (excluding the membership recharge amount; for the refund rules of membership recharge amount, please refer to Chapter V of this Agreement) in accordance with the provisions of this Agreement. The Platform shall not bear any tort liability to you due to the change, interruption, suspension or termination of the Services for the aforementioned reasons, unless otherwise stipulated by laws and regulations. The Platform has the right to change, interrupt, suspend or terminate the Services based on specific circumstances such as transaction security, operation plans, national laws and regulations or the requirements of regulatory authorities in the United States and Europe. If the Platform decides to change, interrupt, suspend or terminate the Services, it will notify you in advance through reasonable methods such as APP pop-ups, official website announcements, and email notifications (except for emergency situations such as force majeure and sudden system failures where advance notification is impossible), and handle the unused Virtual Currency balance in your account (excluding the membership recharge amount; for the refund rules of membership recharge amount, please refer to Chapter V of this Agreement) in accordance with the provisions of this Agreement. The Platform shall not bear any tort liability to you due to the change, interruption, suspension or termination of the Services for the aforementioned reasons, unless otherwise stipulated by laws and regulations.
## **V. Refund Rules** ## **V. Refund Rules**
### **5.1 Restrictions on Refunds After Consumption of Virtual Currency** ### **5.1 Restrictions on Refunds After Consumption of Virtual Currency**
After you use the Virtual Currency for consumption (including but not limited to paid chat, unlocking pictures, purchasing Affection Points, sending virtual gifts, unlocking virtual lovers, etc.), since the Virtual Currency has been converted into the corresponding services or rights provided by the Platform, and the services related to AI virtual characters are instantaneous and irreversible, **the Platform does not provide refund services for this part of the Virtual Currency**. You shall carefully confirm your consumption needs before consumption. After you use the Virtual Currency for consumption (including but not limited to paid chat, unlocking pictures, purchasing Affection Points, sending virtual gifts, unlocking virtual lovers, etc.), since the Virtual Currency has been converted into the corresponding services or rights provided by the Platform, and the services related to AI virtual characters are instantaneous and irreversible, **the Platform does not provide refund services for this part of the Virtual Currency**. You shall carefully confirm your consumption needs before consumption.
## **VI. Disclaimer** ## **VI. Disclaimer**
### **6.1 Provision of Services in Current State and Risk Warning** ### **6.1 Provision of Services in Current State and Risk Warning**
You understand and agree that the Services are provided in accordance with the current state achievable under existing technologies and conditions. The Platform will make its best efforts to provide the Services to you and ensure the security and stability of the Services. However, you also know and acknowledge that the Platform cannot foresee and prevent technical and other risks at all times or at all times, including but not limited to service interruptions, delays, errors or data loss caused by force majeure (such as natural disasters, wars, public health emergencies, etc.), network reasons (such as network congestion, hacker attacks, server failures, etc.), third-party service defects (such as failures of third-party payment institutions, changes in app store policies, etc.), revisions to laws and regulations or adjustments to regulatory policies, etc. In the event of such circumstances, the Platform will make its best commercial efforts to improve the situation, but shall not be obligated to bear any legal liability to you or other third parties, unless such losses are caused by the intentional acts or gross negligence of the Platform. You understand and agree that the Services are provided in accordance with the current state achievable under existing technologies and conditions. The Platform will make its best efforts to provide the Services to you and ensure the security and stability of the Services. However, you also know and acknowledge that the Platform cannot foresee and prevent technical and other risks at all times or at all times, including but not limited to service interruptions, delays, errors or data loss caused by force majeure (such as natural disasters, wars, public health emergencies, etc.), network reasons (such as network congestion, hacker attacks, server failures, etc.), third-party service defects (such as failures of third-party payment institutions, changes in app store policies, etc.), revisions to laws and regulations or adjustments to regulatory policies, etc. In the event of such circumstances, the Platform will make its best commercial efforts to improve the situation, but shall not be obligated to bear any legal liability to you or other third parties, unless such losses are caused by the intentional acts or gross negligence of the Platform.
### **6.2 Disclaimer for System Maintenance and Upgrades** ### **6.2 Disclaimer for System Maintenance and Upgrades**
The Platform may conduct downtime maintenance, system upgrades and function adjustments on its own. If you are unable to use the Services normally due to this, the Platform will notify you of the maintenance/upgrade time and the scope of impact in advance through reasonable methods (except for emergency maintenance), and you agree that the Platform shall not bear legal liability for this. Any losses caused by your attempt to use the Services during the maintenance/upgrade period shall be borne by yourself. The Platform may conduct downtime maintenance, system upgrades and function adjustments on its own. If you are unable to use the Services normally due to this, the Platform will notify you of the maintenance/upgrade time and the scope of impact in advance through reasonable methods (except for emergency maintenance), and you agree that the Platform shall not bear legal liability for this. Any losses caused by your attempt to use the Services during the maintenance/upgrade period shall be borne by yourself.
### **6.3 Limitation of Liability** ### **6.3 Limitation of Liability**
Under no circumstances shall the Platform be liable for any indirect, punitive, incidental or special damages (including but not limited to loss of profits, loss of expected benefits, loss of data, etc.). Moreover, the total liability of the Platform to you, regardless of the cause or manner (including but not limited to breach of contract, tort, etc.), shall not exceed the total amount of fees you actually paid for using the recharge services. Under no circumstances shall the Platform be liable for any indirect, punitive, incidental or special damages (including but not limited to loss of profits, loss of expected benefits, loss of data, etc.). Moreover, the total liability of the Platform to you, regardless of the cause or manner (including but not limited to breach of contract, tort, etc.), shall not exceed the total amount of fees you actually paid for using the recharge services.
## **VII. Liability for Breach of Contract** ## **VII. Liability for Breach of Contract**
### **7.1 Handling of Your Breach of Contract** ### **7.1 Handling of Your Breach of Contract**
If you violate any provisions of this Agreement (including but not limited to purchasing Virtual Currency through unauthorized channels, using funds from If you violate any provisions of this Agreement (including but not limited to purchasing Virtual Currency through unauthorized channels, using funds from
(注:文档部分内容可能由 AI 生成) (注:文档部分内容可能由 AI 生成)

View File

@ -1,370 +1,625 @@
export default function RechargeAgreementPage() { export default function RechargeAgreementPage() {
return ( return (
<div className="flex flex-col items-center justify-start relative size-full min-h-screen"> <div className="relative flex size-full min-h-screen flex-col items-center justify-start">
<div className="flex gap-1 grow items-start justify-center max-w-[1232px] min-h-px min-w-px pb-0 pt-28 px-6 md:px-12 relative shrink-0 w-full"> <div className="relative flex min-h-px w-full max-w-[1232px] min-w-px shrink-0 grow items-start justify-center gap-1 px-6 pt-28 pb-0 md:px-12">
<div className="flex flex-col gap-12 grow items-center justify-start max-w-[752px] min-h-px min-w-px relative shrink-0 w-full"> <div className="relative flex min-h-px w-full max-w-[752px] min-w-px shrink-0 grow flex-col items-center justify-start gap-12">
{/* 主标题 */} {/* 主标题 */}
<div className="txt-headline-s text-center text-white w-full"> <div className="txt-headline-s w-full text-center text-white">
<p className="whitespace-pre-wrap"> <p className="whitespace-pre-wrap">Crushlevel Recharge Service Agreement</p>
Crushlevel Recharge Service Agreement
</p>
</div> </div>
{/* 日期 */} {/* 日期 */}
<div className="txt-body-l text-center text-white w-full"> <div className="txt-body-l w-full text-center text-white">
<p>October 2025</p> <p>October 2025</p>
</div> </div>
{/* 前言 */} {/* 前言 */}
<div className="txt-body-l text-white w-full"> <div className="txt-body-l w-full text-white">
<p className="mb-4">Welcome to use the recharge-related services of "Crushlevel"!</p>
<p className="mb-4"> <p className="mb-4">
Welcome to use the recharge-related services of "Crushlevel"! This Recharge Service Agreement (hereinafter referred to as "this Agreement") is
entered into between you and the operator of Crushlevel (hereinafter referred to as
the "Platform") and/or its affiliates (hereinafter referred to as the "Company"). The
Platform shall provide services to you in accordance with the provisions of this
Agreement and the operating rules issued from time to time (hereinafter referred to as
the "Services"). For the purpose of providing better services to users, you, as the
service user (i.e., the account user who places an order to purchase the Platform's
virtual currency, hereinafter referred to as "you"), shall carefully read and fully
understand this Agreement before starting to use the Services. Among them, clauses
that exempt or limit the Platform's liability, dispute resolution methods,
jurisdiction and other important contents will be highlighted in <strong>bold</strong>{' '}
to draw your attention, and you shall focus on reading these parts. If you do not
agree to this Agreement, please do not take any further actions (including but not
limited to clicking the operation buttons such as purchasing virtual currency, making
payments) or use the Services.
</p> </p>
<p className="mb-4"> <p className="mb-4">
This Recharge Service Agreement (hereinafter referred to as "this Agreement") is entered into between you and the operator of Crushlevel (hereinafter referred to as the "Platform") and/or its affiliates (hereinafter referred to as the "Company"). The Platform shall provide services to you in accordance with the provisions of this Agreement and the operating rules issued from time to time (hereinafter referred to as the "Services"). For the purpose of providing better services to users, you, as the service user (i.e., the account user who places an order to purchase the Platform's virtual currency, hereinafter referred to as "you"), shall carefully read and fully understand this Agreement before starting to use the Services. Among them, clauses that exempt or limit the Platform's liability, dispute resolution methods, jurisdiction and other important contents will be highlighted in <strong>bold</strong> to draw your attention, and you shall focus on reading these parts. If you do not agree to this Agreement, please do not take any further actions (including but not limited to clicking the operation buttons such as purchasing virtual currency, making payments) or use the Services. <strong>Minors are prohibited from using the recharge services.</strong> The Platform
</p> hereby kindly reminds that if you are the guardian of a minor, you shall assume
<p className="mb-4"> guardianship responsibilities for the minor under your guardianship. When the minor
<strong>Minors are prohibited from using the recharge services.</strong> The Platform hereby kindly reminds that if you are the guardian of a minor, you shall assume guardianship responsibilities for the minor under your guardianship. When the minor uses the relevant products and services of this Platform, you shall enable the youth mode and/or other minor protection tools, supervise and guide the minor to use the relevant products and services correctly, and at the same time strengthen the restriction and management of online payment methods to jointly create a sound environment for the healthy growth of minors. This Agreement also complies with the provisions on the protection of minors in the U.S. Children's Online Privacy Protection Act (COPPA) and the European General Data Protection Regulation (GDPR) to ensure that the rights and interests of minors are not infringed. uses the relevant products and services of this Platform, you shall enable the youth
mode and/or other minor protection tools, supervise and guide the minor to use the
relevant products and services correctly, and at the same time strengthen the
restriction and management of online payment methods to jointly create a sound
environment for the healthy growth of minors. This Agreement also complies with the
provisions on the protection of minors in the U.S. Children's Online Privacy
Protection Act (COPPA) and the European General Data Protection Regulation (GDPR) to
ensure that the rights and interests of minors are not infringed.
</p> </p>
</div> </div>
{/* 协议条款 */} {/* 协议条款 */}
<div className="flex flex-col gap-6 items-start justify-start text-white w-full"> <div className="flex w-full flex-col items-start justify-start gap-6 text-white">
{/* I. Service Content */} {/* I. Service Content */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>I. Service Content</p> <p>I. Service Content</p>
</div> </div>
{/* 1.1 Definition and Purpose of Virtual Currency */} {/* 1.1 Definition and Purpose of Virtual Currency */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>1.1 Definition and Purpose of Virtual Currency</p> <p>1.1 Definition and Purpose of Virtual Currency</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
The virtual currency provided by the Platform to you (hereinafter referred to as "Virtual Currency") is a virtual tool limited to relevant consumption within the Crushlevel Platform. It is not a token, legal tender or advance payment certificate, and does not have the circulation and advance payment value of legal tender. After purchasing the Virtual Currency, you may, in accordance with the instructions and guidelines on the relevant pages of the Platform, use it for the following consumption scenarios, including but not limited to: The virtual currency provided by the Platform to you (hereinafter referred to as
"Virtual Currency") is a virtual tool limited to relevant consumption within the
Crushlevel Platform. It is not a token, legal tender or advance payment
certificate, and does not have the circulation and advance payment value of
legal tender. After purchasing the Virtual Currency, you may, in accordance with
the instructions and guidelines on the relevant pages of the Platform, use it
for the following consumption scenarios, including but not limited to:
</p> </p>
<ul className="list-disc ml-6 mb-4"> <ul className="mb-4 ml-6 list-disc">
<li className="mb-2">Paid chat with AI virtual characters;</li> <li className="mb-2">Paid chat with AI virtual characters;</li>
<li className="mb-2">Unlocking pictures related to AI virtual characters;</li> <li className="mb-2">Unlocking pictures related to AI virtual characters;</li>
<li className="mb-2">Purchasing "Affection Points" to increase the interaction level with AI virtual characters;</li> <li className="mb-2">
<li className="mb-2">Recharging for Platform membership to enjoy exclusive membership benefits;</li> Purchasing "Affection Points" to increase the interaction level with AI
virtual characters;
</li>
<li className="mb-2">
Recharging for Platform membership to enjoy exclusive membership benefits;
</li>
<li className="mb-2">Sending virtual gifts to AI virtual characters;</li> <li className="mb-2">Sending virtual gifts to AI virtual characters;</li>
<li className="mb-2">Unlocking more different types of virtual lovers (AI virtual characters).</li> <li className="mb-2">
Unlocking more different types of virtual lovers (AI virtual characters).
</li>
</ul> </ul>
</div> </div>
</div> </div>
{/* 1.2 Restrictions on the Use of Virtual Currency */} {/* 1.2 Restrictions on the Use of Virtual Currency */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>1.2 Restrictions on the Use of Virtual Currency</p> <p>1.2 Restrictions on the Use of Virtual Currency</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
After purchasing the Virtual Currency, you may only use it for the consumption scenarios stipulated in Clause 1.1 of this Agreement. You shall not use it beyond the scope of products/services provided by the Company, nor transfer, trade, sell or gift it between different Crushlevel accounts. After purchasing the Virtual Currency, you may only use it for the consumption
scenarios stipulated in Clause 1.1 of this Agreement. You shall not use it
beyond the scope of products/services provided by the Company, nor transfer,
trade, sell or gift it between different Crushlevel accounts.
</p> </p>
</div> </div>
</div> </div>
{/* 1.3 Official Purchase Channels */} {/* 1.3 Official Purchase Channels */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>1.3 Official Purchase Channels</p> <p>1.3 Official Purchase Channels</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
You shall purchase the Virtual Currency through the official channels designated by the Platform, including but not limited to the Platform's official website, official mobile application (APP) and third-party payment cooperation channels authorized by the Platform. The Platform does not recognize any third-party channels not authorized by the Company (such as unofficial purchasing agents, private transactions, etc.). If you purchase the Virtual Currency through unauthorized channels, the Platform cannot guarantee that such Virtual Currency can be successfully credited to your account. Moreover, such acts may be accompanied by risks such as fraud, money laundering and account theft, causing irreparable losses or damages to you, the Platform and relevant third parties. Therefore, purchasing through unauthorized channels shall be deemed as a violation. The Platform has the right to deduct or clear the Virtual Currency in your account, restrict all or part of the functions of your account, or temporarily or permanently ban your account. You shall bear all losses caused thereby; if your violation of the aforementioned provisions causes losses to the Platform or other third parties, you shall be liable for full compensation. You shall purchase the Virtual Currency through the official channels designated
by the Platform, including but not limited to the Platform's official website,
official mobile application (APP) and third-party payment cooperation channels
authorized by the Platform. The Platform does not recognize any third-party
channels not authorized by the Company (such as unofficial purchasing agents,
private transactions, etc.). If you purchase the Virtual Currency through
unauthorized channels, the Platform cannot guarantee that such Virtual Currency
can be successfully credited to your account. Moreover, such acts may be
accompanied by risks such as fraud, money laundering and account theft, causing
irreparable losses or damages to you, the Platform and relevant third parties.
Therefore, purchasing through unauthorized channels shall be deemed as a
violation. The Platform has the right to deduct or clear the Virtual Currency in
your account, restrict all or part of the functions of your account, or
temporarily or permanently ban your account. You shall bear all losses caused
thereby; if your violation of the aforementioned provisions causes losses to the
Platform or other third parties, you shall be liable for full compensation.
</p> </p>
</div> </div>
</div> </div>
{/* 1.4 Fee Collection and Channel Differences */} {/* 1.4 Fee Collection and Channel Differences */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>1.4 Fee Collection and Channel Differences</p> <p>1.4 Fee Collection and Channel Differences</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
The fees for your purchase of the Virtual Currency shall be collected by the Company or a cooperating party designated by the Company. The Platform specially reminds you that relevant service providers of different purchase channels (such as third-party payment institutions, app stores, etc.) may charge channel service fees when you make payments in accordance with their own operating strategies. This may result in differences in the amount of fees required to purchase the same amount of Virtual Currency through different channels, or differences in the amount of Virtual Currency that can be purchased with the same amount of fees. The specific details shall be subject to the page display when you purchase the Virtual Currency. Please carefully confirm the relevant page information (including but not limited to price, quantity, service fee description, etc.) and choose the Virtual Currency purchase channel reasonably. The fees for your purchase of the Virtual Currency shall be collected by the
Company or a cooperating party designated by the Company. The Platform specially
reminds you that relevant service providers of different purchase channels (such
as third-party payment institutions, app stores, etc.) may charge channel
service fees when you make payments in accordance with their own operating
strategies. This may result in differences in the amount of fees required to
purchase the same amount of Virtual Currency through different channels, or
differences in the amount of Virtual Currency that can be purchased with the
same amount of fees. The specific details shall be subject to the page display
when you purchase the Virtual Currency. Please carefully confirm the relevant
page information (including but not limited to price, quantity, service fee
description, etc.) and choose the Virtual Currency purchase channel reasonably.
</p> </p>
</div> </div>
</div> </div>
{/* 1.5 Provisions on Proxy Recharge Services */} {/* 1.5 Provisions on Proxy Recharge Services */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>1.5 Provisions on Proxy Recharge Services</p> <p>1.5 Provisions on Proxy Recharge Services</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
The Platform does not provide any proxy recharge services. If you intend to purchase the Virtual Currency for another person's account, you shall confirm the identity and will of the account user by yourself. Any disputes arising from proxy recharge (including but not limited to the account user denying receipt of the Virtual Currency, requesting a refund, etc.) shall be resolved through negotiation between you and the account user. The Platform shall not bear any liability to you or the account user in this regard. The Platform does not provide any proxy recharge services. If you intend to
purchase the Virtual Currency for another person's account, you shall confirm
the identity and will of the account user by yourself. Any disputes arising from
proxy recharge (including but not limited to the account user denying receipt of
the Virtual Currency, requesting a refund, etc.) shall be resolved through
negotiation between you and the account user. The Platform shall not bear any
liability to you or the account user in this regard.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* II. Rational Consumption */} {/* II. Rational Consumption */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>II. Rational Consumption</p> <p>II. Rational Consumption</p>
</div> </div>
{/* 2.1 Advocacy of Rational Consumption */} {/* 2.1 Advocacy of Rational Consumption */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>2.1 Advocacy of Rational Consumption</p> <p>2.1 Advocacy of Rational Consumption</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
The Platform advocates rational consumption and spending within one's means. You must purchase and use the Virtual Currency and relevant services reasonably according to your own consumption capacity and actual needs to avoid excessive consumption. When the amount of Virtual Currency you purchase is relatively large or the purchase frequency is abnormal, the Platform has the right to remind you of rational consumption through pop-up prompts, SMS notifications, etc. You shall attach importance to such reminders and make prudent decisions. The Platform advocates rational consumption and spending within one's means. You
must purchase and use the Virtual Currency and relevant services reasonably
according to your own consumption capacity and actual needs to avoid excessive
consumption. When the amount of Virtual Currency you purchase is relatively
large or the purchase frequency is abnormal, the Platform has the right to
remind you of rational consumption through pop-up prompts, SMS notifications,
etc. You shall attach importance to such reminders and make prudent decisions.
</p> </p>
</div> </div>
</div> </div>
{/* 2.2 Requirements for the Legitimacy of Funds */} {/* 2.2 Requirements for the Legitimacy of Funds */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>2.2 Requirements for the Legitimacy of Funds</p> <p>2.2 Requirements for the Legitimacy of Funds</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
The funds you use to purchase the Virtual Currency shall be legally obtained and you shall have the right to use such funds (in compliance with relevant laws, regulations and tax provisions); if you violate the provisions of this Clause, any disputes or controversies arising therefrom (including but not limited to account freezing, tax penalties due to illegal source of funds, etc.) shall be resolved by yourself and you shall bear all legal consequences. If your acts cause losses to the Platform or third parties, you shall also make full compensation. If the Platform discovers (including but not limited to active discovery, receipt of third-party complaints, notifications from regulatory authorities or judicial organs, etc.) that you are suspected of violating the aforementioned provisions, the Platform has the right to deduct or clear the Virtual Currency in your account, restrict all or part of the functions of your account, or even permanently ban your account; at the same time, the Platform has the right to keep relevant information and report to relevant regulatory authorities and judicial organs. The funds you use to purchase the Virtual Currency shall be legally obtained and
you shall have the right to use such funds (in compliance with relevant laws,
regulations and tax provisions); if you violate the provisions of this Clause,
any disputes or controversies arising therefrom (including but not limited to
account freezing, tax penalties due to illegal source of funds, etc.) shall be
resolved by yourself and you shall bear all legal consequences. If your acts
cause losses to the Platform or third parties, you shall also make full
compensation. If the Platform discovers (including but not limited to active
discovery, receipt of third-party complaints, notifications from regulatory
authorities or judicial organs, etc.) that you are suspected of violating the
aforementioned provisions, the Platform has the right to deduct or clear the
Virtual Currency in your account, restrict all or part of the functions of your
account, or even permanently ban your account; at the same time, the Platform
has the right to keep relevant information and report to relevant regulatory
authorities and judicial organs.
</p> </p>
</div> </div>
</div> </div>
{/* 2.3 Resistance to Irregular Consumption Behaviors */} {/* 2.3 Resistance to Irregular Consumption Behaviors */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>2.3 Resistance to Irregular Consumption Behaviors</p> <p>2.3 Resistance to Irregular Consumption Behaviors</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
The Platform strictly resists behaviors that induce, stimulate or incite users to consume irrationally (including but not limited to excessive recharge, frequent purchase of virtual gifts, etc.) and behaviors that induce or instigate minors to recharge with false identity information. If you discover the aforementioned irregular behaviors, you may report to the Platform through the publicized channels of the Platform (such as the official customer service email, the report entrance in the APP, etc.). The Platform will take disciplinary measures in accordance with laws and regulations (including but not limited to warning the irregular account, restricting the account functions, banning the account, etc.). We look forward to working with you to build a healthy and orderly Platform ecosystem. The Platform strictly resists behaviors that induce, stimulate or incite users
to consume irrationally (including but not limited to excessive recharge,
frequent purchase of virtual gifts, etc.) and behaviors that induce or instigate
minors to recharge with false identity information. If you discover the
aforementioned irregular behaviors, you may report to the Platform through the
publicized channels of the Platform (such as the official customer service
email, the report entrance in the APP, etc.). The Platform will take
disciplinary measures in accordance with laws and regulations (including but not
limited to warning the irregular account, restricting the account functions,
banning the account, etc.). We look forward to working with you to build a
healthy and orderly Platform ecosystem.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* III. Your Rights and Obligations */} {/* III. Your Rights and Obligations */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>III. Your Rights and Obligations</p> <p>III. Your Rights and Obligations</p>
</div> </div>
{/* 3.1 Obligation of Authenticity of Information and Cooperation in Investigations */} {/* 3.1 Obligation of Authenticity of Information and Cooperation in Investigations */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>3.1 Obligation of Authenticity of Information and Cooperation in Investigations</p> <p>
3.1 Obligation of Authenticity of Information and Cooperation in Investigations
</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
The personal information or materials you provide in the process of using the Services (including but not limited to name, email, payment account information, etc.) shall be true, accurate and complete, and shall comply with the requirements of relevant laws and regulations on personal information protection, such as the U.S. Fair Credit Reporting Act (FCRA) and the European General Data Protection Regulation (GDPR). If laws, regulations or regulatory authorities require you to cooperate in investigations, you shall provide relevant materials and assist in the investigations in accordance with the Platform's requirements. The personal information or materials you provide in the process of using the
Services (including but not limited to name, email, payment account information,
etc.) shall be true, accurate and complete, and shall comply with the
requirements of relevant laws and regulations on personal information
protection, such as the U.S. Fair Credit Reporting Act (FCRA) and the European
General Data Protection Regulation (GDPR). If laws, regulations or regulatory
authorities require you to cooperate in investigations, you shall provide
relevant materials and assist in the investigations in accordance with the
Platform's requirements.
</p> </p>
</div> </div>
</div> </div>
{/* 3.2 Responsibility for Purchase Operations */} {/* 3.2 Responsibility for Purchase Operations */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>3.2 Responsibility for Purchase Operations</p> <p>3.2 Responsibility for Purchase Operations</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
When purchasing the Virtual Currency, you shall carefully select and/or enter key information such as your account information (e.g., account ID, bound email/mobile phone number) and the quantity of Virtual Currency to be purchased. If due to factors such as your own input errors, improper operations, insufficient understanding of the charging method or failure to confirm the purchase information, there are purchase errors such as wrong account, wrong quantity of Virtual Currency, repeated purchases, etc., resulting in your losses or additional expenses, the Platform has the right not to make compensation or indemnification. When purchasing the Virtual Currency, you shall carefully select and/or enter
key information such as your account information (e.g., account ID, bound
email/mobile phone number) and the quantity of Virtual Currency to be purchased.
If due to factors such as your own input errors, improper operations,
insufficient understanding of the charging method or failure to confirm the
purchase information, there are purchase errors such as wrong account, wrong
quantity of Virtual Currency, repeated purchases, etc., resulting in your losses
or additional expenses, the Platform has the right not to make compensation or
indemnification.
</p> </p>
</div> </div>
</div> </div>
{/* 3.3 Responsibility for Account Safekeeping */} {/* 3.3 Responsibility for Account Safekeeping */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>3.3 Responsibility for Account Safekeeping</p> <p>3.3 Responsibility for Account Safekeeping</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
You shall properly keep your Crushlevel account (including account ID, password, bound email/mobile phone number and verification code, etc.) and be responsible for all operation behaviors and consequences under this account. If the Platform is unable to provide the Services or makes errors in providing the Services due to the following circumstances of yours, resulting in your losses, the Platform shall not bear legal liability unless otherwise explicitly required by laws and regulations: You shall properly keep your Crushlevel account (including account ID, password,
bound email/mobile phone number and verification code, etc.) and be responsible
for all operation behaviors and consequences under this account. If the Platform
is unable to provide the Services or makes errors in providing the Services due
to the following circumstances of yours, resulting in your losses, the Platform
shall not bear legal liability unless otherwise explicitly required by laws and
regulations:
</p> </p>
<ul className="list-disc ml-6 mb-4"> <ul className="mb-4 ml-6 list-disc">
<li className="mb-2">Your account becomes invalid, lost, stolen or banned;</li> <li className="mb-2">Your account becomes invalid, lost, stolen or banned;</li>
<li className="mb-2">The third-party payment institution account or bank account bound to your account is frozen, sealed up or has other abnormalities, or you use an uncertified account or an account that does not belong to you;</li> <li className="mb-2">
<li className="mb-2">You disclose your account password to others or allow others to log in and use your account in other ways;</li> The third-party payment institution account or bank account bound to your
<li className="mb-2">Other circumstances where you have intent or gross negligence (such as failure to update account security settings in a timely manner, ignoring account abnormal login reminders, etc.).</li> account is frozen, sealed up or has other abnormalities, or you use an
uncertified account or an account that does not belong to you;
</li>
<li className="mb-2">
You disclose your account password to others or allow others to log in and use
your account in other ways;
</li>
<li className="mb-2">
Other circumstances where you have intent or gross negligence (such as failure
to update account security settings in a timely manner, ignoring account
abnormal login reminders, etc.).
</li>
</ul> </ul>
</div> </div>
</div> </div>
{/* 3.4 Obligation of Compliant Use */} {/* 3.4 Obligation of Compliant Use */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>3.4 Obligation of Compliant Use</p> <p>3.4 Obligation of Compliant Use</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
You shall use the Services in a legal and compliant manner, and shall not use the Services for any purposes that are illegal or criminal, violate public order and good customs, harm social ethics (in line with the standards of public order and good customs in the United States and Europe), interfere with the normal operation of the Platform or infringe the legitimate rights and interests of third parties. Your use of the Services shall also not violate any documents or other requirements that are binding on you (if any). The Platform specially reminds you not to lend, transfer or provide your account to others for use in other ways, and to reasonably prevent others from committing acts that violate the aforementioned provisions through your account, so as to protect the security of your account and property. You shall use the Services in a legal and compliant manner, and shall not use
the Services for any purposes that are illegal or criminal, violate public order
and good customs, harm social ethics (in line with the standards of public order
and good customs in the United States and Europe), interfere with the normal
operation of the Platform or infringe the legitimate rights and interests of
third parties. Your use of the Services shall also not violate any documents or
other requirements that are binding on you (if any). The Platform specially
reminds you not to lend, transfer or provide your account to others for use in
other ways, and to reasonably prevent others from committing acts that violate
the aforementioned provisions through your account, so as to protect the
security of your account and property.
</p> </p>
</div> </div>
</div> </div>
{/* 3.5 Specifications for Minor Refund Services */} {/* 3.5 Specifications for Minor Refund Services */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>3.5 Specifications for Minor Refund Services</p> <p>3.5 Specifications for Minor Refund Services</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
The Platform provides minor consumption refund services in accordance with laws and regulations to protect the legitimate rights and interests of minors and their guardians (in compliance with the provisions on the protection of minor consumption in the U.S. COPPA and the European GDPR); you shall not use this service for illegal purposes or in improper ways, including but not limited to adults pretending to be minors to defraud refunds, inducing minors to consume and then applying for refunds, etc. The aforementioned acts shall constitute a serious violation of this Agreement. After reasonable confirmation, the Platform has the right to refuse the refund and reserve the right to further pursue your legal liability in accordance with the law (including but not limited to reporting to regulatory authorities, filing a lawsuit, etc.). The Platform provides minor consumption refund services in accordance with laws
and regulations to protect the legitimate rights and interests of minors and
their guardians (in compliance with the provisions on the protection of minor
consumption in the U.S. COPPA and the European GDPR); you shall not use this
service for illegal purposes or in improper ways, including but not limited to
adults pretending to be minors to defraud refunds, inducing minors to consume
and then applying for refunds, etc. The aforementioned acts shall constitute a
serious violation of this Agreement. After reasonable confirmation, the Platform
has the right to refuse the refund and reserve the right to further pursue your
legal liability in accordance with the law (including but not limited to
reporting to regulatory authorities, filing a lawsuit, etc.).
</p> </p>
</div> </div>
</div> </div>
{/* 3.6 Provisions on Third-Party Services */} {/* 3.6 Provisions on Third-Party Services */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>3.6 Provisions on Third-Party Services</p> <p>3.6 Provisions on Third-Party Services</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
If the use of the Services involves relevant services provided by third parties (such as payment services, third-party login services, etc.), in addition to complying with the provisions of this Agreement, you shall also agree to and comply with the service agreements and relevant rules of such third parties. Under no circumstances shall any disputes arising from such third parties and their provided relevant services (including but not limited to payment failures, account security issues, etc.) be resolved by you and the third party on your own. The Platform shall not bear any liability to you or the third party in this regard. If the use of the Services involves relevant services provided by third parties
(such as payment services, third-party login services, etc.), in addition to
complying with the provisions of this Agreement, you shall also agree to and
comply with the service agreements and relevant rules of such third parties.
Under no circumstances shall any disputes arising from such third parties and
their provided relevant services (including but not limited to payment failures,
account security issues, etc.) be resolved by you and the third party on your
own. The Platform shall not bear any liability to you or the third party in this
regard.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* IV. Rights and Obligations of the Platform */} {/* IV. Rights and Obligations of the Platform */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>IV. Rights and Obligations of the Platform</p> <p>IV. Rights and Obligations of the Platform</p>
</div> </div>
{/* 4.1 Right to Adjust Service Rules */} {/* 4.1 Right to Adjust Service Rules */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>4.1 Right to Adjust Service Rules</p> <p>4.1 Right to Adjust Service Rules</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
Based on factors such as revisions to laws and regulations, requirements of regulatory authorities in the United States and Europe, transaction security guarantees, updates to operating strategies, and changes in market environment, the Platform has the right to set relevant restrictions and reminders on the Virtual Currency services from time to time, including but not limited to restricting the transaction limit and/or transaction frequency of all or part of the users, prohibiting specific users from using the Services, and adding transaction verification steps (such as identity verification, SMS verification, etc.). The Platform will notify you of the aforementioned adjustments through reasonable methods such as APP pop-ups, official website announcements, and email notifications. If you do not agree to the adjustments, you may stop using the Services; if you continue to use the Services, it shall be deemed that you agree to such adjustments. Based on factors such as revisions to laws and regulations, requirements of
regulatory authorities in the United States and Europe, transaction security
guarantees, updates to operating strategies, and changes in market environment,
the Platform has the right to set relevant restrictions and reminders on the
Virtual Currency services from time to time, including but not limited to
restricting the transaction limit and/or transaction frequency of all or part of
the users, prohibiting specific users from using the Services, and adding
transaction verification steps (such as identity verification, SMS verification,
etc.). The Platform will notify you of the aforementioned adjustments through
reasonable methods such as APP pop-ups, official website announcements, and
email notifications. If you do not agree to the adjustments, you may stop using
the Services; if you continue to use the Services, it shall be deemed that you
agree to such adjustments.
</p> </p>
</div> </div>
</div> </div>
{/* 4.2 Right to Risk Monitoring and Account Management */} {/* 4.2 Right to Risk Monitoring and Account Management */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>4.2 Right to Risk Monitoring and Account Management</p> <p>4.2 Right to Risk Monitoring and Account Management</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
To ensure transaction security and the stability of the Platform ecosystem, the Platform has the right to monitor your use of the Services (in compliance with relevant laws and regulations on data security and privacy protection in the United States and Europe). For users or accounts that are reasonably identified as high-risk (including but not limited to those suspected of money laundering, fraud, abnormal account login, large-scale purchase of Virtual Currency followed by rapid consumption, etc.), the Platform may take necessary measures to prevent the expansion of risks and protect the property of users and the ecological security of the Platform. Such necessary measures include deducting or clearing the Virtual Currency in your account, restricting all or part of the functions of your account, or temporarily or permanently banning your account. Before taking the aforementioned measures, the Platform will notify you through reasonable methods as much as possible, unless it is impossible to notify due to emergency situations (such as suspected illegal crimes requiring immediate handling). To ensure transaction security and the stability of the Platform ecosystem, the
Platform has the right to monitor your use of the Services (in compliance with
relevant laws and regulations on data security and privacy protection in the
United States and Europe). For users or accounts that are reasonably identified
as high-risk (including but not limited to those suspected of money laundering,
fraud, abnormal account login, large-scale purchase of Virtual Currency followed
by rapid consumption, etc.), the Platform may take necessary measures to prevent
the expansion of risks and protect the property of users and the ecological
security of the Platform. Such necessary measures include deducting or clearing
the Virtual Currency in your account, restricting all or part of the functions
of your account, or temporarily or permanently banning your account. Before
taking the aforementioned measures, the Platform will notify you through
reasonable methods as much as possible, unless it is impossible to notify due to
emergency situations (such as suspected illegal crimes requiring immediate
handling).
</p> </p>
</div> </div>
</div> </div>
{/* 4.3 Right to Correct Errors */} {/* 4.3 Right to Correct Errors */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>4.3 Right to Correct Errors</p> <p>4.3 Right to Correct Errors</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
When the Platform discovers errors in the processing of Virtual Currency (including but not limited to errors in the quantity of Virtual Currency issued or deducted) caused by system failures, network problems, human operation errors or any other reasons, whether the error is beneficial to the Platform or you, the Platform has the right to correct the error. In this case, if the actual quantity of Virtual Currency you receive is less than the quantity you should receive, the Platform will make up the difference to your account as soon as possible after confirming the processing error; if the actual quantity of Virtual Currency you receive is more than the quantity you should receive, the Platform has the right to directly deduct the difference from your account without prior notice. If the Virtual Currency in your account is insufficient to offset the difference, the Platform has the right to require you to make up the difference. You shall fulfill this obligation within the reasonable time limit notified by the Platform; otherwise, the Platform has the right to take measures such as restricting account functions and banning the account. When the Platform discovers errors in the processing of Virtual Currency
(including but not limited to errors in the quantity of Virtual Currency issued
or deducted) caused by system failures, network problems, human operation errors
or any other reasons, whether the error is beneficial to the Platform or you,
the Platform has the right to correct the error. In this case, if the actual
quantity of Virtual Currency you receive is less than the quantity you should
receive, the Platform will make up the difference to your account as soon as
possible after confirming the processing error; if the actual quantity of
Virtual Currency you receive is more than the quantity you should receive, the
Platform has the right to directly deduct the difference from your account
without prior notice. If the Virtual Currency in your account is insufficient to
offset the difference, the Platform has the right to require you to make up the
difference. You shall fulfill this obligation within the reasonable time limit
notified by the Platform; otherwise, the Platform has the right to take measures
such as restricting account functions and banning the account.
</p> </p>
</div> </div>
</div> </div>
{/* 4.4 Right to Change, Suspend or Terminate Services */} {/* 4.4 Right to Change, Suspend or Terminate Services */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>4.4 Right to Change, Suspend or Terminate Services</p> <p>4.4 Right to Change, Suspend or Terminate Services</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
The Platform has the right to change, interrupt, suspend or terminate the Services based on specific circumstances such as transaction security, operation plans, national laws and regulations or the requirements of regulatory authorities in the United States and Europe. If the Platform decides to change, interrupt, suspend or terminate the Services, it will notify you in advance through reasonable methods such as APP pop-ups, official website announcements, and email notifications (except for emergency situations such as force majeure and sudden system failures where advance notification is impossible), and handle the unused Virtual Currency balance in your account (excluding the membership recharge amount; for the refund rules of membership recharge amount, please refer to Chapter V of this Agreement) in accordance with the provisions of this Agreement. The Platform shall not bear any tort liability to you due to the change, interruption, suspension or termination of the Services for the aforementioned reasons, unless otherwise stipulated by laws and regulations. The Platform has the right to change, interrupt, suspend or terminate the
Services based on specific circumstances such as transaction security, operation
plans, national laws and regulations or the requirements of regulatory
authorities in the United States and Europe. If the Platform decides to change,
interrupt, suspend or terminate the Services, it will notify you in advance
through reasonable methods such as APP pop-ups, official website announcements,
and email notifications (except for emergency situations such as force majeure
and sudden system failures where advance notification is impossible), and handle
the unused Virtual Currency balance in your account (excluding the membership
recharge amount; for the refund rules of membership recharge amount, please
refer to Chapter V of this Agreement) in accordance with the provisions of this
Agreement. The Platform shall not bear any tort liability to you due to the
change, interruption, suspension or termination of the Services for the
aforementioned reasons, unless otherwise stipulated by laws and regulations.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* V. Refund Rules */} {/* V. Refund Rules */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>V. Refund Rules</p> <p>V. Refund Rules</p>
</div> </div>
{/* 5.1 Restrictions on Refunds After Consumption of Virtual Currency */} {/* 5.1 Restrictions on Refunds After Consumption of Virtual Currency */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>5.1 Restrictions on Refunds After Consumption of Virtual Currency</p> <p>5.1 Restrictions on Refunds After Consumption of Virtual Currency</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
After you use the Virtual Currency for consumption (including but not limited to paid chat, unlocking pictures, purchasing Affection Points, sending virtual gifts, unlocking virtual lovers, etc.), since the Virtual Currency has been converted into the corresponding services or rights provided by the Platform, and the services related to AI virtual characters are instantaneous and irreversible, <strong>the Platform does not provide refund services for this part of the Virtual Currency</strong>. You shall carefully confirm your consumption needs before consumption. After you use the Virtual Currency for consumption (including but not limited to
paid chat, unlocking pictures, purchasing Affection Points, sending virtual
gifts, unlocking virtual lovers, etc.), since the Virtual Currency has been
converted into the corresponding services or rights provided by the Platform,
and the services related to AI virtual characters are instantaneous and
irreversible,{' '}
<strong>
the Platform does not provide refund services for this part of the Virtual
Currency
</strong>
. You shall carefully confirm your consumption needs before consumption.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* VI. Disclaimer */} {/* VI. Disclaimer */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>VI. Disclaimer</p> <p>VI. Disclaimer</p>
</div> </div>
{/* 6.1 Provision of Services in Current State and Risk Warning */} {/* 6.1 Provision of Services in Current State and Risk Warning */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>6.1 Provision of Services in Current State and Risk Warning</p> <p>6.1 Provision of Services in Current State and Risk Warning</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
You understand and agree that the Services are provided in accordance with the current state achievable under existing technologies and conditions. The Platform will make its best efforts to provide the Services to you and ensure the security and stability of the Services. However, you also know and acknowledge that the Platform cannot foresee and prevent technical and other risks at all times or at all times, including but not limited to service interruptions, delays, errors or data loss caused by force majeure (such as natural disasters, wars, public health emergencies, etc.), network reasons (such as network congestion, hacker attacks, server failures, etc.), third-party service defects (such as failures of third-party payment institutions, changes in app store policies, etc.), revisions to laws and regulations or adjustments to regulatory policies, etc. In the event of such circumstances, the Platform will make its best commercial efforts to improve the situation, but shall not be obligated to bear any legal liability to you or other third parties, unless such losses are caused by the intentional acts or gross negligence of the Platform. You understand and agree that the Services are provided in accordance with the
current state achievable under existing technologies and conditions. The
Platform will make its best efforts to provide the Services to you and ensure
the security and stability of the Services. However, you also know and
acknowledge that the Platform cannot foresee and prevent technical and other
risks at all times or at all times, including but not limited to service
interruptions, delays, errors or data loss caused by force majeure (such as
natural disasters, wars, public health emergencies, etc.), network reasons (such
as network congestion, hacker attacks, server failures, etc.), third-party
service defects (such as failures of third-party payment institutions, changes
in app store policies, etc.), revisions to laws and regulations or adjustments
to regulatory policies, etc. In the event of such circumstances, the Platform
will make its best commercial efforts to improve the situation, but shall not be
obligated to bear any legal liability to you or other third parties, unless such
losses are caused by the intentional acts or gross negligence of the Platform.
</p> </p>
</div> </div>
</div> </div>
{/* 6.2 Disclaimer for System Maintenance and Upgrades */} {/* 6.2 Disclaimer for System Maintenance and Upgrades */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>6.2 Disclaimer for System Maintenance and Upgrades</p> <p>6.2 Disclaimer for System Maintenance and Upgrades</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
The Platform may conduct downtime maintenance, system upgrades and function adjustments on its own. If you are unable to use the Services normally due to this, the Platform will notify you of the maintenance/upgrade time and the scope of impact in advance through reasonable methods (except for emergency maintenance), and you agree that the Platform shall not bear legal liability for this. Any losses caused by your attempt to use the Services during the maintenance/upgrade period shall be borne by yourself. The Platform may conduct downtime maintenance, system upgrades and function
adjustments on its own. If you are unable to use the Services normally due to
this, the Platform will notify you of the maintenance/upgrade time and the scope
of impact in advance through reasonable methods (except for emergency
maintenance), and you agree that the Platform shall not bear legal liability for
this. Any losses caused by your attempt to use the Services during the
maintenance/upgrade period shall be borne by yourself.
</p> </p>
</div> </div>
</div> </div>
{/* 6.3 Limitation of Liability */} {/* 6.3 Limitation of Liability */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>6.3 Limitation of Liability</p> <p>6.3 Limitation of Liability</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
Under no circumstances shall the Platform be liable for any indirect, punitive, incidental or special damages (including but not limited to loss of profits, loss of expected benefits, loss of data, etc.). Moreover, the total liability of the Platform to you, regardless of the cause or manner (including but not limited to breach of contract, tort, etc.), shall not exceed the total amount of fees you actually paid for using the recharge services. Under no circumstances shall the Platform be liable for any indirect, punitive,
incidental or special damages (including but not limited to loss of profits,
loss of expected benefits, loss of data, etc.). Moreover, the total liability of
the Platform to you, regardless of the cause or manner (including but not
limited to breach of contract, tort, etc.), shall not exceed the total amount of
fees you actually paid for using the recharge services.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* VII. Liability for Breach of Contract */} {/* VII. Liability for Breach of Contract */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>VII. Liability for Breach of Contract</p> <p>VII. Liability for Breach of Contract</p>
</div> </div>
{/* 7.1 Handling of Your Breach of Contract */} {/* 7.1 Handling of Your Breach of Contract */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>7.1 Handling of Your Breach of Contract</p> <p>7.1 Handling of Your Breach of Contract</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
If you violate any provisions of this Agreement (including but not limited to purchasing Virtual Currency through unauthorized channels, using funds from illegal sources, etc.), the Platform has the right to take appropriate measures in accordance with the severity of the violation, including but not limited to warning, restricting account functions, temporarily or permanently banning your account, and requiring you to bear corresponding legal liability. If your violation causes losses to the Platform or third parties, you shall be liable for full compensation. If you violate any provisions of this Agreement (including but not limited to
purchasing Virtual Currency through unauthorized channels, using funds from
illegal sources, etc.), the Platform has the right to take appropriate measures
in accordance with the severity of the violation, including but not limited to
warning, restricting account functions, temporarily or permanently banning your
account, and requiring you to bear corresponding legal liability. If your
violation causes losses to the Platform or third parties, you shall be liable
for full compensation.
</p> </p>
</div> </div>
</div> </div>
@ -373,5 +628,5 @@ export default function RechargeAgreementPage() {
</div> </div>
</div> </div>
</div> </div>
); )
} }

View File

@ -1,282 +1,417 @@
export default function TermsOfServicePage() { export default function TermsOfServicePage() {
return ( return (
<div className="flex flex-col items-center justify-start relative size-full min-h-screen "> <div className="relative flex size-full min-h-screen flex-col items-center justify-start">
<div className="flex gap-1 grow items-start justify-center max-w-[1232px] min-h-px min-w-px pb-0 pt-28 px-6 md:px-12 relative shrink-0 w-full"> <div className="relative flex min-h-px w-full max-w-[1232px] min-w-px shrink-0 grow items-start justify-center gap-1 px-6 pt-28 pb-0 md:px-12">
<div className="flex flex-col gap-12 grow items-center justify-start max-w-[752px] min-h-px min-w-px relative shrink-0 w-full"> <div className="relative flex min-h-px w-full max-w-[752px] min-w-px shrink-0 grow flex-col items-center justify-start gap-12">
{/* 主标题 */} {/* 主标题 */}
<div className="txt-headline-s text-center text-white w-full"> <div className="txt-headline-s w-full text-center text-white">
<p className="whitespace-pre-wrap"> <p className="whitespace-pre-wrap">Crushlevel User Agreement</p>
Crushlevel User Agreement
</p>
</div> </div>
{/* 前言 */} {/* 前言 */}
<div className="txt-body-l text-white w-full"> <div className="txt-body-l w-full text-white">
<p className="mb-4"> <p className="mb-4">
Welcome to the Crushlevel application (hereinafter referred to as "this App") and related website (Crushlevel.ai, hereinafter referred to as "this Website"). This User Agreement (hereinafter referred to as "this Agreement") is a legally binding agreement between you (hereinafter referred to as the "User") and the operator of Crushlevel (hereinafter referred to as "We," "Us," or "Our") regarding your use of this App and this Website. Before registering for or using this App and this Website, please read this Agreement carefully and understand its contents in full. If you have any questions regarding this Agreement, you should consult Us. If you do not agree to any part of this Agreement, you should immediately cease registration or use of this App and this Website. Once you register for or use this App and this Website, it means that you have fully understood and agreed to all the terms of this Agreement. Welcome to the Crushlevel application (hereinafter referred to as "this App") and
related website (Crushlevel.ai, hereinafter referred to as "this Website"). This User
Agreement (hereinafter referred to as "this Agreement") is a legally binding agreement
between you (hereinafter referred to as the "User") and the operator of Crushlevel
(hereinafter referred to as "We," "Us," or "Our") regarding your use of this App and
this Website. Before registering for or using this App and this Website, please read
this Agreement carefully and understand its contents in full. If you have any
questions regarding this Agreement, you should consult Us. If you do not agree to any
part of this Agreement, you should immediately cease registration or use of this App
and this Website. Once you register for or use this App and this Website, it means
that you have fully understood and agreed to all the terms of this Agreement.
</p> </p>
</div> </div>
{/* 协议条款 */} {/* 协议条款 */}
<div className="flex flex-col gap-6 items-start justify-start text-white w-full"> <div className="flex w-full flex-col items-start justify-start gap-6 text-white">
{/* Article 1: User Eligibility */} {/* Article 1: User Eligibility */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>Article 1: User Eligibility</p> <p>Article 1: User Eligibility</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
You declare and warrant that at the time of registering an account for this App and this Website, you are at least 18 years old, possess full civil rights capacity and civil capacity for conduct, and are able to independently bear civil liability. If you are under 18 years old, you should read this Agreement under the supervision of your legal guardian and only use this App and this Website with the consent of your legal guardian. You declare and warrant that at the time of registering an account for this App
and this Website, you are at least 18 years old, possess full civil rights
capacity and civil capacity for conduct, and are able to independently bear civil
liability. If you are under 18 years old, you should read this Agreement under the
supervision of your legal guardian and only use this App and this Website with the
consent of your legal guardian.
</p> </p>
<p className="mb-4"> <p className="mb-4">
You shall ensure that the registration information provided is true, accurate, and complete, and promptly update your registration information to ensure its validity. If, due to registration information provided by you being untrue, inaccurate, incomplete, or not updated in a timely manner, We are unable to provide you with corresponding services or any other losses arise, you shall bear full responsibility. You shall ensure that the registration information provided is true, accurate, and
complete, and promptly update your registration information to ensure its
validity. If, due to registration information provided by you being untrue,
inaccurate, incomplete, or not updated in a timely manner, We are unable to
provide you with corresponding services or any other losses arise, you shall bear
full responsibility.
</p> </p>
<p className="mb-4"> <p className="mb-4">
Each user may register only one account. Registering multiple accounts in any form, including but not limited to using different identity information, phone numbers, etc., is strictly prohibited. If We discover that you have registered multiple accounts, We have the right to restrict, freeze, or terminate such accounts without any liability to you. Each user may register only one account. Registering multiple accounts in any
form, including but not limited to using different identity information, phone
numbers, etc., is strictly prohibited. If We discover that you have registered
multiple accounts, We have the right to restrict, freeze, or terminate such
accounts without any liability to you.
</p> </p>
</div> </div>
</div> </div>
{/* Article 2: Account Management */} {/* Article 2: Account Management */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>Article 2: Account Management</p> <p>Article 2: Account Management</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
You are responsible for the security of your account and password and shall not disclose your account and password to any third party. If your account and password are used illegally by others due to your own reasons, you shall bear all consequences arising therefrom, and We assume no liability. You are responsible for the security of your account and password and shall not
disclose your account and password to any third party. If your account and
password are used illegally by others due to your own reasons, you shall bear all
consequences arising therefrom, and We assume no liability.
</p> </p>
<p className="mb-4"> <p className="mb-4">
If you discover that your account and password are being used illegally by others or that other security risks exist, you shall immediately notify Us and take corresponding security measures. Upon receiving your notice, We will take reasonable measures based on the actual circumstances, but We assume no responsibility for the outcome of such measures. If you discover that your account and password are being used illegally by others
or that other security risks exist, you shall immediately notify Us and take
corresponding security measures. Upon receiving your notice, We will take
reasonable measures based on the actual circumstances, but We assume no
responsibility for the outcome of such measures.
</p> </p>
<p className="mb-4"> <p className="mb-4">
Without Our prior written consent, you may not transfer, gift, lease, or sell your account to any third party. If you violate this provision, you shall bear all consequences arising therefrom, and We have the right to restrict, freeze, or terminate the relevant account(s). Without Our prior written consent, you may not transfer, gift, lease, or sell your
account to any third party. If you violate this provision, you shall bear all
consequences arising therefrom, and We have the right to restrict, freeze, or
terminate the relevant account(s).
</p> </p>
</div> </div>
</div> </div>
{/* Article 3: Service Content and Usage Norms */} {/* Article 3: Service Content and Usage Norms */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>Article 3: Service Content and Usage Norms</p> <p>Article 3: Service Content and Usage Norms</p>
</div> </div>
{/* (1) Service Content */} {/* (1) Service Content */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>(1) Service Content</p> <p>(1) Service Content</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
You can create AI virtual characters ("Characters") on this App and this Website. Created Characters fall into two categories: Original and Derivative (based on existing fictional works). You can create AI virtual characters ("Characters") on this App and this
Website. Created Characters fall into two categories: Original and Derivative
(based on existing fictional works).
</p> </p>
<p className="mb-4"> <p className="mb-4">
Other users can chat with the AI virtual Characters you create. Chat methods include text, images, voice, etc. Other users can chat with the AI virtual Characters you create. Chat methods
include text, images, voice, etc.
</p> </p>
<p className="mb-4"> <p className="mb-4">
Chatting with Characters allows users to level up their relationship with the Character, unlocking related features and rewards. Chatting with Characters allows users to level up their relationship with the
Character, unlocking related features and rewards.
</p> </p>
</div> </div>
</div> </div>
{/* (2) Usage Norms */} {/* (2) Usage Norms */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>(2) Usage Norms</p> <p>(2) Usage Norms</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
When using this App and this Website, you shall comply with applicable laws and regulations, public order and good morals, and the provisions of this Agreement. You may not use this App and this Website to engage in any illegal or non-compliant activities. When using this App and this Website, you shall comply with applicable laws and
regulations, public order and good morals, and the provisions of this Agreement.
You may not use this App and this Website to engage in any illegal or
non-compliant activities.
</p> </p>
<p className="mb-4"> <p className="mb-4">
The AI virtual Characters you create and the content you publish during chats (including but not limited to text, images, voice, etc.) must not contain the following: The AI virtual Characters you create and the content you publish during chats
(including but not limited to text, images, voice, etc.) must not contain the
following:
</p> </p>
<ul className="list-disc ml-6 mb-4"> <ul className="mb-4 ml-6 list-disc">
<li className="mb-2">Content that violates laws and regulations, such as content endangering national security, undermining ethnic unity, promoting terrorism, extremism, obscenity, pornography, gambling, etc.;</li> <li className="mb-2">
<li className="mb-2">Content that infringes upon the lawful rights and interests of others, such as infringing upon others' portrait rights, reputation rights, privacy rights, intellectual property rights, etc.;</li> Content that violates laws and regulations, such as content endangering
national security, undermining ethnic unity, promoting terrorism, extremism,
obscenity, pornography, gambling, etc.;
</li>
<li className="mb-2">
Content that infringes upon the lawful rights and interests of others, such as
infringing upon others' portrait rights, reputation rights, privacy rights,
intellectual property rights, etc.;
</li>
<li className="mb-2">Content that is false, fraudulent, or misleading;</li> <li className="mb-2">Content that is false, fraudulent, or misleading;</li>
<li className="mb-2">Content that insults, slanders, intimidates, or harasses others;</li> <li className="mb-2">
<li className="mb-2">Other content that violates public order, good morals, or the provisions of this Agreement.</li> Content that insults, slanders, intimidates, or harasses others;
</li>
<li className="mb-2">
Other content that violates public order, good morals, or the provisions of
this Agreement.
</li>
</ul> </ul>
<p className="mb-4"> <p className="mb-4">
You may not use this App and this Website to engage in any form of network attacks, virus dissemination, spam distribution, or other activities that disrupt the normal operation of this App and this Website. You may not use this App and this Website to engage in any form of network
attacks, virus dissemination, spam distribution, or other activities that
disrupt the normal operation of this App and this Website.
</p> </p>
<p className="mb-4"> <p className="mb-4">
You shall respect the lawful rights and interests of other users and must not maliciously harass or attack other users or infringe upon other users' private information. You shall respect the lawful rights and interests of other users and must not
maliciously harass or attack other users or infringe upon other users' private
information.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* Article 4: Intellectual Property Rights */} {/* Article 4: Intellectual Property Rights */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>Article 4: Intellectual Property Rights</p> <p>Article 4: Intellectual Property Rights</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
We own all intellectual property rights in this App and this Website, including but not limited to copyrights, trademarks, patents, trade secrets, etc. All content of this App and this Website, including but not limited to text, images, audio, video, software, programs, interface design, etc., is protected by laws and regulations. We own all intellectual property rights in this App and this Website, including
but not limited to copyrights, trademarks, patents, trade secrets, etc. All
content of this App and this Website, including but not limited to text, images,
audio, video, software, programs, interface design, etc., is protected by laws and
regulations.
</p> </p>
<p className="mb-4"> <p className="mb-4">
The intellectual property rights in the AI virtual Characters you create and the content you publish on this App and this Website belong to you. However, you grant Us a worldwide, royalty-free, non-exclusive, transferable, and sub-licensable license to use such content for the operation, promotion, marketing, and related activities of this App and this Website. The intellectual property rights in the AI virtual Characters you create and the
content you publish on this App and this Website belong to you. However, you grant
Us a worldwide, royalty-free, non-exclusive, transferable, and sub-licensable
license to use such content for the operation, promotion, marketing, and related
activities of this App and this Website.
</p> </p>
<p className="mb-4"> <p className="mb-4">
When creating Derivative AI virtual Characters, you shall ensure that the Character does not infringe upon the intellectual property rights of the original work. If any dispute arises due to your creation of a Derivative Character infringing upon others' intellectual property rights, you shall bear full responsibility. If losses are caused to Us, you shall compensate Us accordingly. When creating Derivative AI virtual Characters, you shall ensure that the
Character does not infringe upon the intellectual property rights of the original
work. If any dispute arises due to your creation of a Derivative Character
infringing upon others' intellectual property rights, you shall bear full
responsibility. If losses are caused to Us, you shall compensate Us accordingly.
</p> </p>
<p className="mb-4"> <p className="mb-4">
Without Our prior written permission, you may not use, copy, modify, disseminate, or display any intellectual property content of this App and this Website. Without Our prior written permission, you may not use, copy, modify, disseminate,
or display any intellectual property content of this App and this Website.
</p> </p>
</div> </div>
</div> </div>
{/* Article 5: Payments and Transactions */} {/* Article 5: Payments and Transactions */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>Article 5: Payments and Transactions</p> <p>Article 5: Payments and Transactions</p>
</div> </div>
{/* (1) Chat Payments */} {/* (1) Chat Payments */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>(1) Chat Payments</p> <p>(1) Chat Payments</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
Users have a daily limit on free chats with virtual Characters. The specific number of free chats is subject to the actual display within this App and this Website. Users have a daily limit on free chats with virtual Characters. The specific
number of free chats is subject to the actual display within this App and this
Website.
</p> </p>
<p className="mb-4"> <p className="mb-4">
After the free quota is exhausted, users need to pay per message to continue chatting with virtual Characters. Specific payment standards are subject to the actual display within this App and this Website. After the free quota is exhausted, users need to pay per message to continue
chatting with virtual Characters. Specific payment standards are subject to the
actual display within this App and this Website.
</p> </p>
</div> </div>
</div> </div>
{/* (2) Creation Payments */} {/* (2) Creation Payments */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>(2) Creation Payments</p> <p>(2) Creation Payments</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
Creators must pay corresponding fees to create Characters or generate derivative AI images. Fees can be paid via membership subscription or virtual currency. Creators must pay corresponding fees to create Characters or generate derivative
AI images. Fees can be paid via membership subscription or virtual currency.
</p> </p>
<p className="mb-4"> <p className="mb-4">
The specific content, pricing, and validity period of membership services are subject to the actual display within this App and this Website. After purchasing membership, the member benefits will be effective for the corresponding validity period. The specific content, pricing, and validity period of membership services are
subject to the actual display within this App and this Website. After purchasing
membership, the member benefits will be effective for the corresponding validity
period.
</p> </p>
<p className="mb-4"> <p className="mb-4">
Virtual currency is a type of virtual item within this App and this Website, obtainable by users through purchase using fiat currency. The purchase price of virtual currency is subject to the actual display within this App and this Website. Virtual currency may not be exchanged for fiat currency or transferred/gifted to other users. Virtual currency is a type of virtual item within this App and this Website,
obtainable by users through purchase using fiat currency. The purchase price of
virtual currency is subject to the actual display within this App and this
Website. Virtual currency may not be exchanged for fiat currency or
transferred/gifted to other users.
</p> </p>
</div> </div>
</div> </div>
{/* (3) Transaction Rules */} {/* (3) Transaction Rules */}
<div className="flex flex-col gap-4 w-full"> <div className="flex w-full flex-col gap-4">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p>(3) Transaction Rules</p> <p>(3) Transaction Rules</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
Before making any payment, users should carefully confirm the payment information, including but not limited to the amount, content, and payment method. Once payment is successfully completed, no refunds will be issued unless otherwise stipulated by applicable law or as mutually agreed upon by both parties. Before making any payment, users should carefully confirm the payment
information, including but not limited to the amount, content, and payment
method. Once payment is successfully completed, no refunds will be issued unless
otherwise stipulated by applicable law or as mutually agreed upon by both
parties.
</p> </p>
<p className="mb-4"> <p className="mb-4">
If payment failure or incorrect payment amount occurs due to force majeure such as system failures or network issues, We will handle the situation accordingly upon verification, including but not limited to refunding or supplementing payment. If payment failure or incorrect payment amount occurs due to force majeure such
as system failures or network issues, We will handle the situation accordingly
upon verification, including but not limited to refunding or supplementing
payment.
</p> </p>
<p className="mb-4"> <p className="mb-4">
We reserve the right to adjust payment standards, membership service content, virtual currency prices, etc., based on market conditions and business development needs. Adjusted content will be announced on this App and this Website and will become effective after the announcement. We reserve the right to adjust payment standards, membership service content,
virtual currency prices, etc., based on market conditions and business
development needs. Adjusted content will be announced on this App and this
Website and will become effective after the announcement.
</p> </p>
</div> </div>
</div> </div>
</div> </div>
{/* Article 6: Privacy Protection */} {/* Article 6: Privacy Protection */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>Article 6: Privacy Protection</p> <p>Article 6: Privacy Protection</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
We value user privacy protection and will collect, use, store, and protect your personal information in accordance with the provisions of the "Privacy Policy". The "Privacy Policy" is an integral part of this Agreement and has the same legal effect as this Agreement. We value user privacy protection and will collect, use, store, and protect your
personal information in accordance with the provisions of the "Privacy Policy".
The "Privacy Policy" is an integral part of this Agreement and has the same legal
effect as this Agreement.
</p> </p>
<p className="mb-4"> <p className="mb-4">
You should read the "Privacy Policy" carefully to understand how We process personal information. If you do not agree to any part of the "Privacy Policy," you should immediately stop using this App and this Website. You should read the "Privacy Policy" carefully to understand how We process
personal information. If you do not agree to any part of the "Privacy Policy," you
should immediately stop using this App and this Website.
</p> </p>
</div> </div>
</div> </div>
{/* Article 7: Disclaimer */} {/* Article 7: Disclaimer */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>Article 7: Disclaimer</p> <p>Article 7: Disclaimer</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
The services of this App and this Website are provided according to the level of technology and conditions currently available. We will make every effort to ensure the stability and security of the services, but We cannot guarantee that services will be uninterrupted, timely, secure, or error-free. We shall not be liable for any service interruption or malfunction caused by force majeure or third-party reasons. The services of this App and this Website are provided according to the level of
technology and conditions currently available. We will make every effort to ensure
the stability and security of the services, but We cannot guarantee that services
will be uninterrupted, timely, secure, or error-free. We shall not be liable for
any service interruption or malfunction caused by force majeure or third-party
reasons.
</p> </p>
<p className="mb-4"> <p className="mb-4">
Any losses or risks incurred by you during your use of this App and this Website resulting from the use of third-party services or links shall be borne solely by you, and We assume no liability. Any losses or risks incurred by you during your use of this App and this Website
resulting from the use of third-party services or links shall be borne solely by
you, and We assume no liability.
</p> </p>
<p className="mb-4"> <p className="mb-4">
You shall bear full responsibility for any losses or legal liabilities arising from your violation of the provisions of this Agreement or applicable laws and regulations. If losses are caused to Us or other users, you shall compensate accordingly. You shall bear full responsibility for any losses or legal liabilities arising
from your violation of the provisions of this Agreement or applicable laws and
regulations. If losses are caused to Us or other users, you shall compensate
accordingly.
</p> </p>
<p className="mb-4"> <p className="mb-4">
We assume no responsibility for the content on this App and this Website. Regarding the AI virtual Characters created by users and the content published, We only provide a platform service and do not assume responsibility for the authenticity, legality, or accuracy of such content. We assume no responsibility for the content on this App and this Website.
Regarding the AI virtual Characters created by users and the content published, We
only provide a platform service and do not assume responsibility for the
authenticity, legality, or accuracy of such content.
</p> </p>
</div> </div>
</div> </div>
{/* Article 8: Agreement Modification and Termination */} {/* Article 8: Agreement Modification and Termination */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>Article 8: Agreement Modification and Termination</p> <p>Article 8: Agreement Modification and Termination</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
We reserve the right to modify and update this Agreement based on changes in laws and regulations and business development needs. The modified agreement will be announced on this App and this Website and will become effective after the announcement period. If you object to the modified agreement, you should immediately stop using this App and this Website. If you continue to use this App and this Website, it means you have accepted the modified agreement. We reserve the right to modify and update this Agreement based on changes in laws
and regulations and business development needs. The modified agreement will be
announced on this App and this Website and will become effective after the
announcement period. If you object to the modified agreement, you should
immediately stop using this App and this Website. If you continue to use this App
and this Website, it means you have accepted the modified agreement.
</p> </p>
<p className="mb-4"> <p className="mb-4">
If you violate the provisions of this Agreement, We have the right, based on the severity of the violation, to take actions such as warning you, restricting features, freezing, or terminating your account, and reserve the right to pursue your legal liability. If you violate the provisions of this Agreement, We have the right, based on the
severity of the violation, to take actions such as warning you, restricting
features, freezing, or terminating your account, and reserve the right to pursue
your legal liability.
</p> </p>
<p className="mb-4"> <p className="mb-4">
You may apply to Us to deregister your account at any time. After account deregistration, you will no longer be able to use the services of this App and this Website, and your relevant information will be processed in accordance with the "Privacy Policy." You may apply to Us to deregister your account at any time. After account
deregistration, you will no longer be able to use the services of this App and
this Website, and your relevant information will be processed in accordance with
the "Privacy Policy."
</p> </p>
<p className="mb-4"> <p className="mb-4">
If due to legal provisions, government requirements, or other force majeure events this App and this Website cannot continue to provide services, We have the right to terminate this Agreement and will notify you within a reasonable period. If due to legal provisions, government requirements, or other force majeure events
this App and this Website cannot continue to provide services, We have the right
to terminate this Agreement and will notify you within a reasonable period.
</p> </p>
</div> </div>
</div> </div>
{/* Article 9: Governing Law and Dispute Resolution */} {/* Article 9: Governing Law and Dispute Resolution */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>Article 9: Governing Law and Dispute Resolution</p> <p>Article 9: Governing Law and Dispute Resolution</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
The conclusion, validity, interpretation, performance, and dispute resolution of this Agreement shall be governed by the laws of the jurisdiction where the user registered their account. If the laws of the registration jurisdiction contain no relevant provisions, internationally accepted commercial practices shall apply. The conclusion, validity, interpretation, performance, and dispute resolution of
this Agreement shall be governed by the laws of the jurisdiction where the user
registered their account. If the laws of the registration jurisdiction contain no
relevant provisions, internationally accepted commercial practices shall apply.
</p> </p>
<p className="mb-4"> <p className="mb-4">
Any dispute arising from or in connection with this Agreement shall first be resolved through friendly negotiation between the parties. If no settlement is reached through negotiation, either party shall have the right to file a lawsuit with the competent people's court in the location where We are based. Any dispute arising from or in connection with this Agreement shall first be
resolved through friendly negotiation between the parties. If no settlement is
reached through negotiation, either party shall have the right to file a lawsuit
with the competent people's court in the location where We are based.
</p> </p>
</div> </div>
</div> </div>
{/* Article 10: Miscellaneous */} {/* Article 10: Miscellaneous */}
<div className="flex flex-col gap-6 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-6">
<div className="txt-title-l w-full"> <div className="txt-title-l w-full">
<p>Article 10: Miscellaneous</p> <p>Article 10: Miscellaneous</p>
</div> </div>
<div className="txt-body-l w-full"> <div className="txt-body-l w-full">
<p className="mb-4"> <p className="mb-4">
This Agreement constitutes the entire agreement between you and Us regarding the use of this App and this Website, superseding any prior agreements or understandings, whether oral or written, concerning the subject matter hereof. This Agreement constitutes the entire agreement between you and Us regarding the
use of this App and this Website, superseding any prior agreements or
understandings, whether oral or written, concerning the subject matter hereof.
</p> </p>
<p className="mb-4"> <p className="mb-4">
If any term of this Agreement is found to be invalid or unenforceable, it shall not affect the validity of the remaining terms. If any term of this Agreement is found to be invalid or unenforceable, it shall
not affect the validity of the remaining terms.
</p> </p>
<p className="mb-4"> <p className="mb-4">
We reserve the right of final interpretation of this Agreement. If you have any questions during your use of this App and this Website, you may contact Us through the contact methods provided within this App and this Website. We reserve the right of final interpretation of this Agreement. If you have any
questions during your use of this App and this Website, you may contact Us through
the contact methods provided within this App and this Website.
</p> </p>
<p className="mb-4"> <p className="mb-4">
Users are required to carefully read and strictly comply with the above agreement. Thank you for your support and trust in Crushlevel. We hope you enjoy using it! Users are required to carefully read and strictly comply with the above agreement.
Thank you for your support and trust in Crushlevel. We hope you enjoy using it!
</p> </p>
</div> </div>
</div> </div>
@ -284,5 +419,5 @@ export default function TermsOfServicePage() {
</div> </div>
</div> </div>
</div> </div>
); )
} }

View File

@ -123,4 +123,3 @@ If any term of this Agreement is found to be invalid or unenforceable, it shall
We reserve the right of final interpretation of this Agreement. If you have any questions during your use of this App and this Website, you may contact Us through the contact methods provided within this App and this Website. We reserve the right of final interpretation of this Agreement. If you have any questions during your use of this App and this Website, you may contact Us through the contact methods provided within this App and this Website.
Users are required to carefully read and strictly comply with the above agreement. Thank you for your support and trust in Crushlevel. We hope you enjoy using it! Users are required to carefully read and strictly comply with the above agreement. Thank you for your support and trust in Crushlevel. We hope you enjoy using it!

View File

@ -1,19 +1,24 @@
import Empty from "@/components/ui/empty"; import Empty from '@/components/ui/empty'
import Image from "next/image"; import Image from 'next/image'
import Link from "next/link"; import Link from 'next/link'
export default async function NotFound() { export default async function NotFound() {
return ( return (
<div className="w-full h-full flex justify-center items-center min-h-screen"> <div className="flex h-full min-h-screen w-full items-center justify-center">
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<Link href="/"> <Link href="/">
<div className="w-[221px] h-[88px] relative mx-auto"> <div className="relative mx-auto h-[88px] w-[221px]">
<Image src="/icons/login-logo.svg" alt="Anime character" fill className="object-contain" priority /> <Image
src="/icons/login-logo.svg"
alt="Anime character"
fill
className="object-contain"
priority
/>
</div> </div>
</Link> </Link>
<Empty title="Oops, theres nothing here…" /> <Empty title="Oops, theres nothing here…" />
</div> </div>
</div> </div>
) )
} }

View File

@ -1,41 +1,39 @@
import SharePage from "./share-page"; import SharePage from './share-page'
import { HydrationBoundary } from "@tanstack/react-query"; import { HydrationBoundary } from '@tanstack/react-query'
import { dehydrate } from "@tanstack/react-query"; import { dehydrate } from '@tanstack/react-query'
import { QueryClient } from "@tanstack/react-query"; import { QueryClient } from '@tanstack/react-query'
import { aiUserKeys } from "@/lib/query-keys"; import { aiUserKeys } from '@/lib/query-keys'
import { userService } from "@/services/user"; import { userService } from '@/services/user'
import { ApiError } from "@/types/api"; import { ApiError } from '@/types/api'
import { notFound } from "next/navigation"; import { notFound } from 'next/navigation'
const Page = async ({ params }: { params: Promise<{ userId?: string }> }) => { const Page = async ({ params }: { params: Promise<{ userId?: string }> }) => {
const { userId } = await params; const { userId } = await params
if (!userId) { if (!userId) {
notFound(); notFound()
} }
const queryClient = new QueryClient(); const queryClient = new QueryClient()
try { try {
// 预获取用户基本信息 // 预获取用户基本信息
await queryClient.fetchQuery({ await queryClient.fetchQuery({
queryKey: aiUserKeys.baseInfo({ aiId: Number(userId) }), queryKey: aiUserKeys.baseInfo({ aiId: Number(userId) }),
queryFn: () => userService.getAIUserBaseInfo({ aiId: Number(userId) }), queryFn: () => userService.getAIUserBaseInfo({ aiId: Number(userId) }),
}); })
} catch (error) { } catch (error) {
if (error instanceof ApiError && error.errorCode === "10010012") { if (error instanceof ApiError && error.errorCode === '10010012') {
notFound(); notFound()
} }
// 其他错误不影响页面渲染,让客户端处理 // 其他错误不影响页面渲染,让客户端处理
} }
return ( return (
<HydrationBoundary <HydrationBoundary state={dehydrate(queryClient)}>
state={dehydrate(queryClient)}
>
<SharePage /> <SharePage />
</HydrationBoundary> </HydrationBoundary>
); )
} }
export default Page; export default Page

View File

@ -1,72 +1,68 @@
"use client" 'use client'
import { useParams } from "next/navigation"; import { useParams } from 'next/navigation'
import { useGetAIUserBaseInfo, useGetAIUserStat } from "@/hooks/aiUser"; import { useGetAIUserBaseInfo, useGetAIUserStat } from '@/hooks/aiUser'
import Image from "next/image"; import Image from 'next/image'
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { formatNumberToKMB, openApp } from "@/lib/utils"; import { formatNumberToKMB, openApp } from '@/lib/utils'
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button'
import { Tag } from "@/components/ui/tag"; import { Tag } from '@/components/ui/tag'
import { calculateAudioDuration, formatAudioDuration, parseTextWithBrackets } from "@/utils/textParser"; import {
import { useGetIMUserInfo, useGetShareUserInfo } from "@/hooks/useIm"; calculateAudioDuration,
import React from "react"; formatAudioDuration,
parseTextWithBrackets,
} from '@/utils/textParser'
import { useGetIMUserInfo, useGetShareUserInfo } from '@/hooks/useIm'
import React from 'react'
const SharePage = () => { const SharePage = () => {
const { userId } = useParams(); const { userId } = useParams()
const { data: imUserInfo } = useGetShareUserInfo({ const { data: imUserInfo } = useGetShareUserInfo({
aiId: userId ? Number(userId) : 0 aiId: userId ? Number(userId) : 0,
}); })
const { data: statData } = useGetAIUserStat({ aiId: Number(userId) }); const { data: statData } = useGetAIUserStat({ aiId: Number(userId) })
const { likedNum } = statData || {}; const { likedNum } = statData || {}
const { backgroundImg, headImg } = imUserInfo || {}
const { backgroundImg, headImg } = imUserInfo || {};
// 计算预估的音频时长 // 计算预估的音频时长
const estimatedDuration = React.useMemo(() => { const estimatedDuration = React.useMemo(() => {
return calculateAudioDuration( return calculateAudioDuration(
imUserInfo?.dialoguePrologue || '', imUserInfo?.dialoguePrologue || '',
imUserInfo?.dialogueSpeechRate || 0 imUserInfo?.dialogueSpeechRate || 0
); )
}, [imUserInfo?.dialoguePrologue, imUserInfo?.dialogueSpeechRate]); }, [imUserInfo?.dialoguePrologue, imUserInfo?.dialogueSpeechRate])
// 格式化时长显示 // 格式化时长显示
const formattedDuration = formatAudioDuration(estimatedDuration); const formattedDuration = formatAudioDuration(estimatedDuration)
const textParts = parseTextWithBrackets(imUserInfo?.dialoguePrologue || ''); const textParts = parseTextWithBrackets(imUserInfo?.dialoguePrologue || '')
const handleOpenApp = () => { const handleOpenApp = () => {
openApp(`crushlevel://profile/${userId}`); openApp(`crushlevel://profile/${userId}`)
} }
return ( return (
<div className="h-screen relative max-w-[750px] mx-auto w-full"> <div className="relative mx-auto h-screen w-full max-w-[750px]">
<div className="absolute inset-0"> <div className="absolute inset-0">
<Image <Image src={backgroundImg || ''} alt="Background" fill className="object-cover" priority />
src={backgroundImg || ''}
alt="Background"
fill
className="object-cover"
priority
/>
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
background: "linear-gradient(180deg, #211A2B 0%, rgba(33, 26, 43, 0.00) 20%, rgba(33, 26, 43, 0.00) 50%, #211A2B 100%)", background:
'linear-gradient(180deg, #211A2B 0%, rgba(33, 26, 43, 0.00) 20%, rgba(33, 26, 43, 0.00) 50%, #211A2B 100%)',
}} }}
/> />
</div> </div>
<div className="absolute inset-0 flex flex-col justify-between"> <div className="absolute inset-0 flex flex-col justify-between">
<div className="py-4 px-6 shrink-0"> <div className="shrink-0 px-6 py-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar className="size-10"> <Avatar className="size-10">
<AvatarImage src={headImg || ''} /> <AvatarImage src={headImg || ''} />
<AvatarFallback> <AvatarFallback>{imUserInfo?.nickname?.charAt(0) || ''}</AvatarFallback>
{imUserInfo?.nickname?.charAt(0) || ''}
</AvatarFallback>
</Avatar> </Avatar>
<div> <div>
<div className="txt-title-m">{imUserInfo?.nickname || ''}</div> <div className="txt-title-m">{imUserInfo?.nickname || ''}</div>
@ -75,17 +71,22 @@ const SharePage = () => {
</div> </div>
<div /> <div />
</div> </div>
<div className="flex-1 overflow-auto min-h-0 flex flex-col justify-end"> <div className="flex min-h-0 flex-1 flex-col justify-end overflow-auto">
<div className="flex flex-col justify-end"> <div className="flex flex-col justify-end">
<div className="px-6 py-2"> <div className="px-6 py-2">
<div className="bg-surface-element-normal rounded-lg backdrop-blur-[32px] p-4 border border-solid border-outline-normal"> <div className="bg-surface-element-normal border-outline-normal rounded-lg border border-solid p-4 backdrop-blur-[32px]">
<div className="txt-body-m line-clamp-3"> <div className="txt-body-m line-clamp-3">{imUserInfo?.introduction}</div>
{imUserInfo?.introduction} <div
</div> className="mt-2 flex items-center justify-between gap-4"
<div className="flex items-center justify-between gap-4 mt-2" onClick={handleOpenApp}> onClick={handleOpenApp}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tag variant="purple" size="small">{imUserInfo?.characterName}</Tag> <Tag variant="purple" size="small">
<Tag variant="magenta" size="small">{imUserInfo?.tagName}</Tag> {imUserInfo?.characterName}
</Tag>
<Tag variant="magenta" size="small">
{imUserInfo?.tagName}
</Tag>
</div> </div>
<i className="iconfont icon-icon-fullImage !text-[12px]" /> <i className="iconfont icon-icon-fullImage !text-[12px]" />
</div> </div>
@ -93,46 +94,56 @@ const SharePage = () => {
</div> </div>
</div> </div>
<div className="px-6 py-2 flex items-center justify-center"> <div className="flex items-center justify-center px-6 py-2">
<div className="txt-label-s text-center bg-surface-element-normal rounded-xs px-2 py-1">Content generated by AI</div> <div className="txt-label-s bg-surface-element-normal rounded-xs px-2 py-1 text-center">
Content generated by AI
</div>
</div> </div>
<div className="pt-4 px-6 pb-2"> <div className="px-6 pt-4 pb-2">
<div className="w-[80%] bg-surface-element-dark-normal rounded-lg backdrop-blur-[32px] pt-5 px-4 pb-4 txt-body-m relative"> <div className="bg-surface-element-dark-normal txt-body-m relative w-[80%] rounded-lg px-4 pt-5 pb-4 backdrop-blur-[32px]">
{textParts.map((part, index) => ( {textParts.map((part, index) => (
<span <span key={index} className={part.isInBrackets ? 'text-txt-secondary-normal' : ''}>
key={index}
className={part.isInBrackets ? 'text-txt-secondary-normal' : ''}
>
{part.text} {part.text}
</span> </span>
))} ))}
<div <div className="bg-surface-float-normal hover:bg-surface-float-hover absolute -top-3 left-0 flex cursor-pointer items-center gap-2 rounded-tl-sm rounded-r-sm px-3 py-1">
className="bg-surface-float-normal hover:bg-surface-float-hover py-1 px-3 rounded-tl-sm rounded-r-sm flex items-center gap-2 absolute left-0 -top-3 cursor-pointer" <div className="flex h-3 w-3 items-center">
> <i className="iconfont icon-Play !text-[12px] leading-none" />
<div className="h-3 w-3 flex items-center">
<i className="iconfont icon-Play leading-none !text-[12px]" />
</div> </div>
<span className="txt-label-s">{formattedDuration}</span> <span className="txt-label-s">{formattedDuration}</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<div className="py-2 px-4 shrink-0 pb-10"> <div className="shrink-0 px-4 py-2 pb-10">
<div className="rounded-lg px-4 py-2 flex items-center justify-between gap-3 backdrop-blur-lg" style={{ background: "var(--glo-color-transparent-purple-t20, rgba(251, 222, 255, 0.2))" }}> <div
className="flex items-center justify-between gap-3 rounded-lg px-4 py-2 backdrop-blur-lg"
style={{
background: 'var(--glo-color-transparent-purple-t20, rgba(251, 222, 255, 0.2))',
}}
>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Image src="/icons/square-logo.svg" className="rounded-[12px] overflow-hidden" alt="chat" width={48} height={48} /> <Image
src="/icons/square-logo.svg"
className="overflow-hidden rounded-[12px]"
alt="chat"
width={48}
height={48}
/>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<Image src="/images/share/logo.svg" alt="chat" width={103} height={32} /> <Image src="/images/share/logo.svg" alt="chat" width={103} height={32} />
<div className="txt-label-s">Chat, Crush, AI Date</div> <div className="txt-label-s">Chat, Crush, AI Date</div>
</div> </div>
</div> </div>
<Button variant="primary" size="small" className="min-w-auto" onClick={handleOpenApp}>Chat</Button> <Button variant="primary" size="small" className="min-w-auto" onClick={handleOpenApp}>
Chat
</Button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); )
} }
export default SharePage; export default SharePage

View File

@ -1,114 +1,109 @@
"use client" 'use client'
import { useParams } from "next/navigation"; import { useParams } from 'next/navigation'
import { useGetAIUserBaseInfo } from "@/hooks/aiUser"; import { useGetAIUserBaseInfo } from '@/hooks/aiUser'
import Image from "next/image"; import Image from 'next/image'
// 按钮组件 // 按钮组件
interface MobileButtonProps { interface MobileButtonProps {
showIcon?: boolean; showIcon?: boolean
icon?: React.ReactNode | null; icon?: React.ReactNode | null
btnTxt?: string; btnTxt?: string
showTxt?: boolean; showTxt?: boolean
size?: "Large" | "Medium" | "Small"; size?: 'Large' | 'Medium' | 'Small'
variant?: "Contrast" | "Basic" | "Ghost"; variant?: 'Contrast' | 'Basic' | 'Ghost'
type?: "Primary" | "Secondary" | "Tertiary" | "Destructive" | "VIP"; type?: 'Primary' | 'Secondary' | 'Tertiary' | 'Destructive' | 'VIP'
state?: "Default" | "Disabled" | "Pressed"; state?: 'Default' | 'Disabled' | 'Pressed'
onClick?: () => void; onClick?: () => void
} }
function MobileButton({ function MobileButton({
showIcon = true, showIcon = true,
icon = null, icon = null,
btnTxt = "Button", btnTxt = 'Button',
showTxt = true, showTxt = true,
size = "Large", size = 'Large',
variant = "Basic", variant = 'Basic',
type = "Primary", type = 'Primary',
state = "Default", state = 'Default',
onClick onClick,
}: MobileButtonProps) { }: MobileButtonProps) {
if (size === "Small" && variant === "Contrast" && type === "Tertiary" && state === "Default") { if (size === 'Small' && variant === 'Contrast' && type === 'Tertiary' && state === 'Default') {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className="bg-[rgba(0,0,0,0.65)] box-border content-stretch flex gap-1 items-center justify-center overflow-clip px-4 py-1.5 relative rounded-[999px] h-8 hover:bg-[rgba(0,0,0,0.8)] transition-colors" className="relative box-border flex h-8 content-stretch items-center justify-center gap-1 overflow-clip rounded-[999px] bg-[rgba(0,0,0,0.65)] px-4 py-1.5 transition-colors hover:bg-[rgba(0,0,0,0.8)]"
> >
{showIcon && ( {showIcon &&
icon || ( (icon || (
<div className="h-[15.999px] relative shrink-0 w-4"> <div className="relative h-[15.999px] w-4 shrink-0">
<div className="absolute aspect-[9.25554/12.1612] left-1/4 right-[17.15%] translate-y-[-50%]" style={{ top: "calc(50% + 0.065px)" }}> <div
className="absolute right-[17.15%] left-1/4 aspect-[9.25554/12.1612] translate-y-[-50%]"
style={{ top: 'calc(50% + 0.065px)' }}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"> <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M3 2l6 4-6 4V2z" fill="white"/> <path d="M3 2l6 4-6 4V2z" fill="white" />
</svg> </svg>
</div> </div>
</div> </div>
) ))}
)}
{showTxt && ( {showTxt && (
<div className="flex flex-col font-medium justify-center leading-[0] not-italic relative shrink-0 text-[12px] text-center text-nowrap text-white"> <div className="relative flex shrink-0 flex-col justify-center text-center text-[12px] leading-[0] font-medium text-nowrap text-white not-italic">
<p className="leading-[20px] whitespace-pre">{btnTxt}</p> <p className="leading-[20px] whitespace-pre">{btnTxt}</p>
</div> </div>
)} )}
</button> </button>
); )
} }
return ( return (
<button <button
onClick={onClick} onClick={onClick}
className="bg-[rgba(0,0,0,0.65)] box-border content-stretch flex gap-1 items-center justify-center overflow-clip px-4 py-1.5 relative rounded-[999px] h-8 hover:bg-[rgba(0,0,0,0.8)] transition-colors" className="relative box-border flex h-8 content-stretch items-center justify-center gap-1 overflow-clip rounded-[999px] bg-[rgba(0,0,0,0.65)] px-4 py-1.5 transition-colors hover:bg-[rgba(0,0,0,0.8)]"
> >
{showIcon && icon} {showIcon && icon}
{showTxt && ( {showTxt && <span className="text-[12px] font-medium text-white">{btnTxt}</span>}
<span className="font-medium text-[12px] text-white">{btnTxt}</span>
)}
</button> </button>
); )
} }
const SharePage = () => { const SharePage = () => {
const { userId } = useParams(); const { userId } = useParams()
const { data: userInfo } = useGetAIUserBaseInfo({ const { data: userInfo } = useGetAIUserBaseInfo({
aiId: userId ? Number(userId) : 0 aiId: userId ? Number(userId) : 0,
}); })
const { homeImageUrl } = userInfo || {}; const { homeImageUrl } = userInfo || {}
const handleChatClick = () => { const handleChatClick = () => {
// 跳转到应用或下载页面 // 跳转到应用或下载页面
window.open('https://crushlevel.com/download', '_blank'); window.open('https://crushlevel.com/download', '_blank')
}; }
return ( return (
<div className="relative h-screen overflow-hidden max-w-[750px] mx-auto"> <div className="relative mx-auto h-screen max-w-[750px] overflow-hidden">
{/* 背景图片 */} {/* 背景图片 */}
<div <div
className="absolute inset-0 overflow-clip" className="absolute inset-0 overflow-clip"
style={{ style={{
background: "linear-gradient(180deg, #211A2B 0%, rgba(33, 26, 43, 0.00) 20%, rgba(33, 26, 43, 0.00) 50%, #211A2B 100%)" background:
'linear-gradient(180deg, #211A2B 0%, rgba(33, 26, 43, 0.00) 20%, rgba(33, 26, 43, 0.00) 50%, #211A2B 100%)',
}} }}
> >
<div className="absolute inset-0"> <div className="absolute inset-0">
<Image <Image src={homeImageUrl || ''} alt="Background" fill className="object-cover" priority />
src={homeImageUrl || ''}
alt="Background"
fill
className="object-cover"
priority
/>
</div> </div>
<div className="absolute inset-0 bg-black/20" /> <div className="absolute inset-0 bg-black/20" />
</div> </div>
{/* 内容容器 */} {/* 内容容器 */}
<div className="relative z-10 box-border content-stretch flex flex-col items-start justify-start pb-0 pt-4 px-0 min-h-screen"> <div className="relative z-10 box-border flex min-h-screen flex-col content-stretch items-start justify-start px-0 pt-4 pb-0">
{/* 头部用户信息 */} {/* 头部用户信息 */}
<div className="box-border content-stretch flex items-start justify-start overflow-clip px-6 py-0 relative shrink-0 w-full"> <div className="relative box-border flex w-full shrink-0 content-stretch items-start justify-start overflow-clip px-6 py-0">
<div className="basis-0 content-stretch flex gap-2 grow items-center justify-start min-h-px min-w-px relative self-stretch shrink-0"> <div className="relative flex min-h-px min-w-px shrink-0 grow basis-0 content-stretch items-center justify-start gap-2 self-stretch">
{/* 用户头像 */} {/* 用户头像 */}
<div className="overflow-clip relative rounded-[99px] shrink-0 size-10"> <div className="relative size-10 shrink-0 overflow-clip rounded-[99px]">
{userInfo?.headImg ? ( {userInfo?.headImg ? (
<Image <Image
src={userInfo.headImg} src={userInfo.headImg}
@ -117,63 +112,73 @@ const SharePage = () => {
className="object-cover" className="object-cover"
/> />
) : ( ) : (
<div className="w-full h-full bg-gradient-to-br from-purple-400 to-pink-400 flex items-center justify-center"> <div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-purple-400 to-pink-400">
<span className="text-white text-lg font-bold"> <span className="text-lg font-bold text-white">
{userInfo?.nickname?.charAt(0) || 'U'} {userInfo?.nickname?.charAt(0) || 'U'}
</span> </span>
</div> </div>
)} )}
</div> </div>
{/* 用户名和点赞数 */} {/* 用户名和点赞数 */}
<div className="content-stretch flex flex-col items-start justify-start leading-[0] not-italic relative shrink-0 text-center text-nowrap text-white"> <div className="relative flex shrink-0 flex-col content-stretch items-start justify-start text-center leading-[0] text-nowrap text-white not-italic">
<div className="font-semibold relative shrink-0 text-[20px]"> <div className="relative shrink-0 text-[20px] font-semibold">
<p className="leading-[24px] text-nowrap whitespace-pre"> <p className="leading-[24px] text-nowrap whitespace-pre">
{userInfo?.nickname || 'Loading...'} {userInfo?.nickname || 'Loading...'}
</p> </p>
</div> </div>
<div className="font-medium relative shrink-0 text-[12px]"> <div className="relative shrink-0 text-[12px] font-medium">
<p className="leading-[20px] text-nowrap whitespace-pre"> <p className="leading-[20px] text-nowrap whitespace-pre">0 likes</p>
0 likes
</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* 主要内容区域 */} {/* 主要内容区域 */}
<div className="basis-0 content-stretch flex flex-col grow items-start justify-end min-h-px min-w-px relative shrink-0 w-full"> <div className="relative flex min-h-px w-full min-w-px shrink-0 grow basis-0 flex-col content-stretch items-start justify-end">
{/* 消息内容 */} {/* 消息内容 */}
<div className="grid-cols-[max-content] grid-rows-[max-content] inline-grid leading-[0] place-items-start relative shrink-0 px-6"> <div className="relative inline-grid shrink-0 grid-cols-[max-content] grid-rows-[max-content] place-items-start px-6 leading-[0]">
{/* AI信息卡片 */} {/* AI信息卡片 */}
<div className="box-border content-stretch flex flex-col gap-1 items-start justify-start px-0 py-2 relative shrink-0 w-full"> <div className="relative box-border flex w-full shrink-0 flex-col content-stretch items-start justify-start gap-1 px-0 py-2">
<div className="backdrop-blur-[32px] backdrop-filter bg-[rgba(251,222,255,0.08)] box-border content-stretch flex flex-col gap-2 items-start justify-start p-[16px] relative rounded-[16px] shrink-0 w-full border border-[rgba(251,222,255,0.2)]"> <div className="relative box-border flex w-full shrink-0 flex-col content-stretch items-start justify-start gap-2 rounded-[16px] border border-[rgba(251,222,255,0.2)] bg-[rgba(251,222,255,0.08)] p-[16px] backdrop-blur-[32px] backdrop-filter">
{/* 介绍文本 */} {/* 介绍文本 */}
<div className="font-regular leading-[20px] overflow-hidden relative shrink-0 text-[14px] text-white w-full line-clamp-3"> <div className="font-regular relative line-clamp-3 w-full shrink-0 overflow-hidden text-[14px] leading-[20px] text-white">
<p> <p>
<span className="font-semibold">Intro: </span> <span className="font-semibold">Intro: </span>
<span>{userInfo?.introduction || 'This is an AI character with unique personality and charm. Start chatting to discover more about them!'}</span> <span>
{userInfo?.introduction ||
'This is an AI character with unique personality and charm. Start chatting to discover more about them!'}
</span>
</p> </p>
</div> </div>
{/* 标签 */} {/* 标签 */}
<div className="content-stretch flex gap-2 items-start justify-start relative shrink-0 w-full"> <div className="relative flex w-full shrink-0 content-stretch items-start justify-start gap-2">
{userInfo?.tagName ? ( {userInfo?.tagName ? (
userInfo.tagName.split(',').slice(0, 2).map((tag: string, index: number) => ( userInfo.tagName
<div key={index} className="backdrop-blur backdrop-filter box-border content-stretch flex gap-1 h-6 items-center justify-center min-w-6 overflow-clip px-2 py-0.5 relative rounded-[4px] shrink-0 bg-[#d21f77]/45"> .split(',')
<div className="font-medium leading-[20px] text-[12px] text-white"> .slice(0, 2)
{tag.trim()} .map((tag: string, index: number) => (
<div
key={index}
className="relative box-border flex h-6 min-w-6 shrink-0 content-stretch items-center justify-center gap-1 overflow-clip rounded-[4px] bg-[#d21f77]/45 px-2 py-0.5 backdrop-blur backdrop-filter"
>
<div className="text-[12px] leading-[20px] font-medium text-white">
{tag.trim()}
</div>
</div> </div>
</div> ))
))
) : ( ) : (
<> <>
<div className="backdrop-blur backdrop-filter box-border content-stretch flex gap-1 h-6 items-center justify-center min-w-6 overflow-clip px-2 py-0.5 relative rounded-[4px] shrink-0 bg-[#d21f77]/45"> <div className="relative box-border flex h-6 min-w-6 shrink-0 content-stretch items-center justify-center gap-1 overflow-clip rounded-[4px] bg-[#d21f77]/45 px-2 py-0.5 backdrop-blur backdrop-filter">
<div className="font-medium leading-[20px] text-[12px] text-white">Sensual</div> <div className="text-[12px] leading-[20px] font-medium text-white">
Sensual
</div>
</div> </div>
<div className="backdrop-blur backdrop-filter box-border content-stretch flex gap-1 h-6 items-center justify-center min-w-6 overflow-clip px-2 py-0.5 relative rounded-[4px] shrink-0 bg-[#d21f77]/45"> <div className="relative box-border flex h-6 min-w-6 shrink-0 content-stretch items-center justify-center gap-1 overflow-clip rounded-[4px] bg-[#d21f77]/45 px-2 py-0.5 backdrop-blur backdrop-filter">
<div className="font-medium leading-[20px] text-[12px] text-white">Romantic</div> <div className="text-[12px] leading-[20px] font-medium text-white">
Romantic
</div>
</div> </div>
</> </>
)} )}
@ -182,32 +187,34 @@ const SharePage = () => {
</div> </div>
{/* AI生成内容提示 */} {/* AI生成内容提示 */}
<div className="box-border content-stretch flex flex-col gap-1 items-center justify-start px-0 py-2 relative shrink-0 w-full"> <div className="relative box-border flex w-full shrink-0 flex-col content-stretch items-center justify-start gap-1 px-0 py-2">
<div className="backdrop-blur-[32px] backdrop-filter bg-[rgba(251,222,255,0.08)] box-border content-stretch flex gap-2 items-center justify-center px-2 py-1 relative rounded-[4px] shrink-0"> <div className="relative box-border flex shrink-0 content-stretch items-center justify-center gap-2 rounded-[4px] bg-[rgba(251,222,255,0.08)] px-2 py-1 backdrop-blur-[32px] backdrop-filter">
<div className="font-regular leading-[20px] text-[12px] text-center text-white"> <div className="font-regular text-center text-[12px] leading-[20px] text-white">
Content generated by AI Content generated by AI
</div> </div>
</div> </div>
</div> </div>
{/* 示例对话消息 */} {/* 示例对话消息 */}
<div className="box-border content-stretch flex gap-4 items-start justify-start pb-2 pl-0 pr-20 pt-4 relative shrink-0 w-full"> <div className="relative box-border flex w-full shrink-0 content-stretch items-start justify-start gap-4 pt-4 pr-20 pb-2 pl-0">
<div className="backdrop-blur-[32px] backdrop-filter basis-0 bg-[rgba(0,0,0,0.65)] box-border content-stretch flex gap-2.5 grow items-start justify-start min-h-px min-w-px pb-4 pt-5 px-4 relative rounded-[16px] shrink-0"> <div className="relative box-border flex min-h-px min-w-px shrink-0 grow basis-0 content-stretch items-start justify-start gap-2.5 rounded-[16px] bg-[rgba(0,0,0,0.65)] px-4 pt-5 pb-4 backdrop-blur-[32px] backdrop-filter">
{/* 语音标签 */} {/* 语音标签 */}
<div className="absolute bg-[#484151] box-border content-stretch flex gap-2 items-center justify-center left-0 overflow-clip px-3 py-1 rounded-br-[8px] rounded-tl-[8px] rounded-tr-[8px] top-[-12px]"> <div className="absolute top-[-12px] left-0 box-border flex content-stretch items-center justify-center gap-2 overflow-clip rounded-tl-[8px] rounded-tr-[8px] rounded-br-[8px] bg-[#484151] px-3 py-1">
<div className="relative shrink-0 size-3"> <div className="relative size-3 shrink-0">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none"> <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<path d="M3 2l6 4-6 4V2z" fill="white"/> <path d="M3 2l6 4-6 4V2z" fill="white" />
</svg> </svg>
</div> </div>
<div className="font-medium leading-[20px] text-[12px] text-nowrap text-white"> <div className="text-[12px] leading-[20px] font-medium text-nowrap text-white">
2'' 2''
</div> </div>
</div> </div>
{/* 消息内容 */} {/* 消息内容 */}
<div className="basis-0 font-regular grow leading-[20px] min-h-px min-w-px text-[14px] text-white"> <div className="font-regular min-h-px min-w-px grow basis-0 text-[14px] leading-[20px] text-white">
<span className="text-[#958e9e]">(Watching her parents toast you respectfully, I feel very sad.) </span> <span className="text-[#958e9e]">
(Watching her parents toast you respectfully, I feel very sad.){' '}
</span>
<span>Are you?</span> <span>Are you?</span>
</div> </div>
</div> </div>
@ -216,10 +223,10 @@ const SharePage = () => {
</div> </div>
{/* 底部品牌区域 */} {/* 底部品牌区域 */}
<div className="box-border content-stretch flex gap-1 items-start justify-start overflow-clip px-2 py-4 relative shrink-0 w-full"> <div className="relative box-border flex w-full shrink-0 content-stretch items-start justify-start gap-1 overflow-clip px-2 py-4">
<div className="basis-0 bg-gradient-to-r from-[#f264a4] to-[#c241e6] box-border content-stretch flex gap-3 grow items-center justify-start min-h-px min-w-px px-4 py-2 relative rounded-[16px] shrink-0"> <div className="relative box-border flex min-h-px min-w-px shrink-0 grow basis-0 content-stretch items-center justify-start gap-3 rounded-[16px] bg-gradient-to-r from-[#f264a4] to-[#c241e6] px-4 py-2">
{/* App图标 */} {/* App图标 */}
<div className="overflow-clip relative rounded-[12px] shrink-0 size-[24px]"> <div className="relative size-[24px] shrink-0 overflow-clip rounded-[12px]">
<Image <Image
src="/logo.svg" src="/logo.svg"
alt="Crushlevel Logo" alt="Crushlevel Logo"
@ -228,10 +235,10 @@ const SharePage = () => {
className="object-contain" className="object-contain"
/> />
</div> </div>
{/* 品牌信息 */} {/* 品牌信息 */}
<div className="basis-0 content-stretch flex flex-col grow items-start justify-start min-h-px min-w-px relative shrink-0"> <div className="relative flex min-h-px min-w-px shrink-0 grow basis-0 flex-col content-stretch items-start justify-start">
<div className="h-[14.422px] overflow-clip relative shrink-0"> <div className="relative h-[14.422px] shrink-0 overflow-clip">
<Image <Image
src="/logo.svg" src="/logo.svg"
alt="Crushlevel" alt="Crushlevel"
@ -240,17 +247,17 @@ const SharePage = () => {
className="object-contain" className="object-contain"
/> />
</div> </div>
<div className="font-medium leading-[20px] text-[12px] text-center text-nowrap text-white"> <div className="text-center text-[12px] leading-[20px] font-medium text-nowrap text-white">
Chat. Crush. AI Date Chat. Crush. AI Date
</div> </div>
</div> </div>
{/* Chat按钮 */} {/* Chat按钮 */}
<MobileButton <MobileButton
showIcon={false} showIcon={false}
btnTxt="Chat" btnTxt="Chat"
size="Small" size="Small"
variant="Contrast" variant="Contrast"
type="Tertiary" type="Tertiary"
onClick={handleChatClick} onClick={handleChatClick}
/> />
@ -258,7 +265,7 @@ const SharePage = () => {
</div> </div>
</div> </div>
</div> </div>
); )
} }
export default SharePage; export default SharePage

View File

@ -1,58 +1,64 @@
'use client'; 'use client'
import ChatMessageList from "./components/ChatMessageList"; import ChatMessageList from './components/ChatMessageList'
import ChatBackground from "./components/ChatBackground"; import ChatBackground from './components/ChatBackground'
import ChatMessageAction from "./components/ChatMessageAction"; import ChatMessageAction from './components/ChatMessageAction'
import ChatDrawers from "./components/ChatDrawers"; import ChatDrawers from './components/ChatDrawers'
import { ChatConfigProvider } from "./context/chatConfig"; import { ChatConfigProvider } from './context/chatConfig'
import { DrawerLayerProvider } from "./components/ChatDrawers/InlineDrawer"; import { DrawerLayerProvider } from './components/ChatDrawers/InlineDrawer'
import { IconButton } from "@/components/ui/button"; import { IconButton } from '@/components/ui/button'
import { useSetAtom } from "jotai"; import { useSetAtom } from 'jotai'
import { isChatProfileDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat"; import { isChatProfileDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat'
import ChatCall from "./components/ChatCall"; import ChatCall from './components/ChatCall'
import { Badge } from "@/components/ui/badge"; import { Badge } from '@/components/ui/badge'
import { RED_DOT_KEYS, useRedDot } from "@/hooks/useRedDot"; import { RED_DOT_KEYS, useRedDot } from '@/hooks/useRedDot'
import { useS3TokenCache } from "@/hooks/useS3TokenCache"; import { useS3TokenCache } from '@/hooks/useS3TokenCache'
import { BizTypeEnum } from "@/services/common/types"; import { BizTypeEnum } from '@/services/common/types'
import ChatFirstGuideDialog from "./components/ChatFirstGuideDialog"; import ChatFirstGuideDialog from './components/ChatFirstGuideDialog'
import CoinInsufficientDialog from "@/components/features/coin-insufficient-dialog"; import CoinInsufficientDialog from '@/components/features/coin-insufficient-dialog'
const ChatPage = () => { const ChatPage = () => {
const setDrawerState = useSetAtom(isChatProfileDrawerOpenAtom); const setDrawerState = useSetAtom(isChatProfileDrawerOpenAtom)
const setIsChatProfileDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open)); const setIsChatProfileDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const { hasRedDot } = useRedDot(); const { hasRedDot } = useRedDot()
// 预加载S3 Token提升上传速度 // 预加载S3 Token提升上传速度
useS3TokenCache({ useS3TokenCache({
preloadBizTypes: [BizTypeEnum.SOUND_PATH], preloadBizTypes: [BizTypeEnum.SOUND_PATH],
refreshBeforeExpireMinutes: 5 refreshBeforeExpireMinutes: 5,
}); })
const handleOpenChatProfileDrawer = () => { const handleOpenChatProfileDrawer = () => {
setIsChatProfileDrawerOpen(true); setIsChatProfileDrawerOpen(true)
}; }
const isShowRedDot = hasRedDot(RED_DOT_KEYS.CHAT_BACKGROUND) || hasRedDot(RED_DOT_KEYS.CHAT_BUBBLE); const isShowRedDot =
hasRedDot(RED_DOT_KEYS.CHAT_BACKGROUND) || hasRedDot(RED_DOT_KEYS.CHAT_BUBBLE)
return ( return (
<ChatConfigProvider> <ChatConfigProvider>
<div className="overflow-hidden flex"> <div className="flex overflow-hidden">
<div className="bg-background-default absolute inset-0" /> <div className="bg-background-default absolute inset-0" />
<div className="flex-1 relative transition-all border-t border-solid border-outline-normal"> <div className="border-outline-normal relative flex-1 border-t border-solid transition-all">
<ChatBackground /> <ChatBackground />
{/* 消息列表区域 */} {/* 消息列表区域 */}
<div className="relative h-[calc(100vh-64px)] flex flex-col px-6"> <div className="relative flex h-[calc(100vh-64px)] flex-col px-6">
<ChatMessageList /> <ChatMessageList />
<ChatMessageAction /> <ChatMessageAction />
<div className="absolute right-6 top-6 w-8 h-8"> <div className="absolute top-6 right-6 h-8 w-8">
<IconButton iconfont="icon-icon_chatroom_more" variant="ghost" size="small" onClick={handleOpenChatProfileDrawer} /> <IconButton
iconfont="icon-icon_chatroom_more"
variant="ghost"
size="small"
onClick={handleOpenChatProfileDrawer}
/>
{isShowRedDot && <Badge variant="dot" className="absolute top-0 right-0" />} {isShowRedDot && <Badge variant="dot" className="absolute top-0 right-0" />}
</div> </div>
</div> </div>
</div> </div>
<div className="relative transition-all border-t border-solid border-outline-normal"> <div className="border-outline-normal relative border-t border-solid transition-all">
<DrawerLayerProvider> <DrawerLayerProvider>
<ChatDrawers /> <ChatDrawers />
</DrawerLayerProvider> </DrawerLayerProvider>
@ -64,7 +70,7 @@ const ChatPage = () => {
<CoinInsufficientDialog /> <CoinInsufficientDialog />
</ChatConfigProvider> </ChatConfigProvider>
); )
} }
export default ChatPage; export default ChatPage

View File

@ -1,31 +1,52 @@
"use client"; 'use client'
import Image from 'next/image'; import Image from 'next/image'
import * as React from 'react'; import * as React from 'react'
import { useChatConfig } from '../context/chatConfig'; import { useChatConfig } from '../context/chatConfig'
const ChatBackground = () => { const ChatBackground = () => {
const { aiInfo } = useChatConfig(); const { aiInfo } = useChatConfig()
const { backgroundImg } = aiInfo || {}; const { backgroundImg } = aiInfo || {}
return ( return (
<div className="bg-background-default absolute left-0 right-0 top-0 bottom-0 overflow-hidden"> <div className="bg-background-default absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
<div className="absolute w-[752px] left-1/2 -translate-x-1/2 top-0 bottom-0"> <div className="absolute top-0 bottom-0 left-1/2 w-[752px] -translate-x-1/2">
{backgroundImg && <Image src={backgroundImg} alt="Background" className="object-cover h-full w-full pointer-events-none" width={720} height={1280} style={{ objectPosition: "center -48px" }} />} {backgroundImg && (
<Image
src={backgroundImg}
alt="Background"
className="pointer-events-none h-full w-full object-cover"
width={720}
height={1280}
style={{ objectPosition: 'center -48px' }}
/>
)}
{/* <div className="absolute h-full bottom-0 left-0 right-0 top-1/2 -translate-y-1/2 pointer-events-none min-h-[1280px]" style={{ background: 'radial-gradient(48.62% 48.62% at 50% 50%, rgba(33, 26, 43, 0.35) 0%, #211A2B 100%)' }} /> */} {/* <div className="absolute h-full bottom-0 left-0 right-0 top-1/2 -translate-y-1/2 pointer-events-none min-h-[1280px]" style={{ background: 'radial-gradient(48.62% 48.62% at 50% 50%, rgba(33, 26, 43, 0.35) 0%, #211A2B 100%)' }} /> */}
{/* todo */} {/* todo */}
<div className="absolute top-0 bottom-0 left-0 w-[240px]" style={{ background: "linear-gradient(-90deg, rgba(33, 26, 43, 0) 0%, rgba(33, 26, 43, 0.00838519) 11.9%, rgba(33, 26, 43, 0.0324148) 21.59%, rgba(33, 26, 43, 0.0704) 29.39%, rgba(33, 26, 43, 0.120652) 35.67%, rgba(33, 26, 43, 0.181481) 40.75%, rgba(33, 26, 43, 0.2512) 44.98%, rgba(33, 26, 43, 0.328119) 48.7%, rgba(33, 26, 43, 0.410548) 52.25%, rgba(33, 26, 43, 0.4968) 55.96%, rgba(33, 26, 43, 0.585185) 60.19%, rgba(33, 26, 43, 0.674015) 65.27%, rgba(33, 26, 43, 0.7616) 71.55%, rgba(33, 26, 43, 0.846252) 79.36%, rgba(33, 26, 43, 0.926281) 89.04%, #211A2B 100.94%)"}} /> <div
<div className="absolute top-0 bottom-0 right-0 w-[240px]" style={{ background: "linear-gradient(90deg, rgba(33, 26, 43, 0) 0%, rgba(33, 26, 43, 0.00838519) 12.01%, rgba(33, 26, 43, 0.0324148) 21.79%, rgba(33, 26, 43, 0.0704) 29.67%, rgba(33, 26, 43, 0.120652) 36%, rgba(33, 26, 43, 0.181481) 41.13%, rgba(33, 26, 43, 0.2512) 45.4%, rgba(33, 26, 43, 0.328119) 49.15%, rgba(33, 26, 43, 0.410548) 52.73%, rgba(33, 26, 43, 0.4968) 56.49%, rgba(33, 26, 43, 0.585185) 60.75%, rgba(33, 26, 43, 0.674015) 65.88%, rgba(33, 26, 43, 0.7616) 72.22%, rgba(33, 26, 43, 0.846252) 80.1%, rgba(33, 26, 43, 0.926281) 89.87%, #211A2B 101.89%)"}} /> className="absolute top-0 bottom-0 left-0 w-[240px]"
style={{
background:
'linear-gradient(-90deg, rgba(33, 26, 43, 0) 0%, rgba(33, 26, 43, 0.00838519) 11.9%, rgba(33, 26, 43, 0.0324148) 21.59%, rgba(33, 26, 43, 0.0704) 29.39%, rgba(33, 26, 43, 0.120652) 35.67%, rgba(33, 26, 43, 0.181481) 40.75%, rgba(33, 26, 43, 0.2512) 44.98%, rgba(33, 26, 43, 0.328119) 48.7%, rgba(33, 26, 43, 0.410548) 52.25%, rgba(33, 26, 43, 0.4968) 55.96%, rgba(33, 26, 43, 0.585185) 60.19%, rgba(33, 26, 43, 0.674015) 65.27%, rgba(33, 26, 43, 0.7616) 71.55%, rgba(33, 26, 43, 0.846252) 79.36%, rgba(33, 26, 43, 0.926281) 89.04%, #211A2B 100.94%)',
}}
/>
<div
className="absolute top-0 right-0 bottom-0 w-[240px]"
style={{
background:
'linear-gradient(90deg, rgba(33, 26, 43, 0) 0%, rgba(33, 26, 43, 0.00838519) 12.01%, rgba(33, 26, 43, 0.0324148) 21.79%, rgba(33, 26, 43, 0.0704) 29.67%, rgba(33, 26, 43, 0.120652) 36%, rgba(33, 26, 43, 0.181481) 41.13%, rgba(33, 26, 43, 0.2512) 45.4%, rgba(33, 26, 43, 0.328119) 49.15%, rgba(33, 26, 43, 0.410548) 52.73%, rgba(33, 26, 43, 0.4968) 56.49%, rgba(33, 26, 43, 0.585185) 60.75%, rgba(33, 26, 43, 0.674015) 65.88%, rgba(33, 26, 43, 0.7616) 72.22%, rgba(33, 26, 43, 0.846252) 80.1%, rgba(33, 26, 43, 0.926281) 89.87%, #211A2B 101.89%)',
}}
/>
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
background: "radial-gradient(157.27% 64.69% at 50% 37.71%, rgba(33, 26, 43, 0.35) 0%, rgba(33, 26, 43, 0.35545) 11.79%, rgba(33, 26, 43, 0.37107) 21.38%, rgba(33, 26, 43, 0.39576) 29.12%, rgba(33, 26, 43, 0.428424) 35.34%, rgba(33, 26, 43, 0.467963) 40.37%, rgba(33, 26, 43, 0.51328) 44.56%, rgba(33, 26, 43, 0.563277) 48.24%, rgba(33, 26, 43, 0.616856) 51.76%, rgba(33, 26, 43, 0.67292) 55.44%, rgba(33, 26, 43, 0.73037) 59.63%, rgba(33, 26, 43, 0.78811) 64.66%, rgba(33, 26, 43, 0.84504) 70.88%, rgba(33, 26, 43, 0.900064) 78.62%, rgba(33, 26, 43, 0.952083) 88.21%, #211A2B 100%)" background:
'radial-gradient(157.27% 64.69% at 50% 37.71%, rgba(33, 26, 43, 0.35) 0%, rgba(33, 26, 43, 0.35545) 11.79%, rgba(33, 26, 43, 0.37107) 21.38%, rgba(33, 26, 43, 0.39576) 29.12%, rgba(33, 26, 43, 0.428424) 35.34%, rgba(33, 26, 43, 0.467963) 40.37%, rgba(33, 26, 43, 0.51328) 44.56%, rgba(33, 26, 43, 0.563277) 48.24%, rgba(33, 26, 43, 0.616856) 51.76%, rgba(33, 26, 43, 0.67292) 55.44%, rgba(33, 26, 43, 0.73037) 59.63%, rgba(33, 26, 43, 0.78811) 64.66%, rgba(33, 26, 43, 0.84504) 70.88%, rgba(33, 26, 43, 0.900064) 78.62%, rgba(33, 26, 43, 0.952083) 88.21%, #211A2B 100%)',
}} }}
> ></div>
</div>
</div> </div>
</div> </div>
); )
} }
export default ChatBackground; export default ChatBackground

View File

@ -1,81 +1,76 @@
"use client"; 'use client'
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button'
import { SubtitleState } from "./ChatCallContainer"; import { SubtitleState } from './ChatCallContainer'
import { useAtomValue } from "jotai"; import { useAtomValue } from 'jotai'
import { hasReceivedAiGreetingAtom } from "@/atoms/im"; import { hasReceivedAiGreetingAtom } from '@/atoms/im'
import { VoiceWaveAnimation } from "@/components/ui/voice-wave-animation"; import { VoiceWaveAnimation } from '@/components/ui/voice-wave-animation'
const ChatCallStatus = ({ const ChatCallStatus = ({
isConnected, isConnected,
subtitleState, subtitleState,
onInterrupt, onInterrupt,
}: { }: {
isConnected: boolean; isConnected: boolean
subtitleState: SubtitleState; subtitleState: SubtitleState
onInterrupt?: () => void; onInterrupt?: () => void
}) => { }) => {
const hasReceivedAiGreeting = useAtomValue(hasReceivedAiGreetingAtom); const hasReceivedAiGreeting = useAtomValue(hasReceivedAiGreetingAtom)
const renderAction = () => { const renderAction = () => {
if (!subtitleState.hideInterrupt) { if (!subtitleState.hideInterrupt) {
return ( return (
<Button variant="tertiary" size="large" onClick={onInterrupt}>Click to interrupt</Button> <Button variant="tertiary" size="large" onClick={onInterrupt}>
Click to interrupt
</Button>
) )
} }
if (subtitleState.isAiThinking) { if (subtitleState.isAiThinking) {
return ( return (
<div className="flex flex-col gap-6 items-center justify-center"> <div className="flex flex-col items-center justify-center gap-6">
{/* 三个圆点动画 */} {/* 三个圆点动画 */}
<div className="flex gap-2 items-center justify-center"> <div className="flex items-center justify-center gap-2">
<div className="w-3 h-3 bg-white rounded-full animate-calling-dots-1" /> <div className="animate-calling-dots-1 h-3 w-3 rounded-full bg-white" />
<div className="w-4 h-4 bg-white rounded-full animate-calling-dots-2" /> <div className="animate-calling-dots-2 h-4 w-4 rounded-full bg-white" />
<div className="w-3 h-3 bg-white rounded-full animate-calling-dots-3" /> <div className="animate-calling-dots-3 h-3 w-3 rounded-full bg-white" />
</div>
<div className="txt-label-m text-txt-secondary-normal text-center">
Thinking...
</div> </div>
<div className="txt-label-m text-txt-secondary-normal text-center">Thinking...</div>
</div> </div>
) )
} }
return ( return (
<div className="flex flex-col justify-center gap-3"> <div className="flex flex-col justify-center gap-3">
<VoiceWaveAnimation animated={subtitleState.isUserSpeaking} barCount={22} /> <VoiceWaveAnimation animated={subtitleState.isUserSpeaking} barCount={22} />
<div className="text-center txt-label-m text-txt-secondary-normal"> <div className="txt-label-m text-txt-secondary-normal text-center">Listening...</div>
Listening...
</div>
</div> </div>
); )
} }
if (!hasReceivedAiGreeting) { if (!hasReceivedAiGreeting) {
return ( return (
<div className="flex flex-col gap-6 items-center justify-center"> <div className="flex flex-col items-center justify-center gap-6">
{/* 三个圆点动画 */} {/* 三个圆点动画 */}
<div className="flex gap-2 items-center justify-center"> <div className="flex items-center justify-center gap-2">
<div className="w-3 h-3 bg-white rounded-full animate-calling-dots-1" /> <div className="animate-calling-dots-1 h-3 w-3 rounded-full bg-white" />
<div className="w-4 h-4 bg-white rounded-full animate-calling-dots-2" /> <div className="animate-calling-dots-2 h-4 w-4 rounded-full bg-white" />
<div className="w-3 h-3 bg-white rounded-full animate-calling-dots-3" /> <div className="animate-calling-dots-3 h-3 w-3 rounded-full bg-white" />
</div> </div>
<div className="txt-label-m text-txt-secondary-normal text-center"> <div className="txt-label-m text-txt-secondary-normal text-center">
Waiting to be connected Waiting to be connected
</div> </div>
</div> </div>
); )
} }
return ( return (
<div className="content-stretch flex flex-col gap-4 items-center justify-between relative size-full min-h-[220px] my-4"> <div className="relative my-4 flex size-full min-h-[220px] flex-col content-stretch items-center justify-between gap-4">
<div className="txt-body-l text-center max-w-[60vw]">{subtitleState.aiSubtitle}</div> <div className="txt-body-l max-w-[60vw] text-center">{subtitleState.aiSubtitle}</div>
{renderAction()} {renderAction()}
</div> </div>
) )
// if (subtitleState.isAiSpeaking) { // if (subtitleState.isAiSpeaking) {
// return ( // return (
// <div className="content-stretch flex flex-col gap-4 items-center justify-center relative size-full"> // <div className="content-stretch flex flex-col gap-4 items-center justify-center relative size-full">
@ -87,13 +82,13 @@ const ChatCallStatus = ({
// {(() => { // {(() => {
// const subtitle = subtitleState.aiSubtitle; // const subtitle = subtitleState.aiSubtitle;
// const thoughtMatch = subtitle?.match(/^[\(](.*?)[\)]\s*(.*)$/); // const thoughtMatch = subtitle?.match(/^[\(](.*?)[\)]\s*(.*)$/);
// if (thoughtMatch) { // if (thoughtMatch) {
// const [, thought, speech] = thoughtMatch; // const [, thought, speech] = thoughtMatch;
// return ( // return (
// <> // <>
// <span className="txt-body-l text-txt-secondary-normal"> // <span className="txt-body-l text-txt-secondary-normal">
// ({thought}) // ({thought})
// </span> // </span>
// <span className="txt-body-l text-txt-primary-normal"> // <span className="txt-body-l text-txt-primary-normal">
// {speech} // {speech}
@ -111,7 +106,7 @@ const ChatCallStatus = ({
// </p> // </p>
// </div> // </div>
// </div> // </div>
// {/* 打断按钮 */} // {/* 打断按钮 */}
// {!subtitleState.hideInterrupt && <Button variant="tertiary" size="large" onClick={onInterrupt}>Click to interrupt</Button>} // {!subtitleState.hideInterrupt && <Button variant="tertiary" size="large" onClick={onInterrupt}>Click to interrupt</Button>}
// </div> // </div>
@ -138,5 +133,5 @@ const ChatCallStatus = ({
// </div> // </div>
// ) // )
} }
export default ChatCallStatus; export default ChatCallStatus

View File

@ -1,16 +1,21 @@
"use client"; 'use client'
import { Button } from "@/components/ui/button"; import { Button } from '@/components/ui/button'
import { useDoRtcOperation } from "@/hooks/useIm"; import { useDoRtcOperation } from '@/hooks/useIm'
import { useChatConfig } from "../../context/chatConfig"; import { useChatConfig } from '../../context/chatConfig'
import { RtcOperation } from "@/services/im"; import { RtcOperation } from '@/services/im'
import React, { useState } from "react"; import React, { useState } from 'react'
import { useNimChat, useNimMsgContext } from "@/context/NimChat/useNimChat"; import { useNimChat, useNimMsgContext } from '@/context/NimChat/useNimChat'
import { CustomMessageType } from "@/types/im"; import { CustomMessageType } from '@/types/im'
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { hasReceivedAiGreetingAtom, hasStartAICallAtom, isCallAtom, selectedConversationIdAtom } from "@/atoms/im"; import {
import { useQueryClient } from "@tanstack/react-query"; hasReceivedAiGreetingAtom,
import { walletKeys } from "@/lib/query-keys"; hasStartAICallAtom,
isCallAtom,
selectedConversationIdAtom,
} from '@/atoms/im'
import { useQueryClient } from '@tanstack/react-query'
import { walletKeys } from '@/lib/query-keys'
const ChatEndButton = ({ const ChatEndButton = ({
roomId, roomId,
@ -19,27 +24,27 @@ const ChatEndButton = ({
callStartTime, callStartTime,
abortController, abortController,
}: { }: {
roomId: string; roomId: string
taskId: string; taskId: string
onLeave: () => Promise<void>; onLeave: () => Promise<void>
callStartTime: number | null; callStartTime: number | null
abortController: AbortController | null; abortController: AbortController | null
}) => { }) => {
const { aiId, handleUserMessage } = useChatConfig(); const { aiId, handleUserMessage } = useChatConfig()
const { mutateAsync: doRtcOperation } = useDoRtcOperation(); const { mutateAsync: doRtcOperation } = useDoRtcOperation()
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false)
const { nim } = useNimChat(); const { nim } = useNimChat()
const { sendMessageActive } = useNimMsgContext(); const { sendMessageActive } = useNimMsgContext()
const selectedConversationId = useAtomValue(selectedConversationIdAtom); const selectedConversationId = useAtomValue(selectedConversationIdAtom)
const hasReceivedAiGreeting = useAtomValue(hasReceivedAiGreetingAtom); const hasReceivedAiGreeting = useAtomValue(hasReceivedAiGreetingAtom)
const setIsCall = useSetAtom(isCallAtom); const setIsCall = useSetAtom(isCallAtom)
const [hasStartAICall, setHasStartAICall] = useAtom(hasStartAICallAtom); const [hasStartAICall, setHasStartAICall] = useAtom(hasStartAICallAtom)
const queryClient = useQueryClient(); const queryClient = useQueryClient()
const handleEndCall = async () => { const handleEndCall = async () => {
try { try {
setLoading(true); setLoading(true)
const duration = Date.now() - (callStartTime || 0); const duration = Date.now() - (callStartTime || 0)
// 如果已开始AI通话则停止AI通话 // 如果已开始AI通话则停止AI通话
try { try {
if (hasStartAICall) { if (hasStartAICall) {
@ -50,8 +55,8 @@ const ChatEndButton = ({
aiId: aiId, aiId: aiId,
taskId: taskId, taskId: taskId,
duration, duration,
} },
}); })
} else { } else {
await doRtcOperation({ await doRtcOperation({
data: { data: {
@ -60,49 +65,54 @@ const ChatEndButton = ({
aiId: aiId, aiId: aiId,
taskId: taskId, taskId: taskId,
duration, duration,
} },
}); })
} }
setHasStartAICall(false); setHasStartAICall(false)
} catch (error) { } catch (error) {
setHasStartAICall(false); setHasStartAICall(false)
} }
await onLeave(); await onLeave()
if (!hasReceivedAiGreeting) { if (!hasReceivedAiGreeting) {
const text = 'Call Canceled'; const text = 'Call Canceled'
const msg = nim.V2NIMMessageCreator.createCustomMessage( const msg = nim.V2NIMMessageCreator.createCustomMessage(
text, text,
JSON.stringify({ JSON.stringify({
type: CustomMessageType.CALL_CANCEL, type: CustomMessageType.CALL_CANCEL,
duration: Date.now() - (callStartTime || 0), duration: Date.now() - (callStartTime || 0),
}) })
); )
sendMessageActive({ sendMessageActive({
msg, msg,
conversationId: selectedConversationId || '', conversationId: selectedConversationId || '',
isLoading: false, isLoading: false,
}); })
// 通知用户发送了消息,重置自动聊天定时器 // 通知用户发送了消息,重置自动聊天定时器
handleUserMessage(); handleUserMessage()
} }
setIsCall(false); setIsCall(false)
await queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() }); await queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() })
} catch (error) { } catch (error) {
console.log(error); console.log(error)
} finally { } finally {
setLoading(false); setLoading(false)
} }
} }
return ( return (
<Button size="large" className="min-w-[80px]" variant="destructive" loading={loading} onClick={handleEndCall}> <Button
size="large"
className="min-w-[80px]"
variant="destructive"
loading={loading}
onClick={handleEndCall}
>
<i className="iconfont icon-hang-up !text-[24px]" /> <i className="iconfont icon-hang-up !text-[24px]" />
</Button> </Button>
); )
} }
export default ChatEndButton; export default ChatEndButton

View File

@ -1,35 +1,35 @@
"use client" 'use client'
import React from "react"; import React from 'react'
import RtcClient from "./rtc-client"; import RtcClient from './rtc-client'
import { LocalAudioPropertiesInfo } from "@byteplus/rtc"; import { LocalAudioPropertiesInfo } from '@byteplus/rtc'
interface IProps { interface IProps {
onRef: (ref: any) => void; onRef: (ref: any) => void
config: any; config: any
streamOptions: any; streamOptions: any
handleUserPublishStream: any; handleUserPublishStream: any
handleUserUnpublishStream: any; handleUserUnpublishStream: any
handleUserStartVideoCapture?: any; handleUserStartVideoCapture?: any
handleUserStopVideoCapture?: any; handleUserStopVideoCapture?: any
handleUserJoin: any; handleUserJoin: any
handleUserLeave: any; handleUserLeave: any
handleAutoPlayFail: any; handleAutoPlayFail: any
handleEventError: any; handleEventError: any
handlePlayerEvent: any; handlePlayerEvent: any
handleRoomBinaryMessageReceived: any; handleRoomBinaryMessageReceived: any
handleLocalAudioPropertiesReport: (event: LocalAudioPropertiesInfo[]) => void; handleLocalAudioPropertiesReport: (event: LocalAudioPropertiesInfo[]) => void
} }
export default class RtcComponent extends React.Component<IProps, any> { export default class RtcComponent extends React.Component<IProps, any> {
rtc: RtcClient; rtc: RtcClient
constructor(props: IProps) { constructor(props: IProps) {
super(props); super(props)
this.rtc = new RtcClient(props); this.rtc = new RtcClient(props)
} }
componentDidMount() { componentDidMount() {
this.props.onRef(this.rtc); this.props.onRef(this.rtc)
} }
render() { render() {
return <></>; return <></>
} }
} }

View File

@ -1,15 +1,15 @@
"use client"; 'use client'
import { isCallAtom } from "@/atoms/im"; import { isCallAtom } from '@/atoms/im'
import ChatCallContainer from "./ChatCallContainer"; import ChatCallContainer from './ChatCallContainer'
import { useAtomValue } from "jotai"; import { useAtomValue } from 'jotai'
const ChatCall = () => { const ChatCall = () => {
const isCall = useAtomValue(isCallAtom); const isCall = useAtomValue(isCallAtom)
if (!isCall) return null; if (!isCall) return null
return <ChatCallContainer />; return <ChatCallContainer />
} }
export default ChatCall; export default ChatCall

View File

@ -1,62 +1,62 @@
import VERTC, { MediaType, RoomMode, StreamIndex } from '@byteplus/rtc'; import VERTC, { MediaType, RoomMode, StreamIndex } from '@byteplus/rtc'
export default class RtcClient { export default class RtcClient {
constructor(props) { constructor(props) {
this.config = props.config; this.config = props.config
this.streamOptions = props.streamOptions; this.streamOptions = props.streamOptions
this.engine = VERTC.createEngine(props.config.appId); this.engine = VERTC.createEngine(props.config.appId)
this.handleUserPublishStream = props.handleUserPublishStream; this.handleUserPublishStream = props.handleUserPublishStream
this.handleUserUnpublishStream = props.handleUserUnpublishStream; this.handleUserUnpublishStream = props.handleUserUnpublishStream
// this.handleUserStartVideoCapture = props.handleUserStartVideoCapture; // this.handleUserStartVideoCapture = props.handleUserStartVideoCapture;
// this.handleUserStopVideoCapture = props.handleUserStopVideoCapture; // this.handleUserStopVideoCapture = props.handleUserStopVideoCapture;
this.handleEventError = props.handleEventError; this.handleEventError = props.handleEventError
this.setRemoteVideoPlayer = this.setRemoteVideoPlayer.bind(this); this.setRemoteVideoPlayer = this.setRemoteVideoPlayer.bind(this)
this.handleUserJoin = props.handleUserJoin; this.handleUserJoin = props.handleUserJoin
this.handleUserLeave = props.handleUserLeave; this.handleUserLeave = props.handleUserLeave
this.handleAutoPlayFail = props.handleAutoPlayFail; this.handleAutoPlayFail = props.handleAutoPlayFail
this.handlePlayerEvent = props.handlePlayerEvent; this.handlePlayerEvent = props.handlePlayerEvent
this.handleRoomBinaryMessageReceived = props.handleRoomBinaryMessageReceived; this.handleRoomBinaryMessageReceived = props.handleRoomBinaryMessageReceived
this.handleLocalAudioPropertiesReport = props.handleLocalAudioPropertiesReport; this.handleLocalAudioPropertiesReport = props.handleLocalAudioPropertiesReport
this.bindEngineEvents(); this.bindEngineEvents()
} }
SDKVERSION = VERTC.getSdkVersion(); SDKVERSION = VERTC.getSdkVersion()
bindEngineEvents() { bindEngineEvents() {
this.engine.on(VERTC.events.onUserPublishStream, this.handleUserPublishStream); this.engine.on(VERTC.events.onUserPublishStream, this.handleUserPublishStream)
this.engine.on(VERTC.events.onUserUnpublishStream, this.handleUserUnpublishStream); this.engine.on(VERTC.events.onUserUnpublishStream, this.handleUserUnpublishStream)
// this.engine.on(VERTC.events.onUserStartVideoCapture, this.handleUserStartVideoCapture); // this.engine.on(VERTC.events.onUserStartVideoCapture, this.handleUserStartVideoCapture);
// this.engine.on(VERTC.events.onUserStopVideoCapture, this.handleUserStopVideoCapture); // this.engine.on(VERTC.events.onUserStopVideoCapture, this.handleUserStopVideoCapture);
this.engine.on(VERTC.events.onUserJoined, this.handleUserJoin); this.engine.on(VERTC.events.onUserJoined, this.handleUserJoin)
this.engine.on(VERTC.events.onUserLeave, this.handleUserLeave); this.engine.on(VERTC.events.onUserLeave, this.handleUserLeave)
this.engine.on(VERTC.events.onAutoplayFailed, (events) => { this.engine.on(VERTC.events.onAutoplayFailed, (events) => {
console.log('VERTC.events.onAutoplayFailed', events.userId); console.log('VERTC.events.onAutoplayFailed', events.userId)
this.handleAutoPlayFail(events); this.handleAutoPlayFail(events)
}); })
this.engine.on(VERTC.events.onPlayerEvent, this.handlePlayerEvent); this.engine.on(VERTC.events.onPlayerEvent, this.handlePlayerEvent)
this.engine.on(VERTC.events.onError, (e) => this.handleEventError(e, VERTC)); this.engine.on(VERTC.events.onError, (e) => this.handleEventError(e, VERTC))
this.engine.on(VERTC.events.onRoomBinaryMessageReceived, this.handleRoomBinaryMessageReceived); this.engine.on(VERTC.events.onRoomBinaryMessageReceived, this.handleRoomBinaryMessageReceived)
this.engine.on(VERTC.events.onLocalAudioPropertiesReport, this.handleLocalAudioPropertiesReport); this.engine.on(VERTC.events.onLocalAudioPropertiesReport, this.handleLocalAudioPropertiesReport)
} }
async setRemoteVideoPlayer(remoteUserId, domId) { async setRemoteVideoPlayer(remoteUserId, domId) {
await this.engine.subscribeStream(remoteUserId, MediaType.AUDIO_AND_VIDEO); await this.engine.subscribeStream(remoteUserId, MediaType.AUDIO_AND_VIDEO)
await this.engine.setRemoteVideoPlayer(StreamIndex.STREAM_INDEX_MAIN, { await this.engine.setRemoteVideoPlayer(StreamIndex.STREAM_INDEX_MAIN, {
userId: remoteUserId, userId: remoteUserId,
renderDom: domId, renderDom: domId,
}); })
} }
/** /**
* remove the listeners when `createEngine` * remove the listeners when `createEngine`
*/ */
removeEventListener() { removeEventListener() {
this.engine.off(VERTC.events.onUserPublishStream, this.handleStreamAdd); this.engine.off(VERTC.events.onUserPublishStream, this.handleStreamAdd)
this.engine.off(VERTC.events.onUserUnpublishStream, this.handleStreamRemove); this.engine.off(VERTC.events.onUserUnpublishStream, this.handleStreamRemove)
// this.engine.off(VERTC.events.onUserStartVideoCapture, this.handleUserStartVideoCapture); // this.engine.off(VERTC.events.onUserStartVideoCapture, this.handleUserStartVideoCapture);
// this.engine.off(VERTC.events.onUserStopVideoCapture, this.handleUserStopVideoCapture); // this.engine.off(VERTC.events.onUserStopVideoCapture, this.handleUserStopVideoCapture);
this.engine.off(VERTC.events.onUserJoined, this.handleUserJoin); this.engine.off(VERTC.events.onUserJoined, this.handleUserJoin)
this.engine.off(VERTC.events.onUserLeave, this.handleUserLeave); this.engine.off(VERTC.events.onUserLeave, this.handleUserLeave)
this.engine.off(VERTC.events.onAutoplayFailed, this.handleAutoPlayFail); this.engine.off(VERTC.events.onAutoplayFailed, this.handleAutoPlayFail)
this.engine.off(VERTC.events.onPlayerEvent, this.handlePlayerEvent); this.engine.off(VERTC.events.onPlayerEvent, this.handlePlayerEvent)
this.engine.off(VERTC.events.onError, this.handleEventError); this.engine.off(VERTC.events.onError, this.handleEventError)
} }
join(token, roomId, uid) { join(token, roomId, uid) {
return this.engine.joinRoom( return this.engine.joinRoom(
@ -71,29 +71,29 @@ export default class RtcClient {
isAutoSubscribeVideo: false, isAutoSubscribeVideo: false,
roomMode: RoomMode.RTC, roomMode: RoomMode.RTC,
} }
); )
} }
/** /**
* get the devices * get the devices
* @returns * @returns
*/ */
async getDevices() { async getDevices() {
const devices = await VERTC.enumerateAudioCaptureDevices(); const devices = await VERTC.enumerateAudioCaptureDevices()
return { return {
audioInputs: devices, audioInputs: devices,
}; }
} }
/** /**
* create the local stream with the config and publish the local stream * create the local stream with the config and publish the local stream
* @param {*} callback * @param {*} callback
*/ */
async createLocalStream(userId, callback) { async createLocalStream(userId, callback) {
const devices = await this.getDevices(); const devices = await this.getDevices()
const devicesStatus = { const devicesStatus = {
video: 1, video: 1,
audio: 1, audio: 1,
}; }
if (!devices.audioInputs.length && !devices.videoInputs.length) { if (!devices.audioInputs.length && !devices.videoInputs.length) {
callback({ callback({
code: -1, code: -1,
@ -102,19 +102,19 @@ export default class RtcClient {
video: 0, video: 0,
audio: 0, audio: 0,
}, },
}); })
return; return
} }
if (this.streamOptions.audio && devices.audioInputs.length) { if (this.streamOptions.audio && devices.audioInputs.length) {
await this.engine.startAudioCapture(devices.audioInputs[0].deviceId); await this.engine.startAudioCapture(devices.audioInputs[0].deviceId)
} else { } else {
devicesStatus['video'] = 0; devicesStatus['video'] = 0
// this.engine.unpublishStream(MediaType.AUDIO); // this.engine.unpublishStream(MediaType.AUDIO);
} }
if (this.streamOptions.video && devices.videoInputs.length) { if (this.streamOptions.video && devices.videoInputs.length) {
// await this.engine.startVideoCapture(devices.videoInputs[0].deviceId); // await this.engine.startVideoCapture(devices.videoInputs[0].deviceId);
} else { } else {
devicesStatus['audio'] = 0; devicesStatus['audio'] = 0
// this.engine.unpublishStream(MediaType.VIDEO); // this.engine.unpublishStream(MediaType.VIDEO);
} }
// this.engine.setLocalVideoPlayer(StreamIndex.STREAM_INDEX_MAIN, { // this.engine.setLocalVideoPlayer(StreamIndex.STREAM_INDEX_MAIN, {
@ -129,14 +129,14 @@ export default class RtcClient {
code: 0, code: 0,
msg: 'Failed to enumerate devices.', msg: 'Failed to enumerate devices.',
devicesStatus, devicesStatus,
}); })
} }
async changeAudioState(isMicOn) { async changeAudioState(isMicOn) {
if (isMicOn) { if (isMicOn) {
await this.engine.publishStream(MediaType.AUDIO); await this.engine.publishStream(MediaType.AUDIO)
} else { } else {
await this.engine.unpublishStream(MediaType.AUDIO); await this.engine.unpublishStream(MediaType.AUDIO)
} }
} }
@ -149,16 +149,14 @@ export default class RtcClient {
// } // }
async leave() { async leave() {
await Promise.all([ await Promise.all([this.engine?.stopAudioCapture()])
this.engine?.stopAudioCapture(), await this.engine?.unpublishStream(MediaType.AUDIO).catch(console.warn)
]); this.engine.leaveRoom()
await this.engine?.unpublishStream(MediaType.AUDIO).catch(console.warn); this.engine.destroy()
this.engine.leaveRoom();
this.engine.destroy();
} }
async enableAudioPropertiesReport(config) { async enableAudioPropertiesReport(config) {
console.log('enableAudioPropertiesReport', config); console.log('enableAudioPropertiesReport', config)
await this.engine.enableAudioPropertiesReport(config); await this.engine.enableAudioPropertiesReport(config)
} }
} }

View File

@ -1,78 +1,78 @@
import { IRTCEngine } from '@byteplus/rtc'; import { IRTCEngine } from '@byteplus/rtc'
export interface AudioStats { export interface AudioStats {
CodecType: string; CodecType: string
End2EndDelay: number; End2EndDelay: number
MuteState: boolean; MuteState: boolean
PacketLossRate: number; PacketLossRate: number
RecvBitrate: number; RecvBitrate: number
RecvLevel: number; RecvLevel: number
TotalFreezeTime: number; TotalFreezeTime: number
TotalPlayDuration: number; TotalPlayDuration: number
TransportDelay: number; TransportDelay: number
} }
export interface RTCClient { export interface RTCClient {
engine: IRTCEngine; engine: IRTCEngine
init: (...args: any[]) => void; init: (...args: any[]) => void
join: (...args: any[]) => any; join: (...args: any[]) => any
publishStream: (...args: any[]) => Promise<void>; publishStream: (...args: any[]) => Promise<void>
unpublishStream: (...args: any[]) => Promise<void>; unpublishStream: (...args: any[]) => Promise<void>
subscribe: (...args: any[]) => void; subscribe: (...args: any[]) => void
leave: (...args: any[]) => Promise<void>; leave: (...args: any[]) => Promise<void>
on: (...args: any[]) => void; on: (...args: any[]) => void
off: (...args: any[]) => void; off: (...args: any[]) => void
setupLocalVideoPlayer: (...args: any[]) => void; setupLocalVideoPlayer: (...args: any[]) => void
createLocalStream: (...args: any[]) => void; createLocalStream: (...args: any[]) => void
setRemoteVideoPlayer: (...args: any[]) => void; setRemoteVideoPlayer: (...args: any[]) => void
removeEventListener: (...args: any[]) => void; removeEventListener: (...args: any[]) => void
changeAudioState: (...args: any[]) => void; changeAudioState: (...args: any[]) => void
changeVideoState: (...args: any[]) => void; changeVideoState: (...args: any[]) => void
bindEngineEvents: (...args: any[]) => void; bindEngineEvents: (...args: any[]) => void
enableAudioPropertiesReport: (...args: any[]) => void; enableAudioPropertiesReport: (...args: any[]) => void
} }
export interface Stream { export interface Stream {
userId: string; userId: string
hasAudio: boolean; hasAudio: boolean
hasVideo: boolean; hasVideo: boolean
isScreen: boolean; isScreen: boolean
videoStreamDescriptions: any[]; videoStreamDescriptions: any[]
stream: { stream: {
screen: boolean; screen: boolean
}; }
getId: () => string; getId: () => string
enableAudio: () => void; enableAudio: () => void
disableAudio: () => void; disableAudio: () => void
enableVideo: () => void; enableVideo: () => void
disableVideo: () => void; disableVideo: () => void
close: () => void; close: () => void
init: (...args: any[]) => void; init: (...args: any[]) => void
play: (id: string, options?: any) => void; play: (id: string, options?: any) => void
setVideoEncoderConfiguration: (...args: any[]) => void; setVideoEncoderConfiguration: (...args: any[]) => void
getStats(): any; getStats(): any
getAudioLevel(): number; getAudioLevel(): number
playerComp: any; playerComp: any
} }
export type SubscribeOption = { export type SubscribeOption = {
video?: boolean; video?: boolean
audio?: boolean; audio?: boolean
}; }
export type DeviceInstance = { export type DeviceInstance = {
deviceId: string; deviceId: string
groupId: string; groupId: string
kind: 'audioinput' | 'audiooutput' | 'videoinput'; kind: 'audioinput' | 'audiooutput' | 'videoinput'
label: string; label: string
}; }
export type StreamOption = { export type StreamOption = {
audio: boolean; audio: boolean
video: boolean; video: boolean
data?: boolean; data?: boolean
screen?: boolean; screen?: boolean
mediaStream?: MediaStream; mediaStream?: MediaStream
microphoneId?: string; microphoneId?: string
cameraId?: string; cameraId?: string
}; }

View File

@ -1,24 +1,43 @@
"use client" 'use client'
import { useAtom, useSetAtom } from "jotai";
import { isChatBackgroundDrawerOpenAtom, isCrushLevelDrawerOpenAtom, isCrushLevelRetrieveDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat";
import { InlineDrawer, InlineDrawerContent, InlineDrawerHeader, InlineDrawerDescription, InlineDrawerFooter } from "./InlineDrawer";
import { Button, IconButton } from "@/components/ui/button";
import { AiUserImBaseInfoOutput, BackgroundImgListOutput } from "@/services/im/types";
import { useDelChatBackground, useGetChatBackgroundList, useSetChatBackground } from "@/hooks/useIm";
import { useChatConfig } from "../../context/chatConfig";
import { cn } from "@/lib/utils";
import Image from "next/image";
import { Checkbox } from "@/components/ui/checkbox";
import React, { useEffect, useState } from "react";
import { Tag } from "@/components/ui/tag";
import { useRouter } from "next/navigation";
import { useQueryClient } from "@tanstack/react-query";
import { imKeys } from "@/lib/query-keys";
import { ImageViewer } from "@/components/ui/image-viewer";
import { useImageViewer } from "@/hooks/useImageViewer";
import { AlertDialog, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { useAtom, useSetAtom } from 'jotai'
import {
isChatBackgroundDrawerOpenAtom,
isCrushLevelDrawerOpenAtom,
isCrushLevelRetrieveDrawerOpenAtom,
createDrawerOpenState,
} from '@/atoms/chat'
import {
InlineDrawer,
InlineDrawerContent,
InlineDrawerHeader,
InlineDrawerDescription,
InlineDrawerFooter,
} from './InlineDrawer'
import { Button, IconButton } from '@/components/ui/button'
import { AiUserImBaseInfoOutput, BackgroundImgListOutput } from '@/services/im/types'
import { useDelChatBackground, useGetChatBackgroundList, useSetChatBackground } from '@/hooks/useIm'
import { useChatConfig } from '../../context/chatConfig'
import { cn } from '@/lib/utils'
import Image from 'next/image'
import { Checkbox } from '@/components/ui/checkbox'
import React, { useEffect, useState } from 'react'
import { Tag } from '@/components/ui/tag'
import { useRouter } from 'next/navigation'
import { useQueryClient } from '@tanstack/react-query'
import { imKeys } from '@/lib/query-keys'
import { ImageViewer } from '@/components/ui/image-viewer'
import { useImageViewer } from '@/hooks/useImageViewer'
import {
AlertDialog,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
const BackgroundImageViewerAction = ({ const BackgroundImageViewerAction = ({
aiId, aiId,
@ -29,77 +48,111 @@ const BackgroundImageViewerAction = ({
currentIndex, currentIndex,
onChange, onChange,
}: { }: {
aiId: number; aiId: number
datas: BackgroundImgListOutput[]; datas: BackgroundImgListOutput[]
backgroundId: number; backgroundId: number
onDeleted?: (nextIndex: number | null) => void; onDeleted?: (nextIndex: number | null) => void
isSelected: boolean; isSelected: boolean
currentIndex: number; currentIndex: number
onChange: (backgroundId: number) => void; onChange: (backgroundId: number) => void
}) => { }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false)
const { mutateAsync: deleteBackground, isPending: isDeletingBackground } = useDelChatBackground({ aiId, backgroundId }) const { mutateAsync: deleteBackground, isPending: isDeletingBackground } = useDelChatBackground({
aiId,
backgroundId,
})
const handleDelete = async () => { const handleDelete = async () => {
await deleteBackground({ aiId, backgroundId }) await deleteBackground({ aiId, backgroundId })
const nextLength = datas.length - 1; const nextLength = datas.length - 1
if (nextLength <= 0) { if (nextLength <= 0) {
onDeleted?.(null); onDeleted?.(null)
return; return
} }
const isLast = currentIndex >= nextLength; const isLast = currentIndex >= nextLength
const nextIndex = isLast ? nextLength - 1 : currentIndex; const nextIndex = isLast ? nextLength - 1 : currentIndex
onDeleted?.(nextIndex); onDeleted?.(nextIndex)
setIsOpen(false) setIsOpen(false)
} }
const handleSelect = () => { const handleSelect = () => {
// 如果只有一张背景且当前已选中,不允许取消选中 // 如果只有一张背景且当前已选中,不允许取消选中
if (datas.length === 1 && isSelected) { if (datas.length === 1 && isSelected) {
return; return
} }
onChange(backgroundId); onChange(backgroundId)
} }
return ( return (
<> <>
<div className="w-px h-6 bg-outline-normal" /> <div className="bg-outline-normal h-6 w-px" />
<div className="h-8 flex items-center justify-center bg-surface-element-light-normal rounded-full backdrop-blur-lg px-3 gap-2 cursor-pointer" onClick={() => handleSelect()}> <div
className="bg-surface-element-light-normal flex h-8 cursor-pointer items-center justify-center gap-2 rounded-full px-3 backdrop-blur-lg"
onClick={() => handleSelect()}
>
<Checkbox shape="round" checked={isSelected} /> <Checkbox shape="round" checked={isSelected} />
<div className="txt-label-s">Select</div> <div className="txt-label-s">Select</div>
</div> </div>
{!!backgroundId && <div className="w-px h-6 bg-outline-normal" />} {!!backgroundId && <div className="bg-outline-normal h-6 w-px" />}
{!!backgroundId && <IconButton iconfont="icon-trashcan" variant="tertiary" size="small" loading={isDeletingBackground} onClick={handleDelete} />} {!!backgroundId && (
<IconButton
iconfont="icon-trashcan"
variant="tertiary"
size="small"
loading={isDeletingBackground}
onClick={handleDelete}
/>
)}
</> </>
) )
} }
const BackgroundItem = ({ item, selected, inUse, onClick, onImagePreview, totalCount }: { item: BackgroundImgListOutput, selected: boolean, inUse: boolean, onClick: () => void, onImagePreview: () => void, totalCount: number }) => { const BackgroundItem = ({
item,
selected,
inUse,
onClick,
onImagePreview,
totalCount,
}: {
item: BackgroundImgListOutput
selected: boolean
inUse: boolean
onClick: () => void
onImagePreview: () => void
totalCount: number
}) => {
const handleClick = () => { const handleClick = () => {
// 如果只有一张背景且当前已选中,不允许取消选中 // 如果只有一张背景且当前已选中,不允许取消选中
if (totalCount === 1 && selected) { if (totalCount === 1 && selected) {
return; return
} }
onClick(); onClick()
}; }
return ( return (
<div className="relative cursor-pointer group" onClick={handleClick}> <div className="group relative cursor-pointer" onClick={handleClick}>
<div className={cn( <div
"bg-surface-element-normal relative aspect-[3/4] rounded-lg overflow-hidden", className={cn(
selected && "border-2 border-solid border-primary-normal" 'bg-surface-element-normal relative aspect-[3/4] overflow-hidden rounded-lg',
)}> selected && 'border-primary-normal border-2 border-solid'
)}
>
<Image src={item.imgUrl || ''} alt={''} fill className="object-cover" /> <Image src={item.imgUrl || ''} alt={''} fill className="object-cover" />
</div> </div>
{item.isDefault && <Tag className="absolute top-2 left-2" variant="dark" size="small">Default</Tag>} {item.isDefault && (
<Tag className="absolute top-2 left-2" variant="dark" size="small">
Default
</Tag>
)}
{inUse && <Checkbox shape="round" checked className="absolute top-2 right-2" />} {inUse && <Checkbox shape="round" checked className="absolute top-2 right-2" />}
<IconButton <IconButton
className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity" className="absolute right-2 bottom-2 opacity-0 transition-opacity group-hover:opacity-100"
size="xs" size="xs"
variant="contrast" variant="contrast"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation()
onImagePreview(); onImagePreview()
}} }}
> >
<i className="iconfont icon-icon-fullImage" /> <i className="iconfont icon-icon-fullImage" />
@ -110,13 +163,14 @@ const BackgroundItem = ({ item, selected, inUse, onClick, onImagePreview, totalC
const ChatBackgroundDrawer = () => { const ChatBackgroundDrawer = () => {
const [selectId, setSelectId] = useState<number | undefined>() const [selectId, setSelectId] = useState<number | undefined>()
const { aiId, aiInfo } = useChatConfig(); const { aiId, aiInfo } = useChatConfig()
const [drawerState, setDrawerState] = useAtom(isChatBackgroundDrawerOpenAtom); const [drawerState, setDrawerState] = useAtom(isChatBackgroundDrawerOpenAtom)
const open = drawerState.open; const open = drawerState.open
const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open)); const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const router = useRouter(); const router = useRouter()
const setCrushLevelDrawerState = useSetAtom(isCrushLevelDrawerOpenAtom); const setCrushLevelDrawerState = useSetAtom(isCrushLevelDrawerOpenAtom)
const setIsCrushLevelDrawerOpen = (open: boolean) => setCrushLevelDrawerState(createDrawerOpenState(open)); const setIsCrushLevelDrawerOpen = (open: boolean) =>
setCrushLevelDrawerState(createDrawerOpenState(open))
// 图片查看器 // 图片查看器
const { const {
@ -125,58 +179,59 @@ const ChatBackgroundDrawer = () => {
openViewer, openViewer,
closeViewer, closeViewer,
handleIndexChange, handleIndexChange,
} = useImageViewer(); } = useImageViewer()
const { backgroundImg, aiUserHeartbeatRelation } = aiInfo || {} const { backgroundImg, aiUserHeartbeatRelation } = aiInfo || {}
const { heartbeatLevelNum } = aiUserHeartbeatRelation || {} const { heartbeatLevelNum } = aiUserHeartbeatRelation || {}
const isUnlock = heartbeatLevelNum && heartbeatLevelNum >= 10; const isUnlock = heartbeatLevelNum && heartbeatLevelNum >= 10
const { data: originBackgroundList } = useGetChatBackgroundList({ aiId });
const { mutateAsync: updateChatBackground, isPending: isUpdatingChatBackground } = useSetChatBackground({ aiId })
const queryClient = useQueryClient();
const { data: originBackgroundList } = useGetChatBackgroundList({ aiId })
const { mutateAsync: updateChatBackground, isPending: isUpdatingChatBackground } =
useSetChatBackground({ aiId })
const queryClient = useQueryClient()
const backgroundList = React.useMemo(() => { const backgroundList = React.useMemo(() => {
return originBackgroundList?.map(item => ({ return originBackgroundList?.map((item) => ({
...item, ...item,
backgroundId: item.backgroundId || 0, backgroundId: item.backgroundId || 0,
})) }))
}, [originBackgroundList]) }, [originBackgroundList])
useEffect(() => { useEffect(() => {
if (!backgroundList?.length || !aiInfo) return; if (!backgroundList?.length || !aiInfo) return
const defaultId = backgroundList.find(item => item.imgUrl === backgroundImg)?.backgroundId; const defaultId = backgroundList.find((item) => item.imgUrl === backgroundImg)?.backgroundId
setSelectId(defaultId) setSelectId(defaultId)
}, [backgroundList, aiInfo]); }, [backgroundList, aiInfo])
const handleUnlock = () => { const handleUnlock = () => {
// // todo // // todo
// router.push(`/generate/image-2-background?id=${aiId}`); // router.push(`/generate/image-2-background?id=${aiId}`);
// return; // return;
if (!aiId) return; if (!aiId) return
if (!aiUserHeartbeatRelation) return; if (!aiUserHeartbeatRelation) return
if (isUnlock) { if (isUnlock) {
router.push(`/generate/image-2-background?id=${aiId}`); router.push(`/generate/image-2-background?id=${aiId}`)
} else { } else {
setIsCrushLevelDrawerOpen(true) setIsCrushLevelDrawerOpen(true)
} }
} }
const handleConfirm = async () => { const handleConfirm = async () => {
const { imgUrl, isDefault } = backgroundList?.find(item => item.backgroundId === selectId) || {}; const { imgUrl, isDefault } =
backgroundList?.find((item) => item.backgroundId === selectId) || {}
const result = { const result = {
aiId, aiId,
backgroundId: selectId || '', backgroundId: selectId || '',
} }
if (selectId !== 0) { if (selectId !== 0) {
result.backgroundId = selectId || ''; result.backgroundId = selectId || ''
} }
await updateChatBackground(result) await updateChatBackground(result)
queryClient.setQueryData(imKeys.imUserInfo(aiId), (old: AiUserImBaseInfoOutput) => { queryClient.setQueryData(imKeys.imUserInfo(aiId), (old: AiUserImBaseInfoOutput) => {
return { return {
...old, ...old,
@ -188,7 +243,7 @@ const ChatBackgroundDrawer = () => {
} }
const handleImagePreview = (index: number) => { const handleImagePreview = (index: number) => {
openViewer(backgroundList?.map(item => item.imgUrl || '') || [], index) openViewer(backgroundList?.map((item) => item.imgUrl || '') || [], index)
} }
return ( return (
@ -202,39 +257,50 @@ const ChatBackgroundDrawer = () => {
<InlineDrawerHeader>Chat Background</InlineDrawerHeader> <InlineDrawerHeader>Chat Background</InlineDrawerHeader>
<InlineDrawerDescription className="overflow-y-auto"> <InlineDrawerDescription className="overflow-y-auto">
<div> <div>
<div className="bg-surface-element-normal rounded-lg flex justify-between items-center gap-4 p-4"> <div className="bg-surface-element-normal flex items-center justify-between gap-4 rounded-lg p-4">
<div className="flex-1"> <div className="flex-1">
<div className="txt-title-s">Generate Image</div> <div className="txt-title-s">Generate Image</div>
<div className="txt-body-s text-txt-secondary-normal mt-1">{isUnlock ? 'Unlocked' : 'Unlocks at Lv.10'}</div> <div className="txt-body-s text-txt-secondary-normal mt-1">
{isUnlock ? 'Unlocked' : 'Unlocks at Lv.10'}
</div>
</div> </div>
<Button size="small" onClick={handleUnlock}>{isUnlock ? 'Generate' : 'Unlock'}</Button> <Button size="small" onClick={handleUnlock}>
{isUnlock ? 'Generate' : 'Unlock'}
</Button>
</div> </div>
<div className="grid grid-cols-2 gap-4 mt-4"> <div className="mt-4 grid grid-cols-2 gap-4">
{ {backgroundList?.map((item, index) => (
backgroundList?.map((item, index) => ( <BackgroundItem
<BackgroundItem key={item.backgroundId}
key={item.backgroundId} selected={selectId === item.backgroundId}
selected={selectId === item.backgroundId} inUse={item.imgUrl === backgroundImg}
inUse={item.imgUrl === backgroundImg} item={item}
item={item} onClick={() => setSelectId(item.backgroundId)}
onClick={() => setSelectId(item.backgroundId)} onImagePreview={() => handleImagePreview(index)}
onImagePreview={() => handleImagePreview(index)} totalCount={backgroundList?.length || 0}
totalCount={backgroundList?.length || 0} />
/> ))}
))
}
</div> </div>
</div> </div>
</InlineDrawerDescription> </InlineDrawerDescription>
<InlineDrawerFooter> <InlineDrawerFooter>
<Button variant="tertiary" size="large" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="tertiary" size="large" onClick={() => setOpen(false)}>
<Button size="large" variant="primary" loading={isUpdatingChatBackground} onClick={handleConfirm}>Confirm</Button> Cancel
</Button>
<Button
size="large"
variant="primary"
loading={isUpdatingChatBackground}
onClick={handleConfirm}
>
Confirm
</Button>
</InlineDrawerFooter> </InlineDrawerFooter>
</InlineDrawerContent> </InlineDrawerContent>
<ImageViewer <ImageViewer
images={backgroundList?.map(item => item.imgUrl || '') || []} images={backgroundList?.map((item) => item.imgUrl || '') || []}
currentIndex={viewerIndex} currentIndex={viewerIndex}
open={isViewerOpen} open={isViewerOpen}
onClose={closeViewer} onClose={closeViewer}
@ -252,18 +318,18 @@ const ChatBackgroundDrawer = () => {
onDeleted={(nextIndex) => { onDeleted={(nextIndex) => {
if (nextIndex === null) { if (nextIndex === null) {
// 删除后没有图片了 // 删除后没有图片了
closeViewer(); closeViewer()
return; return
} }
// 调整到新的索引,避免越界 // 调整到新的索引,避免越界
handleIndexChange(nextIndex); handleIndexChange(nextIndex)
}} }}
/> />
) )
}} }}
/> />
</InlineDrawer> </InlineDrawer>
); )
} }
export default ChatBackgroundDrawer; export default ChatBackgroundDrawer

View File

@ -1,39 +1,64 @@
"use client" 'use client'
import { isChatButtleDrawerOpenAtom, isCrushLevelDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat"; import {
import { InlineDrawer, InlineDrawerContent, InlineDrawerDescription, InlineDrawerFooter, InlineDrawerHeader } from "./InlineDrawer"; isChatButtleDrawerOpenAtom,
import { useAtom, useSetAtom } from "jotai"; isCrushLevelDrawerOpenAtom,
import { Button } from "@/components/ui/button"; createDrawerOpenState,
import { useGetChatBubbleDictList, useGetMyChatSetting, useSetChatBubble } from "@/hooks/useIm"; } from '@/atoms/chat'
import { useChatConfig } from "../../context/chatConfig"; import {
import { ChatBubbleListOutput, UnlockType } from "@/services/im/types"; InlineDrawer,
import Image from "next/image"; InlineDrawerContent,
import { Tag } from "@/components/ui/tag"; InlineDrawerDescription,
import { useEffect, useState } from "react"; InlineDrawerFooter,
import { useHeartLevelTextFromLevel } from "@/hooks/useHeartLevel"; InlineDrawerHeader,
import { cn } from "@/lib/utils"; } from './InlineDrawer'
import ChatBubble from "../ChatMessageItems/ChatBubble"; import { useAtom, useSetAtom } from 'jotai'
import { Checkbox } from "@/components/ui/checkbox"; import { Button } from '@/components/ui/button'
import { useRouter } from "next/navigation"; import { useGetChatBubbleDictList, useGetMyChatSetting, useSetChatBubble } from '@/hooks/useIm'
import { isVipDrawerOpenAtom } from "@/atoms/im"; import { useChatConfig } from '../../context/chatConfig'
import { useCurrentUser } from "@/hooks/auth"; import { ChatBubbleListOutput, UnlockType } from '@/services/im/types'
import { VipType } from "@/services/wallet"; import Image from 'next/image'
import { useQueryClient } from "@tanstack/react-query"; import { Tag } from '@/components/ui/tag'
import { imKeys } from "@/lib/query-keys"; import { useEffect, useState } from 'react'
import { useHeartLevelTextFromLevel } from '@/hooks/useHeartLevel'
const ChatButtleItem = ({ item, selected, inUse, onClick }: { item: ChatBubbleListOutput, selected: boolean, inUse: boolean, onClick: () => void }) => { import { cn } from '@/lib/utils'
import ChatBubble from '../ChatMessageItems/ChatBubble'
import { Checkbox } from '@/components/ui/checkbox'
import { useRouter } from 'next/navigation'
import { isVipDrawerOpenAtom } from '@/atoms/im'
import { useCurrentUser } from '@/hooks/auth'
import { VipType } from '@/services/wallet'
import { useQueryClient } from '@tanstack/react-query'
import { imKeys } from '@/lib/query-keys'
const ChatButtleItem = ({
item,
selected,
inUse,
onClick,
}: {
item: ChatBubbleListOutput
selected: boolean
inUse: boolean
onClick: () => void
}) => {
return ( return (
<div className="cursor-pointer" onClick={onClick}> <div className="cursor-pointer" onClick={onClick}>
<div className={cn("bg-surface-element-normal rounded-lg aspect-[41/30] relative p-[2px] flex justify-center items-center", selected && "p-0 border-2 border-solid border-primary-normal")}> <div
<ChatBubble className={cn(
isDefault={item.isDefault} 'bg-surface-element-normal relative flex aspect-[41/30] items-center justify-center rounded-lg p-[2px]',
img={item.webImgUrl} selected && 'border-primary-normal border-2 border-solid p-0'
> )}
>
<ChatBubble isDefault={item.isDefault} img={item.webImgUrl}>
Hi Hi
</ChatBubble> </ChatBubble>
{inUse && <Checkbox checked={true} shape="round" className="absolute top-2 right-2" />} {inUse && <Checkbox checked={true} shape="round" className="absolute top-2 right-2" />}
{item.isDefault && <Tag className="absolute top-2 left-2" variant="dark" size="small">Default</Tag>} {item.isDefault && (
<Tag className="absolute top-2 left-2" variant="dark" size="small">
Default
</Tag>
)}
{!item.isUnlock && ( {!item.isUnlock && (
<Tag className="absolute top-2 right-2" variant="dark" size="small"> <Tag className="absolute top-2 right-2" variant="dark" size="small">
<i className="iconfont icon-private-border" /> <i className="iconfont icon-private-border" />
@ -41,15 +66,11 @@ const ChatButtleItem = ({ item, selected, inUse, onClick }: { item: ChatBubbleLi
)} )}
</div> </div>
{item.unlockType === 'MEMBER' ? ( {item.unlockType === 'MEMBER' ? (
<div <div className="txt-label-m mt-2 bg-gradient-to-r from-[#ff9696] via-[#aa90f9] to-[#8df3e2] bg-clip-text text-center text-transparent">
className="bg-gradient-to-r from-[#ff9696] via-[#aa90f9] to-[#8df3e2] bg-clip-text text-transparent txt-label-m text-center mt-2"
>
{item.name} {item.name}
</div> </div>
) : ( ) : (
<div className="txt-label-m text-txt-primary-normal mt-2 text-center"> <div className="txt-label-m text-txt-primary-normal mt-2 text-center">{item.name}</div>
{item.name}
</div>
)} )}
</div> </div>
) )
@ -57,31 +78,32 @@ const ChatButtleItem = ({ item, selected, inUse, onClick }: { item: ChatBubbleLi
const ChatButtleDrawer = () => { const ChatButtleDrawer = () => {
const [selectedCode, setSelectedCode] = useState<string | undefined>() const [selectedCode, setSelectedCode] = useState<string | undefined>()
const [drawerState, setDrawerState] = useAtom(isChatButtleDrawerOpenAtom); const [drawerState, setDrawerState] = useAtom(isChatButtleDrawerOpenAtom)
const open = drawerState.open; const open = drawerState.open
const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open)); const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const { aiId, aiInfo } = useChatConfig(); const { aiId, aiInfo } = useChatConfig()
const { chatBubble } = aiInfo || {}; const { chatBubble } = aiInfo || {}
const setCrushLevelDrawerState = useSetAtom(isCrushLevelDrawerOpenAtom); const setCrushLevelDrawerState = useSetAtom(isCrushLevelDrawerOpenAtom)
const setIsCrushLevelDrawerOpen = (open: boolean) => setCrushLevelDrawerState(createDrawerOpenState(open)); const setIsCrushLevelDrawerOpen = (open: boolean) =>
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom); setCrushLevelDrawerState(createDrawerOpenState(open))
const queryClient = useQueryClient(); const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom)
const queryClient = useQueryClient()
const { data: chatBubbleDictList } = useGetChatBubbleDictList({ aiId }) const { data: chatBubbleDictList } = useGetChatBubbleDictList({ aiId })
const { mutateAsync: setChatBubble, isPending: isSettingChatBubble } = useSetChatBubble({ aiId }) const { mutateAsync: setChatBubble, isPending: isSettingChatBubble } = useSetChatBubble({ aiId })
useEffect(() => { useEffect(() => {
if (!chatBubbleDictList?.length) return if (!chatBubbleDictList?.length) return
const defaultCode = chatBubble?.code?.toString(); const defaultCode = chatBubble?.code?.toString()
setSelectedCode(defaultCode || chatBubbleDictList[0]?.code?.toString()) setSelectedCode(defaultCode || chatBubbleDictList[0]?.code?.toString())
}, [chatBubbleDictList, chatBubble]) }, [chatBubbleDictList, chatBubble])
useEffect(() => { useEffect(() => {
if (open) { if (open) {
queryClient.invalidateQueries({ queryKey: imKeys.imUserInfo(aiId) }) queryClient.invalidateQueries({ queryKey: imKeys.imUserInfo(aiId) })
const defaultCode = chatBubble?.code?.toString(); const defaultCode = chatBubble?.code?.toString()
if (defaultCode) { if (defaultCode) {
setSelectedCode(defaultCode); setSelectedCode(defaultCode)
} }
} }
}, [open]) }, [open])
@ -92,11 +114,18 @@ const ChatButtleDrawer = () => {
} }
const renderConfirmButton = () => { const renderConfirmButton = () => {
if (!selectedCode) return; if (!selectedCode) return
const { isUnlock, unlockType, unlockHeartbeatLevel } = chatBubbleDictList?.find(item => item.code === selectedCode) || {}; const { isUnlock, unlockType, unlockHeartbeatLevel } =
chatBubbleDictList?.find((item) => item.code === selectedCode) || {}
if (isUnlock || isUnlock === null) { if (isUnlock || isUnlock === null) {
return ( return (
<Button size="large" loading={isSettingChatBubble} onClick={() => handleSetChatBubble(selectedCode)}>Confirm</Button> <Button
size="large"
loading={isSettingChatBubble}
onClick={() => handleSetChatBubble(selectedCode)}
>
Confirm
</Button>
) )
} }
@ -117,18 +146,24 @@ const ChatButtleDrawer = () => {
if (unlockType === UnlockType.HeartbeatLevel) { if (unlockType === UnlockType.HeartbeatLevel) {
return ( return (
<Button size="large" <Button size="large" onClick={() => setIsCrushLevelDrawerOpen(true)}>
onClick={() => setIsCrushLevelDrawerOpen(true)}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Image src="/icons/like-gradient.svg" alt="vip" className="block" width={24} height={24} /> <Image
<span className="txt-label-l">{useHeartLevelTextFromLevel(unlockHeartbeatLevel)} Unlock</span> src="/icons/like-gradient.svg"
alt="vip"
className="block"
width={24}
height={24}
/>
<span className="txt-label-l">
{useHeartLevelTextFromLevel(unlockHeartbeatLevel)} Unlock
</span>
</div> </div>
</Button> </Button>
) )
} }
return null; return null
} }
return ( return (
@ -142,28 +177,28 @@ const ChatButtleDrawer = () => {
<InlineDrawerHeader>Chat Buttles</InlineDrawerHeader> <InlineDrawerHeader>Chat Buttles</InlineDrawerHeader>
<InlineDrawerDescription> <InlineDrawerDescription>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{ {chatBubbleDictList?.map((item) => (
chatBubbleDictList?.map(item => ( <ChatButtleItem
<ChatButtleItem key={item.code}
key={item.code} item={item}
item={item} inUse={item.code === chatBubble?.code}
inUse={item.code === chatBubble?.code} selected={item.code === selectedCode}
selected={item.code === selectedCode} onClick={() => {
onClick={() => { setSelectedCode(item.code)
setSelectedCode(item.code) }}
}} />
/> ))}
))
}
</div> </div>
</InlineDrawerDescription> </InlineDrawerDescription>
<InlineDrawerFooter> <InlineDrawerFooter>
<Button variant="tertiary" size="large" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="tertiary" size="large" onClick={() => setOpen(false)}>
Cancel
</Button>
{renderConfirmButton()} {renderConfirmButton()}
</InlineDrawerFooter> </InlineDrawerFooter>
</InlineDrawerContent> </InlineDrawerContent>
</InlineDrawer> </InlineDrawer>
); )
}; }
export default ChatButtleDrawer; export default ChatButtleDrawer

View File

@ -1,24 +1,28 @@
"use client" 'use client'
import { isChatModelDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat";
import { InlineDrawer, InlineDrawerContent, InlineDrawerDescription, InlineDrawerFooter, InlineDrawerHeader } from "./InlineDrawer";
import { useAtom } from "jotai";
import { Button, IconButton } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import Image from "next/image";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useGetChatModelDictList } from "@/hooks/useIm";
import { useEffect } from "react";
import { ChatPriceType, ChatPriceTypeMap } from "@/hooks/useWallet";
import { isChatModelDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat'
import {
InlineDrawer,
InlineDrawerContent,
InlineDrawerDescription,
InlineDrawerFooter,
InlineDrawerHeader,
} from './InlineDrawer'
import { useAtom } from 'jotai'
import { Button, IconButton } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import Image from 'next/image'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useGetChatModelDictList } from '@/hooks/useIm'
import { useEffect } from 'react'
import { ChatPriceType, ChatPriceTypeMap } from '@/hooks/useWallet'
const ChatModelDrawer = () => { const ChatModelDrawer = () => {
const [drawerState, setDrawerState] = useAtom(isChatModelDrawerOpenAtom); const [drawerState, setDrawerState] = useAtom(isChatModelDrawerOpenAtom)
const open = drawerState.open; const open = drawerState.open
const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open)); const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const { data: chatModelDictList } = useGetChatModelDictList(); const { data: chatModelDictList } = useGetChatModelDictList()
console.log('chatModelDictList', chatModelDictList) console.log('chatModelDictList', chatModelDictList)
@ -32,32 +36,40 @@ const ChatModelDrawer = () => {
<InlineDrawerContent> <InlineDrawerContent>
<InlineDrawerHeader>Chat Model</InlineDrawerHeader> <InlineDrawerHeader>Chat Model</InlineDrawerHeader>
<InlineDrawerDescription> <InlineDrawerDescription>
<div className="p-4 bg-surface-element-normal rounded-lg overflow-hidden"> <div className="bg-surface-element-normal overflow-hidden rounded-lg p-4">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="txt-title-s">Role-Playing Model</div> <div className="txt-title-s">Role-Playing Model</div>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<IconButton <IconButton iconfont="icon-question" variant="tertiary" size="mini" />
iconfont="icon-question"
variant="tertiary"
size="mini"
/>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent className="max-w-[300px]"> <TooltipContent className="max-w-[300px]">
<div className="space-y-2"> <div className="space-y-2">
<p className="break-words">Text Message Price: Refers to the cost of chatting with the character via text messages, including sending text, images, or gifts. Charged per message.</p> <p className="break-words">
<p className="break-words">Voice Message Price: Refers to the cost of sending a voice message to the character or playing the characters voice. Charged per use.</p> Text Message Price: Refers to the cost of chatting with the character via
<p className="break-words">Voice Call Price: Refers to the cost of having a voice call with the character. Charged per minute.</p> text messages, including sending text, images, or gifts. Charged per
message.
</p>
<p className="break-words">
Voice Message Price: Refers to the cost of sending a voice message to the
character or playing the characters voice. Charged per use.
</p>
<p className="break-words">
Voice Call Price: Refers to the cost of having a voice call with the
character. Charged per minute.
</p>
</div> </div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</div> </div>
<Checkbox checked={true} shape="round" /> <Checkbox checked={true} shape="round" />
</div> </div>
<div className="txt-body-m text-txt-secondary-normal mt-1">Role-play a conversation with AI</div> <div className="txt-body-m text-txt-secondary-normal mt-1">
Role-play a conversation with AI
</div>
<div className="mt-3 bg-surface-district-normal rounded-sm p-3"> <div className="bg-surface-district-normal mt-3 rounded-sm p-3">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} /> <Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
<span className="txt-label-m text-txt-primary-normal"> <span className="txt-label-m text-txt-primary-normal">
@ -65,8 +77,8 @@ const ChatModelDrawer = () => {
</span> </span>
</div> </div>
<div className="flex items-center justify-between gap-1 mt-3"> <div className="mt-3 flex items-center justify-between gap-1">
<div className="flex items-center gap-1 flex-1 min-w-0"> <div className="flex min-w-0 flex-1 items-center gap-1">
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} /> <Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
<span className="txt-label-m text-txt-primary-normal"> <span className="txt-label-m text-txt-primary-normal">
{ChatPriceTypeMap[ChatPriceType.VOICE]}/Send or play voice {ChatPriceTypeMap[ChatPriceType.VOICE]}/Send or play voice
@ -74,8 +86,8 @@ const ChatModelDrawer = () => {
</div> </div>
</div> </div>
<div className="flex items-center justify-between gap-1 mt-3"> <div className="mt-3 flex items-center justify-between gap-1">
<div className="flex items-center gap-1 flex-1 min-w-0"> <div className="flex min-w-0 flex-1 items-center gap-1">
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} /> <Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
<span className="txt-label-m text-txt-primary-normal"> <span className="txt-label-m text-txt-primary-normal">
{ChatPriceTypeMap[ChatPriceType.VOICE_CALL]}/min Voice call {ChatPriceTypeMap[ChatPriceType.VOICE_CALL]}/min Voice call
@ -87,12 +99,16 @@ const ChatModelDrawer = () => {
<div className="txt-body-m text-txt-secondary-normal mt-6">More models coming soon</div> <div className="txt-body-m text-txt-secondary-normal mt-6">More models coming soon</div>
</InlineDrawerDescription> </InlineDrawerDescription>
<InlineDrawerFooter> <InlineDrawerFooter>
<Button variant="tertiary" size="large" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="tertiary" size="large" onClick={() => setOpen(false)}>
<Button size="large" onClick={() => setOpen(false)}>Save</Button> Cancel
</Button>
<Button size="large" onClick={() => setOpen(false)}>
Save
</Button>
</InlineDrawerFooter> </InlineDrawerFooter>
</InlineDrawerContent> </InlineDrawerContent>
</InlineDrawer> </InlineDrawer>
); )
} }
export default ChatModelDrawer; export default ChatModelDrawer

View File

@ -1,27 +1,38 @@
"use client" 'use client'
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu"; import {
import { IconButton } from "@/components/ui/button"; DropdownMenu,
import { Separator } from "@/components/ui/separator"; DropdownMenuTrigger,
import DeleteMessageDialog from "./DeleteMessageDialog"; DropdownMenuContent,
import { useState } from "react"; DropdownMenuItem,
import useShare from "@/hooks/useShare"; } from '@/components/ui/dropdown-menu'
import { useChatConfig } from "../../../context/chatConfig"; import { IconButton } from '@/components/ui/button'
import { Separator } from '@/components/ui/separator'
import DeleteMessageDialog from './DeleteMessageDialog'
import { useState } from 'react'
import useShare from '@/hooks/useShare'
import { useChatConfig } from '../../../context/chatConfig'
const ChatProfileAction = () => { const ChatProfileAction = () => {
const [deleteMessageDialogOpen, setDeleteMessageDialogOpen] = useState(false); const [deleteMessageDialogOpen, setDeleteMessageDialogOpen] = useState(false)
const { shareFacebook, shareTwitter } = useShare(); const { shareFacebook, shareTwitter } = useShare()
const { aiId } = useChatConfig(); const { aiId } = useChatConfig()
const handleDeleteMessage = () => { const handleDeleteMessage = () => {
setDeleteMessageDialogOpen(true); setDeleteMessageDialogOpen(true)
} }
const handleShareFacebook = () => { const handleShareFacebook = () => {
shareFacebook({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}` }); shareFacebook({
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}`,
})
} }
const handleShareTwitter = () => { const handleShareTwitter = () => {
shareTwitter({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}` }); shareTwitter({
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}`,
})
} }
return ( return (
@ -39,7 +50,7 @@ const ChatProfileAction = () => {
<i className="iconfont icon-social-twitter text-txt-primary-normal !text-[16px]" /> <i className="iconfont icon-social-twitter text-txt-primary-normal !text-[16px]" />
<span>Share to X</span> <span>Share to X</span>
</DropdownMenuItem> </DropdownMenuItem>
<div className="px-2 my-3"> <div className="my-3 px-2">
<Separator className="bg-outline-normal" /> <Separator className="bg-outline-normal" />
</div> </div>
<DropdownMenuItem onClick={handleDeleteMessage}> <DropdownMenuItem onClick={handleDeleteMessage}>
@ -49,9 +60,12 @@ const ChatProfileAction = () => {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<DeleteMessageDialog open={deleteMessageDialogOpen} onOpenChange={setDeleteMessageDialogOpen} /> <DeleteMessageDialog
open={deleteMessageDialogOpen}
onOpenChange={setDeleteMessageDialogOpen}
/>
</div> </div>
); )
} }
export default ChatProfileAction; export default ChatProfileAction

View File

@ -1,14 +1,14 @@
"use client" 'use client'
import ChatProfileShareIcon from "./ChatProfileShareIcon"; import ChatProfileShareIcon from './ChatProfileShareIcon'
import ChatProfileLikeIcon from "./ChatProfileLikeIcon"; import ChatProfileLikeIcon from './ChatProfileLikeIcon'
const ChatProfileLikeAction = () => { const ChatProfileLikeAction = () => {
return ( return (
<div className="flex justify-center items-center gap-4"> <div className="flex items-center justify-center gap-4">
<ChatProfileLikeIcon /> <ChatProfileLikeIcon />
<ChatProfileShareIcon /> <ChatProfileShareIcon />
</div> </div>
); )
} }
export default ChatProfileLikeAction; export default ChatProfileLikeAction

View File

@ -1,50 +1,50 @@
"use client" 'use client'
import { IconButton } from "@/components/ui/button"; import { IconButton } from '@/components/ui/button'
import { useGetAIUserBaseInfo } from "@/hooks/aiUser"; 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 } from 'next/navigation'
import { useChatConfig } from "../../../context/chatConfig"; import { useChatConfig } from '../../../context/chatConfig'
const ChatProfileLikeIcon = () => { const ChatProfileLikeIcon = () => {
const { aiId, aiInfo } = useChatConfig(); const { aiId, aiInfo } = useChatConfig()
const { mutateAsync: doAiUserLiked } = useDoAiUserLiked(); const { mutateAsync: doAiUserLiked } = useDoAiUserLiked()
const { liked } = aiInfo || {}; const { liked } = aiInfo || {}
const queryClient = useQueryClient(); const queryClient = useQueryClient()
const handleLike = () => { const handleLike = () => {
doAiUserLiked({ aiId: Number(aiId), likedStatus: liked ? 'CANCELED' : 'LIKED' }); doAiUserLiked({ aiId: Number(aiId), likedStatus: liked ? 'CANCELED' : 'LIKED' })
queryClient.setQueryData(aiUserKeys.baseInfo({ aiId: Number(aiId) }), (oldData: any) => { queryClient.setQueryData(aiUserKeys.baseInfo({ aiId: Number(aiId) }), (oldData: any) => {
return { return {
...oldData, ...oldData,
liked: !liked, liked: !liked,
} }
}); })
queryClient.setQueryData(imKeys.imUserInfo(Number(aiId)), (oldData: any) => { queryClient.setQueryData(imKeys.imUserInfo(Number(aiId)), (oldData: any) => {
return { return {
...oldData, ...oldData,
liked: !liked, liked: !liked,
} }
}); })
queryClient.setQueryData(aiUserKeys.stat({ aiId: Number(aiId) }), (oldData: any) => { queryClient.setQueryData(aiUserKeys.stat({ aiId: Number(aiId) }), (oldData: any) => {
return { return {
...oldData, ...oldData,
likedNum: !liked ? oldData.likedNum + 1 : oldData.likedNum - 1, likedNum: !liked ? oldData.likedNum + 1 : oldData.likedNum - 1,
} }
}); })
} }
return ( return (
<IconButton <IconButton variant="tertiary" size="large" onClick={handleLike}>
variant="tertiary" {!liked ? (
size="large" <i className="iconfont icon-Like" />
onClick={handleLike} ) : (
> <i className="iconfont icon-Like-fill text-important-normal" />
{!liked ? <i className="iconfont icon-Like" /> : <i className="iconfont icon-Like-fill text-important-normal" />} )}
</IconButton> </IconButton>
); )
} }
export default ChatProfileLikeIcon; export default ChatProfileLikeIcon

View File

@ -1,20 +1,20 @@
"use client" 'use client'
import { isChatProfileEditDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat";
import { useGetMyChatSetting } from "@/hooks/useIm";
import { useSetAtom } from "jotai";
import { useChatConfig } from "../../../context/chatConfig";
import { Gender } from "@/types/user";
import { cn, getAge } from "@/lib/utils";
import { isChatProfileEditDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat'
import { useGetMyChatSetting } from '@/hooks/useIm'
import { useSetAtom } from 'jotai'
import { useChatConfig } from '../../../context/chatConfig'
import { Gender } from '@/types/user'
import { cn, getAge } from '@/lib/utils'
const ChatProfilePersona = () => { const ChatProfilePersona = () => {
const { aiId } = useChatConfig(); const { aiId } = useChatConfig()
const setDrawerState = useSetAtom(isChatProfileEditDrawerOpenAtom); const setDrawerState = useSetAtom(isChatProfileEditDrawerOpenAtom)
const setIsChatProfileEditDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open)); const setIsChatProfileEditDrawerOpen = (open: boolean) =>
const { data: chatSettingData } = useGetMyChatSetting({ aiId }); setDrawerState(createDrawerOpenState(open))
const { data: chatSettingData } = useGetMyChatSetting({ aiId })
const { nickname, sex, birthday, whoAmI } = chatSettingData || {}; const { nickname, sex, birthday, whoAmI } = chatSettingData || {}
const genderMap = { const genderMap = {
[Gender.MALE]: 'Male', [Gender.MALE]: 'Male',
@ -23,32 +23,48 @@ const ChatProfilePersona = () => {
} }
return ( return (
<div className="flex flex-col gap-3 w-full"> <div className="flex w-full flex-col gap-3">
<div className="flex justify-between items-center gap-3"> <div className="flex items-center justify-between gap-3">
<div className="txt-title-s">My Chat Persona</div> <div className="txt-title-s">My Chat Persona</div>
<div className="txt-label-m text-primary-variant-normal cursor-pointer" onClick={() => setIsChatProfileEditDrawerOpen(true)}>Edit</div> <div
className="txt-label-m text-primary-variant-normal cursor-pointer"
onClick={() => setIsChatProfileEditDrawerOpen(true)}
>
Edit
</div>
</div> </div>
<div className="bg-surface-base-normal py-1 rounded-m"> <div className="bg-surface-base-normal rounded-m py-1">
<div className="py-3 px-4 flex justify-between items-center gap-4"> <div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="txt-label-l text-txt-secondary-normal">Nickname</div> <div className="txt-label-l text-txt-secondary-normal">Nickname</div>
<div className="txt-body-l text-txt-primary-normal truncate flex-1 text-right">{nickname}</div> <div className="txt-body-l text-txt-primary-normal flex-1 truncate text-right">
{nickname}
</div>
</div> </div>
<div className="py-3 px-4 flex justify-between items-center gap-4"> <div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="txt-label-l text-txt-secondary-normal">Gender</div> <div className="txt-label-l text-txt-secondary-normal">Gender</div>
<div className="txt-body-l text-txt-primary-normal">{genderMap[sex as keyof typeof genderMap]}</div> <div className="txt-body-l text-txt-primary-normal">
{genderMap[sex as keyof typeof genderMap]}
</div>
</div> </div>
<div className="py-3 px-4 flex justify-between items-center gap-4"> <div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="txt-label-l text-txt-secondary-normal">Age</div> <div className="txt-label-l text-txt-secondary-normal">Age</div>
<div className="txt-body-l text-txt-primary-normal">{getAge(Number(birthday))}</div> <div className="txt-body-l text-txt-primary-normal">{getAge(Number(birthday))}</div>
</div> </div>
<div className="py-3 px-4 flex justify-between items-center gap-4"> <div className="flex items-center justify-between gap-4 px-4 py-3">
<div className="txt-label-l text-txt-secondary-normal">Who am I</div> <div className="txt-label-l text-txt-secondary-normal">Who am I</div>
<div className={cn("txt-body-l text-txt-primary-normal truncate flex-1 text-right", whoAmI ? 'text-txt-primary-normal' : 'text-txt-secondary-normal')}>{whoAmI || 'Unfilled'}</div> <div
className={cn(
'txt-body-l text-txt-primary-normal flex-1 truncate text-right',
whoAmI ? 'text-txt-primary-normal' : 'text-txt-secondary-normal'
)}
>
{whoAmI || 'Unfilled'}
</div>
</div> </div>
</div> </div>
</div> </div>
); )
} }
export default ChatProfilePersona; export default ChatProfilePersona

View File

@ -1,29 +1,37 @@
"use client"; 'use client'
import { IconButton } from "@/components/ui/button"; import { IconButton } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import {
import useShare from "@/hooks/useShare"; DropdownMenu,
import { useParams } from "next/navigation"; DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import useShare from '@/hooks/useShare'
import { useParams } from 'next/navigation'
const ChatProfileShareIcon = () => { const ChatProfileShareIcon = () => {
const { userId } = useParams(); const { userId } = useParams()
const { shareFacebook, shareTwitter } = useShare(); const { shareFacebook, shareTwitter } = useShare()
const handleShareFacebook = () => { const handleShareFacebook = () => {
shareFacebook({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${userId}` }); shareFacebook({
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${userId}`,
})
} }
const handleShareTwitter = () => { const handleShareTwitter = () => {
shareTwitter({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${userId}` }); shareTwitter({
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${userId}`,
})
} }
return ( return (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<IconButton <IconButton variant="tertiary" size="large">
variant="tertiary"
size="large"
>
<i className="iconfont icon-Share-border" /> <i className="iconfont icon-Share-border" />
</IconButton> </IconButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -38,7 +46,7 @@ const ChatProfileShareIcon = () => {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); )
} }
export default ChatProfileShareIcon; export default ChatProfileShareIcon

View File

@ -1,61 +1,75 @@
"use client" 'use client'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog"; import {
import { useNimConversation, useNimMsgContext } from "@/context/NimChat/useNimChat"; AlertDialog,
import { useAtomValue, useSetAtom } from "jotai"; AlertDialogAction,
import { selectedConversationIdAtom } from "@/atoms/im"; AlertDialogCancel,
import { useState } from "react"; AlertDialogContent,
import { useRouter } from "next/navigation"; AlertDialogDescription,
import { useQueryClient } from "@tanstack/react-query"; AlertDialogFooter,
import { imKeys } from "@/lib/query-keys"; AlertDialogHeader,
import { useChatConfig } from "../../../context/chatConfig"; AlertDialogTitle,
import { useDeleteConversations } from "@/hooks/useIm"; AlertDialogTrigger,
} from '@/components/ui/alert-dialog'
import { useNimConversation, useNimMsgContext } from '@/context/NimChat/useNimChat'
import { useAtomValue, useSetAtom } from 'jotai'
import { selectedConversationIdAtom } from '@/atoms/im'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { useQueryClient } from '@tanstack/react-query'
import { imKeys } from '@/lib/query-keys'
import { useChatConfig } from '../../../context/chatConfig'
import { useDeleteConversations } from '@/hooks/useIm'
const DeleteMessageDialog = ({ const DeleteMessageDialog = ({
open, open,
onOpenChange, onOpenChange,
}: { }: {
open: boolean; open: boolean
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void
}) => { }) => {
const { aiId } = useChatConfig(); const { aiId } = useChatConfig()
const { removeConversationById } = useNimConversation(); const { removeConversationById } = useNimConversation()
const { clearHistoryMessage } = useNimMsgContext(); const { clearHistoryMessage } = useNimMsgContext()
const selectedConversationId = useAtomValue(selectedConversationIdAtom); const selectedConversationId = useAtomValue(selectedConversationIdAtom)
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false)
const setSelectedConversationId = useSetAtom(selectedConversationIdAtom); const setSelectedConversationId = useSetAtom(selectedConversationIdAtom)
const router = useRouter(); const router = useRouter()
const queryClient = useQueryClient(); const queryClient = useQueryClient()
const { mutateAsync: deleteConversations } = useDeleteConversations();
const { mutateAsync: deleteConversations } = useDeleteConversations()
const handleDeleteMessage = async () => { const handleDeleteMessage = async () => {
if (!selectedConversationId) return; if (!selectedConversationId) return
setLoading(true); setLoading(true)
await removeConversationById(selectedConversationId); await removeConversationById(selectedConversationId)
await clearHistoryMessage(selectedConversationId); await clearHistoryMessage(selectedConversationId)
await deleteConversations({ aiIdList: [aiId] }); await deleteConversations({ aiIdList: [aiId] })
setSelectedConversationId(null); setSelectedConversationId(null)
router.push("/chat") router.push('/chat')
queryClient.invalidateQueries({ queryKey: imKeys.imUserInfo(aiId) }) queryClient.invalidateQueries({ queryKey: imKeys.imUserInfo(aiId) })
setLoading(false); setLoading(false)
onOpenChange(false); onOpenChange(false)
} }
return ( return (
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete</AlertDialogTitle> <AlertDialogTitle>Delete</AlertDialogTitle>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogDescription>Deletion is permanent. Your accumulated Affection points and the character's memories will not be affected. Please confirm deletion.</AlertDialogDescription> <AlertDialogDescription>
Deletion is permanent. Your accumulated Affection points and the character's memories will
not be affected. Please confirm deletion.
</AlertDialogDescription>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction variant="destructive" disabled={loading} onClick={handleDeleteMessage}>Delete</AlertDialogAction> <AlertDialogAction variant="destructive" disabled={loading} onClick={handleDeleteMessage}>
Delete
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
); )
} }
export default DeleteMessageDialog; export default DeleteMessageDialog

View File

@ -1,68 +1,84 @@
import { InlineDrawer, InlineDrawerContent } from "../InlineDrawer"; import { InlineDrawer, InlineDrawerContent } from '../InlineDrawer'
import { useAtom, useSetAtom } from "jotai"; import { useAtom, useSetAtom } from 'jotai'
import { isChatBackgroundDrawerOpenAtom, isChatProfileDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat"; import {
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; isChatBackgroundDrawerOpenAtom,
import { Tag } from "@/components/ui/tag"; isChatProfileDrawerOpenAtom,
import { Switch } from "@/components/ui/switch"; createDrawerOpenState,
import { useEffect, useState } from "react"; } from '@/atoms/chat'
import ChatProfileAction from "./ChatProfileAction"; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
import { useChatConfig } from "../../../context/chatConfig"; import { Tag } from '@/components/ui/tag'
import Image from "next/image"; import { Switch } from '@/components/ui/switch'
import { formatNumberToKMB, getAge } from "@/lib/utils"; import { useEffect, useState } from 'react'
import { useGetAIUserStat } from "@/hooks/aiUser"; import ChatProfileAction from './ChatProfileAction'
import { IconButton } from "@/components/ui/button"; import { useChatConfig } from '../../../context/chatConfig'
import { useGetMyChatSetting, useSetAutoPlayVoice } from "@/hooks/useIm"; import Image from 'next/image'
import ChatProfilePersona from "./ChatProfilePersona"; import { formatNumberToKMB, getAge } from '@/lib/utils'
import { isChatButtleDrawerOpenAtom, isChatModelDrawerOpenAtom } from "@/atoms/chat"; import { useGetAIUserStat } from '@/hooks/aiUser'
import { Badge } from "@/components/ui/badge"; import { IconButton } from '@/components/ui/button'
import { useRedDot, RED_DOT_KEYS } from "@/hooks/useRedDot"; import { useGetMyChatSetting, useSetAutoPlayVoice } from '@/hooks/useIm'
import { imKeys } from "@/lib/query-keys"; import ChatProfilePersona from './ChatProfilePersona'
import { AiUserImBaseInfoOutput } from "@/services/im"; import { isChatButtleDrawerOpenAtom, isChatModelDrawerOpenAtom } from '@/atoms/chat'
import { useQueryClient } from "@tanstack/react-query"; import { Badge } from '@/components/ui/badge'
import CrushLevelAvatar from "../../CrushLevelAvatar"; import { useRedDot, RED_DOT_KEYS } from '@/hooks/useRedDot'
import ChatProfileLikeAction from "./ChatProfileLikeAction"; import { imKeys } from '@/lib/query-keys'
import { isVipDrawerOpenAtom } from "@/atoms/im"; import { AiUserImBaseInfoOutput } from '@/services/im'
import { useCurrentUser } from "@/hooks/auth"; import { useQueryClient } from '@tanstack/react-query'
import { VipType } from "@/services/wallet"; import CrushLevelAvatar from '../../CrushLevelAvatar'
import Decimal from "decimal.js"; import ChatProfileLikeAction from './ChatProfileLikeAction'
import { isVipDrawerOpenAtom } from '@/atoms/im'
import { useCurrentUser } from '@/hooks/auth'
import { VipType } from '@/services/wallet'
import Decimal from 'decimal.js'
const genderMap = { const genderMap = {
0: '/icons/male.svg', 0: '/icons/male.svg',
1: '/icons/female.svg', 1: '/icons/female.svg',
2: '/icons/gender-neutral.svg' 2: '/icons/gender-neutral.svg',
} }
const ChatProfileDrawer = () => { const ChatProfileDrawer = () => {
const [drawerState, setDrawerState] = useAtom(isChatProfileDrawerOpenAtom); const [drawerState, setDrawerState] = useAtom(isChatProfileDrawerOpenAtom)
const isChatProfileDrawerOpen = drawerState.open; const isChatProfileDrawerOpen = drawerState.open
const setIsChatProfileDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open)); const setIsChatProfileDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const setModelDrawerState = useSetAtom(isChatModelDrawerOpenAtom);
const setIsChatModelDrawerOpen = (open: boolean) => setModelDrawerState(createDrawerOpenState(open));
const setButtleDrawerState = useSetAtom(isChatButtleDrawerOpenAtom);
const setIsChatButtleDrawerOpen = (open: boolean) => setButtleDrawerState(createDrawerOpenState(open));
const setBackgroundDrawerState = useSetAtom(isChatBackgroundDrawerOpenAtom);
const setIsChatBackgroundDrawerOpen = (open: boolean) => setBackgroundDrawerState(createDrawerOpenState(open));
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom);
const { aiId, aiInfo } = useChatConfig(); const setModelDrawerState = useSetAtom(isChatModelDrawerOpenAtom)
const { data: user } = useCurrentUser() || {}; const setIsChatModelDrawerOpen = (open: boolean) =>
const { isMember } = user || {}; setModelDrawerState(createDrawerOpenState(open))
const setButtleDrawerState = useSetAtom(isChatButtleDrawerOpenAtom)
const setIsChatButtleDrawerOpen = (open: boolean) =>
setButtleDrawerState(createDrawerOpenState(open))
const setBackgroundDrawerState = useSetAtom(isChatBackgroundDrawerOpenAtom)
const setIsChatBackgroundDrawerOpen = (open: boolean) =>
setBackgroundDrawerState(createDrawerOpenState(open))
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom)
const { aiId, aiInfo } = useChatConfig()
const { data: user } = useCurrentUser() || {}
const { isMember } = user || {}
const isOwner = user?.userId === aiInfo?.userId
const isOwner = user?.userId === aiInfo?.userId;
// 使用红点管理hooks // 使用红点管理hooks
const { hasRedDot, markAsViewed } = useRedDot(); const { hasRedDot, markAsViewed } = useRedDot()
const { data: statData } = useGetAIUserStat({ aiId }); const { data: statData } = useGetAIUserStat({ aiId })
const { sex, birthday, characterName, tagName, chatBubble, isDefaultBackground, isAutoPlayVoice, aiUserHeartbeatRelation } = aiInfo || {}; const {
const { likedNum, chatNum, conversationNum, coinNum } = statData || {}; sex,
const { heartbeatLevel } = aiUserHeartbeatRelation || {}; birthday,
characterName,
tagName,
chatBubble,
isDefaultBackground,
isAutoPlayVoice,
aiUserHeartbeatRelation,
} = aiInfo || {}
const { likedNum, chatNum, conversationNum, coinNum } = statData || {}
const { heartbeatLevel } = aiUserHeartbeatRelation || {}
const { mutate: setAutoPlayVoice } = useSetAutoPlayVoice(); const { mutate: setAutoPlayVoice } = useSetAutoPlayVoice()
const queryClient = useQueryClient(); const queryClient = useQueryClient()
useEffect(() => { useEffect(() => {
if (isChatProfileDrawerOpen) { if (isChatProfileDrawerOpen) {
@ -87,12 +103,12 @@ const ChatProfileDrawer = () => {
label: 'Crush Coin', label: 'Crush Coin',
value: formatNumberToKMB(new Decimal(coinNum || 0).div(100).toNumber()), value: formatNumberToKMB(new Decimal(coinNum || 0).div(100).toNumber()),
}, },
].filter(Boolean) as { label: string; value: string | number }[]; ].filter(Boolean) as { label: string; value: string | number }[]
const handleClose = () => { const handleClose = () => {
setIsChatProfileDrawerOpen(false); setIsChatProfileDrawerOpen(false)
}; }
return ( return (
<InlineDrawer <InlineDrawer
id="chatProfile" id="chatProfile"
@ -102,21 +118,40 @@ const ChatProfileDrawer = () => {
> >
<InlineDrawerContent> <InlineDrawerContent>
{/* <div className="absolute top-0 left-0 right-0 h-[228px]" style={{ background: "linear-gradient(0deg, rgba(194, 65, 230, 0) 0%, #F264A4 100%)" }} /> */} {/* <div className="absolute top-0 left-0 right-0 h-[228px]" style={{ background: "linear-gradient(0deg, rgba(194, 65, 230, 0) 0%, #F264A4 100%)" }} /> */}
<div className="relative w-full h-full"> <div className="relative h-full w-full">
{/* Header with back and more buttons */} {/* Header with back and more buttons */}
<ChatProfileAction /> <ChatProfileAction />
<IconButton iconfont="icon-arrow-right" variant="ghost" size="small" className="absolute top-6 left-6" onClick={handleClose} /> <IconButton
iconfont="icon-arrow-right"
variant="ghost"
size="small"
className="absolute top-6 left-6"
onClick={handleClose}
/>
<div className="w-full h-full overflow-y-auto" > <div className="h-full w-full overflow-y-auto">
<div
<div className="flex flex-col gap-6 items-center justify-start px-6 pb-10 pt-12" style={{ background: heartbeatLevel ? "linear-gradient(0deg, rgba(194, 65, 230, 0) 0%, #F264A4 100%)" : undefined, backgroundSize: "100% 228px", backgroundRepeat: "no-repeat" }}> className="flex flex-col items-center justify-start gap-6 px-6 pt-12 pb-10"
style={{
background: heartbeatLevel
? 'linear-gradient(0deg, rgba(194, 65, 230, 0) 0%, #F264A4 100%)'
: undefined,
backgroundSize: '100% 228px',
backgroundRepeat: 'no-repeat',
}}
>
<CrushLevelAvatar showText /> <CrushLevelAvatar showText />
{/* Name and Tags */} {/* Name and Tags */}
<div className="flex flex-col gap-4 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-4">
<div className="flex flex-wrap gap-2 items-start justify-center w-full"> <div className="flex w-full flex-wrap items-start justify-center gap-2">
<Tag> <Tag>
<Image src={genderMap[sex as keyof typeof genderMap]} alt="Gender" width={16} height={16} /> <Image
src={genderMap[sex as keyof typeof genderMap]}
alt="Gender"
width={16}
height={16}
/>
<div>{getAge(Number(birthday))}</div> <div>{getAge(Number(birthday))}</div>
</Tag> </Tag>
<Tag>{characterName}</Tag> <Tag>{characterName}</Tag>
@ -127,50 +162,45 @@ const ChatProfileDrawer = () => {
<ChatProfileLikeAction /> <ChatProfileLikeAction />
{/* Statistics */} {/* Statistics */}
<div className="flex flex-row items-start justify-start px-1 py-0 w-full"> <div className="flex w-full flex-row items-start justify-start px-1 py-0">
{ {statList.map((item, index) => (
statList.map((item, index) => ( <div
<div key={index} className="flex-1 flex flex-col gap-1 items-center justify-start px-1 py-0"> key={index}
<div className="txt-numDisplay-s text-txt-primary-normal">{item.value}</div> className="flex flex-1 flex-col items-center justify-start gap-1 px-1 py-0"
<div className="txt-label-s text-txt-secondary-normal">{item.label}</div> >
</div> <div className="txt-numDisplay-s text-txt-primary-normal">{item.value}</div>
)) <div className="txt-label-s text-txt-secondary-normal">{item.label}</div>
} </div>
))}
</div> </div>
<ChatProfilePersona /> <ChatProfilePersona />
{/* Chat Setting */} {/* Chat Setting */}
<div className="flex flex-col gap-3 items-start justify-start w-full"> <div className="flex w-full flex-col items-start justify-start gap-3">
<div className="text-left w-full txt-title-s"> <div className="txt-title-s w-full text-left">Chat Setting</div>
Chat Setting <div className="flex w-full flex-col items-start justify-start gap-4">
</div>
<div className="flex flex-col gap-4 items-start justify-start w-full">
<div <div
className="bg-surface-base-normal rounded-m flex justify-between items-center px-4 py-3 w-full cursor-pointer" className="bg-surface-base-normal rounded-m flex w-full cursor-pointer items-center justify-between px-4 py-3"
onClick={() => { onClick={() => {
setIsChatModelDrawerOpen(true) setIsChatModelDrawerOpen(true)
}} }}
> >
<div className="txt-label-l flex-shrink-0">Chat Model</div> <div className="txt-label-l flex-shrink-0">Chat Model</div>
<div <div className="flex min-w-0 flex-1 cursor-pointer items-center justify-end gap-2">
className="flex items-center justify-end gap-2 cursor-pointer flex-1 min-w-0 " <div className="txt-body-l text-txt-primary-normal text-right">
> Role-Playing
<div className="txt-body-l text-txt-primary-normal text-right">Role-Playing</div> </div>
<IconButton <IconButton iconfont="icon-arrow-right-border" size="small" variant="ghost" />
iconfont="icon-arrow-right-border"
size="small"
variant="ghost"
/>
</div> </div>
</div> </div>
<div className="bg-surface-base-normal rounded-m py-1 w-full"> <div className="bg-surface-base-normal rounded-m w-full py-1">
<div <div
className="flex justify-between items-center px-4 py-3 w-full cursor-pointer" className="flex w-full cursor-pointer items-center justify-between px-4 py-3"
onClick={() => { onClick={() => {
markAsViewed(RED_DOT_KEYS.CHAT_BUBBLE); markAsViewed(RED_DOT_KEYS.CHAT_BUBBLE)
setIsChatButtleDrawerOpen(true); setIsChatButtleDrawerOpen(true)
}} }}
> >
<div className="txt-label-l flex-1"> <div className="txt-label-l flex-1">
@ -179,23 +209,21 @@ const ChatProfileDrawer = () => {
{hasRedDot(RED_DOT_KEYS.CHAT_BUBBLE) && <Badge variant="dot" />} {hasRedDot(RED_DOT_KEYS.CHAT_BUBBLE) && <Badge variant="dot" />}
</div> </div>
</div> </div>
<div <div className="flex min-w-0 flex-1 cursor-pointer items-center justify-end gap-2">
className="flex items-center justify-end gap-2 cursor-pointer flex-1 min-w-0"
>
{/* <div className="txt-body-l text-txt-primary-normal truncate">{chatBubble?.name || 'Default'}</div> */} {/* <div className="txt-body-l text-txt-primary-normal truncate">{chatBubble?.name || 'Default'}</div> */}
<IconButton <IconButton
iconfont="icon-arrow-right-border" iconfont="icon-arrow-right-border"
size="small" size="small"
variant="ghost" variant="ghost"
/> />
</div> </div>
</div> </div>
<div <div
className="flex justify-between items-center px-4 py-3 w-full cursor-pointer" className="flex w-full cursor-pointer items-center justify-between px-4 py-3"
onClick={() => { onClick={() => {
markAsViewed(RED_DOT_KEYS.CHAT_BACKGROUND); markAsViewed(RED_DOT_KEYS.CHAT_BACKGROUND)
setIsChatBackgroundDrawerOpen(true); setIsChatBackgroundDrawerOpen(true)
}} }}
> >
<div className="txt-label-l flex-1"> <div className="txt-label-l flex-1">
@ -204,42 +232,40 @@ const ChatProfileDrawer = () => {
{hasRedDot(RED_DOT_KEYS.CHAT_BACKGROUND) && <Badge variant="dot" />} {hasRedDot(RED_DOT_KEYS.CHAT_BACKGROUND) && <Badge variant="dot" />}
</div> </div>
</div> </div>
<div <div className="flex min-w-0 flex-1 cursor-pointer items-center justify-end gap-2">
className="flex items-center justify-end gap-2 cursor-pointer flex-1 min-w-0"
>
{/* <div className="txt-body-l text-txt-primary-normal truncate">{isDefaultBackground ? 'Default' : ''}</div> */} {/* <div className="txt-body-l text-txt-primary-normal truncate">{isDefaultBackground ? 'Default' : ''}</div> */}
<IconButton <IconButton
iconfont="icon-arrow-right-border" iconfont="icon-arrow-right-border"
size="small" size="small"
variant="ghost" variant="ghost"
/> />
</div> </div>
</div> </div>
</div> </div>
<div <div
className="bg-surface-base-normal rounded-m flex justify-between items-center px-4 py-3 w-full cursor-pointer" className="bg-surface-base-normal rounded-m flex w-full cursor-pointer items-center justify-between px-4 py-3"
onClick={() => { onClick={() => {
const checked = !isAutoPlayVoice; const checked = !isAutoPlayVoice
if (!isMember) { if (!isMember) {
setIsVipDrawerOpen({ open: true, vipType: VipType.AUTO_PLAY_VOICE }) setIsVipDrawerOpen({ open: true, vipType: VipType.AUTO_PLAY_VOICE })
return; return
} }
queryClient.setQueryData(imKeys.imUserInfo(aiId), (oldData: AiUserImBaseInfoOutput) => { queryClient.setQueryData(
return { imKeys.imUserInfo(aiId),
...oldData, (oldData: AiUserImBaseInfoOutput) => {
isAutoPlayVoice: checked, return {
...oldData,
isAutoPlayVoice: checked,
}
} }
}) )
setAutoPlayVoice({ aiId, isAutoPlayVoice: checked }) setAutoPlayVoice({ aiId, isAutoPlayVoice: checked })
}} }}
> >
<div className="txt-label-l flex-1">Auto play voice</div> <div className="txt-label-l flex-1">Auto play voice</div>
<div className="h-8 flex items-center"> <div className="flex h-8 items-center">
<Switch <Switch size="sm" checked={!!isAutoPlayVoice} />
size="sm"
checked={!!isAutoPlayVoice}
/>
</div> </div>
</div> </div>
</div> </div>
@ -249,7 +275,7 @@ const ChatProfileDrawer = () => {
</div> </div>
</InlineDrawerContent> </InlineDrawerContent>
</InlineDrawer> </InlineDrawer>
); )
} }
export default ChatProfileDrawer; export default ChatProfileDrawer

View File

@ -1,117 +1,153 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from 'react'
import { InlineDrawer, InlineDrawerContent, InlineDrawerDescription, InlineDrawerFooter, InlineDrawerHeader } from "./InlineDrawer"; import {
import { z } from "zod"; InlineDrawer,
import dayjs from "dayjs"; InlineDrawerContent,
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" InlineDrawerDescription,
import { zodResolver } from "@hookform/resolvers/zod" InlineDrawerFooter,
import { useForm } from "react-hook-form" InlineDrawerHeader,
import { Gender } from "@/types/user"; } from './InlineDrawer'
import { Input } from "@/components/ui/input"; import { z } from 'zod'
import GenderInput from "@/components/features/genderInput"; import dayjs from 'dayjs'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import {
import { Label } from "@/components/ui/label"; Form,
import { isChatProfileEditDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat"; FormControl,
import { useAtom } from "jotai"; FormField,
import { calculateAge, getDaysInMonth } from "@/lib/utils"; FormItem,
import { Textarea } from "@/components/ui/textarea"; FormLabel,
import { Button } from "@/components/ui/button"; FormMessage,
import { useGetMyChatSetting, useSetMyChatSetting } from "@/hooks/useIm"; } from '@/components/ui/form'
import { useChatConfig } from "../../context/chatConfig"; import { zodResolver } from '@hookform/resolvers/zod'
import { import { useForm } from 'react-hook-form'
AlertDialog, import { Gender } from '@/types/user'
AlertDialogAction, import { Input } from '@/components/ui/input'
AlertDialogCancel, import GenderInput from '@/components/features/genderInput'
AlertDialogContent, import {
AlertDialogDescription, Select,
AlertDialogFooter, SelectContent,
AlertDialogHeader, SelectItem,
AlertDialogTitle SelectTrigger,
} from "@/components/ui/alert-dialog"; SelectValue,
import { useCheckNickname } from "@/hooks/auth"; } from '@/components/ui/select'
import { Label } from '@/components/ui/label'
import { isChatProfileEditDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat'
import { useAtom } from 'jotai'
import { calculateAge, getDaysInMonth } from '@/lib/utils'
import { Textarea } from '@/components/ui/textarea'
import { Button } from '@/components/ui/button'
import { useGetMyChatSetting, useSetMyChatSetting } from '@/hooks/useIm'
import { useChatConfig } from '../../context/chatConfig'
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { useCheckNickname } from '@/hooks/auth'
const currentYear = dayjs().year() const currentYear = dayjs().year()
const years = Array.from({ length: currentYear - 1950 + 1 }, (_, i) => `${1950 + i}`) const years = Array.from({ length: currentYear - 1950 + 1 }, (_, i) => `${1950 + i}`)
const months = Array.from({ length: 12 }, (_, i) => `${i + 1}`.padStart(2, "0")) const months = Array.from({ length: 12 }, (_, i) => `${i + 1}`.padStart(2, '0'))
const monthTexts = Array.from({ length: 12 }, (_, i) => dayjs().month(i).format('MMM')) const monthTexts = Array.from({ length: 12 }, (_, i) => dayjs().month(i).format('MMM'))
const characterFormSchema = z.object({ const characterFormSchema = z
nickname: z.string().trim().min(1, "Please Enter nickname").min(2, "Nickname must be between 2 and 20 characters").max(20, "Nickname must be less than 20 characters"), .object({
sex: z.enum(Gender, { message: "Please select gender" }), nickname: z
year: z.string().min(1, "Please select year"), .string()
month: z.string().min(1, "Please select month"), .trim()
day: z.string().min(1, "Please select day"), .min(1, 'Please Enter nickname')
profile: z.string().trim().optional(), .min(2, 'Nickname must be between 2 and 20 characters')
}).refine((data) => { .max(20, 'Nickname must be less than 20 characters'),
const age = calculateAge(data.year, data.month, data.day) sex: z.enum(Gender, { message: 'Please select gender' }),
return age >= 18 year: z.string().min(1, 'Please select year'),
}, { month: z.string().min(1, 'Please select month'),
message: "Character age must be at least 18 years old", day: z.string().min(1, 'Please select day'),
path: ["year"] profile: z.string().trim().optional(),
}).refine((data) => { })
if (data.profile) { .refine(
if (data.profile.trim().length > 300) { (data) => {
return false; const age = calculateAge(data.year, data.month, data.day)
return age >= 18
},
{
message: 'Character age must be at least 18 years old',
path: ['year'],
} }
return data.profile.trim().length >= 10; )
} .refine(
return true; (data) => {
}, { if (data.profile) {
message: "At least 10 characters", if (data.profile.trim().length > 300) {
path: ["profile"] return false
}) }
return data.profile.trim().length >= 10
}
return true
},
{
message: 'At least 10 characters',
path: ['profile'],
}
)
const ChatProfileEditDrawer = () => { const ChatProfileEditDrawer = () => {
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [showConfirmDialog, setShowConfirmDialog] = useState(false)
const { aiId } = useChatConfig(); const { aiId } = useChatConfig()
const [drawerState, setDrawerState] = useAtom(isChatProfileEditDrawerOpenAtom); const [drawerState, setDrawerState] = useAtom(isChatProfileEditDrawerOpenAtom)
const open = drawerState.open; const open = drawerState.open
const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open)); const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const { data: chatSettingData } = useGetMyChatSetting({ aiId }); const { data: chatSettingData } = useGetMyChatSetting({ aiId })
const { mutateAsync: setMyChatSetting } = useSetMyChatSetting({ aiId }); const { mutateAsync: setMyChatSetting } = useSetMyChatSetting({ aiId })
const birthday = chatSettingData?.birthday ? dayjs(chatSettingData.birthday) : undefined; const birthday = chatSettingData?.birthday ? dayjs(chatSettingData.birthday) : undefined
const form = useForm<z.infer<typeof characterFormSchema>>({ const form = useForm<z.infer<typeof characterFormSchema>>({
resolver: zodResolver(characterFormSchema), resolver: zodResolver(characterFormSchema),
defaultValues: { defaultValues: {
nickname: chatSettingData?.nickname || "", nickname: chatSettingData?.nickname || '',
sex: chatSettingData?.sex || undefined, sex: chatSettingData?.sex || undefined,
year: birthday?.year().toString() || undefined, year: birthday?.year().toString() || undefined,
month: birthday?.month() !== undefined ? (birthday.month() + 1).toString().padStart(2, '0') : undefined, month:
birthday?.month() !== undefined
? (birthday.month() + 1).toString().padStart(2, '0')
: undefined,
day: birthday?.date().toString().padStart(2, '0') || undefined, day: birthday?.date().toString().padStart(2, '0') || undefined,
profile: chatSettingData?.whoAmI || "", profile: chatSettingData?.whoAmI || '',
}, },
}) })
const { mutateAsync: checkNickname } = useCheckNickname({ const { mutateAsync: checkNickname } = useCheckNickname({
onError: (error) => { onError: (error) => {
form.setError("nickname", { form.setError('nickname', {
message: error.errorMsg, message: error.errorMsg,
}) })
} },
}); })
// 处理关闭抽屉的逻辑 // 处理关闭抽屉的逻辑
const handleCloseDrawer = useCallback(() => { const handleCloseDrawer = useCallback(() => {
if (form.formState.isDirty) { if (form.formState.isDirty) {
setShowConfirmDialog(true); setShowConfirmDialog(true)
} else { } else {
setOpen(false); setOpen(false)
} }
}, [form.formState.isDirty, setOpen]); }, [form.formState.isDirty, setOpen])
// 确认放弃修改 // 确认放弃修改
const handleConfirmDiscard = useCallback(() => { const handleConfirmDiscard = useCallback(() => {
form.reset(); form.reset()
setShowConfirmDialog(false); setShowConfirmDialog(false)
setOpen(false); setOpen(false)
}, [form, setOpen]); }, [form, setOpen])
// 取消放弃修改 // 取消放弃修改
const handleCancelDiscard = useCallback(() => { const handleCancelDiscard = useCallback(() => {
setShowConfirmDialog(false); setShowConfirmDialog(false)
}, []); }, [])
useEffect(() => { useEffect(() => {
if (chatSettingData) { if (chatSettingData) {
@ -119,7 +155,10 @@ const ChatProfileEditDrawer = () => {
nickname: chatSettingData.nickname, nickname: chatSettingData.nickname,
sex: chatSettingData.sex, sex: chatSettingData.sex,
year: birthday?.year().toString(), year: birthday?.year().toString(),
month: birthday?.month() !== undefined ? (birthday.month() + 1).toString().padStart(2, '0') : undefined, month:
birthday?.month() !== undefined
? (birthday.month() + 1).toString().padStart(2, '0')
: undefined,
day: birthday?.date().toString().padStart(2, '0'), day: birthday?.date().toString().padStart(2, '0'),
profile: chatSettingData.whoAmI || '', profile: chatSettingData.whoAmI || '',
}) })
@ -130,23 +169,23 @@ const ChatProfileEditDrawer = () => {
useEffect(() => { useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape' && open) { if (event.key === 'Escape' && open) {
handleCloseDrawer(); handleCloseDrawer()
} }
}; }
if (open) { if (open) {
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown)
} }
return () => { return () => {
document.removeEventListener('keydown', handleKeyDown); document.removeEventListener('keydown', handleKeyDown)
}; }
}, [open, handleCloseDrawer]); }, [open, handleCloseDrawer])
async function onSubmit(data: z.infer<typeof characterFormSchema>) { async function onSubmit(data: z.infer<typeof characterFormSchema>) {
if (!form.formState.isDirty) { if (!form.formState.isDirty) {
setOpen(false) setOpen(false)
return; return
} }
setLoading(true) setLoading(true)
try { try {
@ -154,10 +193,10 @@ const ChatProfileEditDrawer = () => {
nickname: data.nickname.trim(), nickname: data.nickname.trim(),
}) })
if (isExist) { if (isExist) {
form.setError("nickname", { form.setError('nickname', {
message: "This nickname is already taken", message: 'This nickname is already taken',
}) })
return; return
} }
await setMyChatSetting({ await setMyChatSetting({
aiId, aiId,
@ -173,8 +212,8 @@ const ChatProfileEditDrawer = () => {
} }
} }
const selectedYear = form.watch("year") const selectedYear = form.watch('year')
const selectedMonth = form.watch("month") const selectedMonth = form.watch('month')
const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : [] const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : []
const genderTexts = [ const genderTexts = [
@ -192,8 +231,8 @@ const ChatProfileEditDrawer = () => {
}, },
] ]
const gender = form.watch("sex"); const gender = form.watch('sex')
const genderText = genderTexts.find(text => text.value === gender)?.label; const genderText = genderTexts.find((text) => text.value === gender)?.label
return ( return (
<> <>
@ -202,49 +241,49 @@ const ChatProfileEditDrawer = () => {
open={open} open={open}
onOpenChange={(isOpen) => { onOpenChange={(isOpen) => {
if (!isOpen) { if (!isOpen) {
handleCloseDrawer(); handleCloseDrawer()
} else { } else {
setOpen(isOpen); setOpen(isOpen)
} }
}} }}
timestamp={drawerState.timestamp} timestamp={drawerState.timestamp}
> >
<InlineDrawerContent> <InlineDrawerContent>
<InlineDrawerHeader>My Chat Persona</InlineDrawerHeader> <InlineDrawerHeader>My Chat Persona</InlineDrawerHeader>
<InlineDrawerDescription> <InlineDrawerDescription>
<Form {...form}> <Form {...form}>
<form className="space-y-8"> <form className="space-y-8">
<FormField <FormField
control={form.control} control={form.control}
name="nickname" name="nickname"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>Nickname</FormLabel> <FormLabel>Nickname</FormLabel>
<FormControl> <FormControl>
<Input <Input
placeholder="Enter nickname" placeholder="Enter nickname"
maxLength={20} maxLength={20}
error={!!form.formState.errors.nickname} error={!!form.formState.errors.nickname}
{...field} {...field}
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<div className=""> <div className="">
<div className="txt-label-m text-txt-secondary-normal">Gender</div> <div className="txt-label-m text-txt-secondary-normal">Gender</div>
<div className="mt-3"> <div className="mt-3">
<div className="bg-surface-element-normal h-12 rounded-m flex items-center px-4 py-3 txt-body-l text-txt-secondary-disabled"> <div className="bg-surface-element-normal rounded-m txt-body-l text-txt-secondary-disabled flex h-12 items-center px-4 py-3">
{genderText} {genderText}
</div> </div>
<div className="txt-body-s text-txt-secondary-disabled mt-1"> <div className="txt-body-s text-txt-secondary-disabled mt-1">
Please note: gender cannot be changed after setting Please note: gender cannot be changed after setting
</div>
</div> </div>
</div> </div>
</div> {/* <FormField
{/* <FormField
control={form.control} control={form.control}
name="sex" name="sex"
render={({ field }) => ( render={({ field }) => (
@ -260,120 +299,143 @@ const ChatProfileEditDrawer = () => {
</FormItem> </FormItem>
)} )}
/> */} /> */}
<div> <div>
<Label className="block txt-label-m mb-3">Birthday</Label> <Label className="txt-label-m mb-3 block">Birthday</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<FormField <FormField
control={form.control} control={form.control}
name="year" name="year"
render={({ field }) => ( render={({ field }) => (
<FormItem className="flex-1"> <FormItem className="flex-1">
<FormControl> <FormControl>
<Select onValueChange={field.onChange} value={field.value}> <Select onValueChange={field.onChange} value={field.value}>
<SelectTrigger className="w-full"> <SelectTrigger className="w-full">
<SelectValue placeholder="Year" /> <SelectValue placeholder="Year" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{years.map(year => ( {years.map((year) => (
<SelectItem key={year} value={year}>{year}</SelectItem> <SelectItem key={year} value={year}>
))} {year}
</SelectContent> </SelectItem>
</Select> ))}
</FormControl> </SelectContent>
</FormItem> </Select>
)} </FormControl>
/> </FormItem>
<FormField )}
control={form.control} />
name="month" <FormField
render={({ field }) => ( control={form.control}
<FormItem className="flex-1"> name="month"
<FormControl> render={({ field }) => (
<Select onValueChange={field.onChange} value={field.value}> <FormItem className="flex-1">
<SelectTrigger className="w-full"> <FormControl>
<SelectValue placeholder="Month" /> <Select onValueChange={field.onChange} value={field.value}>
</SelectTrigger> <SelectTrigger className="w-full">
<SelectContent> <SelectValue placeholder="Month" />
{months.map((m, index) => <SelectItem key={m} value={m}>{monthTexts[index]}</SelectItem>)} </SelectTrigger>
</SelectContent> <SelectContent>
</Select> {months.map((m, index) => (
</FormControl> <SelectItem key={m} value={m}>
</FormItem> {monthTexts[index]}
)} </SelectItem>
/> ))}
<FormField </SelectContent>
control={form.control} </Select>
name="day" </FormControl>
render={({ field }) => ( </FormItem>
<FormItem className="flex-1"> )}
<FormControl> />
<Select onValueChange={field.onChange} value={field.value}> <FormField
<SelectTrigger className="w-full"> control={form.control}
<SelectValue placeholder="Day" /> name="day"
</SelectTrigger> render={({ field }) => (
<SelectContent> <FormItem className="flex-1">
{days.map(d => <SelectItem key={d} value={d}>{d}</SelectItem>)} <FormControl>
</SelectContent> <Select onValueChange={field.onChange} value={field.value}>
</Select> <SelectTrigger className="w-full">
</FormControl> <SelectValue placeholder="Day" />
</FormItem> </SelectTrigger>
)} <SelectContent>
/> {days.map((d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</FormItem>
)}
/>
</div>
<FormMessage>
{form.formState.errors.year?.message ||
form.formState.errors.month?.message ||
form.formState.errors.day?.message}
</FormMessage>
</div> </div>
<FormMessage>
{form.formState.errors.year?.message || form.formState.errors.month?.message || form.formState.errors.day?.message}
</FormMessage>
</div>
<FormField <FormField
control={form.control} control={form.control}
name="profile" name="profile"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel>My Persona<span className="txt-label-m text-txt-secondary-normal">(Optional)</span></FormLabel> <FormLabel>
<FormControl> My Persona
<Textarea <span className="txt-label-m text-txt-secondary-normal">(Optional)</span>
{...field} </FormLabel>
maxLength={300} <FormControl>
error={!!form.formState.errors.profile} <Textarea
placeholder="Set your own persona in CrushLevel" {...field}
/> maxLength={300}
</FormControl> error={!!form.formState.errors.profile}
<FormMessage /> placeholder="Set your own persona in CrushLevel"
</FormItem> />
)} </FormControl>
/> <FormMessage />
</form> </FormItem>
</Form> )}
</InlineDrawerDescription> />
<InlineDrawerFooter> </form>
<Button variant="tertiary" size="large" onClick={handleCloseDrawer}>Cancel</Button> </Form>
<Button type="submit" size="large" onClick={form.handleSubmit(onSubmit)} loading={loading}>Save</Button> </InlineDrawerDescription>
</InlineDrawerFooter> <InlineDrawerFooter>
</InlineDrawerContent> <Button variant="tertiary" size="large" onClick={handleCloseDrawer}>
</InlineDrawer> Cancel
</Button>
<Button
type="submit"
size="large"
onClick={form.handleSubmit(onSubmit)}
loading={loading}
>
Save
</Button>
</InlineDrawerFooter>
</InlineDrawerContent>
</InlineDrawer>
{/* 确认放弃修改的对话框 */} {/* 确认放弃修改的对话框 */}
<AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Unsaved Edits</AlertDialogTitle> <AlertDialogTitle>Unsaved Edits</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
The edited content will not be saved after exiting. Please confirm whether to continue exiting? The edited content will not be saved after exiting. Please confirm whether to continue
</AlertDialogDescription> exiting?
</AlertDialogHeader> </AlertDialogDescription>
<AlertDialogFooter> </AlertDialogHeader>
<AlertDialogCancel onClick={handleCancelDiscard}> <AlertDialogFooter>
Cancel <AlertDialogCancel onClick={handleCancelDiscard}>Cancel</AlertDialogCancel>
</AlertDialogCancel> <AlertDialogAction variant="destructive" onClick={handleConfirmDiscard}>
<AlertDialogAction variant="destructive" onClick={handleConfirmDiscard}> Exit
Exit </AlertDialogAction>
</AlertDialogAction> </AlertDialogFooter>
</AlertDialogFooter> </AlertDialogContent>
</AlertDialogContent> </AlertDialog>
</AlertDialog>
</> </>
); )
}; }
export default ChatProfileEditDrawer; export default ChatProfileEditDrawer

View File

@ -1,31 +1,31 @@
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { useChatConfig } from "../../../context/chatConfig"; import { useChatConfig } from '../../../context/chatConfig'
import { useCurrentUser } from "@/hooks/auth"; import { useCurrentUser } from '@/hooks/auth'
import Link from "next/link"; import Link from 'next/link'
const CrushLevelAvatarGroup = () => { const CrushLevelAvatarGroup = () => {
const { aiInfo, aiId } = useChatConfig(); const { aiInfo, aiId } = useChatConfig()
const { data: currentUser } = useCurrentUser(); const { data: currentUser } = useCurrentUser()
const { headImage: currentUserHeadImg } = currentUser || {}; const { headImage: currentUserHeadImg } = currentUser || {}
const { headImg, nickname } = aiInfo || {}; const { headImg, nickname } = aiInfo || {}
return ( return (
<div className="flex items-center justify-between px-[42] h-[124px]"> <div className="flex h-[124px] items-center justify-between px-[42]">
<Link className="w-20 h-20" href={`/@${aiId}`}> <Link className="h-20 w-20" href={`/@${aiId}`}>
<Avatar className="w-20 h-20"> <Avatar className="h-20 w-20">
<AvatarImage src={headImg} /> <AvatarImage src={headImg} />
<AvatarFallback>{nickname?.slice(0, 1)}</AvatarFallback> <AvatarFallback>{nickname?.slice(0, 1)}</AvatarFallback>
</Avatar> </Avatar>
</Link> </Link>
<Link className="w-20 h-20" href={`/profile`}> <Link className="h-20 w-20" href={`/profile`}>
<Avatar className="w-20 h-20"> <Avatar className="h-20 w-20">
<AvatarImage src={currentUserHeadImg} /> <AvatarImage src={currentUserHeadImg} />
<AvatarFallback>{currentUser?.nickname?.slice(0, 1) || ''}</AvatarFallback> <AvatarFallback>{currentUser?.nickname?.slice(0, 1) || ''}</AvatarFallback>
</Avatar> </Avatar>
</Link> </Link>
</div> </div>
); )
} }
export default CrushLevelAvatarGroup ; export default CrushLevelAvatarGroup

View File

@ -1,37 +1,37 @@
import { IconButton } from "@/components/ui/button" import { IconButton } from '@/components/ui/button'
import { Tag } from "@/components/ui/tag" import { Tag } from '@/components/ui/tag'
import { cn } from "@/lib/utils" import { cn } from '@/lib/utils'
import { HeartbeatLevelDictOutput } from "@/services/im" import { HeartbeatLevelDictOutput } from '@/services/im'
import Image from "next/image" import Image from 'next/image'
const HeartList = ({
datas,
}: {
datas: HeartbeatLevelDictOutput[] | undefined
}) => {
const HeartList = ({ datas }: { datas: HeartbeatLevelDictOutput[] | undefined }) => {
return ( return (
<div className="mt-6 grid grid-cols-2 gap-4"> <div className="mt-6 grid grid-cols-2 gap-4">
{ {datas?.map((item) => (
datas?.map((item) => ( <div key={item.code}>
<div key={item.code}> <div className="bg-surface-element-normal relative aspect-[41/30] overflow-hidden rounded-lg">
<div className="rounded-lg overflow-hidden relative aspect-[41/30] bg-surface-element-normal"> <Image src={item.imgUrl || ''} alt={item.name || ''} fill className="object-cover" />
<Image src={item.imgUrl || ""} alt={item.name || ""} fill className="object-cover" /> <div className="txt-numMonotype-xs text-txt-secondary-normal absolute bottom-2 left-3">
<div className="absolute left-3 bottom-2 txt-numMonotype-xs text-txt-secondary-normal"> {`${item.startVal}`}
{`${item.startVal}`} </div>
</div> {!item.isUnlock && (
{!item.isUnlock && <Tag size="small" variant="dark" className="absolute top-2 right-2"> <Tag size="small" variant="dark" className="absolute top-2 right-2">
<i className="iconfont icon-private-border !text-[12px]" /> <i className="iconfont icon-private-border !text-[12px]" />
</Tag>} </Tag>
</div> )}
<div className={cn("txt-label-m text-center mt-2", !item.isUnlock && "text-txt-secondary-normal")}>
{item.name}
</div>
</div> </div>
)) <div
} className={cn(
'txt-label-m mt-2 text-center',
!item.isUnlock && 'text-txt-secondary-normal'
)}
>
{item.name}
</div>
</div>
))}
</div> </div>
) )
} }
export default HeartList export default HeartList

View File

@ -1,136 +1,156 @@
import React from "react"; import React from 'react'
import { InlineDrawer, InlineDrawerContent, InlineDrawerDescription, InlineDrawerHeader } from "../InlineDrawer"; import {
import { IconButton, Button } from "@/components/ui/button"; InlineDrawer,
import Image from "next/image"; InlineDrawerContent,
import CrushLevelAvatarGroup from "./CrushLevelAvatarGroup"; InlineDrawerDescription,
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; InlineDrawerHeader,
import { Switch } from "@/components/ui/switch"; } from '../InlineDrawer'
import { useAtom, useSetAtom } from "jotai"; import { IconButton, Button } from '@/components/ui/button'
import { isCrushLevelDrawerOpenAtom, isCrushLevelRetrieveDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat"; import Image from 'next/image'
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import CrushLevelAvatarGroup from './CrushLevelAvatarGroup'
import { useGetHeartbeatLevel, useSetShowRelationship } from "@/hooks/useIm"; import {
import { useChatConfig } from "../../../context/chatConfig"; DropdownMenu,
import HeartList from "./HeartList"; DropdownMenuContent,
import { useHeartLevelTextFromLevel } from "@/hooks/useHeartLevel"; DropdownMenuItem,
import numeral from "numeral"; DropdownMenuTrigger,
import { imKeys } from "@/lib/query-keys"; } from '@/components/ui/dropdown-menu'
import { useQueryClient } from "@tanstack/react-query"; import { Switch } from '@/components/ui/switch'
import { headerLevelDictMap } from "@/components/features/AIRelationTag"; import { useAtom, useSetAtom } from 'jotai'
import {
isCrushLevelDrawerOpenAtom,
isCrushLevelRetrieveDrawerOpenAtom,
createDrawerOpenState,
} from '@/atoms/chat'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { useGetHeartbeatLevel, useSetShowRelationship } from '@/hooks/useIm'
import { useChatConfig } from '../../../context/chatConfig'
import HeartList from './HeartList'
import { useHeartLevelTextFromLevel } from '@/hooks/useHeartLevel'
import numeral from 'numeral'
import { imKeys } from '@/lib/query-keys'
import { useQueryClient } from '@tanstack/react-query'
import { headerLevelDictMap } from '@/components/features/AIRelationTag'
const CrushLevelDrawer = () => { const CrushLevelDrawer = () => {
const [drawerState, setDrawerState] = useAtom(isCrushLevelDrawerOpenAtom); const [drawerState, setDrawerState] = useAtom(isCrushLevelDrawerOpenAtom)
const isCrushLevelDrawerOpen = drawerState.open; const isCrushLevelDrawerOpen = drawerState.open
const setIsCrushLevelDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open)); const setIsCrushLevelDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const setRetrieveDrawerState = useSetAtom(isCrushLevelRetrieveDrawerOpenAtom); const setRetrieveDrawerState = useSetAtom(isCrushLevelRetrieveDrawerOpenAtom)
const setIsCrushLevelRetrieveDrawerOpen = (open: boolean) => setRetrieveDrawerState(createDrawerOpenState(open)); const setIsCrushLevelRetrieveDrawerOpen = (open: boolean) =>
const { aiId, aiInfo } = useChatConfig(); setRetrieveDrawerState(createDrawerOpenState(open))
const queryClient = useQueryClient(); const { aiId, aiInfo } = useChatConfig()
const queryClient = useQueryClient()
// 图片加载状态管理 // 图片加载状态管理
const [isBgTopLoaded, setIsBgTopLoaded] = React.useState(false); const [isBgTopLoaded, setIsBgTopLoaded] = React.useState(false)
const [showHeartImage, setShowHeartImage] = React.useState(false); const [showHeartImage, setShowHeartImage] = React.useState(false)
const { aiUserHeartbeatRelation } = aiInfo || {}; const { data, refetch } = useGetHeartbeatLevel({
const { data, refetch } = useGetHeartbeatLevel({
aiId: Number(aiId), aiId: Number(aiId),
enabled: false // 禁用自动查询,手动控制何时获取数据 enabled: false, // 禁用自动查询,手动控制何时获取数据
}); })
const { heartbeatLeveLDictList } = data || {}; const { heartbeatLeveLDictList, aiUserHeartbeatRelation } = data || {}
const { heartbeatLevel, heartbeatVal, heartbeatScore, dayCount, subtractHeartbeatVal, isShow } = aiUserHeartbeatRelation || {}; const { heartbeatLevel, heartbeatVal, heartbeatScore, dayCount, subtractHeartbeatVal, isShow } =
const heartLevelText = useHeartLevelTextFromLevel(heartbeatLevel); aiUserHeartbeatRelation || {}
const heartLevelText = useHeartLevelTextFromLevel(heartbeatLevel)
const { mutate: setShowRelationship, isPending: isSetShowRelationshipPending } = useSetShowRelationship({ aiId: Number(aiId) }); const { mutate: setShowRelationship, isPending: isSetShowRelationshipPending } =
useSetShowRelationship({ aiId: Number(aiId) })
// 计算心的位置 // 计算心的位置
const calculateHeartPosition = () => { const calculateHeartPosition = () => {
const defaultTop = 150; const defaultTop = 150
if (!heartbeatVal || !heartbeatLeveLDictList || heartbeatLeveLDictList.length === 0) { if (!heartbeatVal || !heartbeatLeveLDictList || heartbeatLeveLDictList.length === 0) {
return defaultTop; // 默认位置 return defaultTop // 默认位置
} }
// 获取最低等级的起始值和最高等级的起始值 // 获取最低等级的起始值和最高等级的起始值
const sortedLevels = [...heartbeatLeveLDictList].sort((a, b) => (a.startVal || 0) - (b.startVal || 0)); const sortedLevels = [...heartbeatLeveLDictList].sort(
const minStartVal = sortedLevels[0]?.startVal || 0; (a, b) => (a.startVal || 0) - (b.startVal || 0)
const maxStartVal = sortedLevels[sortedLevels.length - 1]?.startVal || 0; )
const minStartVal = sortedLevels[0]?.startVal || 0
const maxStartVal = sortedLevels[sortedLevels.length - 1]?.startVal || 0
// 如果只有一个等级或者最大最小值相等,使用默认位置 // 如果只有一个等级或者最大最小值相等,使用默认位置
if (maxStartVal <= minStartVal) { if (maxStartVal <= minStartVal) {
return defaultTop; return defaultTop
} }
// 计算当前心动值在整个等级系统中的总进度0-1 // 计算当前心动值在整个等级系统中的总进度0-1
const totalProgress = Math.max(0, Math.min(1, (heartbeatVal - minStartVal) / (maxStartVal - minStartVal))); const totalProgress = Math.max(
0,
Math.min(1, (heartbeatVal - minStartVal) / (maxStartVal - minStartVal))
)
// 将总进度映射到位置范围top 75空心到 top 150全心 // 将总进度映射到位置范围top 75空心到 top 150全心
const minTop = defaultTop; // 空心位置 const minTop = defaultTop // 空心位置
const maxTop = 75; // 全心位置 const maxTop = 75 // 全心位置
const calculatedTop = minTop + (totalProgress * (maxTop - minTop)); const calculatedTop = minTop + totalProgress * (maxTop - minTop)
return calculatedTop; return calculatedTop
}; }
const heartTop = calculateHeartPosition(); const heartTop = calculateHeartPosition()
// 根据位置决定心的显示状态 // 根据位置决定心的显示状态
const getHeartImageSrc = () => { const getHeartImageSrc = () => {
// top 75为空心top 150为全心 // top 75为空心top 150为全心
// 根据位置变化动态调整心的显示状态 // 根据位置变化动态调整心的显示状态
// 目前只有一个心的图片可以通过透明度或其他CSS属性来模拟空心/全心效果 // 目前只有一个心的图片可以通过透明度或其他CSS属性来模拟空心/全心效果
// 或者后续可以添加不同的图片资源 // 或者后续可以添加不同的图片资源
return "/images/crushlevel/heart.png"; return '/images/crushlevel/heart.png'
}; }
// 当抽屉打开时获取心动等级数据 // 当抽屉打开时获取心动等级数据
React.useEffect(() => { React.useEffect(() => {
if (isCrushLevelDrawerOpen) { if (isCrushLevelDrawerOpen) {
refetch(); refetch()
// 重置图片加载状态 // 重置图片加载状态
setIsBgTopLoaded(false); setIsBgTopLoaded(false)
setShowHeartImage(false); setShowHeartImage(false)
} }
}, [isCrushLevelDrawerOpen, refetch]); }, [isCrushLevelDrawerOpen, refetch])
// 处理 bg-top 图片加载完成后的延迟显示逻辑 // 处理 bg-top 图片加载完成后的延迟显示逻辑
React.useEffect(() => { React.useEffect(() => {
if (isBgTopLoaded) { if (isBgTopLoaded) {
// bg-top 加载完成后延迟 300ms 显示心形图片 // bg-top 加载完成后延迟 300ms 显示心形图片
const timer = setTimeout(() => { const timer = setTimeout(() => {
setShowHeartImage(true); setShowHeartImage(true)
}, 300); }, 300)
return () => clearTimeout(timer); return () => clearTimeout(timer)
} }
}, [isBgTopLoaded]); }, [isBgTopLoaded])
// bg-top 图片加载完成的处理函数 // bg-top 图片加载完成的处理函数
const handleBgTopLoad = () => { const handleBgTopLoad = () => {
setIsBgTopLoaded(true); setIsBgTopLoaded(true)
}; }
const renderLineText = () => { const renderLineText = () => {
if (!heartbeatVal) { if (!heartbeatVal) {
return ( return (
<div className="flex items-center gap-2 w-full"> <div className="flex w-full items-center gap-2">
<div className="flex-1 h-px bg-outline-normal" /> <div className="bg-outline-normal h-px flex-1" />
<span className="txt-title-m">No Crush Connection Yet</span> <span className="txt-title-m">No Crush Connection Yet</span>
<div className="flex-1 h-px bg-outline-normal" /> <div className="bg-outline-normal h-px flex-1" />
</div> </div>
) )
} }
if (heartbeatLevel && isShow) { if (heartbeatLevel && isShow) {
return ( return (
<div className="flex items-center gap-2 w-full"> <div className="flex w-full items-center gap-2">
<div className="flex-1 h-px bg-outline-normal" /> <div className="bg-outline-normal h-px flex-1" />
<span className="txt-display-s">· {headerLevelDictMap[heartbeatLevel]?.title} ·</span> <span className="txt-display-s">· {headerLevelDictMap[heartbeatLevel]?.title} ·</span>
<div className="flex-1 h-px bg-outline-normal" /> <div className="bg-outline-normal h-px flex-1" />
</div> </div>
) )
} }
return null; return null
} }
return ( return (
@ -142,48 +162,58 @@ const CrushLevelDrawer = () => {
> >
<InlineDrawerContent className="overflow-y-auto"> <InlineDrawerContent className="overflow-y-auto">
{/* 紫色渐变背景 */} {/* 紫色渐变背景 */}
<div className="absolute top-0 left-0 right-0 h-[480px] overflow-hidden"> <div className="absolute top-0 right-0 left-0 h-[480px] overflow-hidden">
<Image src="/images/crushlevel/bg-bottom.png" alt="CrushLevel" fill className="object-cover" /> <Image
src="/images/crushlevel/bg-bottom.png"
alt="CrushLevel"
fill
className="object-cover"
/>
{/* 心形图片 - 只在 bg-top 加载完成后延迟显示 */} {/* 心形图片 - 只在 bg-top 加载完成后延迟显示 */}
{showHeartImage && ( {showHeartImage && (
<Image <Image
src={getHeartImageSrc()} src={getHeartImageSrc()}
alt="Crush Level" alt="Crush Level"
className="absolute left-1/2 -translate-x-1/2 transition-all duration-500 ease-in-out animate-in fade-in-0 slide-in-from-bottom-4" className="animate-in fade-in-0 slide-in-from-bottom-4 absolute left-1/2 -translate-x-1/2 transition-all duration-500 ease-in-out"
width={124} width={124}
height={124} height={124}
style={{ style={{
top: `${heartTop}px`, top: `${heartTop}px`,
}} }}
/> />
)} )}
<Image <Image
src="/images/crushlevel/bg-top.png" src="/images/crushlevel/bg-top.png"
alt="Crush Level" alt="Crush Level"
fill fill
className="object-cover" className="object-cover"
onLoad={handleBgTopLoad} onLoad={handleBgTopLoad}
/> />
</div> </div>
<div className="relative inset-0"> <div className="relative inset-0">
<InlineDrawerHeader> <InlineDrawerHeader>
<div className="flex justify-between items-center"> <div className="flex items-center justify-between">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<div className="txt-title-m">CrushLevel</div> <div className="txt-title-m">CrushLevel</div>
<Tooltip> <Tooltip>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<IconButton <IconButton iconfont="icon-question" variant="tertiaryDark" size="mini" />
iconfont="icon-question"
variant="tertiaryDark"
size="mini"
/>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent> <TooltipContent>
<div className="w-64 space-y-2"> <div className="w-64 space-y-2">
<p>* Increase your Crush Value by chatting or sending gifts. If you dont interact for 24 hours, your Crush Value may gradually decrease.</p> <p>
<p>* Your virtual characters emotional responses during conversations will affect whether the Crush Value goes up or down.</p> * Increase your Crush Value by chatting or sending gifts. If you dont
<p>* A higher Crush Value boosts your Crush Level, unlocking new titles, features, and relationship stages with your character.</p> interact for 24 hours, your Crush Value may gradually decrease.
</p>
<p>
* Your virtual characters emotional responses during conversations will
affect whether the Crush Value goes up or down.
</p>
<p>
* A higher Crush Value boosts your Crush Level, unlocking new titles,
features, and relationship stages with your character.
</p>
</div> </div>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -191,26 +221,22 @@ const CrushLevelDrawer = () => {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<IconButton <IconButton iconfont="icon-More" variant="ghost" size="small" />
iconfont="icon-More"
variant="ghost"
size="small"
/>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.preventDefault(); // 阻止默认行为 e.preventDefault() // 阻止默认行为
e.stopPropagation(); // 阻止事件冒泡 e.stopPropagation() // 阻止事件冒泡
if (isSetShowRelationshipPending) return; if (isSetShowRelationshipPending) return
queryClient.setQueryData(imKeys.heartbeatLevel(aiId), (old: any) => { queryClient.setQueryData(imKeys.heartbeatLevel(aiId), (old: any) => {
return { return {
...old, ...old,
aiUserHeartbeatRelation: { aiUserHeartbeatRelation: {
...old.aiUserHeartbeatRelation, ...old.aiUserHeartbeatRelation,
isShow: !old.aiUserHeartbeatRelation.isShow isShow: !old.aiUserHeartbeatRelation.isShow,
} },
} }
}) })
queryClient.setQueryData(imKeys.imUserInfo(aiId), (old: any) => { queryClient.setQueryData(imKeys.imUserInfo(aiId), (old: any) => {
@ -218,19 +244,24 @@ const CrushLevelDrawer = () => {
...old, ...old,
aiUserHeartbeatRelation: { aiUserHeartbeatRelation: {
...old.aiUserHeartbeatRelation, ...old.aiUserHeartbeatRelation,
isShow: !old.aiUserHeartbeatRelation.isShow isShow: !old.aiUserHeartbeatRelation.isShow,
} },
} }
}) })
setShowRelationship({ aiId: Number(aiId), isShow: !isShow }); setShowRelationship({ aiId: Number(aiId), isShow: !isShow })
}} }}
onSelect={(e) => { onSelect={(e) => {
e.preventDefault(); // 阻止 onSelect 默认关闭行为 e.preventDefault() // 阻止 onSelect 默认关闭行为
}} }}
> >
<div className="w-full flex items-center justify-between"> <div className="flex w-full items-center justify-between">
<div className="txt-body-l flex-1">Hide Relationship</div> <div className="txt-body-l flex-1">Hide Relationship</div>
<Switch size="sm" className="cursor-pointer" checked={!isShow} disabled={isSetShowRelationshipPending} /> <Switch
size="sm"
className="cursor-pointer"
checked={!isShow}
disabled={isSetShowRelationshipPending}
/>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@ -241,40 +272,52 @@ const CrushLevelDrawer = () => {
<div> <div>
{/* 等级和温度信息 */} {/* 等级和温度信息 */}
<div className="flex flex-col items-center gap-4 px-6"> <div className="flex flex-col items-center gap-4 px-6">
{heartbeatVal ? <div className="flex items-center gap-2 txt-numDisplay-s"> {heartbeatVal ? (
<span>{heartLevelText || 'Lv.0'}</span> <div className="txt-numDisplay-s flex items-center gap-2">
<div className="w-px h-[18px] bg-outline-normal" /> <span>{heartLevelText || 'Lv.0'}</span>
<span> <div className="bg-outline-normal h-[18px] w-px" />
{heartbeatVal || 0}<span className="txt-numMonotype-s">&#8451;</span> <span>
</span> {heartbeatVal || 0}
</div> : ( <span className="txt-numMonotype-s">&#8451;</span>
</span>
</div>
) : (
<div className="px-6 text-center"> <div className="px-6 text-center">
<span className="txt-numDisplay-s"> <span className="txt-numDisplay-s">
{heartbeatVal || 0}<span className="txt-numMonotype-s">&#8451;</span> {heartbeatVal || 0}
<span className="txt-numMonotype-s">&#8451;</span>
</span> </span>
</div> </div>
)} )}
{/* Meet 分割线 */} {/* Meet 分割线 */}
{renderLineText()} {renderLineText()}
{/* 描述文本 */} {/* 描述文本 */}
{!!heartbeatVal && <p className="txt-body-s"> {!!heartbeatVal && (
{`Known for ${Math.max((dayCount || 0), 1)} days | Crush Value higher than ${numeral(heartbeatScore).format("0.00%")} of users`} <p className="txt-body-s">
</p>} {`Known for ${Math.max(dayCount || 0, 1)} days | Crush Value higher than ${numeral(heartbeatScore).format('0.00%')} of users`}
</p>
)}
</div> </div>
</div> </div>
{!!subtractHeartbeatVal && <div className="px-6 my-6"> {!!subtractHeartbeatVal && (
<div className="bg-surface-element-normal rounded-m p-4 flex justify-between items-center gap-2"> <div className="my-6 px-6">
<div className="flex-1 txt-body-s">{`Crush Value lost: -${subtractHeartbeatVal}`}</div> <div className="bg-surface-element-normal rounded-m flex items-center justify-between gap-2 p-4">
<Button variant="ghost" size="small" onClick={() => { <div className="txt-body-s flex-1">{`Crush Value lost: -${subtractHeartbeatVal}`}</div>
setIsCrushLevelRetrieveDrawerOpen(true); <Button
}}> variant="ghost"
Retrieve size="small"
</Button> onClick={() => {
setIsCrushLevelRetrieveDrawerOpen(true)
}}
>
Retrieve
</Button>
</div>
</div> </div>
</div>} )}
{/* 权限卡片网格 */} {/* 权限卡片网格 */}
<InlineDrawerDescription className="pb-6"> <InlineDrawerDescription className="pb-6">
@ -283,7 +326,7 @@ const CrushLevelDrawer = () => {
</div> </div>
</InlineDrawerContent> </InlineDrawerContent>
</InlineDrawer> </InlineDrawer>
); )
}; }
export default CrushLevelDrawer; export default CrushLevelDrawer

View File

@ -1,129 +1,144 @@
import Image from "next/image"; import Image from 'next/image'
import { InlineDrawer, InlineDrawerContent, InlineDrawerDescription, InlineDrawerFooter, InlineDrawerHeader } from "./InlineDrawer"; import {
import { Button } from "@/components/ui/button"; InlineDrawer,
import { useAtom, useSetAtom } from "jotai"; InlineDrawerContent,
import { isCrushLevelRetrieveDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat"; InlineDrawerDescription,
import { useBuyHeartbeat, useGetHeartbeatLevel } from "@/hooks/useIm"; InlineDrawerFooter,
import { useChatConfig } from "../../context/chatConfig"; InlineDrawerHeader,
import numeral from "numeral"; } from './InlineDrawer'
import { formatFromCents } from "@/utils/number"; import { Button } from '@/components/ui/button'
import React, { useEffect } from "react"; import { useAtom, useSetAtom } from 'jotai'
import { useGetWalletBalance, useUpdateWalletBalance } from "@/hooks/useWallet"; import { isCrushLevelRetrieveDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat'
import { isChargeDrawerOpenAtom } from "@/atoms/im"; import { useBuyHeartbeat, useGetHeartbeatLevel } from '@/hooks/useIm'
import { useQueryClient } from "@tanstack/react-query"; import { useChatConfig } from '../../context/chatConfig'
import { imKeys, walletKeys } from "@/lib/query-keys"; import numeral from 'numeral'
import { formatFromCents } from '@/utils/number'
import React, { useEffect } from 'react'
import { useGetWalletBalance, useUpdateWalletBalance } from '@/hooks/useWallet'
import { isChargeDrawerOpenAtom } from '@/atoms/im'
import { useQueryClient } from '@tanstack/react-query'
import { imKeys, walletKeys } from '@/lib/query-keys'
const CrushLevelRetrieveDrawer = () => { const CrushLevelRetrieveDrawer = () => {
// 图片加载状态管理 // 图片加载状态管理
const [isBgTopLoaded, setIsBgTopLoaded] = React.useState(false); const [isBgTopLoaded, setIsBgTopLoaded] = React.useState(false)
const [showHeartImage, setShowHeartImage] = React.useState(false); const [showHeartImage, setShowHeartImage] = React.useState(false)
const [loading, setLoading] = React.useState(false); const [loading, setLoading] = React.useState(false)
const [drawerState, setDrawerState] = useAtom(isCrushLevelRetrieveDrawerOpenAtom); const [drawerState, setDrawerState] = useAtom(isCrushLevelRetrieveDrawerOpenAtom)
const isCrushLevelRetrieveDrawerOpen = drawerState.open; const isCrushLevelRetrieveDrawerOpen = drawerState.open
const setIsCrushLevelRetrieveDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open)); const setIsCrushLevelRetrieveDrawerOpen = (open: boolean) =>
const { aiId, aiInfo } = useChatConfig(); setDrawerState(createDrawerOpenState(open))
const { aiId, aiInfo } = useChatConfig()
const { data, refetch } = useGetHeartbeatLevel({ aiId: Number(aiId) }); const { data, refetch } = useGetHeartbeatLevel({ aiId: Number(aiId) })
const { heartbeatLeveLDictList } = data || {}; const { heartbeatLeveLDictList } = data || {}
const { aiUserHeartbeatRelation } = aiInfo || {}; const { aiUserHeartbeatRelation } = aiInfo || {}
const { subtractHeartbeatVal, heartbeatVal, price } = aiUserHeartbeatRelation || {}; const { subtractHeartbeatVal, heartbeatVal, price } = aiUserHeartbeatRelation || {}
const { mutate: retrieveHeartbeatVal, isPending: isRetrieveHeartbeatValPending } = useBuyHeartbeat({ aiId: Number(aiId) });
const { data: walletData } = useGetWalletBalance();
const walletUpdate = useUpdateWalletBalance();
const setIsChargeDrawerOpen = useSetAtom(isChargeDrawerOpenAtom);
const queryClient = useQueryClient();
const { mutateAsync: retrieveHeartbeatVal, isPending: isRetrieveHeartbeatValPending } =
useBuyHeartbeat({ aiId: Number(aiId) })
const { data: walletData } = useGetWalletBalance()
const walletUpdate = useUpdateWalletBalance()
const setIsChargeDrawerOpen = useSetAtom(isChargeDrawerOpenAtom)
const queryClient = useQueryClient()
const handleRetrieveHeartbeatVal = async () => { const handleRetrieveHeartbeatVal = async () => {
if (loading) return; if (loading) return
try { try {
setLoading(true); setLoading(true)
if (!walletUpdate.checkSufficient(Math.max((price || 0) * (subtractHeartbeatVal || 0), 100) / 100)) { if (
setIsChargeDrawerOpen(true); !walletUpdate.checkSufficient(
return; Math.max((price || 0) * (subtractHeartbeatVal || 0), 100) / 100
)
) {
setIsChargeDrawerOpen(true)
return
} }
await retrieveHeartbeatVal({ aiId: Number(aiId), heartbeatVal: subtractHeartbeatVal || 0 }); await retrieveHeartbeatVal({ aiId: Number(aiId), heartbeatVal: subtractHeartbeatVal || 0 })
await queryClient.invalidateQueries({ queryKey: imKeys.heartbeatLevel(Number(aiId)) }) await queryClient.invalidateQueries({ queryKey: imKeys.heartbeatLevel(Number(aiId)) })
await queryClient.invalidateQueries({ queryKey: imKeys.imUserInfo(Number(aiId)) }) await queryClient.invalidateQueries({ queryKey: imKeys.imUserInfo(Number(aiId)) })
await queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() }) await queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() })
setIsCrushLevelRetrieveDrawerOpen(false); setIsCrushLevelRetrieveDrawerOpen(false)
} catch (error) { } catch (error) {
setLoading(false); setLoading(false)
} finally { } finally {
setLoading(false); setLoading(false)
} }
} }
// 计算心的位置 // 计算心的位置
const calculateHeartPosition = () => { const calculateHeartPosition = () => {
const defaultTop = 150; const defaultTop = 150
if (!heartbeatVal || !heartbeatLeveLDictList || heartbeatLeveLDictList.length === 0) { if (!heartbeatVal || !heartbeatLeveLDictList || heartbeatLeveLDictList.length === 0) {
return defaultTop; // 默认位置 return defaultTop // 默认位置
} }
// 获取最低等级的起始值和最高等级的起始值 // 获取最低等级的起始值和最高等级的起始值
const sortedLevels = [...heartbeatLeveLDictList].sort((a, b) => (a.startVal || 0) - (b.startVal || 0)); const sortedLevels = [...heartbeatLeveLDictList].sort(
const minStartVal = sortedLevels[0]?.startVal || 0; (a, b) => (a.startVal || 0) - (b.startVal || 0)
const maxStartVal = sortedLevels[sortedLevels.length - 1]?.startVal || 0; )
const minStartVal = sortedLevels[0]?.startVal || 0
const maxStartVal = sortedLevels[sortedLevels.length - 1]?.startVal || 0
// 如果只有一个等级或者最大最小值相等,使用默认位置 // 如果只有一个等级或者最大最小值相等,使用默认位置
if (maxStartVal <= minStartVal) { if (maxStartVal <= minStartVal) {
return defaultTop; return defaultTop
} }
// 计算当前心动值在整个等级系统中的总进度0-1 // 计算当前心动值在整个等级系统中的总进度0-1
const totalProgress = Math.max(0, Math.min(1, (heartbeatVal - minStartVal) / (maxStartVal - minStartVal))); const totalProgress = Math.max(
0,
Math.min(1, (heartbeatVal - minStartVal) / (maxStartVal - minStartVal))
)
// 将总进度映射到位置范围top 75空心到 top 150全心 // 将总进度映射到位置范围top 75空心到 top 150全心
const minTop = defaultTop; // 空心位置 const minTop = defaultTop // 空心位置
const maxTop = 75; // 全心位置 const maxTop = 75 // 全心位置
const calculatedTop = minTop + (totalProgress * (maxTop - minTop)); const calculatedTop = minTop + totalProgress * (maxTop - minTop)
return calculatedTop; return calculatedTop
}; }
const heartTop = calculateHeartPosition(); const heartTop = calculateHeartPosition()
// 根据位置决定心的显示状态 // 根据位置决定心的显示状态
const getHeartImageSrc = () => { const getHeartImageSrc = () => {
// top 75为空心top 150为全心 // top 75为空心top 150为全心
// 根据位置变化动态调整心的显示状态 // 根据位置变化动态调整心的显示状态
// 目前只有一个心的图片可以通过透明度或其他CSS属性来模拟空心/全心效果 // 目前只有一个心的图片可以通过透明度或其他CSS属性来模拟空心/全心效果
// 或者后续可以添加不同的图片资源 // 或者后续可以添加不同的图片资源
return "/images/crushlevel/heart.png"; return '/images/crushlevel/heart.png'
}; }
// 当抽屉打开时获取心动等级数据 // 当抽屉打开时获取心动等级数据
React.useEffect(() => { React.useEffect(() => {
if (isCrushLevelRetrieveDrawerOpen) { if (isCrushLevelRetrieveDrawerOpen) {
refetch(); refetch()
// 重置图片加载状态 // 重置图片加载状态
setIsBgTopLoaded(false); setIsBgTopLoaded(false)
setShowHeartImage(false); setShowHeartImage(false)
queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() }); queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() })
} }
}, [isCrushLevelRetrieveDrawerOpen, refetch]); }, [isCrushLevelRetrieveDrawerOpen, refetch])
// 处理 bg-top 图片加载完成后的延迟显示逻辑 // 处理 bg-top 图片加载完成后的延迟显示逻辑
React.useEffect(() => { React.useEffect(() => {
if (isBgTopLoaded) { if (isBgTopLoaded) {
// bg-top 加载完成后延迟 300ms 显示心形图片 // bg-top 加载完成后延迟 300ms 显示心形图片
const timer = setTimeout(() => { const timer = setTimeout(() => {
setShowHeartImage(true); setShowHeartImage(true)
}, 300); }, 300)
return () => clearTimeout(timer); return () => clearTimeout(timer)
} }
}, [isBgTopLoaded]); }, [isBgTopLoaded])
// bg-top 图片加载完成的处理函数 // bg-top 图片加载完成的处理函数
const handleBgTopLoad = () => { const handleBgTopLoad = () => {
setIsBgTopLoaded(true); setIsBgTopLoaded(true)
}; }
return ( return (
<InlineDrawer <InlineDrawer
@ -133,26 +148,31 @@ const CrushLevelRetrieveDrawer = () => {
timestamp={drawerState.timestamp} timestamp={drawerState.timestamp}
> >
<InlineDrawerContent> <InlineDrawerContent>
<div className="absolute top-0 left-0 right-0 h-[480px] overflow-hidden"> <div className="absolute top-0 right-0 left-0 h-[480px] overflow-hidden">
<Image src="/images/crushlevel/bg-bottom.png" alt="CrushLevel" fill className="object-cover" /> <Image
src="/images/crushlevel/bg-bottom.png"
alt="CrushLevel"
fill
className="object-cover"
/>
{/* 心形图片 - 只在 bg-top 加载完成后延迟显示 */} {/* 心形图片 - 只在 bg-top 加载完成后延迟显示 */}
{showHeartImage && ( {showHeartImage && (
<Image <Image
src={getHeartImageSrc()} src={getHeartImageSrc()}
alt="Crush Level" alt="Crush Level"
className="absolute left-1/2 -translate-x-1/2 transition-all duration-500 ease-in-out animate-in fade-in-0 slide-in-from-bottom-4" className="animate-in fade-in-0 slide-in-from-bottom-4 absolute left-1/2 -translate-x-1/2 transition-all duration-500 ease-in-out"
width={124} width={124}
height={124} height={124}
style={{ style={{
top: `${heartTop}px`, top: `${heartTop}px`,
}} }}
/> />
)} )}
<Image <Image
src="/images/crushlevel/bg-top.png" src="/images/crushlevel/bg-top.png"
alt="Crush Level" alt="Crush Level"
fill fill
className="object-cover" className="object-cover"
onLoad={handleBgTopLoad} onLoad={handleBgTopLoad}
/> />
</div> </div>
@ -160,36 +180,44 @@ const CrushLevelRetrieveDrawer = () => {
<InlineDrawerHeader> </InlineDrawerHeader> <InlineDrawerHeader> </InlineDrawerHeader>
<InlineDrawerDescription> <InlineDrawerDescription>
<div className="pt-[124px]"> <div className="pt-[124px]">
<div className="text-center txt-title-m">Recover Crush Value</div> <div className="txt-title-m text-center">Recover Crush Value</div>
<div className="mt-6"> <div className="mt-6">
<div className="flex justify-between items-center py-3"> <div className="flex items-center justify-between py-3">
<div className="flex-1 txt-label-l">Price per Unit</div> <div className="txt-label-l flex-1">Price per Unit</div>
<div className="flex items-center gap-2 txt-numMonotype-s"> <div className="txt-numMonotype-s flex items-center gap-2">
<Image src="/icons/diamond.svg" alt="Crush Level" width={16} height={16} /> <Image src="/icons/diamond.svg" alt="Crush Level" width={16} height={16} />
<div>{formatFromCents(price || 0)}/&#8451;</div> <div>{formatFromCents(price || 0)}/&#8451;</div>
</div> </div>
</div> </div>
<div className="flex justify-between items-center py-3"> <div className="flex items-center justify-between py-3">
<div className="flex-1 txt-label-l">Quantity</div> <div className="txt-label-l flex-1">Quantity</div>
<div className="flex items-center gap-2 txt-numMonotype-s"> <div className="txt-numMonotype-s flex items-center gap-2">
{/* <Image src="/icons/diamond.svg" alt="Crush Level" width={16} height={16} /> */} {/* <Image src="/icons/diamond.svg" alt="Crush Level" width={16} height={16} /> */}
<div>{subtractHeartbeatVal}&#8451;</div> <div>{subtractHeartbeatVal}&#8451;</div>
</div> </div>
</div> </div>
<div className="flex justify-between items-center py-3"> <div className="flex items-center justify-between py-3">
<div className="flex-1 txt-label-l">Total</div> <div className="txt-label-l flex-1">Total</div>
<div className="flex items-center gap-2 txt-numMonotype-s"> <div className="txt-numMonotype-s flex items-center gap-2">
<Image src="/icons/diamond.svg" alt="Crush Level" width={16} height={16} /> <Image src="/icons/diamond.svg" alt="Crush Level" width={16} height={16} />
<div>{formatFromCents(Math.max((price || 0) * (subtractHeartbeatVal || 0), 100))}</div> <div>
{formatFromCents(Math.max((price || 0) * (subtractHeartbeatVal || 0), 100))}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</InlineDrawerDescription> </InlineDrawerDescription>
<InlineDrawerFooter> <InlineDrawerFooter>
<Button variant="tertiary" size="large" onClick={() => { <Button
setIsCrushLevelRetrieveDrawerOpen(false); variant="tertiary"
}}>Cancel</Button> size="large"
onClick={() => {
setIsCrushLevelRetrieveDrawerOpen(false)
}}
>
Cancel
</Button>
<Button <Button
variant="primary" variant="primary"
size="large" size="large"
@ -202,7 +230,7 @@ const CrushLevelRetrieveDrawer = () => {
</div> </div>
</InlineDrawerContent> </InlineDrawerContent>
</InlineDrawer> </InlineDrawer>
); )
} }
export default CrushLevelRetrieveDrawer; export default CrushLevelRetrieveDrawer

View File

@ -1,75 +1,76 @@
import { IconButton } from "@/components/ui/button"; import { IconButton } from '@/components/ui/button'
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils'
import { createContext, useContext, useEffect, useMemo, useCallback, useState, useRef } from "react"; import { createContext, useContext, useEffect, useMemo, useCallback, useState, useRef } from 'react'
// 管理所有抽屉的层级顺序 // 管理所有抽屉的层级顺序
export const DrawerLayerContext = createContext<{ export const DrawerLayerContext = createContext<{
openOrder: string[]; openOrder: string[]
registerDrawer: (id: string) => void; registerDrawer: (id: string) => void
unregisterDrawer: (id: string) => void; unregisterDrawer: (id: string) => void
bringToFront: (id: string) => void; bringToFront: (id: string) => void
getZIndex: (id: string) => number; getZIndex: (id: string) => number
}>({ }>({
openOrder: [], openOrder: [],
registerDrawer: () => {}, registerDrawer: () => {},
unregisterDrawer: () => {}, unregisterDrawer: () => {},
bringToFront: () => {}, bringToFront: () => {},
getZIndex: () => 0, getZIndex: () => 0,
}); })
export const DrawerLayerProvider = ({ export const DrawerLayerProvider = ({
children, children,
baseZIndex = 10, baseZIndex = 10,
}: { }: {
children: React.ReactNode; children: React.ReactNode
baseZIndex?: number; baseZIndex?: number
}) => { }) => {
const [openOrder, setOpenOrder] = useState<string[]>([]); const [openOrder, setOpenOrder] = useState<string[]>([])
const registerDrawer = useCallback((id: string) => { const registerDrawer = useCallback((id: string) => {
setOpenOrder((prev) => (prev.includes(id) ? prev : [...prev, id])); setOpenOrder((prev) => (prev.includes(id) ? prev : [...prev, id]))
}, []); }, [])
const unregisterDrawer = useCallback((id: string) => { const unregisterDrawer = useCallback((id: string) => {
setOpenOrder((prev) => prev.filter((item) => item !== id)); setOpenOrder((prev) => prev.filter((item) => item !== id))
}, []); }, [])
const bringToFront = useCallback((id: string) => { const bringToFront = useCallback((id: string) => {
setOpenOrder((prev) => { setOpenOrder((prev) => {
// 如果该抽屉已经在最前面,则不需要更新 // 如果该抽屉已经在最前面,则不需要更新
if (prev.length > 0 && prev[prev.length - 1] === id) { if (prev.length > 0 && prev[prev.length - 1] === id) {
return prev; return prev
} }
const filtered = prev.filter((item) => item !== id); const filtered = prev.filter((item) => item !== id)
return [...filtered, id]; return [...filtered, id]
}); })
}, []); }, [])
const getZIndex = useCallback(
(id: string) => {
const getZIndex = useCallback((id: string) => { const index = openOrder.indexOf(id)
const index = openOrder.indexOf(id); if (index === -1) return baseZIndex
if (index === -1) return baseZIndex; return baseZIndex + index
return baseZIndex + index; },
}, [openOrder, baseZIndex]); [openOrder, baseZIndex]
)
const value = useMemo( const value = useMemo(
() => ({ openOrder, registerDrawer, unregisterDrawer, bringToFront, getZIndex }), () => ({ openOrder, registerDrawer, unregisterDrawer, bringToFront, getZIndex }),
[openOrder, registerDrawer, unregisterDrawer, bringToFront, getZIndex] [openOrder, registerDrawer, unregisterDrawer, bringToFront, getZIndex]
); )
return <DrawerLayerContext.Provider value={value}>{children}</DrawerLayerContext.Provider>; return <DrawerLayerContext.Provider value={value}>{children}</DrawerLayerContext.Provider>
}; }
const InlineDrawerContext = createContext<{ const InlineDrawerContext = createContext<{
id: string; id: string
open: boolean; open: boolean
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void
}>({ }>({
id: "", id: '',
open: false, open: false,
onOpenChange: () => {}, onOpenChange: () => {},
}); })
export const InlineDrawer = ({ export const InlineDrawer = ({
id, id,
@ -78,55 +79,55 @@ export const InlineDrawer = ({
timestamp, timestamp,
children, children,
}: { }: {
id: string; id: string
open: boolean; open: boolean
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void
timestamp?: number; timestamp?: number
children: React.ReactNode; children: React.ReactNode
}) => { }) => {
const { registerDrawer, unregisterDrawer, bringToFront } = useContext(DrawerLayerContext); const { registerDrawer, unregisterDrawer, bringToFront } = useContext(DrawerLayerContext)
// 当抽屉打开时注册并置顶;当关闭或卸载时移除 // 当抽屉打开时注册并置顶;当关闭或卸载时移除
// 监听 timestamp 变化,确保每次重新打开时都会置顶 // 监听 timestamp 变化,确保每次重新打开时都会置顶
useEffect(() => { useEffect(() => {
if (open) { if (open) {
registerDrawer(id); registerDrawer(id)
bringToFront(id); bringToFront(id)
} }
}, [open, timestamp, id, registerDrawer, bringToFront]); }, [open, timestamp, id, registerDrawer, bringToFront])
useEffect(() => { useEffect(() => {
return () => { return () => {
unregisterDrawer(id); unregisterDrawer(id)
}; }
}, [id, unregisterDrawer, open]); }, [id, unregisterDrawer, open])
// 当抽屉关闭时不渲染任何内容 // 当抽屉关闭时不渲染任何内容
if (!open) { if (!open) {
return null; return null
} }
return ( return (
<InlineDrawerContext.Provider value={{ id, open, onOpenChange }}> <InlineDrawerContext.Provider value={{ id, open, onOpenChange }}>
{children} {children}
</InlineDrawerContext.Provider> </InlineDrawerContext.Provider>
); )
}; }
export const InlineDrawerContent = ({ export const InlineDrawerContent = ({
children, children,
className, className,
}: { }: {
children: React.ReactNode; children: React.ReactNode
className?: string; className?: string
}) => { }) => {
const { id } = useContext(InlineDrawerContext); const { id } = useContext(InlineDrawerContext)
const { getZIndex, bringToFront } = useContext(DrawerLayerContext); const { getZIndex, bringToFront } = useContext(DrawerLayerContext)
const zIndex = getZIndex(id); const zIndex = getZIndex(id)
return ( return (
<div <div
className={cn( className={cn(
"bg-background-default absolute inset-0 flex flex-col w-[400px] border-l border-solid border-outline-normal", 'bg-background-default border-outline-normal absolute inset-0 flex w-[400px] flex-col border-l border-solid',
className className
)} )}
style={{ zIndex }} style={{ zIndex }}
@ -134,41 +135,40 @@ export const InlineDrawerContent = ({
> >
{children} {children}
</div> </div>
); )
}; }
export const InlineDrawerHeader = ({ export const InlineDrawerHeader = ({ children }: { children: React.ReactNode }) => {
children, const { onOpenChange } = useContext(InlineDrawerContext)
}: {
children: React.ReactNode;
}) => {
const { onOpenChange } = useContext(InlineDrawerContext);
return ( return (
<div className="flex items-center gap-2 p-6"> <div className="flex items-center gap-2 p-6">
<IconButton iconfont="icon-arrow-right" variant="ghost" size="small" onClick={() => onOpenChange(false)} /> <IconButton
<div className="txt-title-m flex-1 min-w-0"> iconfont="icon-arrow-right"
{children} variant="ghost"
</div> size="small"
onClick={() => onOpenChange(false)}
/>
<div className="txt-title-m min-w-0 flex-1">{children}</div>
</div> </div>
); )
} }
export const InlineDrawerDescription = ({ export const InlineDrawerDescription = ({
children, children,
className, className,
}: { }: {
children: React.ReactNode; children: React.ReactNode
className?: string; className?: string
}) => { }) => {
return <div className={cn("flex-1 px-6 overflow-y-auto", className)}>{children}</div>; return <div className={cn('flex-1 overflow-y-auto px-6', className)}>{children}</div>
}; }
export const InlineDrawerFooter = ({ export const InlineDrawerFooter = ({
children, children,
className, className,
}: { }: {
children: React.ReactNode; children: React.ReactNode
className?: string; className?: string
}) => { }) => {
return <div className={cn("flex items-center justify-end gap-4 p-6", className)}>{children}</div>; return <div className={cn('flex items-center justify-end gap-4 p-6', className)}>{children}</div>
}; }

View File

@ -1,53 +1,62 @@
import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { isSendGiftsDrawerOpenAtom, createDrawerOpenState, isCrushLevelDrawerOpenAtom } from "@/atoms/chat"; import {
import { InlineDrawer, InlineDrawerContent, InlineDrawerDescription, InlineDrawerFooter, InlineDrawerHeader } from "./InlineDrawer"; isSendGiftsDrawerOpenAtom,
import { Button } from "@/components/ui/button"; createDrawerOpenState,
import Image from "next/image"; isCrushLevelDrawerOpenAtom,
import { useState, useEffect } from "react"; } from '@/atoms/chat'
import { cn } from "@/lib/utils"; import {
import { Input } from "@/components/ui/input"; InlineDrawer,
import { GiftOutput } from "@/services/im"; InlineDrawerContent,
import { useGetGiftList, useSendGift } from "@/hooks/useIm"; InlineDrawerDescription,
import { useChatConfig } from "../../context/chatConfig"; InlineDrawerFooter,
import { Tag } from "@/components/ui/tag"; InlineDrawerHeader,
import { isChargeDrawerOpenAtom, isVipDrawerOpenAtom, isWaitingForReplyAtom } from "@/atoms/im"; } from './InlineDrawer'
import { toast } from "sonner"; import { Button } from '@/components/ui/button'
import numeral from "numeral"; import Image from 'next/image'
import { useGetWalletBalance, useUpdateWalletBalance } from "@/hooks/useWallet"; import { useState, useEffect } from 'react'
import { useCurrentUser } from "@/hooks/auth"; import { cn } from '@/lib/utils'
import { useHeartLevelTextFromLevel } from "@/hooks/useHeartLevel"; import { Input } from '@/components/ui/input'
import { VipType } from "@/services/wallet"; import { GiftOutput } from '@/services/im'
import { useQueryClient } from "@tanstack/react-query"; import { useGetGiftList, useSendGift } from '@/hooks/useIm'
import { walletKeys } from "@/lib/query-keys"; import { useChatConfig } from '../../context/chatConfig'
import { Tag } from '@/components/ui/tag'
import { isChargeDrawerOpenAtom, isVipDrawerOpenAtom, isWaitingForReplyAtom } from '@/atoms/im'
import { toast } from 'sonner'
import numeral from 'numeral'
import { useGetWalletBalance, useUpdateWalletBalance } from '@/hooks/useWallet'
import { useCurrentUser } from '@/hooks/auth'
import { useHeartLevelTextFromLevel } from '@/hooks/useHeartLevel'
import { VipType } from '@/services/wallet'
import { useQueryClient } from '@tanstack/react-query'
import { walletKeys } from '@/lib/query-keys'
// 礼物价格转换工具函数(分转元) // 礼物价格转换工具函数(分转元)
const convertGiftPriceToYuan = (priceInCents: number): number => { const convertGiftPriceToYuan = (priceInCents: number): number => {
return priceInCents / 100; return priceInCents / 100
}; }
// 礼物卡片组件 // 礼物卡片组件
const GiftCard = ({ const GiftCard = ({
gift, gift,
isSelected, isSelected,
onSelect, onSelect,
heartbeatVal, heartbeatVal,
isMember isMember,
}: { }: {
gift: GiftOutput; gift: GiftOutput
isSelected: boolean; isSelected: boolean
onSelect: (gift: GiftOutput) => void; onSelect: (gift: GiftOutput) => void
heartbeatVal: number; heartbeatVal: number
isMember: boolean; isMember: boolean
}) => { }) => {
const handleClick = () => { const handleClick = () => {
onSelect(gift); onSelect(gift)
} }
const renderDisabledTag = () => { const renderDisabledTag = () => {
if (gift.isMemberGift) { if (gift.isMemberGift) {
if (isMember) { if (isMember) {
return null; return null
} }
return ( return (
<Tag className="absolute top-0 left-0" size="small"> <Tag className="absolute top-0 left-0" size="small">
@ -64,107 +73,109 @@ const GiftCard = ({
) )
} }
return null; return null
} }
return ( return (
<div <div
className={cn( className={cn(
"flex-1 min-w-24 p-2 rounded-2xl cursor-pointer transition-all duration-200", 'min-w-24 flex-1 cursor-pointer rounded-2xl p-2 transition-all duration-200',
"bg-transparent flex flex-col items-center gap-1 hover:bg-surface-element-normal border border-transparent", 'hover:bg-surface-element-normal flex flex-col items-center gap-1 border border-transparent bg-transparent',
isSelected && "border border-primary-variant-normal shadow-lg bg-surface-element-normal" isSelected && 'border-primary-variant-normal bg-surface-element-normal border shadow-lg'
)} )}
onClick={handleClick} onClick={handleClick}
> >
{/* 礼物图片 */} {/* 礼物图片 */}
<div className="relative w-full aspect-square"> <div className="relative aspect-square w-full">
<Image src={gift.icon} alt={gift.name} fill className="object-contain" /> <Image src={gift.icon} alt={gift.name} fill className="object-contain" />
{renderDisabledTag()} {renderDisabledTag()}
</div> </div>
{/* 礼物名称 */} {/* 礼物名称 */}
<div className="txt-label-m text-txt-primary-normal text-center line-clamp-1"> <div className="txt-label-m text-txt-primary-normal line-clamp-1 text-center">
{gift.name} {gift.name}
</div> </div>
{/* 价格 */} {/* 价格 */}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Image src="/icons/diamond.svg" alt="diamond" width={12} height={12} /> <Image src="/icons/diamond.svg" alt="diamond" width={12} height={12} />
<span className="txt-numMonotype-xs text-txt-primary-normal">{numeral(convertGiftPriceToYuan(gift.price)).format('0,0')}</span> <span className="txt-numMonotype-xs text-txt-primary-normal">
{numeral(convertGiftPriceToYuan(gift.price)).format('0,0')}
</span>
</div> </div>
</div> </div>
); )
}; }
const SendGiftsDrawer = () => { const SendGiftsDrawer = () => {
const [drawerState, setDrawerState] = useAtom(isSendGiftsDrawerOpenAtom); const [drawerState, setDrawerState] = useAtom(isSendGiftsDrawerOpenAtom)
const isSendGiftsDrawerOpen = drawerState.open; const isSendGiftsDrawerOpen = drawerState.open
const setIsSendGiftsDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open)); const setIsSendGiftsDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const [selectedGift, setSelectedGift] = useState<GiftOutput | null>(null); const [selectedGift, setSelectedGift] = useState<GiftOutput | null>(null)
const [quantity, setQuantity] = useState(1); const [quantity, setQuantity] = useState(1)
const { aiId, aiInfo, handleUserMessage } = useChatConfig(); const { aiId, aiInfo, handleUserMessage } = useChatConfig()
// const isWaitingForReply = useAtomValue(isWaitingForReplyAtom); // const isWaitingForReply = useAtomValue(isWaitingForReplyAtom);
const isWaitingForReply = false; const isWaitingForReply = false
const setIsChargeDrawerOpen = useSetAtom(isChargeDrawerOpenAtom); const setIsChargeDrawerOpen = useSetAtom(isChargeDrawerOpenAtom)
const { data: currentUser } = useCurrentUser(); const { data: currentUser } = useCurrentUser()
const { isMember } = currentUser || {}; const { isMember } = currentUser || {}
const setIsCrushLevelDrawerOpen = useSetAtom(isCrushLevelDrawerOpenAtom); const setIsCrushLevelDrawerOpen = useSetAtom(isCrushLevelDrawerOpenAtom)
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom); const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom)
const queryClient = useQueryClient(); const queryClient = useQueryClient()
const { aiUserHeartbeatRelation } = aiInfo || {}; const { aiUserHeartbeatRelation } = aiInfo || {}
const { heartbeatVal } = aiUserHeartbeatRelation || {}; const { heartbeatVal } = aiUserHeartbeatRelation || {}
const { data } = useGetGiftList(); const { data } = useGetGiftList()
const giftList = data?.datas || []; const giftList = data?.datas || []
const isOwner = currentUser?.userId === aiInfo?.userId; const isOwner = currentUser?.userId === aiInfo?.userId
const { mutateAsync: sendGift, isPending: isSendGiftPending } = useSendGift(); const { mutateAsync: sendGift, isPending: isSendGiftPending } = useSendGift()
// 当礼物列表加载完成且抽屉打开时,自动选中第一个礼物 // 当礼物列表加载完成且抽屉打开时,自动选中第一个礼物
useEffect(() => { useEffect(() => {
if (isSendGiftsDrawerOpen && giftList.length > 0 && !selectedGift) { if (isSendGiftsDrawerOpen && giftList.length > 0 && !selectedGift) {
setSelectedGift(giftList[0]); setSelectedGift(giftList[0])
setQuantity(1); setQuantity(1)
} }
}, [isSendGiftsDrawerOpen, giftList, selectedGift]); }, [isSendGiftsDrawerOpen, giftList, selectedGift])
// 当抽屉关闭时,重置选中状态 // 当抽屉关闭时,重置选中状态
useEffect(() => { useEffect(() => {
if (!isSendGiftsDrawerOpen) { if (!isSendGiftsDrawerOpen) {
setSelectedGift(null); setSelectedGift(null)
setQuantity(1); setQuantity(1)
} }
if (isSendGiftsDrawerOpen) { if (isSendGiftsDrawerOpen) {
queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() }); queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() })
} }
}, [isSendGiftsDrawerOpen]); }, [isSendGiftsDrawerOpen])
// 获取钱包余额和更新方法 // 获取钱包余额和更新方法
const { data: walletData } = useGetWalletBalance(); const { data: walletData } = useGetWalletBalance()
const walletUpdate = useUpdateWalletBalance(); const walletUpdate = useUpdateWalletBalance()
// 用户余额(单位:元) // 用户余额(单位:元)
const balanceString = walletData?.balanceString || '0'; const balanceString = walletData?.balanceString || '0'
// 计算总价(礼物价格是分单位,需要转换为元) // 计算总价(礼物价格是分单位,需要转换为元)
const totalPrice = selectedGift ? convertGiftPriceToYuan(selectedGift.price) * quantity : 0; const totalPrice = selectedGift ? convertGiftPriceToYuan(selectedGift.price) * quantity : 0
// 是否能够购买 // 是否能够购买
const canPurchase = selectedGift && walletUpdate.checkSufficient(totalPrice); const canPurchase = selectedGift && walletUpdate.checkSufficient(totalPrice)
const handleQuantityChange = (delta: number) => { const handleQuantityChange = (delta: number) => {
const newQuantity = Math.max(1, Math.min(100, quantity + delta)); const newQuantity = Math.max(1, Math.min(100, quantity + delta))
setQuantity(newQuantity); setQuantity(newQuantity)
}; }
const handleGiftSelect = (gift: GiftOutput | null) => { const handleGiftSelect = (gift: GiftOutput | null) => {
setSelectedGift(gift); setSelectedGift(gift)
// 重置数量为1 // 重置数量为1
setQuantity(1); setQuantity(1)
}; }
const handleSendGift = async () => { const handleSendGift = async () => {
if (!selectedGift) return; if (!selectedGift) return
// if (isOwner) { // if (isOwner) {
// toast.error('You cannot send gifts to yourself.'); // toast.error('You cannot send gifts to yourself.');
@ -172,8 +183,8 @@ const SendGiftsDrawer = () => {
// } // }
if (!canPurchase) { if (!canPurchase) {
setIsChargeDrawerOpen(true); setIsChargeDrawerOpen(true)
return; return
} }
try { try {
@ -181,27 +192,27 @@ const SendGiftsDrawer = () => {
giftId: selectedGift.id, giftId: selectedGift.id,
aiId: Number(aiId), aiId: Number(aiId),
num: quantity, num: quantity,
}); })
walletUpdate.deduct(totalPrice, { skipInvalidation: true }); walletUpdate.deduct(totalPrice, { skipInvalidation: true })
// 成功后刷新余额数据 // 成功后刷新余额数据
walletUpdate.refresh(); walletUpdate.refresh()
// 通知用户发送了消息,重置自动聊天定时器 // 通知用户发送了消息,重置自动聊天定时器
handleUserMessage(); handleUserMessage()
// 重置选择状态 // 重置选择状态
setSelectedGift(null); setSelectedGift(null)
setQuantity(1); setQuantity(1)
} catch (error) { } catch (error) {
// 失败时回滚余额 // 失败时回滚余额
toast.error("Gift sending failed. Please try again."); toast.error('Gift sending failed. Please try again.')
console.error('送礼物失败:', error); console.error('送礼物失败:', error)
} }
}; }
const getButton = () => { const getButton = () => {
const { isMemberGift, startVal, heartbeatLevel } = selectedGift || {}; const { isMemberGift, startVal, heartbeatLevel } = selectedGift || {}
if (isMemberGift) { if (isMemberGift) {
if (!isMember) { if (!isMember) {
return ( return (
@ -211,23 +222,41 @@ const SendGiftsDrawer = () => {
onClick={() => setIsVipDrawerOpen({ open: true, vipType: VipType.SPECIAL_GIFT })} onClick={() => setIsVipDrawerOpen({ open: true, vipType: VipType.SPECIAL_GIFT })}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Image src="/icons/vip-black.svg" alt="vip" className="block" width={24} height={24} /> <Image
src="/icons/vip-black.svg"
alt="vip"
className="block"
width={24}
height={24}
/>
<span className="txt-label-l">Unlock</span> <span className="txt-label-l">Unlock</span>
</div> </div>
</Button> </Button>
); )
} }
} }
if (heartbeatLevel) { if (heartbeatLevel) {
if ((startVal && typeof heartbeatVal === 'number' && heartbeatVal < startVal || !heartbeatVal)) { if (
(startVal && typeof heartbeatVal === 'number' && heartbeatVal < startVal) ||
!heartbeatVal
) {
return ( return (
<Button size="large" <Button
size="large"
onClick={() => setIsCrushLevelDrawerOpen(createDrawerOpenState(true))} onClick={() => setIsCrushLevelDrawerOpen(createDrawerOpenState(true))}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Image src="/icons/like-gradient.svg" alt="vip" className="block" width={24} height={24} /> <Image
<span className="txt-label-l">{useHeartLevelTextFromLevel(heartbeatLevel)} Unlock</span> src="/icons/like-gradient.svg"
alt="vip"
className="block"
width={24}
height={24}
/>
<span className="txt-label-l">
{useHeartLevelTextFromLevel(heartbeatLevel)} Unlock
</span>
</div> </div>
</Button> </Button>
) )
@ -235,14 +264,10 @@ const SendGiftsDrawer = () => {
} }
return ( return (
<Button <Button onClick={handleSendGift} disabled={isWaitingForReply} loading={isSendGiftPending}>
onClick={handleSendGift}
disabled={isWaitingForReply}
loading={isSendGiftPending}
>
Gift Gift
</Button> </Button>
); )
} }
return ( return (
@ -254,7 +279,7 @@ const SendGiftsDrawer = () => {
> >
<InlineDrawerContent className="bg-background-default"> <InlineDrawerContent className="bg-background-default">
<InlineDrawerHeader>Send Gifts</InlineDrawerHeader> <InlineDrawerHeader>Send Gifts</InlineDrawerHeader>
<InlineDrawerDescription className="flex-1 overflow-y-auto"> <InlineDrawerDescription className="flex-1 overflow-y-auto">
{/* 礼物网格 */} {/* 礼物网格 */}
<div className="grid grid-cols-3 gap-2 pb-6"> <div className="grid grid-cols-3 gap-2 pb-6">
@ -270,16 +295,16 @@ const SendGiftsDrawer = () => {
))} ))}
</div> </div>
</InlineDrawerDescription> </InlineDrawerDescription>
<InlineDrawerFooter className="flex-col gap-4 bg-background-default/65 backdrop-blur-md"> <InlineDrawerFooter className="bg-background-default/65 flex-col gap-4 backdrop-blur-md">
{/* 数量选择器 */} {/* 数量选择器 */}
{selectedGift && ( {selectedGift && (
<div className="flex justify-between items-center w-full"> <div className="flex w-full items-center justify-between">
<div className="txt-label-m text-txt-primary-normal">Quantity</div> <div className="txt-label-m text-txt-primary-normal">Quantity</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button <Button
variant="tertiary" variant="tertiary"
className="!w-12 px-0 min-w-0 rounded-sm" className="!w-12 min-w-0 rounded-sm px-0"
onClick={() => handleQuantityChange(-1)} onClick={() => handleQuantityChange(-1)}
> >
<i className="iconfont icon-reduce" /> <i className="iconfont icon-reduce" />
@ -288,26 +313,26 @@ const SendGiftsDrawer = () => {
className="w-20 text-center" className="w-20 text-center"
value={quantity} value={quantity}
onChange={(e) => { onChange={(e) => {
const value = e.target.value; const value = e.target.value
// 只能输入正整数 // 只能输入正整数
if (!/^\d+$/.test(value)) { if (!/^\d+$/.test(value)) {
return; return
} }
const numValue = Number(value); const numValue = Number(value)
// 限制最大值为100 // 限制最大值为100
if (numValue > 100) { if (numValue > 100) {
setQuantity(100); setQuantity(100)
return; return
} }
setQuantity(numValue); setQuantity(numValue)
}} }}
/> />
<Button <Button
variant="tertiary" variant="tertiary"
className="!w-12 px-0 min-w-0 rounded-sm" className="!w-12 min-w-0 rounded-sm px-0"
onClick={() => handleQuantityChange(1)} onClick={() => handleQuantityChange(1)}
> >
<i className="iconfont icon-add" /> <i className="iconfont icon-add" />
@ -315,18 +340,22 @@ const SendGiftsDrawer = () => {
</div> </div>
</div> </div>
)} )}
{/* 总价显示 */} {/* 总价显示 */}
{selectedGift && (<div className="flex justify-between items-center w-full"> {selectedGift && (
<div className="txt-label-m text-txt-primary-normal">Total</div> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-1"> <div className="txt-label-m text-txt-primary-normal">Total</div>
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} /> <div className="flex items-center gap-1">
<span className="txt-numMonotype-s text-txt-primary-normal">{numeral(totalPrice).format('0,0')}</span> <Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
<span className="txt-numMonotype-s text-txt-primary-normal">
{numeral(totalPrice).format('0,0')}
</span>
</div>
</div> </div>
</div>)} )}
{/* 余额和购买按钮 */} {/* 余额和购买按钮 */}
<div className="flex justify-between items-center w-full"> <div className="flex w-full items-center justify-between">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="txt-label-m text-txt-primary-normal">Balance:</span> <span className="txt-label-m text-txt-primary-normal">Balance:</span>
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} /> <Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
@ -337,7 +366,7 @@ const SendGiftsDrawer = () => {
</InlineDrawerFooter> </InlineDrawerFooter>
</InlineDrawerContent> </InlineDrawerContent>
</InlineDrawer> </InlineDrawer>
); )
}; }
export default SendGiftsDrawer; export default SendGiftsDrawer

View File

@ -1,31 +1,30 @@
import CrushLevelDrawer from "./CrushLevelDrawer"; import CrushLevelDrawer from './CrushLevelDrawer'
import SendGiftsDrawer from "./SendGiftsDrawer"; import SendGiftsDrawer from './SendGiftsDrawer'
import CrushLevelRetrieveDrawer from "./CrushLevelRetrieveDrawer"; import CrushLevelRetrieveDrawer from './CrushLevelRetrieveDrawer'
import ChatProfileDrawer from "./ChatProfileDrawer"; import ChatProfileDrawer from './ChatProfileDrawer'
import { DrawerLayerContext } from "./InlineDrawer"; import { DrawerLayerContext } from './InlineDrawer'
import { useContext } from "react"; import { useContext } from 'react'
import { cn } from "@/lib/utils"; import { cn } from '@/lib/utils'
import ChatProfileEditDrawer from "./ChatProfileEditDrawer"; import ChatProfileEditDrawer from './ChatProfileEditDrawer'
import ChatModelDrawer from "./ChatModelDrawer"; import ChatModelDrawer from './ChatModelDrawer'
import ChatButtleDrawer from "./ChatButtleDrawer"; import ChatButtleDrawer from './ChatButtleDrawer'
import ChatBackgroundDrawer from "./ChatBackgroundDrawer"; import ChatBackgroundDrawer from './ChatBackgroundDrawer'
const ChatDrawers = () => { const ChatDrawers = () => {
const { openOrder } = useContext(DrawerLayerContext); const { openOrder } = useContext(DrawerLayerContext)
return ( return (
<div className={cn("w-[400px]", openOrder.length === 0 && "hidden")}> <div className={cn('w-[400px]', openOrder.length === 0 && 'hidden')}>
<SendGiftsDrawer /> <SendGiftsDrawer />
<CrushLevelDrawer /> <CrushLevelDrawer />
<CrushLevelRetrieveDrawer /> <CrushLevelRetrieveDrawer />
<ChatProfileDrawer /> <ChatProfileDrawer />
<ChatProfileEditDrawer /> <ChatProfileEditDrawer />
<ChatModelDrawer /> <ChatModelDrawer />
<ChatButtleDrawer /> <ChatButtleDrawer />
<ChatBackgroundDrawer /> <ChatBackgroundDrawer />
</div> </div>
); )
} }
export default ChatDrawers; export default ChatDrawers

View File

@ -1,24 +1,33 @@
"use client" 'use client'
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from "@/components/ui/alert-dialog"; import {
import { useEffect, useState } from "react"; AlertDialog,
import { useChatConfig } from "../../context/chatConfig"; AlertDialogContent,
import { useRouter } from "next/navigation"; AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction,
} from '@/components/ui/alert-dialog'
import { useEffect, useState } from 'react'
import { useChatConfig } from '../../context/chatConfig'
import { useRouter } from 'next/navigation'
const ChatFirstGuideDialog = () => { const ChatFirstGuideDialog = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false)
const { aiId } = useChatConfig(); const { aiId } = useChatConfig()
const router = useRouter(); const router = useRouter()
useEffect(() => { useEffect(() => {
if (!!localStorage.getItem('create-ai-show-guide')) { if (!!localStorage.getItem('create-ai-show-guide')) {
setOpen(true); setOpen(true)
localStorage.removeItem('create-ai-show-guide'); localStorage.removeItem('create-ai-show-guide')
} }
}, []) }, [])
const handleConfirm = () => { const handleConfirm = () => {
setOpen(false); setOpen(false)
router.push(`/@${aiId}`); router.push(`/@${aiId}`)
} }
return ( return (
@ -28,7 +37,10 @@ const ChatFirstGuideDialog = () => {
<AlertDialogTitle>Create Album</AlertDialogTitle> <AlertDialogTitle>Create Album</AlertDialogTitle>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogDescription> <AlertDialogDescription>
<p>Go to the characters profile to create an album, attract more chatters, and increase your earnings.</p> <p>
Go to the characters profile to create an album, attract more chatters, and increase
your earnings.
</p>
</AlertDialogDescription> </AlertDialogDescription>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Not now</AlertDialogCancel> <AlertDialogCancel>Not now</AlertDialogCancel>
@ -36,7 +48,7 @@ const ChatFirstGuideDialog = () => {
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
); )
} }
export default ChatFirstGuideDialog; export default ChatFirstGuideDialog

View File

@ -1,26 +1,26 @@
'use client'; 'use client'
import React, { useState } from 'react'; import React, { useState } from 'react'
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils'
import { IconButton } from '@/components/ui/button'; import { IconButton } from '@/components/ui/button'
import Image from 'next/image'; import Image from 'next/image'
interface ReplySuggestion { interface ReplySuggestion {
id: string; id: string
text: string; text: string
isSkeleton?: boolean; isSkeleton?: boolean
} }
interface AiReplySuggestionsProps { interface AiReplySuggestionsProps {
suggestions: ReplySuggestion[]; suggestions: ReplySuggestion[]
currentPage: number; currentPage: number
totalPages: number; totalPages: number
isLoading?: boolean; isLoading?: boolean
onSuggestionEdit: (suggestion: ReplySuggestion) => void; onSuggestionEdit: (suggestion: ReplySuggestion) => void
onSuggestionSend: (suggestion: ReplySuggestion) => void; onSuggestionSend: (suggestion: ReplySuggestion) => void
onPageChange: (page: number) => void; onPageChange: (page: number) => void
onClose: () => void; onClose: () => void
className?: string; className?: string
} }
export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({ export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
@ -32,71 +32,60 @@ export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
onSuggestionSend, onSuggestionSend,
onPageChange, onPageChange,
onClose, onClose,
className className,
}) => { }) => {
// 检查是否显示骨架屏:当前页的建议中有骨架屏标记 // 检查是否显示骨架屏:当前页的建议中有骨架屏标记
const showSkeleton = suggestions.some(s => s.isSkeleton); const showSkeleton = suggestions.some((s) => s.isSkeleton)
return ( return (
<div className={cn( <div className={cn('flex w-full flex-col items-start justify-start gap-4', className)}>
"flex flex-col gap-4 items-start justify-start w-full",
className
)}>
{/* 标题栏 */} {/* 标题栏 */}
<div className="flex items-center justify-between w-full"> <div className="flex w-full items-center justify-between">
<p className="txt-label-m text-txt-secondary-normal"> <p className="txt-label-m text-txt-secondary-normal">Choose one or edit</p>
Choose one or edit <IconButton variant="tertiaryDark" size="xs" onClick={onClose}>
</p>
<IconButton
variant="tertiaryDark"
size="xs"
onClick={onClose}
>
<i className="iconfont icon-close" /> <i className="iconfont icon-close" />
</IconButton> </IconButton>
</div> </div>
{/* 建议列表 */} {/* 建议列表 */}
{showSkeleton ? ( {showSkeleton
// 骨架屏 - 固定显示3条建议的布局 ? // 骨架屏 - 固定显示3条建议的布局
suggestions.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 bg-surface-element-light-normal backdrop-blur-[16px]" className="bg-surface-element-light-normal flex w-full items-end justify-start gap-4 overflow-hidden rounded-xl py-2 pr-4 pl-4 backdrop-blur-[16px]"
>
<div className="flex-1 px-0 py-1">
<div className="h-6 bg-surface-element-normal rounded w-full animate-pulse"></div>
</div>
<div className="size-8 bg-surface-element-normal rounded-full flex-shrink-0 animate-pulse"></div>
</div>
))
) : (
// 实际建议内容
suggestions.map((suggestion) => (
<div
key={suggestion.id}
className="flex gap-4 items-end justify-start overflow-hidden pl-4 pr-4 py-2 rounded-xl w-full cursor-pointer bg-surface-element-light-normal hover:bg-surface-element-light-hover transition-colors backdrop-blur-[16px]"
onClick={() => onSuggestionSend(suggestion)}
>
<div className="flex-1 px-0 py-1">
<div className="txt-body-l overflow-hidden">
<p>{suggestion.text}</p>
</div>
</div>
<IconButton
variant="ghost"
size="small"
onClick={(e) => {
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
onSuggestionEdit(suggestion);
}}
className="flex-shrink-0"
> >
<i className="iconfont icon-icon_order_remark" /> <div className="flex-1 px-0 py-1">
</IconButton> <div className="bg-surface-element-normal h-6 w-full animate-pulse rounded"></div>
</div> </div>
)) <div className="bg-surface-element-normal size-8 flex-shrink-0 animate-pulse rounded-full"></div>
)} </div>
))
: // 实际建议内容
suggestions.map((suggestion) => (
<div
key={suggestion.id}
className="bg-surface-element-light-normal hover:bg-surface-element-light-hover flex w-full cursor-pointer items-end justify-start gap-4 overflow-hidden rounded-xl py-2 pr-4 pl-4 backdrop-blur-[16px] transition-colors"
onClick={() => onSuggestionSend(suggestion)}
>
<div className="flex-1 px-0 py-1">
<div className="txt-body-l overflow-hidden">
<p>{suggestion.text}</p>
</div>
</div>
<IconButton
variant="ghost"
size="small"
onClick={(e) => {
e.stopPropagation() // 阻止事件冒泡,避免触发卡片点击
onSuggestionEdit(suggestion)
}}
className="flex-shrink-0"
>
<i className="iconfont icon-icon_order_remark" />
</IconButton>
</div>
))}
{/* VIP 解锁选项 */} {/* VIP 解锁选项 */}
{/* <div {/* <div
@ -125,8 +114,8 @@ export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
</div> */} </div> */}
{/* 分页控制 */} {/* 分页控制 */}
<div className="flex gap-4 items-center justify-center w-full"> <div className="flex w-full items-center justify-center gap-4">
<div className="flex gap-2 items-start justify-start"> <div className="flex items-start justify-start gap-2">
<IconButton <IconButton
variant="tertiaryDark" variant="tertiaryDark"
size="xs" size="xs"
@ -135,13 +124,13 @@ export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
> >
<i className="iconfont icon-arrow-left-border" /> <i className="iconfont icon-arrow-left-border" />
</IconButton> </IconButton>
<div className="flex gap-3 h-6 items-center justify-center min-w-6"> <div className="flex h-6 min-w-6 items-center justify-center gap-3">
<span className="txt-numMonotype-xs"> <span className="txt-numMonotype-xs">
{currentPage}/{totalPages} {currentPage}/{totalPages}
</span> </span>
</div> </div>
<IconButton <IconButton
variant="tertiaryDark" variant="tertiaryDark"
size="xs" size="xs"
@ -153,7 +142,7 @@ export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
</div> </div>
</div> </div>
</div> </div>
); )
}; }
export default AiReplySuggestions; export default AiReplySuggestions

View File

@ -1,109 +1,115 @@
"use client" 'use client'
import { IconButton } from "@/components/ui/button"; import { IconButton } from '@/components/ui/button'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import {
import { Separator } from "@/components/ui/separator"; DropdownMenu,
import { cn } from "@/lib/utils"; DropdownMenuContent,
import { useState, useCallback } from "react"; DropdownMenuItem,
import { useChatConfig } from "../../context/chatConfig"; DropdownMenuTrigger,
import { useSetAtom } from "jotai"; } from '@/components/ui/dropdown-menu'
import { isCrushLevelDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat"; import { Separator } from '@/components/ui/separator'
import useShare from "@/hooks/useShare"; import { cn } from '@/lib/utils'
import { ChatPriceType, useUpdateWalletBalance } from "@/hooks/useWallet"; import { useState, useCallback } from 'react'
import { isCallAtom, isCoinInsufficientAtom } from "@/atoms/im"; import { useChatConfig } from '../../context/chatConfig'
import { toast } from "sonner"; import { useSetAtom } from 'jotai'
import { useNimMsgContext } from "@/context/NimChat/useNimChat"; import { isCrushLevelDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat'
import useShare from '@/hooks/useShare'
import { ChatPriceType, useUpdateWalletBalance } from '@/hooks/useWallet'
import { isCallAtom, isCoinInsufficientAtom } from '@/atoms/im'
import { toast } from 'sonner'
import { useNimMsgContext } from '@/context/NimChat/useNimChat'
const ChatActionPlus = ({ const ChatActionPlus = ({ onUploadImage }: { onUploadImage: () => void }) => {
onUploadImage, const [open, setOpen] = useState(false)
}: { const { aiInfo, aiId } = useChatConfig()
onUploadImage: () => void; const { audioPlayer } = useNimMsgContext()
}) => { const setDrawerState = useSetAtom(isCrushLevelDrawerOpenAtom)
const [open, setOpen] = useState(false); const setIsCrushLevelDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
const { aiInfo, aiId } = useChatConfig(); const { aiUserHeartbeatRelation } = aiInfo || {}
const { audioPlayer } = useNimMsgContext(); const { heartbeatLevelNum } = aiUserHeartbeatRelation || {}
const setDrawerState = useSetAtom(isCrushLevelDrawerOpenAtom); const { shareFacebook, shareTwitter } = useShare()
const setIsCrushLevelDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open)); const { checkSufficientByType } = useUpdateWalletBalance()
const { aiUserHeartbeatRelation } = aiInfo || {}; const setIsCoinInsufficient = useSetAtom(isCoinInsufficientAtom)
const { heartbeatLevelNum } = aiUserHeartbeatRelation || {}; const setIsCall = useSetAtom(isCallAtom)
const { shareFacebook, shareTwitter } = useShare();
const { checkSufficientByType } = useUpdateWalletBalance();
const setIsCoinInsufficient = useSetAtom(isCoinInsufficientAtom);
const setIsCall = useSetAtom(isCallAtom);
const handleShareFacebook = () => { const handleShareFacebook = () => {
shareFacebook({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}` }); shareFacebook({
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}`,
})
} }
const handleShareTwitter = () => { const handleShareTwitter = () => {
shareTwitter({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}` }); shareTwitter({
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}`,
})
} }
// 请求麦克风权限 // 请求麦克风权限
const requestMicrophonePermission = useCallback(async (): Promise<boolean> => { const requestMicrophonePermission = useCallback(async (): Promise<boolean> => {
try { try {
// 先尝试同时请求麦克风和摄像头权限 // 先尝试同时请求麦克风和摄像头权限
let stream; let stream
try { try {
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false }); stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
console.log('成功获取麦克风和摄像头权限'); console.log('成功获取麦克风和摄像头权限')
} catch (error) { } catch (error) {
// 如果摄像头权限失败,只请求麦克风权限 // 如果摄像头权限失败,只请求麦克风权限
console.log('摄像头权限获取失败,仅请求麦克风权限:', error); console.log('摄像头权限获取失败,仅请求麦克风权限:', error)
stream = await navigator.mediaDevices.getUserMedia({ audio: true }); stream = await navigator.mediaDevices.getUserMedia({ audio: true })
console.log('成功获取麦克风权限'); console.log('成功获取麦克风权限')
} }
// 立即停止流,我们只是为了获取权限 // 立即停止流,我们只是为了获取权限
stream.getTracks().forEach(track => track.stop()); stream.getTracks().forEach((track) => track.stop())
return true; return true
} catch (error) { } catch (error) {
console.log('requestMicrophonePermission error', JSON.stringify(error)); console.log('requestMicrophonePermission error', JSON.stringify(error))
// 可以在这里显示用户友好的错误提示 // 可以在这里显示用户友好的错误提示
toast('Microphone permission is required to make a voice call. Please allow microphone access in your browser settings.'); toast(
return false; 'Microphone permission is required to make a voice call. Please allow microphone access in your browser settings.'
)
return false
} }
}, []); }, [])
const handleMakeCall = async () => { const handleMakeCall = async () => {
audioPlayer?.stop(); audioPlayer?.stop()
if (!heartbeatLevelNum || heartbeatLevelNum < 4) { if (!heartbeatLevelNum || heartbeatLevelNum < 4) {
setIsCrushLevelDrawerOpen(true); setIsCrushLevelDrawerOpen(true)
return; return
} }
if (!checkSufficientByType(ChatPriceType.VOICE_CALL)) { if (!checkSufficientByType(ChatPriceType.VOICE_CALL)) {
setIsCoinInsufficient(true); setIsCoinInsufficient(true)
return; return
} }
// 在开始通话前请求麦克风权限 // 在开始通话前请求麦克风权限
const hasPermission = await requestMicrophonePermission(); const hasPermission = await requestMicrophonePermission()
if (!hasPermission) { if (!hasPermission) {
return; // 如果没有权限,不继续进行通话 return // 如果没有权限,不继续进行通话
} }
setIsCall(true); setIsCall(true)
} }
const handleUploadImage = () => { const handleUploadImage = () => {
if (!heartbeatLevelNum || heartbeatLevelNum < 2) { if (!heartbeatLevelNum || heartbeatLevelNum < 2) {
setIsCrushLevelDrawerOpen(true); setIsCrushLevelDrawerOpen(true)
return; return
} }
if (!checkSufficientByType(ChatPriceType.TEXT)) { if (!checkSufficientByType(ChatPriceType.TEXT)) {
setIsCoinInsufficient(true); setIsCoinInsufficient(true)
return; return
} }
onUploadImage(); onUploadImage()
} }
return ( return (
<DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<IconButton <IconButton variant="ghost" size="small">
variant="ghost" <i className={cn('iconfont', open ? 'icon-close' : 'icon-add')} />
size="small"
>
<i className={cn("iconfont", open ? "icon-close" : "icon-add")} />
</IconButton> </IconButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@ -115,10 +121,10 @@ const ChatActionPlus = ({
<i className="iconfont icon-Call" /> <i className="iconfont icon-Call" />
<span>Make a Call</span> <span>Make a Call</span>
</DropdownMenuItem> </DropdownMenuItem>
<div className="px-2 my-3"> <div className="my-3 px-2">
<Separator className="bg-outline-normal" /> <Separator className="bg-outline-normal" />
</div> </div>
<div className="px-2 py-3 txt-label-m text-txt-secondary-normal">Share to</div> <div className="txt-label-m text-txt-secondary-normal px-2 py-3">Share to</div>
<DropdownMenuItem onClick={handleShareFacebook}> <DropdownMenuItem onClick={handleShareFacebook}>
<i className="iconfont icon-social-facebook" /> <i className="iconfont icon-social-facebook" />
<span>Share to Facebook</span> <span>Share to Facebook</span>
@ -129,7 +135,7 @@ const ChatActionPlus = ({
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
); )
} }
export default ChatActionPlus; export default ChatActionPlus

View File

@ -1,60 +1,61 @@
'use client'; 'use client'
import { IconButton } from "@/components/ui/button"; import { IconButton } from '@/components/ui/button'
import { useMemo, useEffect } from "react"; import { useMemo, useEffect } from 'react'
interface ChatImagePreviewProps { interface ChatImagePreviewProps {
selectedImage: File | null; selectedImage: File | null
imageUploading: boolean; imageUploading: boolean
removeImage: () => void; removeImage: () => void
} }
const ChatImagePreview = ({ selectedImage, imageUploading, removeImage }: ChatImagePreviewProps) => { const ChatImagePreview = ({
selectedImage,
imageUploading,
removeImage,
}: ChatImagePreviewProps) => {
// 使用useMemo缓存ObjectURL避免每次渲染都创建新的URL // 使用useMemo缓存ObjectURL避免每次渲染都创建新的URL
const imageUrl = useMemo(() => { const imageUrl = useMemo(() => {
if (!selectedImage) return null; if (!selectedImage) return null
return URL.createObjectURL(selectedImage); return URL.createObjectURL(selectedImage)
}, [selectedImage]); }, [selectedImage])
// 组件卸载或图片变更时清理ObjectURL避免内存泄漏 // 组件卸载或图片变更时清理ObjectURL避免内存泄漏
useEffect(() => { useEffect(() => {
return () => { return () => {
if (imageUrl) { if (imageUrl) {
URL.revokeObjectURL(imageUrl); URL.revokeObjectURL(imageUrl)
} }
}; }
}, [imageUrl]); }, [imageUrl])
if (!selectedImage || !imageUrl) { if (!selectedImage || !imageUrl) {
return null; return null
} }
return ( return (
<div className="w-full ml-4"> <div className="ml-4 w-full">
<div className="relative inline-block"> <div className="relative inline-block">
<div className="w-24 h-24 rounded-lg overflow-hidden bg-center bg-cover bg-no-repeat relative" <div
style={{ backgroundImage: `url(${imageUrl})` }}> className="relative h-24 w-24 overflow-hidden rounded-lg bg-cover bg-center bg-no-repeat"
style={{ backgroundImage: `url(${imageUrl})` }}
>
{/* 上传进度遮罩 */} {/* 上传进度遮罩 */}
{imageUploading && ( {imageUploading && (
<div className="absolute inset-0 bg-black/50 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center bg-black/50">
<div className="animate-spin rounded-full h-6 w-6 border-2 border-white border-t-transparent"></div> <div className="h-6 w-6 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
</div> </div>
)} )}
{/* 删除按钮 */} {/* 删除按钮 */}
<div className="absolute top-2 right-2 leading-none"> <div className="absolute top-2 right-2 leading-none">
<IconButton <IconButton variant="tertiaryDark" size="mini" onClick={removeImage}>
variant="tertiaryDark"
size="mini"
onClick={removeImage}
>
<i className="iconfont icon-close" /> <i className="iconfont icon-close" />
</IconButton> </IconButton>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); )
} }
export default ChatImagePreview; export default ChatImagePreview

Some files were not shown because too many files have changed in this diff Show More