Compare commits

..

No commits in common. "main" and "dev" have entirely different histories.
main ... dev

477 changed files with 34437 additions and 40769 deletions

2
.env
View File

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

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 Normal file
View File

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

View File

@ -1,6 +1,43 @@
# 依赖
node_modules node_modules
.next .pnpm-store
dist
package-lock.json
pnpm-lock.yaml pnpm-lock.yaml
# 构建产物
.next
out
dist
build
# 配置文件
package-lock.json
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,8 +1,10 @@
{ {
"semi": false, "semi": true,
"singleQuote": true, "singleQuote": false,
"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,14 +1,12 @@
# 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.
@ -16,20 +14,17 @@
- `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,27 +73,22 @@ 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并重定向到首页
- 完成整个登录流程 - 完成整个登录流程
@ -136,7 +131,6 @@ src/
### 认证相关API ### 认证相关API
#### 第三方登录 #### 第三方登录
``` ```
POST /web/third/login POST /web/third/login
Content-Type: application/json Content-Type: application/json
@ -150,7 +144,6 @@ Content-Type: application/json
``` ```
#### 获取用户信息 #### 获取用户信息
``` ```
GET /web/user/base-info GET /web/user/base-info
Headers: Headers:
@ -159,7 +152,6 @@ Headers:
``` ```
#### 登出 #### 登出
``` ```
POST /web/user/logout POST /web/user/logout
Headers: Headers:
@ -184,14 +176,12 @@ 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 运行报错,请改用下行
@ -201,7 +191,6 @@ 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`: 组件或函数名(无法解析时为文件名)
@ -213,7 +202,6 @@ node scripts/extract-copy.cjs
- `notes`: 预留备注 - `notes`: 预留备注
### 说明与边界 ### 说明与边界
- 仅提取可静态分析到的硬编码字符串;运行时拼接(仅变量)无法还原将被忽略 - 仅提取可静态分析到的硬编码字符串;运行时拼接(仅变量)无法还原将被忽略
- 会过滤明显的“代码样”字符串(如过长的标识符) - 会过滤明显的“代码样”字符串(如过长的标识符)
- 扫描目录为 `src/`,忽略 `node_modules/.next/__tests__/mocks` - 扫描目录为 `src/`,忽略 `node_modules/.next/__tests__/mocks`

View File

@ -1,11 +1,9 @@
# AI 建议回复功能重构说明 # AI 建议回复功能重构说明
## 重构日期 ## 重构日期
2025-11-17 2025-11-17
## 最新更新 ## 最新更新
2025-11-17 - 优化请求逻辑,确保每次只发送 3 次 API 请求 2025-11-17 - 优化请求逻辑,确保每次只发送 3 次 API 请求
## 问题描述 ## 问题描述
@ -30,33 +28,29 @@
#### 状态简化 #### 状态简化
**重构前:** **重构前:**
```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` 中,逻辑更清晰
- 页面切换时自动检查并加载缺失的页面数据 - 页面切换时自动检查并加载缺失的页面数据
@ -64,14 +58,12 @@ 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, ...)`
@ -81,22 +73,19 @@ 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 = const suggestions = isCurrentPageLoading || !currentPageSuggestions
isCurrentPageLoading || !currentPageSuggestions ? Array.from({ length: 3 }, (_, index) => ({
? Array.from({ length: 3 }, (_, index) => ({ id: `skeleton-${currentPage}-${index}`,
id: `skeleton-${currentPage}-${index}`, text: '',
text: '', isSkeleton: true
isSkeleton: true, }))
})) : currentPageSuggestions;
: currentPageSuggestions
``` ```
UI 组件根据 `isSkeleton` 标志渲染骨架屏或真实内容。 UI 组件根据 `isSkeleton` 标志渲染骨架屏或真实内容。
@ -104,7 +93,6 @@ 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` 动画效果
@ -112,26 +100,22 @@ 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 行
- 逻辑流程更清晰直观 - 逻辑流程更清晰直观
- 减少了状态依赖和副作用 - 减少了状态依赖和副作用
@ -174,19 +158,18 @@ 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`
@ -198,7 +181,6 @@ 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` 中的数据获取逻辑
@ -234,7 +216,6 @@ fetchAllData() {
``` ```
**关键优势** **关键优势**
- 🚀 **第1页数据到达后立即展示**,用户无需等待所有数据 - 🚀 **第1页数据到达后立即展示**,用户无需等待所有数据
- 📊 **后续页面数据追加展示**不影响用户浏览第1页 - 📊 **后续页面数据追加展示**不影响用户浏览第1页
- ⏱️ **感知加载时间更短**,提升用户体验 - ⏱️ **感知加载时间更短**,提升用户体验
@ -254,3 +235,4 @@ fetchAllData() {
2. 测试网络慢场景:确认骨架屏正确显示 2. 测试网络慢场景:确认骨架屏正确显示
3. 测试 Coin 不足场景:确认面板正确关闭 3. 测试 Coin 不足场景:确认面板正确关闭
4. 测试新消息场景:发送消息后面板已打开时自动刷新 4. 测试新消息场景:发送消息后面板已打开时自动刷新

View File

@ -7,27 +7,21 @@
## 实现组件 ## 实现组件
### 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建议功能到聊天输入组件
- 添加提示词按钮来触发建议面板 - 添加提示词按钮来触发建议面板
- 处理建议选择和应用 - 处理建议选择和应用
- 管理面板状态 - 管理面板状态
@ -35,14 +29,12 @@
## 设计细节 ## 设计细节
### 视觉设计 ### 视觉设计
- 遵循Figma设计稿的视觉样式 - 遵循Figma设计稿的视觉样式
- 使用毛玻璃效果和圆角设计 - 使用毛玻璃效果和圆角设计
- 渐变色彩搭配 - 渐变色彩搭配
- 响应式布局 - 响应式布局
### 交互设计 ### 交互设计
- 点击提示词按钮显示/隐藏建议面板 - 点击提示词按钮显示/隐藏建议面板
- **点击建议卡片:直接发送该建议作为消息** - **点击建议卡片:直接发送该建议作为消息**
- **点击编辑图标:将建议文案放入输入框进行编辑** - **点击编辑图标:将建议文案放入输入框进行编辑**
@ -68,26 +60,22 @@
## 核心逻辑 ## 核心逻辑
### 建议获取时机 ### 建议获取时机
- 只有当最后一条消息来自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,7 +42,9 @@ function ProfilePage() {
return ( return (
<div> <div>
{/* 触发按钮 */} {/* 触发按钮 */}
<button onClick={openAvatarSetting}>设置头像</button> <button onClick={openAvatarSetting}>
设置头像
</button>
{/* 头像设置模态框 */} {/* 头像设置模态框 */}
<AvatarSetting <AvatarSetting
@ -59,14 +61,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类名 |
## 层级关系 ## 层级关系

View File

@ -7,19 +7,16 @@
## 功能特性 ## 功能特性
### 1. 状态处理 ### 1. 状态处理
- **无等级状态**只显示AI头像和昵称 - **无等级状态**只显示AI头像和昵称
- **有等级状态**显示AI和用户双头像包含心动等级信息 - **有等级状态**显示AI和用户双头像包含心动等级信息
### 2. 视觉设计 ### 2. 视觉设计
- **双头像布局**AI头像和用户头像并排显示带有白色边框和阴影 - **双头像布局**AI头像和用户头像并排显示带有白色边框和阴影
- **多层背景装饰**:三层渐变圆形背景,从外到内颜色递进 - **多层背景装饰**:三层渐变圆形背景,从外到内颜色递进
- **心动等级徽章**:居中显示的心形徽章,包含等级数字 - **心动等级徽章**:居中显示的心形徽章,包含等级数字
- **角色信息展示**:角色名称和心动温度标签 - **角色信息展示**:角色名称和心动温度标签
### 3. 动画效果 ### 3. 动画效果
- **等级变化动画**:心形背景从大到小消失,数字等级渐变切换 - **等级变化动画**:心形背景从大到小消失,数字等级渐变切换
- **分层延迟**三层心形背景依次消失0ms, 100ms, 200ms延迟 - **分层延迟**三层心形背景依次消失0ms, 100ms, 200ms延迟
- **数字切换**:背景完全消失后,等级数字淡出淡入切换到新等级 - **数字切换**:背景完全消失后,等级数字淡出淡入切换到新等级
@ -28,8 +25,8 @@
```typescript ```typescript
interface CrushLevelAvatarProps { interface CrushLevelAvatarProps {
size?: 'large' | 'small' // 头像尺寸 size?: "large" | "small"; // 头像尺寸
showAnimation?: boolean // 是否显示等级变化动画 showAnimation?: boolean; // 是否显示等级变化动画
} }
``` ```
@ -66,12 +63,10 @@ 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`
@ -80,17 +75,12 @@ import CrushLevelAvatar from './components/CrushLevelAvatar';
## 实现细节 ## 实现细节
### 背景装饰层 ### 背景装饰层
```tsx ```tsx
{ {/* 心形背景层 - 使用SVG图标 */}
/* 心形背景层 - 使用SVG图标 */ <div className={cn(
} "absolute left-1/2 top-[-63px] -translate-x-1/2 w-60 h-[210px] pointer-events-none",
;<div isLevelChanging && showAnimation && "animate-scale-fade-out"
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"
@ -101,17 +91,19 @@ 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
;<div className="absolute top-1/2 left-1/2 z-10 size-10 -translate-x-1/2 -translate-y-1/2"> src="/icons/crushlevel_heart.svg"
<Image src="/icons/crushlevel_heart.svg" alt="heart" width={40} height={40} /> alt="heart"
width={40}
height={40}
/>
<div <div
className={cn( className={cn(
'relative z-10 text-base font-bold text-white transition-all duration-300', "relative z-10 font-bold text-white text-base transition-all duration-300",
isLevelChanging && 'animate-level-change' isLevelChanging && "animate-level-change"
)} )}
key={displayLevel} key={displayLevel}
> >
@ -121,52 +113,35 @@ 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% { 0% { transform: scale(1); opacity: 1; }
transform: scale(1); 100% { transform: scale(0.3); opacity: 0; }
opacity: 1;
}
100% {
transform: scale(0.3);
opacity: 0;
}
} }
/* 等级数字变化动画 */ /* 等级数字变化动画 */
@keyframes level-change { @keyframes level-change {
0% { 0% { opacity: 1; transform: scale(1); }
opacity: 1; 50% { opacity: 0; transform: scale(0.8); }
transform: scale(1); 100% { opacity: 1; transform: scale(1); }
}
50% {
opacity: 0;
transform: scale(0.8);
}
100% {
opacity: 1;
transform: scale(1);
}
} }
``` ```
@ -183,11 +158,9 @@ 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,7 +25,6 @@ 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
@ -43,7 +42,6 @@ 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
@ -125,7 +123,6 @@ console.log('Google Client ID:', process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID)
**问题 1**: 环境变量未生效 **问题 1**: 环境变量未生效
**解决方案**: **解决方案**:
- 重启开发服务器 (`npm run dev`) - 重启开发服务器 (`npm run dev`)
- 确保文件名正确 (`.env.local`) - 确保文件名正确 (`.env.local`)
- 检查变量名拼写 - 检查变量名拼写
@ -133,7 +130,6 @@ 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`,生产环境使用实际域名
@ -141,7 +137,6 @@ 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 应用状态为已发布/激活
@ -175,3 +170,4 @@ 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,17 @@
## 新旧方式对比 ## 新旧方式对比
### 旧方式OAuth 2.0 重定向流程) ### 旧方式OAuth 2.0 重定向流程)
``` ```
用户点击按钮 → 跳转到 Google 授权页面 → 授权后重定向回应用 用户点击按钮 → 跳转到 Google 授权页面 → 授权后重定向回应用
``` ```
❌ 需要页面跳转 ❌ 需要页面跳转
❌ 需要配置回调路由 ❌ 需要配置回调路由
❌ 用户体验不连贯 ❌ 用户体验不连贯
### 新方式Google Identity Services ### 新方式Google Identity Services
``` ```
用户点击按钮 → 弹出 Google 授权窗口 → 授权后直接回调 用户点击按钮 → 弹出 Google 授权窗口 → 授权后直接回调
``` ```
✅ 无需页面跳转 ✅ 无需页面跳转
✅ 无需回调路由 ✅ 无需回调路由
✅ 用户体验流畅 ✅ 用户体验流畅
@ -54,13 +50,11 @@
### 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
@ -89,16 +83,15 @@ 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
- 处理授权码回调 - 处理授权码回调
@ -107,7 +100,6 @@ export const googleOAuth = {
**关键实现**: **关键实现**:
#### SDK 加载 #### SDK 加载
```typescript ```typescript
useEffect(() => { useEffect(() => {
const loadGoogleSDK = async () => { const loadGoogleSDK = async () => {
@ -116,7 +108,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")
} }
} }
@ -125,7 +117,6 @@ useEffect(() => {
``` ```
#### 授权码处理 #### 授权码处理
```typescript ```typescript
const handleGoogleResponse = async (response: GoogleCodeResponse) => { const handleGoogleResponse = async (response: GoogleCodeResponse) => {
const deviceId = tokenManager.getDeviceId() const deviceId = tokenManager.getDeviceId()
@ -133,23 +124,22 @@ 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 已加载
@ -159,7 +149,10 @@ const handleGoogleLogin = async () => {
// 初始化 Code Client // 初始化 Code Client
if (!codeClientRef.current) { if (!codeClientRef.current) {
codeClientRef.current = googleOAuth.initCodeClient(handleGoogleResponse, handleGoogleError) codeClientRef.current = googleOAuth.initCodeClient(
handleGoogleResponse,
handleGoogleError
)
} }
// 请求授权码(弹出授权窗口) // 请求授权码(弹出授权窗口)
@ -207,7 +200,6 @@ POST /api/auth/login
``` ```
后端需要: 后端需要:
1. 使用授权码向 Google 交换 access_token 1. 使用授权码向 Google 交换 access_token
2. 使用 access_token 获取用户信息 2. 使用 access_token 获取用户信息
3. 创建或更新用户 3. 创建或更新用户
@ -216,72 +208,60 @@ 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) => {
// 用户取消授权不显示错误提示 // 用户取消授权不显示错误提示
@ -289,14 +269,13 @@ const handleGoogleError = (error: any) => {
return return
} }
toast.error('Google login failed') toast.error("Google login failed")
} }
``` ```
## 测试清单 ## 测试清单
### 本地测试 ### 本地测试
- [ ] SDK 正常加载 - [ ] SDK 正常加载
- [ ] 点击按钮弹出授权窗口 - [ ] 点击按钮弹出授权窗口
- [ ] 授权后正确回调 - [ ] 授权后正确回调
@ -306,7 +285,6 @@ const handleGoogleError = (error: any) => {
- [ ] 错误情况的处理 - [ ] 错误情况的处理
### 生产环境测试 ### 生产环境测试
- [ ] 配置正确的 JavaScript 来源 - [ ] 配置正确的 JavaScript 来源
- [ ] HTTPS 证书有效 - [ ] HTTPS 证书有效
- [ ] 环境变量配置正确 - [ ] 环境变量配置正确
@ -316,7 +294,6 @@ const handleGoogleError = (error: any) => {
## 浏览器兼容性 ## 浏览器兼容性
Google Identity Services 支持: Google Identity Services 支持:
- ✅ Chrome 90+ - ✅ Chrome 90+
- ✅ Firefox 88+ - ✅ Firefox 88+
- ✅ Safari 14+ - ✅ Safari 14+
@ -325,21 +302,17 @@ Google Identity Services 支持:
## 安全注意事项 ## 安全注意事项
### 1. 客户端 ID 保护 ### 1. 客户端 ID 保护
虽然客户端 ID 是公开的,但仍需注意: 虽然客户端 ID 是公开的,但仍需注意:
- 限制授权的 JavaScript 来源 - 限制授权的 JavaScript 来源
- 定期检查使用情况 - 定期检查使用情况
- 发现异常及时更换 - 发现异常及时更换
### 2. 授权码处理 ### 2. 授权码处理
- 授权码只能使用一次 - 授权码只能使用一次
- 及时传递给后端 - 及时传递给后端
- 不要在客户端存储 - 不要在客户端存储
### 3. HTTPS 要求 ### 3. HTTPS 要求
- 生产环境必须使用 HTTPS - 生产环境必须使用 HTTPS
- 本地开发可以使用 HTTP - 本地开发可以使用 HTTP
@ -367,39 +340,36 @@ 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(document.getElementById('buttonDiv'), { window.google.accounts.id.renderButton(
theme: 'outline', document.getElementById('buttonDiv'),
size: 'large', { theme: 'outline', size: 'large' }
}) )
``` ```
## 相关文档 ## 相关文档
@ -418,3 +388,4 @@ window.google.accounts.id.renderButton(document.getElementById('buttonDiv'), {
**更加安全** - SDK 自动处理安全验证 **更加安全** - SDK 自动处理安全验证
强烈建议新项目直接使用这种方式! 强烈建议新项目直接使用这种方式!

View File

@ -40,7 +40,6 @@ 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 回调路由
@ -84,7 +83,6 @@ POST /api/auth/login
``` ```
后端需要: 后端需要:
1. 使用授权码向 Google 交换 access_token 1. 使用授权码向 Google 交换 access_token
2. 使用 access_token 获取用户信息 2. 使用 access_token 获取用户信息
3. 创建或更新用户 3. 创建或更新用户
@ -93,15 +91,12 @@ 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 登录。
## 生产环境部署 ## 生产环境部署
@ -109,7 +104,6 @@ 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
``` ```
@ -136,7 +130,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,12 +56,11 @@ 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
@ -71,7 +70,6 @@ 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 授权页面
@ -80,7 +78,6 @@ export const googleOAuth = {
- 处理登录成功/失败的重定向 - 处理登录成功/失败的重定向
**关键方法**: **关键方法**:
```typescript ```typescript
const handleGoogleLogin = () => { const handleGoogleLogin = () => {
// 1. 生成 state // 1. 生成 state
@ -98,7 +95,6 @@ const handleGoogleLogin = () => {
``` ```
**OAuth 回调处理**: **OAuth 回调处理**:
```typescript ```typescript
useEffect(() => { useEffect(() => {
const googleCode = searchParams.get('google_code') const googleCode = searchParams.get('google_code')
@ -115,7 +111,6 @@ 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
- 重定向回登录页面,并将参数传递给前端 - 重定向回登录页面,并将参数传递给前端
@ -163,14 +158,13 @@ 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. 创建或更新用户账号
@ -179,26 +173,22 @@ 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 || '')
@ -211,20 +201,17 @@ if (loginRedirectUrl) {
``` ```
### 2. 错误处理 ### 2. 错误处理
- 授权失败时显示友好的错误提示 - 授权失败时显示友好的错误提示
- 自动清理 URL 参数 - 自动清理 URL 参数
- 不影响用户继续尝试登录 - 不影响用户继续尝试登录
### 3. 加载状态 ### 3. 加载状态
- 使用 `useLogin` Hook 的 loading 状态 - 使用 `useLogin` Hook 的 loading 状态
- 可以添加 loading 动画提升体验 - 可以添加 loading 动画提升体验
## 测试清单 ## 测试清单
### 本地测试 ### 本地测试
- [ ] 点击 Google 登录按钮跳转到 Google 授权页面 - [ ] 点击 Google 登录按钮跳转到 Google 授权页面
- [ ] 授权后正确回调到应用 - [ ] 授权后正确回调到应用
- [ ] 授权码正确传递给后端 - [ ] 授权码正确传递给后端
@ -233,7 +220,6 @@ if (loginRedirectUrl) {
- [ ] 错误情况处理正确 - [ ] 错误情况处理正确
### 生产环境测试 ### 生产环境测试
- [ ] 配置正确的回调 URL - [ ] 配置正确的回调 URL
- [ ] HTTPS 证书有效 - [ ] HTTPS 证书有效
- [ ] 环境变量配置正确 - [ ] 环境变量配置正确
@ -242,60 +228,50 @@ 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 登录逻辑
@ -303,7 +279,6 @@ const useOAuthLogin = (provider: 'google' | 'discord' | 'apple') => {
``` ```
### 3. 添加登录统计 ### 3. 添加登录统计
记录不同登录方式的使用情况,优化用户体验。 记录不同登录方式的使用情况,优化用户体验。
## 相关文档 ## 相关文档
@ -311,3 +286,4 @@ 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,25 +20,22 @@
### 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
@ -53,56 +50,54 @@ 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( const updateMessageLikeStatus = useCallback(async (
async (conversationId: string, messageClientId: string, likeStatus: MessageLikeStatus) => { conversationId: string,
// 1. 获取当前登录用户ID messageClientId: string,
const currentUserId = nim.V2NIMLoginService.getLoginUser() likeStatus: MessageLikeStatus
) => {
// 1. 获取当前登录用户ID
const currentUserId = nim.V2NIMLoginService.getLoginUser();
// 2. 解析当前消息的serverExtension // 2. 解析当前消息的serverExtension
const currentServerExt = parseMessageServerExtension(targetMessage.serverExtension) const currentServerExt = parseMessageServerExtension(targetMessage.serverExtension);
// 3. 更新用户的点赞状态(简化版) // 3. 更新用户的点赞状态(简化版)
const newServerExt = { ...currentServerExt } const newServerExt = { ...currentServerExt };
if (likeStatus === MessageLikeStatus.None) { if (likeStatus === MessageLikeStatus.None) {
delete newServerExt[currentUserId] // 移除点赞状态 delete newServerExt[currentUserId]; // 移除点赞状态
} else { } else {
newServerExt[currentUserId] = likeStatus // 设置新状态 newServerExt[currentUserId] = likeStatus; // 设置新状态
} }
// 4. 调用NIM SDK更新消息 // 4. 调用NIM SDK更新消息
const modifyResult = await nim.V2NIMMessageService.modifyMessage(targetMessage, { const modifyResult = await nim.V2NIMMessageService.modifyMessage(targetMessage, {
serverExtension: stringifyMessageServerExtension(newServerExt), serverExtension: stringifyMessageServerExtension(newServerExt)
}) });
// 5. 更新本地状态 // 5. 更新本地状态
addMsg(conversationId, [modifyResult.message], false) addMsg(conversationId, [modifyResult.message], false);
}, }, [addMsg]);
[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消息容器组件已集成点赞功能
- 鼠标悬停显示操作按钮 - 鼠标悬停显示操作按钮
@ -158,13 +153,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);
``` ```
## 状态管理 ## 状态管理
@ -180,24 +175,22 @@ 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
@ -208,38 +201,30 @@ 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( return updateMessageLikeStatusWithRetry(conversationId, messageClientId, likeStatus, retryCount - 1);
conversationId,
messageClientId,
likeStatus,
retryCount - 1
)
} else { } else {
throw error throw error;
} }
} }
} };
``` ```
### 3. 批量操作 ### 3. 批量操作
对于大量消息的点赞状态批量更新: 对于大量消息的点赞状态批量更新:
```typescript ```typescript
const batchUpdateLikes = ( const batchUpdateLikes = (updates: Array<{
updates: Array<{ conversationId: string;
conversationId: string messageClientId: string;
messageClientId: string likeStatus: MessageLikeStatus;
likeStatus: MessageLikeStatus }>) => {
}>
) => {
// 批量更新逻辑 // 批量更新逻辑
} };
``` ```
## 注意事项 ## 注意事项

View File

@ -11,15 +11,16 @@
**文件位置**: `src/app/(main)/home/components/StartChat/StartChatItem.tsx` **文件位置**: `src/app/(main)/home/components/StartChat/StartChatItem.tsx`
**功能**: **功能**:
- 对话建议列表中的每一项都是一个链接 - 对话建议列表中的每一项都是一个链接
- 点击时跳转到聊天页面,并将建议文本作为 URL 参数传递 - 点击时跳转到聊天页面,并将建议文本作为 URL 参数传递
- 使用 `encodeURIComponent()` 对文本进行编码,确保特殊字符正确传递 - 使用 `encodeURIComponent()` 对文本进行编码,确保特殊字符正确传递
**示例**: **示例**:
```tsx ```tsx
<Link href={`/chat/${character.aiId}?text=${encodeURIComponent(suggestion)}`} className="..."> <Link
href={`/chat/${character.aiId}?text=${encodeURIComponent(suggestion)}`}
className="..."
>
<span>{suggestion}</span> <span>{suggestion}</span>
</Link> </Link>
``` ```
@ -29,42 +30,36 @@
**文件位置**: `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!
``` ```
@ -90,15 +85,13 @@ 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://...
``` ```
@ -106,7 +99,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);
} }
``` ```
@ -115,3 +108,4 @@ 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,19 +7,16 @@
## 核心功能 ## 核心功能
### 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. **状态管理**:生成中、播放中、已缓存等状态
@ -59,11 +56,14 @@ function MyComponent() {
text: '你好,这是测试语音', text: '你好,这是测试语音',
voiceType: 'S_zh_xiaoxiao_emotion', voiceType: 'S_zh_xiaoxiao_emotion',
speechRate: 0, speechRate: 0,
loudnessRate: 0, loudnessRate: 0
} }
return ( return (
<button onClick={() => generateAndPlay(config)} disabled={isGenerating}> <button
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

@ -1,67 +1,66 @@
# 项目概述 # 项目概述
这是一个使用 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,26 +1,13 @@
import { dirname } from 'path' import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import { fileURLToPath } from 'url' import nextTypescript from "eslint-config-next/typescript";
import { FlatCompat } from '@eslint/eslintrc' import { dirname } from "path";
import prettier from 'eslint-config-prettier' import { fileURLToPath } from "url";
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 compat = new FlatCompat({ const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
baseDirectory: __dirname, ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts"]
}) }];
const eslintConfig = [ export default 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

File diff suppressed because it is too large Load Diff

View File

@ -3,14 +3,15 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "next lint", "lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"i18n:scan": "i18next-scanner", "i18n:scan": "i18next-scanner",
"i18n:scan-custom": "tsx scripts/i18n-scan.ts", "i18n:scan-custom": "tsx scripts/i18n-scan.ts",
"i18n:convert": "node scripts/convert-to-i18n.js", "i18n:convert": "node scripts/convert-to-i18n.js"
"format": "prettier --write ."
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.850.0", "@aws-sdk/client-s3": "^3.850.0",
@ -50,13 +51,13 @@
"keen-slider": "^6.8.6", "keen-slider": "^6.8.6",
"lamejs": "^1.2.1", "lamejs": "^1.2.1",
"lucide-react": "^0.525.0", "lucide-react": "^0.525.0",
"next": "15.3.5", "next": "16.0.3",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"nim-web-sdk-ng": "^10.9.41", "nim-web-sdk-ng": "^10.9.41",
"numeral": "^2.0.6", "numeral": "^2.0.6",
"qs": "^6.14.0", "qs": "^6.14.0",
"react": "^19.0.0", "react": "19.2.0",
"react-dom": "^19.0.0", "react-dom": "19.2.0",
"react-easy-crop": "^5.5.0", "react-easy-crop": "^5.5.0",
"react-hook-form": "^7.60.0", "react-hook-form": "^7.60.0",
"react-photo-view": "^1.2.7", "react-photo-view": "^1.2.7",
@ -68,27 +69,24 @@
"zod": "^4.0.5" "zod": "^4.0.5"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/js-cookie": "^3.0.6", "@types/js-cookie": "^3.0.6",
"@types/node": "^20", "@types/node": "^20",
"@types/numeral": "^2.0.5", "@types/numeral": "^2.0.5",
"@types/qs": "^6.14.0", "@types/qs": "^6.14.0",
"@types/react": "^19", "@types/react": "19.2.5",
"@types/react-dom": "^19", "@types/react-dom": "19.2.3",
"acorn": "^8.15.0", "acorn": "^8.15.0",
"acorn-jsx": "^5.3.2", "acorn-jsx": "^5.3.2",
"acorn-typescript": "^1.4.13", "acorn-typescript": "^1.4.13",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "15.3.5", "eslint-config-next": "16.0.3",
"eslint-config-prettier": "^10.1.8", "eslint-config-prettier": "^9.1.0",
"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.7.1", "prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.7.1", "prettier-plugin-tailwindcss": "^0.6.11",
"tailwindcss": "^4", "tailwindcss": "^4",
"ts-morph": "^27.0.2", "ts-morph": "^27.0.2",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
@ -101,5 +99,9 @@
"workerDirectory": [ "workerDirectory": [
"public" "public"
] ]
},
"overrides": {
"@types/react": "19.2.5",
"@types/react-dom": "19.2.3"
} }
} }

8692
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -1,18 +1,15 @@
/* 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: 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.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') url('https://at.alicdn.com/t/font_985780_km7mi63cihi.svg?t=1545807318834#iconfont') format('svg');
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;
@ -51,6 +48,7 @@
color: #666; color: #666;
} }
#tabs .active { #tabs .active {
border-bottom-color: #f00; border-bottom-color: #f00;
color: #222; color: #222;
@ -121,15 +119,9 @@
font-size: 42px; font-size: 42px;
margin: 10px auto; margin: 10px auto;
color: #333; color: #333;
-webkit-transition: -webkit-transition: font-size 0.25s linear, width 0.25s linear;
font-size 0.25s linear, -moz-transition: font-size 0.25s linear, width 0.25s linear;
width 0.25s linear; transition: font-size 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 {
@ -223,35 +215,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;
} }
@ -268,7 +260,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;
@ -277,21 +269,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 {
@ -326,11 +318,12 @@
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;
@ -406,8 +399,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;
@ -429,45 +422,46 @@ 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-'],
pre[class*='language-'] { code[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: 0.5em 0; margin: .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: 0.1em; padding: .1em;
border-radius: 0.3em; border-radius: .3em;
white-space: normal; white-space: normal;
} }
@ -483,7 +477,7 @@ pre[class*='language-'] {
} }
.namespace { .namespace {
opacity: 0.7; opacity: .7;
} }
.token.property, .token.property,
@ -511,7 +505,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%, 0.5); background: hsla(0, 0%, 100%, .5);
} }
.token.atrule, .token.atrule,
@ -522,7 +516,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

@ -101,7 +101,10 @@ 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 (event.request.cache === 'only-if-cached' && event.request.mode !== 'same-origin') { if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return return
} }
@ -153,7 +156,7 @@ async function handleRequest(event, requestId) {
}, },
}, },
}, },
responseClone.body ? [serializedRequest.body, responseClone.body] : [] responseClone.body ? [serializedRequest.body, responseClone.body] : [],
) )
} }
@ -217,7 +220,9 @@ 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((value) => value !== 'msw/passthrough') const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) { if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', ')) headers.set('accept', filteredValues.join(', '))
@ -253,7 +258,7 @@ async function getResponse(event, client, requestId) {
...serializedRequest, ...serializedRequest,
}, },
}, },
[serializedRequest.body] [serializedRequest.body],
) )
switch (clientMessage.type) { switch (clientMessage.type) {
@ -287,7 +292,10 @@ function sendToClient(client, message, transferrables = []) {
resolve(event.data) resolve(event.data)
} }
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)]) client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
}) })
} }

View File

@ -5,49 +5,41 @@
✅ **成功完成文案翻译覆盖任务** ✅ **成功完成文案翻译覆盖任务**
### 统计数据 ### 统计数据
- **总翻译条目**: 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
@ -59,12 +51,10 @@ node scripts/apply-translations.cjs
## 处理建议 ## 处理建议
### 对于剩余冲突 ### 对于剩余冲突
1. **文本未找到的条目**:检查是否包含特殊字符或格式问题 1. **文本未找到的条目**:检查是否包含特殊字符或格式问题
2. **多处匹配的条目**:需要人工确认具体替换哪个位置 2. **多处匹配的条目**:需要人工确认具体替换哪个位置
### 后续优化 ### 后续优化
1. 可以针对特殊字符emoji的匹配进行优化 1. 可以针对特殊字符emoji的匹配进行优化
2. 可以添加更智能的多处匹配处理逻辑 2. 可以添加更智能的多处匹配处理逻辑
3. 可以添加翻译质量验证机制 3. 可以添加翻译质量验证机制
@ -72,7 +62,6 @@ 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,306 +18,309 @@ 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( let translations = data.filter(row =>
(row) => row.text && row.corrected_text && row.text !== row.corrected_text row.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,
@ -327,52 +330,53 @@ 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,114 +1,105 @@
/* /*
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)) 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;
} }
async function collectFiles() { async function collectFiles() {
const patterns = ['src/**/*.{ts,tsx}'] const patterns = ['src/**/*.{ts,tsx}'];
const ignore = [ const ignore = ['**/node_modules/**','**/.next/**','**/__tests__/**','**/mocks/**','**/mock/**','**/*.d.ts'];
'**/node_modules/**', return await globby(patterns, { gitignore: true, ignore });
'**/.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,
@ -118,15 +109,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,
@ -136,29 +127,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,
@ -168,75 +159,47 @@ 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') { if (left === 'toast' || left === 'message') { kind = 'toast'; keyOrLocator = `${left}.${name}`; }
kind = 'toast' if ((left || '').toLowerCase().includes('dialog')) { kind = 'dialog'; keyOrLocator = `${left}.${name}`; }
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') { if (id === 'alert' || id === 'confirm') { kind = 'dialog'; keyOrLocator = id; }
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, { pushItem(items, { route, file: path.relative(WORKDIR, abs), componentOrFn, kind, keyOrLocator, text, line: getNodeLine(node) });
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 ( if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) {
init && const text = init.getLiteralText();
(Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init)) pushItem(items, { route, file: path.relative(WORKDIR, abs), componentOrFn, kind: 'error', keyOrLocator: 'form.setError', text, line: getNodeLine(msgProp) });
) {
const text = init.getLiteralText()
pushItem(items, {
route,
file: path.relative(WORKDIR, abs),
componentOrFn,
kind: 'error',
keyOrLocator: 'form.setError',
text,
line: getNodeLine(msgProp),
})
} }
} }
} }
@ -244,46 +207,35 @@ 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 ( if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) {
init && const text = init.getLiteralText();
(Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init)) pushItem(items, { route, file: path.relative(WORKDIR, abs), componentOrFn, kind: 'validation', keyOrLocator: 'message', text, line: getNodeLine(prop) });
) {
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) {
@ -297,37 +249,36 @@ 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({ const project = new Project({ tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'), skipAddingFilesFromTsConfig: true });
tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'), const items = [];
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,198 +3,193 @@
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 { import { Project, SyntaxKind, Node, JsxAttribute, StringLiteral, NoSubstitutionTemplateLiteral } from "ts-morph";
Project, import * as XLSX from "xlsx";
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 = ['src/**/*.{ts,tsx}'] const patterns = [
"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,
@ -204,40 +199,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),
@ -246,33 +241,30 @@ 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 ( if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) {
init && const text = init.getLiteralText();
(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),
}) });
} }
} }
} }
@ -280,51 +272,48 @@ 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 ( if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) {
init && const text = init.getLiteralText();
(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 {
@ -337,40 +326,42 @@ 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,216 +3,211 @@
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 { import { Project, SyntaxKind, Node, JsxAttribute, StringLiteral, NoSubstitutionTemplateLiteral } from "ts-morph";
Project, import * as XLSX from "xlsx";
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 = ['src/**/*.{ts,tsx}'] const patterns = [
"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,
@ -222,40 +217,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),
@ -264,33 +259,30 @@ 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 ( if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) {
init && const text = init.getLiteralText();
(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),
}) });
} }
} }
} }
@ -298,62 +290,59 @@ 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 ( if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) {
init && const text = init.getLiteralText();
(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 {
@ -367,81 +356,75 @@ 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( byRoute: aggregated.reduce((acc, item) => {
(acc, item) => { acc[item.route] = (acc[item.route] || 0) + 1;
acc[item.route] = (acc[item.route] || 0) + 1 return acc;
return acc }, {} as Record<string, number>),
}, byKind: aggregated.reduce((acc, item) => {
{} as Record<string, number> acc[item.kind] = (acc[item.kind] || 0) + 1;
), return acc;
byKind: aggregated.reduce( }, {} as Record<string, number>),
(acc, item) => { translationKeys: Object.keys(translation).length
acc[item.kind] = (acc[item.kind] || 0) + 1 };
return acc
},
{} as Record<string, number>
),
translationKeys: Object.keys(translation).length,
}
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));
// 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,286 +20,289 @@ 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( let translations = data.filter(row =>
(row) => row.text && row.corrected_text && row.text !== row.corrected_text row.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,
@ -309,55 +312,56 @@ 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

@ -1,17 +1,20 @@
const AboutPage = () => { const AboutPage = () => {
return ( return (
<div className="relative flex size-full flex-col items-center justify-start"> <div className="flex flex-col items-center justify-start relative size-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="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="max-w-[752px]"> <div className="max-w-[752px]">
<div className="relative w-full pb-[27.26%]"> <div className="pb-[27.26%] w-full relative">
<img <img
src="/images/about/banner.png" src="/images/about/banner.png"
alt="Banner" alt="Banner"
className="absolute inset-0 object-cover" className="inset-0 object-cover absolute"
/> />
</div> </div>
<div className="txt-body-l mt-12"> <div className="mt-12 txt-body-l">
<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>
@ -20,18 +23,23 @@ 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 From that tentative "Hi" to the trembling "I do", find a home for the flirts you never sent,
sent, </div>
<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">{`Contact Us: ${process.env.NEXT_PUBLIC_EMAIL_CONTACT_US}`}</div> <div className="mt-8">
{`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,11 +1,13 @@
import { ReactNode } from 'react' import { ReactNode } from "react";
const AuthLayout = ({ children }: { children: ReactNode }) => { const AuthLayout = ({ children }: { children: ReactNode }) => {
return ( return (
<div className="relative flex items-center justify-center bg-[url('/common-bg.png')] bg-cover bg-fixed bg-top bg-no-repeat"> <div className="flex items-center justify-center relative bg-[url('/common-bg.png')] bg-cover bg-top bg-no-repeat bg-fixed">
<div className="min-h-screen w-full">{children}</div> <div className="min-h-screen w-full">
{children}
</div>
</div> </div>
) );
} }
export default AuthLayout export default AuthLayout;

View File

@ -1,89 +1,86 @@
'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
} }
// 使用code调用后端登录接口 // 处理Discord授权码
const deviceId = tokenManager.getDeviceId() if (discordCode) {
const loginData = { // 验证state参数可选的安全检查
appClient: AppClient.Web, const savedState = sessionStorage.getItem('discord_oauth_state')
deviceCode: deviceId, if (savedState && discordState && savedState !== discordState) {
thirdToken: discordCode, // 直接传递Discord授权码 toast.error("Discord login failed")
thirdType: ThirdType.Discord, return
} }
login.mutate(loginData, { // 使用code调用后端登录接口
onSuccess: () => { const deviceId = tokenManager.getDeviceId()
toast.success('Login successful') const loginData = {
appClient: AppClient.Web,
deviceCode: deviceId,
thirdToken: discordCode, // 直接传递Discord授权码
thirdType: ThirdType.Discord
}
// 清除 Next.js 路由缓存,避免使用登录前 prefetch 的重定向响应 login.mutate(loginData, {
router.refresh() onSuccess: () => {
toast.success("Login successful")
// 清理URL参数和sessionStorage // 清理URL参数和sessionStorage
sessionStorage.removeItem('discord_oauth_state') sessionStorage.removeItem('discord_oauth_state')
const newUrl = new URL(window.location.href) const newUrl = new URL(window.location.href)
newUrl.searchParams.delete('discord_code') newUrl.searchParams.delete('discord_code')
newUrl.searchParams.delete('discord_state') newUrl.searchParams.delete('discord_state')
router.replace(newUrl.pathname) router.replace(newUrl.pathname)
const loginRedirectUrl = sessionStorage.getItem('login_redirect_url') const loginRedirectUrl = sessionStorage.getItem('login_redirect_url')
// 重定向到首页或指定页面 // 重定向到首页或指定页面
if (loginRedirectUrl) { if (loginRedirectUrl) {
router.push(loginRedirectUrl) router.push(loginRedirectUrl)
} else { } else {
router.push('/') router.push('/')
}
},
onError: (error) => {
// 清理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)
} }
}, })
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 {
@ -103,7 +100,7 @@ const DiscordButton = () => {
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")
} }
} }
@ -113,9 +110,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)
@ -29,15 +29,12 @@ const GoogleButton = () => {
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')
@ -51,13 +48,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)
} }
} }
@ -77,14 +74,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")
} }
} }
@ -100,14 +97,17 @@ const GoogleButton = () => {
// 如果 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 ref={buttonRef} style={{ display: 'none' }} /> <div
ref={buttonRef}
style={{ display: 'none' }}
/>
{/* 自定义样式的按钮,点击时触发隐藏的 Google 按钮 */} {/* 自定义样式的按钮,点击时触发隐藏的 Google 按钮 */}
<SocialButton <SocialButton
@ -116,9 +116,7 @@ const GoogleButton = () => {
handleGoogleLogin() handleGoogleLogin()
// 触发隐藏的 Google 按钮 // 触发隐藏的 Google 按钮
if (buttonRef.current) { if (buttonRef.current) {
const googleButton = buttonRef.current.querySelector( const googleButton = buttonRef.current.querySelector('div[role="button"]') as HTMLElement
'div[role="button"]'
) as HTMLElement
if (googleButton) { if (googleButton) {
googleButton.click() googleButton.click()
} }
@ -126,10 +124,11 @@ 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[]
@ -12,9 +12,9 @@ interface ImageCarouselProps {
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,7 +25,9 @@ export function ImageCarousel({
const timer = setInterval(() => { const timer = setInterval(() => {
setIsTransitioning(true) setIsTransitioning(true)
setTimeout(() => { setTimeout(() => {
setCurrentIndex((prevIndex) => (prevIndex === images.length - 1 ? 0 : prevIndex + 1)) setCurrentIndex((prevIndex) =>
prevIndex === images.length - 1 ? 0 : prevIndex + 1
)
setIsTransitioning(false) setIsTransitioning(false)
}, 250) // 渐隐时间的一半 }, 250) // 渐隐时间的一半
}, interval) }, interval)
@ -38,17 +40,19 @@ export function ImageCarousel({
} }
return ( return (
<div className={`group relative ${className}`}> <div className={`relative group ${className}`}>
{/* 主图片容器 */} {/* 主图片容器 */}
<div className="relative h-full w-full overflow-hidden"> <div className="relative w-full h-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={`absolute inset-0 object-cover object-top transition-opacity duration-500 ${ className={`object-cover object-top transition-opacity duration-500 absolute inset-0 ${
index === currentIndex && !isTransitioning ? 'opacity-100' : 'opacity-0' index === currentIndex && !isTransitioning
? 'opacity-100'
: 'opacity-0'
}`} }`}
priority={index === 0} priority={index === 0}
/> />

View File

@ -1,95 +1,92 @@
'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) => (prevIndex === images.length - 1 ? 0 : prevIndex + 1)) setCurrentIndex((prevIndex) =>
setIsTransitioning(false) prevIndex === images.length - 1 ? 0 : prevIndex + 1
}, 300) );
}, 3000) // 每3秒切换一次 setIsTransitioning(false);
}, 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 h-full w-full overflow-hidden"> <div className="relative w-full h-full overflow-hidden">
{/* 滚动背景 */} {/* 滚动背景 */}
<ScrollingBackground imageSrc={scrollBg} /> <ScrollingBackground imageSrc={scrollBg} />
{/* 内容层 */} {/* 内容层 */}
<div className="relative z-10 flex h-full flex-col justify-end"> <div className="relative z-10 flex flex-col justify-end h-full">
{/* 底部遮罩层 - 铺满背景底部高度500px */} {/* 底部遮罩层 - 铺满背景底部高度500px */}
<div <div className="absolute bottom-0 left-0 right-0 h-[500px] z-[5]" style={{
className="absolute right-0 bottom-0 left-0 z-[5] h-[500px]" background: "linear-gradient(180deg, rgba(33, 26, 43, 0) 0%, #211A2B 100%)",
style={{ boxShadow: "0px 4px 4px 0px #00000040"
background: 'linear-gradient(180deg, rgba(33, 26, 43, 0) 0%, #211A2B 100%)', }} />
boxShadow: '0px 4px 4px 0px #00000040',
}}
/>
{/* 文字内容 - 在图片上方 */} {/* 文字内容 - 在图片上方 */}
<div <div
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 ${ 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 ${
isTransitioning ? 'opacity-0' : 'opacity-100' isTransitioning ? "opacity-0" : "opacity-100"
}`} }`}
> >
<div className="mx-auto flex items-center justify-center px-4 py-2 lg:px-6 lg:py-3"> <div className="flex items-center justify-center px-4 lg:px-6 py-2 lg:py-3 mx-auto">
<h2 className="txt-headline-m lg:txt-headline-l relative flex items-center gap-2 text-white"> <h2 className="txt-headline-m lg:txt-headline-l text-white flex items-center gap-2 relative">
{currentText.title} {currentText.title}
<Image <Image src="/images/login/v1/icon-star-right.svg" alt="logo" width={38} height={36} className="absolute -top-[18px] -right-[38px]" />
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 mx-auto max-w-[320px] lg:max-w-[380px]"> <p className="txt-body-m lg:txt-body-l max-w-[320px] lg:max-w-[380px] mx-auto">
{currentText.subtitle} {currentText.subtitle}
</p> </p>
</div> </div>
{/* 角色图片 - 尽可能放大,紧贴底部 */} {/* 角色图片 - 尽可能放大,紧贴底部 */}
<div className="relative h-[80vh] w-full lg:h-[85vh]"> <div className="relative w-full h-[80vh] 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 ? 'opacity-100' : 'opacity-0' index === currentIndex && !isTransitioning
? "opacity-100"
: "opacity-0"
}`} }`}
> >
<Image <Image
@ -104,5 +101,6 @@ 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 container.scrollTop = scrollPosition;
requestAnimationFrame(animate) requestAnimationFrame(animate);
} };
const animationId = 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",
}} }}
> >
{/* 隐藏滚动条的样式 */} {/* 隐藏滚动条的样式 */}
@ -53,9 +53,18 @@ export function ScrollingBackground({ imageSrc }: ScrollingBackgroundProps) {
{/* 两张相同的图片,用于无缝循环 */} {/* 两张相同的图片,用于无缝循环 */}
<div className="relative"> <div className="relative">
<img src={imageSrc} alt="Background" className="block h-auto w-full" /> <img
<img src={imageSrc} alt="Background" className="block h-auto w-full" /> src={imageSrc}
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,7 +12,13 @@ interface SocialButtonProps {
export function SocialButton({ icon, children, loading, onClick, disabled }: SocialButtonProps) { export function SocialButton({ icon, children, loading, onClick, disabled }: SocialButtonProps) {
return ( return (
<Button variant="tertiary" block onClick={onClick} disabled={disabled} loading={loading}> <Button
variant="tertiary"
block
onClick={onClick}
disabled={disabled}
loading={loading}
>
{icon} {icon}
{children} {children}
</Button> </Button>

View File

@ -1,44 +1,45 @@
'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="mb-4 text-center sm:mb-6"> <div className="text-center mb-4 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 text-sm sm:mt-4 sm:text-base">Chat, Crush, AI Date</p> <p className="text-gradient mt-3 sm:mt-4 text-sm sm:text-base">Chat, Crush, AI Date</p>
</div> </div>
<div className="mt-4 space-y-3 sm:mt-6 sm:space-y-4"> <div className="space-y-3 sm:space-y-4 mt-4 sm:mt-6">
<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="mt-4 text-center sm:mt-6"> <div className="text-center mt-4 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,41 +1,29 @@
'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 { import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
Select, import { Form, FormField, FormControl, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
SelectTrigger, import GenderInput from "@/components/features/genderInput"
SelectValue, import { zodResolver } from "@hookform/resolvers/zod"
SelectContent, import * as z from "zod"
SelectItem, import { Gender } from "@/types/user"
} from '@/components/ui/select' import { useEffect, useState } from "react"
import { import dayjs from "dayjs"
Form, import { useCheckNickname, useCompleteUser, useCurrentUser } from "@/hooks/auth"
FormField, import { useRouter, useSearchParams } from "next/navigation"
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({ length: dayjs(`${year}-${month}`).daysInMonth() }, (_, i) => return Array.from(
`${i + 1}`.padStart(2, '0') { length: dayjs(`${year}-${month}`).daysInMonth() },
(_, i) => `${i + 1}`.padStart(2, "0")
) )
} }
@ -45,28 +33,19 @@ function calculateAge(year: string, month: string, day: string) {
return today.diff(birthDate, 'year') return today.diff(birthDate, 'year')
} }
const schema = z const schema = z.object({
.object({ nickname: z.string().trim().min(1, "Please Enter nickname").min(2, "Nickname must be between 2 and 20 characters"),
nickname: z gender: z.enum(Gender, { message: "Please select your gender" }),
.string() year: z.string().min(1, "Please select your birthday"),
.trim() month: z.string().min(1, "Please select your birthday"),
.min(1, 'Please Enter nickname') day: z.string().min(1, "Please select your birthday")
.min(2, 'Nickname must be between 2 and 20 characters'), }).refine((data) => {
gender: z.enum(Gender, { message: 'Please select your gender' }), const age = calculateAge(data.year, data.month, data.day)
year: z.string().min(1, 'Please select your birthday'), return age >= 18
month: z.string().min(1, 'Please select your birthday'), }, {
day: z.string().min(1, 'Please select your birthday'), message: "Character age must be at least 18 years old",
}) 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>
@ -74,50 +53,51 @@ 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 { data: user, refetch } = useCurrentUser() const { mutateAsync } = useCompleteUser();
const router = useRouter() const { data: user, refetch } = useCurrentUser();
const searchParams = useSearchParams() const router = useRouter();
const redirect = searchParams.get('redirect') const searchParams = useSearchParams();
const [loading, setLoading] = useState(false) const redirect = searchParams.get("redirect");
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 {
@ -125,10 +105,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(),
@ -136,11 +116,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)
@ -151,19 +131,12 @@ export default function FieldsPage() {
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="relative mx-auto mt-[20vh] h-[88px] w-[221px]"> <div className="w-[221px] h-[88px] relative mx-auto mt-[20vh]">
<Image <Image src="/images/login/logo.svg" alt="Anime character" width={221} height={88} className="object-cover" priority />
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 py-4 text-center">Personal Information</h2> <h2 className="txt-title-l text-center py-4">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
@ -194,18 +167,19 @@ export default function FieldsPage() {
<FormItem> <FormItem>
<FormLabel className="txt-label-m">Gender</FormLabel> <FormLabel className="txt-label-m">Gender</FormLabel>
<FormControl> <FormControl>
<GenderInput value={field.value} onChange={field.onChange} /> <GenderInput
value={field.value}
onChange={field.onChange}
/>
</FormControl> </FormControl>
<div className="txt-body-s text-txt-secondary-normal mt-1"> <div className="txt-body-s text-txt-secondary-normal mt-1">Please note: gender cannot be changed after setting</div>
Please note: gender cannot be changed after setting
</div>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
<div> <div>
<Label className="txt-label-m mb-3 block">Birthday</Label> <Label className="block txt-label-m mb-3">Birthday</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<FormField <FormField
control={form.control} control={form.control}
@ -213,15 +187,9 @@ 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}> <SelectTrigger className="w-full" error={!!form.formState.errors.year}><SelectValue placeholder="Year" /></SelectTrigger>
<SelectValue placeholder="Year" />
</SelectTrigger>
<SelectContent> <SelectContent>
{years.map((y) => ( {years.map(y => <SelectItem key={y} value={y}>{y}</SelectItem>)}
<SelectItem key={y} value={y}>
{y}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</FormItem> </FormItem>
@ -234,15 +202,9 @@ 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}> <SelectTrigger className="w-full" error={!!form.formState.errors.year}><SelectValue placeholder="Month" /></SelectTrigger>
<SelectValue placeholder="Month" />
</SelectTrigger>
<SelectContent> <SelectContent>
{months.map((m, index) => ( {months.map((m, index) => <SelectItem key={m} value={m}>{monthTexts[index]}</SelectItem>)}
<SelectItem key={m} value={m}>
{monthTexts[index]}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</FormItem> </FormItem>
@ -255,18 +217,9 @@ 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 <SelectTrigger className="w-full" error={!!form.formState.errors.year || !!form.formState.errors.day}><SelectValue placeholder="Day" /></SelectTrigger>
className="w-full"
error={!!form.formState.errors.year || !!form.formState.errors.day}
>
<SelectValue placeholder="Day" />
</SelectTrigger>
<SelectContent> <SelectContent>
{days.map((d) => ( {days.map(d => <SelectItem key={d} value={d}>{d}</SelectItem>)}
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent> </SelectContent>
</Select> </Select>
</FormItem> </FormItem>
@ -274,15 +227,15 @@ export default function FieldsPage() {
/> />
</div> </div>
<FormMessage> <FormMessage>
{form.formState.errors.year?.message || {form.formState.errors.year?.message || form.formState.errors.month?.message || form.formState.errors.day?.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" loading={loading}> <Button type="submit"
loading={loading}
>
Submit Submit
</Button> </Button>
</div> </div>

View File

@ -1,7 +1,10 @@
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,35 +1,35 @@
'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="relative hidden lg:block lg:w-1/2"> <div className="hidden lg:block lg:w-1/2 relative">
<LeftPanel scrollBg={scrollBg} images={images} /> <LeftPanel scrollBg={scrollBg} images={images} />
{/* 关闭按钮 - 桌面端 */} {/* 关闭按钮 - 桌面端 */}
@ -43,19 +43,25 @@ export default function LoginPage() {
</div> </div>
{/* 右侧 - 登录表单 */} {/* 右侧 - 登录表单 */}
<div className="relative flex w-full flex-col items-center justify-center px-6 sm:px-12 lg:w-1/2"> <div className="w-full lg:w-1/2 flex flex-col items-center justify-center px-6 sm:px-12 relative">
{/* 关闭按钮 - 移动端 */} {/* 关闭按钮 - 移动端 */}
<IconButton <IconButton
iconfont="icon-close" iconfont="icon-close"
variant="tertiary" variant="tertiary"
size="large" size="large"
className="absolute top-4 right-4 z-20 lg:hidden" className="absolute top-4 right-4 lg:hidden z-20"
onClick={handleClose} onClick={handleClose}
/> />
{/* Logo */} {/* Logo */}
<div className="relative mb-8 h-[48px] w-[120px] sm:mb-12 sm:h-[64px] sm:w-[160px]"> <div className="w-[120px] h-[48px] sm:w-[160px] sm:h-[64px] relative mb-8 sm:mb-12">
<Image src="/logo.svg" alt="Crush Level" fill className="object-contain" priority /> <Image
src="/logo.svg"
alt="Crush Level"
fill
className="object-contain"
priority
/>
</div> </div>
{/* 登录表单 */} {/* 登录表单 */}

View File

@ -1,61 +1,10 @@
import type { Metadata } from 'next' import LoginPage from "./login-page";
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,7 +1,8 @@
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,268 +1,223 @@
export default function PrivacyPolicyPage() { export default function PrivacyPolicyPage() {
return ( return (
<div className="relative flex size-full min-h-screen flex-col items-center justify-start"> <div className="flex flex-col items-center justify-start relative size-full min-h-screen">
<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 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-[752px] min-w-px shrink-0 grow flex-col items-center justify-start gap-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="txt-headline-s w-full text-center text-white"> <div className="txt-headline-s text-center text-white w-full">
<p className="whitespace-pre-wrap">Crushlevel Privacy Policy</p> <p className="whitespace-pre-wrap">
Crushlevel Privacy Policy
</p>
</div> </div>
{/* 前言 */} {/* 前言 */}
<div className="txt-body-l w-full text-white"> <div className="txt-body-l text-white w-full">
<p className="mb-4"> <p className="mb-4">
Welcome to the Crushlevel application ("this App") and related website (Crushlevel.ai, 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.
"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 w-full flex-col items-start justify-start gap-6 text-white"> <div className="flex flex-col gap-6 items-start justify-start text-white w-full">
{/* 1. Scope of Application */} {/* 1. Scope of Application */}
<div className="flex w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 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.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 When registering an account, we collect your mobile number or email address to create your account, facilitate login, and deliver service notifications.
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 <strong>Virtual Character Creation:</strong> Descriptions you provide for AI characters (excluding non-personal information).
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 <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.
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, <strong>Feature Engagement:</strong> Records related to relationship upgrades, unlocked features, and rewards to monitor service usage and ensure performance.
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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), For transactions (pay-per-message, subscriptions, virtual currency purchases), we collect payment methods, amounts, and timestamps to complete transactions and deliver paid services.
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 To ensure service stability and security, we may collect device model, OS version, IP address, browser type, timestamps, and access records.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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">We use your personal information to:</p> <p className="mb-4">
<ul className="mb-4 ml-6 list-disc"> We use your personal information to:
<li className="mb-2"> </p>
Provide services (account management, character creation, chat interactions, <ul className="list-disc ml-6 mb-4">
paid features, etc.). <li className="mb-2">Provide services (account management, character creation, chat interactions, paid features, etc.).</li>
</li> <li className="mb-2">Optimize services by analyzing usage patterns to enhance functionality.</li>
<li className="mb-2"> <li className="mb-2">Conduct promotional activities (with your consent or where permitted by law), without disclosing sensitive data.</li>
Optimize services by analyzing usage patterns to enhance functionality. <li className="mb-2">Troubleshoot issues, maintain service integrity, and protect your rights.</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">
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 a) Your data is stored on secure servers with robust technical/administrative measures to prevent loss, leakage, tampering, or misuse.
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, b) Retention periods align with service needs and legal requirements. Post-expiry, data is deleted or anonymized.
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 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).
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 a) We implement strict security protocols, including encryption and access controls, to prevent unauthorized access or disclosure.
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 b) Only authorized personnel bound by confidentiality obligations may access your data.
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 c) In case of a data breach, we will take remedial actions and notify you/regulators as required by law.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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, 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.
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 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.
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 c) Public disclosure occurs only when legally required or requested by authorities, with efforts to minimize exposure.
authorities, with efforts to minimize exposure.
</p> </p>
</div> </div>
</div> </div>
{/* 7. Your Rights */} {/* 7. Your Rights */}
<div className="flex w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 a) <strong>Access & Correction:</strong> Request to view or correct inaccurate/incomplete data.
inaccurate/incomplete data.
</p> </p>
<p className="mb-4"> <p className="mb-4">
b) <strong>Deletion:</strong> Request deletion where legally permissible or upon b) <strong>Deletion:</strong> Request deletion where legally permissible or upon service termination.
service termination.
</p> </p>
<p className="mb-4"> <p className="mb-4">
c) <strong>Consent Withdrawal:</strong> Withdraw consent for data processing c) <strong>Consent Withdrawal:</strong> Withdraw consent for data processing (note: may affect service functionality).
(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 d) <strong>Account Deactivation:</strong> Deactivate your account; data will be processed per relevant policies.
processed per relevant policies.
</p> </p>
</div> </div>
</div> </div>
{/* 8. Minor Protection */} {/* 8. Minor Protection */}
<div className="flex w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 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.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 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.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 For questions or to exercise your rights, contact us via provided channels. We will respond within a reasonable timeframe.
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!
@ -273,5 +228,5 @@ export default function PrivacyPolicyPage() {
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

@ -1,4 +1,5 @@
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.
@ -90,3 +91,5 @@ Thank you for trusting Crushlevel. We strive to protect your information securit
\ \
\ \

View File

@ -1,19 +1,13 @@
# **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;
@ -22,110 +16,60 @@ 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,625 +1,370 @@
export default function RechargeAgreementPage() { export default function RechargeAgreementPage() {
return ( return (
<div className="relative flex size-full min-h-screen flex-col items-center justify-start"> <div className="flex flex-col items-center justify-start relative size-full min-h-screen">
<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 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-[752px] min-w-px shrink-0 grow flex-col items-center justify-start gap-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="txt-headline-s w-full text-center text-white"> <div className="txt-headline-s text-center text-white w-full">
<p className="whitespace-pre-wrap">Crushlevel Recharge Service Agreement</p> <p className="whitespace-pre-wrap">
Crushlevel Recharge Service Agreement
</p>
</div> </div>
{/* 日期 */} {/* 日期 */}
<div className="txt-body-l w-full text-center text-white"> <div className="txt-body-l text-center text-white w-full">
<p>October 2025</p> <p>October 2025</p>
</div> </div>
{/* 前言 */} {/* 前言 */}
<div className="txt-body-l w-full text-white"> <div className="txt-body-l text-white w-full">
<p className="mb-4">Welcome to use the recharge-related services of "Crushlevel"!</p>
<p className="mb-4"> <p className="mb-4">
This Recharge Service Agreement (hereinafter referred to as "this Agreement") is Welcome to use the recharge-related services of "Crushlevel"!
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">
<strong>Minors are prohibited from using the recharge services.</strong> The Platform 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.
hereby kindly reminds that if you are the guardian of a minor, you shall assume </p>
guardianship responsibilities for the minor under your guardianship. When the minor <p className="mb-4">
uses the relevant products and services of this Platform, you shall enable the youth <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.
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 w-full flex-col items-start justify-start gap-6 text-white"> <div className="flex flex-col gap-6 items-start justify-start text-white w-full">
{/* I. Service Content */} {/* I. Service Content */}
<div className="flex w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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:
"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="mb-4 ml-6 list-disc"> <ul className="list-disc ml-6 mb-4">
<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"> <li className="mb-2">Purchasing "Affection Points" to increase the interaction level with AI virtual characters;</li>
Purchasing "Affection Points" to increase the interaction level with AI <li className="mb-2">Recharging for Platform membership to enjoy exclusive membership benefits;</li>
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"> <li className="mb-2">Unlocking more different types of virtual lovers (AI virtual characters).</li>
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<div className="txt-title-s w-full"> <div className="txt-title-s w-full">
<p> <p>3.1 Obligation of Authenticity of Information and Cooperation in Investigations</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 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.
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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, 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:
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="mb-4 ml-6 list-disc"> <ul className="list-disc ml-6 mb-4">
<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"> <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>
The third-party payment institution account or bank account bound to your <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>
account is frozen, sealed up or has other abnormalities, or you use an <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>
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.).
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
(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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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).
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
(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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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, 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.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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>
@ -628,5 +373,5 @@ export default function RechargeAgreementPage() {
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

@ -1,417 +1,282 @@
export default function TermsOfServicePage() { export default function TermsOfServicePage() {
return ( return (
<div className="relative flex size-full min-h-screen flex-col items-center justify-start"> <div className="flex flex-col items-center justify-start relative size-full min-h-screen ">
<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 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-[752px] min-w-px shrink-0 grow flex-col items-center justify-start gap-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="txt-headline-s w-full text-center text-white"> <div className="txt-headline-s text-center text-white w-full">
<p className="whitespace-pre-wrap">Crushlevel User Agreement</p> <p className="whitespace-pre-wrap">
Crushlevel User Agreement
</p>
</div> </div>
{/* 前言 */} {/* 前言 */}
<div className="txt-body-l w-full text-white"> <div className="txt-body-l text-white w-full">
<p className="mb-4"> <p className="mb-4">
Welcome to the Crushlevel application (hereinafter referred to as "this App") and 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.
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 w-full flex-col items-start justify-start gap-6 text-white"> <div className="flex flex-col gap-6 items-start justify-start text-white w-full">
{/* Article 1: User Eligibility */} {/* Article 1: User Eligibility */}
<div className="flex w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 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.
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 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.
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 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.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 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.
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 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.
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 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).
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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).
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 Other users can chat with the AI virtual Characters you create. Chat methods include text, images, voice, etc.
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 Chatting with Characters allows users to level up their relationship with the Character, unlocking related features and rewards.
Character, unlocking related features and rewards.
</p> </p>
</div> </div>
</div> </div>
{/* (2) Usage Norms */} {/* (2) Usage Norms */}
<div className="flex w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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 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:
(including but not limited to text, images, voice, etc.) must not contain the
following:
</p> </p>
<ul className="mb-4 ml-6 list-disc"> <ul className="list-disc ml-6 mb-4">
<li className="mb-2"> <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>
Content that violates laws and regulations, such as content endangering <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>
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"> <li className="mb-2">Content that insults, slanders, intimidates, or harasses others;</li>
Content that insults, slanders, intimidates, or harasses others; <li className="mb-2">Other content that violates public order, good morals, or the provisions of this Agreement.</li>
</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 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.
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 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.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 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.
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 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.
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 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.
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, Without Our prior written permission, you may not use, copy, modify, disseminate, or display any intellectual property content of this App and this Website.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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 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.
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 Creators must pay corresponding fees to create Characters or generate derivative AI images. Fees can be paid via membership subscription or virtual currency.
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 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.
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, 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.
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 w-full flex-col gap-4"> <div className="flex flex-col gap-4 w-full">
<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 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.
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 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.
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, 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.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 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.
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 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.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 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.
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 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.
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 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.
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. 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.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 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.
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 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.
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 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."
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 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.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 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.
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 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.
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 w-full flex-col items-start justify-start gap-6"> <div className="flex flex-col gap-6 items-start justify-start w-full">
<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 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.
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 If any term of this Agreement is found to be invalid or unenforceable, it shall not affect the validity of the remaining terms.
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 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.
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. 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!
Thank you for your support and trust in Crushlevel. We hope you enjoy using it!
</p> </p>
</div> </div>
</div> </div>
@ -419,5 +284,5 @@ export default function TermsOfServicePage() {
</div> </div>
</div> </div>
</div> </div>
) );
} }

View File

@ -123,3 +123,4 @@ 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,20 +1,15 @@
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="flex h-full min-h-screen w-full items-center justify-center"> <div className="w-full h-full flex justify-center items-center min-h-screen">
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<Link href="/"> <Link href="/">
<div className="relative mx-auto h-[88px] w-[221px]"> <div className="w-[221px] h-[88px] relative mx-auto">
<Image <Image src="/icons/login-logo.svg" alt="Anime character" fill className="object-contain" priority />
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…" />

View File

@ -1,39 +1,41 @@
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 state={dehydrate(queryClient)}> <HydrationBoundary
state={dehydrate(queryClient)}
>
<SharePage /> <SharePage />
</HydrationBoundary> </HydrationBoundary>
) );
} }
export default Page export default Page;

View File

@ -1,68 +1,72 @@
'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 { import { calculateAudioDuration, formatAudioDuration, parseTextWithBrackets } from "@/utils/textParser";
calculateAudioDuration, import { useGetIMUserInfo, useGetShareUserInfo } from "@/hooks/useIm";
formatAudioDuration, import React from "react";
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="relative mx-auto h-screen w-full max-w-[750px]"> <div className="h-screen relative max-w-[750px] mx-auto w-full">
<div className="absolute inset-0"> <div className="absolute inset-0">
<Image src={backgroundImg || ''} alt="Background" fill className="object-cover" priority /> <Image
src={backgroundImg || ''}
alt="Background"
fill
className="object-cover"
priority
/>
<div <div
className="absolute inset-0" className="absolute inset-0"
style={{ style={{
background: background: "linear-gradient(180deg, #211A2B 0%, rgba(33, 26, 43, 0.00) 20%, rgba(33, 26, 43, 0.00) 50%, #211A2B 100%)",
'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="shrink-0 px-6 py-4"> <div className="py-4 px-6 shrink-0">
<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>{imUserInfo?.nickname?.charAt(0) || ''}</AvatarFallback> <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>
@ -71,22 +75,17 @@ const SharePage = () => {
</div> </div>
<div /> <div />
</div> </div>
<div className="flex min-h-0 flex-1 flex-col justify-end overflow-auto"> <div className="flex-1 overflow-auto min-h-0 flex flex-col justify-end">
<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 border-outline-normal rounded-lg border border-solid p-4 backdrop-blur-[32px]"> <div className="bg-surface-element-normal rounded-lg backdrop-blur-[32px] p-4 border border-solid border-outline-normal">
<div className="txt-body-m line-clamp-3">{imUserInfo?.introduction}</div> <div className="txt-body-m line-clamp-3">
<div {imUserInfo?.introduction}
className="mt-2 flex items-center justify-between gap-4" </div>
onClick={handleOpenApp} <div className="flex items-center justify-between gap-4 mt-2" onClick={handleOpenApp}>
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Tag variant="purple" size="small"> <Tag variant="purple" size="small">{imUserInfo?.characterName}</Tag>
{imUserInfo?.characterName} <Tag variant="magenta" size="small">{imUserInfo?.tagName}</Tag>
</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>
@ -94,56 +93,46 @@ const SharePage = () => {
</div> </div>
</div> </div>
<div className="flex items-center justify-center px-6 py-2"> <div className="px-6 py-2 flex items-center justify-center">
<div className="txt-label-s bg-surface-element-normal rounded-xs px-2 py-1 text-center"> <div className="txt-label-s text-center bg-surface-element-normal rounded-xs px-2 py-1">Content generated by AI</div>
Content generated by AI
</div>
</div> </div>
<div className="px-6 pt-4 pb-2"> <div className="pt-4 px-6 pb-2">
<div className="bg-surface-element-dark-normal txt-body-m relative w-[80%] rounded-lg px-4 pt-5 pb-4 backdrop-blur-[32px]"> <div className="w-[80%] bg-surface-element-dark-normal rounded-lg backdrop-blur-[32px] pt-5 px-4 pb-4 txt-body-m relative">
{textParts.map((part, index) => ( {textParts.map((part, index) => (
<span key={index} className={part.isInBrackets ? 'text-txt-secondary-normal' : ''}> <span
key={index}
className={part.isInBrackets ? 'text-txt-secondary-normal' : ''}
>
{part.text} {part.text}
</span> </span>
))} ))}
<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"> <div
<div className="flex h-3 w-3 items-center"> 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"
<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="shrink-0 px-4 py-2 pb-10"> <div className="py-2 px-4 shrink-0 pb-10">
<div <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))" }}>
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 <Image src="/icons/square-logo.svg" className="rounded-[12px] overflow-hidden" alt="chat" width={48} height={48} />
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}> <Button variant="primary" size="small" className="min-w-auto" onClick={handleOpenApp}>Chat</Button>
Chat
</Button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
) );
} }
export default SharePage export default SharePage;

View File

@ -1,109 +1,114 @@
'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="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)]" 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"
> >
{showIcon && {showIcon && (
(icon || ( icon || (
<div className="relative h-[15.999px] w-4 shrink-0"> <div className="h-[15.999px] relative shrink-0 w-4">
<div <div className="absolute aspect-[9.25554/12.1612] left-1/4 right-[17.15%] translate-y-[-50%]" style={{ top: "calc(50% + 0.065px)" }}>
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="relative flex shrink-0 flex-col justify-center text-center text-[12px] leading-[0] font-medium text-nowrap text-white not-italic"> <div className="flex flex-col font-medium justify-center leading-[0] not-italic relative shrink-0 text-[12px] text-center text-nowrap text-white">
<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="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)]" 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"
> >
{showIcon && icon} {showIcon && icon}
{showTxt && <span className="text-[12px] font-medium text-white">{btnTxt}</span>} {showTxt && (
<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 mx-auto h-screen max-w-[750px] overflow-hidden"> <div className="relative h-screen overflow-hidden max-w-[750px] mx-auto">
{/* 背景图片 */} {/* 背景图片 */}
<div <div
className="absolute inset-0 overflow-clip" className="absolute inset-0 overflow-clip"
style={{ style={{
background: background: "linear-gradient(180deg, #211A2B 0%, rgba(33, 26, 43, 0.00) 20%, rgba(33, 26, 43, 0.00) 50%, #211A2B 100%)"
'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 src={homeImageUrl || ''} alt="Background" fill className="object-cover" priority /> <Image
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 flex min-h-screen flex-col content-stretch items-start justify-start px-0 pt-4 pb-0"> <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 box-border flex w-full shrink-0 content-stretch items-start justify-start overflow-clip px-6 py-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 flex min-h-px min-w-px shrink-0 grow basis-0 content-stretch items-center justify-start gap-2 self-stretch"> <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 size-10 shrink-0 overflow-clip rounded-[99px]"> <div className="overflow-clip relative rounded-[99px] shrink-0 size-10">
{userInfo?.headImg ? ( {userInfo?.headImg ? (
<Image <Image
src={userInfo.headImg} src={userInfo.headImg}
@ -112,8 +117,8 @@ const SharePage = () => {
className="object-cover" className="object-cover"
/> />
) : ( ) : (
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-purple-400 to-pink-400"> <div className="w-full h-full bg-gradient-to-br from-purple-400 to-pink-400 flex items-center justify-center">
<span className="text-lg font-bold text-white"> <span className="text-white text-lg font-bold">
{userInfo?.nickname?.charAt(0) || 'U'} {userInfo?.nickname?.charAt(0) || 'U'}
</span> </span>
</div> </div>
@ -121,64 +126,54 @@ const SharePage = () => {
</div> </div>
{/* 用户名和点赞数 */} {/* 用户名和点赞数 */}
<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="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 shrink-0 text-[20px] font-semibold"> <div className="font-semibold relative shrink-0 text-[20px]">
<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="relative shrink-0 text-[12px] font-medium"> <div className="font-medium relative shrink-0 text-[12px]">
<p className="leading-[20px] text-nowrap whitespace-pre">0 likes</p> <p className="leading-[20px] text-nowrap whitespace-pre">
0 likes
</p>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{/* 主要内容区域 */} {/* 主要内容区域 */}
<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="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 inline-grid shrink-0 grid-cols-[max-content] grid-rows-[max-content] place-items-start px-6 leading-[0]"> <div className="grid-cols-[max-content] grid-rows-[max-content] inline-grid leading-[0] place-items-start relative shrink-0 px-6">
{/* AI信息卡片 */} {/* AI信息卡片 */}
<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="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-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="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="font-regular relative line-clamp-3 w-full shrink-0 overflow-hidden text-[14px] leading-[20px] text-white"> <div className="font-regular leading-[20px] overflow-hidden relative shrink-0 text-[14px] text-white w-full line-clamp-3">
<p> <p>
<span className="font-semibold">Intro: </span> <span className="font-semibold">Intro: </span>
<span> <span>{userInfo?.introduction || 'This is an AI character with unique personality and charm. Start chatting to discover more about them!'}</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="relative flex w-full shrink-0 content-stretch items-start justify-start gap-2"> <div className="content-stretch flex gap-2 items-start justify-start relative shrink-0 w-full">
{userInfo?.tagName ? ( {userInfo?.tagName ? (
userInfo.tagName userInfo.tagName.split(',').slice(0, 2).map((tag: string, index: number) => (
.split(',') <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">
.slice(0, 2) <div className="font-medium leading-[20px] text-[12px] text-white">
.map((tag: string, index: number) => ( {tag.trim()}
<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 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">
Sensual
</div> </div>
</div> </div>
<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="text-[12px] leading-[20px] font-medium text-white"> ) : (
Romantic <>
</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="font-medium leading-[20px] text-[12px] text-white">Sensual</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="font-medium leading-[20px] text-[12px] text-white">Romantic</div>
</div> </div>
</> </>
)} )}
@ -187,34 +182,32 @@ const SharePage = () => {
</div> </div>
{/* AI生成内容提示 */} {/* AI生成内容提示 */}
<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="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 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="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="font-regular text-center text-[12px] leading-[20px] text-white"> <div className="font-regular leading-[20px] text-[12px] text-center text-white">
Content generated by AI Content generated by AI
</div> </div>
</div> </div>
</div> </div>
{/* 示例对话消息 */} {/* 示例对话消息 */}
<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="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 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="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="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="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="relative size-3 shrink-0"> <div className="relative shrink-0 size-3">
<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="text-[12px] leading-[20px] font-medium text-nowrap text-white"> <div className="font-medium leading-[20px] text-[12px] text-nowrap text-white">
2'' 2''
</div> </div>
</div> </div>
{/* 消息内容 */} {/* 消息内容 */}
<div className="font-regular min-h-px min-w-px grow basis-0 text-[14px] leading-[20px] text-white"> <div className="basis-0 font-regular grow leading-[20px] min-h-px min-w-px text-[14px] text-white">
<span className="text-[#958e9e]"> <span className="text-[#958e9e]">(Watching her parents toast you respectfully, I feel very sad.) </span>
(Watching her parents toast you respectfully, I feel very sad.){' '}
</span>
<span>Are you?</span> <span>Are you?</span>
</div> </div>
</div> </div>
@ -223,10 +216,10 @@ const SharePage = () => {
</div> </div>
{/* 底部品牌区域 */} {/* 底部品牌区域 */}
<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="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 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"> <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">
{/* App图标 */} {/* App图标 */}
<div className="relative size-[24px] shrink-0 overflow-clip rounded-[12px]"> <div className="overflow-clip relative rounded-[12px] shrink-0 size-[24px]">
<Image <Image
src="/logo.svg" src="/logo.svg"
alt="Crushlevel Logo" alt="Crushlevel Logo"
@ -237,8 +230,8 @@ const SharePage = () => {
</div> </div>
{/* 品牌信息 */} {/* 品牌信息 */}
<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="basis-0 content-stretch flex flex-col grow items-start justify-start min-h-px min-w-px relative shrink-0">
<div className="relative h-[14.422px] shrink-0 overflow-clip"> <div className="h-[14.422px] overflow-clip relative shrink-0">
<Image <Image
src="/logo.svg" src="/logo.svg"
alt="Crushlevel" alt="Crushlevel"
@ -247,7 +240,7 @@ const SharePage = () => {
className="object-contain" className="object-contain"
/> />
</div> </div>
<div className="text-center text-[12px] leading-[20px] font-medium text-nowrap text-white"> <div className="font-medium leading-[20px] text-[12px] text-center text-nowrap text-white">
Chat. Crush. AI Date Chat. Crush. AI Date
</div> </div>
</div> </div>
@ -265,7 +258,7 @@ const SharePage = () => {
</div> </div>
</div> </div>
</div> </div>
) );
} }
export default SharePage export default SharePage;

View File

@ -1,64 +1,58 @@
'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 = const isShowRedDot = hasRedDot(RED_DOT_KEYS.CHAT_BACKGROUND) || hasRedDot(RED_DOT_KEYS.CHAT_BUBBLE);
hasRedDot(RED_DOT_KEYS.CHAT_BACKGROUND) || hasRedDot(RED_DOT_KEYS.CHAT_BUBBLE)
return ( return (
<ChatConfigProvider> <ChatConfigProvider>
<div className="flex overflow-hidden"> <div className="overflow-hidden flex">
<div className="bg-background-default absolute inset-0" /> <div className="bg-background-default absolute inset-0" />
<div className="border-outline-normal relative flex-1 border-t border-solid transition-all"> <div className="flex-1 relative transition-all border-t border-solid border-outline-normal">
<ChatBackground /> <ChatBackground />
{/* 消息列表区域 */} {/* 消息列表区域 */}
<div className="relative flex h-[calc(100vh-64px)] flex-col px-6"> <div className="relative h-[calc(100vh-64px)] flex flex-col px-6">
<ChatMessageList /> <ChatMessageList />
<ChatMessageAction /> <ChatMessageAction />
<div className="absolute top-6 right-6 h-8 w-8"> <div className="absolute right-6 top-6 w-8 h-8">
<IconButton <IconButton iconfont="icon-icon_chatroom_more" variant="ghost" size="small" onClick={handleOpenChatProfileDrawer} />
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="border-outline-normal relative border-t border-solid transition-all"> <div className="relative transition-all border-t border-solid border-outline-normal">
<DrawerLayerProvider> <DrawerLayerProvider>
<ChatDrawers /> <ChatDrawers />
</DrawerLayerProvider> </DrawerLayerProvider>
@ -70,7 +64,7 @@ const ChatPage = () => {
<CoinInsufficientDialog /> <CoinInsufficientDialog />
</ChatConfigProvider> </ChatConfigProvider>
) );
} }
export default ChatPage export default ChatPage;

View File

@ -1,52 +1,31 @@
'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 top-0 right-0 bottom-0 left-0 overflow-hidden"> <div className="bg-background-default absolute left-0 right-0 top-0 bottom-0 overflow-hidden">
<div className="absolute top-0 bottom-0 left-1/2 w-[752px] -translate-x-1/2"> <div className="absolute w-[752px] left-1/2 -translate-x-1/2 top-0 bottom-0">
{backgroundImg && ( {backgroundImg && <Image src={backgroundImg} alt="Background" className="object-cover h-full w-full pointer-events-none" width={720} height={1280} style={{ objectPosition: "center -48px" }} />}
<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 <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%)"}} />
className="absolute top-0 bottom-0 left-0 w-[240px]" <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%)"}} />
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: 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%)"
'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,76 +1,81 @@
'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}> <Button variant="tertiary" size="large" onClick={onInterrupt}>Click to interrupt</Button>
Click to interrupt
</Button>
) )
} }
if (subtitleState.isAiThinking) { if (subtitleState.isAiThinking) {
return ( return (
<div className="flex flex-col items-center justify-center gap-6"> <div className="flex flex-col gap-6 items-center justify-center">
{/* 三个圆点动画 */} {/* 三个圆点动画 */}
<div className="flex items-center justify-center gap-2"> <div className="flex gap-2 items-center justify-center">
<div className="animate-calling-dots-1 h-3 w-3 rounded-full bg-white" /> <div className="w-3 h-3 bg-white rounded-full animate-calling-dots-1" />
<div className="animate-calling-dots-2 h-4 w-4 rounded-full bg-white" /> <div className="w-4 h-4 bg-white rounded-full animate-calling-dots-2" />
<div className="animate-calling-dots-3 h-3 w-3 rounded-full bg-white" /> <div className="w-3 h-3 bg-white rounded-full animate-calling-dots-3" />
</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="txt-label-m text-txt-secondary-normal text-center">Listening...</div> <div className="text-center txt-label-m text-txt-secondary-normal">
Listening...
</div>
</div> </div>
) );
} }
if (!hasReceivedAiGreeting) { if (!hasReceivedAiGreeting) {
return ( return (
<div className="flex flex-col items-center justify-center gap-6"> <div className="flex flex-col gap-6 items-center justify-center">
{/* 三个圆点动画 */} {/* 三个圆点动画 */}
<div className="flex items-center justify-center gap-2"> <div className="flex gap-2 items-center justify-center">
<div className="animate-calling-dots-1 h-3 w-3 rounded-full bg-white" /> <div className="w-3 h-3 bg-white rounded-full animate-calling-dots-1" />
<div className="animate-calling-dots-2 h-4 w-4 rounded-full bg-white" /> <div className="w-4 h-4 bg-white rounded-full animate-calling-dots-2" />
<div className="animate-calling-dots-3 h-3 w-3 rounded-full bg-white" /> <div className="w-3 h-3 bg-white rounded-full animate-calling-dots-3" />
</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="relative my-4 flex size-full min-h-[220px] flex-col content-stretch items-center justify-between gap-4"> <div className="content-stretch flex flex-col gap-4 items-center justify-between relative size-full min-h-[220px] my-4">
<div className="txt-body-l max-w-[60vw] text-center">{subtitleState.aiSubtitle}</div> <div className="txt-body-l text-center max-w-[60vw]">{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">
@ -134,4 +139,4 @@ const ChatCallStatus = ({
// ) // )
} }
export default ChatCallStatus export default ChatCallStatus;

View File

@ -1,21 +1,16 @@
'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 { import { hasReceivedAiGreetingAtom, hasStartAICallAtom, isCallAtom, selectedConversationIdAtom } from "@/atoms/im";
hasReceivedAiGreetingAtom, import { useQueryClient } from "@tanstack/react-query";
hasStartAICallAtom, import { walletKeys } from "@/lib/query-keys";
isCallAtom,
selectedConversationIdAtom,
} from '@/atoms/im'
import { useQueryClient } from '@tanstack/react-query'
import { walletKeys } from '@/lib/query-keys'
const ChatEndButton = ({ const ChatEndButton = ({
roomId, roomId,
@ -24,27 +19,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) {
@ -55,8 +50,8 @@ const ChatEndButton = ({
aiId: aiId, aiId: aiId,
taskId: taskId, taskId: taskId,
duration, duration,
}, }
}) });
} else { } else {
await doRtcOperation({ await doRtcOperation({
data: { data: {
@ -65,54 +60,49 @@ 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 <Button size="large" className="min-w-[80px]" variant="destructive" loading={loading} onClick={handleEndCall}>
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,14 +149,16 @@ export default class RtcClient {
// } // }
async leave() { async leave() {
await Promise.all([this.engine?.stopAudioCapture()]) await Promise.all([
await this.engine?.unpublishStream(MediaType.AUDIO).catch(console.warn) this.engine?.stopAudioCapture(),
this.engine.leaveRoom() ]);
this.engine.destroy() await this.engine?.unpublishStream(MediaType.AUDIO).catch(console.warn);
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,43 +1,24 @@
'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,
@ -48,111 +29,77 @@ 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({ const { mutateAsync: deleteBackground, isPending: isDeletingBackground } = useDelChatBackground({ aiId, backgroundId })
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="bg-outline-normal h-6 w-px" /> <div className="w-px h-6 bg-outline-normal" />
<div <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()}>
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="bg-outline-normal h-6 w-px" />} {!!backgroundId && <div className="w-px h-6 bg-outline-normal" />}
{!!backgroundId && ( {!!backgroundId && <IconButton iconfont="icon-trashcan" variant="tertiary" size="small" loading={isDeletingBackground} onClick={handleDelete} />}
<IconButton
iconfont="icon-trashcan"
variant="tertiary"
size="small"
loading={isDeletingBackground}
onClick={handleDelete}
/>
)}
</> </>
) )
} }
const BackgroundItem = ({ const BackgroundItem = ({ item, selected, inUse, onClick, onImagePreview, totalCount }: { item: BackgroundImgListOutput, selected: boolean, inUse: boolean, onClick: () => void, onImagePreview: () => void, totalCount: number }) => {
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="group relative cursor-pointer" onClick={handleClick}> <div className="relative cursor-pointer group" onClick={handleClick}>
<div <div className={cn(
className={cn( "bg-surface-element-normal relative aspect-[3/4] rounded-lg overflow-hidden",
'bg-surface-element-normal relative aspect-[3/4] overflow-hidden rounded-lg', selected && "border-2 border-solid border-primary-normal"
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 && ( {item.isDefault && <Tag className="absolute top-2 left-2" variant="dark" size="small">Default</Tag>}
<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 right-2 bottom-2 opacity-0 transition-opacity group-hover:opacity-100" className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
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" />
@ -163,14 +110,13 @@ const BackgroundItem = ({
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) => const setIsCrushLevelDrawerOpen = (open: boolean) => setCrushLevelDrawerState(createDrawerOpenState(open));
setCrushLevelDrawerState(createDrawerOpenState(open))
// 图片查看器 // 图片查看器
const { const {
@ -179,56 +125,55 @@ 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 } = const { imgUrl, isDefault } = backgroundList?.find(item => item.backgroundId === selectId) || {};
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)
@ -243,7 +188,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 (
@ -257,50 +202,39 @@ 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 flex items-center justify-between gap-4 rounded-lg p-4"> <div className="bg-surface-element-normal rounded-lg flex justify-between items-center gap-4 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"> <div className="txt-body-s text-txt-secondary-normal mt-1">{isUnlock ? 'Unlocked' : 'Unlocks at Lv.10'}</div>
{isUnlock ? 'Unlocked' : 'Unlocks at Lv.10'}
</div>
</div> </div>
<Button size="small" onClick={handleUnlock}> <Button size="small" onClick={handleUnlock}>{isUnlock ? 'Generate' : 'Unlock'}</Button>
{isUnlock ? 'Generate' : 'Unlock'}
</Button>
</div> </div>
<div className="mt-4 grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4 mt-4">
{backgroundList?.map((item, index) => ( {
<BackgroundItem backgroundList?.map((item, index) => (
key={item.backgroundId} <BackgroundItem
selected={selectId === item.backgroundId} key={item.backgroundId}
inUse={item.imgUrl === backgroundImg} selected={selectId === item.backgroundId}
item={item} inUse={item.imgUrl === backgroundImg}
onClick={() => setSelectId(item.backgroundId)} item={item}
onImagePreview={() => handleImagePreview(index)} onClick={() => setSelectId(item.backgroundId)}
totalCount={backgroundList?.length || 0} onImagePreview={() => handleImagePreview(index)}
/> totalCount={backgroundList?.length || 0}
))} />
))
}
</div> </div>
</div> </div>
</InlineDrawerDescription> </InlineDrawerDescription>
<InlineDrawerFooter> <InlineDrawerFooter>
<Button variant="tertiary" size="large" onClick={() => setOpen(false)}> <Button variant="tertiary" size="large" onClick={() => setOpen(false)}>Cancel</Button>
Cancel <Button size="large" variant="primary" loading={isUpdatingChatBackground} onClick={handleConfirm}>Confirm</Button>
</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}
@ -318,18 +252,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,64 +1,39 @@
'use client' "use client"
import { import { isChatButtleDrawerOpenAtom, isCrushLevelDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat";
isChatButtleDrawerOpenAtom, import { InlineDrawer, InlineDrawerContent, InlineDrawerDescription, InlineDrawerFooter, InlineDrawerHeader } from "./InlineDrawer";
isCrushLevelDrawerOpenAtom, import { useAtom, useSetAtom } from "jotai";
createDrawerOpenState, import { Button } from "@/components/ui/button";
} from '@/atoms/chat' import { useGetChatBubbleDictList, useGetMyChatSetting, useSetChatBubble } from "@/hooks/useIm";
import { import { useChatConfig } from "../../context/chatConfig";
InlineDrawer, import { ChatBubbleListOutput, UnlockType } from "@/services/im/types";
InlineDrawerContent, import Image from "next/image";
InlineDrawerDescription, import { Tag } from "@/components/ui/tag";
InlineDrawerFooter, import { useEffect, useState } from "react";
InlineDrawerHeader, import { useHeartLevelTextFromLevel } from "@/hooks/useHeartLevel";
} from './InlineDrawer' import { cn } from "@/lib/utils";
import { useAtom, useSetAtom } from 'jotai' import ChatBubble from "../ChatMessageItems/ChatBubble";
import { Button } from '@/components/ui/button' import { Checkbox } from "@/components/ui/checkbox";
import { useGetChatBubbleDictList, useGetMyChatSetting, useSetChatBubble } from '@/hooks/useIm' import { useRouter } from "next/navigation";
import { useChatConfig } from '../../context/chatConfig' import { isVipDrawerOpenAtom } from "@/atoms/im";
import { ChatBubbleListOutput, UnlockType } from '@/services/im/types' import { useCurrentUser } from "@/hooks/auth";
import Image from 'next/image' import { VipType } from "@/services/wallet";
import { Tag } from '@/components/ui/tag' import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from 'react' import { imKeys } from "@/lib/query-keys";
import { useHeartLevelTextFromLevel } from '@/hooks/useHeartLevel'
import { cn } from '@/lib/utils' const ChatButtleItem = ({ item, selected, inUse, onClick }: { item: ChatBubbleListOutput, selected: boolean, inUse: boolean, onClick: () => void }) => {
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 <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")}>
className={cn( <ChatBubble
'bg-surface-element-normal relative flex aspect-[41/30] items-center justify-center rounded-lg p-[2px]', isDefault={item.isDefault}
selected && 'border-primary-normal border-2 border-solid p-0' img={item.webImgUrl}
)} >
>
<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 && ( {item.isDefault && <Tag className="absolute top-2 left-2" variant="dark" size="small">Default</Tag>}
<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" />
@ -66,11 +41,15 @@ const ChatButtleItem = ({
)} )}
</div> </div>
{item.unlockType === 'MEMBER' ? ( {item.unlockType === 'MEMBER' ? (
<div className="txt-label-m mt-2 bg-gradient-to-r from-[#ff9696] via-[#aa90f9] to-[#8df3e2] bg-clip-text text-center text-transparent"> <div
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">{item.name}</div> <div className="txt-label-m text-txt-primary-normal mt-2 text-center">
{item.name}
</div>
)} )}
</div> </div>
) )
@ -78,32 +57,31 @@ const ChatButtleItem = ({
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) => const setIsCrushLevelDrawerOpen = (open: boolean) => setCrushLevelDrawerState(createDrawerOpenState(open));
setCrushLevelDrawerState(createDrawerOpenState(open)) const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom);
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom) const queryClient = useQueryClient();
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])
@ -114,18 +92,11 @@ const ChatButtleDrawer = () => {
} }
const renderConfirmButton = () => { const renderConfirmButton = () => {
if (!selectedCode) return if (!selectedCode) return;
const { isUnlock, unlockType, unlockHeartbeatLevel } = const { isUnlock, unlockType, unlockHeartbeatLevel } = chatBubbleDictList?.find(item => item.code === selectedCode) || {};
chatBubbleDictList?.find((item) => item.code === selectedCode) || {}
if (isUnlock || isUnlock === null) { if (isUnlock || isUnlock === null) {
return ( return (
<Button <Button size="large" loading={isSettingChatBubble} onClick={() => handleSetChatBubble(selectedCode)}>Confirm</Button>
size="large"
loading={isSettingChatBubble}
onClick={() => handleSetChatBubble(selectedCode)}
>
Confirm
</Button>
) )
} }
@ -146,24 +117,18 @@ const ChatButtleDrawer = () => {
if (unlockType === UnlockType.HeartbeatLevel) { if (unlockType === UnlockType.HeartbeatLevel) {
return ( return (
<Button size="large" onClick={() => setIsCrushLevelDrawerOpen(true)}> <Button size="large"
onClick={() => setIsCrushLevelDrawerOpen(true)}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Image <Image src="/icons/like-gradient.svg" alt="vip" className="block" width={24} height={24} />
src="/icons/like-gradient.svg" <span className="txt-label-l">{useHeartLevelTextFromLevel(unlockHeartbeatLevel)} Unlock</span>
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 (
@ -177,28 +142,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) => ( {
<ChatButtleItem chatBubbleDictList?.map(item => (
key={item.code} <ChatButtleItem
item={item} key={item.code}
inUse={item.code === chatBubble?.code} item={item}
selected={item.code === selectedCode} inUse={item.code === chatBubble?.code}
onClick={() => { selected={item.code === selectedCode}
setSelectedCode(item.code) onClick={() => {
}} setSelectedCode(item.code)
/> }}
))} />
))
}
</div> </div>
</InlineDrawerDescription> </InlineDrawerDescription>
<InlineDrawerFooter> <InlineDrawerFooter>
<Button variant="tertiary" size="large" onClick={() => setOpen(false)}> <Button variant="tertiary" size="large" onClick={() => setOpen(false)}>Cancel</Button>
Cancel
</Button>
{renderConfirmButton()} {renderConfirmButton()}
</InlineDrawerFooter> </InlineDrawerFooter>
</InlineDrawerContent> </InlineDrawerContent>
</InlineDrawer> </InlineDrawer>
) );
} };
export default ChatButtleDrawer export default ChatButtleDrawer;

View File

@ -1,28 +1,24 @@
'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)
@ -36,40 +32,32 @@ const ChatModelDrawer = () => {
<InlineDrawerContent> <InlineDrawerContent>
<InlineDrawerHeader>Chat Model</InlineDrawerHeader> <InlineDrawerHeader>Chat Model</InlineDrawerHeader>
<InlineDrawerDescription> <InlineDrawerDescription>
<div className="bg-surface-element-normal overflow-hidden rounded-lg p-4"> <div className="p-4 bg-surface-element-normal rounded-lg overflow-hidden">
<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 iconfont="icon-question" variant="tertiary" size="mini" /> <IconButton
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"> <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>
Text Message Price: Refers to the cost of chatting with the character via <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 messages, including sending text, images, or gifts. Charged per <p className="break-words">Voice Call Price: Refers to the cost of having a voice call with the character. Charged per minute.</p>
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"> <div className="txt-body-m text-txt-secondary-normal mt-1">Role-play a conversation with AI</div>
Role-play a conversation with AI
</div>
<div className="bg-surface-district-normal mt-3 rounded-sm p-3"> <div className="mt-3 bg-surface-district-normal 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">
@ -77,8 +65,8 @@ const ChatModelDrawer = () => {
</span> </span>
</div> </div>
<div className="mt-3 flex items-center justify-between gap-1"> <div className="flex items-center justify-between gap-1 mt-3">
<div className="flex min-w-0 flex-1 items-center gap-1"> <div className="flex items-center gap-1 flex-1 min-w-0">
<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
@ -86,8 +74,8 @@ const ChatModelDrawer = () => {
</div> </div>
</div> </div>
<div className="mt-3 flex items-center justify-between gap-1"> <div className="flex items-center justify-between gap-1 mt-3">
<div className="flex min-w-0 flex-1 items-center gap-1"> <div className="flex items-center gap-1 flex-1 min-w-0">
<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
@ -99,16 +87,12 @@ 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)}> <Button variant="tertiary" size="large" onClick={() => setOpen(false)}>Cancel</Button>
Cancel <Button size="large" onClick={() => setOpen(false)}>Save</Button>
</Button>
<Button size="large" onClick={() => setOpen(false)}>
Save
</Button>
</InlineDrawerFooter> </InlineDrawerFooter>
</InlineDrawerContent> </InlineDrawerContent>
</InlineDrawer> </InlineDrawer>
) );
} }
export default ChatModelDrawer export default ChatModelDrawer;

View File

@ -1,38 +1,27 @@
'use client' "use client"
import { import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
DropdownMenu, import { IconButton } from "@/components/ui/button";
DropdownMenuTrigger, import { Separator } from "@/components/ui/separator";
DropdownMenuContent, import DeleteMessageDialog from "./DeleteMessageDialog";
DropdownMenuItem, import { useState } from "react";
} from '@/components/ui/dropdown-menu' import useShare from "@/hooks/useShare";
import { IconButton } from '@/components/ui/button' import { useChatConfig } from "../../../context/chatConfig";
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({ shareFacebook({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}` });
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}`,
})
} }
const handleShareTwitter = () => { const handleShareTwitter = () => {
shareTwitter({ shareTwitter({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}` });
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}`,
})
} }
return ( return (
@ -50,7 +39,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="my-3 px-2"> <div className="px-2 my-3">
<Separator className="bg-outline-normal" /> <Separator className="bg-outline-normal" />
</div> </div>
<DropdownMenuItem onClick={handleDeleteMessage}> <DropdownMenuItem onClick={handleDeleteMessage}>
@ -60,12 +49,9 @@ const ChatProfileAction = () => {
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<DeleteMessageDialog <DeleteMessageDialog open={deleteMessageDialogOpen} onOpenChange={setDeleteMessageDialogOpen} />
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 items-center justify-center gap-4"> <div className="flex justify-center items-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 variant="tertiary" size="large" onClick={handleLike}> <IconButton
{!liked ? ( variant="tertiary"
<i className="iconfont icon-Like" /> size="large"
) : ( 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) => const setIsChatProfileEditDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open));
setDrawerState(createDrawerOpenState(open)) const { data: chatSettingData } = useGetMyChatSetting({ aiId });
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,48 +23,32 @@ const ChatProfilePersona = () => {
} }
return ( return (
<div className="flex w-full flex-col gap-3"> <div className="flex flex-col gap-3 w-full">
<div className="flex items-center justify-between gap-3"> <div className="flex justify-between items-center gap-3">
<div className="txt-title-s">My Chat Persona</div> <div className="txt-title-s">My Chat Persona</div>
<div <div className="txt-label-m text-primary-variant-normal cursor-pointer" onClick={() => setIsChatProfileEditDrawerOpen(true)}>Edit</div>
className="txt-label-m text-primary-variant-normal cursor-pointer"
onClick={() => setIsChatProfileEditDrawerOpen(true)}
>
Edit
</div>
</div> </div>
<div className="bg-surface-base-normal rounded-m py-1"> <div className="bg-surface-base-normal py-1 rounded-m">
<div className="flex items-center justify-between gap-4 px-4 py-3"> <div className="py-3 px-4 flex justify-between items-center gap-4">
<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 flex-1 truncate text-right"> <div className="txt-body-l text-txt-primary-normal truncate flex-1 text-right">{nickname}</div>
{nickname}
</div>
</div> </div>
<div className="flex items-center justify-between gap-4 px-4 py-3"> <div className="py-3 px-4 flex justify-between items-center gap-4">
<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"> <div className="txt-body-l text-txt-primary-normal">{genderMap[sex as keyof typeof genderMap]}</div>
{genderMap[sex as keyof typeof genderMap]}
</div>
</div> </div>
<div className="flex items-center justify-between gap-4 px-4 py-3"> <div className="py-3 px-4 flex justify-between items-center gap-4">
<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="flex items-center justify-between gap-4 px-4 py-3"> <div className="py-3 px-4 flex justify-between items-center gap-4">
<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 <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>
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,37 +1,29 @@
'use client' "use client";
import { IconButton } from '@/components/ui/button' import { IconButton } from "@/components/ui/button";
import { import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
DropdownMenu, import useShare from "@/hooks/useShare";
DropdownMenuContent, import { useParams } from "next/navigation";
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({ shareFacebook({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${userId}` });
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${userId}`,
})
} }
const handleShareTwitter = () => { const handleShareTwitter = () => {
shareTwitter({ shareTwitter({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${userId}` });
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 variant="tertiary" size="large"> <IconButton
variant="tertiary"
size="large"
>
<i className="iconfont icon-Share-border" /> <i className="iconfont icon-Share-border" />
</IconButton> </IconButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
@ -46,7 +38,7 @@ const ChatProfileShareIcon = () => {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) );
} }
export default ChatProfileShareIcon export default ChatProfileShareIcon;

View File

@ -1,54 +1,45 @@
'use client' "use client"
import { import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
AlertDialog, import { useNimConversation, useNimMsgContext } from "@/context/NimChat/useNimChat";
AlertDialogAction, import { useAtomValue, useSetAtom } from "jotai";
AlertDialogCancel, import { selectedConversationIdAtom } from "@/atoms/im";
AlertDialogContent, import { useState } from "react";
AlertDialogDescription, import { useRouter } from "next/navigation";
AlertDialogFooter, import { useQueryClient } from "@tanstack/react-query";
AlertDialogHeader, import { imKeys } from "@/lib/query-keys";
AlertDialogTitle, import { useChatConfig } from "../../../context/chatConfig";
AlertDialogTrigger, import { useDeleteConversations } from "@/hooks/useIm";
} 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 (
@ -57,19 +48,14 @@ const DeleteMessageDialog = ({
<AlertDialogHeader> <AlertDialogHeader>
<AlertDialogTitle>Delete</AlertDialogTitle> <AlertDialogTitle>Delete</AlertDialogTitle>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogDescription> <AlertDialogDescription>Deletion is permanent. Your accumulated Affection points and the character's memories will not be affected. Please confirm deletion.</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}> <AlertDialogAction variant="destructive" disabled={loading} onClick={handleDeleteMessage}>Delete</AlertDialogAction>
Delete
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>
) );
} }
export default DeleteMessageDialog export default DeleteMessageDialog;

View File

@ -1,84 +1,68 @@
import { InlineDrawer, InlineDrawerContent } from '../InlineDrawer' import { InlineDrawer, InlineDrawerContent } from "../InlineDrawer";
import { useAtom, useSetAtom } from 'jotai' import { useAtom, useSetAtom } from "jotai";
import { import { isChatBackgroundDrawerOpenAtom, isChatProfileDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat";
isChatBackgroundDrawerOpenAtom, import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
isChatProfileDrawerOpenAtom, import { Tag } from "@/components/ui/tag";
createDrawerOpenState, import { Switch } from "@/components/ui/switch";
} from '@/atoms/chat' import { useEffect, useState } from "react";
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar' import ChatProfileAction from "./ChatProfileAction";
import { Tag } from '@/components/ui/tag' import { useChatConfig } from "../../../context/chatConfig";
import { Switch } from '@/components/ui/switch' import Image from "next/image";
import { useEffect, useState } from 'react' import { formatNumberToKMB, getAge } from "@/lib/utils";
import ChatProfileAction from './ChatProfileAction' import { useGetAIUserStat } from "@/hooks/aiUser";
import { useChatConfig } from '../../../context/chatConfig' import { IconButton } from "@/components/ui/button";
import Image from 'next/image' import { useGetMyChatSetting, useSetAutoPlayVoice } from "@/hooks/useIm";
import { formatNumberToKMB, getAge } from '@/lib/utils' import ChatProfilePersona from "./ChatProfilePersona";
import { useGetAIUserStat } from '@/hooks/aiUser' import { isChatButtleDrawerOpenAtom, isChatModelDrawerOpenAtom } from "@/atoms/chat";
import { IconButton } from '@/components/ui/button' import { Badge } from "@/components/ui/badge";
import { useGetMyChatSetting, useSetAutoPlayVoice } from '@/hooks/useIm' import { useRedDot, RED_DOT_KEYS } from "@/hooks/useRedDot";
import ChatProfilePersona from './ChatProfilePersona' import { imKeys } from "@/lib/query-keys";
import { isChatButtleDrawerOpenAtom, isChatModelDrawerOpenAtom } from '@/atoms/chat' import { AiUserImBaseInfoOutput } from "@/services/im";
import { Badge } from '@/components/ui/badge' import { useQueryClient } from "@tanstack/react-query";
import { useRedDot, RED_DOT_KEYS } from '@/hooks/useRedDot' import CrushLevelAvatar from "../../CrushLevelAvatar";
import { imKeys } from '@/lib/query-keys' import ChatProfileLikeAction from "./ChatProfileLikeAction";
import { AiUserImBaseInfoOutput } from '@/services/im' import { isVipDrawerOpenAtom } from "@/atoms/im";
import { useQueryClient } from '@tanstack/react-query' import { useCurrentUser } from "@/hooks/auth";
import CrushLevelAvatar from '../../CrushLevelAvatar' import { VipType } from "@/services/wallet";
import ChatProfileLikeAction from './ChatProfileLikeAction' import Decimal from "decimal.js";
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 setModelDrawerState = useSetAtom(isChatModelDrawerOpenAtom);
const setIsChatModelDrawerOpen = (open: boolean) => const setIsChatModelDrawerOpen = (open: boolean) => setModelDrawerState(createDrawerOpenState(open));
setModelDrawerState(createDrawerOpenState(open))
const setButtleDrawerState = useSetAtom(isChatButtleDrawerOpenAtom) const setButtleDrawerState = useSetAtom(isChatButtleDrawerOpenAtom);
const setIsChatButtleDrawerOpen = (open: boolean) => const setIsChatButtleDrawerOpen = (open: boolean) => setButtleDrawerState(createDrawerOpenState(open));
setButtleDrawerState(createDrawerOpenState(open))
const setBackgroundDrawerState = useSetAtom(isChatBackgroundDrawerOpenAtom) const setBackgroundDrawerState = useSetAtom(isChatBackgroundDrawerOpenAtom);
const setIsChatBackgroundDrawerOpen = (open: boolean) => const setIsChatBackgroundDrawerOpen = (open: boolean) => setBackgroundDrawerState(createDrawerOpenState(open));
setBackgroundDrawerState(createDrawerOpenState(open)) const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom);
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom)
const { aiId, aiInfo } = useChatConfig() const { aiId, aiInfo } = useChatConfig();
const { data: user } = useCurrentUser() || {} const { data: user } = useCurrentUser() || {};
const { isMember } = user || {} 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 { const { sex, birthday, characterName, tagName, chatBubble, isDefaultBackground, isAutoPlayVoice, aiUserHeartbeatRelation } = aiInfo || {};
sex, const { likedNum, chatNum, conversationNum, coinNum } = statData || {};
birthday, const { heartbeatLevel } = aiUserHeartbeatRelation || {};
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) {
@ -103,11 +87,11 @@ 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
@ -118,40 +102,21 @@ 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 h-full w-full"> <div className="relative w-full h-full">
{/* Header with back and more buttons */} {/* Header with back and more buttons */}
<ChatProfileAction /> <ChatProfileAction />
<IconButton <IconButton iconfont="icon-arrow-right" variant="ghost" size="small" className="absolute top-6 left-6" onClick={handleClose} />
iconfont="icon-arrow-right"
variant="ghost"
size="small"
className="absolute top-6 left-6"
onClick={handleClose}
/>
<div className="h-full w-full overflow-y-auto"> <div className="w-full h-full overflow-y-auto" >
<div
className="flex flex-col items-center justify-start gap-6 px-6 pt-12 pb-10" <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" }}>
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 w-full flex-col items-start justify-start gap-4"> <div className="flex flex-col gap-4 items-start justify-start w-full">
<div className="flex w-full flex-wrap items-start justify-center gap-2"> <div className="flex flex-wrap gap-2 items-start justify-center w-full">
<Tag> <Tag>
<Image <Image src={genderMap[sex as keyof typeof genderMap]} alt="Gender" width={16} height={16} />
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>
@ -162,45 +127,50 @@ const ChatProfileDrawer = () => {
<ChatProfileLikeAction /> <ChatProfileLikeAction />
{/* Statistics */} {/* Statistics */}
<div className="flex w-full flex-row items-start justify-start px-1 py-0"> <div className="flex flex-row items-start justify-start px-1 py-0 w-full">
{statList.map((item, index) => ( {
<div statList.map((item, index) => (
key={index} <div key={index} className="flex-1 flex flex-col gap-1 items-center justify-start px-1 py-0">
className="flex flex-1 flex-col items-center justify-start gap-1 px-1 py-0" <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 className="txt-numDisplay-s text-txt-primary-normal">{item.value}</div> </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 w-full flex-col items-start justify-start gap-3"> <div className="flex flex-col gap-3 items-start justify-start w-full">
<div className="txt-title-s w-full text-left">Chat Setting</div> <div className="text-left w-full txt-title-s">
<div className="flex w-full flex-col items-start justify-start gap-4"> Chat Setting
</div>
<div className="flex flex-col gap-4 items-start justify-start w-full">
<div <div
className="bg-surface-base-normal rounded-m flex w-full cursor-pointer items-center justify-between px-4 py-3" className="bg-surface-base-normal rounded-m flex justify-between items-center px-4 py-3 w-full cursor-pointer"
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 className="flex min-w-0 flex-1 cursor-pointer items-center justify-end gap-2"> <div
<div className="txt-body-l text-txt-primary-normal text-right"> className="flex items-center justify-end gap-2 cursor-pointer flex-1 min-w-0 "
Role-Playing >
</div> <div className="txt-body-l text-txt-primary-normal text-right">Role-Playing</div>
<IconButton iconfont="icon-arrow-right-border" size="small" variant="ghost" /> <IconButton
iconfont="icon-arrow-right-border"
size="small"
variant="ghost"
/>
</div> </div>
</div> </div>
<div className="bg-surface-base-normal rounded-m w-full py-1"> <div className="bg-surface-base-normal rounded-m py-1 w-full">
<div <div
className="flex w-full cursor-pointer items-center justify-between px-4 py-3" className="flex justify-between items-center px-4 py-3 w-full cursor-pointer"
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">
@ -209,7 +179,9 @@ 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 className="flex min-w-0 flex-1 cursor-pointer items-center justify-end gap-2"> <div
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"
@ -220,10 +192,10 @@ const ChatProfileDrawer = () => {
</div> </div>
<div <div
className="flex w-full cursor-pointer items-center justify-between px-4 py-3" className="flex justify-between items-center px-4 py-3 w-full cursor-pointer"
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">
@ -232,7 +204,9 @@ 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 className="flex min-w-0 flex-1 cursor-pointer items-center justify-end gap-2"> <div
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"
@ -244,28 +218,28 @@ const ChatProfileDrawer = () => {
</div> </div>
<div <div
className="bg-surface-base-normal rounded-m flex w-full cursor-pointer items-center justify-between px-4 py-3" className="bg-surface-base-normal rounded-m flex justify-between items-center px-4 py-3 w-full cursor-pointer"
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( queryClient.setQueryData(imKeys.imUserInfo(aiId), (oldData: AiUserImBaseInfoOutput) => {
imKeys.imUserInfo(aiId), return {
(oldData: AiUserImBaseInfoOutput) => { ...oldData,
return { isAutoPlayVoice: checked,
...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="flex h-8 items-center"> <div className="h-8 flex items-center">
<Switch size="sm" checked={!!isAutoPlayVoice} /> <Switch
size="sm"
checked={!!isAutoPlayVoice}
/>
</div> </div>
</div> </div>
</div> </div>
@ -275,7 +249,7 @@ const ChatProfileDrawer = () => {
</div> </div>
</InlineDrawerContent> </InlineDrawerContent>
</InlineDrawer> </InlineDrawer>
) );
} }
export default ChatProfileDrawer export default ChatProfileDrawer;

View File

@ -1,41 +1,22 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from "react";
import { import { InlineDrawer, InlineDrawerContent, InlineDrawerDescription, InlineDrawerFooter, InlineDrawerHeader } from "./InlineDrawer";
InlineDrawer, import { z } from "zod";
InlineDrawerContent, import dayjs from "dayjs";
InlineDrawerDescription, import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
InlineDrawerFooter, import { zodResolver } from "@hookform/resolvers/zod"
InlineDrawerHeader, import { useForm } from "react-hook-form"
} from './InlineDrawer' import { Gender } from "@/types/user";
import { z } from 'zod' import { Input } from "@/components/ui/input";
import dayjs from 'dayjs' import GenderInput from "@/components/features/genderInput";
import { import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
Form, import { Label } from "@/components/ui/label";
FormControl, import { isChatProfileEditDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat";
FormField, import { useAtom } from "jotai";
FormItem, import { calculateAge, getDaysInMonth } from "@/lib/utils";
FormLabel, import { Textarea } from "@/components/ui/textarea";
FormMessage, import { Button } from "@/components/ui/button";
} from '@/components/ui/form' import { useGetMyChatSetting, useSetMyChatSetting } from "@/hooks/useIm";
import { zodResolver } from '@hookform/resolvers/zod' import { useChatConfig } from "../../context/chatConfig";
import { useForm } from 'react-hook-form'
import { Gender } from '@/types/user'
import { Input } from '@/components/ui/input'
import GenderInput from '@/components/features/genderInput'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} 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 { import {
AlertDialog, AlertDialog,
AlertDialogAction, AlertDialogAction,
@ -44,110 +25,93 @@ import {
AlertDialogDescription, AlertDialogDescription,
AlertDialogFooter, AlertDialogFooter,
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle
} from '@/components/ui/alert-dialog' } from "@/components/ui/alert-dialog";
import { useCheckNickname } from '@/hooks/auth' 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 const characterFormSchema = z.object({
.object({ 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"),
nickname: z sex: z.enum(Gender, { message: "Please select gender" }),
.string() year: z.string().min(1, "Please select year"),
.trim() month: z.string().min(1, "Please select month"),
.min(1, 'Please Enter nickname') day: z.string().min(1, "Please select day"),
.min(2, 'Nickname must be between 2 and 20 characters') profile: z.string().trim().optional(),
.max(20, 'Nickname must be less than 20 characters'), }).refine((data) => {
sex: z.enum(Gender, { message: 'Please select gender' }), const age = calculateAge(data.year, data.month, data.day)
year: z.string().min(1, 'Please select year'), return age >= 18
month: z.string().min(1, 'Please select month'), }, {
day: z.string().min(1, 'Please select day'), message: "Character age must be at least 18 years old",
profile: z.string().trim().optional(), path: ["year"]
}) }).refine((data) => {
.refine( if (data.profile) {
(data) => { if (data.profile.trim().length > 300) {
const age = calculateAge(data.year, data.month, data.day) return false;
return age >= 18
},
{
message: 'Character age must be at least 18 years old',
path: ['year'],
} }
) return data.profile.trim().length >= 10;
.refine( }
(data) => { return true;
if (data.profile) { }, {
if (data.profile.trim().length > 300) { message: "At least 10 characters",
return false path: ["profile"]
} })
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: month: birthday?.month() !== undefined ? (birthday.month() + 1).toString().padStart(2, '0') : undefined,
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) {
@ -155,10 +119,7 @@ const ChatProfileEditDrawer = () => {
nickname: chatSettingData.nickname, nickname: chatSettingData.nickname,
sex: chatSettingData.sex, sex: chatSettingData.sex,
year: birthday?.year().toString(), year: birthday?.year().toString(),
month: month: birthday?.month() !== undefined ? (birthday.month() + 1).toString().padStart(2, '0') : undefined,
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 || '',
}) })
@ -169,23 +130,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 {
@ -193,10 +154,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,
@ -212,8 +173,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 = [
@ -231,8 +192,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 (
<> <>
@ -241,49 +202,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 rounded-m txt-body-l text-txt-secondary-disabled flex h-12 items-center px-4 py-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">
{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>
{/* <FormField </div>
{/* <FormField
control={form.control} control={form.control}
name="sex" name="sex"
render={({ field }) => ( render={({ field }) => (
@ -299,143 +260,120 @@ const ChatProfileEditDrawer = () => {
</FormItem> </FormItem>
)} )}
/> */} /> */}
<div> <div>
<Label className="txt-label-m mb-3 block">Birthday</Label> <Label className="block txt-label-m mb-3">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}> <SelectItem key={year} value={year}>{year}</SelectItem>
{year} ))}
</SelectItem> </SelectContent>
))} </Select>
</SelectContent> </FormControl>
</Select> </FormItem>
</FormControl> )}
</FormItem> />
)} <FormField
/> control={form.control}
<FormField name="month"
control={form.control} render={({ field }) => (
name="month" <FormItem className="flex-1">
render={({ field }) => ( <FormControl>
<FormItem className="flex-1"> <Select onValueChange={field.onChange} value={field.value}>
<FormControl> <SelectTrigger className="w-full">
<Select onValueChange={field.onChange} value={field.value}> <SelectValue placeholder="Month" />
<SelectTrigger className="w-full"> </SelectTrigger>
<SelectValue placeholder="Month" /> <SelectContent>
</SelectTrigger> {months.map((m, index) => <SelectItem key={m} value={m}>{monthTexts[index]}</SelectItem>)}
<SelectContent> </SelectContent>
{months.map((m, index) => ( </Select>
<SelectItem key={m} value={m}> </FormControl>
{monthTexts[index]} </FormItem>
</SelectItem> )}
))} />
</SelectContent> <FormField
</Select> control={form.control}
</FormControl> name="day"
</FormItem> render={({ field }) => (
)} <FormItem className="flex-1">
/> <FormControl>
<FormField <Select onValueChange={field.onChange} value={field.value}>
control={form.control} <SelectTrigger className="w-full">
name="day" <SelectValue placeholder="Day" />
render={({ field }) => ( </SelectTrigger>
<FormItem className="flex-1"> <SelectContent>
<FormControl> {days.map(d => <SelectItem key={d} value={d}>{d}</SelectItem>)}
<Select onValueChange={field.onChange} value={field.value}> </SelectContent>
<SelectTrigger className="w-full"> </Select>
<SelectValue placeholder="Day" /> </FormControl>
</SelectTrigger> </FormItem>
<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> <FormLabel>My Persona<span className="txt-label-m text-txt-secondary-normal">(Optional)</span></FormLabel>
My Persona <FormControl>
<span className="txt-label-m text-txt-secondary-normal">(Optional)</span> <Textarea
</FormLabel> {...field}
<FormControl> maxLength={300}
<Textarea error={!!form.formState.errors.profile}
{...field} placeholder="Set your own persona in CrushLevel"
maxLength={300} />
error={!!form.formState.errors.profile} </FormControl>
placeholder="Set your own persona in CrushLevel" <FormMessage />
/> </FormItem>
</FormControl> )}
<FormMessage /> />
</FormItem> </form>
)} </Form>
/> </InlineDrawerDescription>
</form> <InlineDrawerFooter>
</Form> <Button variant="tertiary" size="large" onClick={handleCloseDrawer}>Cancel</Button>
</InlineDrawerDescription> <Button type="submit" size="large" onClick={form.handleSubmit(onSubmit)} loading={loading}>Save</Button>
<InlineDrawerFooter> </InlineDrawerFooter>
<Button variant="tertiary" size="large" onClick={handleCloseDrawer}> </InlineDrawerContent>
Cancel </InlineDrawer>
</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 The edited content will not be saved after exiting. Please confirm whether to continue exiting?
exiting? </AlertDialogDescription>
</AlertDialogDescription> </AlertDialogHeader>
</AlertDialogHeader> <AlertDialogFooter>
<AlertDialogFooter> <AlertDialogCancel onClick={handleCancelDiscard}>
<AlertDialogCancel onClick={handleCancelDiscard}>Cancel</AlertDialogCancel> Cancel
<AlertDialogAction variant="destructive" onClick={handleConfirmDiscard}> </AlertDialogCancel>
Exit <AlertDialogAction variant="destructive" onClick={handleConfirmDiscard}>
</AlertDialogAction> Exit
</AlertDialogFooter> </AlertDialogAction>
</AlertDialogContent> </AlertDialogFooter>
</AlertDialog> </AlertDialogContent>
</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 h-[124px] items-center justify-between px-[42]"> <div className="flex items-center justify-between px-[42] h-[124px]">
<Link className="h-20 w-20" href={`/@${aiId}`}> <Link className="w-20 h-20" href={`/@${aiId}`}>
<Avatar className="h-20 w-20"> <Avatar className="w-20 h-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="h-20 w-20" href={`/profile`}> <Link className="w-20 h-20" href={`/profile`}>
<Avatar className="h-20 w-20"> <Avatar className="w-20 h-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,35 +1,35 @@
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) => ( {
<div key={item.code}> datas?.map((item) => (
<div className="bg-surface-element-normal relative aspect-[41/30] overflow-hidden rounded-lg"> <div key={item.code}>
<Image src={item.imgUrl || ''} alt={item.name || ''} fill className="object-cover" /> <div className="rounded-lg overflow-hidden relative aspect-[41/30] bg-surface-element-normal">
<div className="txt-numMonotype-xs text-txt-secondary-normal absolute bottom-2 left-3"> <Image src={item.imgUrl || ""} alt={item.name || ""} fill className="object-cover" />
{`${item.startVal}`} <div className="absolute left-3 bottom-2 txt-numMonotype-xs text-txt-secondary-normal">
</div> {`${item.startVal}`}
{!item.isUnlock && ( </div>
<Tag size="small" variant="dark" className="absolute top-2 right-2"> {!item.isUnlock && <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>
) )
} }

View File

@ -1,96 +1,76 @@
import React from 'react' import React from "react";
import { import { InlineDrawer, InlineDrawerContent, InlineDrawerDescription, InlineDrawerHeader } from "../InlineDrawer";
InlineDrawer, import { IconButton, Button } from "@/components/ui/button";
InlineDrawerContent, import Image from "next/image";
InlineDrawerDescription, import CrushLevelAvatarGroup from "./CrushLevelAvatarGroup";
InlineDrawerHeader, import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
} from '../InlineDrawer' import { Switch } from "@/components/ui/switch";
import { IconButton, Button } from '@/components/ui/button' import { useAtom, useSetAtom } from "jotai";
import Image from 'next/image' import { isCrushLevelDrawerOpenAtom, isCrushLevelRetrieveDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat";
import CrushLevelAvatarGroup from './CrushLevelAvatarGroup' import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { import { useGetHeartbeatLevel, useSetShowRelationship } from "@/hooks/useIm";
DropdownMenu, import { useChatConfig } from "../../../context/chatConfig";
DropdownMenuContent, import HeartList from "./HeartList";
DropdownMenuItem, import { useHeartLevelTextFromLevel } from "@/hooks/useHeartLevel";
DropdownMenuTrigger, import numeral from "numeral";
} from '@/components/ui/dropdown-menu' import { imKeys } from "@/lib/query-keys";
import { Switch } from '@/components/ui/switch' import { useQueryClient } from "@tanstack/react-query";
import { useAtom, useSetAtom } from 'jotai' import { headerLevelDictMap } from "@/components/features/AIRelationTag";
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) => const setIsCrushLevelRetrieveDrawerOpen = (open: boolean) => setRetrieveDrawerState(createDrawerOpenState(open));
setRetrieveDrawerState(createDrawerOpenState(open)) const { aiId, aiInfo } = useChatConfig();
const { aiId, aiInfo } = useChatConfig() const queryClient = useQueryClient();
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, aiUserHeartbeatRelation } = data || {} const { heartbeatLeveLDictList } = data || {};
const { heartbeatLevel, heartbeatVal, heartbeatScore, dayCount, subtractHeartbeatVal, isShow } = const { heartbeatLevel, heartbeatVal, heartbeatScore, dayCount, subtractHeartbeatVal, isShow } = aiUserHeartbeatRelation || {};
aiUserHeartbeatRelation || {} const heartLevelText = useHeartLevelTextFromLevel(heartbeatLevel);
const heartLevelText = useHeartLevelTextFromLevel(heartbeatLevel)
const { mutate: setShowRelationship, isPending: isSetShowRelationshipPending } = const { mutate: setShowRelationship, isPending: isSetShowRelationshipPending } = useSetShowRelationship({ aiId: Number(aiId) });
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( const sortedLevels = [...heartbeatLeveLDictList].sort((a, b) => (a.startVal || 0) - (b.startVal || 0));
(a, b) => (a.startVal || 0) - (b.startVal || 0) const minStartVal = sortedLevels[0]?.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( const totalProgress = Math.max(0, Math.min(1, (heartbeatVal - minStartVal) / (maxStartVal - minStartVal)));
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 = () => {
@ -99,58 +79,58 @@ const CrushLevelDrawer = () => {
// 目前只有一个心的图片可以通过透明度或其他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 w-full items-center gap-2"> <div className="flex items-center gap-2 w-full">
<div className="bg-outline-normal h-px flex-1" /> <div className="flex-1 h-px bg-outline-normal" />
<span className="txt-title-m">No Crush Connection Yet</span> <span className="txt-title-m">No Crush Connection Yet</span>
<div className="bg-outline-normal h-px flex-1" /> <div className="flex-1 h-px bg-outline-normal" />
</div> </div>
) )
} }
if (heartbeatLevel && isShow) { if (heartbeatLevel && isShow) {
return ( return (
<div className="flex w-full items-center gap-2"> <div className="flex items-center gap-2 w-full">
<div className="bg-outline-normal h-px flex-1" /> <div className="flex-1 h-px bg-outline-normal" />
<span className="txt-display-s">· {headerLevelDictMap[heartbeatLevel]?.title} ·</span> <span className="txt-display-s">· {headerLevelDictMap[heartbeatLevel]?.title} ·</span>
<div className="bg-outline-normal h-px flex-1" /> <div className="flex-1 h-px bg-outline-normal" />
</div> </div>
) )
} }
return null return null;
} }
return ( return (
@ -162,19 +142,14 @@ const CrushLevelDrawer = () => {
> >
<InlineDrawerContent className="overflow-y-auto"> <InlineDrawerContent className="overflow-y-auto">
{/* 紫色渐变背景 */} {/* 紫色渐变背景 */}
<div className="absolute top-0 right-0 left-0 h-[480px] overflow-hidden"> <div className="absolute top-0 left-0 right-0 h-[480px] overflow-hidden">
<Image <Image src="/images/crushlevel/bg-bottom.png" alt="CrushLevel" fill className="object-cover" />
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="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" 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"
width={124} width={124}
height={124} height={124}
style={{ style={{
@ -193,27 +168,22 @@ const CrushLevelDrawer = () => {
<div className="relative inset-0"> <div className="relative inset-0">
<InlineDrawerHeader> <InlineDrawerHeader>
<div className="flex items-center justify-between"> <div className="flex justify-between items-center">
<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 iconfont="icon-question" variant="tertiaryDark" size="mini" /> <IconButton
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> <p>* Increase your Crush Value by chatting or sending gifts. If you dont interact for 24 hours, your Crush Value may gradually decrease.</p>
* Increase your Crush Value by chatting or sending gifts. If you dont <p>* Your virtual characters emotional responses during conversations will affect whether the Crush Value goes up or down.</p>
interact for 24 hours, your Crush Value may gradually decrease. <p>* A higher Crush Value boosts your Crush Level, unlocking new titles, features, and relationship stages with your character.</p>
</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>
@ -221,22 +191,26 @@ const CrushLevelDrawer = () => {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<IconButton iconfont="icon-More" variant="ghost" size="small" /> <IconButton
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) => {
@ -244,24 +218,19 @@ 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="flex w-full items-center justify-between"> <div className="w-full flex 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 <Switch size="sm" className="cursor-pointer" checked={!isShow} disabled={isSetShowRelationshipPending} />
size="sm"
className="cursor-pointer"
checked={!isShow}
disabled={isSetShowRelationshipPending}
/>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
@ -272,20 +241,16 @@ 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 ? ( {heartbeatVal ? <div className="flex items-center gap-2 txt-numDisplay-s">
<div className="txt-numDisplay-s flex items-center gap-2"> <span>{heartLevelText || 'Lv.0'}</span>
<span>{heartLevelText || 'Lv.0'}</span> <div className="w-px h-[18px] bg-outline-normal" />
<div className="bg-outline-normal h-[18px] w-px" /> <span>
<span> {heartbeatVal || 0}<span className="txt-numMonotype-s">&#8451;</span>
{heartbeatVal || 0} </span>
<span className="txt-numMonotype-s">&#8451;</span> </div> : (
</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} {heartbeatVal || 0}<span className="txt-numMonotype-s">&#8451;</span>
<span className="txt-numMonotype-s">&#8451;</span>
</span> </span>
</div> </div>
)} )}
@ -294,30 +259,22 @@ const CrushLevelDrawer = () => {
{renderLineText()} {renderLineText()}
{/* 描述文本 */} {/* 描述文本 */}
{!!heartbeatVal && ( {!!heartbeatVal && <p className="txt-body-s">
<p className="txt-body-s"> {`Known for ${Math.max((dayCount || 0), 1)} days | Crush Value higher than ${numeral(heartbeatScore).format("0.00%")} of users`}
{`Known for ${Math.max(dayCount || 0, 1)} days | Crush Value higher than ${numeral(heartbeatScore).format('0.00%')} of users`} </p>}
</p>
)}
</div> </div>
</div> </div>
{!!subtractHeartbeatVal && ( {!!subtractHeartbeatVal && <div className="px-6 my-6">
<div className="my-6 px-6"> <div className="bg-surface-element-normal rounded-m p-4 flex justify-between items-center gap-2">
<div className="bg-surface-element-normal rounded-m flex items-center justify-between gap-2 p-4"> <div className="flex-1 txt-body-s">{`Crush Value lost: -${subtractHeartbeatVal}`}</div>
<div className="txt-body-s flex-1">{`Crush Value lost: -${subtractHeartbeatVal}`}</div> <Button variant="ghost" size="small" onClick={() => {
<Button setIsCrushLevelRetrieveDrawerOpen(true);
variant="ghost" }}>
size="small" Retrieve
onClick={() => { </Button>
setIsCrushLevelRetrieveDrawerOpen(true)
}}
>
Retrieve
</Button>
</div>
</div> </div>
)} </div>}
{/* 权限卡片网格 */} {/* 权限卡片网格 */}
<InlineDrawerDescription className="pb-6"> <InlineDrawerDescription className="pb-6">
@ -326,7 +283,7 @@ const CrushLevelDrawer = () => {
</div> </div>
</InlineDrawerContent> </InlineDrawerContent>
</InlineDrawer> </InlineDrawer>
) );
} };
export default CrushLevelDrawer export default CrushLevelDrawer;

View File

@ -1,106 +1,90 @@
import Image from 'next/image' import Image from "next/image";
import { import { InlineDrawer, InlineDrawerContent, InlineDrawerDescription, InlineDrawerFooter, InlineDrawerHeader } from "./InlineDrawer";
InlineDrawer, import { Button } from "@/components/ui/button";
InlineDrawerContent, import { useAtom, useSetAtom } from "jotai";
InlineDrawerDescription, import { isCrushLevelRetrieveDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat";
InlineDrawerFooter, import { useBuyHeartbeat, useGetHeartbeatLevel } from "@/hooks/useIm";
InlineDrawerHeader, import { useChatConfig } from "../../context/chatConfig";
} from './InlineDrawer' import numeral from "numeral";
import { Button } from '@/components/ui/button' import { formatFromCents } from "@/utils/number";
import { useAtom, useSetAtom } from 'jotai' import React, { useEffect } from "react";
import { isCrushLevelRetrieveDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat' import { useGetWalletBalance, useUpdateWalletBalance } from "@/hooks/useWallet";
import { useBuyHeartbeat, useGetHeartbeatLevel } from '@/hooks/useIm' import { isChargeDrawerOpenAtom } from "@/atoms/im";
import { useChatConfig } from '../../context/chatConfig' import { useQueryClient } from "@tanstack/react-query";
import numeral from 'numeral' import { imKeys, walletKeys } from "@/lib/query-keys";
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) => const setIsCrushLevelRetrieveDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open));
setDrawerState(createDrawerOpenState(open)) const { aiId, aiInfo } = useChatConfig();
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 ( if (!walletUpdate.checkSufficient(Math.max((price || 0) * (subtractHeartbeatVal || 0), 100) / 100)) {
!walletUpdate.checkSufficient( setIsChargeDrawerOpen(true);
Math.max((price || 0) * (subtractHeartbeatVal || 0), 100) / 100 return;
)
) {
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( const sortedLevels = [...heartbeatLeveLDictList].sort((a, b) => (a.startVal || 0) - (b.startVal || 0));
(a, b) => (a.startVal || 0) - (b.startVal || 0) const minStartVal = sortedLevels[0]?.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( const totalProgress = Math.max(0, Math.min(1, (heartbeatVal - minStartVal) / (maxStartVal - minStartVal)));
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 = () => {
@ -109,36 +93,37 @@ const CrushLevelRetrieveDrawer = () => {
// 目前只有一个心的图片可以通过透明度或其他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
@ -148,19 +133,14 @@ const CrushLevelRetrieveDrawer = () => {
timestamp={drawerState.timestamp} timestamp={drawerState.timestamp}
> >
<InlineDrawerContent> <InlineDrawerContent>
<div className="absolute top-0 right-0 left-0 h-[480px] overflow-hidden"> <div className="absolute top-0 left-0 right-0 h-[480px] overflow-hidden">
<Image <Image src="/images/crushlevel/bg-bottom.png" alt="CrushLevel" fill className="object-cover" />
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="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" 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"
width={124} width={124}
height={124} height={124}
style={{ style={{
@ -180,44 +160,36 @@ const CrushLevelRetrieveDrawer = () => {
<InlineDrawerHeader> </InlineDrawerHeader> <InlineDrawerHeader> </InlineDrawerHeader>
<InlineDrawerDescription> <InlineDrawerDescription>
<div className="pt-[124px]"> <div className="pt-[124px]">
<div className="txt-title-m text-center">Recover Crush Value</div> <div className="text-center txt-title-m">Recover Crush Value</div>
<div className="mt-6"> <div className="mt-6">
<div className="flex items-center justify-between py-3"> <div className="flex justify-between items-center py-3">
<div className="txt-label-l flex-1">Price per Unit</div> <div className="flex-1 txt-label-l">Price per Unit</div>
<div className="txt-numMonotype-s flex items-center gap-2"> <div className="flex items-center gap-2 txt-numMonotype-s">
<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 items-center justify-between py-3"> <div className="flex justify-between items-center py-3">
<div className="txt-label-l flex-1">Quantity</div> <div className="flex-1 txt-label-l">Quantity</div>
<div className="txt-numMonotype-s flex items-center gap-2"> <div className="flex items-center gap-2 txt-numMonotype-s">
{/* <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 items-center justify-between py-3"> <div className="flex justify-between items-center py-3">
<div className="txt-label-l flex-1">Total</div> <div className="flex-1 txt-label-l">Total</div>
<div className="txt-numMonotype-s flex items-center gap-2"> <div className="flex items-center gap-2 txt-numMonotype-s">
<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> <div>{formatFromCents(Math.max((price || 0) * (subtractHeartbeatVal || 0), 100))}</div>
{formatFromCents(Math.max((price || 0) * (subtractHeartbeatVal || 0), 100))}
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</InlineDrawerDescription> </InlineDrawerDescription>
<InlineDrawerFooter> <InlineDrawerFooter>
<Button <Button variant="tertiary" size="large" onClick={() => {
variant="tertiary" setIsCrushLevelRetrieveDrawerOpen(false);
size="large" }}>Cancel</Button>
onClick={() => {
setIsCrushLevelRetrieveDrawerOpen(false)
}}
>
Cancel
</Button>
<Button <Button
variant="primary" variant="primary"
size="large" size="large"
@ -230,7 +202,7 @@ const CrushLevelRetrieveDrawer = () => {
</div> </div>
</InlineDrawerContent> </InlineDrawerContent>
</InlineDrawer> </InlineDrawer>
) );
} }
export default CrushLevelRetrieveDrawer export default CrushLevelRetrieveDrawer;

View File

@ -1,76 +1,75 @@
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 index = openOrder.indexOf(id) const getZIndex = useCallback((id: string) => {
if (index === -1) return baseZIndex const index = openOrder.indexOf(id);
return baseZIndex + index if (index === -1) return baseZIndex;
}, 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,
@ -79,55 +78,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 border-outline-normal absolute inset-0 flex w-[400px] flex-col border-l border-solid', "bg-background-default absolute inset-0 flex flex-col w-[400px] border-l border-solid border-outline-normal",
className className
)} )}
style={{ zIndex }} style={{ zIndex }}
@ -135,40 +134,41 @@ export const InlineDrawerContent = ({
> >
{children} {children}
</div> </div>
) );
} };
export const InlineDrawerHeader = ({ children }: { children: React.ReactNode }) => { export const InlineDrawerHeader = ({
const { onOpenChange } = useContext(InlineDrawerContext) children,
}: {
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 <IconButton iconfont="icon-arrow-right" variant="ghost" size="small" onClick={() => onOpenChange(false)} />
iconfont="icon-arrow-right" <div className="txt-title-m flex-1 min-w-0">
variant="ghost" {children}
size="small" </div>
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 overflow-y-auto px-6', className)}>{children}</div> return <div className={cn("flex-1 px-6 overflow-y-auto", 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,39 +1,29 @@
import { useAtom, useAtomValue, useSetAtom } from 'jotai' import { useAtom, useAtomValue, useSetAtom } from "jotai";
import { import { isSendGiftsDrawerOpenAtom, createDrawerOpenState, isCrushLevelDrawerOpenAtom } from "@/atoms/chat";
isSendGiftsDrawerOpenAtom, import { InlineDrawer, InlineDrawerContent, InlineDrawerDescription, InlineDrawerFooter, InlineDrawerHeader } from "./InlineDrawer";
createDrawerOpenState, import { Button } from "@/components/ui/button";
isCrushLevelDrawerOpenAtom, import Image from "next/image";
} from '@/atoms/chat' import { useState, useEffect } from "react";
import { import { cn } from "@/lib/utils";
InlineDrawer, import { Input } from "@/components/ui/input";
InlineDrawerContent, import { GiftOutput } from "@/services/im";
InlineDrawerDescription, import { useGetGiftList, useSendGift } from "@/hooks/useIm";
InlineDrawerFooter, import { useChatConfig } from "../../context/chatConfig";
InlineDrawerHeader, import { Tag } from "@/components/ui/tag";
} from './InlineDrawer' import { isChargeDrawerOpenAtom, isVipDrawerOpenAtom, isWaitingForReplyAtom } from "@/atoms/im";
import { Button } from '@/components/ui/button' import { toast } from "sonner";
import Image from 'next/image' import numeral from "numeral";
import { useState, useEffect } from 'react' import { useGetWalletBalance, useUpdateWalletBalance } from "@/hooks/useWallet";
import { cn } from '@/lib/utils' import { useCurrentUser } from "@/hooks/auth";
import { Input } from '@/components/ui/input' import { useHeartLevelTextFromLevel } from "@/hooks/useHeartLevel";
import { GiftOutput } from '@/services/im' import { VipType } from "@/services/wallet";
import { useGetGiftList, useSendGift } from '@/hooks/useIm' import { useQueryClient } from "@tanstack/react-query";
import { useChatConfig } from '../../context/chatConfig' import { walletKeys } from "@/lib/query-keys";
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 = ({
@ -41,22 +31,23 @@ const GiftCard = ({
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">
@ -73,109 +64,107 @@ const GiftCard = ({
) )
} }
return null return null;
} }
return ( return (
<div <div
className={cn( className={cn(
'min-w-24 flex-1 cursor-pointer rounded-2xl p-2 transition-all duration-200', "flex-1 min-w-24 p-2 rounded-2xl cursor-pointer transition-all duration-200",
'hover:bg-surface-element-normal flex flex-col items-center gap-1 border border-transparent bg-transparent', "bg-transparent flex flex-col items-center gap-1 hover:bg-surface-element-normal border border-transparent",
isSelected && 'border-primary-variant-normal bg-surface-element-normal border shadow-lg' isSelected && "border border-primary-variant-normal shadow-lg bg-surface-element-normal"
)} )}
onClick={handleClick} onClick={handleClick}
> >
{/* 礼物图片 */} {/* 礼物图片 */}
<div className="relative aspect-square w-full"> <div className="relative w-full aspect-square">
<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 line-clamp-1 text-center"> <div className="txt-label-m text-txt-primary-normal text-center line-clamp-1">
{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"> <span className="txt-numMonotype-xs text-txt-primary-normal">{numeral(convertGiftPriceToYuan(gift.price)).format('0,0')}</span>
{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.');
@ -183,8 +172,8 @@ const SendGiftsDrawer = () => {
// } // }
if (!canPurchase) { if (!canPurchase) {
setIsChargeDrawerOpen(true) setIsChargeDrawerOpen(true);
return return;
} }
try { try {
@ -192,27 +181,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 (
@ -222,41 +211,23 @@ 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 <Image src="/icons/vip-black.svg" alt="vip" className="block" width={24} height={24} />
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 ( if ((startVal && typeof heartbeatVal === 'number' && heartbeatVal < startVal || !heartbeatVal)) {
(startVal && typeof heartbeatVal === 'number' && heartbeatVal < startVal) ||
!heartbeatVal
) {
return ( return (
<Button <Button size="large"
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 <Image src="/icons/like-gradient.svg" alt="vip" className="block" width={24} height={24} />
src="/icons/like-gradient.svg" <span className="txt-label-l">{useHeartLevelTextFromLevel(heartbeatLevel)} Unlock</span>
alt="vip"
className="block"
width={24}
height={24}
/>
<span className="txt-label-l">
{useHeartLevelTextFromLevel(heartbeatLevel)} Unlock
</span>
</div> </div>
</Button> </Button>
) )
@ -264,10 +235,14 @@ const SendGiftsDrawer = () => {
} }
return ( return (
<Button onClick={handleSendGift} disabled={isWaitingForReply} loading={isSendGiftPending}> <Button
onClick={handleSendGift}
disabled={isWaitingForReply}
loading={isSendGiftPending}
>
Gift Gift
</Button> </Button>
) );
} }
return ( return (
@ -296,15 +271,15 @@ const SendGiftsDrawer = () => {
</div> </div>
</InlineDrawerDescription> </InlineDrawerDescription>
<InlineDrawerFooter className="bg-background-default/65 flex-col gap-4 backdrop-blur-md"> <InlineDrawerFooter className="flex-col gap-4 bg-background-default/65 backdrop-blur-md">
{/* 数量选择器 */} {/* 数量选择器 */}
{selectedGift && ( {selectedGift && (
<div className="flex w-full items-center justify-between"> <div className="flex justify-between items-center w-full">
<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 min-w-0 rounded-sm px-0" className="!w-12 px-0 min-w-0 rounded-sm"
onClick={() => handleQuantityChange(-1)} onClick={() => handleQuantityChange(-1)}
> >
<i className="iconfont icon-reduce" /> <i className="iconfont icon-reduce" />
@ -313,26 +288,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 min-w-0 rounded-sm px-0" className="!w-12 px-0 min-w-0 rounded-sm"
onClick={() => handleQuantityChange(1)} onClick={() => handleQuantityChange(1)}
> >
<i className="iconfont icon-add" /> <i className="iconfont icon-add" />
@ -342,20 +317,16 @@ const SendGiftsDrawer = () => {
)} )}
{/* 总价显示 */} {/* 总价显示 */}
{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">Total</div>
<div className="txt-label-m text-txt-primary-normal">Total</div> <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-numMonotype-s text-txt-primary-normal">{numeral(totalPrice).format('0,0')}</span>
<span className="txt-numMonotype-s text-txt-primary-normal">
{numeral(totalPrice).format('0,0')}
</span>
</div>
</div> </div>
)} </div>)}
{/* 余额和购买按钮 */} {/* 余额和购买按钮 */}
<div className="flex w-full items-center justify-between"> <div className="flex justify-between items-center w-full">
<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} />
@ -366,7 +337,7 @@ const SendGiftsDrawer = () => {
</InlineDrawerFooter> </InlineDrawerFooter>
</InlineDrawerContent> </InlineDrawerContent>
</InlineDrawer> </InlineDrawer>
) );
} };
export default SendGiftsDrawer export default SendGiftsDrawer;

View File

@ -1,20 +1,21 @@
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 />
@ -24,7 +25,7 @@ const ChatDrawers = () => {
<ChatButtleDrawer /> <ChatButtleDrawer />
<ChatBackgroundDrawer /> <ChatBackgroundDrawer />
</div> </div>
) );
} }
export default ChatDrawers export default ChatDrawers;

View File

@ -1,33 +1,24 @@
'use client' "use client"
import { import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from "@/components/ui/alert-dialog";
AlertDialog, import { useEffect, useState } from "react";
AlertDialogContent, import { useChatConfig } from "../../context/chatConfig";
AlertDialogHeader, import { useRouter } from "next/navigation";
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 (
@ -37,10 +28,7 @@ const ChatFirstGuideDialog = () => {
<AlertDialogTitle>Create Album</AlertDialogTitle> <AlertDialogTitle>Create Album</AlertDialogTitle>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogDescription> <AlertDialogDescription>
<p> <p>Go to the characters profile to create an album, attract more chatters, and increase your earnings.</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>
@ -48,7 +36,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,60 +32,71 @@ 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('flex w-full flex-col items-start justify-start gap-4', className)}> <div className={cn(
"flex flex-col gap-4 items-start justify-start w-full",
className
)}>
{/* 标题栏 */} {/* 标题栏 */}
<div className="flex w-full items-center justify-between"> <div className="flex items-center justify-between w-full">
<p className="txt-label-m text-txt-secondary-normal">Choose one or edit</p> <p className="txt-label-m text-txt-secondary-normal">
<IconButton variant="tertiaryDark" size="xs" onClick={onClose}> Choose one or edit
</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="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]" className="flex gap-4 items-end justify-start overflow-hidden pl-4 pr-4 py-2 rounded-xl w-full bg-surface-element-light-normal backdrop-blur-[16px]"
> >
<div className="flex-1 px-0 py-1"> <div className="flex-1 px-0 py-1">
<div className="bg-surface-element-normal h-6 w-full animate-pulse rounded"></div> <div className="h-6 bg-surface-element-normal rounded w-full animate-pulse"></div>
</div>
<div className="bg-surface-element-normal size-8 flex-shrink-0 animate-pulse rounded-full"></div>
</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="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" suggestions.map((suggestion) => (
onClick={() => onSuggestionSend(suggestion)} <div
> key={suggestion.id}
<div className="flex-1 px-0 py-1"> 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]"
<div className="txt-body-l overflow-hidden"> onClick={() => onSuggestionSend(suggestion)}
<p>{suggestion.text}</p> >
</div> <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> </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
@ -114,8 +125,8 @@ export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
</div> */} </div> */}
{/* 分页控制 */} {/* 分页控制 */}
<div className="flex w-full items-center justify-center gap-4"> <div className="flex gap-4 items-center justify-center w-full">
<div className="flex items-start justify-start gap-2"> <div className="flex gap-2 items-start justify-start">
<IconButton <IconButton
variant="tertiaryDark" variant="tertiaryDark"
size="xs" size="xs"
@ -125,7 +136,7 @@ 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 h-6 min-w-6 items-center justify-center gap-3"> <div className="flex gap-3 h-6 items-center justify-center min-w-6">
<span className="txt-numMonotype-xs"> <span className="txt-numMonotype-xs">
{currentPage}/{totalPages} {currentPage}/{totalPages}
</span> </span>
@ -142,7 +153,7 @@ export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
</div> </div>
</div> </div>
</div> </div>
) );
} };
export default AiReplySuggestions export default AiReplySuggestions;

View File

@ -1,115 +1,109 @@
'use client' "use client"
import { IconButton } from '@/components/ui/button' import { IconButton } from "@/components/ui/button";
import { import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
DropdownMenu, import { Separator } from "@/components/ui/separator";
DropdownMenuContent, import { cn } from "@/lib/utils";
DropdownMenuItem, import { useState, useCallback } from "react";
DropdownMenuTrigger, import { useChatConfig } from "../../context/chatConfig";
} from '@/components/ui/dropdown-menu' import { useSetAtom } from "jotai";
import { Separator } from '@/components/ui/separator' import { isCrushLevelDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat";
import { cn } from '@/lib/utils' import useShare from "@/hooks/useShare";
import { useState, useCallback } from 'react' import { ChatPriceType, useUpdateWalletBalance } from "@/hooks/useWallet";
import { useChatConfig } from '../../context/chatConfig' import { isCallAtom, isCoinInsufficientAtom } from "@/atoms/im";
import { useSetAtom } from 'jotai' import { toast } from "sonner";
import { isCrushLevelDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat' import { useNimMsgContext } from "@/context/NimChat/useNimChat";
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 = ({ onUploadImage }: { onUploadImage: () => void }) => { const ChatActionPlus = ({
const [open, setOpen] = useState(false) onUploadImage,
const { aiInfo, aiId } = useChatConfig() }: {
const { audioPlayer } = useNimMsgContext() onUploadImage: () => void;
const setDrawerState = useSetAtom(isCrushLevelDrawerOpenAtom) }) => {
const setIsCrushLevelDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open)) const [open, setOpen] = useState(false);
const { aiUserHeartbeatRelation } = aiInfo || {} const { aiInfo, aiId } = useChatConfig();
const { heartbeatLevelNum } = aiUserHeartbeatRelation || {} const { audioPlayer } = useNimMsgContext();
const { shareFacebook, shareTwitter } = useShare() const setDrawerState = useSetAtom(isCrushLevelDrawerOpenAtom);
const { checkSufficientByType } = useUpdateWalletBalance() const setIsCrushLevelDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open));
const setIsCoinInsufficient = useSetAtom(isCoinInsufficientAtom) const { aiUserHeartbeatRelation } = aiInfo || {};
const setIsCall = useSetAtom(isCallAtom) const { heartbeatLevelNum } = aiUserHeartbeatRelation || {};
const { shareFacebook, shareTwitter } = useShare();
const { checkSufficientByType } = useUpdateWalletBalance();
const setIsCoinInsufficient = useSetAtom(isCoinInsufficientAtom);
const setIsCall = useSetAtom(isCallAtom);
const handleShareFacebook = () => { const handleShareFacebook = () => {
shareFacebook({ shareFacebook({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}` });
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}`,
})
} }
const handleShareTwitter = () => { const handleShareTwitter = () => {
shareTwitter({ shareTwitter({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}` });
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( toast('Microphone permission is required to make a voice call. Please allow microphone access in your browser settings.');
'Microphone permission is required to make a voice call. Please allow microphone access in your browser settings.' return false;
)
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 variant="ghost" size="small"> <IconButton
<i className={cn('iconfont', open ? 'icon-close' : 'icon-add')} /> variant="ghost"
size="small"
>
<i className={cn("iconfont", open ? "icon-close" : "icon-add")} />
</IconButton> </IconButton>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end"> <DropdownMenuContent align="end">
@ -121,10 +115,10 @@ const ChatActionPlus = ({ onUploadImage }: { onUploadImage: () => void }) => {
<i className="iconfont icon-Call" /> <i className="iconfont icon-Call" />
<span>Make a Call</span> <span>Make a Call</span>
</DropdownMenuItem> </DropdownMenuItem>
<div className="my-3 px-2"> <div className="px-2 my-3">
<Separator className="bg-outline-normal" /> <Separator className="bg-outline-normal" />
</div> </div>
<div className="txt-label-m text-txt-secondary-normal px-2 py-3">Share to</div> <div className="px-2 py-3 txt-label-m text-txt-secondary-normal">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>
@ -135,7 +129,7 @@ const ChatActionPlus = ({ onUploadImage }: { onUploadImage: () => void }) => {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) );
} }
export default ChatActionPlus export default ChatActionPlus;

View File

@ -1,61 +1,60 @@
'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 = ({ const ChatImagePreview = ({ selectedImage, imageUploading, removeImage }: ChatImagePreviewProps) => {
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="ml-4 w-full"> <div className="w-full ml-4">
<div className="relative inline-block"> <div className="relative inline-block">
<div <div className="w-24 h-24 rounded-lg overflow-hidden bg-center bg-cover bg-no-repeat relative"
className="relative h-24 w-24 overflow-hidden rounded-lg bg-cover bg-center bg-no-repeat" style={{ backgroundImage: `url(${imageUrl})` }}>
style={{ backgroundImage: `url(${imageUrl})` }}
>
{/* 上传进度遮罩 */} {/* 上传进度遮罩 */}
{imageUploading && ( {imageUploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50"> <div className="absolute inset-0 bg-black/50 flex items-center justify-center">
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white border-t-transparent"></div> <div className="animate-spin rounded-full h-6 w-6 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 variant="tertiaryDark" size="mini" onClick={removeImage}> <IconButton
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;

View File

@ -1,51 +1,47 @@
'use client' 'use client';
import React, { useState, useRef, useEffect } from 'react' import React, { useState, useRef, useEffect } 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 { useAtomValue, useSetAtom } from 'jotai' import { useAtomValue, useSetAtom } from 'jotai';
import { isSendGiftsDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat' import { isSendGiftsDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat';
import { useNimChat, useNimMsgContext } from '@/context/NimChat/useNimChat' import { useNimChat, useNimMsgContext } from '@/context/NimChat/useNimChat';
import { import { selectedConversationIdAtom, isWaitingForReplyAtom, isCoinInsufficientAtom } from '@/atoms/im';
selectedConversationIdAtom, import { Textarea } from '@/components/ui/textarea';
isWaitingForReplyAtom, import { useS3Upload } from '@/hooks/useS3Upload';
isCoinInsufficientAtom, import { BizTypeEnum } from '@/services/common/types';
} from '@/atoms/im' import ChatImagePreview from './ChatImagePreview';
import { Textarea } from '@/components/ui/textarea' import { useGetTextFromAsrVoice } from '@/hooks/useIm';
import { useS3Upload } from '@/hooks/useS3Upload' import { VoiceWaveAnimation } from '@/components/ui/voice-wave-animation';
import { BizTypeEnum } from '@/services/common/types' import { CustomMessageType } from '@/types/im';
import ChatImagePreview from './ChatImagePreview' import ChatActionPlus from './ChatActionPlus';
import { useGetTextFromAsrVoice } from '@/hooks/useIm' import AiReplySuggestions from './AiReplySuggestions';
import { VoiceWaveAnimation } from '@/components/ui/voice-wave-animation' import { useAiReplySuggestions } from '@/hooks/useAiReplySuggestions';
import { CustomMessageType } from '@/types/im' import { useChatConfig } from '../../context/chatConfig/useChatConfig';
import ChatActionPlus from './ChatActionPlus' import { toast } from 'sonner';
import AiReplySuggestions from './AiReplySuggestions' import CoinInsufficientDialog from '@/components/features/coin-insufficient-dialog';
import { useAiReplySuggestions } from '@/hooks/useAiReplySuggestions' import { ChatPriceType, useGetWalletBalance, useUpdateWalletBalance } from '@/hooks/useWallet';
import { useChatConfig } from '../../context/chatConfig/useChatConfig' import { PriceType } from '@/services/wallet';
import { toast } from 'sonner' import { useSearchParams } from 'next/navigation';
import CoinInsufficientDialog from '@/components/features/coin-insufficient-dialog'
import { ChatPriceType, useGetWalletBalance, useUpdateWalletBalance } from '@/hooks/useWallet'
import { PriceType } from '@/services/wallet'
import { useSearchParams } from 'next/navigation'
interface ChatInputProps { interface ChatInputProps {
onSendMessage?: (message: string, images?: File[]) => void onSendMessage?: (message: string, images?: File[]) => void;
onSendVoice?: (audioBlob: Blob) => void onSendVoice?: (audioBlob: Blob) => void;
userAvatar?: string userAvatar?: string;
className?: string className?: string;
placeholder?: string placeholder?: string;
} }
interface ImageError { interface ImageError {
type: 'format' | 'size' type: 'format' | 'size';
message: string message: string;
} }
interface ImageInfo { interface ImageInfo {
url: string url: string;
width: number width: number;
height: number height: number;
file?: File file?: File;
} }
const ChatInput: React.FC<ChatInputProps> = ({ const ChatInput: React.FC<ChatInputProps> = ({
@ -53,37 +49,36 @@ const ChatInput: React.FC<ChatInputProps> = ({
onSendVoice, onSendVoice,
userAvatar, userAvatar,
className, className,
placeholder = 'Chat', placeholder = "Chat",
}) => { }) => {
const searchParams = useSearchParams() const searchParams = useSearchParams();
const [message, setMessage] = useState('') const [message, setMessage] = useState('');
const [selectedImage, setSelectedImage] = useState<File | null>(null) const [selectedImage, setSelectedImage] = useState<File | null>(null);
const [imageInfo, setImageInfo] = useState<ImageInfo | null>(null) const [imageInfo, setImageInfo] = useState<ImageInfo | null>(null);
const [imageError, setImageError] = useState<ImageError | null>(null) const [imageError, setImageError] = useState<ImageError | null>(null);
const [isRecording, setIsRecording] = useState(false) const [isRecording, setIsRecording] = useState(false);
const [isListening, setIsListening] = useState(false) const [isListening, setIsListening] = useState(false);
const [isProcessingAudio, setIsProcessingAudio] = useState(false) // 音频处理状态(上传+ASR const [isProcessingAudio, setIsProcessingAudio] = useState(false); // 音频处理状态(上传+ASR
const [isComposing, setIsComposing] = useState(false) // 中文输入法组合输入状态 const [isComposing, setIsComposing] = useState(false); // 中文输入法组合输入状态
const fileInputRef = useRef<HTMLInputElement>(null) const fileInputRef = useRef<HTMLInputElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null) const textareaRef = useRef<HTMLTextAreaElement>(null);
const mediaRecorderRef = useRef<MediaRecorder | null>(null) const mediaRecorderRef = useRef<MediaRecorder | null>(null);
const audioChunksRef = useRef<Blob[]>([]) const audioChunksRef = useRef<Blob[]>([]);
const isRecordingRef = useRef<boolean>(false) const isRecordingRef = useRef<boolean>(false);
const isCancelledRef = useRef<boolean>(false) const isCancelledRef = useRef<boolean>(false);
const recordingStartTimeRef = useRef<number>(0) // 录音开始时间 const recordingStartTimeRef = useRef<number>(0); // 录音开始时间
const recordingTimerRef = useRef<NodeJS.Timeout | null>(null) // 60秒定时器 const recordingTimerRef = useRef<NodeJS.Timeout | null>(null); // 60秒定时器
const setDrawerState = useSetAtom(isSendGiftsDrawerOpenAtom) const setDrawerState = useSetAtom(isSendGiftsDrawerOpenAtom);
const setIsSendGiftsDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open)) const setIsSendGiftsDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open));
const { sendMessageActive, audioPlayer } = useNimMsgContext() const { sendMessageActive, audioPlayer } = useNimMsgContext();
const { nim } = useNimChat() const { nim } = useNimChat();
const selectedConversationId = useAtomValue(selectedConversationIdAtom) const selectedConversationId = useAtomValue(selectedConversationIdAtom);
// const isWaitingForReply = useAtomValue(isWaitingForReplyAtom); // const isWaitingForReply = useAtomValue(isWaitingForReplyAtom);
const isWaitingForReply = false const isWaitingForReply = false;
const { mutateAsync: getTextFromAsrVoice, isPending: isTextFromAsrVoicePending } = const { mutateAsync: getTextFromAsrVoice, isPending: isTextFromAsrVoicePending } = useGetTextFromAsrVoice();
useGetTextFromAsrVoice() const { aiId, handleUserMessage } = useChatConfig();
const { aiId, handleUserMessage } = useChatConfig() const { checkSufficientByType } = useUpdateWalletBalance();
const { checkSufficientByType } = useUpdateWalletBalance() const setIsCoinInsufficient = useSetAtom(isCoinInsufficientAtom);
const setIsCoinInsufficient = useSetAtom(isCoinInsufficientAtom)
// AI建议回复相关状态 // AI建议回复相关状态
const { const {
@ -96,8 +91,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
hideSuggestions, hideSuggestions,
handlePageChange, handlePageChange,
} = useAiReplySuggestions({ } = useAiReplySuggestions({
aiId: Number(aiId), aiId: Number(aiId)
}) });
// S3上传钩子 // S3上传钩子
const { const {
@ -105,139 +100,141 @@ const ChatInput: React.FC<ChatInputProps> = ({
uploadFile, uploadFile,
cancelUpload, cancelUpload,
error: uploadError, error: uploadError,
progress: uploadProgress, progress: uploadProgress
} = useS3Upload({ } = useS3Upload({
bizType: BizTypeEnum.IM, bizType: BizTypeEnum.IM,
onSuccess: (url) => { onSuccess: (url) => {
console.log('Image uploaded successfully:', url) console.log('Image uploaded successfully:', url);
}, },
onError: (error) => { onError: (error) => {
console.error('Image upload failed:', error) console.error('Image upload failed:', error);
setImageError({ type: 'size', message: 'Image upload failed, please try again' }) setImageError({ type: 'size', message: 'Image upload failed, please try again' });
}, }
}) });
// 自动调整 Textarea 高度的函数 // 自动调整 Textarea 高度的函数
const adjustTextareaHeight = () => { const adjustTextareaHeight = () => {
const textarea = textareaRef.current const textarea = textareaRef.current;
if (textarea) { if (textarea) {
// 重置高度以获得准确的 scrollHeight // 重置高度以获得准确的 scrollHeight
textarea.style.height = 'auto' textarea.style.height = 'auto';
// 计算内容高度但限制最大高度为视口的40%以防止过度扩展 // 计算内容高度但限制最大高度为视口的40%以防止过度扩展
const maxHeight = Math.min(textarea.scrollHeight, window.innerHeight * 0.4) const maxHeight = Math.min(textarea.scrollHeight, window.innerHeight * 0.4);
textarea.style.height = maxHeight + 'px' textarea.style.height = maxHeight + 'px';
} }
} };
// 重置 Textarea 高度到初始状态 // 重置 Textarea 高度到初始状态
const resetTextareaHeight = () => { const resetTextareaHeight = () => {
const textarea = textareaRef.current const textarea = textareaRef.current;
if (textarea) { if (textarea) {
textarea.style.height = 'auto' textarea.style.height = 'auto';
textarea.style.height = '32px' // 设置为最小高度 textarea.style.height = '32px'; // 设置为最小高度
} }
} };
// 监听 message 变化,自动调整高度 // 监听 message 变化,自动调整高度
useEffect(() => { useEffect(() => {
adjustTextareaHeight() adjustTextareaHeight();
}, [message]) }, [message]);
// 从 URL 参数中获取 text 并填充到输入框 // 从 URL 参数中获取 text 并填充到输入框
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();
} }
} else { } else {
if (textareaRef.current) { if (textareaRef.current) {
textareaRef.current.focus() textareaRef.current.focus();
} }
} }
}, [searchParams]) }, [searchParams]);
// 音频上传的S3配置 // 音频上传的S3配置
const { uploadFile: uploadAudioFile } = useS3Upload({ const {
uploadFile: uploadAudioFile
} = useS3Upload({
bizType: BizTypeEnum.SOUND_PATH, bizType: BizTypeEnum.SOUND_PATH,
}) });
// 获取图片尺寸信息 // 获取图片尺寸信息
const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => { const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const img = new Image() const img = new Image();
const url = URL.createObjectURL(file) const url = URL.createObjectURL(file);
img.onload = () => { img.onload = () => {
URL.revokeObjectURL(url) URL.revokeObjectURL(url);
resolve({ resolve({
width: img.naturalWidth, width: img.naturalWidth,
height: img.naturalHeight, height: img.naturalHeight
}) });
} };
img.onerror = () => { img.onerror = () => {
URL.revokeObjectURL(url) URL.revokeObjectURL(url);
reject(new Error('Failed to load image')) reject(new Error('Failed to load image'));
} };
img.src = url img.src = url;
}) });
} };
// 验证图片格式和大小 // 验证图片格式和大小
const validateImage = (file: File): ImageError | null => { const validateImage = (file: File): ImageError | null => {
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png'] const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
const maxSize = 10 * 1024 * 1024 // 10MB const maxSize = 10 * 1024 * 1024; // 10MB
if (!allowedTypes.includes(file.type)) { if (!allowedTypes.includes(file.type)) {
return { return {
type: 'format', type: 'format',
message: 'Supported formats: JPG, JPEG, PNG', message: 'Supported formats: JPG, JPEG, PNG'
} };
} }
if (file.size > maxSize) { if (file.size > maxSize) {
return { return {
type: 'size', type: 'size',
message: 'Image size must be less than 10MB', message: 'Image size must be less than 10MB'
} };
} }
return null return null;
} };
// 处理发送消息 // 处理发送消息
const handleSend = () => { const handleSend = () => {
if (isRecording) { if (isRecording) {
// 手动停止录音时,也需要检查时长 // 手动停止录音时,也需要检查时长
mediaRecorderRef.current?.stop() mediaRecorderRef.current?.stop();
setIsRecording(false) setIsRecording(false);
isRecordingRef.current = false isRecordingRef.current = false;
return return;
} }
// 如果正在等待回复或正在上传图片,禁止发送新消息 // 如果正在等待回复或正在上传图片,禁止发送新消息
if (isWaitingForReply || imageUploading || isProcessingAudio) { if (isWaitingForReply || imageUploading || isProcessingAudio) {
return return;
} }
// 如果有图片,必须同时有文字内容 // 如果有图片,必须同时有文字内容
if (imageInfo && !message.trim()) { if (imageInfo && !message.trim()) {
return return;
} }
if (!checkSufficientByType(ChatPriceType.TEXT)) { if (!checkSufficientByType(ChatPriceType.TEXT)) {
setIsCoinInsufficient(true) setIsCoinInsufficient(true);
return return;
} }
// 必须有文字内容或者同时有图片和文字 // 必须有文字内容或者同时有图片和文字
if (message.trim() || (imageInfo && message.trim())) { if (message.trim() || (imageInfo && message.trim())) {
let msg let msg;
if (imageInfo) { if (imageInfo) {
// 创建图文消息(自定义消息) // 创建图文消息(自定义消息)
@ -245,313 +242,313 @@ const ChatInput: React.FC<ChatInputProps> = ({
type: CustomMessageType.IMAGE, type: CustomMessageType.IMAGE,
url: `${imageInfo.url}`, url: `${imageInfo.url}`,
width: imageInfo.width, width: imageInfo.width,
height: imageInfo.height, height: imageInfo.height
} };
msg = nim.V2NIMMessageCreator.createCustomMessage( msg = nim.V2NIMMessageCreator.createCustomMessage(
message.trim(), // text字段 message.trim(), // text字段
JSON.stringify(customContent) // custom字段 JSON.stringify(customContent) // custom字段
) );
} else { } else {
// 纯文本消息 // 纯文本消息
msg = nim.V2NIMMessageCreator.createTextMessage(message.trim()) msg = nim.V2NIMMessageCreator.createTextMessage(message.trim());
} }
sendMessageActive({ sendMessageActive({
msg, msg,
conversationId: selectedConversationId || '', conversationId: selectedConversationId || '',
}) });
// 通知用户发送了消息,重置自动聊天定时器 // 通知用户发送了消息,重置自动聊天定时器
handleUserMessage() handleUserMessage();
// 清空输入 // 清空输入
setMessage('') setMessage('');
setSelectedImage(null) setSelectedImage(null);
setImageInfo(null) setImageInfo(null);
setImageError(null) setImageError(null);
// 重置 Textarea 高度 // 重置 Textarea 高度
resetTextareaHeight() resetTextareaHeight();
} }
} };
// 处理键盘事件 // 处理键盘事件
const handleKeyPress = (e: React.KeyboardEvent) => { const handleKeyPress = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey && !isComposing) { if (e.key === 'Enter' && !e.shiftKey && !isComposing) {
e.preventDefault() e.preventDefault();
handleSend() handleSend();
} }
} };
// 处理中文输入法组合事件 // 处理中文输入法组合事件
const handleCompositionStart = () => { const handleCompositionStart = () => {
setIsComposing(true) setIsComposing(true);
} };
const handleCompositionEnd = () => { const handleCompositionEnd = () => {
setIsComposing(false) setIsComposing(false);
} };
// 处理图片选择 // 处理图片选择
const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] const file = e.target.files?.[0];
if (file) { if (file) {
const error = validateImage(file) const error = validateImage(file);
if (error) { if (error) {
setImageError(error) setImageError(error);
setSelectedImage(null) setSelectedImage(null);
setImageInfo(null) setImageInfo(null);
} else { } else {
setSelectedImage(file) setSelectedImage(file);
setImageError(null) setImageError(null);
try { try {
// 获取图片尺寸 // 获取图片尺寸
const dimensions = await getImageDimensions(file) const dimensions = await getImageDimensions(file);
// 开始上传 // 开始上传
const uploadedUrl = await uploadFile(file) const uploadedUrl = await uploadFile(file);
console.log('uploadedUrl', uploadedUrl) console.log('uploadedUrl', uploadedUrl);
if (uploadedUrl) { if (uploadedUrl) {
setImageInfo({ setImageInfo({
url: `${uploadedUrl}`, url: `${uploadedUrl}`,
width: dimensions.width, width: dimensions.width,
height: dimensions.height, height: dimensions.height,
file, file
}) });
} }
} catch (error) { } catch (error) {
console.error('处理图片失败:', error) console.error('处理图片失败:', error);
setImageError({ type: 'size', message: 'Image processing failed, please try again' }) setImageError({ type: 'size', message: 'Image processing failed, please try again' });
setSelectedImage(null) setSelectedImage(null);
} }
} }
} }
// 清空input的值允许重新选择同一个文件 // 清空input的值允许重新选择同一个文件
e.target.value = '' e.target.value = '';
} };
// 取消录音 // 取消录音
const handleCancelRecording = () => { const handleCancelRecording = () => {
if (mediaRecorderRef.current && isRecording) { if (mediaRecorderRef.current && isRecording) {
// 标记为取消状态防止在onstop中处理音频 // 标记为取消状态防止在onstop中处理音频
isCancelledRef.current = true isCancelledRef.current = true;
// 清除60秒定时器 // 清除60秒定时器
if (recordingTimerRef.current) { if (recordingTimerRef.current) {
clearTimeout(recordingTimerRef.current) clearTimeout(recordingTimerRef.current);
recordingTimerRef.current = null recordingTimerRef.current = null;
} }
mediaRecorderRef.current.stop() mediaRecorderRef.current.stop();
// 获取所有音频轨道并停止 // 获取所有音频轨道并停止
if (mediaRecorderRef.current.stream) { if (mediaRecorderRef.current.stream) {
mediaRecorderRef.current.stream.getTracks().forEach((track) => track.stop()) mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
} }
setIsRecording(false) setIsRecording(false);
isRecordingRef.current = false isRecordingRef.current = false;
setIsListening(false) setIsListening(false);
setIsProcessingAudio(false) // 重置音频处理状态 setIsProcessingAudio(false); // 重置音频处理状态
audioChunksRef.current = [] audioChunksRef.current = [];
recordingStartTimeRef.current = 0 recordingStartTimeRef.current = 0;
} }
} };
// 处理语音录制 // 处理语音录制
const handleVoiceRecord = async () => { const handleVoiceRecord = async () => {
if (!checkSufficientByType(ChatPriceType.VOICE)) { if (!checkSufficientByType(ChatPriceType.VOICE)) {
setIsCoinInsufficient(true) setIsCoinInsufficient(true);
return return;
} }
audioPlayer?.stop() audioPlayer?.stop();
if (!isRecording) { if (!isRecording) {
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const mediaRecorder = new MediaRecorder(stream) const mediaRecorder = new MediaRecorder(stream);
mediaRecorderRef.current = mediaRecorder mediaRecorderRef.current = mediaRecorder;
audioChunksRef.current = [] audioChunksRef.current = [];
// 添加音频上下文来检测音量 // 添加音频上下文来检测音量
const audioContext = new AudioContext() const audioContext = new AudioContext();
const analyser = audioContext.createAnalyser() const analyser = audioContext.createAnalyser();
const microphone = audioContext.createMediaStreamSource(stream) const microphone = audioContext.createMediaStreamSource(stream);
microphone.connect(analyser) microphone.connect(analyser);
analyser.smoothingTimeConstant = 0.8 analyser.smoothingTimeConstant = 0.8;
analyser.fftSize = 1024 analyser.fftSize = 1024;
const bufferLength = analyser.frequencyBinCount const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength) const dataArray = new Uint8Array(bufferLength);
mediaRecorder.ondataavailable = (event) => { mediaRecorder.ondataavailable = (event) => {
audioChunksRef.current.push(event.data) audioChunksRef.current.push(event.data);
} };
mediaRecorder.onstop = async () => { mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }) const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
stream.getTracks().forEach((track) => track.stop()) stream.getTracks().forEach(track => track.stop());
audioContext.close() audioContext.close();
setIsListening(false) setIsListening(false);
isRecordingRef.current = false isRecordingRef.current = false;
// 清除60秒定时器 // 清除60秒定时器
if (recordingTimerRef.current) { if (recordingTimerRef.current) {
clearTimeout(recordingTimerRef.current) clearTimeout(recordingTimerRef.current);
recordingTimerRef.current = null recordingTimerRef.current = null;
} }
// 如果是取消录音,直接返回,不处理音频 // 如果是取消录音,直接返回,不处理音频
if (isCancelledRef.current) { if (isCancelledRef.current) {
isCancelledRef.current = false // 重置取消状态 isCancelledRef.current = false; // 重置取消状态
recordingStartTimeRef.current = 0 recordingStartTimeRef.current = 0;
return return;
} }
// 检查录音时长 // 检查录音时长
const recordingDuration = Date.now() - recordingStartTimeRef.current const recordingDuration = Date.now() - recordingStartTimeRef.current;
if (recordingDuration < 1000) { if (recordingDuration < 1000) {
// 录音时间少于1秒显示提示 // 录音时间少于1秒显示提示
toast.error('Voice too short') toast.error("Voice too short");
recordingStartTimeRef.current = 0 recordingStartTimeRef.current = 0;
return return;
} }
// 开始音频处理(上传+ASR // 开始音频处理(上传+ASR
setIsProcessingAudio(true) setIsProcessingAudio(true);
try { try {
// 将 Blob 转换为 base64 // 将 Blob 转换为 base64
const base64Data = await new Promise<string>((resolve, reject) => { const base64Data = await new Promise<string>((resolve, reject) => {
const reader = new FileReader() const reader = new FileReader();
reader.onload = () => { reader.onload = () => {
const result = reader.result as string const result = reader.result as string;
console.log('result', result) console.log('result', result);
// 移除 data:audio/mp3;base64, 前缀 // 移除 data:audio/mp3;base64, 前缀
const base64 = result.split(',')[1] const base64 = result.split(',')[1];
resolve(base64) resolve(base64);
} };
reader.onerror = reject reader.onerror = reject;
reader.readAsDataURL(audioBlob) reader.readAsDataURL(audioBlob);
}) });
// 调用ASR接口获取文字 // 调用ASR接口获取文字
const resp = await getTextFromAsrVoice({ const resp = await getTextFromAsrVoice({
data: base64Data, data: base64Data,
aiId: Number(aiId), aiId: Number(aiId),
}) });
const text = resp?.content const text = resp?.content;
const trimmedText = text?.trim() const trimmedText = text?.trim();
if (trimmedText) { if (trimmedText) {
// 纯文本消息 // 纯文本消息
const msg = nim.V2NIMMessageCreator.createTextMessage(trimmedText) const msg = nim.V2NIMMessageCreator.createTextMessage(trimmedText);
sendMessageActive({ sendMessageActive({
msg, msg,
conversationId: selectedConversationId || '', conversationId: selectedConversationId || '',
}) });
// 通知用户发送了消息,重置自动聊天定时器 // 通知用户发送了消息,重置自动聊天定时器
handleUserMessage() handleUserMessage();
// 重置 Textarea 高度(虽然语音消息不会改变输入框内容,但为了保持一致性) // 重置 Textarea 高度(虽然语音消息不会改变输入框内容,但为了保持一致性)
resetTextareaHeight() resetTextareaHeight();
} }
} catch (error) { } catch (error) {
console.error('音频上传或转换失败:', error) console.error('音频上传或转换失败:', error);
// 可以在这里添加错误提示给用户 // 可以在这里添加错误提示给用户
} finally { } finally {
// 完成音频处理 // 完成音频处理
setIsProcessingAudio(false) setIsProcessingAudio(false);
recordingStartTimeRef.current = 0 recordingStartTimeRef.current = 0;
} }
} };
mediaRecorder.start() mediaRecorder.start();
setIsRecording(true) setIsRecording(true);
isRecordingRef.current = true isRecordingRef.current = true;
isCancelledRef.current = false // 重置取消状态 isCancelledRef.current = false; // 重置取消状态
// 记录录音开始时间 // 记录录音开始时间
recordingStartTimeRef.current = Date.now() recordingStartTimeRef.current = Date.now();
// 设置60秒定时器自动停止录音 // 设置60秒定时器自动停止录音
recordingTimerRef.current = setTimeout(() => { recordingTimerRef.current = setTimeout(() => {
if (mediaRecorderRef.current && isRecordingRef.current) { if (mediaRecorderRef.current && isRecordingRef.current) {
mediaRecorderRef.current.stop() mediaRecorderRef.current.stop();
setIsRecording(false) setIsRecording(false);
isRecordingRef.current = false isRecordingRef.current = false;
} }
}, 60000) // 60秒 }, 60000); // 60秒
// 检测音量的函数 // 检测音量的函数
const checkVolume = () => { const checkVolume = () => {
if (isRecordingRef.current) { if (isRecordingRef.current) {
analyser.getByteFrequencyData(dataArray) analyser.getByteFrequencyData(dataArray);
const volume = dataArray.reduce((sum, value) => sum + value, 0) / bufferLength const volume = dataArray.reduce((sum, value) => sum + value, 0) / bufferLength;
setIsListening(volume > 10) // 设置音量阈值 setIsListening(volume > 10); // 设置音量阈值
requestAnimationFrame(checkVolume) requestAnimationFrame(checkVolume);
} }
} };
// 确保在设置录音状态后再开始检测音量 // 确保在设置录音状态后再开始检测音量
checkVolume() checkVolume();
} catch (error) { } catch (error) {
console.error('Error accessing microphone:', error) console.error('Error accessing microphone:', error);
} }
} else { } else {
// 发送动作改在发送消息的按钮上 // 发送动作改在发送消息的按钮上
return return;
// 手动停止录音时,也需要检查时长 // 手动停止录音时,也需要检查时长
mediaRecorderRef.current?.stop() mediaRecorderRef.current?.stop();
setIsRecording(false) setIsRecording(false);
isRecordingRef.current = false isRecordingRef.current = false;
} }
} };
// 移除选中的图片 // 移除选中的图片
const removeImage = () => { const removeImage = () => {
// 如果正在上传,取消上传 // 如果正在上传,取消上传
if (imageUploading) { if (imageUploading) {
cancelUpload() cancelUpload();
} }
setSelectedImage(null) setSelectedImage(null);
setImageInfo(null) setImageInfo(null);
setImageError(null) setImageError(null);
} };
// 处理AI建议编辑放到输入框 // 处理AI建议编辑放到输入框
const handleSuggestionEdit = (suggestion: any) => { const handleSuggestionEdit = (suggestion: any) => {
setMessage(suggestion.text) setMessage(suggestion.text);
hideSuggestions() hideSuggestions();
} };
// 处理AI建议发送直接发送消息 // 处理AI建议发送直接发送消息
const handleSuggestionSend = (suggestion: any) => { const handleSuggestionSend = (suggestion: any) => {
// 直接发送建议内容作为消息 // 直接发送建议内容作为消息
if (suggestion.text.trim()) { if (suggestion.text.trim()) {
const msg = nim.V2NIMMessageCreator.createTextMessage(suggestion.text.trim()) const msg = nim.V2NIMMessageCreator.createTextMessage(suggestion.text.trim());
sendMessageActive({ sendMessageActive({
msg, msg,
conversationId: selectedConversationId || '', conversationId: selectedConversationId || '',
}) });
// 通知用户发送了消息,重置自动聊天定时器 // 通知用户发送了消息,重置自动聊天定时器
handleUserMessage() handleUserMessage();
hideSuggestions() hideSuggestions();
// 重置 Textarea 高度 // 重置 Textarea 高度
resetTextareaHeight() resetTextareaHeight();
} }
} };
return ( return (
<div className={cn('relative flex w-full flex-col', className)}> <div className={cn("relative flex flex-col w-full", className)}>
{/* AI建议回复面板 */} {/* AI建议回复面板 */}
{suggestionsVisible && ( {suggestionsVisible && (
<div className="mb-4 rounded-xl px-16 pt-6"> <div className="mb-4 rounded-xl px-16 pt-6">
@ -570,8 +567,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
{/* 错误提示 */} {/* 错误提示 */}
{imageError && ( {imageError && (
<div className="mx-3 mb-2 rounded-lg border border-red-500/30 bg-red-500/20 px-3 py-2"> <div className="mx-3 mb-2 px-3 py-2 bg-red-500/20 border border-red-500/30 rounded-lg">
<p className="text-sm text-red-400">{imageError.message}</p> <p className="text-red-400 text-sm">{imageError.message}</p>
</div> </div>
)} )}
@ -582,10 +579,10 @@ const ChatInput: React.FC<ChatInputProps> = ({
</IconButton> </IconButton>
{/* 输入框容器 */} {/* 输入框容器 */}
<div className="relative flex-1"> <div className="flex-1 relative">
{isRecording ? ( {isRecording ? (
/* 录音状态界面 - 按照Figma设计稿样式 */ /* 录音状态界面 - 按照Figma设计稿样式 */
<div className="box-border flex min-h-[48px] items-center justify-start gap-4 overflow-clip rounded-3xl bg-[rgba(255,255,255,0.15)] p-2 backdrop-blur backdrop-filter"> <div className="backdrop-blur backdrop-filter bg-[rgba(255,255,255,0.15)] box-border flex gap-4 items-center justify-start overflow-clip p-2 rounded-3xl min-h-[48px]">
{/* 左侧语音按钮 */} {/* 左侧语音按钮 */}
<IconButton <IconButton
variant="ghost" variant="ghost"
@ -597,7 +594,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
</IconButton> </IconButton>
{/* 中间声波纹 */} {/* 中间声波纹 */}
<div className="flex flex-1 items-center justify-center px-4"> <div className="flex-1 flex items-center justify-center px-4">
<VoiceWaveAnimation <VoiceWaveAnimation
animated={isListening} animated={isListening}
barCount={40} barCount={40}
@ -606,18 +603,20 @@ const ChatInput: React.FC<ChatInputProps> = ({
</div> </div>
{/* 右侧删除按钮 */} {/* 右侧删除按钮 */}
<IconButton variant="tertiary" size="small" onClick={handleCancelRecording}> <IconButton
variant="tertiary"
size="small"
onClick={handleCancelRecording}
>
<i className="iconfont icon-close" /> <i className="iconfont icon-close" />
</IconButton> </IconButton>
</div> </div>
) : ( ) : (
/* 正常输入状态界面 */ /* 正常输入状态界面 */
<div <div className={cn(
className={cn( "relative flex items-end min-h-[48px] bg-surface-element-light-press backdrop-blur-sm rounded-xl p-2",
'bg-surface-element-light-press relative flex min-h-[48px] items-end rounded-xl p-2 backdrop-blur-sm', isWaitingForReply && "opacity-60",
isWaitingForReply && 'opacity-60' )}>
)}
>
{/* 语音录制按钮 */} {/* 语音录制按钮 */}
<IconButton <IconButton
variant="ghost" variant="ghost"
@ -629,7 +628,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
</IconButton> </IconButton>
{/* 文本输入 */} {/* 文本输入 */}
<div className={cn('w-full')}> <div className={cn(
"w-full",
)}>
<ChatImagePreview <ChatImagePreview
selectedImage={selectedImage} selectedImage={selectedImage}
imageUploading={imageUploading} imageUploading={imageUploading}
@ -646,7 +647,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
placeholder={placeholder} placeholder={placeholder}
// placeholder={isWaitingForReply ? "等待AI回复中..." : placeholder} // placeholder={isWaitingForReply ? "等待AI回复中..." : placeholder}
// disabled={isWaitingForReply} // disabled={isWaitingForReply}
className="text-txt-primary-normal placeholder:text-txt-tertiary-normal txt-body-l max-h-none min-h-[24px] flex-1 resize-none overflow-hidden border-none bg-transparent py-1 break-all outline-none" className="flex-1 bg-transparent border-none outline-none resize-none text-txt-primary-normal placeholder:text-txt-tertiary-normal txt-body-l min-h-[24px] max-h-none py-1 overflow-hidden break-all"
rows={1} rows={1}
maxLength={500} maxLength={500}
style={{ style={{
@ -660,23 +661,25 @@ const ChatInput: React.FC<ChatInputProps> = ({
</div> </div>
{/* 右侧按钮组 */} {/* 右侧按钮组 */}
<div className="ml-2 flex items-center gap-2"> <div className="flex items-center gap-2 ml-2">
{/* 提示词提示按钮 */} {/* 提示词提示按钮 */}
<IconButton <IconButton
variant="ghost" variant="ghost"
size="small" size="small"
onClick={() => { onClick={() => {
if (!checkSufficientByType(ChatPriceType.TEXT)) { if (!checkSufficientByType(ChatPriceType.TEXT)) {
setIsCoinInsufficient(true) setIsCoinInsufficient(true);
return return;
} }
if (suggestionsVisible) { if (suggestionsVisible) {
hideSuggestions() hideSuggestions();
} else { } else {
showSuggestions() showSuggestions();
} }
}} }}
className={cn(suggestionsVisible && 'bg-surface-element-hover')} className={cn(
suggestionsVisible && "bg-surface-element-hover"
)}
> >
<i className="iconfont icon-prompt" /> <i className="iconfont icon-prompt" />
</IconButton> </IconButton>
@ -686,25 +689,19 @@ const ChatInput: React.FC<ChatInputProps> = ({
)} )}
</div> </div>
{ {(
/* 发送按钮 */ /* 发送按钮 */
<IconButton <IconButton
variant="default" variant="default"
size="large" size="large"
loading={isProcessingAudio} loading={isProcessingAudio}
onClick={() => handleSend()} onClick={() => handleSend()}
disabled={ disabled={!isRecording && ((!message.trim() && !imageInfo) || (imageInfo && !message.trim()) || isWaitingForReply || imageUploading)}
!isRecording &&
((!message.trim() && !imageInfo) ||
(imageInfo && !message.trim()) ||
isWaitingForReply ||
imageUploading)
}
className="flex-shrink-0" className="flex-shrink-0"
> >
<i className="iconfont icon-icon-send" /> <i className="iconfont icon-icon-send" />
</IconButton> </IconButton>
} )}
{/* 隐藏的文件输入 */} {/* 隐藏的文件输入 */}
<input <input
@ -716,7 +713,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
/> />
</div> </div>
</div> </div>
) );
} };
export default ChatInput export default ChatInput;

View File

@ -1,26 +1,24 @@
import ChatInput from './ChatInput' import ChatInput from "./ChatInput";
const ChatMessageAction = () => { const ChatMessageAction = () => {
const handleSendMessage = (message: string, images?: File[]) => { const handleSendMessage = (message: string, images?: File[]) => {
console.log('发送消息:', message) console.log('发送消息:', message);
if (images && images.length > 0) { if (images && images.length > 0) {
console.log('发送图片:', images) console.log('发送图片:', images);
} }
// TODO: 实现发送消息的逻辑 // TODO: 实现发送消息的逻辑
} };
const handleSendVoice = (audioBlob: Blob) => { const handleSendVoice = (audioBlob: Blob) => {
console.log('发送语音:', audioBlob) console.log('发送语音:', audioBlob);
// TODO: 实现发送语音的逻辑 // TODO: 实现发送语音的逻辑
} };
return ( return (
<div className="pb-6"> <div className="pb-6">
<div className="relative mx-auto flex max-w-[752px] items-center gap-4"> <div className="max-w-[752px] mx-auto flex items-center gap-4 relative">
<div <div className="absolute left-0 right-0 bottom-0 h-[120px] " style={{ background: "linear-gradient(180deg, rgba(33, 26, 43, 0) 0%, #211A2B 40.83%)" }} />
className="absolute right-0 bottom-0 left-0 h-[120px]" <div className="w-full relative">
style={{ background: 'linear-gradient(180deg, rgba(33, 26, 43, 0) 0%, #211A2B 40.83%)' }}
/>
<div className="relative w-full">
<ChatInput <ChatInput
onSendMessage={handleSendMessage} onSendMessage={handleSendMessage}
onSendVoice={handleSendVoice} onSendVoice={handleSendVoice}
@ -29,7 +27,7 @@ const ChatMessageAction = () => {
</div> </div>
</div> </div>
</div> </div>
) );
} }
export default ChatMessageAction export default ChatMessageAction;

View File

@ -1,42 +1,45 @@
'use client' "use client";
import { ExtendedMessage } from '@/atoms/im' import { ExtendedMessage } from '@/atoms/im';
import { useNimMsgContext } from '@/context/NimChat/useNimChat' import { useNimMsgContext } from '@/context/NimChat/useNimChat';
import { useVoiceTTS } from '@/hooks/useVoiceTTS' import { useVoiceTTS } from '@/hooks/useVoiceTTS';
import * as React from 'react' import * as React from 'react';
import { useChatConfig } from '../../context/chatConfig' import { useChatConfig } from '../../context/chatConfig';
import { LoadingIcon } from '@/components/ui/button' import { LoadingIcon } from '@/components/ui/button';
import { WaveAnimation } from '@/components/ui/wave-animation' import { WaveAnimation } from '@/components/ui/wave-animation';
import { import { extractTextForVoice, calculateAudioDuration, formatAudioDuration } from '@/utils/textParser';
extractTextForVoice,
calculateAudioDuration,
formatAudioDuration,
} from '@/utils/textParser'
const ChatAudioTag = ({ message }: { message: ExtendedMessage }) => { const ChatAudioTag = ({
const { audioPlayer } = useNimMsgContext() message,
const { aiInfo, aiId } = useChatConfig() }: {
const { dialoguePitch, dialogueSpeechRate, voiceType } = aiInfo || {} message: ExtendedMessage;
}) => {
const { audioPlayer } = useNimMsgContext();
const { aiInfo, aiId } = useChatConfig();
const { dialoguePitch, dialogueSpeechRate, voiceType } = aiInfo || {};
const { isGenerating, generateAudioUrl } = useVoiceTTS({ const {
isGenerating,
generateAudioUrl,
} = useVoiceTTS({
cacheEnabled: true, cacheEnabled: true,
needCheckSufficient: true, needCheckSufficient: true,
}) });
const audioText = extractTextForVoice(message.text || '') const audioText = extractTextForVoice(message.text || '');
// 计算预估的音频时长 // 计算预估的音频时长
const estimatedDuration = React.useMemo(() => { const estimatedDuration = React.useMemo(() => {
return calculateAudioDuration( return calculateAudioDuration(
message.text || '', message.text || '',
typeof dialogueSpeechRate === 'number' ? dialogueSpeechRate : 0 typeof dialogueSpeechRate === 'number' ? dialogueSpeechRate : 0
) );
}, [message.text, dialogueSpeechRate]) }, [message.text, dialogueSpeechRate]);
// 格式化时长显示 // 格式化时长显示
const formattedDuration = formatAudioDuration(estimatedDuration) const formattedDuration = formatAudioDuration(estimatedDuration);
const isPlaying = audioPlayer?.isPlaying(message.messageClientId) const isPlaying = audioPlayer?.isPlaying(message.messageClientId);
const handlePlay = async () => { const handlePlay = async () => {
const audioUrl = await generateAudioUrl({ const audioUrl = await generateAudioUrl({
@ -45,22 +48,22 @@ const ChatAudioTag = ({ message }: { message: ExtendedMessage }) => {
speechRate: dialogueSpeechRate, speechRate: dialogueSpeechRate,
pitchRate: dialoguePitch, pitchRate: dialoguePitch,
aiId: aiId, aiId: aiId,
}) });
if (!audioUrl) { if (!audioUrl) {
return return;
} }
audioPlayer?.play(message.messageClientId, audioUrl) audioPlayer?.play(message.messageClientId, audioUrl);
} }
// const duration = message.duration; // const duration = message.duration;
const renderIcon = () => { const renderIcon = () => {
if (isGenerating) { if (isGenerating) {
return <LoadingIcon className="!text-[16px] leading-none" /> return <LoadingIcon className='!text-[16px] leading-none' />;
} }
if (isPlaying) { if (isPlaying) {
return <WaveAnimation className="text-txt-primary-normal animate-pulse" /> return <WaveAnimation className="text-txt-primary-normal animate-pulse" />;
} }
return <i className="iconfont icon-Play leading-none" /> return <i className="iconfont icon-Play leading-none" />
@ -73,18 +76,18 @@ const ChatAudioTag = ({ message }: { message: ExtendedMessage }) => {
// } // }
if (!audioText) { if (!audioText) {
return null return null;
} }
return ( return (
<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-l-sm rounded-tr-sm px-3 py-1" className="bg-surface-float-normal hover:bg-surface-float-hover py-1 px-3 rounded-l-sm rounded-tr-sm flex items-center gap-2 absolute left-0 -top-3 cursor-pointer"
onClick={handlePlay} onClick={handlePlay}
> >
<div className="flex h-4 w-4 items-center">{renderIcon()}</div> <div className="h-4 w-4 flex items-center">{renderIcon()}</div>
<span className="txt-label-s">{formattedDuration}</span> <span className="txt-label-s">{formattedDuration}</span>
</div> </div>
) );
} }
export default ChatAudioTag export default ChatAudioTag;

View File

@ -1,13 +1,13 @@
'use client' "use client"
import { cn } from '@/lib/utils' import { cn } from "@/lib/utils";
import { ReactNode } from 'react' import { ReactNode } from "react";
interface ChatBubbleProps { interface ChatBubbleProps {
children: ReactNode children: ReactNode;
className?: string className?: string;
isDefault?: boolean isDefault?: boolean;
img?: string img?: string;
} }
const ChatBubble = ({ children, isDefault, img, className }: ChatBubbleProps) => { const ChatBubble = ({ children, isDefault, img, className }: ChatBubbleProps) => {
@ -15,13 +15,15 @@ const ChatBubble = ({ children, isDefault, img, className }: ChatBubbleProps) =>
<div className="relative max-w-[496px] p-4"> <div className="relative max-w-[496px] p-4">
<div <div
className={cn( className={cn(
isDefault && ['bg-primary-normal absolute inset-0 rounded-lg backdrop-blur-[32px]'], isDefault && [
"absolute inset-0 bg-primary-normal backdrop-blur-[32px] rounded-lg",
],
!isDefault && [ !isDefault && [
'absolute -inset-2', "absolute -inset-2",
'border-[30px] border-transparent', "border-[30px] border-transparent",
'[border-image-slice:70_fill]', "[border-image-slice:70_fill]",
'[border-image-width:30px]', "[border-image-width:30px]",
'[border-image-repeat:stretch]', "[border-image-repeat:stretch]"
], ],
className className
)} )}
@ -29,10 +31,14 @@ const ChatBubble = ({ children, isDefault, img, className }: ChatBubbleProps) =>
borderImageSource: isDefault ? 'none' : `url(${img || ''})`, borderImageSource: isDefault ? 'none' : `url(${img || ''})`,
// borderImageSource: isDefault ? 'none' : `url(https://hhb.crushlevel.ai/static/chatBubble/chat_bubble_temp_1.png)`, // borderImageSource: isDefault ? 'none' : `url(https://hhb.crushlevel.ai/static/chatBubble/chat_bubble_temp_1.png)`,
}} }}
></div> >
<div className="relative min-w-[20px] text-left">{children}</div>
</div>
<div className="relative min-w-[20px] text-left">
{children}
</div>
</div> </div>
) );
} }
export default ChatBubble export default ChatBubble;

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