Compare commits
2 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
f19f3e2541 | |
|
|
c6d3f07968 |
2
.env
2
.env
|
|
@ -22,7 +22,7 @@ NEXT_PUBLIC_RTC_APP_ID=689ade491323ae01797818e0
|
|||
|
||||
# 启用 mock
|
||||
NEXT_PUBLIC_ENABLE_MOCK=false
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_APP_URL=https://test.crushlevel.ai
|
||||
|
||||
NEXT_PUBLIC_IM_USER_SUFFIX=@u@t
|
||||
NEXT_PUBLIC_IM_AI_SUFFIX=@r@t
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ yarn-error.log*
|
|||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
# .env*
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
|
|
|||
8
.npmrc
8
.npmrc
|
|
@ -1,8 +0,0 @@
|
|||
# 使用 pnpm
|
||||
shamefully-hoist=true
|
||||
strict-peer-dependencies=false
|
||||
auto-install-peers=true
|
||||
|
||||
# 国内镜像加速(可选,如果需要的话)
|
||||
# registry=https://registry.npmmirror.com
|
||||
|
||||
|
|
@ -1,43 +1,6 @@
|
|||
# 依赖
|
||||
node_modules
|
||||
.pnpm-store
|
||||
pnpm-lock.yaml
|
||||
|
||||
# 构建产物
|
||||
.next
|
||||
out
|
||||
dist
|
||||
build
|
||||
|
||||
# 配置文件
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf",
|
||||
"plugins": ["prettier-plugin-tailwindcss"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
# Repository Guidelines
|
||||
|
||||
## 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/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.
|
||||
- Mock handlers live in `src/mocks`, MSW’s worker sits in `public/mockServiceWorker.js`, localization bundles under `public/locales`, and generated docs go to `docs/`.
|
||||
|
||||
## 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 build` compiles the production bundle; run after routing or configuration changes.
|
||||
- `npm run start` serves the built app and mirrors production runtime.
|
||||
|
|
@ -14,17 +16,20 @@
|
|||
- `npm run i18n:scan`, `npm run i18n:scan-custom`, and `npm run i18n:convert` refresh translation keys and write `docs/copy-audit.xlsx`.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
- 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`.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
- 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.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
- 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.
|
||||
- PRs need a concise summary, screenshots for UI updates, environment-variable callouts, and links to related issues or docs.
|
||||
|
|
|
|||
12
README.md
12
README.md
|
|
@ -73,22 +73,27 @@ npm run dev
|
|||
## Discord登录流程
|
||||
|
||||
### 1. 用户点击Discord登录按钮
|
||||
|
||||
- 系统生成随机state参数用于安全验证
|
||||
- 跳转到Discord授权页面
|
||||
|
||||
### 2. Discord授权
|
||||
|
||||
用户在Discord页面授权后,Discord重定向到回调URL并携带授权码
|
||||
|
||||
### 3. 回调处理
|
||||
|
||||
- API路由 `/api/auth/discord/callback` 接收授权码
|
||||
- 将授权码作为URL参数重定向到前端登录页面
|
||||
|
||||
### 4. 前端登录处理
|
||||
|
||||
- 前端登录页面检测到URL中的`discord_code`参数
|
||||
- 使用授权码调用后端API: `POST /web/third/login`
|
||||
- 后端验证授权码并返回认证token
|
||||
|
||||
### 5. 登录完成
|
||||
|
||||
- 前端保存token并重定向到首页
|
||||
- 完成整个登录流程
|
||||
|
||||
|
|
@ -131,6 +136,7 @@ src/
|
|||
### 认证相关API
|
||||
|
||||
#### 第三方登录
|
||||
|
||||
```
|
||||
POST /web/third/login
|
||||
Content-Type: application/json
|
||||
|
|
@ -144,6 +150,7 @@ Content-Type: application/json
|
|||
```
|
||||
|
||||
#### 获取用户信息
|
||||
|
||||
```
|
||||
GET /web/user/base-info
|
||||
Headers:
|
||||
|
|
@ -152,6 +159,7 @@ Headers:
|
|||
```
|
||||
|
||||
#### 登出
|
||||
|
||||
```
|
||||
POST /web/user/logout
|
||||
Headers:
|
||||
|
|
@ -176,12 +184,14 @@ Headers:
|
|||
为便于产品/运营统一校对当前所有展示文案,项目提供静态扫描脚本,自动抽取源码中的用户可见与可感知文案并导出为 Excel。
|
||||
|
||||
### 覆盖范围
|
||||
|
||||
- JSX 文本节点与按钮/链接文案
|
||||
- 属性文案:`placeholder` / `title` / `alt` / `aria-*` / `label`
|
||||
- 交互文案:`toast.*` / `message.*` / `alert` / `confirm` / `Dialog`/`Tooltip` 等常见调用
|
||||
- 表单校验与错误提示:`form.setError(..., { message })`、校验链条中的 `{ message: '...' }`
|
||||
|
||||
### 运行
|
||||
|
||||
```bash
|
||||
# 生成 docs/copy-audit.xlsx
|
||||
npx ts-node scripts/extract-copy.ts # 若 ESM 运行报错,请改用下行
|
||||
|
|
@ -191,6 +201,7 @@ node scripts/extract-copy.cjs
|
|||
输出文件:`docs/copy-audit.xlsx`
|
||||
|
||||
### Excel 字段说明(Sheet: copy)
|
||||
|
||||
- `route`: Next.js App Router 路由(如 `(main)/home`)或 `shared`
|
||||
- `file`: 文案所在文件(相对仓库根路径)
|
||||
- `componentOrFn`: 组件或函数名(无法解析时为文件名)
|
||||
|
|
@ -202,6 +213,7 @@ node scripts/extract-copy.cjs
|
|||
- `notes`: 预留备注
|
||||
|
||||
### 说明与边界
|
||||
|
||||
- 仅提取可静态分析到的硬编码字符串;运行时拼接(仅变量)无法还原将被忽略
|
||||
- 会过滤明显的“代码样”字符串(如过长的标识符)
|
||||
- 扫描目录为 `src/`,忽略 `node_modules/.next/__tests__/mocks` 等
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
# AI 建议回复功能重构说明
|
||||
|
||||
## 重构日期
|
||||
|
||||
2025-11-17
|
||||
|
||||
## 最新更新
|
||||
|
||||
2025-11-17 - 优化请求逻辑,确保每次只发送 3 次 API 请求
|
||||
|
||||
## 问题描述
|
||||
|
|
@ -28,29 +30,33 @@
|
|||
#### 状态简化
|
||||
|
||||
**重构前:**
|
||||
|
||||
```typescript
|
||||
const [allSuggestions, setAllSuggestions] = useState<ReplySuggestion[]>([]);
|
||||
const [loadedPages, setLoadedPages] = useState<Set<number>>(new Set([1]));
|
||||
const [isPageLoading, setIsPageLoading] = useState(false);
|
||||
const [isRequesting, setIsRequesting] = useState(false);
|
||||
const [allSuggestions, setAllSuggestions] = useState<ReplySuggestion[]>([])
|
||||
const [loadedPages, setLoadedPages] = useState<Set<number>>(new Set([1]))
|
||||
const [isPageLoading, setIsPageLoading] = useState(false)
|
||||
const [isRequesting, setIsRequesting] = useState(false)
|
||||
// ... 其他状态
|
||||
```
|
||||
|
||||
**重构后:**
|
||||
|
||||
```typescript
|
||||
const [pageData, setPageData] = useState<Map<number, ReplySuggestion[]>>(new Map());
|
||||
const [loadingPages, setLoadingPages] = useState<Set<number>>(new Set());
|
||||
const [pageData, setPageData] = useState<Map<number, ReplySuggestion[]>>(new Map())
|
||||
const [loadingPages, setLoadingPages] = useState<Set<number>>(new Set())
|
||||
// ... 其他必要状态
|
||||
```
|
||||
|
||||
#### 核心逻辑变化
|
||||
|
||||
**重构前:**
|
||||
|
||||
- `showSuggestions` 函数会根据多种条件判断是否需要获取数据
|
||||
- 首次获取第1页数据,然后静默获取第2、3页
|
||||
- 页面切换时复杂的加载状态判断
|
||||
|
||||
**重构后:**
|
||||
|
||||
- **每次调用 `showSuggestions` 都重置到第1页并重新获取所有数据**
|
||||
- 按页存储数据到 `Map` 中,逻辑更清晰
|
||||
- 页面切换时自动检查并加载缺失的页面数据
|
||||
|
|
@ -58,12 +64,14 @@ const [loadingPages, setLoadingPages] = useState<Set<number>>(new Set());
|
|||
### 2. 数据获取流程优化
|
||||
|
||||
#### 重构前流程
|
||||
|
||||
1. 获取第1页 → 立即展示
|
||||
2. 静默获取第2页 → 更新 `loadedPages`
|
||||
3. 静默获取第3页 → 更新 `loadedPages`
|
||||
4. 用户切换页面时检查 `loadedPages`
|
||||
|
||||
#### 重构后流程
|
||||
|
||||
1. 清空旧数据
|
||||
2. 获取第1页 → 存入 `pageData.set(1, ...)`
|
||||
3. 获取第2页 → 存入 `pageData.set(2, ...)`
|
||||
|
|
@ -73,19 +81,22 @@ const [loadingPages, setLoadingPages] = useState<Set<number>>(new Set());
|
|||
### 3. 骨架屏显示逻辑
|
||||
|
||||
**重构前:**
|
||||
|
||||
```typescript
|
||||
const displaySuggestions = isCurrentPageLoaded ? suggestions : Array.from({ length: 3 }, ...);
|
||||
```
|
||||
|
||||
**重构后:**
|
||||
|
||||
```typescript
|
||||
const suggestions = isCurrentPageLoading || !currentPageSuggestions
|
||||
const suggestions =
|
||||
isCurrentPageLoading || !currentPageSuggestions
|
||||
? Array.from({ length: 3 }, (_, index) => ({
|
||||
id: `skeleton-${currentPage}-${index}`,
|
||||
text: '',
|
||||
isSkeleton: true
|
||||
isSkeleton: true,
|
||||
}))
|
||||
: currentPageSuggestions;
|
||||
: currentPageSuggestions
|
||||
```
|
||||
|
||||
UI 组件根据 `isSkeleton` 标志渲染骨架屏或真实内容。
|
||||
|
|
@ -93,6 +104,7 @@ UI 组件根据 `isSkeleton` 标志渲染骨架屏或真实内容。
|
|||
### 4. UI 组件更新
|
||||
|
||||
`AiReplySuggestions.tsx` 更新:
|
||||
|
||||
- 添加 `isSkeleton` 字段到 `ReplySuggestion` 接口
|
||||
- 检查 `suggestions.some(s => s.isSkeleton)` 决定是否显示骨架屏
|
||||
- 骨架屏添加 `animate-pulse` 动画效果
|
||||
|
|
@ -100,22 +112,26 @@ UI 组件根据 `isSkeleton` 标志渲染骨架屏或真实内容。
|
|||
## 重构优势
|
||||
|
||||
### 1. **简化的状态管理**
|
||||
|
||||
- 使用 `Map<number, ReplySuggestion[]>` 按页存储数据,结构清晰
|
||||
- 移除了 `isRequesting`、`isPageLoading`、`isCurrentPageLoaded` 等冗余状态
|
||||
- 只需维护 `loadingPages: Set<number>` 来跟踪正在加载的页面
|
||||
|
||||
### 2. **智能的数据管理**
|
||||
|
||||
- **有新 AI 消息时**:重置到第1页并重新获取数据
|
||||
- **没有新消息时**:保留之前的建议内容和当前页面位置
|
||||
- 避免了"空白建议"的问题
|
||||
- 数据加载状态清晰可见(骨架屏动画)
|
||||
|
||||
### 3. **更可靠的数据加载**
|
||||
|
||||
- 智能判断是否需要重新获取数据(基于最后一条 AI 消息 ID)
|
||||
- 首次打开或数据为空时自动获取
|
||||
- 减少了因状态不同步导致的 bug
|
||||
|
||||
### 4. **更好的代码可维护性**
|
||||
|
||||
- 代码从 312 行减少到 200 行
|
||||
- 逻辑流程更清晰直观
|
||||
- 减少了状态依赖和副作用
|
||||
|
|
@ -166,10 +182,11 @@ return {
|
|||
showSuggestions, // () => void
|
||||
hideSuggestions, // () => void
|
||||
handlePageChange, // (page: number) => void
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
移除的返回值:
|
||||
|
||||
- `isPageLoading`
|
||||
- `isCurrentPageLoaded`
|
||||
- `refreshSuggestions`
|
||||
|
|
@ -181,6 +198,7 @@ return {
|
|||
**问题**:原始实现会触发多次重复请求
|
||||
|
||||
**解决方案**:
|
||||
|
||||
1. **统一的 `fetchAllData` 函数**:一次性顺序获取3页数据,使用局部变量传递 `batchNo` 和 `excContentList`
|
||||
2. **防重复调用保护**:在 `fetchAllData` 开始时检查 `loadingPages.size > 0`,如果已有加载则跳过
|
||||
3. **移除分页独立加载**:删除了 `fetchPageData` 函数和 `handlePageChange` 中的数据获取逻辑
|
||||
|
|
@ -216,6 +234,7 @@ fetchAllData() {
|
|||
```
|
||||
|
||||
**关键优势**:
|
||||
|
||||
- 🚀 **第1页数据到达后立即展示**,用户无需等待所有数据
|
||||
- 📊 **后续页面数据追加展示**,不影响用户浏览第1页
|
||||
- ⏱️ **感知加载时间更短**,提升用户体验
|
||||
|
|
@ -235,4 +254,3 @@ fetchAllData() {
|
|||
2. 测试网络慢场景:确认骨架屏正确显示
|
||||
3. 测试 Coin 不足场景:确认面板正确关闭
|
||||
4. 测试新消息场景:发送消息后面板已打开时自动刷新
|
||||
|
||||
|
|
|
|||
|
|
@ -7,21 +7,27 @@
|
|||
## 实现组件
|
||||
|
||||
### 1. AiReplySuggestions.tsx
|
||||
|
||||
主要的AI建议回复组件,包含以下功能:
|
||||
|
||||
- 显示多个AI建议选项
|
||||
- 支持编辑建议内容
|
||||
- VIP解锁更多功能入口
|
||||
- 分页导航控制
|
||||
|
||||
### 2. useAiReplySuggestions.ts
|
||||
|
||||
状态管理Hook,处理:
|
||||
|
||||
- AI建议的获取和管理
|
||||
- 分页逻辑
|
||||
- 面板显示/隐藏控制
|
||||
- **自动刷新机制**:当面板打开时收到新AI消息会自动刷新建议
|
||||
|
||||
### 3. ChatInput.tsx(更新)
|
||||
|
||||
集成AI建议功能到聊天输入组件:
|
||||
|
||||
- 添加提示词按钮来触发建议面板
|
||||
- 处理建议选择和应用
|
||||
- 管理面板状态
|
||||
|
|
@ -29,12 +35,14 @@
|
|||
## 设计细节
|
||||
|
||||
### 视觉设计
|
||||
|
||||
- 遵循Figma设计稿的视觉样式
|
||||
- 使用毛玻璃效果和圆角设计
|
||||
- 渐变色彩搭配
|
||||
- 响应式布局
|
||||
|
||||
### 交互设计
|
||||
|
||||
- 点击提示词按钮显示/隐藏建议面板
|
||||
- **点击建议卡片:直接发送该建议作为消息**
|
||||
- **点击编辑图标:将建议文案放入输入框进行编辑**
|
||||
|
|
@ -60,22 +68,26 @@
|
|||
## 核心逻辑
|
||||
|
||||
### 建议获取时机
|
||||
|
||||
- 只有当最后一条消息来自AI(对方)时,才会在打开面板时获取新建议
|
||||
- 如果最后一条消息来自用户,则显示之前缓存的建议或骨架屏
|
||||
- 每次检测到新的AI消息后,第一次打开面板会重新获取建议
|
||||
|
||||
### 骨架屏显示
|
||||
|
||||
- **骨架屏已集成到建议弹窗内部**,不再是独立组件
|
||||
- 在API调用期间显示骨架屏,提升用户体验
|
||||
- 骨架屏固定显示2条建议的布局结构
|
||||
|
||||
### 分页机制
|
||||
|
||||
- **API一次性返回所有建议数据**,不是分页请求
|
||||
- 每页显示2条建议
|
||||
- 根据API返回的总数自动计算页数
|
||||
- **点击左右切换只是前端切换显示,不会重新请求接口**
|
||||
|
||||
### 缓存策略
|
||||
|
||||
- 建议会被缓存,避免重复API调用
|
||||
- 只有检测到新的AI消息时才会清空缓存重新获取
|
||||
- **自动刷新**:当面板已打开且收到新AI消息时,自动刷新建议
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@
|
|||
## 使用方法
|
||||
|
||||
```tsx
|
||||
import AvatarSetting from "@/app/(main)/profile/components/AvatarSetting"
|
||||
import AvatarSetting from '@/app/(main)/profile/components/AvatarSetting'
|
||||
|
||||
function ProfilePage() {
|
||||
const [isAvatarSettingOpen, setIsAvatarSettingOpen] = useState(false)
|
||||
const [currentAvatar, setCurrentAvatar] = useState<string>("")
|
||||
const [currentAvatar, setCurrentAvatar] = useState<string>('')
|
||||
|
||||
const handleAvatarChange = (avatarUrl: string) => {
|
||||
setCurrentAvatar(avatarUrl)
|
||||
|
|
@ -27,7 +27,7 @@ function ProfilePage() {
|
|||
}
|
||||
|
||||
const handleAvatarDelete = () => {
|
||||
setCurrentAvatar("")
|
||||
setCurrentAvatar('')
|
||||
// 这里可以调用API删除头像
|
||||
}
|
||||
|
||||
|
|
@ -42,9 +42,7 @@ function ProfilePage() {
|
|||
return (
|
||||
<div>
|
||||
{/* 触发按钮 */}
|
||||
<button onClick={openAvatarSetting}>
|
||||
设置头像
|
||||
</button>
|
||||
<button onClick={openAvatarSetting}>设置头像</button>
|
||||
|
||||
{/* 头像设置模态框 */}
|
||||
<AvatarSetting
|
||||
|
|
@ -62,7 +60,7 @@ function ProfilePage() {
|
|||
## Props
|
||||
|
||||
| 属性 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| ---------------- | ----------------------------- | ----------- | ------------------- |
|
||||
| `isOpen` | `boolean` | `false` | 控制模态框显示/隐藏 |
|
||||
| `onClose` | `() => void` | `undefined` | 关闭模态框的回调 |
|
||||
| `currentAvatar` | `string \| undefined` | `undefined` | 当前头像URL |
|
||||
|
|
|
|||
|
|
@ -7,16 +7,19 @@
|
|||
## 功能特性
|
||||
|
||||
### 1. 状态处理
|
||||
|
||||
- **无等级状态**:只显示AI头像和昵称
|
||||
- **有等级状态**:显示AI和用户双头像,包含心动等级信息
|
||||
|
||||
### 2. 视觉设计
|
||||
|
||||
- **双头像布局**:AI头像和用户头像并排显示,带有白色边框和阴影
|
||||
- **多层背景装饰**:三层渐变圆形背景,从外到内颜色递进
|
||||
- **心动等级徽章**:居中显示的心形徽章,包含等级数字
|
||||
- **角色信息展示**:角色名称和心动温度标签
|
||||
|
||||
### 3. 动画效果
|
||||
|
||||
- **等级变化动画**:心形背景从大到小消失,数字等级渐变切换
|
||||
- **分层延迟**:三层心形背景依次消失(0ms, 100ms, 200ms延迟)
|
||||
- **数字切换**:背景完全消失后,等级数字淡出淡入切换到新等级
|
||||
|
|
@ -25,8 +28,8 @@
|
|||
|
||||
```typescript
|
||||
interface CrushLevelAvatarProps {
|
||||
size?: "large" | "small"; // 头像尺寸
|
||||
showAnimation?: boolean; // 是否显示等级变化动画
|
||||
size?: 'large' | 'small' // 头像尺寸
|
||||
showAnimation?: boolean // 是否显示等级变化动画
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -63,10 +66,12 @@ import CrushLevelAvatar from './components/CrushLevelAvatar';
|
|||
## 样式系统
|
||||
|
||||
### CSS 动画类
|
||||
|
||||
- `.animate-scale-down` - 缩放动画
|
||||
- `.animate-delay-100/200/300` - 动画延迟
|
||||
|
||||
### 颜色设计
|
||||
|
||||
- 外层背景:`from-purple-500/20`
|
||||
- 中层背景:`from-pink-500/30`
|
||||
- 内层背景:`from-magenta-500/40`
|
||||
|
|
@ -75,12 +80,17 @@ import CrushLevelAvatar from './components/CrushLevelAvatar';
|
|||
## 实现细节
|
||||
|
||||
### 背景装饰层
|
||||
|
||||
```tsx
|
||||
{/* 心形背景层 - 使用SVG图标 */}
|
||||
<div className={cn(
|
||||
"absolute left-1/2 top-[-63px] -translate-x-1/2 w-60 h-[210px] pointer-events-none",
|
||||
isLevelChanging && showAnimation && "animate-scale-fade-out"
|
||||
)}>
|
||||
{
|
||||
/* 心形背景层 - 使用SVG图标 */
|
||||
}
|
||||
;<div
|
||||
className={cn(
|
||||
'pointer-events-none absolute top-[-63px] left-1/2 h-[210px] w-60 -translate-x-1/2',
|
||||
isLevelChanging && showAnimation && 'animate-scale-fade-out'
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
src="/icons/crushlevel_heart.svg"
|
||||
alt="heart background"
|
||||
|
|
@ -91,19 +101,17 @@ import CrushLevelAvatar from './components/CrushLevelAvatar';
|
|||
```
|
||||
|
||||
### 心动等级徽章
|
||||
|
||||
```tsx
|
||||
{/* 心形背景 + 等级数字 */}
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 size-10 z-10">
|
||||
<Image
|
||||
src="/icons/crushlevel_heart.svg"
|
||||
alt="heart"
|
||||
width={40}
|
||||
height={40}
|
||||
/>
|
||||
{
|
||||
/* 心形背景 + 等级数字 */
|
||||
}
|
||||
;<div className="absolute top-1/2 left-1/2 z-10 size-10 -translate-x-1/2 -translate-y-1/2">
|
||||
<Image src="/icons/crushlevel_heart.svg" alt="heart" width={40} height={40} />
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-10 font-bold text-white text-base transition-all duration-300",
|
||||
isLevelChanging && "animate-level-change"
|
||||
'relative z-10 text-base font-bold text-white transition-all duration-300',
|
||||
isLevelChanging && 'animate-level-change'
|
||||
)}
|
||||
key={displayLevel}
|
||||
>
|
||||
|
|
@ -113,35 +121,52 @@ import CrushLevelAvatar from './components/CrushLevelAvatar';
|
|||
```
|
||||
|
||||
### 动画触发机制
|
||||
|
||||
```tsx
|
||||
// 监听等级变化触发动画
|
||||
useEffect(() => {
|
||||
if (showAnimation && heartbeatLevel && heartbeatLevel !== displayLevel) {
|
||||
setIsLevelChanging(true);
|
||||
setAnimationKey(prev => prev + 1);
|
||||
setIsLevelChanging(true)
|
||||
setAnimationKey((prev) => prev + 1)
|
||||
|
||||
// 背景消失后更新等级数字
|
||||
setTimeout(() => {
|
||||
setDisplayLevel(heartbeatLevel);
|
||||
setIsLevelChanging(false);
|
||||
}, 600); // 背景消失动画时长
|
||||
setDisplayLevel(heartbeatLevel)
|
||||
setIsLevelChanging(false)
|
||||
}, 600) // 背景消失动画时长
|
||||
}
|
||||
}, [heartbeatLevel, showAnimation, displayLevel]);
|
||||
}, [heartbeatLevel, showAnimation, displayLevel])
|
||||
```
|
||||
|
||||
### CSS 动画定义
|
||||
|
||||
```css
|
||||
/* 心形背景消失动画 */
|
||||
@keyframes scale-fade-out {
|
||||
0% { transform: scale(1); opacity: 1; }
|
||||
100% { transform: scale(0.3); opacity: 0; }
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.3);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 等级数字变化动画 */
|
||||
@keyframes level-change {
|
||||
0% { opacity: 1; transform: scale(1); }
|
||||
50% { opacity: 0; transform: scale(0.8); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -158,9 +183,11 @@ useEffect(() => {
|
|||
可以通过访问 `/test-crush-level-avatar` 页面查看组件的不同状态和配置效果。
|
||||
|
||||
### 调试功能
|
||||
|
||||
在浏览器控制台中可以调用以下函数来手动触发动画:
|
||||
|
||||
```javascript
|
||||
window.triggerLevelAnimation(); // 触发等级变化动画
|
||||
window.triggerLevelAnimation() // 触发等级变化动画
|
||||
```
|
||||
|
||||
## 更新历史
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ 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.
|
||||
|
||||
| Type | Token | Value | 移动端约定值 | 有调整会标黄 | 新增会标蓝 | 可不录入的文字会变红 |
|
||||
|-------------|--------------------------------|----------------------------------------|--------------|--------------|------------|----------------------|
|
||||
| ----------- | -------------------------- | ---------------------- | ------------ | ------------ | ---------- | -------------------- |
|
||||
| color | glo.color.orange.0 | #FFECDE | | | | |
|
||||
| | glo.color.orange.10 | #FFD7B8 | | | | |
|
||||
| | glo.color.orange.20 | #FFBF8F | | | | |
|
||||
|
|
@ -210,7 +210,7 @@ The `Global tokens` sheet defines foundational design tokens, including colors,
|
|||
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 |
|
||||
|-----------------|---------------|------------------------------------------|-------------------------------------------------------------------------------------------|---------------------------------------------|
|
||||
| ------------ | ---------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------- | -------------------- |
|
||||
| color | primary | color.primary.normal | $glo.color.magenta.50 | |
|
||||
| | | color.primary.hover | $glo.color.magenta.40 | |
|
||||
| | | color.primary.press | $glo.color.magenta.60 | |
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ NEXT_PUBLIC_DISCORD_CLIENT_ID=your_discord_client_id_here
|
|||
```
|
||||
|
||||
**获取步骤**:
|
||||
|
||||
1. 访问 [Discord Developer Portal](https://discord.com/developers/applications)
|
||||
2. 创建或选择应用
|
||||
3. 在 OAuth2 设置中配置回调 URL:
|
||||
|
|
@ -42,6 +43,7 @@ NEXT_PUBLIC_GOOGLE_CLIENT_ID=your_google_client_id_here
|
|||
```
|
||||
|
||||
**获取步骤**:
|
||||
|
||||
1. 访问 [Google Cloud Console](https://console.cloud.google.com/)
|
||||
2. 创建或选择项目
|
||||
3. 启用 Google+ API
|
||||
|
|
@ -123,6 +125,7 @@ console.log('Google Client ID:', process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID)
|
|||
**问题 1**: 环境变量未生效
|
||||
|
||||
**解决方案**:
|
||||
|
||||
- 重启开发服务器 (`npm run dev`)
|
||||
- 确保文件名正确 (`.env.local`)
|
||||
- 检查变量名拼写
|
||||
|
|
@ -130,6 +133,7 @@ console.log('Google Client ID:', process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID)
|
|||
**问题 2**: OAuth 回调失败
|
||||
|
||||
**解决方案**:
|
||||
|
||||
- 检查 `NEXT_PUBLIC_APP_URL` 是否正确
|
||||
- 确保回调 URL 在 OAuth 提供商处正确配置
|
||||
- 开发环境使用 `http://localhost:3000`,生产环境使用实际域名
|
||||
|
|
@ -137,6 +141,7 @@ console.log('Google Client ID:', process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID)
|
|||
**问题 3**: 客户端 ID 无效
|
||||
|
||||
**解决方案**:
|
||||
|
||||
- 确认复制的是 Client ID 而不是 Client Secret
|
||||
- 检查是否有多余的空格或换行符
|
||||
- 确认 OAuth 应用状态为已发布/激活
|
||||
|
|
@ -170,4 +175,3 @@ console.log('Google Client ID:', process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID)
|
|||
- [Next.js 环境变量文档](https://nextjs.org/docs/app/building-your-application/configuring/environment-variables)
|
||||
- [Discord OAuth 文档](https://discord.com/developers/docs/topics/oauth2)
|
||||
- [Google OAuth 文档](https://developers.google.com/identity/protocols/oauth2)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,17 +7,21 @@
|
|||
## 新旧方式对比
|
||||
|
||||
### 旧方式(OAuth 2.0 重定向流程)
|
||||
|
||||
```
|
||||
用户点击按钮 → 跳转到 Google 授权页面 → 授权后重定向回应用
|
||||
```
|
||||
|
||||
❌ 需要页面跳转
|
||||
❌ 需要配置回调路由
|
||||
❌ 用户体验不连贯
|
||||
|
||||
### 新方式(Google Identity Services)
|
||||
|
||||
```
|
||||
用户点击按钮 → 弹出 Google 授权窗口 → 授权后直接回调
|
||||
```
|
||||
|
||||
✅ 无需页面跳转
|
||||
✅ 无需回调路由
|
||||
✅ 用户体验流畅
|
||||
|
|
@ -50,11 +54,13 @@
|
|||
### 1. Google OAuth 配置 (`src/lib/oauth/google.ts`)
|
||||
|
||||
**主要功能**:
|
||||
|
||||
- 定义 Google Identity Services 的 TypeScript 类型
|
||||
- 提供 SDK 加载方法
|
||||
- 提供 Code Client 初始化方法
|
||||
|
||||
**关键代码**:
|
||||
|
||||
```typescript
|
||||
export const googleOAuth = {
|
||||
// 加载 Google Identity Services SDK
|
||||
|
|
@ -83,15 +89,16 @@ export const googleOAuth = {
|
|||
scope: GOOGLE_SCOPES,
|
||||
ux_mode: 'popup',
|
||||
callback,
|
||||
error_callback: errorCallback
|
||||
error_callback: errorCallback,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### 2. GoogleButton 组件 (`src/app/(auth)/login/components/GoogleButton.tsx`)
|
||||
|
||||
**主要功能**:
|
||||
|
||||
- 加载 Google Identity Services SDK
|
||||
- 初始化 Code Client
|
||||
- 处理授权码回调
|
||||
|
|
@ -100,6 +107,7 @@ export const googleOAuth = {
|
|||
**关键实现**:
|
||||
|
||||
#### SDK 加载
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const loadGoogleSDK = async () => {
|
||||
|
|
@ -108,7 +116,7 @@ useEffect(() => {
|
|||
console.log('Google Identity Services SDK loaded')
|
||||
} catch (error) {
|
||||
console.error('Failed to load Google SDK:', error)
|
||||
toast.error("Failed to load Google login")
|
||||
toast.error('Failed to load Google login')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -117,6 +125,7 @@ useEffect(() => {
|
|||
```
|
||||
|
||||
#### 授权码处理
|
||||
|
||||
```typescript
|
||||
const handleGoogleResponse = async (response: GoogleCodeResponse) => {
|
||||
const deviceId = tokenManager.getDeviceId()
|
||||
|
|
@ -124,22 +133,23 @@ const handleGoogleResponse = async (response: GoogleCodeResponse) => {
|
|||
appClient: AppClient.Web,
|
||||
deviceCode: deviceId,
|
||||
thirdToken: response.code, // Google 授权码
|
||||
thirdType: ThirdType.Google
|
||||
thirdType: ThirdType.Google,
|
||||
}
|
||||
|
||||
login.mutate(loginData, {
|
||||
onSuccess: () => {
|
||||
toast.success("Login successful")
|
||||
toast.success('Login successful')
|
||||
router.push('/')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Login failed")
|
||||
}
|
||||
toast.error('Login failed')
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
#### 登录按钮点击
|
||||
|
||||
```typescript
|
||||
const handleGoogleLogin = async () => {
|
||||
// 确保 SDK 已加载
|
||||
|
|
@ -149,10 +159,7 @@ const handleGoogleLogin = async () => {
|
|||
|
||||
// 初始化 Code Client
|
||||
if (!codeClientRef.current) {
|
||||
codeClientRef.current = googleOAuth.initCodeClient(
|
||||
handleGoogleResponse,
|
||||
handleGoogleError
|
||||
)
|
||||
codeClientRef.current = googleOAuth.initCodeClient(handleGoogleResponse, handleGoogleError)
|
||||
}
|
||||
|
||||
// 请求授权码(弹出授权窗口)
|
||||
|
|
@ -200,6 +207,7 @@ POST /api/auth/login
|
|||
```
|
||||
|
||||
后端需要:
|
||||
|
||||
1. 使用授权码向 Google 交换 access_token
|
||||
2. 使用 access_token 获取用户信息
|
||||
3. 创建或更新用户
|
||||
|
|
@ -208,22 +216,26 @@ POST /api/auth/login
|
|||
## 优势
|
||||
|
||||
### 1. 更好的用户体验
|
||||
|
||||
- ✅ 无需离开当前页面
|
||||
- ✅ 弹窗授权,快速完成
|
||||
- ✅ 不打断用户操作流程
|
||||
|
||||
### 2. 更简单的实现
|
||||
|
||||
- ✅ 不需要回调路由
|
||||
- ✅ 不需要处理 URL 参数
|
||||
- ✅ 不需要 state 验证
|
||||
- ✅ 代码更简洁
|
||||
|
||||
### 3. 更安全
|
||||
|
||||
- ✅ 弹窗隔离,防止钓鱼
|
||||
- ✅ SDK 自动处理安全验证
|
||||
- ✅ 支持 CORS 和 CSP
|
||||
|
||||
### 4. 更现代
|
||||
|
||||
- ✅ Google 官方推荐方式
|
||||
- ✅ 持续维护和更新
|
||||
- ✅ 更好的浏览器兼容性
|
||||
|
|
@ -231,7 +243,7 @@ POST /api/auth/login
|
|||
## 与旧实现的对比
|
||||
|
||||
| 特性 | 旧方式(重定向) | 新方式(GIS) |
|
||||
|------|----------------|--------------|
|
||||
| ------------ | ---------------- | --------------- |
|
||||
| 页面跳转 | ✅ 需要 | ❌ 不需要 |
|
||||
| 回调路由 | ✅ 需要 | ❌ 不需要 |
|
||||
| State 验证 | ✅ 需要手动实现 | ❌ SDK 自动处理 |
|
||||
|
|
@ -243,25 +255,33 @@ POST /api/auth/login
|
|||
## 常见问题
|
||||
|
||||
### Q: SDK 加载失败怎么办?
|
||||
|
||||
A:
|
||||
|
||||
- 检查网络连接
|
||||
- 确认没有被广告拦截器阻止
|
||||
- 检查浏览器控制台错误信息
|
||||
|
||||
### Q: 弹窗被浏览器拦截?
|
||||
|
||||
A:
|
||||
|
||||
- 确保在用户点击事件中调用 `requestCode()`
|
||||
- 不要在异步操作后调用
|
||||
- 检查浏览器弹窗设置
|
||||
|
||||
### Q: 授权后没有回调?
|
||||
|
||||
A:
|
||||
|
||||
- 检查回调函数是否正确绑定
|
||||
- 查看浏览器控制台是否有错误
|
||||
- 确认 Client ID 配置正确
|
||||
|
||||
### Q: 用户取消授权如何处理?
|
||||
|
||||
A:
|
||||
|
||||
```typescript
|
||||
const handleGoogleError = (error: any) => {
|
||||
// 用户取消授权不显示错误提示
|
||||
|
|
@ -269,13 +289,14 @@ const handleGoogleError = (error: any) => {
|
|||
return
|
||||
}
|
||||
|
||||
toast.error("Google login failed")
|
||||
toast.error('Google login failed')
|
||||
}
|
||||
```
|
||||
|
||||
## 测试清单
|
||||
|
||||
### 本地测试
|
||||
|
||||
- [ ] SDK 正常加载
|
||||
- [ ] 点击按钮弹出授权窗口
|
||||
- [ ] 授权后正确回调
|
||||
|
|
@ -285,6 +306,7 @@ const handleGoogleError = (error: any) => {
|
|||
- [ ] 错误情况的处理
|
||||
|
||||
### 生产环境测试
|
||||
|
||||
- [ ] 配置正确的 JavaScript 来源
|
||||
- [ ] HTTPS 证书有效
|
||||
- [ ] 环境变量配置正确
|
||||
|
|
@ -294,6 +316,7 @@ const handleGoogleError = (error: any) => {
|
|||
## 浏览器兼容性
|
||||
|
||||
Google Identity Services 支持:
|
||||
|
||||
- ✅ Chrome 90+
|
||||
- ✅ Firefox 88+
|
||||
- ✅ Safari 14+
|
||||
|
|
@ -302,17 +325,21 @@ Google Identity Services 支持:
|
|||
## 安全注意事项
|
||||
|
||||
### 1. 客户端 ID 保护
|
||||
|
||||
虽然客户端 ID 是公开的,但仍需注意:
|
||||
|
||||
- 限制授权的 JavaScript 来源
|
||||
- 定期检查使用情况
|
||||
- 发现异常及时更换
|
||||
|
||||
### 2. 授权码处理
|
||||
|
||||
- 授权码只能使用一次
|
||||
- 及时传递给后端
|
||||
- 不要在客户端存储
|
||||
|
||||
### 3. HTTPS 要求
|
||||
|
||||
- 生产环境必须使用 HTTPS
|
||||
- 本地开发可以使用 HTTP
|
||||
|
||||
|
|
@ -340,36 +367,39 @@ Google Identity Services 支持:
|
|||
## 扩展功能
|
||||
|
||||
### 1. One Tap 登录
|
||||
|
||||
可以添加 Google One Tap 功能,自动显示登录提示:
|
||||
|
||||
```typescript
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
callback: handleCredentialResponse
|
||||
callback: handleCredentialResponse,
|
||||
})
|
||||
|
||||
window.google.accounts.id.prompt()
|
||||
```
|
||||
|
||||
### 2. 自动登录
|
||||
|
||||
可以实现自动登录功能:
|
||||
|
||||
```typescript
|
||||
window.google.accounts.id.initialize({
|
||||
client_id: GOOGLE_CLIENT_ID,
|
||||
callback: handleCredentialResponse,
|
||||
auto_select: true
|
||||
auto_select: true,
|
||||
})
|
||||
```
|
||||
|
||||
### 3. 自定义按钮样式
|
||||
|
||||
可以使用 Google 提供的标准按钮:
|
||||
|
||||
```typescript
|
||||
window.google.accounts.id.renderButton(
|
||||
document.getElementById('buttonDiv'),
|
||||
{ theme: 'outline', size: 'large' }
|
||||
)
|
||||
window.google.accounts.id.renderButton(document.getElementById('buttonDiv'), {
|
||||
theme: 'outline',
|
||||
size: 'large',
|
||||
})
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
|
@ -388,4 +418,3 @@ window.google.accounts.id.renderButton(
|
|||
✅ **更加安全** - SDK 自动处理安全验证
|
||||
|
||||
强烈建议新项目直接使用这种方式!
|
||||
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ npm run dev
|
|||
## 文件清单
|
||||
|
||||
已创建的文件:
|
||||
|
||||
- ✅ `src/lib/oauth/google.ts` - Google OAuth 配置
|
||||
- ✅ `src/app/(auth)/login/components/GoogleButton.tsx` - Google 登录按钮组件
|
||||
- ✅ `src/app/api/auth/google/callback/route.ts` - OAuth 回调路由
|
||||
|
|
@ -83,6 +84,7 @@ POST /api/auth/login
|
|||
```
|
||||
|
||||
后端需要:
|
||||
|
||||
1. 使用授权码向 Google 交换 access_token
|
||||
2. 使用 access_token 获取用户信息
|
||||
3. 创建或更新用户
|
||||
|
|
@ -91,12 +93,15 @@ POST /api/auth/login
|
|||
## 常见问题
|
||||
|
||||
### Q: 点击按钮后没有跳转?
|
||||
|
||||
A: 检查浏览器控制台是否有错误,确认环境变量已正确配置。
|
||||
|
||||
### Q: 回调后显示错误?
|
||||
|
||||
A: 检查 Google Cloud Console 中的回调 URL 配置是否正确。
|
||||
|
||||
### Q: 登录接口调用失败?
|
||||
|
||||
A: 确认后端接口已实现并支持 Google 登录。
|
||||
|
||||
## 生产环境部署
|
||||
|
|
@ -104,6 +109,7 @@ A: 确认后端接口已实现并支持 Google 登录。
|
|||
### 1. 更新 Google OAuth 配置
|
||||
|
||||
在 Google Cloud Console 添加生产环境回调 URL:
|
||||
|
||||
```
|
||||
https://your-domain.com/api/auth/google/callback
|
||||
```
|
||||
|
|
@ -130,7 +136,7 @@ NEXT_PUBLIC_GOOGLE_CLIENT_ID=生产环境客户端ID
|
|||
## 技术支持
|
||||
|
||||
如有问题,请参考:
|
||||
|
||||
- [完整文档](./GoogleOAuth.md)
|
||||
- [环境变量配置](./EnvironmentVariables.md)
|
||||
- [Google OAuth 官方文档](https://developers.google.com/identity/protocols/oauth2)
|
||||
|
||||
|
|
|
|||
|
|
@ -56,11 +56,12 @@ export const googleOAuth = {
|
|||
getAuthUrl: (state?: string): string => {
|
||||
// 构建 Google OAuth 授权 URL
|
||||
// 包含 client_id, redirect_uri, scope 等参数
|
||||
}
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
**配置参数**:
|
||||
|
||||
- `client_id`: Google OAuth 客户端 ID
|
||||
- `redirect_uri`: 授权后的回调 URL
|
||||
- `scope`: 请求的权限范围(email, profile)
|
||||
|
|
@ -70,6 +71,7 @@ export const googleOAuth = {
|
|||
### 2. GoogleButton 组件 (`src/app/(auth)/login/components/GoogleButton.tsx`)
|
||||
|
||||
**功能**:
|
||||
|
||||
- 处理 Google 登录按钮点击事件
|
||||
- 生成随机 state 用于安全验证
|
||||
- 跳转到 Google 授权页面
|
||||
|
|
@ -78,6 +80,7 @@ export const googleOAuth = {
|
|||
- 处理登录成功/失败的重定向
|
||||
|
||||
**关键方法**:
|
||||
|
||||
```typescript
|
||||
const handleGoogleLogin = () => {
|
||||
// 1. 生成 state
|
||||
|
|
@ -95,6 +98,7 @@ const handleGoogleLogin = () => {
|
|||
```
|
||||
|
||||
**OAuth 回调处理**:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const googleCode = searchParams.get('google_code')
|
||||
|
|
@ -111,6 +115,7 @@ useEffect(() => {
|
|||
### 3. Google 回调路由 (`src/app/api/auth/google/callback/route.ts`)
|
||||
|
||||
**功能**:
|
||||
|
||||
- 接收 Google OAuth 回调
|
||||
- 提取授权码 (code) 和 state
|
||||
- 重定向回登录页面,并将参数传递给前端
|
||||
|
|
@ -165,6 +170,7 @@ interface LoginRequest {
|
|||
```
|
||||
|
||||
后端需要:
|
||||
|
||||
1. 使用授权码向 Google 交换 access_token
|
||||
2. 使用 access_token 获取用户信息
|
||||
3. 创建或更新用户账号
|
||||
|
|
@ -173,22 +179,26 @@ interface LoginRequest {
|
|||
## 安全特性
|
||||
|
||||
### 1. State 参数验证
|
||||
|
||||
- 前端生成随机 state 并保存到 sessionStorage
|
||||
- 回调时验证 state 是否匹配
|
||||
- 防止 CSRF 攻击
|
||||
|
||||
### 2. 授权码模式
|
||||
|
||||
- 使用 OAuth 2.0 授权码流程
|
||||
- 授权码只能使用一次
|
||||
- Token 交换在后端进行,更安全
|
||||
|
||||
### 3. URL 参数清理
|
||||
|
||||
- 登录成功后清理 URL 中的敏感参数
|
||||
- 防止参数泄露
|
||||
|
||||
## 用户体验优化
|
||||
|
||||
### 1. 重定向保持
|
||||
|
||||
```typescript
|
||||
// 保存登录前的页面
|
||||
sessionStorage.setItem('login_redirect_url', redirect || '')
|
||||
|
|
@ -201,17 +211,20 @@ if (loginRedirectUrl) {
|
|||
```
|
||||
|
||||
### 2. 错误处理
|
||||
|
||||
- 授权失败时显示友好的错误提示
|
||||
- 自动清理 URL 参数
|
||||
- 不影响用户继续尝试登录
|
||||
|
||||
### 3. 加载状态
|
||||
|
||||
- 使用 `useLogin` Hook 的 loading 状态
|
||||
- 可以添加 loading 动画提升体验
|
||||
|
||||
## 测试清单
|
||||
|
||||
### 本地测试
|
||||
|
||||
- [ ] 点击 Google 登录按钮跳转到 Google 授权页面
|
||||
- [ ] 授权后正确回调到应用
|
||||
- [ ] 授权码正确传递给后端
|
||||
|
|
@ -220,6 +233,7 @@ if (loginRedirectUrl) {
|
|||
- [ ] 错误情况处理正确
|
||||
|
||||
### 生产环境测试
|
||||
|
||||
- [ ] 配置正确的回调 URL
|
||||
- [ ] HTTPS 证书有效
|
||||
- [ ] 环境变量配置正确
|
||||
|
|
@ -228,25 +242,31 @@ if (loginRedirectUrl) {
|
|||
## 常见问题
|
||||
|
||||
### 1. 回调 URL 不匹配
|
||||
|
||||
**错误**: `redirect_uri_mismatch`
|
||||
|
||||
**解决方案**:
|
||||
|
||||
- 检查 Google Cloud Console 中配置的回调 URL
|
||||
- 确保 `NEXT_PUBLIC_APP_URL` 环境变量正确
|
||||
- 开发环境和生产环境需要分别配置
|
||||
|
||||
### 2. State 验证失败
|
||||
|
||||
**错误**: "Google login failed"
|
||||
|
||||
**解决方案**:
|
||||
|
||||
- 检查 sessionStorage 是否正常工作
|
||||
- 确保没有跨域问题
|
||||
- 检查浏览器是否禁用了 cookie/storage
|
||||
|
||||
### 3. 授权码已使用
|
||||
|
||||
**错误**: 后端返回授权码无效
|
||||
|
||||
**解决方案**:
|
||||
|
||||
- 授权码只能使用一次
|
||||
- 避免重复调用登录接口
|
||||
- 清理 URL 参数防止页面刷新时重复使用
|
||||
|
|
@ -254,7 +274,7 @@ if (loginRedirectUrl) {
|
|||
## 与 Discord 登录的对比
|
||||
|
||||
| 特性 | Discord | Google |
|
||||
|------|---------|--------|
|
||||
| -------------- | -------------------------------- | ------------------------------------ |
|
||||
| OAuth Provider | Discord | Google |
|
||||
| Scopes | identify, email | userinfo.email, userinfo.profile |
|
||||
| 授权 URL | discord.com/api/oauth2/authorize | accounts.google.com/o/oauth2/v2/auth |
|
||||
|
|
@ -265,13 +285,17 @@ if (loginRedirectUrl) {
|
|||
## 扩展建议
|
||||
|
||||
### 1. 添加 Apple 登录
|
||||
|
||||
参考 Google 登录的实现,创建:
|
||||
|
||||
- `src/lib/oauth/apple.ts`
|
||||
- `src/app/(auth)/login/components/AppleButton.tsx`
|
||||
- `src/app/api/auth/apple/callback/route.ts`
|
||||
|
||||
### 2. 统一 OAuth 处理
|
||||
|
||||
可以创建通用的 OAuth Hook:
|
||||
|
||||
```typescript
|
||||
const useOAuthLogin = (provider: 'google' | 'discord' | 'apple') => {
|
||||
// 通用的 OAuth 登录逻辑
|
||||
|
|
@ -279,6 +303,7 @@ const useOAuthLogin = (provider: 'google' | 'discord' | 'apple') => {
|
|||
```
|
||||
|
||||
### 3. 添加登录统计
|
||||
|
||||
记录不同登录方式的使用情况,优化用户体验。
|
||||
|
||||
## 相关文档
|
||||
|
|
@ -286,4 +311,3 @@ const useOAuthLogin = (provider: 'google' | 'discord' | 'apple') => {
|
|||
- [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)
|
||||
- Discord OAuth 实现参考
|
||||
|
||||
|
|
|
|||
|
|
@ -20,22 +20,25 @@
|
|||
### 1. 数据结构
|
||||
|
||||
#### MessageLikeStatus 枚举
|
||||
|
||||
```typescript
|
||||
export enum MessageLikeStatus {
|
||||
None = 'none', // 未点赞/踩
|
||||
Liked = 'liked', // 已点赞
|
||||
Disliked = 'disliked' // 已踩
|
||||
Disliked = 'disliked', // 已踩
|
||||
}
|
||||
```
|
||||
|
||||
#### MessageServerExtension 接口(简化版)
|
||||
|
||||
```typescript
|
||||
export interface MessageServerExtension {
|
||||
[userId: string]: MessageLikeStatus; // 用户ID -> 点赞状态的直接映射
|
||||
[userId: string]: MessageLikeStatus // 用户ID -> 点赞状态的直接映射
|
||||
}
|
||||
```
|
||||
|
||||
#### 工具函数
|
||||
|
||||
```typescript
|
||||
// 解析消息的serverExtension字段
|
||||
export const parseMessageServerExtension = (serverExtension?: string): MessageServerExtension
|
||||
|
|
@ -50,39 +53,40 @@ export const getUserLikeStatus = (message: ExtendedMessage, userId: string): Mes
|
|||
### 2. 核心功能
|
||||
|
||||
#### NimMsgContext 扩展
|
||||
|
||||
在 `NimMsgContext` 中添加了 `updateMessageLikeStatus` 方法,使用 NIM SDK 的 `modifyMessage` API:
|
||||
|
||||
```typescript
|
||||
const updateMessageLikeStatus = useCallback(async (
|
||||
conversationId: string,
|
||||
messageClientId: string,
|
||||
likeStatus: MessageLikeStatus
|
||||
) => {
|
||||
const updateMessageLikeStatus = useCallback(
|
||||
async (conversationId: string, messageClientId: string, likeStatus: MessageLikeStatus) => {
|
||||
// 1. 获取当前登录用户ID
|
||||
const currentUserId = nim.V2NIMLoginService.getLoginUser();
|
||||
const currentUserId = nim.V2NIMLoginService.getLoginUser()
|
||||
|
||||
// 2. 解析当前消息的serverExtension
|
||||
const currentServerExt = parseMessageServerExtension(targetMessage.serverExtension);
|
||||
const currentServerExt = parseMessageServerExtension(targetMessage.serverExtension)
|
||||
|
||||
// 3. 更新用户的点赞状态(简化版)
|
||||
const newServerExt = { ...currentServerExt };
|
||||
const newServerExt = { ...currentServerExt }
|
||||
if (likeStatus === MessageLikeStatus.None) {
|
||||
delete newServerExt[currentUserId]; // 移除点赞状态
|
||||
delete newServerExt[currentUserId] // 移除点赞状态
|
||||
} else {
|
||||
newServerExt[currentUserId] = likeStatus; // 设置新状态
|
||||
newServerExt[currentUserId] = likeStatus // 设置新状态
|
||||
}
|
||||
|
||||
// 4. 调用NIM SDK更新消息
|
||||
const modifyResult = await nim.V2NIMMessageService.modifyMessage(targetMessage, {
|
||||
serverExtension: stringifyMessageServerExtension(newServerExt)
|
||||
});
|
||||
serverExtension: stringifyMessageServerExtension(newServerExt),
|
||||
})
|
||||
|
||||
// 5. 更新本地状态
|
||||
addMsg(conversationId, [modifyResult.message], false);
|
||||
}, [addMsg]);
|
||||
addMsg(conversationId, [modifyResult.message], false)
|
||||
},
|
||||
[addMsg]
|
||||
)
|
||||
```
|
||||
|
||||
#### useMessageLike Hook
|
||||
|
||||
提供便捷的点赞操作方法:
|
||||
|
||||
```typescript
|
||||
|
|
@ -92,12 +96,13 @@ const {
|
|||
cancelLikeMessage, // 取消点赞/踩
|
||||
toggleLike, // 切换点赞状态
|
||||
toggleDislike, // 切换踩状态
|
||||
} = useMessageLike();
|
||||
} = useMessageLike()
|
||||
```
|
||||
|
||||
### 3. UI组件
|
||||
|
||||
#### ChatOtherTextContainer
|
||||
|
||||
AI消息容器组件已集成点赞功能:
|
||||
|
||||
- 鼠标悬停显示操作按钮
|
||||
|
|
@ -153,13 +158,13 @@ const MyComponent = ({ message }: { message: ExtendedMessage }) => {
|
|||
|
||||
```typescript
|
||||
// 直接设置点赞状态
|
||||
await likeMessage(conversationId, messageClientId);
|
||||
await likeMessage(conversationId, messageClientId)
|
||||
|
||||
// 直接设置踩状态
|
||||
await dislikeMessage(conversationId, messageClientId);
|
||||
await dislikeMessage(conversationId, messageClientId)
|
||||
|
||||
// 取消所有状态
|
||||
await cancelLikeMessage(conversationId, messageClientId);
|
||||
await cancelLikeMessage(conversationId, messageClientId)
|
||||
```
|
||||
|
||||
## 状态管理
|
||||
|
|
@ -175,22 +180,24 @@ await cancelLikeMessage(conversationId, messageClientId);
|
|||
## 扩展建议
|
||||
|
||||
### 1. 消息更新监听
|
||||
|
||||
由于使用了 NIM SDK 的 `modifyMessage` API,建议监听消息更新事件:
|
||||
|
||||
```typescript
|
||||
// 监听消息修改事件
|
||||
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) {
|
||||
console.log('消息点赞状态已更新:', message.messageClientId, serverExt);
|
||||
console.log('消息点赞状态已更新:', message.messageClientId, serverExt)
|
||||
}
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
### 2. 错误处理
|
||||
|
||||
为点赞操作添加错误处理和重试机制:
|
||||
|
||||
```typescript
|
||||
|
|
@ -201,30 +208,38 @@ const updateMessageLikeStatusWithRetry = async (
|
|||
retryCount = 3
|
||||
) => {
|
||||
try {
|
||||
await updateMessageLikeStatus(conversationId, messageClientId, likeStatus);
|
||||
await updateMessageLikeStatus(conversationId, messageClientId, likeStatus)
|
||||
} catch (error) {
|
||||
if (retryCount > 0) {
|
||||
console.log(`点赞失败,剩余重试次数: ${retryCount}`, error);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // 等待1秒
|
||||
return updateMessageLikeStatusWithRetry(conversationId, messageClientId, likeStatus, retryCount - 1);
|
||||
console.log(`点赞失败,剩余重试次数: ${retryCount}`, error)
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000)) // 等待1秒
|
||||
return updateMessageLikeStatusWithRetry(
|
||||
conversationId,
|
||||
messageClientId,
|
||||
likeStatus,
|
||||
retryCount - 1
|
||||
)
|
||||
} else {
|
||||
throw error;
|
||||
throw error
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 批量操作
|
||||
|
||||
对于大量消息的点赞状态批量更新:
|
||||
|
||||
```typescript
|
||||
const batchUpdateLikes = (updates: Array<{
|
||||
conversationId: string;
|
||||
messageClientId: string;
|
||||
likeStatus: MessageLikeStatus;
|
||||
}>) => {
|
||||
const batchUpdateLikes = (
|
||||
updates: Array<{
|
||||
conversationId: string
|
||||
messageClientId: string
|
||||
likeStatus: MessageLikeStatus
|
||||
}>
|
||||
) => {
|
||||
// 批量更新逻辑
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## 注意事项
|
||||
|
|
|
|||
|
|
@ -11,16 +11,15 @@
|
|||
**文件位置**: `src/app/(main)/home/components/StartChat/StartChatItem.tsx`
|
||||
|
||||
**功能**:
|
||||
|
||||
- 对话建议列表中的每一项都是一个链接
|
||||
- 点击时跳转到聊天页面,并将建议文本作为 URL 参数传递
|
||||
- 使用 `encodeURIComponent()` 对文本进行编码,确保特殊字符正确传递
|
||||
|
||||
**示例**:
|
||||
|
||||
```tsx
|
||||
<Link
|
||||
href={`/chat/${character.aiId}?text=${encodeURIComponent(suggestion)}`}
|
||||
className="..."
|
||||
>
|
||||
<Link href={`/chat/${character.aiId}?text=${encodeURIComponent(suggestion)}`} className="...">
|
||||
<span>{suggestion}</span>
|
||||
</Link>
|
||||
```
|
||||
|
|
@ -30,36 +29,42 @@
|
|||
**文件位置**: `src/app/(main)/chat/[aiId]/components/ChatMessageAction/ChatInput.tsx`
|
||||
|
||||
**功能**:
|
||||
|
||||
- 使用 `useSearchParams()` Hook 获取 URL 参数
|
||||
- 在组件挂载时检查是否有 `text` 参数
|
||||
- 如果有,自动填充到输入框并聚焦
|
||||
|
||||
**实现代码**:
|
||||
|
||||
```tsx
|
||||
const searchParams = useSearchParams();
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
useEffect(() => {
|
||||
const textFromUrl = searchParams.get('text');
|
||||
const textFromUrl = searchParams.get('text')
|
||||
if (textFromUrl) {
|
||||
setMessage(textFromUrl);
|
||||
setMessage(textFromUrl)
|
||||
// 聚焦到输入框
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.focus()
|
||||
}
|
||||
}
|
||||
}, [searchParams]);
|
||||
}, [searchParams])
|
||||
```
|
||||
|
||||
## 使用场景
|
||||
|
||||
### 场景 1: 对话建议快捷回复
|
||||
|
||||
用户在首页看到 AI 角色的对话建议,点击后:
|
||||
|
||||
1. 跳转到聊天页面
|
||||
2. 建议文本自动填充到输入框
|
||||
3. 用户可以直接发送或修改后发送
|
||||
|
||||
### 场景 2: 外部链接跳转
|
||||
|
||||
可以通过外部链接直接跳转到聊天页面并预填充文本:
|
||||
|
||||
```
|
||||
https://your-domain.com/chat/123?text=Hello%20there!
|
||||
```
|
||||
|
|
@ -85,13 +90,15 @@ https://your-domain.com/chat/123?text=Hello%20there!
|
|||
### 可能的增强功能:
|
||||
|
||||
1. **清除 URL 参数**: 填充文本后清除 URL 中的 `text` 参数,避免刷新页面时重复填充
|
||||
|
||||
```tsx
|
||||
const router = useRouter();
|
||||
const router = useRouter()
|
||||
// 填充后清除参数
|
||||
router.replace(`/chat/${aiId}`, { scroll: false });
|
||||
router.replace(`/chat/${aiId}`, { scroll: false })
|
||||
```
|
||||
|
||||
2. **支持多个参数**: 可以扩展支持其他参数,如 `image`、`voice` 等
|
||||
|
||||
```
|
||||
/chat/123?text=Hello&image=https://...
|
||||
```
|
||||
|
|
@ -99,7 +106,7 @@ https://your-domain.com/chat/123?text=Hello%20there!
|
|||
3. **参数验证**: 添加文本长度限制和内容验证
|
||||
```tsx
|
||||
if (textFromUrl && textFromUrl.length <= 1000) {
|
||||
setMessage(textFromUrl);
|
||||
setMessage(textFromUrl)
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -108,4 +115,3 @@ https://your-domain.com/chat/123?text=Hello%20there!
|
|||
- `src/app/(main)/home/components/StartChat/StartChatItem.tsx` - 发起跳转的组件
|
||||
- `src/app/(main)/chat/[aiId]/components/ChatMessageAction/ChatInput.tsx` - 接收参数的组件
|
||||
- `src/app/(main)/home/context/AudioPlayerContext.tsx` - 音频播放上下文(相关功能)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,16 +7,19 @@
|
|||
## 核心功能
|
||||
|
||||
### 1. 智能缓存
|
||||
|
||||
- 基于配置参数生成唯一哈希值作为缓存键
|
||||
- 相同配置的语音只生成一次,存储在内存中
|
||||
- 支持手动清除缓存
|
||||
|
||||
### 2. 参数映射
|
||||
|
||||
- `tone` (音调) → `loudnessRate` (音量)
|
||||
- `speed` (语速) → `speechRate` (语速)
|
||||
- 参数范围:[-50, 100]
|
||||
|
||||
### 3. 播放逻辑
|
||||
|
||||
1. **优先级**:TTS 生成的语音 > 预设语音文件
|
||||
2. **错误回退**:TTS 失败时自动使用预设语音
|
||||
3. **状态管理**:生成中、播放中、已缓存等状态
|
||||
|
|
@ -56,14 +59,11 @@ function MyComponent() {
|
|||
text: '你好,这是测试语音',
|
||||
voiceType: 'S_zh_xiaoxiao_emotion',
|
||||
speechRate: 0,
|
||||
loudnessRate: 0
|
||||
loudnessRate: 0,
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => generateAndPlay(config)}
|
||||
disabled={isGenerating}
|
||||
>
|
||||
<button onClick={() => generateAndPlay(config)} disabled={isGenerating}>
|
||||
{isGenerating ? '生成中...' : isPlaying(config) ? '播放中' : '播放'}
|
||||
</button>
|
||||
)
|
||||
|
|
@ -79,7 +79,7 @@ function CreateForm() {
|
|||
const [voiceConfig, setVoiceConfig] = useState({
|
||||
tone: 0, // 音调 [-50, 100]
|
||||
speed: 0, // 语速 [-50, 100]
|
||||
content: 'voice_id' // 语音类型ID
|
||||
content: 'voice_id', // 语音类型ID
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
# 项目概述
|
||||
|
||||
这是一个使用 Next.js App Router 的 Web 应用.
|
||||
|
||||
crushlevel-next/
|
||||
|
|
@ -56,11 +57,11 @@ crushlevel-next/
|
|||
├── docs # 文档
|
||||
└── README.md
|
||||
|
||||
|
||||
## UI库
|
||||
|
||||
使用Shadcn/U作为UI的基础组件,结合tailwindcss实现。
|
||||
token都存放在global.css中。
|
||||
|
||||
## 组件库
|
||||
基础组件库components/ui
|
||||
|
||||
基础组件库components/ui
|
||||
|
|
|
|||
|
|
@ -1,13 +1,26 @@
|
|||
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTypescript from "eslint-config-next/typescript";
|
||||
import { dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { FlatCompat } from '@eslint/eslintrc'
|
||||
import prettier from 'eslint-config-prettier'
|
||||
import importQuotes from 'eslint-plugin-import-quotes'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const eslintConfig = [...nextCoreWebVitals, ...nextTypescript, {
|
||||
ignores: ["node_modules/**", ".next/**", "out/**", "build/**", "next-env.d.ts"]
|
||||
}];
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
})
|
||||
|
||||
export default eslintConfig;
|
||||
const eslintConfig = [
|
||||
...compat.extends('next/core-web-vitals', 'next/typescript'),
|
||||
prettier,
|
||||
{
|
||||
plugins: { 'import-quotes': importQuotes },
|
||||
rules: {
|
||||
// 👇 import 使用双引号
|
||||
'import-quotes/import-quotes': ['error', 'double'],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
export default eslintConfig
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ module.exports = {
|
|||
// 禁用默认的 i18next 函数扫描,因为项目还没有使用 i18next
|
||||
func: {
|
||||
list: [],
|
||||
extensions: []
|
||||
extensions: [],
|
||||
},
|
||||
trans: {
|
||||
component: 'Trans',
|
||||
|
|
@ -24,36 +24,36 @@ module.exports = {
|
|||
acorn: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
plugins: ['typescript', 'jsx']
|
||||
plugins: ['typescript', 'jsx'],
|
||||
},
|
||||
fallbackKey: function (ns, value) {
|
||||
return value
|
||||
},
|
||||
fallbackKey: function(ns, value) {
|
||||
return value;
|
||||
}
|
||||
},
|
||||
lngs: ['en'],
|
||||
defaultLng: 'en',
|
||||
defaultNs: 'translation',
|
||||
defaultValue: function(lng, ns, key) {
|
||||
return key;
|
||||
defaultValue: function (lng, ns, key) {
|
||||
return key
|
||||
},
|
||||
resource: {
|
||||
loadPath: 'public/locales/{{lng}}/{{ns}}.json',
|
||||
savePath: 'public/locales/{{lng}}/{{ns}}.json',
|
||||
jsonIndent: 2,
|
||||
lineEnding: '\n'
|
||||
lineEnding: '\n',
|
||||
},
|
||||
nsSeparator: ':',
|
||||
keySeparator: '.',
|
||||
interpolation: {
|
||||
prefix: '{{',
|
||||
suffix: '}}'
|
||||
suffix: '}}',
|
||||
},
|
||||
// 自定义提取规则,用于扫描未使用 i18next 的文本
|
||||
customTransComponents: [
|
||||
{
|
||||
name: 'Trans',
|
||||
props: ['i18nKey', 'defaults']
|
||||
}
|
||||
props: ['i18nKey', 'defaults'],
|
||||
},
|
||||
],
|
||||
// 扫描 JSX 文本和属性
|
||||
detect: {
|
||||
|
|
@ -64,7 +64,7 @@ module.exports = {
|
|||
// 扫描函数调用中的字符串
|
||||
func: ['toast', 'alert', 'confirm', 'message', 'console.log', 'console.error'],
|
||||
// 扫描对象字面量中的 message 属性
|
||||
object: ['message', 'error', 'warning', 'success']
|
||||
}
|
||||
}
|
||||
};
|
||||
object: ['message', 'error', 'warning', 'success'],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { NextConfig } from "next";
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
|
|
@ -8,27 +8,27 @@ const nextConfig: NextConfig = {
|
|||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "picsum.photos",
|
||||
protocol: 'https',
|
||||
hostname: 'picsum.photos',
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "public-pictures.epal.gg",
|
||||
protocol: 'https',
|
||||
hostname: 'public-pictures.epal.gg',
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "hhb.crushlevel.ai",
|
||||
protocol: 'https',
|
||||
hostname: 'hhb.crushlevel.ai',
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "sub.crushlevel.ai",
|
||||
protocol: 'https',
|
||||
hostname: 'sub.crushlevel.ai',
|
||||
},
|
||||
{
|
||||
protocol: "https",
|
||||
hostname: "img.crushlevel.ai",
|
||||
}
|
||||
protocol: 'https',
|
||||
hostname: 'img.crushlevel.ai',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default nextConfig;
|
||||
export default nextConfig
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
34
package.json
34
package.json
|
|
@ -3,15 +3,14 @@
|
|||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "next lint",
|
||||
"i18n:scan": "i18next-scanner",
|
||||
"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": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
|
|
@ -51,13 +50,13 @@
|
|||
"keen-slider": "^6.8.6",
|
||||
"lamejs": "^1.2.1",
|
||||
"lucide-react": "^0.525.0",
|
||||
"next": "16.0.3",
|
||||
"next": "15.3.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"nim-web-sdk-ng": "^10.9.41",
|
||||
"numeral": "^2.0.6",
|
||||
"qs": "^6.14.0",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-easy-crop": "^5.5.0",
|
||||
"react-hook-form": "^7.60.0",
|
||||
"react-photo-view": "^1.2.7",
|
||||
|
|
@ -69,24 +68,27 @@
|
|||
"zod": "^4.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/node": "^20",
|
||||
"@types/numeral": "^2.0.5",
|
||||
"@types/qs": "^6.14.0",
|
||||
"@types/react": "19.2.5",
|
||||
"@types/react-dom": "19.2.3",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-jsx": "^5.3.2",
|
||||
"acorn-typescript": "^1.4.13",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.3",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-config-next": "15.3.5",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-import-quotes": "^0.0.1",
|
||||
"eslint-plugin-prettier": "^5.5.4",
|
||||
"globby": "^15.0.0",
|
||||
"i18next-scanner": "^4.6.0",
|
||||
"msw": "^2.10.4",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-tailwindcss": "^0.6.11",
|
||||
"prettier": "^3.7.1",
|
||||
"prettier-plugin-tailwindcss": "^0.7.1",
|
||||
"tailwindcss": "^4",
|
||||
"ts-morph": "^27.0.2",
|
||||
"ts-node": "^10.9.2",
|
||||
|
|
@ -99,9 +101,5 @@
|
|||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"@types/react": "19.2.5",
|
||||
"@types/react-dom": "19.2.3"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
8692
pnpm-lock.yaml
8692
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -1,5 +1,5 @@
|
|||
const config = {
|
||||
plugins: ["@tailwindcss/postcss"],
|
||||
};
|
||||
plugins: ['@tailwindcss/postcss'],
|
||||
}
|
||||
|
||||
export default config;
|
||||
export default config
|
||||
|
|
|
|||
|
|
@ -0,0 +1 @@
|
|||
google.com, pub-6468790746781495, DIRECT, f08c47fec0942fa0
|
||||
|
|
@ -1,15 +1,18 @@
|
|||
/* Logo 字体 */
|
||||
@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#iefix') format('embedded-opentype'),
|
||||
src:
|
||||
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.eot?t=1545807318834#iefix')
|
||||
format('embedded-opentype'),
|
||||
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.woff?t=1545807318834') format('woff'),
|
||||
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.ttf?t=1545807318834') format('truetype'),
|
||||
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.svg?t=1545807318834#iconfont') format('svg');
|
||||
url('https://at.alicdn.com/t/font_985780_km7mi63cihi.svg?t=1545807318834#iconfont')
|
||||
format('svg');
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-family: "iconfont logo";
|
||||
font-family: 'iconfont logo';
|
||||
font-size: 160px;
|
||||
font-style: normal;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
|
|
@ -48,7 +51,6 @@
|
|||
color: #666;
|
||||
}
|
||||
|
||||
|
||||
#tabs .active {
|
||||
border-bottom-color: #f00;
|
||||
color: #222;
|
||||
|
|
@ -119,9 +121,15 @@
|
|||
font-size: 42px;
|
||||
margin: 10px auto;
|
||||
color: #333;
|
||||
-webkit-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;
|
||||
-webkit-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 {
|
||||
|
|
@ -215,35 +223,35 @@
|
|||
margin: 1em 0;
|
||||
}
|
||||
|
||||
.markdown>p,
|
||||
.markdown>blockquote,
|
||||
.markdown>.highlight,
|
||||
.markdown>ol,
|
||||
.markdown>ul {
|
||||
.markdown > p,
|
||||
.markdown > blockquote,
|
||||
.markdown > .highlight,
|
||||
.markdown > ol,
|
||||
.markdown > ul {
|
||||
width: 80%;
|
||||
}
|
||||
|
||||
.markdown ul>li {
|
||||
.markdown ul > li {
|
||||
list-style: circle;
|
||||
}
|
||||
|
||||
.markdown>ul li,
|
||||
.markdown blockquote ul>li {
|
||||
.markdown > ul li,
|
||||
.markdown blockquote ul > li {
|
||||
margin-left: 20px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.markdown>ul li p,
|
||||
.markdown>ol li p {
|
||||
.markdown > ul li p,
|
||||
.markdown > ol li p {
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
|
||||
.markdown ol>li {
|
||||
.markdown ol > li {
|
||||
list-style: decimal;
|
||||
}
|
||||
|
||||
.markdown>ol li,
|
||||
.markdown blockquote ol>li {
|
||||
.markdown > ol li,
|
||||
.markdown blockquote ol > li {
|
||||
margin-left: 20px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
|
@ -260,7 +268,7 @@
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown>table {
|
||||
.markdown > table {
|
||||
border-collapse: collapse;
|
||||
border-spacing: 0px;
|
||||
empty-cells: show;
|
||||
|
|
@ -269,21 +277,21 @@
|
|||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.markdown>table th {
|
||||
.markdown > table th {
|
||||
white-space: nowrap;
|
||||
color: #333;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.markdown>table th,
|
||||
.markdown>table td {
|
||||
.markdown > table th,
|
||||
.markdown > table td {
|
||||
border: 1px solid #e9e9e9;
|
||||
padding: 8px 16px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown>table th {
|
||||
background: #F7F7F7;
|
||||
.markdown > table th {
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.markdown blockquote {
|
||||
|
|
@ -318,12 +326,11 @@
|
|||
display: inline-block;
|
||||
}
|
||||
|
||||
.markdown>br,
|
||||
.markdown>p>br {
|
||||
.markdown > br,
|
||||
.markdown > p > br {
|
||||
clear: both;
|
||||
}
|
||||
|
||||
|
||||
.hljs {
|
||||
display: block;
|
||||
background: white;
|
||||
|
|
@ -399,8 +406,8 @@ https://prismjs.com/download.html#themes=prism&languages=markup+css+clike+javasc
|
|||
* Based on dabblet (http://dabblet.com)
|
||||
* @author Lea Verou
|
||||
*/
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
code[class*='language-'],
|
||||
pre[class*='language-'] {
|
||||
color: black;
|
||||
background: none;
|
||||
text-shadow: 0 1px white;
|
||||
|
|
@ -422,46 +429,45 @@ pre[class*="language-"] {
|
|||
hyphens: none;
|
||||
}
|
||||
|
||||
pre[class*="language-"]::-moz-selection,
|
||||
pre[class*="language-"] ::-moz-selection,
|
||||
code[class*="language-"]::-moz-selection,
|
||||
code[class*="language-"] ::-moz-selection {
|
||||
pre[class*='language-']::-moz-selection,
|
||||
pre[class*='language-'] ::-moz-selection,
|
||||
code[class*='language-']::-moz-selection,
|
||||
code[class*='language-'] ::-moz-selection {
|
||||
text-shadow: none;
|
||||
background: #b3d4fc;
|
||||
}
|
||||
|
||||
pre[class*="language-"]::selection,
|
||||
pre[class*="language-"] ::selection,
|
||||
code[class*="language-"]::selection,
|
||||
code[class*="language-"] ::selection {
|
||||
pre[class*='language-']::selection,
|
||||
pre[class*='language-'] ::selection,
|
||||
code[class*='language-']::selection,
|
||||
code[class*='language-'] ::selection {
|
||||
text-shadow: none;
|
||||
background: #b3d4fc;
|
||||
}
|
||||
|
||||
@media print {
|
||||
|
||||
code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
code[class*='language-'],
|
||||
pre[class*='language-'] {
|
||||
text-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Code blocks */
|
||||
pre[class*="language-"] {
|
||||
pre[class*='language-'] {
|
||||
padding: 1em;
|
||||
margin: .5em 0;
|
||||
margin: 0.5em 0;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
:not(pre)>code[class*="language-"],
|
||||
pre[class*="language-"] {
|
||||
:not(pre) > code[class*='language-'],
|
||||
pre[class*='language-'] {
|
||||
background: #f5f2f0;
|
||||
}
|
||||
|
||||
/* Inline code */
|
||||
:not(pre)>code[class*="language-"] {
|
||||
padding: .1em;
|
||||
border-radius: .3em;
|
||||
:not(pre) > code[class*='language-'] {
|
||||
padding: 0.1em;
|
||||
border-radius: 0.3em;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
|
|
@ -477,7 +483,7 @@ pre[class*="language-"] {
|
|||
}
|
||||
|
||||
.namespace {
|
||||
opacity: .7;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.token.property,
|
||||
|
|
@ -505,7 +511,7 @@ pre[class*="language-"] {
|
|||
.language-css .token.string,
|
||||
.style .token.string {
|
||||
color: #9a6e3a;
|
||||
background: hsla(0, 0%, 100%, .5);
|
||||
background: hsla(0, 0%, 100%, 0.5);
|
||||
}
|
||||
|
||||
.token.atrule,
|
||||
|
|
@ -516,7 +522,7 @@ pre[class*="language-"] {
|
|||
|
||||
.token.function,
|
||||
.token.class-name {
|
||||
color: #DD4A68;
|
||||
color: #dd4a68;
|
||||
}
|
||||
|
||||
.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
|
|
@ -101,10 +101,7 @@ addEventListener('fetch', function (event) {
|
|||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// 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
|
||||
}
|
||||
|
||||
|
|
@ -156,7 +153,7 @@ async function handleRequest(event, requestId) {
|
|||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : []
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -220,9 +217,7 @@ async function getResponse(event, client, requestId) {
|
|||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
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) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
|
|
@ -258,7 +253,7 @@ async function getResponse(event, client, requestId) {
|
|||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
[serializedRequest.body]
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
|
|
@ -292,10 +287,7 @@ function sendToClient(client, message, transferrables = []) {
|
|||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
client.postMessage(message, [channel.port2, ...transferrables.filter(Boolean)])
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,41 +5,49 @@
|
|||
✅ **成功完成文案翻译覆盖任务**
|
||||
|
||||
### 统计数据
|
||||
|
||||
- **总翻译条目**: 378 条(去重后)
|
||||
- **成功替换**: 334 条
|
||||
- **成功率**: 88.4%
|
||||
- **剩余冲突**: 44 条
|
||||
|
||||
### 冲突分析
|
||||
|
||||
- **文本未找到**: 24 条 - 主要是包含特殊字符(如 emoji)的文本
|
||||
- **多处匹配**: 20 条 - 同一文本在文件中出现多次,需要人工确认
|
||||
|
||||
## 实现的功能
|
||||
|
||||
### 1. 智能文案替换
|
||||
|
||||
- 基于 `ts-morph` AST 解析,精确定位不同类型的文案
|
||||
- 支持 JSX 文本、属性值、函数参数等多种文案类型
|
||||
- 保持代码格式和缩进不变
|
||||
|
||||
### 2. 冲突检测机制
|
||||
|
||||
- 自动检测文件不存在、文本未找到、多处匹配等冲突
|
||||
- 生成详细的冲突报告,便于人工处理
|
||||
|
||||
### 3. 去重处理
|
||||
|
||||
- 自动去除翻译数据中的重复条目
|
||||
- 避免重复替换导致的错误
|
||||
|
||||
### 4. 报告生成
|
||||
|
||||
- 成功替换报告:`scripts/translation-report.json`
|
||||
- 冲突报告:`scripts/translation-conflicts.xlsx`
|
||||
|
||||
## 使用的脚本
|
||||
|
||||
### 主要脚本
|
||||
|
||||
- `scripts/apply-translations.cjs` - 基础翻译应用脚本
|
||||
- `scripts/reset-and-apply-translations.cjs` - 重置并应用翻译脚本(推荐使用)
|
||||
|
||||
### 使用方法
|
||||
|
||||
```bash
|
||||
# 重置文件并应用翻译(推荐)
|
||||
node scripts/reset-and-apply-translations.cjs
|
||||
|
|
@ -51,10 +59,12 @@ node scripts/apply-translations.cjs
|
|||
## 处理建议
|
||||
|
||||
### 对于剩余冲突
|
||||
|
||||
1. **文本未找到的条目**:检查是否包含特殊字符或格式问题
|
||||
2. **多处匹配的条目**:需要人工确认具体替换哪个位置
|
||||
|
||||
### 后续优化
|
||||
|
||||
1. 可以针对特殊字符(emoji)的匹配进行优化
|
||||
2. 可以添加更智能的多处匹配处理逻辑
|
||||
3. 可以添加翻译质量验证机制
|
||||
|
|
@ -62,6 +72,7 @@ node scripts/apply-translations.cjs
|
|||
## 文件变更
|
||||
|
||||
所有成功替换的文案已直接修改到源代码文件中,包括:
|
||||
|
||||
- React 组件中的 JSX 文本
|
||||
- 属性值(title、placeholder、alt 等)
|
||||
- 函数调用中的字符串参数
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
/*
|
||||
CommonJS runtime for applying translations from Excel to source code.
|
||||
*/
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const { Project, SyntaxKind, Node } = require('ts-morph');
|
||||
const XLSX = require('xlsx');
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
const { Project, SyntaxKind, Node } = require('ts-morph')
|
||||
const XLSX = require('xlsx')
|
||||
|
||||
const WORKDIR = process.cwd();
|
||||
const TRANSLATES_FILE = path.join(WORKDIR, 'scripts', 'translates.xlsx');
|
||||
const REPORT_FILE = path.join(WORKDIR, 'scripts', 'translation-report.json');
|
||||
const CONFLICTS_FILE = path.join(WORKDIR, 'scripts', 'translation-conflicts.xlsx');
|
||||
const WORKDIR = process.cwd()
|
||||
const TRANSLATES_FILE = path.join(WORKDIR, 'scripts', 'translates.xlsx')
|
||||
const REPORT_FILE = path.join(WORKDIR, 'scripts', 'translation-report.json')
|
||||
const CONFLICTS_FILE = path.join(WORKDIR, 'scripts', 'translation-conflicts.xlsx')
|
||||
|
||||
// 统计信息
|
||||
const stats = {
|
||||
|
|
@ -18,309 +18,306 @@ const stats = {
|
|||
conflicts: 0,
|
||||
fileNotFound: 0,
|
||||
textNotFound: 0,
|
||||
multipleMatches: 0
|
||||
};
|
||||
multipleMatches: 0,
|
||||
}
|
||||
|
||||
// 冲突列表
|
||||
const conflicts = [];
|
||||
const conflicts = []
|
||||
|
||||
// 成功替换列表
|
||||
const successfulReplacements = [];
|
||||
const successfulReplacements = []
|
||||
|
||||
function loadTranslations() {
|
||||
console.log('📖 读取翻译数据...');
|
||||
const wb = XLSX.readFile(TRANSLATES_FILE);
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const data = XLSX.utils.sheet_to_json(ws, { defval: '' });
|
||||
console.log('📖 读取翻译数据...')
|
||||
const wb = XLSX.readFile(TRANSLATES_FILE)
|
||||
const ws = wb.Sheets[wb.SheetNames[0]]
|
||||
const data = XLSX.utils.sheet_to_json(ws, { defval: '' })
|
||||
|
||||
// 筛选出需要替换的条目
|
||||
let translations = data.filter(row =>
|
||||
row.text &&
|
||||
row.corrected_text &&
|
||||
row.text !== row.corrected_text
|
||||
);
|
||||
let translations = data.filter(
|
||||
(row) => row.text && row.corrected_text && row.text !== row.corrected_text
|
||||
)
|
||||
|
||||
// 去重:按 file + line + text 去重,保留第一个
|
||||
const seen = new Set();
|
||||
translations = translations.filter(row => {
|
||||
const key = `${row.file}:${row.line}:${row.text}`;
|
||||
const seen = new Set()
|
||||
translations = translations.filter((row) => {
|
||||
const key = `${row.file}:${row.line}:${row.text}`
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
console.log(`📊 找到 ${translations.length} 条需要替换的翻译(已去重)`);
|
||||
stats.total = translations.length;
|
||||
console.log(`📊 找到 ${translations.length} 条需要替换的翻译(已去重)`)
|
||||
stats.total = translations.length
|
||||
|
||||
return translations;
|
||||
return translations
|
||||
}
|
||||
|
||||
function groupByFile(translations) {
|
||||
const groups = new Map();
|
||||
const groups = new Map()
|
||||
for (const translation of translations) {
|
||||
const filePath = path.join(WORKDIR, translation.file);
|
||||
const filePath = path.join(WORKDIR, translation.file)
|
||||
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) {
|
||||
if (!node) return null;
|
||||
if (!node) return null
|
||||
|
||||
// 处理 JSX 文本节点
|
||||
if (Node.isJsxText(node)) {
|
||||
const text = node.getText().replace(/\s+/g, ' ').trim();
|
||||
if (text === targetText) return node;
|
||||
const text = node.getText().replace(/\s+/g, ' ').trim()
|
||||
if (text === targetText) return node
|
||||
}
|
||||
|
||||
// 处理字符串字面量
|
||||
if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
|
||||
const text = node.getLiteralText();
|
||||
if (text === targetText) return node;
|
||||
const text = node.getLiteralText()
|
||||
if (text === targetText) return node
|
||||
}
|
||||
|
||||
// 处理 JSX 表达式中的字符串
|
||||
if (Node.isJsxExpression(node)) {
|
||||
const expr = node.getExpression();
|
||||
const expr = node.getExpression()
|
||||
if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) {
|
||||
const text = expr.getLiteralText();
|
||||
if (text === targetText) return node;
|
||||
const text = expr.getLiteralText()
|
||||
if (text === targetText) return node
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
function findTextInFile(sourceFile, translation) {
|
||||
const { text, line, kind } = translation;
|
||||
const matches = [];
|
||||
const { text, line, kind } = translation
|
||||
const matches = []
|
||||
|
||||
sourceFile.forEachDescendant((node) => {
|
||||
// 根据 kind 类型进行不同的匹配
|
||||
if (kind === 'text') {
|
||||
// 查找 JSX 文本节点
|
||||
if (Node.isJsxText(node)) {
|
||||
const nodeText = node.getText().replace(/\s+/g, ' ').trim();
|
||||
const nodeText = node.getText().replace(/\s+/g, ' ').trim()
|
||||
if (nodeText === text) {
|
||||
matches.push({ node, type: 'jsx-text', line: node.getStartLineNumber() });
|
||||
matches.push({ node, type: 'jsx-text', line: node.getStartLineNumber() })
|
||||
}
|
||||
}
|
||||
// 查找 JSX 表达式中的字符串
|
||||
if (Node.isJsxExpression(node)) {
|
||||
const expr = node.getExpression();
|
||||
const expr = node.getExpression()
|
||||
if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) {
|
||||
const nodeText = expr.getLiteralText();
|
||||
const nodeText = expr.getLiteralText()
|
||||
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)) {
|
||||
// 查找 JSX 属性
|
||||
if (Node.isJsxAttribute(node)) {
|
||||
const name = node.getNameNode().getText().toLowerCase();
|
||||
const value = getStringFromInitializer(node);
|
||||
const name = node.getNameNode().getText().toLowerCase()
|
||||
const value = getStringFromInitializer(node)
|
||||
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)) {
|
||||
// 查找函数调用中的字符串参数
|
||||
if (Node.isCallExpression(node)) {
|
||||
const args = node.getArguments();
|
||||
const args = node.getArguments()
|
||||
for (const arg of args) {
|
||||
if (Node.isStringLiteral(arg) || Node.isNoSubstitutionTemplateLiteral(arg)) {
|
||||
const nodeText = arg.getLiteralText();
|
||||
const nodeText = arg.getLiteralText()
|
||||
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) {
|
||||
const init = attr.getInitializer();
|
||||
if (!init) return undefined;
|
||||
const init = attr.getInitializer()
|
||||
if (!init) return undefined
|
||||
if (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init)) {
|
||||
return init.getLiteralText();
|
||||
return init.getLiteralText()
|
||||
}
|
||||
if (Node.isJsxExpression(init)) {
|
||||
const expr = init.getExpression();
|
||||
if (!expr) return undefined;
|
||||
const expr = init.getExpression()
|
||||
if (!expr) return undefined
|
||||
if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) {
|
||||
return expr.getLiteralText();
|
||||
return expr.getLiteralText()
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
|
||||
function replaceText(node, newText, type) {
|
||||
try {
|
||||
if (type === 'jsx-text') {
|
||||
// JSX 文本节点需要特殊处理,保持空白字符
|
||||
const originalText = node.getText();
|
||||
const newTextWithWhitespace = originalText.replace(/\S+/g, newText);
|
||||
node.replaceWithText(newTextWithWhitespace);
|
||||
const originalText = node.getText()
|
||||
const newTextWithWhitespace = originalText.replace(/\S+/g, newText)
|
||||
node.replaceWithText(newTextWithWhitespace)
|
||||
} else if (type === 'jsx-expression' || type === 'function-arg') {
|
||||
// 字符串字面量
|
||||
if (Node.isStringLiteral(node)) {
|
||||
node.replaceWithText(`"${newText}"`);
|
||||
node.replaceWithText(`"${newText}"`)
|
||||
} else if (Node.isNoSubstitutionTemplateLiteral(node)) {
|
||||
node.replaceWithText(`\`${newText}\``);
|
||||
node.replaceWithText(`\`${newText}\``)
|
||||
}
|
||||
} else if (type === 'jsx-attribute') {
|
||||
// JSX 属性值
|
||||
const init = node.getInitializer();
|
||||
const init = node.getInitializer()
|
||||
if (init) {
|
||||
if (Node.isStringLiteral(init)) {
|
||||
init.replaceWithText(`"${newText}"`);
|
||||
init.replaceWithText(`"${newText}"`)
|
||||
} else if (Node.isNoSubstitutionTemplateLiteral(init)) {
|
||||
init.replaceWithText(`\`${newText}\``);
|
||||
init.replaceWithText(`\`${newText}\``)
|
||||
} else if (Node.isJsxExpression(init)) {
|
||||
const expr = init.getExpression();
|
||||
const expr = init.getExpression()
|
||||
if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) {
|
||||
if (Node.isStringLiteral(expr)) {
|
||||
expr.replaceWithText(`"${newText}"`);
|
||||
expr.replaceWithText(`"${newText}"`)
|
||||
} else {
|
||||
expr.replaceWithText(`\`${newText}\``);
|
||||
expr.replaceWithText(`\`${newText}\``)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(`❌ 替换失败: ${error.message}`);
|
||||
return false;
|
||||
console.error(`❌ 替换失败: ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function processFile(filePath, translations) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(`❌ 文件不存在: ${path.relative(WORKDIR, filePath)}`);
|
||||
translations.forEach(t => {
|
||||
console.log(`❌ 文件不存在: ${path.relative(WORKDIR, filePath)}`)
|
||||
translations.forEach((t) => {
|
||||
conflicts.push({
|
||||
...t,
|
||||
conflictType: 'FILE_NOT_FOUND',
|
||||
conflictReason: '文件不存在'
|
||||
});
|
||||
});
|
||||
stats.fileNotFound += translations.length;
|
||||
return;
|
||||
conflictReason: '文件不存在',
|
||||
})
|
||||
})
|
||||
stats.fileNotFound += translations.length
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`📝 处理文件: ${path.relative(WORKDIR, filePath)}`);
|
||||
console.log(`📝 处理文件: ${path.relative(WORKDIR, filePath)}`)
|
||||
|
||||
try {
|
||||
const project = new Project({
|
||||
tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'),
|
||||
skipAddingFilesFromTsConfig: true
|
||||
});
|
||||
const sourceFile = project.addSourceFileAtPath(filePath);
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
})
|
||||
const sourceFile = project.addSourceFileAtPath(filePath)
|
||||
|
||||
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) {
|
||||
matches = findTextInFile(sourceFile, translation);
|
||||
matches = findTextInFile(sourceFile, translation)
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
conflicts.push({
|
||||
...translation,
|
||||
conflictType: 'TEXT_NOT_FOUND_IN_FILE',
|
||||
conflictReason: '在文件中找不到匹配的文本'
|
||||
});
|
||||
stats.textNotFound++;
|
||||
continue;
|
||||
conflictReason: '在文件中找不到匹配的文本',
|
||||
})
|
||||
stats.textNotFound++
|
||||
continue
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
conflicts.push({
|
||||
...translation,
|
||||
conflictType: 'MULTIPLE_MATCHES',
|
||||
conflictReason: `找到 ${matches.length} 个匹配项,需要人工确认`
|
||||
});
|
||||
stats.multipleMatches++;
|
||||
continue;
|
||||
conflictReason: `找到 ${matches.length} 个匹配项,需要人工确认`,
|
||||
})
|
||||
stats.multipleMatches++
|
||||
continue
|
||||
}
|
||||
|
||||
// 执行替换
|
||||
const match = matches[0];
|
||||
const success = replaceText(match.node, corrected_text, match.type);
|
||||
const match = matches[0]
|
||||
const success = replaceText(match.node, corrected_text, match.type)
|
||||
|
||||
if (success) {
|
||||
successfulReplacements.push({
|
||||
...translation,
|
||||
actualLine: match.line,
|
||||
replacementType: match.type
|
||||
});
|
||||
stats.success++;
|
||||
console.log(`✅ 替换成功: "${text}" -> "${corrected_text}" (行 ${match.line})`);
|
||||
replacementType: match.type,
|
||||
})
|
||||
stats.success++
|
||||
console.log(`✅ 替换成功: "${text}" -> "${corrected_text}" (行 ${match.line})`)
|
||||
} else {
|
||||
conflicts.push({
|
||||
...translation,
|
||||
conflictType: 'REPLACEMENT_FAILED',
|
||||
conflictReason: '替换操作失败'
|
||||
});
|
||||
stats.conflicts++;
|
||||
conflictReason: '替换操作失败',
|
||||
})
|
||||
stats.conflicts++
|
||||
}
|
||||
}
|
||||
|
||||
// 保存修改后的文件
|
||||
sourceFile.saveSync();
|
||||
|
||||
sourceFile.saveSync()
|
||||
} catch (error) {
|
||||
console.error(`❌ 处理文件失败: ${filePath}`, error.message);
|
||||
translations.forEach(t => {
|
||||
console.error(`❌ 处理文件失败: ${filePath}`, error.message)
|
||||
translations.forEach((t) => {
|
||||
conflicts.push({
|
||||
...t,
|
||||
conflictType: 'PARSE_ERROR',
|
||||
conflictReason: `文件解析失败: ${error.message}`
|
||||
});
|
||||
});
|
||||
stats.conflicts += translations.length;
|
||||
conflictReason: `文件解析失败: ${error.message}`,
|
||||
})
|
||||
})
|
||||
stats.conflicts += translations.length
|
||||
}
|
||||
}
|
||||
|
||||
function generateReport() {
|
||||
console.log('\n📊 生成报告...');
|
||||
console.log('\n📊 生成报告...')
|
||||
|
||||
// 生成成功替换报告
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
stats,
|
||||
successfulReplacements,
|
||||
conflicts: conflicts.map(c => ({
|
||||
conflicts: conflicts.map((c) => ({
|
||||
file: c.file,
|
||||
line: c.line,
|
||||
text: c.text,
|
||||
corrected_text: c.corrected_text,
|
||||
conflictType: c.conflictType,
|
||||
conflictReason: c.conflictReason
|
||||
}))
|
||||
};
|
||||
conflictReason: c.conflictReason,
|
||||
})),
|
||||
}
|
||||
|
||||
fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2));
|
||||
console.log(`📄 成功替换报告已保存: ${REPORT_FILE}`);
|
||||
fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2))
|
||||
console.log(`📄 成功替换报告已保存: ${REPORT_FILE}`)
|
||||
|
||||
// 生成冲突报告 Excel
|
||||
if (conflicts.length > 0) {
|
||||
const conflictRows = conflicts.map(c => ({
|
||||
const conflictRows = conflicts.map((c) => ({
|
||||
file: c.file,
|
||||
line: c.line,
|
||||
text: c.text,
|
||||
|
|
@ -330,53 +327,52 @@ function generateReport() {
|
|||
route: c.route,
|
||||
componentOrFn: c.componentOrFn,
|
||||
kind: c.kind,
|
||||
keyOrLocator: c.keyOrLocator
|
||||
}));
|
||||
keyOrLocator: c.keyOrLocator,
|
||||
}))
|
||||
|
||||
const ws = XLSX.utils.json_to_sheet(conflictRows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'conflicts');
|
||||
XLSX.writeFile(wb, CONFLICTS_FILE);
|
||||
console.log(`📄 冲突报告已保存: ${CONFLICTS_FILE}`);
|
||||
const ws = XLSX.utils.json_to_sheet(conflictRows)
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'conflicts')
|
||||
XLSX.writeFile(wb, CONFLICTS_FILE)
|
||||
console.log(`📄 冲突报告已保存: ${CONFLICTS_FILE}`)
|
||||
}
|
||||
}
|
||||
|
||||
function printSummary() {
|
||||
console.log('\n📈 处理完成!');
|
||||
console.log(`总翻译条目: ${stats.total}`);
|
||||
console.log(`✅ 成功替换: ${stats.success}`);
|
||||
console.log(`❌ 文件不存在: ${stats.fileNotFound}`);
|
||||
console.log(`❌ 文本未找到: ${stats.textNotFound}`);
|
||||
console.log(`❌ 多处匹配: ${stats.multipleMatches}`);
|
||||
console.log(`❌ 其他冲突: ${stats.conflicts}`);
|
||||
console.log(`\n成功率: ${((stats.success / stats.total) * 100).toFixed(1)}%`);
|
||||
console.log('\n📈 处理完成!')
|
||||
console.log(`总翻译条目: ${stats.total}`)
|
||||
console.log(`✅ 成功替换: ${stats.success}`)
|
||||
console.log(`❌ 文件不存在: ${stats.fileNotFound}`)
|
||||
console.log(`❌ 文本未找到: ${stats.textNotFound}`)
|
||||
console.log(`❌ 多处匹配: ${stats.multipleMatches}`)
|
||||
console.log(`❌ 其他冲突: ${stats.conflicts}`)
|
||||
console.log(`\n成功率: ${((stats.success / stats.total) * 100).toFixed(1)}%`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 开始应用翻译...\n');
|
||||
console.log('🚀 开始应用翻译...\n')
|
||||
|
||||
try {
|
||||
// 1. 读取翻译数据
|
||||
const translations = loadTranslations();
|
||||
const translations = loadTranslations()
|
||||
|
||||
// 2. 按文件分组
|
||||
const fileGroups = groupByFile(translations);
|
||||
const fileGroups = groupByFile(translations)
|
||||
|
||||
// 3. 处理每个文件
|
||||
for (const [filePath, fileTranslations] of fileGroups) {
|
||||
processFile(filePath, fileTranslations);
|
||||
processFile(filePath, fileTranslations)
|
||||
}
|
||||
|
||||
// 4. 生成报告
|
||||
generateReport();
|
||||
generateReport()
|
||||
|
||||
// 5. 打印总结
|
||||
printSummary();
|
||||
|
||||
printSummary()
|
||||
} catch (error) {
|
||||
console.error('❌ 执行失败:', error);
|
||||
process.exitCode = 1;
|
||||
console.error('❌ 执行失败:', error)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -2,104 +2,104 @@
|
|||
将现有的 copy-audit.xlsx 转换为 i18next 格式的翻译文件
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const XLSX = require('xlsx');
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const XLSX = require('xlsx')
|
||||
|
||||
const WORKDIR = process.cwd();
|
||||
const WORKDIR = process.cwd()
|
||||
|
||||
function generateI18nKey(item) {
|
||||
// 生成 i18next 格式的键名
|
||||
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 kind = item.kind;
|
||||
const locator = item.keyOrLocator.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 kind = item.kind
|
||||
const locator = item.keyOrLocator.replace(/[^a-zA-Z0-9]/g, '_')
|
||||
|
||||
return `${route}.${component}.${kind}.${locator}`.toLowerCase();
|
||||
return `${route}.${component}.${kind}.${locator}`.toLowerCase()
|
||||
}
|
||||
|
||||
function main() {
|
||||
// 读取现有的 Excel 文件
|
||||
const excelFile = path.join(WORKDIR, 'docs', 'copy-audit.xlsx');
|
||||
const excelFile = path.join(WORKDIR, 'docs', 'copy-audit.xlsx')
|
||||
if (!fs.existsSync(excelFile)) {
|
||||
console.error('❌ 找不到 copy-audit.xlsx 文件,请先运行 extract-copy 脚本');
|
||||
process.exit(1);
|
||||
console.error('❌ 找不到 copy-audit.xlsx 文件,请先运行 extract-copy 脚本')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const workbook = XLSX.readFile(excelFile);
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const data = XLSX.utils.sheet_to_json(worksheet);
|
||||
const workbook = XLSX.readFile(excelFile)
|
||||
const sheetName = workbook.SheetNames[0]
|
||||
const worksheet = workbook.Sheets[sheetName]
|
||||
const data = XLSX.utils.sheet_to_json(worksheet)
|
||||
|
||||
console.log(`📊 读取到 ${data.length} 条记录`);
|
||||
console.log(`📊 读取到 ${data.length} 条记录`)
|
||||
|
||||
// 生成 i18next 格式的翻译文件
|
||||
const translation = {};
|
||||
const i18nKeys = [];
|
||||
const translation = {}
|
||||
const i18nKeys = []
|
||||
|
||||
data.forEach((item, index) => {
|
||||
if (item.text && item.text.trim()) {
|
||||
const key = generateI18nKey(item);
|
||||
translation[key] = item.text;
|
||||
const key = generateI18nKey(item)
|
||||
translation[key] = item.text
|
||||
i18nKeys.push({
|
||||
key,
|
||||
value: item.text,
|
||||
route: item.route,
|
||||
file: item.file,
|
||||
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)) {
|
||||
fs.mkdirSync(localesDir, { recursive: true });
|
||||
fs.mkdirSync(localesDir, { recursive: true })
|
||||
}
|
||||
|
||||
// 写入翻译文件
|
||||
const translationFile = path.join(localesDir, 'translation.json');
|
||||
fs.writeFileSync(translationFile, JSON.stringify(translation, null, 2));
|
||||
const translationFile = path.join(localesDir, 'translation.json')
|
||||
fs.writeFileSync(translationFile, JSON.stringify(translation, null, 2))
|
||||
|
||||
// 生成详细的 i18n 扫描报告
|
||||
const report = {
|
||||
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,
|
||||
byRoute: data.reduce((acc, item) => {
|
||||
acc[item.route] = (acc[item.route] || 0) + 1;
|
||||
return acc;
|
||||
acc[item.route] = (acc[item.route] || 0) + 1
|
||||
return acc
|
||||
}, {}),
|
||||
byKind: data.reduce((acc, item) => {
|
||||
acc[item.kind] = (acc[item.kind] || 0) + 1;
|
||||
return acc;
|
||||
acc[item.kind] = (acc[item.kind] || 0) + 1
|
||||
return acc
|
||||
}, {}),
|
||||
sampleKeys: Object.keys(translation).slice(0, 10)
|
||||
};
|
||||
sampleKeys: Object.keys(translation).slice(0, 10),
|
||||
}
|
||||
|
||||
// 生成 Excel 格式的 i18n 报告
|
||||
const i18nWorkbook = XLSX.utils.book_new();
|
||||
const i18nSheet = XLSX.utils.json_to_sheet(i18nKeys);
|
||||
XLSX.utils.book_append_sheet(i18nWorkbook, i18nSheet, 'i18n-keys');
|
||||
const i18nWorkbook = XLSX.utils.book_new()
|
||||
const i18nSheet = XLSX.utils.json_to_sheet(i18nKeys)
|
||||
XLSX.utils.book_append_sheet(i18nWorkbook, i18nSheet, 'i18n-keys')
|
||||
|
||||
const i18nReportFile = path.join(WORKDIR, 'docs', 'i18n-scan-report.xlsx');
|
||||
XLSX.writeFile(i18nWorkbook, i18nReportFile);
|
||||
const i18nReportFile = path.join(WORKDIR, 'docs', 'i18n-scan-report.xlsx')
|
||||
XLSX.writeFile(i18nWorkbook, i18nReportFile)
|
||||
|
||||
// 生成 JSON 报告
|
||||
const reportFile = path.join(WORKDIR, 'docs', 'i18n-scan-report.json');
|
||||
fs.writeFileSync(reportFile, JSON.stringify(report, null, 2));
|
||||
const reportFile = path.join(WORKDIR, 'docs', 'i18n-scan-report.json')
|
||||
fs.writeFileSync(reportFile, JSON.stringify(report, null, 2))
|
||||
|
||||
console.log('✅ i18next 扫描转换完成!');
|
||||
console.log(`📊 总扫描条目: ${data.length}`);
|
||||
console.log(`🔑 生成翻译键: ${Object.keys(translation).length}`);
|
||||
console.log(`📁 翻译文件: ${translationFile}`);
|
||||
console.log(`📋 i18n Excel 报告: ${i18nReportFile}`);
|
||||
console.log(`📄 JSON 报告: ${reportFile}`);
|
||||
console.log('\n📝 示例翻译键:');
|
||||
report.sampleKeys.forEach(key => {
|
||||
console.log(` ${key}: "${translation[key]}"`);
|
||||
});
|
||||
console.log('✅ i18next 扫描转换完成!')
|
||||
console.log(`📊 总扫描条目: ${data.length}`)
|
||||
console.log(`🔑 生成翻译键: ${Object.keys(translation).length}`)
|
||||
console.log(`📁 翻译文件: ${translationFile}`)
|
||||
console.log(`📋 i18n Excel 报告: ${i18nReportFile}`)
|
||||
console.log(`📄 JSON 报告: ${reportFile}`)
|
||||
console.log('\n📝 示例翻译键:')
|
||||
report.sampleKeys.forEach((key) => {
|
||||
console.log(` ${key}: "${translation[key]}"`)
|
||||
})
|
||||
}
|
||||
|
||||
main();
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -1,105 +1,114 @@
|
|||
/*
|
||||
CommonJS runtime for extracting user-facing copy into Excel.
|
||||
*/
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const { globby } = require('globby');
|
||||
const { Project, SyntaxKind, Node } = require('ts-morph');
|
||||
const XLSX = require('xlsx');
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
const { globby } = require('globby')
|
||||
const { Project, SyntaxKind, Node } = require('ts-morph')
|
||||
const XLSX = require('xlsx')
|
||||
|
||||
const WORKDIR = process.cwd();
|
||||
const SRC_DIR = path.join(WORKDIR, 'src');
|
||||
const APP_DIR = path.join(SRC_DIR, 'app');
|
||||
const WORKDIR = process.cwd()
|
||||
const SRC_DIR = path.join(WORKDIR, 'src')
|
||||
const APP_DIR = path.join(SRC_DIR, 'app')
|
||||
|
||||
function ensureExcelDir() {
|
||||
const docsDir = path.join(WORKDIR, 'docs');
|
||||
if (!fs.existsSync(docsDir)) fs.mkdirSync(docsDir, { recursive: true });
|
||||
const docsDir = path.join(WORKDIR, 'docs')
|
||||
if (!fs.existsSync(docsDir)) fs.mkdirSync(docsDir, { recursive: true })
|
||||
}
|
||||
|
||||
function isMeaningfulText(value) {
|
||||
if (!value) return false;
|
||||
const trimmed = String(value).replace(/\s+/g, ' ').trim();
|
||||
if (!trimmed) return false;
|
||||
if (/^[A-Za-z0-9_.$-]+$/.test(trimmed) && trimmed.length > 24) return false;
|
||||
return true;
|
||||
if (!value) return false
|
||||
const trimmed = String(value).replace(/\s+/g, ' ').trim()
|
||||
if (!trimmed) return false
|
||||
if (/^[A-Za-z0-9_.$-]+$/.test(trimmed) && trimmed.length > 24) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function getRouteForFile(absFilePath) {
|
||||
if (!absFilePath.startsWith(APP_DIR)) return 'shared';
|
||||
let dir = path.dirname(absFilePath);
|
||||
if (!absFilePath.startsWith(APP_DIR)) return 'shared'
|
||||
let dir = path.dirname(absFilePath)
|
||||
while (dir.startsWith(APP_DIR)) {
|
||||
const pageTsx = path.join(dir, 'page.tsx');
|
||||
const pageTs = path.join(dir, 'page.ts');
|
||||
const pageTsx = path.join(dir, 'page.tsx')
|
||||
const pageTs = path.join(dir, 'page.ts')
|
||||
if (fs.existsSync(pageTsx) || fs.existsSync(pageTs)) {
|
||||
const rel = path.relative(APP_DIR, dir);
|
||||
return rel || '/';
|
||||
const rel = path.relative(APP_DIR, dir)
|
||||
return rel || '/'
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
const parent = path.dirname(dir)
|
||||
if (parent === dir) break
|
||||
dir = parent
|
||||
}
|
||||
const relToApp = path.relative(APP_DIR, absFilePath);
|
||||
const parts = relToApp.split(path.sep);
|
||||
return parts.length > 0 ? parts[0] : 'shared';
|
||||
const relToApp = path.relative(APP_DIR, absFilePath)
|
||||
const parts = relToApp.split(path.sep)
|
||||
return parts.length > 0 ? parts[0] : 'shared'
|
||||
}
|
||||
|
||||
function getComponentOrFnName(node) {
|
||||
const fn = node.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration);
|
||||
if (fn && fn.getName && fn.getName()) return fn.getName();
|
||||
const varDecl = node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
|
||||
if (varDecl && varDecl.getName) return varDecl.getName();
|
||||
const cls = node.getFirstAncestorByKind(SyntaxKind.ClassDeclaration);
|
||||
if (cls && cls.getName && cls.getName()) return cls.getName();
|
||||
const sf = node.getSourceFile();
|
||||
return path.basename(sf.getFilePath());
|
||||
const fn = node.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration)
|
||||
if (fn && fn.getName && fn.getName()) return fn.getName()
|
||||
const varDecl = node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration)
|
||||
if (varDecl && varDecl.getName) return varDecl.getName()
|
||||
const cls = node.getFirstAncestorByKind(SyntaxKind.ClassDeclaration)
|
||||
if (cls && cls.getName && cls.getName()) return cls.getName()
|
||||
const sf = node.getSourceFile()
|
||||
return path.basename(sf.getFilePath())
|
||||
}
|
||||
|
||||
function getNodeLine(node) {
|
||||
const pos = node.getStartLineNumber && node.getStartLineNumber();
|
||||
return pos || 1;
|
||||
const pos = node.getStartLineNumber && node.getStartLineNumber()
|
||||
return pos || 1
|
||||
}
|
||||
|
||||
function getAttrName(attr) {
|
||||
return attr.getNameNode().getText();
|
||||
return attr.getNameNode().getText()
|
||||
}
|
||||
|
||||
function getStringFromInitializer(attr) {
|
||||
const init = attr.getInitializer();
|
||||
if (!init) return undefined;
|
||||
if (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init)) return init.getLiteralText();
|
||||
const init = attr.getInitializer()
|
||||
if (!init) return undefined
|
||||
if (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
|
||||
return init.getLiteralText()
|
||||
if (Node.isJsxExpression(init)) {
|
||||
const expr = init.getExpression();
|
||||
if (!expr) return undefined;
|
||||
if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) return expr.getLiteralText();
|
||||
const expr = init.getExpression()
|
||||
if (!expr) return undefined
|
||||
if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))
|
||||
return expr.getLiteralText()
|
||||
}
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function collectFiles() {
|
||||
const patterns = ['src/**/*.{ts,tsx}'];
|
||||
const ignore = ['**/node_modules/**','**/.next/**','**/__tests__/**','**/mocks/**','**/mock/**','**/*.d.ts'];
|
||||
return await globby(patterns, { gitignore: true, ignore });
|
||||
const patterns = ['src/**/*.{ts,tsx}']
|
||||
const ignore = [
|
||||
'**/node_modules/**',
|
||||
'**/.next/**',
|
||||
'**/__tests__/**',
|
||||
'**/mocks/**',
|
||||
'**/mock/**',
|
||||
'**/*.d.ts',
|
||||
]
|
||||
return await globby(patterns, { gitignore: true, ignore })
|
||||
}
|
||||
|
||||
function pushItem(items, item) {
|
||||
if (!isMeaningfulText(item.text)) return;
|
||||
items.push(item);
|
||||
if (!isMeaningfulText(item.text)) return
|
||||
items.push(item)
|
||||
}
|
||||
|
||||
function extractFromSourceFile(abs, items, project) {
|
||||
const sf = project.addSourceFileAtPath(abs);
|
||||
const sf = project.addSourceFileAtPath(abs)
|
||||
sf.forEachDescendant((node) => {
|
||||
// JSX text nodes
|
||||
if (Node.isJsxElement(node)) {
|
||||
const opening = node.getOpeningElement();
|
||||
const componentOrFn = getComponentOrFnName(node);
|
||||
const route = getRouteForFile(abs);
|
||||
const tagName = opening.getTagNameNode().getText();
|
||||
const opening = node.getOpeningElement()
|
||||
const componentOrFn = getComponentOrFnName(node)
|
||||
const route = getRouteForFile(abs)
|
||||
const tagName = opening.getTagNameNode().getText()
|
||||
// 递归抓取所有子层级文本节点
|
||||
const textNodes = node.getDescendantsOfKind(SyntaxKind.JsxText);
|
||||
const textNodes = node.getDescendantsOfKind(SyntaxKind.JsxText)
|
||||
textNodes.forEach((t) => {
|
||||
const text = t.getText();
|
||||
const cleaned = text.replace(/\s+/g, ' ').trim();
|
||||
const text = t.getText()
|
||||
const cleaned = text.replace(/\s+/g, ' ').trim()
|
||||
if (isMeaningfulText(cleaned)) {
|
||||
pushItem(items, {
|
||||
route,
|
||||
|
|
@ -109,15 +118,15 @@ function extractFromSourceFile(abs, items, project) {
|
|||
keyOrLocator: tagName,
|
||||
text: cleaned,
|
||||
line: getNodeLine(t),
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
})
|
||||
// 抓取 {'...'} 这类表达式中的字符串字面量
|
||||
const exprs = node.getDescendantsOfKind(SyntaxKind.JsxExpression);
|
||||
const exprs = node.getDescendantsOfKind(SyntaxKind.JsxExpression)
|
||||
exprs.forEach((expr) => {
|
||||
const inner = expr.getExpression();
|
||||
const inner = expr.getExpression()
|
||||
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)) {
|
||||
pushItem(items, {
|
||||
route,
|
||||
|
|
@ -127,29 +136,29 @@ function extractFromSourceFile(abs, items, project) {
|
|||
keyOrLocator: tagName,
|
||||
text: cleaned,
|
||||
line: getNodeLine(expr),
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// JSX attributes
|
||||
if (Node.isJsxOpeningElement(node) || Node.isJsxSelfClosingElement(node)) {
|
||||
const route = getRouteForFile(abs);
|
||||
const componentOrFn = getComponentOrFnName(node);
|
||||
const tag = node.getTagNameNode().getText();
|
||||
const attrs = node.getAttributes().filter(Node.isJsxAttribute);
|
||||
const route = getRouteForFile(abs)
|
||||
const componentOrFn = getComponentOrFnName(node)
|
||||
const tag = node.getTagNameNode().getText()
|
||||
const attrs = node.getAttributes().filter(Node.isJsxAttribute)
|
||||
attrs.forEach((attr) => {
|
||||
const name = getAttrName(attr);
|
||||
const lower = name.toLowerCase();
|
||||
const value = getStringFromInitializer(attr);
|
||||
if (!value) return;
|
||||
let kind = null;
|
||||
if (lower === 'placeholder') kind = 'placeholder';
|
||||
else if (lower === 'title') kind = 'title';
|
||||
else if (lower === 'alt') kind = 'alt';
|
||||
else if (lower.startsWith('aria-')) kind = 'aria';
|
||||
else if (lower === 'label') kind = 'label';
|
||||
const name = getAttrName(attr)
|
||||
const lower = name.toLowerCase()
|
||||
const value = getStringFromInitializer(attr)
|
||||
if (!value) return
|
||||
let kind = null
|
||||
if (lower === 'placeholder') kind = 'placeholder'
|
||||
else if (lower === 'title') kind = 'title'
|
||||
else if (lower === 'alt') kind = 'alt'
|
||||
else if (lower.startsWith('aria-')) kind = 'aria'
|
||||
else if (lower === 'label') kind = 'label'
|
||||
if (kind) {
|
||||
pushItem(items, {
|
||||
route,
|
||||
|
|
@ -159,47 +168,75 @@ function extractFromSourceFile(abs, items, project) {
|
|||
keyOrLocator: `${tag}.${name}`,
|
||||
text: value,
|
||||
line: getNodeLine(attr),
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// Interaction messages
|
||||
if (Node.isCallExpression(node)) {
|
||||
const route = getRouteForFile(abs);
|
||||
const componentOrFn = getComponentOrFnName(node);
|
||||
const expr = node.getExpression();
|
||||
let kind = null;
|
||||
let keyOrLocator = '';
|
||||
const route = getRouteForFile(abs)
|
||||
const componentOrFn = getComponentOrFnName(node)
|
||||
const expr = node.getExpression()
|
||||
let kind = null
|
||||
let keyOrLocator = ''
|
||||
if (Node.isPropertyAccessExpression(expr)) {
|
||||
const left = expr.getExpression().getText();
|
||||
const name = expr.getName();
|
||||
if (left === 'toast' || left === 'message') { kind = 'toast'; keyOrLocator = `${left}.${name}`; }
|
||||
if ((left || '').toLowerCase().includes('dialog')) { kind = 'dialog'; keyOrLocator = `${left}.${name}`; }
|
||||
const left = expr.getExpression().getText()
|
||||
const name = expr.getName()
|
||||
if (left === 'toast' || left === 'message') {
|
||||
kind = 'toast'
|
||||
keyOrLocator = `${left}.${name}`
|
||||
}
|
||||
if ((left || '').toLowerCase().includes('dialog')) {
|
||||
kind = 'dialog'
|
||||
keyOrLocator = `${left}.${name}`
|
||||
}
|
||||
} else if (Node.isIdentifier(expr)) {
|
||||
const id = expr.getText();
|
||||
if (id === 'alert' || id === 'confirm') { kind = 'dialog'; keyOrLocator = id; }
|
||||
const id = expr.getText()
|
||||
if (id === 'alert' || id === 'confirm') {
|
||||
kind = 'dialog'
|
||||
keyOrLocator = id
|
||||
}
|
||||
}
|
||||
if (kind) {
|
||||
const arg0 = node.getArguments()[0];
|
||||
const arg0 = node.getArguments()[0]
|
||||
if (arg0 && (Node.isStringLiteral(arg0) || Node.isNoSubstitutionTemplateLiteral(arg0))) {
|
||||
const text = arg0.getLiteralText();
|
||||
pushItem(items, { route, file: path.relative(WORKDIR, abs), componentOrFn, kind, keyOrLocator, text, line: getNodeLine(node) });
|
||||
const text = arg0.getLiteralText()
|
||||
pushItem(items, {
|
||||
route,
|
||||
file: path.relative(WORKDIR, abs),
|
||||
componentOrFn,
|
||||
kind,
|
||||
keyOrLocator,
|
||||
text,
|
||||
line: getNodeLine(node),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// form.setError("field", { message: "..." })
|
||||
if (Node.isPropertyAccessExpression(expr) && expr.getName() === 'setError') {
|
||||
const args = node.getArguments();
|
||||
const args = node.getArguments()
|
||||
if (args.length >= 2) {
|
||||
const second = args[1];
|
||||
const second = args[1]
|
||||
if (Node.isObjectLiteralExpression(second)) {
|
||||
const msgProp = second.getProperty('message');
|
||||
const msgProp = second.getProperty('message')
|
||||
if (msgProp && Node.isPropertyAssignment(msgProp)) {
|
||||
const init = msgProp.getInitializer();
|
||||
if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) {
|
||||
const text = init.getLiteralText();
|
||||
pushItem(items, { route, file: path.relative(WORKDIR, abs), componentOrFn, kind: 'error', keyOrLocator: 'form.setError', text, line: getNodeLine(msgProp) });
|
||||
const init = msgProp.getInitializer()
|
||||
if (
|
||||
init &&
|
||||
(Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
|
||||
) {
|
||||
const text = init.getLiteralText()
|
||||
pushItem(items, {
|
||||
route,
|
||||
file: path.relative(WORKDIR, abs),
|
||||
componentOrFn,
|
||||
kind: 'error',
|
||||
keyOrLocator: 'form.setError',
|
||||
text,
|
||||
line: getNodeLine(msgProp),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -207,35 +244,46 @@ function extractFromSourceFile(abs, items, project) {
|
|||
}
|
||||
|
||||
// Generic validation object { message: "..." }
|
||||
const args = node.getArguments();
|
||||
const args = node.getArguments()
|
||||
for (const a of args) {
|
||||
if (Node.isObjectLiteralExpression(a)) {
|
||||
const prop = a.getProperty('message');
|
||||
const prop = a.getProperty('message')
|
||||
if (prop && Node.isPropertyAssignment(prop)) {
|
||||
const init = prop.getInitializer();
|
||||
if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) {
|
||||
const text = init.getLiteralText();
|
||||
pushItem(items, { route, file: path.relative(WORKDIR, abs), componentOrFn, kind: 'validation', keyOrLocator: 'message', text, line: getNodeLine(prop) });
|
||||
const init = prop.getInitializer()
|
||||
if (
|
||||
init &&
|
||||
(Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
|
||||
) {
|
||||
const text = init.getLiteralText()
|
||||
pushItem(items, {
|
||||
route,
|
||||
file: path.relative(WORKDIR, abs),
|
||||
componentOrFn,
|
||||
kind: 'validation',
|
||||
keyOrLocator: 'message',
|
||||
text,
|
||||
line: getNodeLine(prop),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
function aggregate(items) {
|
||||
const map = new Map();
|
||||
const map = new Map()
|
||||
for (const it of items) {
|
||||
const key = `${it.route}__${it.kind}__${it.keyOrLocator}__${it.text}`;
|
||||
if (!map.has(key)) map.set(key, { item: it, count: 1 });
|
||||
else map.get(key).count += 1;
|
||||
const key = `${it.route}__${it.kind}__${it.keyOrLocator}__${it.text}`
|
||||
if (!map.has(key)) map.set(key, { item: it, count: 1 })
|
||||
else map.get(key).count += 1
|
||||
}
|
||||
const result = [];
|
||||
const result = []
|
||||
for (const { item, count } of map.values()) {
|
||||
result.push({ ...item, count });
|
||||
result.push({ ...item, count })
|
||||
}
|
||||
return result;
|
||||
return result
|
||||
}
|
||||
|
||||
function toWorkbook(items) {
|
||||
|
|
@ -249,36 +297,37 @@ function toWorkbook(items) {
|
|||
line: it.line,
|
||||
count: it.count || 1,
|
||||
notes: it.notes || '',
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(rows, { skipHeader: false });
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'copy');
|
||||
return wb;
|
||||
}))
|
||||
const ws = XLSX.utils.json_to_sheet(rows, { skipHeader: false })
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'copy')
|
||||
return wb
|
||||
}
|
||||
|
||||
async function main() {
|
||||
ensureExcelDir();
|
||||
const files = await collectFiles();
|
||||
const project = new Project({ tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'), skipAddingFilesFromTsConfig: true });
|
||||
const items = [];
|
||||
ensureExcelDir()
|
||||
const files = await collectFiles()
|
||||
const project = new Project({
|
||||
tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'),
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
})
|
||||
const items = []
|
||||
for (const rel of files) {
|
||||
const abs = path.join(WORKDIR, rel);
|
||||
const abs = path.join(WORKDIR, rel)
|
||||
try {
|
||||
extractFromSourceFile(abs, items, project);
|
||||
extractFromSourceFile(abs, items, project)
|
||||
} catch (e) {
|
||||
// continue on parse errors
|
||||
}
|
||||
}
|
||||
const aggregated = aggregate(items);
|
||||
const wb = toWorkbook(aggregated);
|
||||
const out = path.join(WORKDIR, 'docs', 'copy-audit.xlsx');
|
||||
XLSX.writeFile(wb, out);
|
||||
console.log(`Wrote ${aggregated.length} rows to ${out}`);
|
||||
const aggregated = aggregate(items)
|
||||
const wb = toWorkbook(aggregated)
|
||||
const out = path.join(WORKDIR, 'docs', 'copy-audit.xlsx')
|
||||
XLSX.writeFile(wb, out)
|
||||
console.log(`Wrote ${aggregated.length} rows to ${out}`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
|
||||
console.error(err)
|
||||
process.exitCode = 1
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,193 +3,198 @@
|
|||
Scans TS/TSX under src/, groups by Next.js App Router route, and writes docs/copy-audit.xlsx.
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { globby } from "globby";
|
||||
import { Project, SyntaxKind, Node, JsxAttribute, StringLiteral, NoSubstitutionTemplateLiteral } from "ts-morph";
|
||||
import * as XLSX from "xlsx";
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import { globby } from 'globby'
|
||||
import {
|
||||
Project,
|
||||
SyntaxKind,
|
||||
Node,
|
||||
JsxAttribute,
|
||||
StringLiteral,
|
||||
NoSubstitutionTemplateLiteral,
|
||||
} from 'ts-morph'
|
||||
import * as XLSX from 'xlsx'
|
||||
|
||||
type CopyKind =
|
||||
| "text"
|
||||
| "placeholder"
|
||||
| "title"
|
||||
| "alt"
|
||||
| "aria"
|
||||
| "label"
|
||||
| "toast"
|
||||
| "dialog"
|
||||
| "error"
|
||||
| "validation";
|
||||
| 'text'
|
||||
| 'placeholder'
|
||||
| 'title'
|
||||
| 'alt'
|
||||
| 'aria'
|
||||
| 'label'
|
||||
| 'toast'
|
||||
| 'dialog'
|
||||
| 'error'
|
||||
| 'validation'
|
||||
|
||||
interface CopyItem {
|
||||
route: string;
|
||||
file: string;
|
||||
componentOrFn: string;
|
||||
kind: CopyKind;
|
||||
keyOrLocator: string;
|
||||
text: string;
|
||||
line: number;
|
||||
notes?: string;
|
||||
route: string
|
||||
file: string
|
||||
componentOrFn: string
|
||||
kind: CopyKind
|
||||
keyOrLocator: string
|
||||
text: string
|
||||
line: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
const WORKDIR = process.cwd();
|
||||
const SRC_DIR = path.join(WORKDIR, "src");
|
||||
const APP_DIR = path.join(SRC_DIR, "app");
|
||||
const WORKDIR = process.cwd()
|
||||
const SRC_DIR = path.join(WORKDIR, 'src')
|
||||
const APP_DIR = path.join(SRC_DIR, 'app')
|
||||
|
||||
function ensureExcelDir() {
|
||||
const docsDir = path.join(WORKDIR, "docs");
|
||||
if (!fs.existsSync(docsDir)) fs.mkdirSync(docsDir, { recursive: true });
|
||||
const docsDir = path.join(WORKDIR, 'docs')
|
||||
if (!fs.existsSync(docsDir)) fs.mkdirSync(docsDir, { recursive: true })
|
||||
}
|
||||
|
||||
function isMeaningfulText(value: string | undefined | null): value is string {
|
||||
if (!value) return false;
|
||||
const trimmed = value.replace(/\s+/g, " ").trim();
|
||||
if (!trimmed) return false;
|
||||
if (!value) return false
|
||||
const trimmed = value.replace(/\s+/g, ' ').trim()
|
||||
if (!trimmed) return false
|
||||
// Filter obvious code-like tokens
|
||||
if (/^[A-Za-z0-9_.$-]+$/.test(trimmed) && trimmed.length > 24) return false;
|
||||
return true;
|
||||
if (/^[A-Za-z0-9_.$-]+$/.test(trimmed) && trimmed.length > 24) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function getRouteForFile(absFilePath: string): string {
|
||||
if (!absFilePath.startsWith(APP_DIR)) return "shared";
|
||||
let dir = path.dirname(absFilePath);
|
||||
if (!absFilePath.startsWith(APP_DIR)) return 'shared'
|
||||
let dir = path.dirname(absFilePath)
|
||||
// Walk up to find nearest folder that contains a page.tsx (or page.ts)
|
||||
while (dir.startsWith(APP_DIR)) {
|
||||
const pageTsx = path.join(dir, "page.tsx");
|
||||
const pageTs = path.join(dir, "page.ts");
|
||||
const pageTsx = path.join(dir, 'page.tsx')
|
||||
const pageTs = path.join(dir, 'page.ts')
|
||||
if (fs.existsSync(pageTsx) || fs.existsSync(pageTs)) {
|
||||
const rel = path.relative(APP_DIR, dir);
|
||||
return rel || "/";
|
||||
const rel = path.relative(APP_DIR, dir)
|
||||
return rel || '/'
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
const parent = path.dirname(dir)
|
||||
if (parent === dir) break
|
||||
dir = parent
|
||||
}
|
||||
// Fallback: route is the first app subfolder segment
|
||||
const relToApp = path.relative(APP_DIR, absFilePath);
|
||||
const parts = relToApp.split(path.sep);
|
||||
return parts.length > 0 ? parts[0] : "shared";
|
||||
const relToApp = path.relative(APP_DIR, absFilePath)
|
||||
const parts = relToApp.split(path.sep)
|
||||
return parts.length > 0 ? parts[0] : 'shared'
|
||||
}
|
||||
|
||||
function getComponentOrFnName(node: Node): string {
|
||||
const fn = node.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration);
|
||||
if (fn?.getName()) return fn.getName()!;
|
||||
const varDecl = node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
|
||||
if (varDecl?.getName()) return varDecl.getName();
|
||||
const cls = node.getFirstAncestorByKind(SyntaxKind.ClassDeclaration);
|
||||
if (cls?.getName()) return cls.getName()!;
|
||||
const sf = node.getSourceFile();
|
||||
return path.basename(sf.getFilePath());
|
||||
const fn = node.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration)
|
||||
if (fn?.getName()) return fn.getName()!
|
||||
const varDecl = node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration)
|
||||
if (varDecl?.getName()) return varDecl.getName()
|
||||
const cls = node.getFirstAncestorByKind(SyntaxKind.ClassDeclaration)
|
||||
if (cls?.getName()) return cls.getName()!
|
||||
const sf = node.getSourceFile()
|
||||
return path.basename(sf.getFilePath())
|
||||
}
|
||||
|
||||
function getNodeLine(node: Node): number {
|
||||
const pos = node.getStartLineNumber();
|
||||
return pos ?? 1;
|
||||
const pos = node.getStartLineNumber()
|
||||
return pos ?? 1
|
||||
}
|
||||
|
||||
function getAttrName(attr: JsxAttribute): string {
|
||||
return attr.getNameNode().getText();
|
||||
return attr.getNameNode().getText()
|
||||
}
|
||||
|
||||
function getStringFromInitializer(attr: JsxAttribute): string | undefined {
|
||||
const init = attr.getInitializer();
|
||||
if (!init) return undefined;
|
||||
if (Node.isStringLiteral(init)) return init.getLiteralText();
|
||||
if (Node.isNoSubstitutionTemplateLiteral(init)) return init.getLiteralText();
|
||||
const init = attr.getInitializer()
|
||||
if (!init) return undefined
|
||||
if (Node.isStringLiteral(init)) return init.getLiteralText()
|
||||
if (Node.isNoSubstitutionTemplateLiteral(init)) return init.getLiteralText()
|
||||
if (Node.isJsxExpression(init)) {
|
||||
const expr = init.getExpression();
|
||||
if (!expr) return undefined;
|
||||
if (Node.isStringLiteral(expr)) return expr.getLiteralText();
|
||||
if (Node.isNoSubstitutionTemplateLiteral(expr)) return expr.getLiteralText();
|
||||
const expr = init.getExpression()
|
||||
if (!expr) return undefined
|
||||
if (Node.isStringLiteral(expr)) return expr.getLiteralText()
|
||||
if (Node.isNoSubstitutionTemplateLiteral(expr)) return expr.getLiteralText()
|
||||
}
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
|
||||
function pushItem(items: CopyItem[], item: CopyItem) {
|
||||
if (!isMeaningfulText(item.text)) return;
|
||||
items.push(item);
|
||||
if (!isMeaningfulText(item.text)) return
|
||||
items.push(item)
|
||||
}
|
||||
|
||||
async function collectFiles(): Promise<string[]> {
|
||||
const patterns = [
|
||||
"src/**/*.{ts,tsx}",
|
||||
];
|
||||
const patterns = ['src/**/*.{ts,tsx}']
|
||||
const ignore = [
|
||||
"**/node_modules/**",
|
||||
"**/.next/**",
|
||||
"**/__tests__/**",
|
||||
"**/mocks/**",
|
||||
"**/mock/**",
|
||||
"**/*.d.ts",
|
||||
];
|
||||
return await globby(patterns, { gitignore: true, ignore });
|
||||
'**/node_modules/**',
|
||||
'**/.next/**',
|
||||
'**/__tests__/**',
|
||||
'**/mocks/**',
|
||||
'**/mock/**',
|
||||
'**/*.d.ts',
|
||||
]
|
||||
return await globby(patterns, { gitignore: true, ignore })
|
||||
}
|
||||
|
||||
function extractFromSourceFile(abs: string, items: CopyItem[], project: Project) {
|
||||
const sf = project.addSourceFileAtPath(abs);
|
||||
const sf = project.addSourceFileAtPath(abs)
|
||||
// JSX text nodes
|
||||
sf.forEachDescendant((node) => {
|
||||
if (Node.isJsxElement(node)) {
|
||||
const opening = node.getOpeningElement();
|
||||
const componentOrFn = getComponentOrFnName(node);
|
||||
const route = getRouteForFile(abs);
|
||||
const opening = node.getOpeningElement()
|
||||
const componentOrFn = getComponentOrFnName(node)
|
||||
const route = getRouteForFile(abs)
|
||||
// 递归提取所有 JsxText 与 {'...'} 字面量
|
||||
const tagName = opening.getTagNameNode().getText();
|
||||
const textNodes = node.getDescendantsOfKind(SyntaxKind.JsxText);
|
||||
const tagName = opening.getTagNameNode().getText()
|
||||
const textNodes = node.getDescendantsOfKind(SyntaxKind.JsxText)
|
||||
textNodes.forEach((t) => {
|
||||
const text = t.getText();
|
||||
const cleaned = text.replace(/\s+/g, " ").trim();
|
||||
const text = t.getText()
|
||||
const cleaned = text.replace(/\s+/g, ' ').trim()
|
||||
if (isMeaningfulText(cleaned)) {
|
||||
pushItem(items, {
|
||||
route,
|
||||
file: path.relative(WORKDIR, abs),
|
||||
componentOrFn,
|
||||
kind: "text",
|
||||
kind: 'text',
|
||||
keyOrLocator: tagName,
|
||||
text: cleaned,
|
||||
line: getNodeLine(t),
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
const exprs = node.getDescendantsOfKind(SyntaxKind.JsxExpression);
|
||||
})
|
||||
const exprs = node.getDescendantsOfKind(SyntaxKind.JsxExpression)
|
||||
exprs.forEach((expr) => {
|
||||
const inner = expr.getExpression();
|
||||
const inner = expr.getExpression()
|
||||
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)) {
|
||||
pushItem(items, {
|
||||
route,
|
||||
file: path.relative(WORKDIR, abs),
|
||||
componentOrFn,
|
||||
kind: "text",
|
||||
kind: 'text',
|
||||
keyOrLocator: tagName,
|
||||
text: cleaned,
|
||||
line: getNodeLine(expr),
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// JSX attributes
|
||||
if (Node.isJsxOpeningElement(node) || Node.isJsxSelfClosingElement(node)) {
|
||||
const route = getRouteForFile(abs);
|
||||
const componentOrFn = getComponentOrFnName(node);
|
||||
const route = getRouteForFile(abs)
|
||||
const componentOrFn = getComponentOrFnName(node)
|
||||
const tag = Node.isJsxOpeningElement(node)
|
||||
? node.getTagNameNode().getText()
|
||||
: node.getTagNameNode().getText();
|
||||
const attrs = node.getAttributes().filter(Node.isJsxAttribute);
|
||||
: node.getTagNameNode().getText()
|
||||
const attrs = node.getAttributes().filter(Node.isJsxAttribute)
|
||||
attrs.forEach((attr) => {
|
||||
const name = getAttrName(attr);
|
||||
const lower = name.toLowerCase();
|
||||
const value = getStringFromInitializer(attr);
|
||||
if (!value) return;
|
||||
let kind: CopyKind | null = null;
|
||||
if (lower === "placeholder") kind = "placeholder";
|
||||
else if (lower === "title") kind = "title";
|
||||
else if (lower === "alt") kind = "alt";
|
||||
else if (lower.startsWith("aria-")) kind = "aria";
|
||||
else if (lower === "label") kind = "label";
|
||||
const name = getAttrName(attr)
|
||||
const lower = name.toLowerCase()
|
||||
const value = getStringFromInitializer(attr)
|
||||
if (!value) return
|
||||
let kind: CopyKind | null = null
|
||||
if (lower === 'placeholder') kind = 'placeholder'
|
||||
else if (lower === 'title') kind = 'title'
|
||||
else if (lower === 'alt') kind = 'alt'
|
||||
else if (lower.startsWith('aria-')) kind = 'aria'
|
||||
else if (lower === 'label') kind = 'label'
|
||||
if (kind) {
|
||||
pushItem(items, {
|
||||
route,
|
||||
|
|
@ -199,40 +204,40 @@ function extractFromSourceFile(abs: string, items: CopyItem[], project: Project)
|
|||
keyOrLocator: `${tag}.${name}`,
|
||||
text: value,
|
||||
line: getNodeLine(attr),
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// Interaction messages: toast.*, alert, confirm, message.*
|
||||
if (Node.isCallExpression(node)) {
|
||||
const route = getRouteForFile(abs);
|
||||
const componentOrFn = getComponentOrFnName(node);
|
||||
const expr = node.getExpression();
|
||||
let kind: CopyKind | null = null;
|
||||
let keyOrLocator = "";
|
||||
const route = getRouteForFile(abs)
|
||||
const componentOrFn = getComponentOrFnName(node)
|
||||
const expr = node.getExpression()
|
||||
let kind: CopyKind | null = null
|
||||
let keyOrLocator = ''
|
||||
if (Node.isPropertyAccessExpression(expr)) {
|
||||
const left = expr.getExpression().getText();
|
||||
const name = expr.getName();
|
||||
if (left === "toast" || left === "message") {
|
||||
kind = "toast";
|
||||
keyOrLocator = `${left}.${name}`;
|
||||
const left = expr.getExpression().getText()
|
||||
const name = expr.getName()
|
||||
if (left === 'toast' || left === 'message') {
|
||||
kind = 'toast'
|
||||
keyOrLocator = `${left}.${name}`
|
||||
}
|
||||
if (left.toLowerCase().includes("dialog")) {
|
||||
kind = "dialog";
|
||||
keyOrLocator = `${left}.${name}`;
|
||||
if (left.toLowerCase().includes('dialog')) {
|
||||
kind = 'dialog'
|
||||
keyOrLocator = `${left}.${name}`
|
||||
}
|
||||
} else if (Node.isIdentifier(expr)) {
|
||||
const id = expr.getText();
|
||||
if (id === "alert" || id === "confirm") {
|
||||
kind = "dialog";
|
||||
keyOrLocator = id;
|
||||
const id = expr.getText()
|
||||
if (id === 'alert' || id === 'confirm') {
|
||||
kind = 'dialog'
|
||||
keyOrLocator = id
|
||||
}
|
||||
}
|
||||
if (kind) {
|
||||
const arg0 = node.getArguments()[0];
|
||||
const arg0 = node.getArguments()[0]
|
||||
if (arg0 && (Node.isStringLiteral(arg0) || Node.isNoSubstitutionTemplateLiteral(arg0))) {
|
||||
const text = (arg0 as StringLiteral | NoSubstitutionTemplateLiteral).getLiteralText();
|
||||
const text = (arg0 as StringLiteral | NoSubstitutionTemplateLiteral).getLiteralText()
|
||||
pushItem(items, {
|
||||
route,
|
||||
file: path.relative(WORKDIR, abs),
|
||||
|
|
@ -241,30 +246,33 @@ function extractFromSourceFile(abs: string, items: CopyItem[], project: Project)
|
|||
keyOrLocator,
|
||||
text,
|
||||
line: getNodeLine(node),
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// form.setError("field", { message: "..." })
|
||||
if (Node.isPropertyAccessExpression(expr) && expr.getName() === "setError") {
|
||||
const args = node.getArguments();
|
||||
if (Node.isPropertyAccessExpression(expr) && expr.getName() === 'setError') {
|
||||
const args = node.getArguments()
|
||||
if (args.length >= 2) {
|
||||
const second = args[1];
|
||||
const second = args[1]
|
||||
if (Node.isObjectLiteralExpression(second)) {
|
||||
const msgProp = second.getProperty("message");
|
||||
const msgProp = second.getProperty('message')
|
||||
if (msgProp && Node.isPropertyAssignment(msgProp)) {
|
||||
const init = msgProp.getInitializer();
|
||||
if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) {
|
||||
const text = init.getLiteralText();
|
||||
const init = msgProp.getInitializer()
|
||||
if (
|
||||
init &&
|
||||
(Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
|
||||
) {
|
||||
const text = init.getLiteralText()
|
||||
pushItem(items, {
|
||||
route,
|
||||
file: path.relative(WORKDIR, abs),
|
||||
componentOrFn,
|
||||
kind: "error",
|
||||
keyOrLocator: "form.setError",
|
||||
kind: 'error',
|
||||
keyOrLocator: 'form.setError',
|
||||
text,
|
||||
line: getNodeLine(msgProp),
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -272,48 +280,51 @@ function extractFromSourceFile(abs: string, items: CopyItem[], project: Project)
|
|||
}
|
||||
|
||||
// Generic validation: any object literal { message: "..." } inside chained calls
|
||||
const args = node.getArguments();
|
||||
const args = node.getArguments()
|
||||
for (const a of args) {
|
||||
if (Node.isObjectLiteralExpression(a)) {
|
||||
const prop = a.getProperty("message");
|
||||
const prop = a.getProperty('message')
|
||||
if (prop && Node.isPropertyAssignment(prop)) {
|
||||
const init = prop.getInitializer();
|
||||
if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) {
|
||||
const text = init.getLiteralText();
|
||||
const init = prop.getInitializer()
|
||||
if (
|
||||
init &&
|
||||
(Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
|
||||
) {
|
||||
const text = init.getLiteralText()
|
||||
pushItem(items, {
|
||||
route,
|
||||
file: path.relative(WORKDIR, abs),
|
||||
componentOrFn,
|
||||
kind: "validation",
|
||||
keyOrLocator: "message",
|
||||
kind: 'validation',
|
||||
keyOrLocator: 'message',
|
||||
text,
|
||||
line: getNodeLine(prop),
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
function aggregate(items: CopyItem[]): CopyItem[] {
|
||||
// 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) {
|
||||
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 });
|
||||
map.set(key, { item: it, count: 1 })
|
||||
} else {
|
||||
map.get(key)!.count += 1;
|
||||
map.get(key)!.count += 1
|
||||
}
|
||||
}
|
||||
const result: CopyItem[] = [];
|
||||
const result: CopyItem[] = []
|
||||
for (const { item, count } of map.values()) {
|
||||
(item as any).count = count;
|
||||
result.push(item);
|
||||
;(item as any).count = count
|
||||
result.push(item)
|
||||
}
|
||||
return result;
|
||||
return result
|
||||
}
|
||||
|
||||
function toWorkbook(items: CopyItem[]): XLSX.WorkBook {
|
||||
|
|
@ -326,42 +337,40 @@ function toWorkbook(items: CopyItem[]): XLSX.WorkBook {
|
|||
text: it.text,
|
||||
line: it.line,
|
||||
count: (it as any).count ?? 1,
|
||||
notes: it.notes ?? "",
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(rows, { skipHeader: false });
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "copy");
|
||||
return wb;
|
||||
notes: it.notes ?? '',
|
||||
}))
|
||||
const ws = XLSX.utils.json_to_sheet(rows, { skipHeader: false })
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'copy')
|
||||
return wb
|
||||
}
|
||||
|
||||
async function main() {
|
||||
ensureExcelDir();
|
||||
const files = await collectFiles();
|
||||
ensureExcelDir()
|
||||
const files = await collectFiles()
|
||||
const project = new Project({
|
||||
tsConfigFilePath: path.join(WORKDIR, "tsconfig.json"),
|
||||
tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'),
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
});
|
||||
const items: CopyItem[] = [];
|
||||
})
|
||||
const items: CopyItem[] = []
|
||||
for (const rel of files) {
|
||||
const abs = path.join(WORKDIR, rel);
|
||||
const abs = path.join(WORKDIR, rel)
|
||||
try {
|
||||
extractFromSourceFile(abs, items, project);
|
||||
extractFromSourceFile(abs, items, project)
|
||||
} catch (e) {
|
||||
// swallow parse errors but continue
|
||||
}
|
||||
}
|
||||
const aggregated = aggregate(items);
|
||||
const wb = toWorkbook(aggregated);
|
||||
const out = path.join(WORKDIR, "docs", "copy-audit.xlsx");
|
||||
XLSX.writeFile(wb, out);
|
||||
const aggregated = aggregate(items)
|
||||
const wb = toWorkbook(aggregated)
|
||||
const out = path.join(WORKDIR, 'docs', 'copy-audit.xlsx')
|
||||
XLSX.writeFile(wb, out)
|
||||
// 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) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
|
||||
console.error(err)
|
||||
process.exitCode = 1
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,211 +3,216 @@
|
|||
基于现有的 extract-copy.ts 工具,生成 i18next 格式的扫描报告
|
||||
*/
|
||||
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { globby } from "globby";
|
||||
import { Project, SyntaxKind, Node, JsxAttribute, StringLiteral, NoSubstitutionTemplateLiteral } from "ts-morph";
|
||||
import * as XLSX from "xlsx";
|
||||
import path from 'node:path'
|
||||
import fs from 'node:fs'
|
||||
import { globby } from 'globby'
|
||||
import {
|
||||
Project,
|
||||
SyntaxKind,
|
||||
Node,
|
||||
JsxAttribute,
|
||||
StringLiteral,
|
||||
NoSubstitutionTemplateLiteral,
|
||||
} from 'ts-morph'
|
||||
import * as XLSX from 'xlsx'
|
||||
|
||||
type CopyKind =
|
||||
| "text"
|
||||
| "placeholder"
|
||||
| "title"
|
||||
| "alt"
|
||||
| "aria"
|
||||
| "label"
|
||||
| "toast"
|
||||
| "dialog"
|
||||
| "error"
|
||||
| "validation";
|
||||
| 'text'
|
||||
| 'placeholder'
|
||||
| 'title'
|
||||
| 'alt'
|
||||
| 'aria'
|
||||
| 'label'
|
||||
| 'toast'
|
||||
| 'dialog'
|
||||
| 'error'
|
||||
| 'validation'
|
||||
|
||||
interface CopyItem {
|
||||
route: string;
|
||||
file: string;
|
||||
componentOrFn: string;
|
||||
kind: CopyKind;
|
||||
keyOrLocator: string;
|
||||
text: string;
|
||||
line: number;
|
||||
notes?: string;
|
||||
route: string
|
||||
file: string
|
||||
componentOrFn: string
|
||||
kind: CopyKind
|
||||
keyOrLocator: string
|
||||
text: string
|
||||
line: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
interface I18nKey {
|
||||
key: string;
|
||||
value: string;
|
||||
context?: string;
|
||||
file: string;
|
||||
line: number;
|
||||
key: string
|
||||
value: string
|
||||
context?: string
|
||||
file: string
|
||||
line: number
|
||||
}
|
||||
|
||||
const WORKDIR = process.cwd();
|
||||
const SRC_DIR = path.join(WORKDIR, "src");
|
||||
const APP_DIR = path.join(SRC_DIR, "app");
|
||||
const WORKDIR = process.cwd()
|
||||
const SRC_DIR = path.join(WORKDIR, 'src')
|
||||
const APP_DIR = path.join(SRC_DIR, 'app')
|
||||
|
||||
function ensureExcelDir() {
|
||||
const docsDir = path.join(WORKDIR, "docs");
|
||||
if (!fs.existsSync(docsDir)) fs.mkdirSync(docsDir, { recursive: true });
|
||||
const docsDir = path.join(WORKDIR, 'docs')
|
||||
if (!fs.existsSync(docsDir)) fs.mkdirSync(docsDir, { recursive: true })
|
||||
}
|
||||
|
||||
function isMeaningfulText(value: string | undefined | null): value is string {
|
||||
if (!value) return false;
|
||||
const trimmed = value.replace(/\s+/g, " ").trim();
|
||||
if (!trimmed) return false;
|
||||
if (!value) return false
|
||||
const trimmed = value.replace(/\s+/g, ' ').trim()
|
||||
if (!trimmed) return false
|
||||
// Filter obvious code-like tokens
|
||||
if (/^[A-Za-z0-9_.$-]+$/.test(trimmed) && trimmed.length > 24) return false;
|
||||
return true;
|
||||
if (/^[A-Za-z0-9_.$-]+$/.test(trimmed) && trimmed.length > 24) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function getRouteForFile(absFilePath: string): string {
|
||||
if (!absFilePath.startsWith(APP_DIR)) return "shared";
|
||||
let dir = path.dirname(absFilePath);
|
||||
if (!absFilePath.startsWith(APP_DIR)) return 'shared'
|
||||
let dir = path.dirname(absFilePath)
|
||||
// Walk up to find nearest folder that contains a page.tsx (or page.ts)
|
||||
while (dir.startsWith(APP_DIR)) {
|
||||
const pageTsx = path.join(dir, "page.tsx");
|
||||
const pageTs = path.join(dir, "page.ts");
|
||||
const pageTsx = path.join(dir, 'page.tsx')
|
||||
const pageTs = path.join(dir, 'page.ts')
|
||||
if (fs.existsSync(pageTsx) || fs.existsSync(pageTs)) {
|
||||
const rel = path.relative(APP_DIR, dir);
|
||||
return rel || "/";
|
||||
const rel = path.relative(APP_DIR, dir)
|
||||
return rel || '/'
|
||||
}
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
const parent = path.dirname(dir)
|
||||
if (parent === dir) break
|
||||
dir = parent
|
||||
}
|
||||
// Fallback: route is the first app subfolder segment
|
||||
const relToApp = path.relative(APP_DIR, absFilePath);
|
||||
const parts = relToApp.split(path.sep);
|
||||
return parts.length > 0 ? parts[0] : "shared";
|
||||
const relToApp = path.relative(APP_DIR, absFilePath)
|
||||
const parts = relToApp.split(path.sep)
|
||||
return parts.length > 0 ? parts[0] : 'shared'
|
||||
}
|
||||
|
||||
function getComponentOrFnName(node: Node): string {
|
||||
const fn = node.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration);
|
||||
if (fn?.getName()) return fn.getName()!;
|
||||
const varDecl = node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration);
|
||||
if (varDecl?.getName()) return varDecl.getName();
|
||||
const cls = node.getFirstAncestorByKind(SyntaxKind.ClassDeclaration);
|
||||
if (cls?.getName()) return cls.getName()!;
|
||||
const sf = node.getSourceFile();
|
||||
return path.basename(sf.getFilePath());
|
||||
const fn = node.getFirstAncestorByKind(SyntaxKind.FunctionDeclaration)
|
||||
if (fn?.getName()) return fn.getName()!
|
||||
const varDecl = node.getFirstAncestorByKind(SyntaxKind.VariableDeclaration)
|
||||
if (varDecl?.getName()) return varDecl.getName()
|
||||
const cls = node.getFirstAncestorByKind(SyntaxKind.ClassDeclaration)
|
||||
if (cls?.getName()) return cls.getName()!
|
||||
const sf = node.getSourceFile()
|
||||
return path.basename(sf.getFilePath())
|
||||
}
|
||||
|
||||
function getNodeLine(node: Node): number {
|
||||
const pos = node.getStartLineNumber();
|
||||
return pos ?? 1;
|
||||
const pos = node.getStartLineNumber()
|
||||
return pos ?? 1
|
||||
}
|
||||
|
||||
function getAttrName(attr: JsxAttribute): string {
|
||||
return attr.getNameNode().getText();
|
||||
return attr.getNameNode().getText()
|
||||
}
|
||||
|
||||
function getStringFromInitializer(attr: JsxAttribute): string | undefined {
|
||||
const init = attr.getInitializer();
|
||||
if (!init) return undefined;
|
||||
if (Node.isStringLiteral(init)) return init.getLiteralText();
|
||||
if (Node.isNoSubstitutionTemplateLiteral(init)) return init.getLiteralText();
|
||||
const init = attr.getInitializer()
|
||||
if (!init) return undefined
|
||||
if (Node.isStringLiteral(init)) return init.getLiteralText()
|
||||
if (Node.isNoSubstitutionTemplateLiteral(init)) return init.getLiteralText()
|
||||
if (Node.isJsxExpression(init)) {
|
||||
const expr = init.getExpression();
|
||||
if (!expr) return undefined;
|
||||
if (Node.isStringLiteral(expr)) return expr.getLiteralText();
|
||||
if (Node.isNoSubstitutionTemplateLiteral(expr)) return expr.getLiteralText();
|
||||
const expr = init.getExpression()
|
||||
if (!expr) return undefined
|
||||
if (Node.isStringLiteral(expr)) return expr.getLiteralText()
|
||||
if (Node.isNoSubstitutionTemplateLiteral(expr)) return expr.getLiteralText()
|
||||
}
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
|
||||
function pushItem(items: CopyItem[], item: CopyItem) {
|
||||
if (!isMeaningfulText(item.text)) return;
|
||||
items.push(item);
|
||||
if (!isMeaningfulText(item.text)) return
|
||||
items.push(item)
|
||||
}
|
||||
|
||||
function generateI18nKey(item: CopyItem): string {
|
||||
// 生成 i18next 格式的键名
|
||||
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 kind = item.kind;
|
||||
const locator = item.keyOrLocator.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 kind = item.kind
|
||||
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[]> {
|
||||
const patterns = [
|
||||
"src/**/*.{ts,tsx}",
|
||||
];
|
||||
const patterns = ['src/**/*.{ts,tsx}']
|
||||
const ignore = [
|
||||
"**/node_modules/**",
|
||||
"**/.next/**",
|
||||
"**/__tests__/**",
|
||||
"**/mocks/**",
|
||||
"**/mock/**",
|
||||
"**/*.d.ts",
|
||||
];
|
||||
return await globby(patterns, { gitignore: true, ignore });
|
||||
'**/node_modules/**',
|
||||
'**/.next/**',
|
||||
'**/__tests__/**',
|
||||
'**/mocks/**',
|
||||
'**/mock/**',
|
||||
'**/*.d.ts',
|
||||
]
|
||||
return await globby(patterns, { gitignore: true, ignore })
|
||||
}
|
||||
|
||||
function extractFromSourceFile(abs: string, items: CopyItem[], project: Project) {
|
||||
const sf = project.addSourceFileAtPath(abs);
|
||||
const sf = project.addSourceFileAtPath(abs)
|
||||
// JSX text nodes
|
||||
sf.forEachDescendant((node) => {
|
||||
if (Node.isJsxElement(node)) {
|
||||
const opening = node.getOpeningElement();
|
||||
const componentOrFn = getComponentOrFnName(node);
|
||||
const route = getRouteForFile(abs);
|
||||
const opening = node.getOpeningElement()
|
||||
const componentOrFn = getComponentOrFnName(node)
|
||||
const route = getRouteForFile(abs)
|
||||
// 递归提取所有 JsxText 与 {'...'} 字面量
|
||||
const tagName = opening.getTagNameNode().getText();
|
||||
const textNodes = node.getDescendantsOfKind(SyntaxKind.JsxText);
|
||||
const tagName = opening.getTagNameNode().getText()
|
||||
const textNodes = node.getDescendantsOfKind(SyntaxKind.JsxText)
|
||||
textNodes.forEach((t) => {
|
||||
const text = t.getText();
|
||||
const cleaned = text.replace(/\s+/g, " ").trim();
|
||||
const text = t.getText()
|
||||
const cleaned = text.replace(/\s+/g, ' ').trim()
|
||||
if (isMeaningfulText(cleaned)) {
|
||||
pushItem(items, {
|
||||
route,
|
||||
file: path.relative(WORKDIR, abs),
|
||||
componentOrFn,
|
||||
kind: "text",
|
||||
kind: 'text',
|
||||
keyOrLocator: tagName,
|
||||
text: cleaned,
|
||||
line: getNodeLine(t),
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
const exprs = node.getDescendantsOfKind(SyntaxKind.JsxExpression);
|
||||
})
|
||||
const exprs = node.getDescendantsOfKind(SyntaxKind.JsxExpression)
|
||||
exprs.forEach((expr) => {
|
||||
const inner = expr.getExpression();
|
||||
const inner = expr.getExpression()
|
||||
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)) {
|
||||
pushItem(items, {
|
||||
route,
|
||||
file: path.relative(WORKDIR, abs),
|
||||
componentOrFn,
|
||||
kind: "text",
|
||||
kind: 'text',
|
||||
keyOrLocator: tagName,
|
||||
text: cleaned,
|
||||
line: getNodeLine(expr),
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// JSX attributes
|
||||
if (Node.isJsxOpeningElement(node) || Node.isJsxSelfClosingElement(node)) {
|
||||
const route = getRouteForFile(abs);
|
||||
const componentOrFn = getComponentOrFnName(node);
|
||||
const route = getRouteForFile(abs)
|
||||
const componentOrFn = getComponentOrFnName(node)
|
||||
const tag = Node.isJsxOpeningElement(node)
|
||||
? node.getTagNameNode().getText()
|
||||
: node.getTagNameNode().getText();
|
||||
const attrs = node.getAttributes().filter(Node.isJsxAttribute);
|
||||
: node.getTagNameNode().getText()
|
||||
const attrs = node.getAttributes().filter(Node.isJsxAttribute)
|
||||
attrs.forEach((attr) => {
|
||||
const name = getAttrName(attr);
|
||||
const lower = name.toLowerCase();
|
||||
const value = getStringFromInitializer(attr);
|
||||
if (!value) return;
|
||||
let kind: CopyKind | null = null;
|
||||
if (lower === "placeholder") kind = "placeholder";
|
||||
else if (lower === "title") kind = "title";
|
||||
else if (lower === "alt") kind = "alt";
|
||||
else if (lower.startsWith("aria-")) kind = "aria";
|
||||
else if (lower === "label") kind = "label";
|
||||
const name = getAttrName(attr)
|
||||
const lower = name.toLowerCase()
|
||||
const value = getStringFromInitializer(attr)
|
||||
if (!value) return
|
||||
let kind: CopyKind | null = null
|
||||
if (lower === 'placeholder') kind = 'placeholder'
|
||||
else if (lower === 'title') kind = 'title'
|
||||
else if (lower === 'alt') kind = 'alt'
|
||||
else if (lower.startsWith('aria-')) kind = 'aria'
|
||||
else if (lower === 'label') kind = 'label'
|
||||
if (kind) {
|
||||
pushItem(items, {
|
||||
route,
|
||||
|
|
@ -217,40 +222,40 @@ function extractFromSourceFile(abs: string, items: CopyItem[], project: Project)
|
|||
keyOrLocator: `${tag}.${name}`,
|
||||
text: value,
|
||||
line: getNodeLine(attr),
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
// Interaction messages: toast.*, alert, confirm, message.*
|
||||
if (Node.isCallExpression(node)) {
|
||||
const route = getRouteForFile(abs);
|
||||
const componentOrFn = getComponentOrFnName(node);
|
||||
const expr = node.getExpression();
|
||||
let kind: CopyKind | null = null;
|
||||
let keyOrLocator = "";
|
||||
const route = getRouteForFile(abs)
|
||||
const componentOrFn = getComponentOrFnName(node)
|
||||
const expr = node.getExpression()
|
||||
let kind: CopyKind | null = null
|
||||
let keyOrLocator = ''
|
||||
if (Node.isPropertyAccessExpression(expr)) {
|
||||
const left = expr.getExpression().getText();
|
||||
const name = expr.getName();
|
||||
if (left === "toast" || left === "message") {
|
||||
kind = "toast";
|
||||
keyOrLocator = `${left}.${name}`;
|
||||
const left = expr.getExpression().getText()
|
||||
const name = expr.getName()
|
||||
if (left === 'toast' || left === 'message') {
|
||||
kind = 'toast'
|
||||
keyOrLocator = `${left}.${name}`
|
||||
}
|
||||
if (left.toLowerCase().includes("dialog")) {
|
||||
kind = "dialog";
|
||||
keyOrLocator = `${left}.${name}`;
|
||||
if (left.toLowerCase().includes('dialog')) {
|
||||
kind = 'dialog'
|
||||
keyOrLocator = `${left}.${name}`
|
||||
}
|
||||
} else if (Node.isIdentifier(expr)) {
|
||||
const id = expr.getText();
|
||||
if (id === "alert" || id === "confirm") {
|
||||
kind = "dialog";
|
||||
keyOrLocator = id;
|
||||
const id = expr.getText()
|
||||
if (id === 'alert' || id === 'confirm') {
|
||||
kind = 'dialog'
|
||||
keyOrLocator = id
|
||||
}
|
||||
}
|
||||
if (kind) {
|
||||
const arg0 = node.getArguments()[0];
|
||||
const arg0 = node.getArguments()[0]
|
||||
if (arg0 && (Node.isStringLiteral(arg0) || Node.isNoSubstitutionTemplateLiteral(arg0))) {
|
||||
const text = (arg0 as StringLiteral | NoSubstitutionTemplateLiteral).getLiteralText();
|
||||
const text = (arg0 as StringLiteral | NoSubstitutionTemplateLiteral).getLiteralText()
|
||||
pushItem(items, {
|
||||
route,
|
||||
file: path.relative(WORKDIR, abs),
|
||||
|
|
@ -259,30 +264,33 @@ function extractFromSourceFile(abs: string, items: CopyItem[], project: Project)
|
|||
keyOrLocator,
|
||||
text,
|
||||
line: getNodeLine(node),
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// form.setError("field", { message: "..." })
|
||||
if (Node.isPropertyAccessExpression(expr) && expr.getName() === "setError") {
|
||||
const args = node.getArguments();
|
||||
if (Node.isPropertyAccessExpression(expr) && expr.getName() === 'setError') {
|
||||
const args = node.getArguments()
|
||||
if (args.length >= 2) {
|
||||
const second = args[1];
|
||||
const second = args[1]
|
||||
if (Node.isObjectLiteralExpression(second)) {
|
||||
const msgProp = second.getProperty("message");
|
||||
const msgProp = second.getProperty('message')
|
||||
if (msgProp && Node.isPropertyAssignment(msgProp)) {
|
||||
const init = msgProp.getInitializer();
|
||||
if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) {
|
||||
const text = init.getLiteralText();
|
||||
const init = msgProp.getInitializer()
|
||||
if (
|
||||
init &&
|
||||
(Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
|
||||
) {
|
||||
const text = init.getLiteralText()
|
||||
pushItem(items, {
|
||||
route,
|
||||
file: path.relative(WORKDIR, abs),
|
||||
componentOrFn,
|
||||
kind: "error",
|
||||
keyOrLocator: "form.setError",
|
||||
kind: 'error',
|
||||
keyOrLocator: 'form.setError',
|
||||
text,
|
||||
line: getNodeLine(msgProp),
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -290,59 +298,62 @@ function extractFromSourceFile(abs: string, items: CopyItem[], project: Project)
|
|||
}
|
||||
|
||||
// Generic validation: any object literal { message: "..." } inside chained calls
|
||||
const args = node.getArguments();
|
||||
const args = node.getArguments()
|
||||
for (const a of args) {
|
||||
if (Node.isObjectLiteralExpression(a)) {
|
||||
const prop = a.getProperty("message");
|
||||
const prop = a.getProperty('message')
|
||||
if (prop && Node.isPropertyAssignment(prop)) {
|
||||
const init = prop.getInitializer();
|
||||
if (init && (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))) {
|
||||
const text = init.getLiteralText();
|
||||
const init = prop.getInitializer()
|
||||
if (
|
||||
init &&
|
||||
(Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init))
|
||||
) {
|
||||
const text = init.getLiteralText()
|
||||
pushItem(items, {
|
||||
route,
|
||||
file: path.relative(WORKDIR, abs),
|
||||
componentOrFn,
|
||||
kind: "validation",
|
||||
keyOrLocator: "message",
|
||||
kind: 'validation',
|
||||
keyOrLocator: 'message',
|
||||
text,
|
||||
line: getNodeLine(prop),
|
||||
});
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
function aggregate(items: CopyItem[]): CopyItem[] {
|
||||
// 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) {
|
||||
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 });
|
||||
map.set(key, { item: it, count: 1 })
|
||||
} else {
|
||||
map.get(key)!.count += 1;
|
||||
map.get(key)!.count += 1
|
||||
}
|
||||
}
|
||||
const result: CopyItem[] = [];
|
||||
const result: CopyItem[] = []
|
||||
for (const { item, count } of map.values()) {
|
||||
(item as any).count = count;
|
||||
result.push(item);
|
||||
;(item as any).count = count
|
||||
result.push(item)
|
||||
}
|
||||
return result;
|
||||
return result
|
||||
}
|
||||
|
||||
function generateI18nTranslation(items: CopyItem[]): Record<string, string> {
|
||||
const translation: Record<string, string> = {};
|
||||
const translation: Record<string, string> = {}
|
||||
|
||||
items.forEach((item) => {
|
||||
const key = generateI18nKey(item);
|
||||
translation[key] = item.text;
|
||||
});
|
||||
const key = generateI18nKey(item)
|
||||
translation[key] = item.text
|
||||
})
|
||||
|
||||
return translation;
|
||||
return translation
|
||||
}
|
||||
|
||||
function toWorkbook(items: CopyItem[]): XLSX.WorkBook {
|
||||
|
|
@ -356,75 +367,81 @@ function toWorkbook(items: CopyItem[]): XLSX.WorkBook {
|
|||
line: it.line,
|
||||
count: (it as any).count ?? 1,
|
||||
i18nKey: generateI18nKey(it),
|
||||
notes: it.notes ?? "",
|
||||
}));
|
||||
const ws = XLSX.utils.json_to_sheet(rows, { skipHeader: false });
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, "i18n-scan");
|
||||
return wb;
|
||||
notes: it.notes ?? '',
|
||||
}))
|
||||
const ws = XLSX.utils.json_to_sheet(rows, { skipHeader: false })
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'i18n-scan')
|
||||
return wb
|
||||
}
|
||||
|
||||
async function main() {
|
||||
ensureExcelDir();
|
||||
const files = await collectFiles();
|
||||
ensureExcelDir()
|
||||
const files = await collectFiles()
|
||||
const project = new Project({
|
||||
tsConfigFilePath: path.join(WORKDIR, "tsconfig.json"),
|
||||
tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'),
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
});
|
||||
const items: CopyItem[] = [];
|
||||
})
|
||||
const items: CopyItem[] = []
|
||||
for (const rel of files) {
|
||||
const abs = path.join(WORKDIR, rel);
|
||||
const abs = path.join(WORKDIR, rel)
|
||||
try {
|
||||
extractFromSourceFile(abs, items, project);
|
||||
extractFromSourceFile(abs, items, project)
|
||||
} catch (e) {
|
||||
// swallow parse errors but continue
|
||||
}
|
||||
}
|
||||
const aggregated = aggregate(items);
|
||||
const aggregated = aggregate(items)
|
||||
|
||||
// 生成 i18next 格式的翻译文件
|
||||
const translation = generateI18nTranslation(aggregated);
|
||||
const localesDir = path.join(WORKDIR, "public", "locales", "en");
|
||||
const translation = generateI18nTranslation(aggregated)
|
||||
const localesDir = path.join(WORKDIR, 'public', 'locales', 'en')
|
||||
if (!fs.existsSync(localesDir)) {
|
||||
fs.mkdirSync(localesDir, { recursive: true });
|
||||
fs.mkdirSync(localesDir, { recursive: true })
|
||||
}
|
||||
const translationFile = path.join(localesDir, "translation.json");
|
||||
fs.writeFileSync(translationFile, JSON.stringify(translation, null, 2));
|
||||
const translationFile = path.join(localesDir, 'translation.json')
|
||||
fs.writeFileSync(translationFile, JSON.stringify(translation, null, 2))
|
||||
|
||||
// 生成 Excel 报告
|
||||
const wb = toWorkbook(aggregated);
|
||||
const out = path.join(WORKDIR, "docs", "i18n-scan-report.xlsx");
|
||||
XLSX.writeFile(wb, out);
|
||||
const wb = toWorkbook(aggregated)
|
||||
const out = path.join(WORKDIR, 'docs', 'i18n-scan-report.xlsx')
|
||||
XLSX.writeFile(wb, out)
|
||||
|
||||
// 生成扫描报告
|
||||
const report = {
|
||||
totalItems: aggregated.length,
|
||||
uniqueTexts: new Set(aggregated.map(item => item.text)).size,
|
||||
byRoute: aggregated.reduce((acc, item) => {
|
||||
acc[item.route] = (acc[item.route] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>),
|
||||
byKind: aggregated.reduce((acc, item) => {
|
||||
acc[item.kind] = (acc[item.kind] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>),
|
||||
translationKeys: Object.keys(translation).length
|
||||
};
|
||||
uniqueTexts: new Set(aggregated.map((item) => item.text)).size,
|
||||
byRoute: aggregated.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.route] = (acc[item.route] || 0) + 1
|
||||
return acc
|
||||
},
|
||||
{} as Record<string, number>
|
||||
),
|
||||
byKind: aggregated.reduce(
|
||||
(acc, item) => {
|
||||
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");
|
||||
fs.writeFileSync(reportFile, JSON.stringify(report, null, 2));
|
||||
const reportFile = path.join(WORKDIR, 'docs', 'i18n-scan-report.json')
|
||||
fs.writeFileSync(reportFile, JSON.stringify(report, null, 2))
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`✅ i18next 扫描完成!`);
|
||||
console.log(`📊 总扫描条目: ${aggregated.length}`);
|
||||
console.log(`🔑 生成翻译键: ${Object.keys(translation).length}`);
|
||||
console.log(`📁 翻译文件: ${translationFile}`);
|
||||
console.log(`📋 Excel 报告: ${out}`);
|
||||
console.log(`📄 JSON 报告: ${reportFile}`);
|
||||
console.log(`✅ i18next 扫描完成!`)
|
||||
console.log(`📊 总扫描条目: ${aggregated.length}`)
|
||||
console.log(`🔑 生成翻译键: ${Object.keys(translation).length}`)
|
||||
console.log(`📁 翻译文件: ${translationFile}`)
|
||||
console.log(`📋 Excel 报告: ${out}`)
|
||||
console.log(`📄 JSON 报告: ${reportFile}`)
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
console.error(err)
|
||||
process.exitCode = 1
|
||||
})
|
||||
|
|
|
|||
|
|
@ -2,16 +2,16 @@
|
|||
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.
|
||||
*/
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const { execSync } = require('child_process');
|
||||
const { Project, SyntaxKind, Node } = require('ts-morph');
|
||||
const XLSX = require('xlsx');
|
||||
const path = require('node:path')
|
||||
const fs = require('node:fs')
|
||||
const { execSync } = require('child_process')
|
||||
const { Project, SyntaxKind, Node } = require('ts-morph')
|
||||
const XLSX = require('xlsx')
|
||||
|
||||
const WORKDIR = process.cwd();
|
||||
const TRANSLATES_FILE = path.join(WORKDIR, 'scripts', 'translates.xlsx');
|
||||
const REPORT_FILE = path.join(WORKDIR, 'scripts', 'translation-report.json');
|
||||
const CONFLICTS_FILE = path.join(WORKDIR, 'scripts', 'translation-conflicts.xlsx');
|
||||
const WORKDIR = process.cwd()
|
||||
const TRANSLATES_FILE = path.join(WORKDIR, 'scripts', 'translates.xlsx')
|
||||
const REPORT_FILE = path.join(WORKDIR, 'scripts', 'translation-report.json')
|
||||
const CONFLICTS_FILE = path.join(WORKDIR, 'scripts', 'translation-conflicts.xlsx')
|
||||
|
||||
// 统计信息
|
||||
const stats = {
|
||||
|
|
@ -20,289 +20,286 @@ const stats = {
|
|||
conflicts: 0,
|
||||
fileNotFound: 0,
|
||||
textNotFound: 0,
|
||||
multipleMatches: 0
|
||||
};
|
||||
multipleMatches: 0,
|
||||
}
|
||||
|
||||
// 冲突列表
|
||||
const conflicts = [];
|
||||
const conflicts = []
|
||||
|
||||
// 成功替换列表
|
||||
const successfulReplacements = [];
|
||||
const successfulReplacements = []
|
||||
|
||||
function resetFiles() {
|
||||
console.log('🔄 重置文件到原始状态...');
|
||||
console.log('🔄 重置文件到原始状态...')
|
||||
try {
|
||||
// 使用 git 重置所有修改的文件
|
||||
execSync('git checkout -- .', { cwd: WORKDIR, stdio: 'inherit' });
|
||||
console.log('✅ 文件重置完成');
|
||||
execSync('git checkout -- .', { cwd: WORKDIR, stdio: 'inherit' })
|
||||
console.log('✅ 文件重置完成')
|
||||
} catch (error) {
|
||||
console.error('❌ 重置文件失败:', error.message);
|
||||
process.exit(1);
|
||||
console.error('❌ 重置文件失败:', error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
function loadTranslations() {
|
||||
console.log('📖 读取翻译数据...');
|
||||
const wb = XLSX.readFile(TRANSLATES_FILE);
|
||||
const ws = wb.Sheets[wb.SheetNames[0]];
|
||||
const data = XLSX.utils.sheet_to_json(ws, { defval: '' });
|
||||
console.log('📖 读取翻译数据...')
|
||||
const wb = XLSX.readFile(TRANSLATES_FILE)
|
||||
const ws = wb.Sheets[wb.SheetNames[0]]
|
||||
const data = XLSX.utils.sheet_to_json(ws, { defval: '' })
|
||||
|
||||
// 筛选出需要替换的条目
|
||||
let translations = data.filter(row =>
|
||||
row.text &&
|
||||
row.corrected_text &&
|
||||
row.text !== row.corrected_text
|
||||
);
|
||||
let translations = data.filter(
|
||||
(row) => row.text && row.corrected_text && row.text !== row.corrected_text
|
||||
)
|
||||
|
||||
// 去重:按 file + line + text 去重,保留第一个
|
||||
const seen = new Set();
|
||||
translations = translations.filter(row => {
|
||||
const key = `${row.file}:${row.line}:${row.text}`;
|
||||
const seen = new Set()
|
||||
translations = translations.filter((row) => {
|
||||
const key = `${row.file}:${row.line}:${row.text}`
|
||||
if (seen.has(key)) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
seen.add(key)
|
||||
return true
|
||||
})
|
||||
|
||||
console.log(`📊 找到 ${translations.length} 条需要替换的翻译(已去重)`);
|
||||
stats.total = translations.length;
|
||||
console.log(`📊 找到 ${translations.length} 条需要替换的翻译(已去重)`)
|
||||
stats.total = translations.length
|
||||
|
||||
return translations;
|
||||
return translations
|
||||
}
|
||||
|
||||
function groupByFile(translations) {
|
||||
const groups = new Map();
|
||||
const groups = new Map()
|
||||
for (const translation of translations) {
|
||||
const filePath = path.join(WORKDIR, translation.file);
|
||||
const filePath = path.join(WORKDIR, translation.file)
|
||||
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) {
|
||||
const { text, kind } = translation;
|
||||
const matches = [];
|
||||
const { text, kind } = translation
|
||||
const matches = []
|
||||
|
||||
sourceFile.forEachDescendant((node) => {
|
||||
// 根据 kind 类型进行不同的匹配
|
||||
if (kind === 'text') {
|
||||
// 查找 JSX 文本节点
|
||||
if (Node.isJsxText(node)) {
|
||||
const nodeText = node.getText().replace(/\s+/g, ' ').trim();
|
||||
const nodeText = node.getText().replace(/\s+/g, ' ').trim()
|
||||
if (nodeText === text) {
|
||||
matches.push({ node, type: 'jsx-text', line: node.getStartLineNumber() });
|
||||
matches.push({ node, type: 'jsx-text', line: node.getStartLineNumber() })
|
||||
}
|
||||
}
|
||||
// 查找 JSX 表达式中的字符串
|
||||
if (Node.isJsxExpression(node)) {
|
||||
const expr = node.getExpression();
|
||||
const expr = node.getExpression()
|
||||
if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) {
|
||||
const nodeText = expr.getLiteralText();
|
||||
const nodeText = expr.getLiteralText()
|
||||
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)) {
|
||||
// 查找 JSX 属性
|
||||
if (Node.isJsxAttribute(node)) {
|
||||
const name = node.getNameNode().getText().toLowerCase();
|
||||
const value = getStringFromInitializer(node);
|
||||
const name = node.getNameNode().getText().toLowerCase()
|
||||
const value = getStringFromInitializer(node)
|
||||
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)) {
|
||||
// 查找函数调用中的字符串参数
|
||||
if (Node.isCallExpression(node)) {
|
||||
const args = node.getArguments();
|
||||
const args = node.getArguments()
|
||||
for (const arg of args) {
|
||||
if (Node.isStringLiteral(arg) || Node.isNoSubstitutionTemplateLiteral(arg)) {
|
||||
const nodeText = arg.getLiteralText();
|
||||
const nodeText = arg.getLiteralText()
|
||||
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) {
|
||||
const init = attr.getInitializer();
|
||||
if (!init) return undefined;
|
||||
const init = attr.getInitializer()
|
||||
if (!init) return undefined
|
||||
if (Node.isStringLiteral(init) || Node.isNoSubstitutionTemplateLiteral(init)) {
|
||||
return init.getLiteralText();
|
||||
return init.getLiteralText()
|
||||
}
|
||||
if (Node.isJsxExpression(init)) {
|
||||
const expr = init.getExpression();
|
||||
if (!expr) return undefined;
|
||||
const expr = init.getExpression()
|
||||
if (!expr) return undefined
|
||||
if (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr)) {
|
||||
return expr.getLiteralText();
|
||||
return expr.getLiteralText()
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
return undefined
|
||||
}
|
||||
|
||||
function replaceText(node, newText, type) {
|
||||
try {
|
||||
if (type === 'jsx-text') {
|
||||
// JSX 文本节点需要特殊处理,保持空白字符
|
||||
const originalText = node.getText();
|
||||
const newTextWithWhitespace = originalText.replace(/\S+/g, newText);
|
||||
node.replaceWithText(newTextWithWhitespace);
|
||||
const originalText = node.getText()
|
||||
const newTextWithWhitespace = originalText.replace(/\S+/g, newText)
|
||||
node.replaceWithText(newTextWithWhitespace)
|
||||
} else if (type === 'jsx-expression' || type === 'function-arg') {
|
||||
// 字符串字面量
|
||||
if (Node.isStringLiteral(node)) {
|
||||
node.replaceWithText(`"${newText}"`);
|
||||
node.replaceWithText(`"${newText}"`)
|
||||
} else if (Node.isNoSubstitutionTemplateLiteral(node)) {
|
||||
node.replaceWithText(`\`${newText}\``);
|
||||
node.replaceWithText(`\`${newText}\``)
|
||||
}
|
||||
} else if (type === 'jsx-attribute') {
|
||||
// JSX 属性值
|
||||
const init = node.getInitializer();
|
||||
const init = node.getInitializer()
|
||||
if (init) {
|
||||
if (Node.isStringLiteral(init)) {
|
||||
init.replaceWithText(`"${newText}"`);
|
||||
init.replaceWithText(`"${newText}"`)
|
||||
} else if (Node.isNoSubstitutionTemplateLiteral(init)) {
|
||||
init.replaceWithText(`\`${newText}\``);
|
||||
init.replaceWithText(`\`${newText}\``)
|
||||
} else if (Node.isJsxExpression(init)) {
|
||||
const expr = init.getExpression();
|
||||
const expr = init.getExpression()
|
||||
if (expr && (Node.isStringLiteral(expr) || Node.isNoSubstitutionTemplateLiteral(expr))) {
|
||||
if (Node.isStringLiteral(expr)) {
|
||||
expr.replaceWithText(`"${newText}"`);
|
||||
expr.replaceWithText(`"${newText}"`)
|
||||
} else {
|
||||
expr.replaceWithText(`\`${newText}\``);
|
||||
expr.replaceWithText(`\`${newText}\``)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error(`❌ 替换失败: ${error.message}`);
|
||||
return false;
|
||||
console.error(`❌ 替换失败: ${error.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function processFile(filePath, translations) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.log(`❌ 文件不存在: ${path.relative(WORKDIR, filePath)}`);
|
||||
translations.forEach(t => {
|
||||
console.log(`❌ 文件不存在: ${path.relative(WORKDIR, filePath)}`)
|
||||
translations.forEach((t) => {
|
||||
conflicts.push({
|
||||
...t,
|
||||
conflictType: 'FILE_NOT_FOUND',
|
||||
conflictReason: '文件不存在'
|
||||
});
|
||||
});
|
||||
stats.fileNotFound += translations.length;
|
||||
return;
|
||||
conflictReason: '文件不存在',
|
||||
})
|
||||
})
|
||||
stats.fileNotFound += translations.length
|
||||
return
|
||||
}
|
||||
|
||||
console.log(`📝 处理文件: ${path.relative(WORKDIR, filePath)}`);
|
||||
console.log(`📝 处理文件: ${path.relative(WORKDIR, filePath)}`)
|
||||
|
||||
try {
|
||||
const project = new Project({
|
||||
tsConfigFilePath: path.join(WORKDIR, 'tsconfig.json'),
|
||||
skipAddingFilesFromTsConfig: true
|
||||
});
|
||||
const sourceFile = project.addSourceFileAtPath(filePath);
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
})
|
||||
const sourceFile = project.addSourceFileAtPath(filePath)
|
||||
|
||||
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) {
|
||||
conflicts.push({
|
||||
...translation,
|
||||
conflictType: 'TEXT_NOT_FOUND_IN_FILE',
|
||||
conflictReason: '在文件中找不到匹配的文本'
|
||||
});
|
||||
stats.textNotFound++;
|
||||
continue;
|
||||
conflictReason: '在文件中找不到匹配的文本',
|
||||
})
|
||||
stats.textNotFound++
|
||||
continue
|
||||
}
|
||||
|
||||
if (matches.length > 1) {
|
||||
conflicts.push({
|
||||
...translation,
|
||||
conflictType: 'MULTIPLE_MATCHES',
|
||||
conflictReason: `找到 ${matches.length} 个匹配项,需要人工确认`
|
||||
});
|
||||
stats.multipleMatches++;
|
||||
continue;
|
||||
conflictReason: `找到 ${matches.length} 个匹配项,需要人工确认`,
|
||||
})
|
||||
stats.multipleMatches++
|
||||
continue
|
||||
}
|
||||
|
||||
// 执行替换
|
||||
const match = matches[0];
|
||||
const success = replaceText(match.node, corrected_text, match.type);
|
||||
const match = matches[0]
|
||||
const success = replaceText(match.node, corrected_text, match.type)
|
||||
|
||||
if (success) {
|
||||
successfulReplacements.push({
|
||||
...translation,
|
||||
actualLine: match.line,
|
||||
replacementType: match.type
|
||||
});
|
||||
stats.success++;
|
||||
console.log(`✅ 替换成功: "${text}" -> "${corrected_text}" (行 ${match.line})`);
|
||||
replacementType: match.type,
|
||||
})
|
||||
stats.success++
|
||||
console.log(`✅ 替换成功: "${text}" -> "${corrected_text}" (行 ${match.line})`)
|
||||
} else {
|
||||
conflicts.push({
|
||||
...translation,
|
||||
conflictType: 'REPLACEMENT_FAILED',
|
||||
conflictReason: '替换操作失败'
|
||||
});
|
||||
stats.conflicts++;
|
||||
conflictReason: '替换操作失败',
|
||||
})
|
||||
stats.conflicts++
|
||||
}
|
||||
}
|
||||
|
||||
// 保存修改后的文件
|
||||
sourceFile.saveSync();
|
||||
|
||||
sourceFile.saveSync()
|
||||
} catch (error) {
|
||||
console.error(`❌ 处理文件失败: ${filePath}`, error.message);
|
||||
translations.forEach(t => {
|
||||
console.error(`❌ 处理文件失败: ${filePath}`, error.message)
|
||||
translations.forEach((t) => {
|
||||
conflicts.push({
|
||||
...t,
|
||||
conflictType: 'PARSE_ERROR',
|
||||
conflictReason: `文件解析失败: ${error.message}`
|
||||
});
|
||||
});
|
||||
stats.conflicts += translations.length;
|
||||
conflictReason: `文件解析失败: ${error.message}`,
|
||||
})
|
||||
})
|
||||
stats.conflicts += translations.length
|
||||
}
|
||||
}
|
||||
|
||||
function generateReport() {
|
||||
console.log('\n📊 生成报告...');
|
||||
console.log('\n📊 生成报告...')
|
||||
|
||||
// 生成成功替换报告
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
stats,
|
||||
successfulReplacements,
|
||||
conflicts: conflicts.map(c => ({
|
||||
conflicts: conflicts.map((c) => ({
|
||||
file: c.file,
|
||||
line: c.line,
|
||||
text: c.text,
|
||||
corrected_text: c.corrected_text,
|
||||
conflictType: c.conflictType,
|
||||
conflictReason: c.conflictReason
|
||||
}))
|
||||
};
|
||||
conflictReason: c.conflictReason,
|
||||
})),
|
||||
}
|
||||
|
||||
fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2));
|
||||
console.log(`📄 成功替换报告已保存: ${REPORT_FILE}`);
|
||||
fs.writeFileSync(REPORT_FILE, JSON.stringify(report, null, 2))
|
||||
console.log(`📄 成功替换报告已保存: ${REPORT_FILE}`)
|
||||
|
||||
// 生成冲突报告 Excel
|
||||
if (conflicts.length > 0) {
|
||||
const conflictRows = conflicts.map(c => ({
|
||||
const conflictRows = conflicts.map((c) => ({
|
||||
file: c.file,
|
||||
line: c.line,
|
||||
text: c.text,
|
||||
|
|
@ -312,56 +309,55 @@ function generateReport() {
|
|||
route: c.route,
|
||||
componentOrFn: c.componentOrFn,
|
||||
kind: c.kind,
|
||||
keyOrLocator: c.keyOrLocator
|
||||
}));
|
||||
keyOrLocator: c.keyOrLocator,
|
||||
}))
|
||||
|
||||
const ws = XLSX.utils.json_to_sheet(conflictRows);
|
||||
const wb = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'conflicts');
|
||||
XLSX.writeFile(wb, CONFLICTS_FILE);
|
||||
console.log(`📄 冲突报告已保存: ${CONFLICTS_FILE}`);
|
||||
const ws = XLSX.utils.json_to_sheet(conflictRows)
|
||||
const wb = XLSX.utils.book_new()
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'conflicts')
|
||||
XLSX.writeFile(wb, CONFLICTS_FILE)
|
||||
console.log(`📄 冲突报告已保存: ${CONFLICTS_FILE}`)
|
||||
}
|
||||
}
|
||||
|
||||
function printSummary() {
|
||||
console.log('\n📈 处理完成!');
|
||||
console.log(`总翻译条目: ${stats.total}`);
|
||||
console.log(`✅ 成功替换: ${stats.success}`);
|
||||
console.log(`❌ 文件不存在: ${stats.fileNotFound}`);
|
||||
console.log(`❌ 文本未找到: ${stats.textNotFound}`);
|
||||
console.log(`❌ 多处匹配: ${stats.multipleMatches}`);
|
||||
console.log(`❌ 其他冲突: ${stats.conflicts}`);
|
||||
console.log(`\n成功率: ${((stats.success / stats.total) * 100).toFixed(1)}%`);
|
||||
console.log('\n📈 处理完成!')
|
||||
console.log(`总翻译条目: ${stats.total}`)
|
||||
console.log(`✅ 成功替换: ${stats.success}`)
|
||||
console.log(`❌ 文件不存在: ${stats.fileNotFound}`)
|
||||
console.log(`❌ 文本未找到: ${stats.textNotFound}`)
|
||||
console.log(`❌ 多处匹配: ${stats.multipleMatches}`)
|
||||
console.log(`❌ 其他冲突: ${stats.conflicts}`)
|
||||
console.log(`\n成功率: ${((stats.success / stats.total) * 100).toFixed(1)}%`)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('🚀 开始重置并应用翻译...\n');
|
||||
console.log('🚀 开始重置并应用翻译...\n')
|
||||
|
||||
try {
|
||||
// 1. 重置文件到原始状态
|
||||
resetFiles();
|
||||
resetFiles()
|
||||
|
||||
// 2. 读取翻译数据
|
||||
const translations = loadTranslations();
|
||||
const translations = loadTranslations()
|
||||
|
||||
// 3. 按文件分组
|
||||
const fileGroups = groupByFile(translations);
|
||||
const fileGroups = groupByFile(translations)
|
||||
|
||||
// 4. 处理每个文件
|
||||
for (const [filePath, fileTranslations] of fileGroups) {
|
||||
processFile(filePath, fileTranslations);
|
||||
processFile(filePath, fileTranslations)
|
||||
}
|
||||
|
||||
// 5. 生成报告
|
||||
generateReport();
|
||||
generateReport()
|
||||
|
||||
// 6. 打印总结
|
||||
printSummary();
|
||||
|
||||
printSummary()
|
||||
} catch (error) {
|
||||
console.error('❌ 执行失败:', error);
|
||||
process.exitCode = 1;
|
||||
console.error('❌ 执行失败:', error)
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
main()
|
||||
|
|
|
|||
|
|
@ -1,20 +1,17 @@
|
|||
|
||||
|
||||
const AboutPage = () => {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-start relative size-full">
|
||||
<div className="flex gap-1 grow items-start justify-center content-stretch min-h-px min-w-px pb-0 pt-28 px-6 md:px-12 relative shrink-0 w-full">
|
||||
<div className="relative flex size-full flex-col items-center justify-start">
|
||||
<div className="relative flex min-h-px w-full min-w-px shrink-0 grow content-stretch items-start justify-center gap-1 px-6 pt-28 pb-0 md:px-12">
|
||||
<div className="max-w-[752px]">
|
||||
<div className="pb-[27.26%] w-full relative">
|
||||
<div className="relative w-full pb-[27.26%]">
|
||||
<img
|
||||
src="/images/about/banner.png"
|
||||
alt="Banner"
|
||||
className="inset-0 object-cover absolute"
|
||||
className="absolute inset-0 object-cover"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 txt-body-l">
|
||||
<div className="txt-body-l mt-12">
|
||||
<div>
|
||||
Grow your love story with CrushLevel AI—From "Hi" to "I Do", sparked by every chat
|
||||
</div>
|
||||
|
|
@ -23,23 +20,18 @@ const AboutPage = () => {
|
|||
At CrushLevel AI, every chat writes a new verse in your love epic—
|
||||
</div>
|
||||
<div>
|
||||
From that tentative "Hi" to the trembling "I do", find a home for the flirts you never sent,
|
||||
</div>
|
||||
<div>
|
||||
the responses you longed for,
|
||||
</div>
|
||||
<div>
|
||||
and the risky emotional gambles you feared to take.
|
||||
From that tentative "Hi" to the trembling "I do", find a home for the flirts you never
|
||||
sent,
|
||||
</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 className="mt-8">{`Contact Us: ${process.env.NEXT_PUBLIC_EMAIL_CONTACT_US}`}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default AboutPage;
|
||||
export default AboutPage
|
||||
|
|
|
|||
|
|
@ -1,13 +1,11 @@
|
|||
import { ReactNode } from "react";
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
const AuthLayout = ({ children }: { children: ReactNode }) => {
|
||||
return (
|
||||
<div className="flex items-center justify-center relative bg-[url('/common-bg.png')] bg-cover bg-top bg-no-repeat bg-fixed">
|
||||
<div className="min-h-screen w-full">
|
||||
{children}
|
||||
<div className="relative flex items-center justify-center bg-[url('/common-bg.png')] bg-cover bg-fixed bg-top bg-no-repeat">
|
||||
<div className="min-h-screen w-full">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default AuthLayout;
|
||||
export default AuthLayout
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
"use client"
|
||||
'use client'
|
||||
|
||||
import { discordOAuth } from "@/lib/oauth/discord";
|
||||
import { SocialButton } from "./SocialButton";
|
||||
import { toast } from "sonner";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useLogin } from "@/hooks/auth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { tokenManager } from "@/lib/auth/token";
|
||||
import { AppClient, ThirdType } from "@/services/auth";
|
||||
import { discordOAuth } from '@/lib/oauth/discord'
|
||||
import { SocialButton } from './SocialButton'
|
||||
import { toast } from 'sonner'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useLogin } from '@/hooks/auth'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { tokenManager } from '@/lib/auth/token'
|
||||
import { AppClient, ThirdType } from '@/services/auth'
|
||||
|
||||
const DiscordButton = () => {
|
||||
const login = useLogin()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirect = searchParams.get('redirect');
|
||||
const redirect = searchParams.get('redirect')
|
||||
|
||||
// 处理Discord OAuth回调
|
||||
useEffect(() => {
|
||||
|
|
@ -23,7 +23,7 @@ const DiscordButton = () => {
|
|||
|
||||
// 处理错误情况
|
||||
if (error) {
|
||||
toast.error("Discord login failed")
|
||||
toast.error('Discord login failed')
|
||||
|
||||
// 清理URL参数
|
||||
const newUrl = new URL(window.location.href)
|
||||
|
|
@ -37,7 +37,7 @@ const DiscordButton = () => {
|
|||
// 验证state参数(可选的安全检查)
|
||||
const savedState = sessionStorage.getItem('discord_oauth_state')
|
||||
if (savedState && discordState && savedState !== discordState) {
|
||||
toast.error("Discord login failed")
|
||||
toast.error('Discord login failed')
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -47,12 +47,15 @@ const DiscordButton = () => {
|
|||
appClient: AppClient.Web,
|
||||
deviceCode: deviceId,
|
||||
thirdToken: discordCode, // 直接传递Discord授权码
|
||||
thirdType: ThirdType.Discord
|
||||
thirdType: ThirdType.Discord,
|
||||
}
|
||||
|
||||
login.mutate(loginData, {
|
||||
onSuccess: () => {
|
||||
toast.success("Login successful")
|
||||
toast.success('Login successful')
|
||||
|
||||
// 清除 Next.js 路由缓存,避免使用登录前 prefetch 的重定向响应
|
||||
router.refresh()
|
||||
|
||||
// 清理URL参数和sessionStorage
|
||||
sessionStorage.removeItem('discord_oauth_state')
|
||||
|
|
@ -77,7 +80,7 @@ const DiscordButton = () => {
|
|||
newUrl.searchParams.delete('discord_state')
|
||||
newUrl.searchParams.delete('redirect')
|
||||
router.replace(newUrl.pathname)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
|
@ -100,7 +103,7 @@ const DiscordButton = () => {
|
|||
window.location.href = authUrl
|
||||
} catch (error) {
|
||||
console.error('Discord login error:', error)
|
||||
toast.error("Discord login failed")
|
||||
toast.error('Discord login failed')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -110,9 +113,9 @@ const DiscordButton = () => {
|
|||
onClick={handleDiscordLogin}
|
||||
disabled={login.isPending}
|
||||
>
|
||||
{login.isPending ? "Signing in..." : "Continue with Discord"}
|
||||
{login.isPending ? 'Signing in...' : 'Continue with Discord'}
|
||||
</SocialButton>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default DiscordButton;
|
||||
export default DiscordButton
|
||||
|
|
|
|||
|
|
@ -1,19 +1,19 @@
|
|||
"use client"
|
||||
'use client'
|
||||
|
||||
import { googleOAuth, type GoogleCredentialResponse } from "@/lib/oauth/google";
|
||||
import { SocialButton } from "./SocialButton";
|
||||
import { toast } from "sonner";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useLogin } from "@/hooks/auth";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { tokenManager } from "@/lib/auth/token";
|
||||
import { AppClient, ThirdType } from "@/services/auth";
|
||||
import { googleOAuth, type GoogleCredentialResponse } from '@/lib/oauth/google'
|
||||
import { SocialButton } from './SocialButton'
|
||||
import { toast } from 'sonner'
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useLogin } from '@/hooks/auth'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { tokenManager } from '@/lib/auth/token'
|
||||
import { AppClient, ThirdType } from '@/services/auth'
|
||||
|
||||
const GoogleButton = () => {
|
||||
const login = useLogin()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirect = searchParams.get('redirect');
|
||||
const redirect = searchParams.get('redirect')
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const buttonRef = useRef<HTMLDivElement>(null)
|
||||
const isInitializedRef = useRef(false)
|
||||
|
|
@ -29,12 +29,15 @@ const GoogleButton = () => {
|
|||
appClient: AppClient.Web,
|
||||
deviceCode: deviceId,
|
||||
thirdToken: response.credential, // Google ID Token (JWT)
|
||||
thirdType: ThirdType.Google
|
||||
thirdType: ThirdType.Google,
|
||||
}
|
||||
|
||||
login.mutate(loginData, {
|
||||
onSuccess: () => {
|
||||
toast.success("Login successful")
|
||||
toast.success('Login successful')
|
||||
|
||||
// 清除 Next.js 路由缓存,避免使用登录前 prefetch 的重定向响应
|
||||
router.refresh()
|
||||
|
||||
const loginRedirectUrl = sessionStorage.getItem('login_redirect_url')
|
||||
sessionStorage.removeItem('login_redirect_url')
|
||||
|
|
@ -48,13 +51,13 @@ const GoogleButton = () => {
|
|||
},
|
||||
onError: (error) => {
|
||||
console.error('Login error:', error)
|
||||
toast.error("Login failed")
|
||||
toast.error('Login failed')
|
||||
setIsLoading(false)
|
||||
}
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Google login error:', error)
|
||||
toast.error("Google login failed")
|
||||
toast.error('Google login failed')
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
|
@ -74,14 +77,14 @@ const GoogleButton = () => {
|
|||
theme: 'outline',
|
||||
size: 'large',
|
||||
text: 'continue_with',
|
||||
width: buttonRef.current.offsetWidth.toString()
|
||||
width: buttonRef.current.offsetWidth.toString(),
|
||||
})
|
||||
isInitializedRef.current = true
|
||||
console.log('Google Sign-In button rendered')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load Google SDK:', error)
|
||||
toast.error("Failed to load Google login")
|
||||
toast.error('Failed to load Google login')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -97,17 +100,14 @@ const GoogleButton = () => {
|
|||
// 如果 Google 按钮已渲染,点击会自动触发
|
||||
// 如果未渲染,显示提示
|
||||
if (!isInitializedRef.current) {
|
||||
toast.error("Google login is not ready yet")
|
||||
toast.error('Google login is not ready yet')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 隐藏的 Google 标准按钮容器 */}
|
||||
<div
|
||||
ref={buttonRef}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<div ref={buttonRef} style={{ display: 'none' }} />
|
||||
|
||||
{/* 自定义样式的按钮,点击时触发隐藏的 Google 按钮 */}
|
||||
<SocialButton
|
||||
|
|
@ -116,7 +116,9 @@ const GoogleButton = () => {
|
|||
handleGoogleLogin()
|
||||
// 触发隐藏的 Google 按钮
|
||||
if (buttonRef.current) {
|
||||
const googleButton = buttonRef.current.querySelector('div[role="button"]') as HTMLElement
|
||||
const googleButton = buttonRef.current.querySelector(
|
||||
'div[role="button"]'
|
||||
) as HTMLElement
|
||||
if (googleButton) {
|
||||
googleButton.click()
|
||||
}
|
||||
|
|
@ -124,11 +126,10 @@ const GoogleButton = () => {
|
|||
}}
|
||||
disabled={login.isPending || isLoading}
|
||||
>
|
||||
{login.isPending || isLoading ? "Signing in..." : "Continue with Google"}
|
||||
{login.isPending || isLoading ? 'Signing in...' : 'Continue with Google'}
|
||||
</SocialButton>
|
||||
</>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default GoogleButton;
|
||||
|
||||
export default GoogleButton
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
'use client'
|
||||
|
||||
import Image from "next/image"
|
||||
import { useState, useEffect } from "react"
|
||||
import Image from 'next/image'
|
||||
import { useState, useEffect } from 'react'
|
||||
|
||||
interface ImageCarouselProps {
|
||||
images: string[]
|
||||
|
|
@ -12,9 +12,9 @@ interface ImageCarouselProps {
|
|||
|
||||
export function ImageCarousel({
|
||||
images,
|
||||
className = "",
|
||||
className = '',
|
||||
autoPlay = true,
|
||||
interval = 3000
|
||||
interval = 3000,
|
||||
}: ImageCarouselProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [isTransitioning, setIsTransitioning] = useState(false)
|
||||
|
|
@ -25,9 +25,7 @@ export function ImageCarousel({
|
|||
const timer = setInterval(() => {
|
||||
setIsTransitioning(true)
|
||||
setTimeout(() => {
|
||||
setCurrentIndex((prevIndex) =>
|
||||
prevIndex === images.length - 1 ? 0 : prevIndex + 1
|
||||
)
|
||||
setCurrentIndex((prevIndex) => (prevIndex === images.length - 1 ? 0 : prevIndex + 1))
|
||||
setIsTransitioning(false)
|
||||
}, 250) // 渐隐时间的一半
|
||||
}, interval)
|
||||
|
|
@ -40,19 +38,17 @@ export function ImageCarousel({
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={`relative group ${className}`}>
|
||||
<div className={`group relative ${className}`}>
|
||||
{/* 主图片容器 */}
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
{images.map((image, index) => (
|
||||
<Image
|
||||
key={`${image}-${index}`}
|
||||
src={`/images${image}`}
|
||||
alt={`Slide image ${index + 1}`}
|
||||
fill
|
||||
className={`object-cover object-top transition-opacity duration-500 absolute inset-0 ${
|
||||
index === currentIndex && !isTransitioning
|
||||
? 'opacity-100'
|
||||
: 'opacity-0'
|
||||
className={`absolute inset-0 object-cover object-top transition-opacity duration-500 ${
|
||||
index === currentIndex && !isTransitioning ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
priority={index === 0}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,92 +1,95 @@
|
|||
"use client";
|
||||
'use client'
|
||||
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { ScrollingBackground } from "./ScrollingBackground";
|
||||
import Image from 'next/image'
|
||||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { ScrollingBackground } from './ScrollingBackground'
|
||||
|
||||
interface LeftPanelProps {
|
||||
scrollBg: string;
|
||||
images: string[];
|
||||
scrollBg: string
|
||||
images: string[]
|
||||
}
|
||||
|
||||
// 基础文字内容
|
||||
const baseTexts = [
|
||||
{ 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: "Chat", 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: 'Chat', subtitle: "From 'Hi' to 'I Do', sparked by every chat." },
|
||||
]
|
||||
|
||||
// 根据图片数量循环生成文字内容
|
||||
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) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
const [currentIndex, setCurrentIndex] = useState(0)
|
||||
const [isTransitioning, setIsTransitioning] = useState(false)
|
||||
|
||||
// 根据图片数量动态生成文案(使用 useMemo 优化性能)
|
||||
const imageTexts = useMemo(() => generateImageTexts(images.length), [images.length]);
|
||||
const imageTexts = useMemo(() => generateImageTexts(images.length), [images.length])
|
||||
|
||||
useEffect(() => {
|
||||
if (images.length <= 1) return;
|
||||
if (images.length <= 1) return
|
||||
|
||||
const timer = setInterval(() => {
|
||||
setIsTransitioning(true);
|
||||
setIsTransitioning(true)
|
||||
setTimeout(() => {
|
||||
setCurrentIndex((prevIndex) =>
|
||||
prevIndex === images.length - 1 ? 0 : prevIndex + 1
|
||||
);
|
||||
setIsTransitioning(false);
|
||||
}, 300);
|
||||
}, 3000); // 每3秒切换一次
|
||||
setCurrentIndex((prevIndex) => (prevIndex === images.length - 1 ? 0 : prevIndex + 1))
|
||||
setIsTransitioning(false)
|
||||
}, 300)
|
||||
}, 3000) // 每3秒切换一次
|
||||
|
||||
return () => clearInterval(timer);
|
||||
}, [images.length]);
|
||||
return () => clearInterval(timer)
|
||||
}, [images.length])
|
||||
|
||||
const currentText = imageTexts[currentIndex] || imageTexts[0];
|
||||
const currentText = imageTexts[currentIndex] || imageTexts[0]
|
||||
|
||||
return (
|
||||
<div className="relative w-full h-full overflow-hidden">
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
{/* 滚动背景 */}
|
||||
<ScrollingBackground imageSrc={scrollBg} />
|
||||
|
||||
|
||||
|
||||
{/* 内容层 */}
|
||||
<div className="relative z-10 flex flex-col justify-end h-full">
|
||||
<div className="relative z-10 flex h-full flex-col justify-end">
|
||||
{/* 底部遮罩层 - 铺满背景底部,高度500px */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-[500px] z-[5]" style={{
|
||||
background: "linear-gradient(180deg, rgba(33, 26, 43, 0) 0%, #211A2B 100%)",
|
||||
boxShadow: "0px 4px 4px 0px #00000040"
|
||||
}} />
|
||||
<div
|
||||
className="absolute right-0 bottom-0 left-0 z-[5] h-[500px]"
|
||||
style={{
|
||||
background: 'linear-gradient(180deg, rgba(33, 26, 43, 0) 0%, #211A2B 100%)',
|
||||
boxShadow: '0px 4px 4px 0px #00000040',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 文字内容 - 在图片上方 */}
|
||||
<div
|
||||
className={`text-center px-4 lg:px-8 mb-6 lg:mb-8 transition-opacity duration-700 absolute left-0 right-0 bottom-16 lg:bottom-20 z-10 ${
|
||||
isTransitioning ? "opacity-0" : "opacity-100"
|
||||
className={`absolute right-0 bottom-16 left-0 z-10 mb-6 px-4 text-center transition-opacity duration-700 lg:bottom-20 lg:mb-8 lg:px-8 ${
|
||||
isTransitioning ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
<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 text-white flex items-center gap-2 relative">
|
||||
<div className="mx-auto flex items-center justify-center px-4 py-2 lg:px-6 lg:py-3">
|
||||
<h2 className="txt-headline-m lg:txt-headline-l relative flex items-center gap-2 text-white">
|
||||
{currentText.title}
|
||||
<Image src="/images/login/v1/icon-star-right.svg" alt="logo" width={38} height={36} className="absolute -top-[18px] -right-[38px]" />
|
||||
<Image
|
||||
src="/images/login/v1/icon-star-right.svg"
|
||||
alt="logo"
|
||||
width={38}
|
||||
height={36}
|
||||
className="absolute -top-[18px] -right-[38px]"
|
||||
/>
|
||||
</h2>
|
||||
</div>
|
||||
<p className="txt-body-m lg:txt-body-l max-w-[320px] lg:max-w-[380px] mx-auto">
|
||||
<p className="txt-body-m lg:txt-body-l mx-auto max-w-[320px] lg:max-w-[380px]">
|
||||
{currentText.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 角色图片 - 尽可能放大,紧贴底部 */}
|
||||
<div className="relative w-full h-[80vh] lg:h-[85vh]">
|
||||
<div className="relative h-[80vh] w-full lg:h-[85vh]">
|
||||
{images.map((image, index) => (
|
||||
<div
|
||||
key={`${image}-${index}`}
|
||||
className={`absolute inset-0 transition-opacity duration-700 ${
|
||||
index === currentIndex && !isTransitioning
|
||||
? "opacity-100"
|
||||
: "opacity-0"
|
||||
index === currentIndex && !isTransitioning ? 'opacity-100' : 'opacity-0'
|
||||
}`}
|
||||
>
|
||||
<Image
|
||||
|
|
@ -101,6 +104,5 @@ export function LeftPanel({ scrollBg, images }: LeftPanelProps) {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,47 +1,47 @@
|
|||
"use client";
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
interface ScrollingBackgroundProps {
|
||||
imageSrc: string;
|
||||
imageSrc: string
|
||||
}
|
||||
|
||||
export function ScrollingBackground({ imageSrc }: ScrollingBackgroundProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
|
||||
let scrollPosition = 0;
|
||||
const speed = 0.5; // 滚动速度,数字越大滚动越快
|
||||
let scrollPosition = 0
|
||||
const speed = 0.5 // 滚动速度,数字越大滚动越快
|
||||
|
||||
const animate = () => {
|
||||
scrollPosition += speed;
|
||||
scrollPosition += speed
|
||||
|
||||
// 当滚动到一半时重置(因为我们有两张图片)
|
||||
if (scrollPosition >= container.scrollHeight / 2) {
|
||||
scrollPosition = 0;
|
||||
scrollPosition = 0
|
||||
}
|
||||
|
||||
container.scrollTop = scrollPosition;
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
container.scrollTop = scrollPosition
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
const animationId = requestAnimationFrame(animate);
|
||||
const animationId = requestAnimationFrame(animate)
|
||||
|
||||
return () => {
|
||||
cancelAnimationFrame(animationId);
|
||||
};
|
||||
}, []);
|
||||
cancelAnimationFrame(animationId)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="absolute inset-0 overflow-hidden"
|
||||
style={{
|
||||
scrollbarWidth: "none",
|
||||
msOverflowStyle: "none",
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
}}
|
||||
>
|
||||
{/* 隐藏滚动条的样式 */}
|
||||
|
|
@ -53,18 +53,9 @@ export function ScrollingBackground({ imageSrc }: ScrollingBackgroundProps) {
|
|||
|
||||
{/* 两张相同的图片,用于无缝循环 */}
|
||||
<div className="relative">
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt="Background"
|
||||
className="w-full h-auto block"
|
||||
/>
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt="Background"
|
||||
className="w-full h-auto block"
|
||||
/>
|
||||
<img src={imageSrc} alt="Background" className="block h-auto w-full" />
|
||||
<img src={imageSrc} alt="Background" className="block h-auto w-full" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import type { ReactNode } from "react"
|
||||
'use client'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
interface SocialButtonProps {
|
||||
icon: ReactNode
|
||||
|
|
@ -12,13 +12,7 @@ interface SocialButtonProps {
|
|||
|
||||
export function SocialButton({ icon, children, loading, onClick, disabled }: SocialButtonProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
block
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
>
|
||||
<Button variant="tertiary" block onClick={onClick} disabled={disabled} loading={loading}>
|
||||
{icon}
|
||||
{children}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,45 +1,44 @@
|
|||
"use client"
|
||||
import { SocialButton } from "./SocialButton"
|
||||
import Link from "next/link"
|
||||
import { toast } from "sonner"
|
||||
import DiscordButton from "./DiscordButton"
|
||||
import GoogleButton from "./GoogleButton"
|
||||
'use client'
|
||||
import { SocialButton } from './SocialButton'
|
||||
import Link from 'next/link'
|
||||
import { toast } from 'sonner'
|
||||
import DiscordButton from './DiscordButton'
|
||||
import GoogleButton from './GoogleButton'
|
||||
|
||||
export function LoginForm() {
|
||||
|
||||
const handleAppleLogin = () => {
|
||||
toast.info("Apple Sign In", {
|
||||
description: "Apple登录功能正在开发中..."
|
||||
toast.info('Apple Sign In', {
|
||||
description: 'Apple登录功能正在开发中...',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full space-y-3 sm:space-y-4">
|
||||
<div className="text-center mb-4 sm:mb-6">
|
||||
<div className="mb-4 text-center sm:mb-6">
|
||||
<h2 className="txt-title-m sm:txt-title-l">Log in/Sign up</h2>
|
||||
<p className="text-gradient mt-3 sm:mt-4 text-sm sm:text-base">Chat, Crush, AI Date</p>
|
||||
<p className="text-gradient mt-3 text-sm sm:mt-4 sm:text-base">Chat, Crush, AI Date</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 sm:space-y-4 mt-4 sm:mt-6">
|
||||
<div className="mt-4 space-y-3 sm:mt-6 sm:space-y-4">
|
||||
<DiscordButton />
|
||||
|
||||
<GoogleButton />
|
||||
|
||||
<SocialButton
|
||||
{/* <SocialButton
|
||||
icon={<i className="iconfont icon-social-apple !text-[20px] sm:!text-[24px]"></i>}
|
||||
onClick={handleAppleLogin}
|
||||
>
|
||||
Continue with Apple
|
||||
</SocialButton>
|
||||
</SocialButton> */}
|
||||
</div>
|
||||
|
||||
<div className="text-center mt-4 sm:mt-6">
|
||||
<div className="mt-4 text-center sm:mt-6">
|
||||
<p className="txt-body-s sm:txt-body-m text-txt-secondary-normal">
|
||||
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">
|
||||
User Agreement
|
||||
</Link>{" "}
|
||||
and{" "}
|
||||
</Link>{' '}
|
||||
and{' '}
|
||||
<Link href="/policy/privacy" target="_blank" className="text-primary-variant-normal">
|
||||
Privacy Policy
|
||||
</Link>
|
||||
|
|
|
|||
|
|
@ -1,29 +1,41 @@
|
|||
"use client"
|
||||
import Image from "next/image"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select"
|
||||
import { Form, FormField, FormControl, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
||||
import GenderInput from "@/components/features/genderInput"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import * as z from "zod"
|
||||
import { Gender } from "@/types/user"
|
||||
import { useEffect, useState } from "react"
|
||||
import dayjs from "dayjs"
|
||||
import { useCheckNickname, useCompleteUser, useCurrentUser } from "@/hooks/auth"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
'use client'
|
||||
import Image from 'next/image'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Form,
|
||||
FormField,
|
||||
FormControl,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import GenderInput from '@/components/features/genderInput'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import * as z from 'zod'
|
||||
import { Gender } from '@/types/user'
|
||||
import { useEffect, useState } from 'react'
|
||||
import dayjs from 'dayjs'
|
||||
import { useCheckNickname, useCompleteUser, useCurrentUser } from '@/hooks/auth'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
const currentYear = dayjs().year()
|
||||
const 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'))
|
||||
|
||||
function getDaysInMonth(year: string, month: string) {
|
||||
return Array.from(
|
||||
{ length: dayjs(`${year}-${month}`).daysInMonth() },
|
||||
(_, i) => `${i + 1}`.padStart(2, "0")
|
||||
return Array.from({ length: dayjs(`${year}-${month}`).daysInMonth() }, (_, i) =>
|
||||
`${i + 1}`.padStart(2, '0')
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -33,19 +45,28 @@ function calculateAge(year: string, month: string, day: string) {
|
|||
return today.diff(birthDate, 'year')
|
||||
}
|
||||
|
||||
const schema = z.object({
|
||||
nickname: z.string().trim().min(1, "Please Enter nickname").min(2, "Nickname must be between 2 and 20 characters"),
|
||||
gender: z.enum(Gender, { message: "Please select your gender" }),
|
||||
year: z.string().min(1, "Please select your birthday"),
|
||||
month: z.string().min(1, "Please select your birthday"),
|
||||
day: z.string().min(1, "Please select your birthday")
|
||||
}).refine((data) => {
|
||||
const schema = z
|
||||
.object({
|
||||
nickname: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, 'Please Enter nickname')
|
||||
.min(2, 'Nickname must be between 2 and 20 characters'),
|
||||
gender: z.enum(Gender, { message: 'Please select your gender' }),
|
||||
year: z.string().min(1, 'Please select your birthday'),
|
||||
month: z.string().min(1, 'Please select your birthday'),
|
||||
day: z.string().min(1, 'Please select your birthday'),
|
||||
})
|
||||
.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"]
|
||||
})
|
||||
},
|
||||
{
|
||||
message: 'Character age must be at least 18 years old',
|
||||
path: ['year'],
|
||||
}
|
||||
)
|
||||
|
||||
type FormValues = z.infer<typeof schema>
|
||||
|
||||
|
|
@ -53,51 +74,50 @@ export default function FieldsPage() {
|
|||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
nickname: "",
|
||||
nickname: '',
|
||||
gender: undefined,
|
||||
year: "2000",
|
||||
month: "01",
|
||||
day: "01"
|
||||
year: '2000',
|
||||
month: '01',
|
||||
day: '01',
|
||||
},
|
||||
mode: "all",
|
||||
},
|
||||
);
|
||||
const { mutateAsync } = useCompleteUser();
|
||||
const { data: user, refetch } = useCurrentUser();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const redirect = searchParams.get("redirect");
|
||||
const [loading, setLoading] = useState(false);
|
||||
mode: 'all',
|
||||
})
|
||||
const { mutateAsync } = useCompleteUser()
|
||||
const { data: user, refetch } = useCurrentUser()
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const redirect = searchParams.get('redirect')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const { mutateAsync: checkNickname } = useCheckNickname({
|
||||
onError: (error) => {
|
||||
form.setError("nickname", {
|
||||
form.setError('nickname', {
|
||||
message: error.errorMsg,
|
||||
})
|
||||
}
|
||||
});
|
||||
},
|
||||
})
|
||||
|
||||
const selectedYear = form.watch("year")
|
||||
const selectedMonth = form.watch("month")
|
||||
const selectedYear = form.watch('year')
|
||||
const selectedMonth = form.watch('month')
|
||||
const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : []
|
||||
|
||||
useEffect(() => {
|
||||
if (!user?.cpUserInfo) {
|
||||
router.push("/")
|
||||
router.push('/')
|
||||
}
|
||||
}, [user?.cpUserInfo])
|
||||
|
||||
useEffect(() => {
|
||||
const currentDay = form.getValues("day")
|
||||
const currentDay = form.getValues('day')
|
||||
const maxDay = days[days.length - 1]
|
||||
if (parseInt(currentDay) > parseInt(maxDay)) {
|
||||
form.setValue("day", maxDay)
|
||||
form.setValue('day', maxDay)
|
||||
}
|
||||
}, [selectedYear, selectedMonth, days, form])
|
||||
|
||||
async function onSubmit(data: FormValues) {
|
||||
if (!user?.userId) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
|
|
@ -105,10 +125,10 @@ export default function FieldsPage() {
|
|||
nickname: data.nickname.trim(),
|
||||
})
|
||||
if (isExist) {
|
||||
form.setError("nickname", {
|
||||
message: "This nickname is already taken",
|
||||
form.setError('nickname', {
|
||||
message: 'This nickname is already taken',
|
||||
})
|
||||
return;
|
||||
return
|
||||
}
|
||||
await mutateAsync({
|
||||
nickname: data.nickname.trim(),
|
||||
|
|
@ -116,11 +136,11 @@ export default function FieldsPage() {
|
|||
birthday: `${data.year}-${data.month}-${data.day}`,
|
||||
userId: user?.userId,
|
||||
})
|
||||
await refetch();
|
||||
await refetch()
|
||||
if (redirect) {
|
||||
router.push(decodeURIComponent(redirect))
|
||||
} else {
|
||||
router.push("/")
|
||||
router.push('/')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
|
|
@ -131,12 +151,19 @@ export default function FieldsPage() {
|
|||
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-[221px] h-[88px] relative mx-auto mt-[20vh]">
|
||||
<Image src="/images/login/logo.svg" alt="Anime character" width={221} height={88} className="object-cover" priority />
|
||||
<div className="relative mx-auto mt-[20vh] h-[88px] w-[221px]">
|
||||
<Image
|
||||
src="/images/login/logo.svg"
|
||||
alt="Anime character"
|
||||
width={221}
|
||||
height={88}
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-20 w-[752px]">
|
||||
<div className="bg-surface-element-normal mt-6 rounded-lg p-6">
|
||||
<h2 className="txt-title-l text-center py-4">Personal Information</h2>
|
||||
<h2 className="txt-title-l py-4 text-center">Personal Information</h2>
|
||||
<Form {...form}>
|
||||
<form className="flex flex-col gap-6" onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<FormField
|
||||
|
|
@ -167,19 +194,18 @@ export default function FieldsPage() {
|
|||
<FormItem>
|
||||
<FormLabel className="txt-label-m">Gender</FormLabel>
|
||||
<FormControl>
|
||||
<GenderInput
|
||||
value={field.value}
|
||||
onChange={field.onChange}
|
||||
/>
|
||||
<GenderInput value={field.value} onChange={field.onChange} />
|
||||
</FormControl>
|
||||
<div className="txt-body-s text-txt-secondary-normal mt-1">Please note: gender cannot be changed after setting</div>
|
||||
<div className="txt-body-s text-txt-secondary-normal mt-1">
|
||||
Please note: gender cannot be changed after setting
|
||||
</div>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Label className="block txt-label-m mb-3">Birthday</Label>
|
||||
<Label className="txt-label-m mb-3 block">Birthday</Label>
|
||||
<div className="flex gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
|
@ -187,9 +213,15 @@ export default function FieldsPage() {
|
|||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger className="w-full" error={!!form.formState.errors.year}><SelectValue placeholder="Year" /></SelectTrigger>
|
||||
<SelectTrigger className="w-full" error={!!form.formState.errors.year}>
|
||||
<SelectValue placeholder="Year" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map(y => <SelectItem key={y} value={y}>{y}</SelectItem>)}
|
||||
{years.map((y) => (
|
||||
<SelectItem key={y} value={y}>
|
||||
{y}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
|
|
@ -202,9 +234,15 @@ export default function FieldsPage() {
|
|||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger className="w-full" error={!!form.formState.errors.year}><SelectValue placeholder="Month" /></SelectTrigger>
|
||||
<SelectTrigger className="w-full" error={!!form.formState.errors.year}>
|
||||
<SelectValue placeholder="Month" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{months.map((m, index) => <SelectItem key={m} value={m}>{monthTexts[index]}</SelectItem>)}
|
||||
{months.map((m, index) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{monthTexts[index]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
|
|
@ -217,9 +255,18 @@ export default function FieldsPage() {
|
|||
render={({ field }) => (
|
||||
<FormItem className="flex-1">
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger className="w-full" error={!!form.formState.errors.year || !!form.formState.errors.day}><SelectValue placeholder="Day" /></SelectTrigger>
|
||||
<SelectTrigger
|
||||
className="w-full"
|
||||
error={!!form.formState.errors.year || !!form.formState.errors.day}
|
||||
>
|
||||
<SelectValue placeholder="Day" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{days.map(d => <SelectItem key={d} value={d}>{d}</SelectItem>)}
|
||||
{days.map((d) => (
|
||||
<SelectItem key={d} value={d}>
|
||||
{d}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormItem>
|
||||
|
|
@ -227,15 +274,15 @@ export default function FieldsPage() {
|
|||
/>
|
||||
</div>
|
||||
<FormMessage>
|
||||
{form.formState.errors.year?.message || form.formState.errors.month?.message || form.formState.errors.day?.message}
|
||||
{form.formState.errors.year?.message ||
|
||||
form.formState.errors.month?.message ||
|
||||
form.formState.errors.day?.message}
|
||||
</FormMessage>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between">
|
||||
<div />
|
||||
<Button type="submit"
|
||||
loading={loading}
|
||||
>
|
||||
<Button type="submit" loading={loading}>
|
||||
Submit
|
||||
</Button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,7 @@
|
|||
import FieldsPage from "./fields-page";
|
||||
import FieldsPage from './fields-page'
|
||||
|
||||
const Page = () => {
|
||||
|
||||
return (
|
||||
<FieldsPage />
|
||||
);
|
||||
return <FieldsPage />
|
||||
}
|
||||
|
||||
export default Page;
|
||||
export default Page
|
||||
|
|
|
|||
|
|
@ -1,35 +1,35 @@
|
|||
"use client"
|
||||
import Image from "next/image"
|
||||
import { LoginForm } from "./components/login-form"
|
||||
import { LeftPanel } from "./components/LeftPanel"
|
||||
import { IconButton } from "@/components/ui/button";
|
||||
import { useRouter } from "next/navigation";
|
||||
'use client'
|
||||
import Image from 'next/image'
|
||||
import { LoginForm } from './components/login-form'
|
||||
import { LeftPanel } from './components/LeftPanel'
|
||||
import { IconButton } from '@/components/ui/button'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
const scrollBg = "/images/login/v1/bg.png";
|
||||
const scrollBg = '/images/login/v1/bg.png'
|
||||
const images = [
|
||||
"/images/login/v1/1.png",
|
||||
"/images/login/v1/2.png",
|
||||
"/images/login/v1/3.png",
|
||||
"/images/login/v1/4.png",
|
||||
"/images/login/v1/5.png",
|
||||
"/images/login/v1/6.png",
|
||||
"/images/login/v1/7.png",
|
||||
"/images/login/v1/8.png",
|
||||
"/images/login/v1/9.png",
|
||||
"/images/login/v1/10.png",
|
||||
'/images/login/v1/1.png',
|
||||
'/images/login/v1/2.png',
|
||||
'/images/login/v1/3.png',
|
||||
'/images/login/v1/4.png',
|
||||
'/images/login/v1/5.png',
|
||||
'/images/login/v1/6.png',
|
||||
'/images/login/v1/7.png',
|
||||
'/images/login/v1/8.png',
|
||||
'/images/login/v1/9.png',
|
||||
'/images/login/v1/10.png',
|
||||
]
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const router = useRouter()
|
||||
|
||||
const handleClose = () => {
|
||||
router.replace('/');
|
||||
router.replace('/')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen overflow-hidden">
|
||||
{/* 左侧 - 滚动背景 + 图片轮播 (桌面端显示) */}
|
||||
<div className="hidden lg:block lg:w-1/2 relative">
|
||||
<div className="relative hidden lg:block lg:w-1/2">
|
||||
<LeftPanel scrollBg={scrollBg} images={images} />
|
||||
|
||||
{/* 关闭按钮 - 桌面端 */}
|
||||
|
|
@ -43,25 +43,19 @@ export default function LoginPage() {
|
|||
</div>
|
||||
|
||||
{/* 右侧 - 登录表单 */}
|
||||
<div className="w-full lg:w-1/2 flex flex-col items-center justify-center px-6 sm:px-12 relative">
|
||||
<div className="relative flex w-full flex-col items-center justify-center px-6 sm:px-12 lg:w-1/2">
|
||||
{/* 关闭按钮 - 移动端 */}
|
||||
<IconButton
|
||||
iconfont="icon-close"
|
||||
variant="tertiary"
|
||||
size="large"
|
||||
className="absolute top-4 right-4 lg:hidden z-20"
|
||||
className="absolute top-4 right-4 z-20 lg:hidden"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
{/* Logo */}
|
||||
<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
|
||||
/>
|
||||
<div className="relative mb-8 h-[48px] w-[120px] sm:mb-12 sm:h-[64px] sm:w-[160px]">
|
||||
<Image src="/logo.svg" alt="Crush Level" fill className="object-contain" priority />
|
||||
</div>
|
||||
|
||||
{/* 登录表单 */}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,61 @@
|
|||
import LoginPage from "./login-page";
|
||||
import type { Metadata } from 'next'
|
||||
import LoginPage from './login-page'
|
||||
|
||||
const Page = () => {
|
||||
|
||||
return (
|
||||
<LoginPage />
|
||||
);
|
||||
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',
|
||||
},
|
||||
}
|
||||
|
||||
export default Page;
|
||||
const Page = () => {
|
||||
return <LoginPage />
|
||||
}
|
||||
|
||||
export default Page
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import { redirect } from "next/navigation";
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
const Policy = () => {
|
||||
|
||||
redirect("/policy/privacy")
|
||||
redirect('/policy/privacy')
|
||||
}
|
||||
|
||||
export default Policy;
|
||||
export default Policy
|
||||
|
|
|
|||
|
|
@ -1,223 +1,268 @@
|
|||
export default function PrivacyPolicyPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-start relative size-full min-h-screen">
|
||||
<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="flex flex-col gap-12 grow items-center justify-start max-w-[752px] min-h-px min-w-px relative shrink-0 w-full">
|
||||
<div className="relative flex size-full min-h-screen flex-col items-center justify-start">
|
||||
<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="relative flex min-h-px w-full max-w-[752px] min-w-px shrink-0 grow flex-col items-center justify-start gap-12">
|
||||
{/* 主标题 */}
|
||||
<div className="txt-headline-s text-center text-white w-full">
|
||||
<p className="whitespace-pre-wrap">
|
||||
Crushlevel Privacy Policy
|
||||
</p>
|
||||
<div className="txt-headline-s w-full text-center text-white">
|
||||
<p className="whitespace-pre-wrap">Crushlevel Privacy Policy</p>
|
||||
</div>
|
||||
|
||||
{/* 前言 */}
|
||||
<div className="txt-body-l text-white w-full">
|
||||
<div className="txt-body-l w-full text-white">
|
||||
<p className="mb-4">
|
||||
Welcome to the Crushlevel application ("this App") and related website (Crushlevel.ai, "this Website"). We recognize the importance of your personal information and are committed to protecting your privacy rights and information security. This Privacy Policy ("Policy") explains the principles and practices we follow when collecting, using, storing, and safeguarding your personal information. Please read this Policy carefully before using our services. By registering or using our services, you acknowledge full acceptance of this Policy. For any inquiries, contact us through the provided channels.
|
||||
Welcome to the Crushlevel application ("this App") and related website (Crushlevel.ai,
|
||||
"this Website"). We recognize the importance of your personal information and are
|
||||
committed to protecting your privacy rights and information security. This Privacy
|
||||
Policy ("Policy") explains the principles and practices we follow when collecting,
|
||||
using, storing, and safeguarding your personal information. Please read this Policy
|
||||
carefully before using our services. By registering or using our services, you
|
||||
acknowledge full acceptance of this Policy. For any inquiries, contact us through the
|
||||
provided channels.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 隐私政策条款 */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start text-white w-full">
|
||||
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6 text-white">
|
||||
{/* 1. Scope of Application */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>1. Scope of Application</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
This Policy applies to all personal information processing activities (including collection, use, storage, and protection) during your use of this App and Website. We comply with applicable laws in your country/region and international data protection standards.
|
||||
This Policy applies to all personal information processing activities (including
|
||||
collection, use, storage, and protection) during your use of this App and Website.
|
||||
We comply with applicable laws in your country/region and international data
|
||||
protection standards.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Collection of Personal Information */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>2. Collection of Personal Information</p>
|
||||
</div>
|
||||
|
||||
{/* a) Registration Information */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>a) Registration Information</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
When registering an account, we collect your mobile number or email address to create your account, facilitate login, and deliver service notifications.
|
||||
When registering an account, we collect your mobile number or email address to
|
||||
create your account, facilitate login, and deliver service notifications.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* b) Service Usage Information */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>b) Service Usage Information</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
<strong>Virtual Character Creation:</strong> Descriptions you provide for AI characters (excluding non-personal information).
|
||||
<strong>Virtual Character Creation:</strong> Descriptions you provide for AI
|
||||
characters (excluding non-personal information).
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
<strong>Chat Interactions:</strong> Text, images, voice messages, and other content exchanged with AI characters to enable service functionality, store chat history, and optimize features.
|
||||
<strong>Chat Interactions:</strong> Text, images, voice messages, and other
|
||||
content exchanged with AI characters to enable service functionality, store chat
|
||||
history, and optimize features.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
<strong>Feature Engagement:</strong> Records related to relationship upgrades, unlocked features, and rewards to monitor service usage and ensure performance.
|
||||
<strong>Feature Engagement:</strong> Records related to relationship upgrades,
|
||||
unlocked features, and rewards to monitor service usage and ensure performance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* c) Payment Information */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>c) Payment Information</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
For transactions (pay-per-message, subscriptions, virtual currency purchases), we collect payment methods, amounts, and timestamps to complete transactions and deliver paid services.
|
||||
For transactions (pay-per-message, subscriptions, virtual currency purchases),
|
||||
we collect payment methods, amounts, and timestamps to complete transactions and
|
||||
deliver paid services.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* d) Device & Log Information */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>d) Device & Log Information</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
To ensure service stability and security, we may collect device model, OS version, IP address, browser type, timestamps, and access records.
|
||||
To ensure service stability and security, we may collect device model, OS
|
||||
version, IP address, browser type, timestamps, and access records.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3. Use of Personal Information */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>3. Use of Personal Information</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
We use your personal information to:
|
||||
</p>
|
||||
<ul className="list-disc ml-6 mb-4">
|
||||
<li className="mb-2">Provide services (account management, character creation, chat interactions, paid features, etc.).</li>
|
||||
<li className="mb-2">Optimize services by analyzing usage patterns to enhance functionality.</li>
|
||||
<li className="mb-2">Conduct promotional activities (with your consent or where permitted by law), without disclosing sensitive data.</li>
|
||||
<li className="mb-2">Troubleshoot issues, maintain service integrity, and protect your rights.</li>
|
||||
<p className="mb-4">We use your personal information to:</p>
|
||||
<ul className="mb-4 ml-6 list-disc">
|
||||
<li className="mb-2">
|
||||
Provide services (account management, character creation, chat interactions,
|
||||
paid features, etc.).
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
Optimize services by analyzing usage patterns to enhance functionality.
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
Conduct promotional activities (with your consent or where permitted by law),
|
||||
without disclosing sensitive data.
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
Troubleshoot issues, maintain service integrity, and protect your rights.
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Storage of Personal Information */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>4. Storage of Personal Information</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
a) Your data is stored on secure servers with robust technical/administrative measures to prevent loss, leakage, tampering, or misuse.
|
||||
a) Your data is stored on secure servers with robust technical/administrative
|
||||
measures to prevent loss, leakage, tampering, or misuse.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
b) Retention periods align with service needs and legal requirements. Post-expiry, data is deleted or anonymized.
|
||||
b) Retention periods align with service needs and legal requirements. Post-expiry,
|
||||
data is deleted or anonymized.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
c) Data is primarily stored in your country/region. Cross-border transfers (if any) comply with applicable laws and implement safeguards (e.g., standard contractual clauses).
|
||||
c) Data is primarily stored in your country/region. Cross-border transfers (if
|
||||
any) comply with applicable laws and implement safeguards (e.g., standard
|
||||
contractual clauses).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5. Protection of Personal Information */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>5. Protection of Personal Information</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
a) We implement strict security protocols, including encryption and access controls, to prevent unauthorized access or disclosure.
|
||||
a) We implement strict security protocols, including encryption and access
|
||||
controls, to prevent unauthorized access or disclosure.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
b) Only authorized personnel bound by confidentiality obligations may access your data.
|
||||
b) Only authorized personnel bound by confidentiality obligations may access your
|
||||
data.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
c) In case of a data breach, we will take remedial actions and notify you/regulators as required by law.
|
||||
c) In case of a data breach, we will take remedial actions and notify
|
||||
you/regulators as required by law.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6. Sharing, Transfer, and Disclosure */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>6. Sharing, Transfer, and Disclosure</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
a) We do not share your data with third parties without your explicit consent, except where mandated by law or to protect public/personal interests.
|
||||
a) We do not share your data with third parties without your explicit consent,
|
||||
except where mandated by law or to protect public/personal interests.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
b) We do not transfer your data unless: (i) you consent; or (ii) during corporate restructuring (mergers, acquisitions), where recipients must adhere to this Policy.
|
||||
b) We do not transfer your data unless: (i) you consent; or (ii) during corporate
|
||||
restructuring (mergers, acquisitions), where recipients must adhere to this
|
||||
Policy.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
c) Public disclosure occurs only when legally required or requested by authorities, with efforts to minimize exposure.
|
||||
c) Public disclosure occurs only when legally required or requested by
|
||||
authorities, with efforts to minimize exposure.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 7. Your Rights */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>7. Your Rights</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
a) <strong>Access & Correction:</strong> Request to view or correct inaccurate/incomplete data.
|
||||
a) <strong>Access & Correction:</strong> Request to view or correct
|
||||
inaccurate/incomplete data.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
b) <strong>Deletion:</strong> Request deletion where legally permissible or upon service termination.
|
||||
b) <strong>Deletion:</strong> Request deletion where legally permissible or upon
|
||||
service termination.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
c) <strong>Consent Withdrawal:</strong> Withdraw consent for data processing (note: may affect service functionality).
|
||||
c) <strong>Consent Withdrawal:</strong> Withdraw consent for data processing
|
||||
(note: may affect service functionality).
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
d) <strong>Account Deactivation:</strong> Deactivate your account; data will be processed per relevant policies.
|
||||
d) <strong>Account Deactivation:</strong> Deactivate your account; data will be
|
||||
processed per relevant policies.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 8. Minor Protection */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>8. Minor Protection</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
Users under 18 must obtain parental/guardian consent before using our services. We prioritize minors' data protection. Parents/guardians may contact us to review or delete minors' data improperly collected. We strictly comply with minor protection laws in your jurisdiction.
|
||||
Users under 18 must obtain parental/guardian consent before using our services. We
|
||||
prioritize minors' data protection. Parents/guardians may contact us to review or
|
||||
delete minors' data improperly collected. We strictly comply with minor protection
|
||||
laws in your jurisdiction.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 9. Policy Updates */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>9. Policy Updates</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
We may revise this Policy due to legal changes or operational needs. Revised versions will be published on this App/Website and take effect after the notice period. Continued use constitutes acceptance of changes. Revisions will comply with local legal requirements.
|
||||
We may revise this Policy due to legal changes or operational needs. Revised
|
||||
versions will be published on this App/Website and take effect after the notice
|
||||
period. Continued use constitutes acceptance of changes. Revisions will comply
|
||||
with local legal requirements.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 10. Contact Us */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>10. Contact Us</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
For questions or to exercise your rights, contact us via provided channels. We will respond within a reasonable timeframe.
|
||||
For questions or to exercise your rights, contact us via provided channels. We
|
||||
will respond within a reasonable timeframe.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Thank you for trusting Crushlevel. We strive to protect your information security!
|
||||
|
|
@ -228,5 +273,5 @@ export default function PrivacyPolicyPage() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
|
||||
Crushlevel Privacy Policy
|
||||
Crushlevel Privacy Policy
|
||||
|
||||
Welcome to the Crushlevel application ("this App") and related website (Crushlevel.ai, "this Website"). We recognize the importance of your personal information and are committed to protecting your privacy rights and information security. This Privacy Policy ("Policy") explains the principles and practices we follow when collecting, using, storing, and safeguarding your personal information. Please read this Policy carefully before using our services. By registering or using our services, you acknowledge full acceptance of this Policy. For any inquiries, contact us through the provided channels.
|
||||
|
||||
|
|
@ -91,5 +90,3 @@ Thank you for trusting Crushlevel. We strive to protect your information securit
|
|||
|
||||
\
|
||||
\
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,19 @@
|
|||
# **Crushlevel Recharge Service Agreement**
|
||||
|
||||
October 2025
|
||||
|
||||
## **Preamble**
|
||||
|
||||
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.
|
||||
|
||||
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**
|
||||
|
||||
### **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:
|
||||
|
||||
- Paid chat with AI virtual characters;
|
||||
|
|
@ -16,60 +22,110 @@ The virtual currency provided by the Platform to you (hereinafter referred to as
|
|||
- Recharging for Platform membership to enjoy exclusive membership benefits;
|
||||
- Sending virtual gifts to AI virtual characters;
|
||||
- Unlocking more different types of virtual lovers (AI virtual characters).
|
||||
|
||||
### **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.
|
||||
|
||||
### **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.
|
||||
|
||||
### **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.
|
||||
|
||||
### **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.
|
||||
|
||||
## **II. 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.
|
||||
|
||||
### **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.
|
||||
|
||||
### **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.
|
||||
|
||||
## **III. Your Rights and Obligations**
|
||||
|
||||
### **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.
|
||||
|
||||
### **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.
|
||||
|
||||
### **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:
|
||||
|
||||
- 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;
|
||||
- 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.).
|
||||
|
||||
### **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.
|
||||
|
||||
### **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.).
|
||||
|
||||
### **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.
|
||||
|
||||
## **IV. Rights and Obligations of the Platform**
|
||||
|
||||
### **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.
|
||||
|
||||
### **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).
|
||||
|
||||
### **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.
|
||||
|
||||
### **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.
|
||||
|
||||
## **V. Refund Rules**
|
||||
|
||||
### **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.
|
||||
|
||||
## **VI. Disclaimer**
|
||||
|
||||
### **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.
|
||||
|
||||
### **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.
|
||||
|
||||
### **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.
|
||||
|
||||
## **VII. Liability for 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
|
||||
|
||||
(注:文档部分内容可能由 AI 生成)
|
||||
|
|
|
|||
|
|
@ -1,370 +1,625 @@
|
|||
export default function RechargeAgreementPage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-start relative size-full min-h-screen">
|
||||
<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="flex flex-col gap-12 grow items-center justify-start max-w-[752px] min-h-px min-w-px relative shrink-0 w-full">
|
||||
<div className="relative flex size-full min-h-screen flex-col items-center justify-start">
|
||||
<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="relative flex min-h-px w-full max-w-[752px] min-w-px shrink-0 grow flex-col items-center justify-start gap-12">
|
||||
{/* 主标题 */}
|
||||
<div className="txt-headline-s text-center text-white w-full">
|
||||
<p className="whitespace-pre-wrap">
|
||||
Crushlevel Recharge Service Agreement
|
||||
</p>
|
||||
<div className="txt-headline-s w-full text-center text-white">
|
||||
<p className="whitespace-pre-wrap">Crushlevel Recharge Service Agreement</p>
|
||||
</div>
|
||||
|
||||
{/* 日期 */}
|
||||
<div className="txt-body-l text-center text-white w-full">
|
||||
<div className="txt-body-l w-full text-center text-white">
|
||||
<p>October 2025</p>
|
||||
</div>
|
||||
|
||||
{/* 前言 */}
|
||||
<div className="txt-body-l text-white w-full">
|
||||
<div className="txt-body-l w-full text-white">
|
||||
<p className="mb-4">Welcome to use the recharge-related services of "Crushlevel"!</p>
|
||||
<p className="mb-4">
|
||||
Welcome to use the recharge-related services of "Crushlevel"!
|
||||
This Recharge Service Agreement (hereinafter referred to as "this Agreement") is
|
||||
entered into between you and the operator of Crushlevel (hereinafter referred to as
|
||||
the "Platform") and/or its affiliates (hereinafter referred to as the "Company"). The
|
||||
Platform shall provide services to you in accordance with the provisions of this
|
||||
Agreement and the operating rules issued from time to time (hereinafter referred to as
|
||||
the "Services"). For the purpose of providing better services to users, you, as the
|
||||
service user (i.e., the account user who places an order to purchase the Platform's
|
||||
virtual currency, hereinafter referred to as "you"), shall carefully read and fully
|
||||
understand this Agreement before starting to use the Services. Among them, clauses
|
||||
that exempt or limit the Platform's liability, dispute resolution methods,
|
||||
jurisdiction and other important contents will be highlighted in <strong>bold</strong>{' '}
|
||||
to draw your attention, and you shall focus on reading these parts. If you do not
|
||||
agree to this Agreement, please do not take any further actions (including but not
|
||||
limited to clicking the operation buttons such as purchasing virtual currency, making
|
||||
payments) or use the Services.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
This Recharge Service Agreement (hereinafter referred to as "this Agreement") is entered into between you and the operator of Crushlevel (hereinafter referred to as the "Platform") and/or its affiliates (hereinafter referred to as the "Company"). The Platform shall provide services to you in accordance with the provisions of this Agreement and the operating rules issued from time to time (hereinafter referred to as the "Services"). For the purpose of providing better services to users, you, as the service user (i.e., the account user who places an order to purchase the Platform's virtual currency, hereinafter referred to as "you"), shall carefully read and fully understand this Agreement before starting to use the Services. Among them, clauses that exempt or limit the Platform's liability, dispute resolution methods, jurisdiction and other important contents will be highlighted in <strong>bold</strong> to draw your attention, and you shall focus on reading these parts. If you do not agree to this Agreement, please do not take any further actions (including but not limited to clicking the operation buttons such as purchasing virtual currency, making payments) or use the Services.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
<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.
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 协议条款 */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start text-white w-full">
|
||||
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6 text-white">
|
||||
{/* I. Service Content */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>I. Service Content</p>
|
||||
</div>
|
||||
|
||||
{/* 1.1 Definition and Purpose of Virtual Currency */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>1.1 Definition and Purpose of Virtual Currency</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
The virtual currency provided by the Platform to you (hereinafter referred to as "Virtual Currency") is a virtual tool limited to relevant consumption within the Crushlevel Platform. It is not a token, legal tender or advance payment certificate, and does not have the circulation and advance payment value of legal tender. After purchasing the Virtual Currency, you may, in accordance with the instructions and guidelines on the relevant pages of the Platform, use it for the following consumption scenarios, including but not limited to:
|
||||
The virtual currency provided by the Platform to you (hereinafter referred to as
|
||||
"Virtual Currency") is a virtual tool limited to relevant consumption within the
|
||||
Crushlevel Platform. It is not a token, legal tender or advance payment
|
||||
certificate, and does not have the circulation and advance payment value of
|
||||
legal tender. After purchasing the Virtual Currency, you may, in accordance with
|
||||
the instructions and guidelines on the relevant pages of the Platform, use it
|
||||
for the following consumption scenarios, including but not limited to:
|
||||
</p>
|
||||
<ul className="list-disc ml-6 mb-4">
|
||||
<ul className="mb-4 ml-6 list-disc">
|
||||
<li className="mb-2">Paid chat with AI virtual characters;</li>
|
||||
<li className="mb-2">Unlocking pictures related to AI virtual characters;</li>
|
||||
<li className="mb-2">Purchasing "Affection Points" to increase the interaction level with AI virtual characters;</li>
|
||||
<li className="mb-2">Recharging for Platform membership to enjoy exclusive membership benefits;</li>
|
||||
<li className="mb-2">
|
||||
Purchasing "Affection Points" to increase the interaction level with AI
|
||||
virtual characters;
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
Recharging for Platform membership to enjoy exclusive membership benefits;
|
||||
</li>
|
||||
<li className="mb-2">Sending virtual gifts to AI virtual characters;</li>
|
||||
<li className="mb-2">Unlocking more different types of virtual lovers (AI virtual characters).</li>
|
||||
<li className="mb-2">
|
||||
Unlocking more different types of virtual lovers (AI virtual characters).
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1.2 Restrictions on the Use of Virtual Currency */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>1.2 Restrictions on the Use of Virtual Currency</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
After purchasing the Virtual Currency, you may only use it for the consumption scenarios stipulated in Clause 1.1 of this Agreement. You shall not use it beyond the scope of products/services provided by the Company, nor transfer, trade, sell or gift it between different Crushlevel accounts.
|
||||
After purchasing the Virtual Currency, you may only use it for the consumption
|
||||
scenarios stipulated in Clause 1.1 of this Agreement. You shall not use it
|
||||
beyond the scope of products/services provided by the Company, nor transfer,
|
||||
trade, sell or gift it between different Crushlevel accounts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1.3 Official Purchase Channels */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>1.3 Official Purchase Channels</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
You shall purchase the Virtual Currency through the official channels designated by the Platform, including but not limited to the Platform's official website, official mobile application (APP) and third-party payment cooperation channels authorized by the Platform. The Platform does not recognize any third-party channels not authorized by the Company (such as unofficial purchasing agents, private transactions, etc.). If you purchase the Virtual Currency through unauthorized channels, the Platform cannot guarantee that such Virtual Currency can be successfully credited to your account. Moreover, such acts may be accompanied by risks such as fraud, money laundering and account theft, causing irreparable losses or damages to you, the Platform and relevant third parties. Therefore, purchasing through unauthorized channels shall be deemed as a violation. The Platform has the right to deduct or clear the Virtual Currency in your account, restrict all or part of the functions of your account, or temporarily or permanently ban your account. You shall bear all losses caused thereby; if your violation of the aforementioned provisions causes losses to the Platform or other third parties, you shall be liable for full compensation.
|
||||
You shall purchase the Virtual Currency through the official channels designated
|
||||
by the Platform, including but not limited to the Platform's official website,
|
||||
official mobile application (APP) and third-party payment cooperation channels
|
||||
authorized by the Platform. The Platform does not recognize any third-party
|
||||
channels not authorized by the Company (such as unofficial purchasing agents,
|
||||
private transactions, etc.). If you purchase the Virtual Currency through
|
||||
unauthorized channels, the Platform cannot guarantee that such Virtual Currency
|
||||
can be successfully credited to your account. Moreover, such acts may be
|
||||
accompanied by risks such as fraud, money laundering and account theft, causing
|
||||
irreparable losses or damages to you, the Platform and relevant third parties.
|
||||
Therefore, purchasing through unauthorized channels shall be deemed as a
|
||||
violation. The Platform has the right to deduct or clear the Virtual Currency in
|
||||
your account, restrict all or part of the functions of your account, or
|
||||
temporarily or permanently ban your account. You shall bear all losses caused
|
||||
thereby; if your violation of the aforementioned provisions causes losses to the
|
||||
Platform or other third parties, you shall be liable for full compensation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1.4 Fee Collection and Channel Differences */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>1.4 Fee Collection and Channel Differences</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
The fees for your purchase of the Virtual Currency shall be collected by the Company or a cooperating party designated by the Company. The Platform specially reminds you that relevant service providers of different purchase channels (such as third-party payment institutions, app stores, etc.) may charge channel service fees when you make payments in accordance with their own operating strategies. This may result in differences in the amount of fees required to purchase the same amount of Virtual Currency through different channels, or differences in the amount of Virtual Currency that can be purchased with the same amount of fees. The specific details shall be subject to the page display when you purchase the Virtual Currency. Please carefully confirm the relevant page information (including but not limited to price, quantity, service fee description, etc.) and choose the Virtual Currency purchase channel reasonably.
|
||||
The fees for your purchase of the Virtual Currency shall be collected by the
|
||||
Company or a cooperating party designated by the Company. The Platform specially
|
||||
reminds you that relevant service providers of different purchase channels (such
|
||||
as third-party payment institutions, app stores, etc.) may charge channel
|
||||
service fees when you make payments in accordance with their own operating
|
||||
strategies. This may result in differences in the amount of fees required to
|
||||
purchase the same amount of Virtual Currency through different channels, or
|
||||
differences in the amount of Virtual Currency that can be purchased with the
|
||||
same amount of fees. The specific details shall be subject to the page display
|
||||
when you purchase the Virtual Currency. Please carefully confirm the relevant
|
||||
page information (including but not limited to price, quantity, service fee
|
||||
description, etc.) and choose the Virtual Currency purchase channel reasonably.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 1.5 Provisions on Proxy Recharge Services */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>1.5 Provisions on Proxy Recharge Services</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
The Platform does not provide any proxy recharge services. If you intend to purchase the Virtual Currency for another person's account, you shall confirm the identity and will of the account user by yourself. Any disputes arising from proxy recharge (including but not limited to the account user denying receipt of the Virtual Currency, requesting a refund, etc.) shall be resolved through negotiation between you and the account user. The Platform shall not bear any liability to you or the account user in this regard.
|
||||
The Platform does not provide any proxy recharge services. If you intend to
|
||||
purchase the Virtual Currency for another person's account, you shall confirm
|
||||
the identity and will of the account user by yourself. Any disputes arising from
|
||||
proxy recharge (including but not limited to the account user denying receipt of
|
||||
the Virtual Currency, requesting a refund, etc.) shall be resolved through
|
||||
negotiation between you and the account user. The Platform shall not bear any
|
||||
liability to you or the account user in this regard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* II. Rational Consumption */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>II. Rational Consumption</p>
|
||||
</div>
|
||||
|
||||
{/* 2.1 Advocacy of Rational Consumption */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>2.1 Advocacy of Rational Consumption</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
The Platform advocates rational consumption and spending within one's means. You must purchase and use the Virtual Currency and relevant services reasonably according to your own consumption capacity and actual needs to avoid excessive consumption. When the amount of Virtual Currency you purchase is relatively large or the purchase frequency is abnormal, the Platform has the right to remind you of rational consumption through pop-up prompts, SMS notifications, etc. You shall attach importance to such reminders and make prudent decisions.
|
||||
The Platform advocates rational consumption and spending within one's means. You
|
||||
must purchase and use the Virtual Currency and relevant services reasonably
|
||||
according to your own consumption capacity and actual needs to avoid excessive
|
||||
consumption. When the amount of Virtual Currency you purchase is relatively
|
||||
large or the purchase frequency is abnormal, the Platform has the right to
|
||||
remind you of rational consumption through pop-up prompts, SMS notifications,
|
||||
etc. You shall attach importance to such reminders and make prudent decisions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2.2 Requirements for the Legitimacy of Funds */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>2.2 Requirements for the Legitimacy of Funds</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
The funds you use to purchase the Virtual Currency shall be legally obtained and you shall have the right to use such funds (in compliance with relevant laws, regulations and tax provisions); if you violate the provisions of this Clause, any disputes or controversies arising therefrom (including but not limited to account freezing, tax penalties due to illegal source of funds, etc.) shall be resolved by yourself and you shall bear all legal consequences. If your acts cause losses to the Platform or third parties, you shall also make full compensation. If the Platform discovers (including but not limited to active discovery, receipt of third-party complaints, notifications from regulatory authorities or judicial organs, etc.) that you are suspected of violating the aforementioned provisions, the Platform has the right to deduct or clear the Virtual Currency in your account, restrict all or part of the functions of your account, or even permanently ban your account; at the same time, the Platform has the right to keep relevant information and report to relevant regulatory authorities and judicial organs.
|
||||
The funds you use to purchase the Virtual Currency shall be legally obtained and
|
||||
you shall have the right to use such funds (in compliance with relevant laws,
|
||||
regulations and tax provisions); if you violate the provisions of this Clause,
|
||||
any disputes or controversies arising therefrom (including but not limited to
|
||||
account freezing, tax penalties due to illegal source of funds, etc.) shall be
|
||||
resolved by yourself and you shall bear all legal consequences. If your acts
|
||||
cause losses to the Platform or third parties, you shall also make full
|
||||
compensation. If the Platform discovers (including but not limited to active
|
||||
discovery, receipt of third-party complaints, notifications from regulatory
|
||||
authorities or judicial organs, etc.) that you are suspected of violating the
|
||||
aforementioned provisions, the Platform has the right to deduct or clear the
|
||||
Virtual Currency in your account, restrict all or part of the functions of your
|
||||
account, or even permanently ban your account; at the same time, the Platform
|
||||
has the right to keep relevant information and report to relevant regulatory
|
||||
authorities and judicial organs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2.3 Resistance to Irregular Consumption Behaviors */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>2.3 Resistance to Irregular Consumption Behaviors</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
The Platform strictly resists behaviors that induce, stimulate or incite users to consume irrationally (including but not limited to excessive recharge, frequent purchase of virtual gifts, etc.) and behaviors that induce or instigate minors to recharge with false identity information. If you discover the aforementioned irregular behaviors, you may report to the Platform through the publicized channels of the Platform (such as the official customer service email, the report entrance in the APP, etc.). The Platform will take disciplinary measures in accordance with laws and regulations (including but not limited to warning the irregular account, restricting the account functions, banning the account, etc.). We look forward to working with you to build a healthy and orderly Platform ecosystem.
|
||||
The Platform strictly resists behaviors that induce, stimulate or incite users
|
||||
to consume irrationally (including but not limited to excessive recharge,
|
||||
frequent purchase of virtual gifts, etc.) and behaviors that induce or instigate
|
||||
minors to recharge with false identity information. If you discover the
|
||||
aforementioned irregular behaviors, you may report to the Platform through the
|
||||
publicized channels of the Platform (such as the official customer service
|
||||
email, the report entrance in the APP, etc.). The Platform will take
|
||||
disciplinary measures in accordance with laws and regulations (including but not
|
||||
limited to warning the irregular account, restricting the account functions,
|
||||
banning the account, etc.). We look forward to working with you to build a
|
||||
healthy and orderly Platform ecosystem.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* III. Your Rights and Obligations */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>III. Your Rights and Obligations</p>
|
||||
</div>
|
||||
|
||||
{/* 3.1 Obligation of Authenticity of Information and Cooperation in Investigations */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>3.1 Obligation of Authenticity of Information and Cooperation in Investigations</p>
|
||||
<p>
|
||||
3.1 Obligation of Authenticity of Information and Cooperation in Investigations
|
||||
</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
The personal information or materials you provide in the process of using the Services (including but not limited to name, email, payment account information, etc.) shall be true, accurate and complete, and shall comply with the requirements of relevant laws and regulations on personal information protection, such as the U.S. Fair Credit Reporting Act (FCRA) and the European General Data Protection Regulation (GDPR). If laws, regulations or regulatory authorities require you to cooperate in investigations, you shall provide relevant materials and assist in the investigations in accordance with the Platform's requirements.
|
||||
The personal information or materials you provide in the process of using the
|
||||
Services (including but not limited to name, email, payment account information,
|
||||
etc.) shall be true, accurate and complete, and shall comply with the
|
||||
requirements of relevant laws and regulations on personal information
|
||||
protection, such as the U.S. Fair Credit Reporting Act (FCRA) and the European
|
||||
General Data Protection Regulation (GDPR). If laws, regulations or regulatory
|
||||
authorities require you to cooperate in investigations, you shall provide
|
||||
relevant materials and assist in the investigations in accordance with the
|
||||
Platform's requirements.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3.2 Responsibility for Purchase Operations */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>3.2 Responsibility for Purchase Operations</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
When purchasing the Virtual Currency, you shall carefully select and/or enter key information such as your account information (e.g., account ID, bound email/mobile phone number) and the quantity of Virtual Currency to be purchased. If due to factors such as your own input errors, improper operations, insufficient understanding of the charging method or failure to confirm the purchase information, there are purchase errors such as wrong account, wrong quantity of Virtual Currency, repeated purchases, etc., resulting in your losses or additional expenses, the Platform has the right not to make compensation or indemnification.
|
||||
When purchasing the Virtual Currency, you shall carefully select and/or enter
|
||||
key information such as your account information (e.g., account ID, bound
|
||||
email/mobile phone number) and the quantity of Virtual Currency to be purchased.
|
||||
If due to factors such as your own input errors, improper operations,
|
||||
insufficient understanding of the charging method or failure to confirm the
|
||||
purchase information, there are purchase errors such as wrong account, wrong
|
||||
quantity of Virtual Currency, repeated purchases, etc., resulting in your losses
|
||||
or additional expenses, the Platform has the right not to make compensation or
|
||||
indemnification.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3.3 Responsibility for Account Safekeeping */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>3.3 Responsibility for Account Safekeeping</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
You shall properly keep your Crushlevel account (including account ID, password, bound email/mobile phone number and verification code, etc.) and be responsible for all operation behaviors and consequences under this account. If the Platform is unable to provide the Services or makes errors in providing the Services due to the following circumstances of yours, resulting in your losses, the Platform shall not bear legal liability unless otherwise explicitly required by laws and regulations:
|
||||
You shall properly keep your Crushlevel account (including account ID, password,
|
||||
bound email/mobile phone number and verification code, etc.) and be responsible
|
||||
for all operation behaviors and consequences under this account. If the Platform
|
||||
is unable to provide the Services or makes errors in providing the Services due
|
||||
to the following circumstances of yours, resulting in your losses, the Platform
|
||||
shall not bear legal liability unless otherwise explicitly required by laws and
|
||||
regulations:
|
||||
</p>
|
||||
<ul className="list-disc ml-6 mb-4">
|
||||
<ul className="mb-4 ml-6 list-disc">
|
||||
<li className="mb-2">Your account becomes invalid, lost, stolen or banned;</li>
|
||||
<li className="mb-2">The third-party payment institution account or bank account bound to your account is frozen, sealed up or has other abnormalities, or you use an uncertified account or an account that does not belong to you;</li>
|
||||
<li className="mb-2">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>
|
||||
<li className="mb-2">
|
||||
The third-party payment institution account or bank account bound to your
|
||||
account is frozen, sealed up or has other abnormalities, or you use an
|
||||
uncertified account or an account that does not belong to you;
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3.4 Obligation of Compliant Use */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>3.4 Obligation of Compliant Use</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
You shall use the Services in a legal and compliant manner, and shall not use the Services for any purposes that are illegal or criminal, violate public order and good customs, harm social ethics (in line with the standards of public order and good customs in the United States and Europe), interfere with the normal operation of the Platform or infringe the legitimate rights and interests of third parties. Your use of the Services shall also not violate any documents or other requirements that are binding on you (if any). The Platform specially reminds you not to lend, transfer or provide your account to others for use in other ways, and to reasonably prevent others from committing acts that violate the aforementioned provisions through your account, so as to protect the security of your account and property.
|
||||
You shall use the Services in a legal and compliant manner, and shall not use
|
||||
the Services for any purposes that are illegal or criminal, violate public order
|
||||
and good customs, harm social ethics (in line with the standards of public order
|
||||
and good customs in the United States and Europe), interfere with the normal
|
||||
operation of the Platform or infringe the legitimate rights and interests of
|
||||
third parties. Your use of the Services shall also not violate any documents or
|
||||
other requirements that are binding on you (if any). The Platform specially
|
||||
reminds you not to lend, transfer or provide your account to others for use in
|
||||
other ways, and to reasonably prevent others from committing acts that violate
|
||||
the aforementioned provisions through your account, so as to protect the
|
||||
security of your account and property.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3.5 Specifications for Minor Refund Services */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>3.5 Specifications for Minor Refund Services</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
The Platform provides minor consumption refund services in accordance with laws and regulations to protect the legitimate rights and interests of minors and their guardians (in compliance with the provisions on the protection of minor consumption in the U.S. COPPA and the European GDPR); you shall not use this service for illegal purposes or in improper ways, including but not limited to adults pretending to be minors to defraud refunds, inducing minors to consume and then applying for refunds, etc. The aforementioned acts shall constitute a serious violation of this Agreement. After reasonable confirmation, the Platform has the right to refuse the refund and reserve the right to further pursue your legal liability in accordance with the law (including but not limited to reporting to regulatory authorities, filing a lawsuit, etc.).
|
||||
The Platform provides minor consumption refund services in accordance with laws
|
||||
and regulations to protect the legitimate rights and interests of minors and
|
||||
their guardians (in compliance with the provisions on the protection of minor
|
||||
consumption in the U.S. COPPA and the European GDPR); you shall not use this
|
||||
service for illegal purposes or in improper ways, including but not limited to
|
||||
adults pretending to be minors to defraud refunds, inducing minors to consume
|
||||
and then applying for refunds, etc. The aforementioned acts shall constitute a
|
||||
serious violation of this Agreement. After reasonable confirmation, the Platform
|
||||
has the right to refuse the refund and reserve the right to further pursue your
|
||||
legal liability in accordance with the law (including but not limited to
|
||||
reporting to regulatory authorities, filing a lawsuit, etc.).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 3.6 Provisions on Third-Party Services */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>3.6 Provisions on Third-Party Services</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
If the use of the Services involves relevant services provided by third parties (such as payment services, third-party login services, etc.), in addition to complying with the provisions of this Agreement, you shall also agree to and comply with the service agreements and relevant rules of such third parties. Under no circumstances shall any disputes arising from such third parties and their provided relevant services (including but not limited to payment failures, account security issues, etc.) be resolved by you and the third party on your own. The Platform shall not bear any liability to you or the third party in this regard.
|
||||
If the use of the Services involves relevant services provided by third parties
|
||||
(such as payment services, third-party login services, etc.), in addition to
|
||||
complying with the provisions of this Agreement, you shall also agree to and
|
||||
comply with the service agreements and relevant rules of such third parties.
|
||||
Under no circumstances shall any disputes arising from such third parties and
|
||||
their provided relevant services (including but not limited to payment failures,
|
||||
account security issues, etc.) be resolved by you and the third party on your
|
||||
own. The Platform shall not bear any liability to you or the third party in this
|
||||
regard.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* IV. Rights and Obligations of the Platform */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>IV. Rights and Obligations of the Platform</p>
|
||||
</div>
|
||||
|
||||
{/* 4.1 Right to Adjust Service Rules */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>4.1 Right to Adjust Service Rules</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
Based on factors such as revisions to laws and regulations, requirements of regulatory authorities in the United States and Europe, transaction security guarantees, updates to operating strategies, and changes in market environment, the Platform has the right to set relevant restrictions and reminders on the Virtual Currency services from time to time, including but not limited to restricting the transaction limit and/or transaction frequency of all or part of the users, prohibiting specific users from using the Services, and adding transaction verification steps (such as identity verification, SMS verification, etc.). The Platform will notify you of the aforementioned adjustments through reasonable methods such as APP pop-ups, official website announcements, and email notifications. If you do not agree to the adjustments, you may stop using the Services; if you continue to use the Services, it shall be deemed that you agree to such adjustments.
|
||||
Based on factors such as revisions to laws and regulations, requirements of
|
||||
regulatory authorities in the United States and Europe, transaction security
|
||||
guarantees, updates to operating strategies, and changes in market environment,
|
||||
the Platform has the right to set relevant restrictions and reminders on the
|
||||
Virtual Currency services from time to time, including but not limited to
|
||||
restricting the transaction limit and/or transaction frequency of all or part of
|
||||
the users, prohibiting specific users from using the Services, and adding
|
||||
transaction verification steps (such as identity verification, SMS verification,
|
||||
etc.). The Platform will notify you of the aforementioned adjustments through
|
||||
reasonable methods such as APP pop-ups, official website announcements, and
|
||||
email notifications. If you do not agree to the adjustments, you may stop using
|
||||
the Services; if you continue to use the Services, it shall be deemed that you
|
||||
agree to such adjustments.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4.2 Right to Risk Monitoring and Account Management */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>4.2 Right to Risk Monitoring and Account Management</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
To ensure transaction security and the stability of the Platform ecosystem, the Platform has the right to monitor your use of the Services (in compliance with relevant laws and regulations on data security and privacy protection in the United States and Europe). For users or accounts that are reasonably identified as high-risk (including but not limited to those suspected of money laundering, fraud, abnormal account login, large-scale purchase of Virtual Currency followed by rapid consumption, etc.), the Platform may take necessary measures to prevent the expansion of risks and protect the property of users and the ecological security of the Platform. Such necessary measures include deducting or clearing the Virtual Currency in your account, restricting all or part of the functions of your account, or temporarily or permanently banning your account. Before taking the aforementioned measures, the Platform will notify you through reasonable methods as much as possible, unless it is impossible to notify due to emergency situations (such as suspected illegal crimes requiring immediate handling).
|
||||
To ensure transaction security and the stability of the Platform ecosystem, the
|
||||
Platform has the right to monitor your use of the Services (in compliance with
|
||||
relevant laws and regulations on data security and privacy protection in the
|
||||
United States and Europe). For users or accounts that are reasonably identified
|
||||
as high-risk (including but not limited to those suspected of money laundering,
|
||||
fraud, abnormal account login, large-scale purchase of Virtual Currency followed
|
||||
by rapid consumption, etc.), the Platform may take necessary measures to prevent
|
||||
the expansion of risks and protect the property of users and the ecological
|
||||
security of the Platform. Such necessary measures include deducting or clearing
|
||||
the Virtual Currency in your account, restricting all or part of the functions
|
||||
of your account, or temporarily or permanently banning your account. Before
|
||||
taking the aforementioned measures, the Platform will notify you through
|
||||
reasonable methods as much as possible, unless it is impossible to notify due to
|
||||
emergency situations (such as suspected illegal crimes requiring immediate
|
||||
handling).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4.3 Right to Correct Errors */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>4.3 Right to Correct Errors</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
When the Platform discovers errors in the processing of Virtual Currency (including but not limited to errors in the quantity of Virtual Currency issued or deducted) caused by system failures, network problems, human operation errors or any other reasons, whether the error is beneficial to the Platform or you, the Platform has the right to correct the error. In this case, if the actual quantity of Virtual Currency you receive is less than the quantity you should receive, the Platform will make up the difference to your account as soon as possible after confirming the processing error; if the actual quantity of Virtual Currency you receive is more than the quantity you should receive, the Platform has the right to directly deduct the difference from your account without prior notice. If the Virtual Currency in your account is insufficient to offset the difference, the Platform has the right to require you to make up the difference. You shall fulfill this obligation within the reasonable time limit notified by the Platform; otherwise, the Platform has the right to take measures such as restricting account functions and banning the account.
|
||||
When the Platform discovers errors in the processing of Virtual Currency
|
||||
(including but not limited to errors in the quantity of Virtual Currency issued
|
||||
or deducted) caused by system failures, network problems, human operation errors
|
||||
or any other reasons, whether the error is beneficial to the Platform or you,
|
||||
the Platform has the right to correct the error. In this case, if the actual
|
||||
quantity of Virtual Currency you receive is less than the quantity you should
|
||||
receive, the Platform will make up the difference to your account as soon as
|
||||
possible after confirming the processing error; if the actual quantity of
|
||||
Virtual Currency you receive is more than the quantity you should receive, the
|
||||
Platform has the right to directly deduct the difference from your account
|
||||
without prior notice. If the Virtual Currency in your account is insufficient to
|
||||
offset the difference, the Platform has the right to require you to make up the
|
||||
difference. You shall fulfill this obligation within the reasonable time limit
|
||||
notified by the Platform; otherwise, the Platform has the right to take measures
|
||||
such as restricting account functions and banning the account.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4.4 Right to Change, Suspend or Terminate Services */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>4.4 Right to Change, Suspend or Terminate Services</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
The Platform has the right to change, interrupt, suspend or terminate the Services based on specific circumstances such as transaction security, operation plans, national laws and regulations or the requirements of regulatory authorities in the United States and Europe. If the Platform decides to change, interrupt, suspend or terminate the Services, it will notify you in advance through reasonable methods such as APP pop-ups, official website announcements, and email notifications (except for emergency situations such as force majeure and sudden system failures where advance notification is impossible), and handle the unused Virtual Currency balance in your account (excluding the membership recharge amount; for the refund rules of membership recharge amount, please refer to Chapter V of this Agreement) in accordance with the provisions of this Agreement. The Platform shall not bear any tort liability to you due to the change, interruption, suspension or termination of the Services for the aforementioned reasons, unless otherwise stipulated by laws and regulations.
|
||||
The Platform has the right to change, interrupt, suspend or terminate the
|
||||
Services based on specific circumstances such as transaction security, operation
|
||||
plans, national laws and regulations or the requirements of regulatory
|
||||
authorities in the United States and Europe. If the Platform decides to change,
|
||||
interrupt, suspend or terminate the Services, it will notify you in advance
|
||||
through reasonable methods such as APP pop-ups, official website announcements,
|
||||
and email notifications (except for emergency situations such as force majeure
|
||||
and sudden system failures where advance notification is impossible), and handle
|
||||
the unused Virtual Currency balance in your account (excluding the membership
|
||||
recharge amount; for the refund rules of membership recharge amount, please
|
||||
refer to Chapter V of this Agreement) in accordance with the provisions of this
|
||||
Agreement. The Platform shall not bear any tort liability to you due to the
|
||||
change, interruption, suspension or termination of the Services for the
|
||||
aforementioned reasons, unless otherwise stipulated by laws and regulations.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* V. Refund Rules */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>V. Refund Rules</p>
|
||||
</div>
|
||||
|
||||
{/* 5.1 Restrictions on Refunds After Consumption of Virtual Currency */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>5.1 Restrictions on Refunds After Consumption of Virtual Currency</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
After you use the Virtual Currency for consumption (including but not limited to paid chat, unlocking pictures, purchasing Affection Points, sending virtual gifts, unlocking virtual lovers, etc.), since the Virtual Currency has been converted into the corresponding services or rights provided by the Platform, and the services related to AI virtual characters are instantaneous and irreversible, <strong>the Platform does not provide refund services for this part of the Virtual Currency</strong>. You shall carefully confirm your consumption needs before consumption.
|
||||
After you use the Virtual Currency for consumption (including but not limited to
|
||||
paid chat, unlocking pictures, purchasing Affection Points, sending virtual
|
||||
gifts, unlocking virtual lovers, etc.), since the Virtual Currency has been
|
||||
converted into the corresponding services or rights provided by the Platform,
|
||||
and the services related to AI virtual characters are instantaneous and
|
||||
irreversible,{' '}
|
||||
<strong>
|
||||
the Platform does not provide refund services for this part of the Virtual
|
||||
Currency
|
||||
</strong>
|
||||
. You shall carefully confirm your consumption needs before consumption.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VI. Disclaimer */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>VI. Disclaimer</p>
|
||||
</div>
|
||||
|
||||
{/* 6.1 Provision of Services in Current State and Risk Warning */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>6.1 Provision of Services in Current State and Risk Warning</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
You understand and agree that the Services are provided in accordance with the current state achievable under existing technologies and conditions. The Platform will make its best efforts to provide the Services to you and ensure the security and stability of the Services. However, you also know and acknowledge that the Platform cannot foresee and prevent technical and other risks at all times or at all times, including but not limited to service interruptions, delays, errors or data loss caused by force majeure (such as natural disasters, wars, public health emergencies, etc.), network reasons (such as network congestion, hacker attacks, server failures, etc.), third-party service defects (such as failures of third-party payment institutions, changes in app store policies, etc.), revisions to laws and regulations or adjustments to regulatory policies, etc. In the event of such circumstances, the Platform will make its best commercial efforts to improve the situation, but shall not be obligated to bear any legal liability to you or other third parties, unless such losses are caused by the intentional acts or gross negligence of the Platform.
|
||||
You understand and agree that the Services are provided in accordance with the
|
||||
current state achievable under existing technologies and conditions. The
|
||||
Platform will make its best efforts to provide the Services to you and ensure
|
||||
the security and stability of the Services. However, you also know and
|
||||
acknowledge that the Platform cannot foresee and prevent technical and other
|
||||
risks at all times or at all times, including but not limited to service
|
||||
interruptions, delays, errors or data loss caused by force majeure (such as
|
||||
natural disasters, wars, public health emergencies, etc.), network reasons (such
|
||||
as network congestion, hacker attacks, server failures, etc.), third-party
|
||||
service defects (such as failures of third-party payment institutions, changes
|
||||
in app store policies, etc.), revisions to laws and regulations or adjustments
|
||||
to regulatory policies, etc. In the event of such circumstances, the Platform
|
||||
will make its best commercial efforts to improve the situation, but shall not be
|
||||
obligated to bear any legal liability to you or other third parties, unless such
|
||||
losses are caused by the intentional acts or gross negligence of the Platform.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6.2 Disclaimer for System Maintenance and Upgrades */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>6.2 Disclaimer for System Maintenance and Upgrades</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
The Platform may conduct downtime maintenance, system upgrades and function adjustments on its own. If you are unable to use the Services normally due to this, the Platform will notify you of the maintenance/upgrade time and the scope of impact in advance through reasonable methods (except for emergency maintenance), and you agree that the Platform shall not bear legal liability for this. Any losses caused by your attempt to use the Services during the maintenance/upgrade period shall be borne by yourself.
|
||||
The Platform may conduct downtime maintenance, system upgrades and function
|
||||
adjustments on its own. If you are unable to use the Services normally due to
|
||||
this, the Platform will notify you of the maintenance/upgrade time and the scope
|
||||
of impact in advance through reasonable methods (except for emergency
|
||||
maintenance), and you agree that the Platform shall not bear legal liability for
|
||||
this. Any losses caused by your attempt to use the Services during the
|
||||
maintenance/upgrade period shall be borne by yourself.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 6.3 Limitation of Liability */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>6.3 Limitation of Liability</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
Under no circumstances shall the Platform be liable for any indirect, punitive, incidental or special damages (including but not limited to loss of profits, loss of expected benefits, loss of data, etc.). Moreover, the total liability of the Platform to you, regardless of the cause or manner (including but not limited to breach of contract, tort, etc.), shall not exceed the total amount of fees you actually paid for using the recharge services.
|
||||
Under no circumstances shall the Platform be liable for any indirect, punitive,
|
||||
incidental or special damages (including but not limited to loss of profits,
|
||||
loss of expected benefits, loss of data, etc.). Moreover, the total liability of
|
||||
the Platform to you, regardless of the cause or manner (including but not
|
||||
limited to breach of contract, tort, etc.), shall not exceed the total amount of
|
||||
fees you actually paid for using the recharge services.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* VII. Liability for Breach of Contract */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>VII. Liability for Breach of Contract</p>
|
||||
</div>
|
||||
|
||||
{/* 7.1 Handling of Your Breach of Contract */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>7.1 Handling of Your Breach of Contract</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
If you violate any provisions of this Agreement (including but not limited to purchasing Virtual Currency through unauthorized channels, using funds from illegal sources, etc.), the Platform has the right to take appropriate measures in accordance with the severity of the violation, including but not limited to warning, restricting account functions, temporarily or permanently banning your account, and requiring you to bear corresponding legal liability. If your violation causes losses to the Platform or third parties, you shall be liable for full compensation.
|
||||
If you violate any provisions of this Agreement (including but not limited to
|
||||
purchasing Virtual Currency through unauthorized channels, using funds from
|
||||
illegal sources, etc.), the Platform has the right to take appropriate measures
|
||||
in accordance with the severity of the violation, including but not limited to
|
||||
warning, restricting account functions, temporarily or permanently banning your
|
||||
account, and requiring you to bear corresponding legal liability. If your
|
||||
violation causes losses to the Platform or third parties, you shall be liable
|
||||
for full compensation.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -373,5 +628,5 @@ export default function RechargeAgreementPage() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,282 +1,417 @@
|
|||
export default function TermsOfServicePage() {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-start relative size-full min-h-screen ">
|
||||
<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="flex flex-col gap-12 grow items-center justify-start max-w-[752px] min-h-px min-w-px relative shrink-0 w-full">
|
||||
<div className="relative flex size-full min-h-screen flex-col items-center justify-start">
|
||||
<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="relative flex min-h-px w-full max-w-[752px] min-w-px shrink-0 grow flex-col items-center justify-start gap-12">
|
||||
{/* 主标题 */}
|
||||
<div className="txt-headline-s text-center text-white w-full">
|
||||
<p className="whitespace-pre-wrap">
|
||||
Crushlevel User Agreement
|
||||
</p>
|
||||
<div className="txt-headline-s w-full text-center text-white">
|
||||
<p className="whitespace-pre-wrap">Crushlevel User Agreement</p>
|
||||
</div>
|
||||
|
||||
{/* 前言 */}
|
||||
<div className="txt-body-l text-white w-full">
|
||||
<div className="txt-body-l w-full text-white">
|
||||
<p className="mb-4">
|
||||
Welcome to the Crushlevel application (hereinafter referred to as "this App") and related website (Crushlevel.ai, hereinafter referred to as "this Website"). This User Agreement (hereinafter referred to as "this Agreement") is a legally binding agreement between you (hereinafter referred to as the "User") and the operator of Crushlevel (hereinafter referred to as "We," "Us," or "Our") regarding your use of this App and this Website. Before registering for or using this App and this Website, please read this Agreement carefully and understand its contents in full. If you have any questions regarding this Agreement, you should consult Us. If you do not agree to any part of this Agreement, you should immediately cease registration or use of this App and this Website. Once you register for or use this App and this Website, it means that you have fully understood and agreed to all the terms of this Agreement.
|
||||
Welcome to the Crushlevel application (hereinafter referred to as "this App") and
|
||||
related website (Crushlevel.ai, hereinafter referred to as "this Website"). This User
|
||||
Agreement (hereinafter referred to as "this Agreement") is a legally binding agreement
|
||||
between you (hereinafter referred to as the "User") and the operator of Crushlevel
|
||||
(hereinafter referred to as "We," "Us," or "Our") regarding your use of this App and
|
||||
this Website. Before registering for or using this App and this Website, please read
|
||||
this Agreement carefully and understand its contents in full. If you have any
|
||||
questions regarding this Agreement, you should consult Us. If you do not agree to any
|
||||
part of this Agreement, you should immediately cease registration or use of this App
|
||||
and this Website. Once you register for or use this App and this Website, it means
|
||||
that you have fully understood and agreed to all the terms of this Agreement.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 协议条款 */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start text-white w-full">
|
||||
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6 text-white">
|
||||
{/* Article 1: User Eligibility */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>Article 1: User Eligibility</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
You declare and warrant that at the time of registering an account for this App and this Website, you are at least 18 years old, possess full civil rights capacity and civil capacity for conduct, and are able to independently bear civil liability. If you are under 18 years old, you should read this Agreement under the supervision of your legal guardian and only use this App and this Website with the consent of your legal guardian.
|
||||
You declare and warrant that at the time of registering an account for this App
|
||||
and this Website, you are at least 18 years old, possess full civil rights
|
||||
capacity and civil capacity for conduct, and are able to independently bear civil
|
||||
liability. If you are under 18 years old, you should read this Agreement under the
|
||||
supervision of your legal guardian and only use this App and this Website with the
|
||||
consent of your legal guardian.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
You shall ensure that the registration information provided is true, accurate, and complete, and promptly update your registration information to ensure its validity. If, due to registration information provided by you being untrue, inaccurate, incomplete, or not updated in a timely manner, We are unable to provide you with corresponding services or any other losses arise, you shall bear full responsibility.
|
||||
You shall ensure that the registration information provided is true, accurate, and
|
||||
complete, and promptly update your registration information to ensure its
|
||||
validity. If, due to registration information provided by you being untrue,
|
||||
inaccurate, incomplete, or not updated in a timely manner, We are unable to
|
||||
provide you with corresponding services or any other losses arise, you shall bear
|
||||
full responsibility.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Each user may register only one account. Registering multiple accounts in any form, including but not limited to using different identity information, phone numbers, etc., is strictly prohibited. If We discover that you have registered multiple accounts, We have the right to restrict, freeze, or terminate such accounts without any liability to you.
|
||||
Each user may register only one account. Registering multiple accounts in any
|
||||
form, including but not limited to using different identity information, phone
|
||||
numbers, etc., is strictly prohibited. If We discover that you have registered
|
||||
multiple accounts, We have the right to restrict, freeze, or terminate such
|
||||
accounts without any liability to you.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article 2: Account Management */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>Article 2: Account Management</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
You are responsible for the security of your account and password and shall not disclose your account and password to any third party. If your account and password are used illegally by others due to your own reasons, you shall bear all consequences arising therefrom, and We assume no liability.
|
||||
You are responsible for the security of your account and password and shall not
|
||||
disclose your account and password to any third party. If your account and
|
||||
password are used illegally by others due to your own reasons, you shall bear all
|
||||
consequences arising therefrom, and We assume no liability.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
If you discover that your account and password are being used illegally by others or that other security risks exist, you shall immediately notify Us and take corresponding security measures. Upon receiving your notice, We will take reasonable measures based on the actual circumstances, but We assume no responsibility for the outcome of such measures.
|
||||
If you discover that your account and password are being used illegally by others
|
||||
or that other security risks exist, you shall immediately notify Us and take
|
||||
corresponding security measures. Upon receiving your notice, We will take
|
||||
reasonable measures based on the actual circumstances, but We assume no
|
||||
responsibility for the outcome of such measures.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Without Our prior written consent, you may not transfer, gift, lease, or sell your account to any third party. If you violate this provision, you shall bear all consequences arising therefrom, and We have the right to restrict, freeze, or terminate the relevant account(s).
|
||||
Without Our prior written consent, you may not transfer, gift, lease, or sell your
|
||||
account to any third party. If you violate this provision, you shall bear all
|
||||
consequences arising therefrom, and We have the right to restrict, freeze, or
|
||||
terminate the relevant account(s).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article 3: Service Content and Usage Norms */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>Article 3: Service Content and Usage Norms</p>
|
||||
</div>
|
||||
|
||||
{/* (1) Service Content */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>(1) Service Content</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
You can create AI virtual characters ("Characters") on this App and this Website. Created Characters fall into two categories: Original and Derivative (based on existing fictional works).
|
||||
You can create AI virtual characters ("Characters") on this App and this
|
||||
Website. Created Characters fall into two categories: Original and Derivative
|
||||
(based on existing fictional works).
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Other users can chat with the AI virtual Characters you create. Chat methods include text, images, voice, etc.
|
||||
Other users can chat with the AI virtual Characters you create. Chat methods
|
||||
include text, images, voice, etc.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Chatting with Characters allows users to level up their relationship with the Character, unlocking related features and rewards.
|
||||
Chatting with Characters allows users to level up their relationship with the
|
||||
Character, unlocking related features and rewards.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* (2) Usage Norms */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>(2) Usage Norms</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
When using this App and this Website, you shall comply with applicable laws and regulations, public order and good morals, and the provisions of this Agreement. You may not use this App and this Website to engage in any illegal or non-compliant activities.
|
||||
When using this App and this Website, you shall comply with applicable laws and
|
||||
regulations, public order and good morals, and the provisions of this Agreement.
|
||||
You may not use this App and this Website to engage in any illegal or
|
||||
non-compliant activities.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
The AI virtual Characters you create and the content you publish during chats (including but not limited to text, images, voice, etc.) must not contain the following:
|
||||
The AI virtual Characters you create and the content you publish during chats
|
||||
(including but not limited to text, images, voice, etc.) must not contain the
|
||||
following:
|
||||
</p>
|
||||
<ul className="list-disc ml-6 mb-4">
|
||||
<li className="mb-2">Content that violates laws and regulations, such as content endangering national security, undermining ethnic unity, promoting terrorism, extremism, obscenity, pornography, gambling, etc.;</li>
|
||||
<li className="mb-2">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>
|
||||
<ul className="mb-4 ml-6 list-disc">
|
||||
<li className="mb-2">
|
||||
Content that violates laws and regulations, such as content endangering
|
||||
national security, undermining ethnic unity, promoting terrorism, extremism,
|
||||
obscenity, pornography, gambling, etc.;
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
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 insults, slanders, intimidates, or harasses others;</li>
|
||||
<li className="mb-2">Other content that violates public order, good morals, or the provisions of this Agreement.</li>
|
||||
<li className="mb-2">
|
||||
Content that insults, slanders, intimidates, or harasses others;
|
||||
</li>
|
||||
<li className="mb-2">
|
||||
Other content that violates public order, good morals, or the provisions of
|
||||
this Agreement.
|
||||
</li>
|
||||
</ul>
|
||||
<p className="mb-4">
|
||||
You may not use this App and this Website to engage in any form of network attacks, virus dissemination, spam distribution, or other activities that disrupt the normal operation of this App and this Website.
|
||||
You may not use this App and this Website to engage in any form of network
|
||||
attacks, virus dissemination, spam distribution, or other activities that
|
||||
disrupt the normal operation of this App and this Website.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
You shall respect the lawful rights and interests of other users and must not maliciously harass or attack other users or infringe upon other users' private information.
|
||||
You shall respect the lawful rights and interests of other users and must not
|
||||
maliciously harass or attack other users or infringe upon other users' private
|
||||
information.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article 4: Intellectual Property Rights */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>Article 4: Intellectual Property Rights</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
We own all intellectual property rights in this App and this Website, including but not limited to copyrights, trademarks, patents, trade secrets, etc. All content of this App and this Website, including but not limited to text, images, audio, video, software, programs, interface design, etc., is protected by laws and regulations.
|
||||
We own all intellectual property rights in this App and this Website, including
|
||||
but not limited to copyrights, trademarks, patents, trade secrets, etc. All
|
||||
content of this App and this Website, including but not limited to text, images,
|
||||
audio, video, software, programs, interface design, etc., is protected by laws and
|
||||
regulations.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
The intellectual property rights in the AI virtual Characters you create and the content you publish on this App and this Website belong to you. However, you grant Us a worldwide, royalty-free, non-exclusive, transferable, and sub-licensable license to use such content for the operation, promotion, marketing, and related activities of this App and this Website.
|
||||
The intellectual property rights in the AI virtual Characters you create and the
|
||||
content you publish on this App and this Website belong to you. However, you grant
|
||||
Us a worldwide, royalty-free, non-exclusive, transferable, and sub-licensable
|
||||
license to use such content for the operation, promotion, marketing, and related
|
||||
activities of this App and this Website.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
When creating Derivative AI virtual Characters, you shall ensure that the Character does not infringe upon the intellectual property rights of the original work. If any dispute arises due to your creation of a Derivative Character infringing upon others' intellectual property rights, you shall bear full responsibility. If losses are caused to Us, you shall compensate Us accordingly.
|
||||
When creating Derivative AI virtual Characters, you shall ensure that the
|
||||
Character does not infringe upon the intellectual property rights of the original
|
||||
work. If any dispute arises due to your creation of a Derivative Character
|
||||
infringing upon others' intellectual property rights, you shall bear full
|
||||
responsibility. If losses are caused to Us, you shall compensate Us accordingly.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Without Our prior written permission, you may not use, copy, modify, disseminate, or display any intellectual property content of this App and this Website.
|
||||
Without Our prior written permission, you may not use, copy, modify, disseminate,
|
||||
or display any intellectual property content of this App and this Website.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article 5: Payments and Transactions */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>Article 5: Payments and Transactions</p>
|
||||
</div>
|
||||
|
||||
{/* (1) Chat Payments */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>(1) Chat Payments</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
Users have a daily limit on free chats with virtual Characters. The specific number of free chats is subject to the actual display within this App and this Website.
|
||||
Users have a daily limit on free chats with virtual Characters. The specific
|
||||
number of free chats is subject to the actual display within this App and this
|
||||
Website.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
After the free quota is exhausted, users need to pay per message to continue chatting with virtual Characters. Specific payment standards are subject to the actual display within this App and this Website.
|
||||
After the free quota is exhausted, users need to pay per message to continue
|
||||
chatting with virtual Characters. Specific payment standards are subject to the
|
||||
actual display within this App and this Website.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* (2) Creation Payments */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>(2) Creation Payments</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
Creators must pay corresponding fees to create Characters or generate derivative AI images. Fees can be paid via membership subscription or virtual currency.
|
||||
Creators must pay corresponding fees to create Characters or generate derivative
|
||||
AI images. Fees can be paid via membership subscription or virtual currency.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
The specific content, pricing, and validity period of membership services are subject to the actual display within this App and this Website. After purchasing membership, the member benefits will be effective for the corresponding validity period.
|
||||
The specific content, pricing, and validity period of membership services are
|
||||
subject to the actual display within this App and this Website. After purchasing
|
||||
membership, the member benefits will be effective for the corresponding validity
|
||||
period.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Virtual currency is a type of virtual item within this App and this Website, obtainable by users through purchase using fiat currency. The purchase price of virtual currency is subject to the actual display within this App and this Website. Virtual currency may not be exchanged for fiat currency or transferred/gifted to other users.
|
||||
Virtual currency is a type of virtual item within this App and this Website,
|
||||
obtainable by users through purchase using fiat currency. The purchase price of
|
||||
virtual currency is subject to the actual display within this App and this
|
||||
Website. Virtual currency may not be exchanged for fiat currency or
|
||||
transferred/gifted to other users.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* (3) Transaction Rules */}
|
||||
<div className="flex flex-col gap-4 w-full">
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<div className="txt-title-s w-full">
|
||||
<p>(3) Transaction Rules</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
Before making any payment, users should carefully confirm the payment information, including but not limited to the amount, content, and payment method. Once payment is successfully completed, no refunds will be issued unless otherwise stipulated by applicable law or as mutually agreed upon by both parties.
|
||||
Before making any payment, users should carefully confirm the payment
|
||||
information, including but not limited to the amount, content, and payment
|
||||
method. Once payment is successfully completed, no refunds will be issued unless
|
||||
otherwise stipulated by applicable law or as mutually agreed upon by both
|
||||
parties.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
If payment failure or incorrect payment amount occurs due to force majeure such as system failures or network issues, We will handle the situation accordingly upon verification, including but not limited to refunding or supplementing payment.
|
||||
If payment failure or incorrect payment amount occurs due to force majeure such
|
||||
as system failures or network issues, We will handle the situation accordingly
|
||||
upon verification, including but not limited to refunding or supplementing
|
||||
payment.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
We reserve the right to adjust payment standards, membership service content, virtual currency prices, etc., based on market conditions and business development needs. Adjusted content will be announced on this App and this Website and will become effective after the announcement.
|
||||
We reserve the right to adjust payment standards, membership service content,
|
||||
virtual currency prices, etc., based on market conditions and business
|
||||
development needs. Adjusted content will be announced on this App and this
|
||||
Website and will become effective after the announcement.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article 6: Privacy Protection */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>Article 6: Privacy Protection</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
We value user privacy protection and will collect, use, store, and protect your personal information in accordance with the provisions of the "Privacy Policy". The "Privacy Policy" is an integral part of this Agreement and has the same legal effect as this Agreement.
|
||||
We value user privacy protection and will collect, use, store, and protect your
|
||||
personal information in accordance with the provisions of the "Privacy Policy".
|
||||
The "Privacy Policy" is an integral part of this Agreement and has the same legal
|
||||
effect as this Agreement.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
You should read the "Privacy Policy" carefully to understand how We process personal information. If you do not agree to any part of the "Privacy Policy," you should immediately stop using this App and this Website.
|
||||
You should read the "Privacy Policy" carefully to understand how We process
|
||||
personal information. If you do not agree to any part of the "Privacy Policy," you
|
||||
should immediately stop using this App and this Website.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article 7: Disclaimer */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>Article 7: Disclaimer</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
The services of this App and this Website are provided according to the level of technology and conditions currently available. We will make every effort to ensure the stability and security of the services, but We cannot guarantee that services will be uninterrupted, timely, secure, or error-free. We shall not be liable for any service interruption or malfunction caused by force majeure or third-party reasons.
|
||||
The services of this App and this Website are provided according to the level of
|
||||
technology and conditions currently available. We will make every effort to ensure
|
||||
the stability and security of the services, but We cannot guarantee that services
|
||||
will be uninterrupted, timely, secure, or error-free. We shall not be liable for
|
||||
any service interruption or malfunction caused by force majeure or third-party
|
||||
reasons.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Any losses or risks incurred by you during your use of this App and this Website resulting from the use of third-party services or links shall be borne solely by you, and We assume no liability.
|
||||
Any losses or risks incurred by you during your use of this App and this Website
|
||||
resulting from the use of third-party services or links shall be borne solely by
|
||||
you, and We assume no liability.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
You shall bear full responsibility for any losses or legal liabilities arising from your violation of the provisions of this Agreement or applicable laws and regulations. If losses are caused to Us or other users, you shall compensate accordingly.
|
||||
You shall bear full responsibility for any losses or legal liabilities arising
|
||||
from your violation of the provisions of this Agreement or applicable laws and
|
||||
regulations. If losses are caused to Us or other users, you shall compensate
|
||||
accordingly.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
We assume no responsibility for the content on this App and this Website. Regarding the AI virtual Characters created by users and the content published, We only provide a platform service and do not assume responsibility for the authenticity, legality, or accuracy of such content.
|
||||
We assume no responsibility for the content on this App and this Website.
|
||||
Regarding the AI virtual Characters created by users and the content published, We
|
||||
only provide a platform service and do not assume responsibility for the
|
||||
authenticity, legality, or accuracy of such content.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article 8: Agreement Modification and Termination */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>Article 8: Agreement Modification and Termination</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
We reserve the right to modify and update this Agreement based on changes in laws and regulations and business development needs. The modified agreement will be announced on this App and this Website and will become effective after the announcement period. If you object to the modified agreement, you should immediately stop using this App and this Website. If you continue to use this App and this Website, it means you have accepted the modified agreement.
|
||||
We reserve the right to modify and update this Agreement based on changes in laws
|
||||
and regulations and business development needs. The modified agreement will be
|
||||
announced on this App and this Website and will become effective after the
|
||||
announcement period. If you object to the modified agreement, you should
|
||||
immediately stop using this App and this Website. If you continue to use this App
|
||||
and this Website, it means you have accepted the modified agreement.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
If you violate the provisions of this Agreement, We have the right, based on the severity of the violation, to take actions such as warning you, restricting features, freezing, or terminating your account, and reserve the right to pursue your legal liability.
|
||||
If you violate the provisions of this Agreement, We have the right, based on the
|
||||
severity of the violation, to take actions such as warning you, restricting
|
||||
features, freezing, or terminating your account, and reserve the right to pursue
|
||||
your legal liability.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
You may apply to Us to deregister your account at any time. After account deregistration, you will no longer be able to use the services of this App and this Website, and your relevant information will be processed in accordance with the "Privacy Policy."
|
||||
You may apply to Us to deregister your account at any time. After account
|
||||
deregistration, you will no longer be able to use the services of this App and
|
||||
this Website, and your relevant information will be processed in accordance with
|
||||
the "Privacy Policy."
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
If due to legal provisions, government requirements, or other force majeure events this App and this Website cannot continue to provide services, We have the right to terminate this Agreement and will notify you within a reasonable period.
|
||||
If due to legal provisions, government requirements, or other force majeure events
|
||||
this App and this Website cannot continue to provide services, We have the right
|
||||
to terminate this Agreement and will notify you within a reasonable period.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article 9: Governing Law and Dispute Resolution */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>Article 9: Governing Law and Dispute Resolution</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
The conclusion, validity, interpretation, performance, and dispute resolution of this Agreement shall be governed by the laws of the jurisdiction where the user registered their account. If the laws of the registration jurisdiction contain no relevant provisions, internationally accepted commercial practices shall apply.
|
||||
The conclusion, validity, interpretation, performance, and dispute resolution of
|
||||
this Agreement shall be governed by the laws of the jurisdiction where the user
|
||||
registered their account. If the laws of the registration jurisdiction contain no
|
||||
relevant provisions, internationally accepted commercial practices shall apply.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Any dispute arising from or in connection with this Agreement shall first be resolved through friendly negotiation between the parties. If no settlement is reached through negotiation, either party shall have the right to file a lawsuit with the competent people's court in the location where We are based.
|
||||
Any dispute arising from or in connection with this Agreement shall first be
|
||||
resolved through friendly negotiation between the parties. If no settlement is
|
||||
reached through negotiation, either party shall have the right to file a lawsuit
|
||||
with the competent people's court in the location where We are based.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Article 10: Miscellaneous */}
|
||||
<div className="flex flex-col gap-6 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-6">
|
||||
<div className="txt-title-l w-full">
|
||||
<p>Article 10: Miscellaneous</p>
|
||||
</div>
|
||||
<div className="txt-body-l w-full">
|
||||
<p className="mb-4">
|
||||
This Agreement constitutes the entire agreement between you and Us regarding the use of this App and this Website, superseding any prior agreements or understandings, whether oral or written, concerning the subject matter hereof.
|
||||
This Agreement constitutes the entire agreement between you and Us regarding the
|
||||
use of this App and this Website, superseding any prior agreements or
|
||||
understandings, whether oral or written, concerning the subject matter hereof.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
If any term of this Agreement is found to be invalid or unenforceable, it shall not affect the validity of the remaining terms.
|
||||
If any term of this Agreement is found to be invalid or unenforceable, it shall
|
||||
not affect the validity of the remaining terms.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
We reserve the right of final interpretation of this Agreement. If you have any questions during your use of this App and this Website, you may contact Us through the contact methods provided within this App and this Website.
|
||||
We reserve the right of final interpretation of this Agreement. If you have any
|
||||
questions during your use of this App and this Website, you may contact Us through
|
||||
the contact methods provided within this App and this Website.
|
||||
</p>
|
||||
<p className="mb-4">
|
||||
Users are required to carefully read and strictly comply with the above agreement. Thank you for your support and trust in Crushlevel. We hope you enjoy using it!
|
||||
Users are required to carefully read and strictly comply with the above agreement.
|
||||
Thank you for your support and trust in Crushlevel. We hope you enjoy using it!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -284,5 +419,5 @@ export default function TermsOfServicePage() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,4 +123,3 @@ If any term of this Agreement is found to be invalid or unenforceable, it shall
|
|||
We reserve the right of final interpretation of this Agreement. If you have any questions during your use of this App and this Website, you may contact Us through the contact methods provided within this App and this Website.
|
||||
|
||||
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!
|
||||
|
||||
|
|
|
|||
|
|
@ -1,15 +1,20 @@
|
|||
import Empty from "@/components/ui/empty";
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import Empty from '@/components/ui/empty'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
|
||||
export default async function NotFound() {
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex justify-center items-center min-h-screen">
|
||||
<div className="flex h-full min-h-screen w-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Link href="/">
|
||||
<div className="w-[221px] h-[88px] relative mx-auto">
|
||||
<Image src="/icons/login-logo.svg" alt="Anime character" fill className="object-contain" priority />
|
||||
<div className="relative mx-auto h-[88px] w-[221px]">
|
||||
<Image
|
||||
src="/icons/login-logo.svg"
|
||||
alt="Anime character"
|
||||
fill
|
||||
className="object-contain"
|
||||
priority
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
<Empty title="Oops, there’s nothing here…" />
|
||||
|
|
|
|||
|
|
@ -1,41 +1,39 @@
|
|||
import SharePage from "./share-page";
|
||||
import { HydrationBoundary } from "@tanstack/react-query";
|
||||
import { dehydrate } from "@tanstack/react-query";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { aiUserKeys } from "@/lib/query-keys";
|
||||
import { userService } from "@/services/user";
|
||||
import { ApiError } from "@/types/api";
|
||||
import { notFound } from "next/navigation";
|
||||
import SharePage from './share-page'
|
||||
import { HydrationBoundary } from '@tanstack/react-query'
|
||||
import { dehydrate } from '@tanstack/react-query'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { aiUserKeys } from '@/lib/query-keys'
|
||||
import { userService } from '@/services/user'
|
||||
import { ApiError } from '@/types/api'
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
const Page = async ({ params }: { params: Promise<{ userId?: string }> }) => {
|
||||
const { userId } = await params;
|
||||
const { userId } = await params
|
||||
|
||||
if (!userId) {
|
||||
notFound();
|
||||
notFound()
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
try {
|
||||
// 预获取用户基本信息
|
||||
await queryClient.fetchQuery({
|
||||
queryKey: aiUserKeys.baseInfo({ aiId: Number(userId) }),
|
||||
queryFn: () => userService.getAIUserBaseInfo({ aiId: Number(userId) }),
|
||||
});
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof ApiError && error.errorCode === "10010012") {
|
||||
notFound();
|
||||
if (error instanceof ApiError && error.errorCode === '10010012') {
|
||||
notFound()
|
||||
}
|
||||
// 其他错误不影响页面渲染,让客户端处理
|
||||
}
|
||||
|
||||
return (
|
||||
<HydrationBoundary
|
||||
state={dehydrate(queryClient)}
|
||||
>
|
||||
<HydrationBoundary state={dehydrate(queryClient)}>
|
||||
<SharePage />
|
||||
</HydrationBoundary>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default Page;
|
||||
export default Page
|
||||
|
|
|
|||
|
|
@ -1,72 +1,68 @@
|
|||
"use client"
|
||||
'use client'
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useGetAIUserBaseInfo, useGetAIUserStat } from "@/hooks/aiUser";
|
||||
import Image from "next/image";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { formatNumberToKMB, openApp } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tag } from "@/components/ui/tag";
|
||||
import { calculateAudioDuration, formatAudioDuration, parseTextWithBrackets } from "@/utils/textParser";
|
||||
import { useGetIMUserInfo, useGetShareUserInfo } from "@/hooks/useIm";
|
||||
import React from "react";
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useGetAIUserBaseInfo, useGetAIUserStat } from '@/hooks/aiUser'
|
||||
import Image from 'next/image'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { formatNumberToKMB, openApp } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tag } from '@/components/ui/tag'
|
||||
import {
|
||||
calculateAudioDuration,
|
||||
formatAudioDuration,
|
||||
parseTextWithBrackets,
|
||||
} from '@/utils/textParser'
|
||||
import { useGetIMUserInfo, useGetShareUserInfo } from '@/hooks/useIm'
|
||||
import React from 'react'
|
||||
|
||||
const SharePage = () => {
|
||||
const { userId } = useParams();
|
||||
const { userId } = useParams()
|
||||
|
||||
const { data: imUserInfo } = useGetShareUserInfo({
|
||||
aiId: userId ? Number(userId) : 0
|
||||
});
|
||||
const { data: statData } = useGetAIUserStat({ aiId: Number(userId) });
|
||||
const { likedNum } = statData || {};
|
||||
aiId: userId ? Number(userId) : 0,
|
||||
})
|
||||
const { data: statData } = useGetAIUserStat({ aiId: Number(userId) })
|
||||
const { likedNum } = statData || {}
|
||||
|
||||
|
||||
const { backgroundImg, headImg } = imUserInfo || {};
|
||||
const { backgroundImg, headImg } = imUserInfo || {}
|
||||
|
||||
// 计算预估的音频时长
|
||||
const estimatedDuration = React.useMemo(() => {
|
||||
return calculateAudioDuration(
|
||||
imUserInfo?.dialoguePrologue || '',
|
||||
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 = () => {
|
||||
openApp(`crushlevel://profile/${userId}`);
|
||||
openApp(`crushlevel://profile/${userId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen relative max-w-[750px] mx-auto w-full">
|
||||
<div className="relative mx-auto h-screen w-full max-w-[750px]">
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
src={backgroundImg || ''}
|
||||
alt="Background"
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
<Image src={backgroundImg || ''} alt="Background" fill className="object-cover" priority />
|
||||
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #211A2B 0%, rgba(33, 26, 43, 0.00) 20%, rgba(33, 26, 43, 0.00) 50%, #211A2B 100%)",
|
||||
background:
|
||||
'linear-gradient(180deg, #211A2B 0%, rgba(33, 26, 43, 0.00) 20%, rgba(33, 26, 43, 0.00) 50%, #211A2B 100%)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="absolute inset-0 flex flex-col justify-between">
|
||||
<div className="py-4 px-6 shrink-0">
|
||||
<div className="shrink-0 px-6 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="size-10">
|
||||
<AvatarImage src={headImg || ''} />
|
||||
<AvatarFallback>
|
||||
{imUserInfo?.nickname?.charAt(0) || ''}
|
||||
</AvatarFallback>
|
||||
<AvatarFallback>{imUserInfo?.nickname?.charAt(0) || ''}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div>
|
||||
<div className="txt-title-m">{imUserInfo?.nickname || ''}</div>
|
||||
|
|
@ -75,17 +71,22 @@ const SharePage = () => {
|
|||
</div>
|
||||
<div />
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto min-h-0 flex flex-col justify-end">
|
||||
<div className="flex min-h-0 flex-1 flex-col justify-end overflow-auto">
|
||||
<div className="flex flex-col justify-end">
|
||||
<div className="px-6 py-2">
|
||||
<div className="bg-surface-element-normal rounded-lg backdrop-blur-[32px] p-4 border border-solid border-outline-normal">
|
||||
<div className="txt-body-m line-clamp-3">
|
||||
{imUserInfo?.introduction}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 mt-2" onClick={handleOpenApp}>
|
||||
<div className="bg-surface-element-normal border-outline-normal rounded-lg border border-solid p-4 backdrop-blur-[32px]">
|
||||
<div className="txt-body-m line-clamp-3">{imUserInfo?.introduction}</div>
|
||||
<div
|
||||
className="mt-2 flex items-center justify-between gap-4"
|
||||
onClick={handleOpenApp}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag variant="purple" size="small">{imUserInfo?.characterName}</Tag>
|
||||
<Tag variant="magenta" size="small">{imUserInfo?.tagName}</Tag>
|
||||
<Tag variant="purple" size="small">
|
||||
{imUserInfo?.characterName}
|
||||
</Tag>
|
||||
<Tag variant="magenta" size="small">
|
||||
{imUserInfo?.tagName}
|
||||
</Tag>
|
||||
</div>
|
||||
<i className="iconfont icon-icon-fullImage !text-[12px]" />
|
||||
</div>
|
||||
|
|
@ -93,46 +94,56 @@ const SharePage = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-2 flex items-center justify-center">
|
||||
<div className="txt-label-s text-center bg-surface-element-normal rounded-xs px-2 py-1">Content generated by AI</div>
|
||||
<div className="flex items-center justify-center px-6 py-2">
|
||||
<div className="txt-label-s bg-surface-element-normal rounded-xs px-2 py-1 text-center">
|
||||
Content generated by AI
|
||||
</div>
|
||||
<div className="pt-4 px-6 pb-2">
|
||||
<div className="w-[80%] bg-surface-element-dark-normal rounded-lg backdrop-blur-[32px] pt-5 px-4 pb-4 txt-body-m relative">
|
||||
</div>
|
||||
<div className="px-6 pt-4 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]">
|
||||
{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}
|
||||
</span>
|
||||
))}
|
||||
|
||||
<div
|
||||
className="bg-surface-float-normal hover:bg-surface-float-hover py-1 px-3 rounded-tl-sm rounded-r-sm flex items-center gap-2 absolute left-0 -top-3 cursor-pointer"
|
||||
>
|
||||
<div className="h-3 w-3 flex items-center">
|
||||
<i className="iconfont icon-Play leading-none !text-[12px]" />
|
||||
<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 className="flex h-3 w-3 items-center">
|
||||
<i className="iconfont icon-Play !text-[12px] leading-none" />
|
||||
</div>
|
||||
<span className="txt-label-s">{formattedDuration}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="py-2 px-4 shrink-0 pb-10">
|
||||
<div className="rounded-lg px-4 py-2 flex items-center justify-between gap-3 backdrop-blur-lg" style={{ background: "var(--glo-color-transparent-purple-t20, rgba(251, 222, 255, 0.2))" }}>
|
||||
<div className="shrink-0 px-4 py-2 pb-10">
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 rounded-lg px-4 py-2 backdrop-blur-lg"
|
||||
style={{
|
||||
background: 'var(--glo-color-transparent-purple-t20, rgba(251, 222, 255, 0.2))',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Image src="/icons/square-logo.svg" className="rounded-[12px] overflow-hidden" alt="chat" width={48} height={48} />
|
||||
<Image
|
||||
src="/icons/square-logo.svg"
|
||||
className="overflow-hidden rounded-[12px]"
|
||||
alt="chat"
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Image src="/images/share/logo.svg" alt="chat" width={103} height={32} />
|
||||
<div className="txt-label-s">Chat, Crush, AI Date</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="primary" size="small" className="min-w-auto" onClick={handleOpenApp}>Chat</Button>
|
||||
<Button variant="primary" size="small" className="min-w-auto" onClick={handleOpenApp}>
|
||||
Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default SharePage;
|
||||
export default SharePage
|
||||
|
|
|
|||
|
|
@ -1,114 +1,109 @@
|
|||
"use client"
|
||||
'use client'
|
||||
|
||||
import { useParams } from "next/navigation";
|
||||
import { useGetAIUserBaseInfo } from "@/hooks/aiUser";
|
||||
import Image from "next/image";
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useGetAIUserBaseInfo } from '@/hooks/aiUser'
|
||||
import Image from 'next/image'
|
||||
|
||||
// 按钮组件
|
||||
interface MobileButtonProps {
|
||||
showIcon?: boolean;
|
||||
icon?: React.ReactNode | null;
|
||||
btnTxt?: string;
|
||||
showTxt?: boolean;
|
||||
size?: "Large" | "Medium" | "Small";
|
||||
variant?: "Contrast" | "Basic" | "Ghost";
|
||||
type?: "Primary" | "Secondary" | "Tertiary" | "Destructive" | "VIP";
|
||||
state?: "Default" | "Disabled" | "Pressed";
|
||||
onClick?: () => void;
|
||||
showIcon?: boolean
|
||||
icon?: React.ReactNode | null
|
||||
btnTxt?: string
|
||||
showTxt?: boolean
|
||||
size?: 'Large' | 'Medium' | 'Small'
|
||||
variant?: 'Contrast' | 'Basic' | 'Ghost'
|
||||
type?: 'Primary' | 'Secondary' | 'Tertiary' | 'Destructive' | 'VIP'
|
||||
state?: 'Default' | 'Disabled' | 'Pressed'
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
function MobileButton({
|
||||
showIcon = true,
|
||||
icon = null,
|
||||
btnTxt = "Button",
|
||||
btnTxt = 'Button',
|
||||
showTxt = true,
|
||||
size = "Large",
|
||||
variant = "Basic",
|
||||
type = "Primary",
|
||||
state = "Default",
|
||||
onClick
|
||||
size = 'Large',
|
||||
variant = 'Basic',
|
||||
type = 'Primary',
|
||||
state = 'Default',
|
||||
onClick,
|
||||
}: MobileButtonProps) {
|
||||
if (size === "Small" && variant === "Contrast" && type === "Tertiary" && state === "Default") {
|
||||
if (size === 'Small' && variant === 'Contrast' && type === 'Tertiary' && state === 'Default') {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="bg-[rgba(0,0,0,0.65)] box-border content-stretch flex gap-1 items-center justify-center overflow-clip px-4 py-1.5 relative rounded-[999px] h-8 hover:bg-[rgba(0,0,0,0.8)] transition-colors"
|
||||
className="relative box-border flex h-8 content-stretch items-center justify-center gap-1 overflow-clip rounded-[999px] bg-[rgba(0,0,0,0.65)] px-4 py-1.5 transition-colors hover:bg-[rgba(0,0,0,0.8)]"
|
||||
>
|
||||
{showIcon &&
|
||||
(icon || (
|
||||
<div className="relative h-[15.999px] w-4 shrink-0">
|
||||
<div
|
||||
className="absolute right-[17.15%] left-1/4 aspect-[9.25554/12.1612] translate-y-[-50%]"
|
||||
style={{ top: 'calc(50% + 0.065px)' }}
|
||||
>
|
||||
{showIcon && (
|
||||
icon || (
|
||||
<div className="h-[15.999px] relative shrink-0 w-4">
|
||||
<div className="absolute aspect-[9.25554/12.1612] left-1/4 right-[17.15%] translate-y-[-50%]" style={{ top: "calc(50% + 0.065px)" }}>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
))}
|
||||
{showTxt && (
|
||||
<div className="flex flex-col font-medium justify-center leading-[0] not-italic relative shrink-0 text-[12px] text-center text-nowrap text-white">
|
||||
<div className="relative flex shrink-0 flex-col justify-center text-center text-[12px] leading-[0] font-medium text-nowrap text-white not-italic">
|
||||
<p className="leading-[20px] whitespace-pre">{btnTxt}</p>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="bg-[rgba(0,0,0,0.65)] box-border content-stretch flex gap-1 items-center justify-center overflow-clip px-4 py-1.5 relative rounded-[999px] h-8 hover:bg-[rgba(0,0,0,0.8)] transition-colors"
|
||||
className="relative box-border flex h-8 content-stretch items-center justify-center gap-1 overflow-clip rounded-[999px] bg-[rgba(0,0,0,0.65)] px-4 py-1.5 transition-colors hover:bg-[rgba(0,0,0,0.8)]"
|
||||
>
|
||||
{showIcon && icon}
|
||||
{showTxt && (
|
||||
<span className="font-medium text-[12px] text-white">{btnTxt}</span>
|
||||
)}
|
||||
{showTxt && <span className="text-[12px] font-medium text-white">{btnTxt}</span>}
|
||||
</button>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
const SharePage = () => {
|
||||
const { userId } = useParams();
|
||||
const { userId } = useParams()
|
||||
|
||||
const { data: userInfo } = useGetAIUserBaseInfo({
|
||||
aiId: userId ? Number(userId) : 0
|
||||
});
|
||||
aiId: userId ? Number(userId) : 0,
|
||||
})
|
||||
|
||||
const { homeImageUrl } = userInfo || {};
|
||||
const { homeImageUrl } = userInfo || {}
|
||||
|
||||
const handleChatClick = () => {
|
||||
// 跳转到应用或下载页面
|
||||
window.open('https://crushlevel.com/download', '_blank');
|
||||
};
|
||||
window.open('https://crushlevel.com/download', '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative h-screen overflow-hidden max-w-[750px] mx-auto">
|
||||
<div className="relative mx-auto h-screen max-w-[750px] overflow-hidden">
|
||||
{/* 背景图片 */}
|
||||
<div
|
||||
className="absolute inset-0 overflow-clip"
|
||||
style={{
|
||||
background: "linear-gradient(180deg, #211A2B 0%, rgba(33, 26, 43, 0.00) 20%, rgba(33, 26, 43, 0.00) 50%, #211A2B 100%)"
|
||||
background:
|
||||
'linear-gradient(180deg, #211A2B 0%, rgba(33, 26, 43, 0.00) 20%, rgba(33, 26, 43, 0.00) 50%, #211A2B 100%)',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0">
|
||||
<Image
|
||||
src={homeImageUrl || ''}
|
||||
alt="Background"
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
/>
|
||||
<Image src={homeImageUrl || ''} alt="Background" fill className="object-cover" priority />
|
||||
</div>
|
||||
<div className="absolute inset-0 bg-black/20" />
|
||||
</div>
|
||||
|
||||
{/* 内容容器 */}
|
||||
<div className="relative z-10 box-border content-stretch flex flex-col items-start justify-start pb-0 pt-4 px-0 min-h-screen">
|
||||
<div className="relative z-10 box-border flex min-h-screen flex-col content-stretch items-start justify-start px-0 pt-4 pb-0">
|
||||
{/* 头部用户信息 */}
|
||||
<div className="box-border content-stretch flex items-start justify-start overflow-clip px-6 py-0 relative shrink-0 w-full">
|
||||
<div className="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 box-border flex w-full shrink-0 content-stretch items-start justify-start overflow-clip px-6 py-0">
|
||||
<div className="relative flex min-h-px min-w-px shrink-0 grow basis-0 content-stretch items-center justify-start gap-2 self-stretch">
|
||||
{/* 用户头像 */}
|
||||
<div className="overflow-clip relative rounded-[99px] shrink-0 size-10">
|
||||
<div className="relative size-10 shrink-0 overflow-clip rounded-[99px]">
|
||||
{userInfo?.headImg ? (
|
||||
<Image
|
||||
src={userInfo.headImg}
|
||||
|
|
@ -117,8 +112,8 @@ const SharePage = () => {
|
|||
className="object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-purple-400 to-pink-400 flex items-center justify-center">
|
||||
<span className="text-white text-lg font-bold">
|
||||
<div className="flex h-full w-full items-center justify-center bg-gradient-to-br from-purple-400 to-pink-400">
|
||||
<span className="text-lg font-bold text-white">
|
||||
{userInfo?.nickname?.charAt(0) || 'U'}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -126,54 +121,64 @@ const SharePage = () => {
|
|||
</div>
|
||||
|
||||
{/* 用户名和点赞数 */}
|
||||
<div className="content-stretch flex flex-col items-start justify-start leading-[0] not-italic relative shrink-0 text-center text-nowrap text-white">
|
||||
<div className="font-semibold relative shrink-0 text-[20px]">
|
||||
<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="relative shrink-0 text-[20px] font-semibold">
|
||||
<p className="leading-[24px] text-nowrap whitespace-pre">
|
||||
{userInfo?.nickname || 'Loading...'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="font-medium relative shrink-0 text-[12px]">
|
||||
<p className="leading-[20px] text-nowrap whitespace-pre">
|
||||
0 likes
|
||||
</p>
|
||||
<div className="relative shrink-0 text-[12px] font-medium">
|
||||
<p className="leading-[20px] text-nowrap whitespace-pre">0 likes</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主要内容区域 */}
|
||||
<div className="basis-0 content-stretch flex flex-col grow items-start justify-end min-h-px min-w-px relative shrink-0 w-full">
|
||||
<div className="relative flex min-h-px w-full min-w-px shrink-0 grow basis-0 flex-col content-stretch items-start justify-end">
|
||||
{/* 消息内容 */}
|
||||
<div className="grid-cols-[max-content] grid-rows-[max-content] inline-grid leading-[0] place-items-start relative shrink-0 px-6">
|
||||
|
||||
<div className="relative inline-grid shrink-0 grid-cols-[max-content] grid-rows-[max-content] place-items-start px-6 leading-[0]">
|
||||
{/* AI信息卡片 */}
|
||||
<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="backdrop-blur-[32px] backdrop-filter bg-[rgba(251,222,255,0.08)] box-border content-stretch flex flex-col gap-2 items-start justify-start p-[16px] relative rounded-[16px] shrink-0 w-full border border-[rgba(251,222,255,0.2)]">
|
||||
<div className="relative box-border flex w-full shrink-0 flex-col content-stretch items-start justify-start gap-1 px-0 py-2">
|
||||
<div className="relative box-border flex w-full shrink-0 flex-col content-stretch items-start justify-start gap-2 rounded-[16px] border border-[rgba(251,222,255,0.2)] bg-[rgba(251,222,255,0.08)] p-[16px] backdrop-blur-[32px] backdrop-filter">
|
||||
{/* 介绍文本 */}
|
||||
<div className="font-regular leading-[20px] overflow-hidden relative shrink-0 text-[14px] text-white w-full line-clamp-3">
|
||||
<div className="font-regular relative line-clamp-3 w-full shrink-0 overflow-hidden text-[14px] leading-[20px] text-white">
|
||||
<p>
|
||||
<span className="font-semibold">Intro: </span>
|
||||
<span>{userInfo?.introduction || 'This is an AI character with unique personality and charm. Start chatting to discover more about them!'}</span>
|
||||
<span>
|
||||
{userInfo?.introduction ||
|
||||
'This is an AI character with unique personality and charm. Start chatting to discover more about them!'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 标签 */}
|
||||
<div className="content-stretch flex gap-2 items-start justify-start relative shrink-0 w-full">
|
||||
<div className="relative flex w-full shrink-0 content-stretch items-start justify-start gap-2">
|
||||
{userInfo?.tagName ? (
|
||||
userInfo.tagName.split(',').slice(0, 2).map((tag: string, index: number) => (
|
||||
<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">
|
||||
<div className="font-medium leading-[20px] text-[12px] text-white">
|
||||
userInfo.tagName
|
||||
.split(',')
|
||||
.slice(0, 2)
|
||||
.map((tag: string, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="relative box-border flex h-6 min-w-6 shrink-0 content-stretch items-center justify-center gap-1 overflow-clip rounded-[4px] bg-[#d21f77]/45 px-2 py-0.5 backdrop-blur backdrop-filter"
|
||||
>
|
||||
<div className="text-[12px] leading-[20px] font-medium text-white">
|
||||
{tag.trim()}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<>
|
||||
<div 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 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 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">Romantic</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
|
@ -182,32 +187,34 @@ const SharePage = () => {
|
|||
</div>
|
||||
|
||||
{/* AI生成内容提示 */}
|
||||
<div className="box-border content-stretch flex flex-col gap-1 items-center justify-start px-0 py-2 relative shrink-0 w-full">
|
||||
<div className="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 leading-[20px] text-[12px] text-center text-white">
|
||||
<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="relative box-border flex shrink-0 content-stretch items-center justify-center gap-2 rounded-[4px] bg-[rgba(251,222,255,0.08)] px-2 py-1 backdrop-blur-[32px] backdrop-filter">
|
||||
<div className="font-regular text-center text-[12px] leading-[20px] text-white">
|
||||
Content generated by AI
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 示例对话消息 */}
|
||||
<div className="box-border content-stretch flex gap-4 items-start justify-start pb-2 pl-0 pr-20 pt-4 relative shrink-0 w-full">
|
||||
<div className="backdrop-blur-[32px] backdrop-filter basis-0 bg-[rgba(0,0,0,0.65)] box-border content-stretch flex gap-2.5 grow items-start justify-start min-h-px min-w-px pb-4 pt-5 px-4 relative rounded-[16px] shrink-0">
|
||||
<div className="relative box-border flex w-full shrink-0 content-stretch items-start justify-start gap-4 pt-4 pr-20 pb-2 pl-0">
|
||||
<div className="relative box-border flex min-h-px min-w-px shrink-0 grow basis-0 content-stretch items-start justify-start gap-2.5 rounded-[16px] bg-[rgba(0,0,0,0.65)] px-4 pt-5 pb-4 backdrop-blur-[32px] backdrop-filter">
|
||||
{/* 语音标签 */}
|
||||
<div className="absolute bg-[#484151] box-border content-stretch flex gap-2 items-center justify-center left-0 overflow-clip px-3 py-1 rounded-br-[8px] rounded-tl-[8px] rounded-tr-[8px] top-[-12px]">
|
||||
<div className="relative shrink-0 size-3">
|
||||
<div className="absolute top-[-12px] left-0 box-border flex content-stretch items-center justify-center gap-2 overflow-clip rounded-tl-[8px] rounded-tr-[8px] rounded-br-[8px] bg-[#484151] px-3 py-1">
|
||||
<div className="relative size-3 shrink-0">
|
||||
<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>
|
||||
</div>
|
||||
<div className="font-medium leading-[20px] text-[12px] text-nowrap text-white">
|
||||
<div className="text-[12px] leading-[20px] font-medium text-nowrap text-white">
|
||||
2''
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 消息内容 */}
|
||||
<div className="basis-0 font-regular grow leading-[20px] min-h-px min-w-px text-[14px] text-white">
|
||||
<span className="text-[#958e9e]">(Watching her parents toast you respectfully, I feel very sad.) </span>
|
||||
<div className="font-regular min-h-px min-w-px grow basis-0 text-[14px] leading-[20px] text-white">
|
||||
<span className="text-[#958e9e]">
|
||||
(Watching her parents toast you respectfully, I feel very sad.){' '}
|
||||
</span>
|
||||
<span>Are you?</span>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -216,10 +223,10 @@ const SharePage = () => {
|
|||
</div>
|
||||
|
||||
{/* 底部品牌区域 */}
|
||||
<div className="box-border content-stretch flex gap-1 items-start justify-start overflow-clip px-2 py-4 relative shrink-0 w-full">
|
||||
<div className="basis-0 bg-gradient-to-r from-[#f264a4] to-[#c241e6] box-border content-stretch flex gap-3 grow items-center justify-start min-h-px min-w-px px-4 py-2 relative rounded-[16px] shrink-0">
|
||||
<div className="relative box-border flex w-full shrink-0 content-stretch items-start justify-start gap-1 overflow-clip px-2 py-4">
|
||||
<div className="relative box-border flex min-h-px min-w-px shrink-0 grow basis-0 content-stretch items-center justify-start gap-3 rounded-[16px] bg-gradient-to-r from-[#f264a4] to-[#c241e6] px-4 py-2">
|
||||
{/* App图标 */}
|
||||
<div className="overflow-clip relative rounded-[12px] shrink-0 size-[24px]">
|
||||
<div className="relative size-[24px] shrink-0 overflow-clip rounded-[12px]">
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
alt="Crushlevel Logo"
|
||||
|
|
@ -230,8 +237,8 @@ const SharePage = () => {
|
|||
</div>
|
||||
|
||||
{/* 品牌信息 */}
|
||||
<div className="basis-0 content-stretch flex flex-col grow items-start justify-start min-h-px min-w-px relative shrink-0">
|
||||
<div className="h-[14.422px] overflow-clip relative shrink-0">
|
||||
<div className="relative flex min-h-px min-w-px shrink-0 grow basis-0 flex-col content-stretch items-start justify-start">
|
||||
<div className="relative h-[14.422px] shrink-0 overflow-clip">
|
||||
<Image
|
||||
src="/logo.svg"
|
||||
alt="Crushlevel"
|
||||
|
|
@ -240,7 +247,7 @@ const SharePage = () => {
|
|||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="font-medium leading-[20px] text-[12px] text-center text-nowrap text-white">
|
||||
<div className="text-center text-[12px] leading-[20px] font-medium text-nowrap text-white">
|
||||
Chat. Crush. AI Date
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -258,7 +265,7 @@ const SharePage = () => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default SharePage;
|
||||
export default SharePage
|
||||
|
|
|
|||
|
|
@ -1,58 +1,64 @@
|
|||
'use client';
|
||||
'use client'
|
||||
|
||||
import ChatMessageList from "./components/ChatMessageList";
|
||||
import ChatBackground from "./components/ChatBackground";
|
||||
import ChatMessageAction from "./components/ChatMessageAction";
|
||||
import ChatDrawers from "./components/ChatDrawers";
|
||||
import { ChatConfigProvider } from "./context/chatConfig";
|
||||
import { DrawerLayerProvider } from "./components/ChatDrawers/InlineDrawer";
|
||||
import { IconButton } from "@/components/ui/button";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isChatProfileDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat";
|
||||
import ChatCall from "./components/ChatCall";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { RED_DOT_KEYS, useRedDot } from "@/hooks/useRedDot";
|
||||
import { useS3TokenCache } from "@/hooks/useS3TokenCache";
|
||||
import { BizTypeEnum } from "@/services/common/types";
|
||||
import ChatFirstGuideDialog from "./components/ChatFirstGuideDialog";
|
||||
import CoinInsufficientDialog from "@/components/features/coin-insufficient-dialog";
|
||||
import ChatMessageList from './components/ChatMessageList'
|
||||
import ChatBackground from './components/ChatBackground'
|
||||
import ChatMessageAction from './components/ChatMessageAction'
|
||||
import ChatDrawers from './components/ChatDrawers'
|
||||
import { ChatConfigProvider } from './context/chatConfig'
|
||||
import { DrawerLayerProvider } from './components/ChatDrawers/InlineDrawer'
|
||||
import { IconButton } from '@/components/ui/button'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { isChatProfileDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat'
|
||||
import ChatCall from './components/ChatCall'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { RED_DOT_KEYS, useRedDot } from '@/hooks/useRedDot'
|
||||
import { useS3TokenCache } from '@/hooks/useS3TokenCache'
|
||||
import { BizTypeEnum } from '@/services/common/types'
|
||||
import ChatFirstGuideDialog from './components/ChatFirstGuideDialog'
|
||||
import CoinInsufficientDialog from '@/components/features/coin-insufficient-dialog'
|
||||
|
||||
const ChatPage = () => {
|
||||
const setDrawerState = useSetAtom(isChatProfileDrawerOpenAtom);
|
||||
const setIsChatProfileDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open));
|
||||
const setDrawerState = useSetAtom(isChatProfileDrawerOpenAtom)
|
||||
const setIsChatProfileDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
|
||||
|
||||
const { hasRedDot } = useRedDot();
|
||||
const { hasRedDot } = useRedDot()
|
||||
|
||||
// 预加载S3 Token,提升上传速度
|
||||
useS3TokenCache({
|
||||
preloadBizTypes: [BizTypeEnum.SOUND_PATH],
|
||||
refreshBeforeExpireMinutes: 5
|
||||
});
|
||||
refreshBeforeExpireMinutes: 5,
|
||||
})
|
||||
|
||||
const handleOpenChatProfileDrawer = () => {
|
||||
setIsChatProfileDrawerOpen(true);
|
||||
};
|
||||
setIsChatProfileDrawerOpen(true)
|
||||
}
|
||||
|
||||
const isShowRedDot = hasRedDot(RED_DOT_KEYS.CHAT_BACKGROUND) || hasRedDot(RED_DOT_KEYS.CHAT_BUBBLE);
|
||||
const isShowRedDot =
|
||||
hasRedDot(RED_DOT_KEYS.CHAT_BACKGROUND) || hasRedDot(RED_DOT_KEYS.CHAT_BUBBLE)
|
||||
|
||||
return (
|
||||
<ChatConfigProvider>
|
||||
<div className="overflow-hidden flex">
|
||||
<div className="flex overflow-hidden">
|
||||
<div className="bg-background-default absolute inset-0" />
|
||||
<div className="flex-1 relative transition-all border-t border-solid border-outline-normal">
|
||||
<div className="border-outline-normal relative flex-1 border-t border-solid transition-all">
|
||||
<ChatBackground />
|
||||
|
||||
{/* 消息列表区域 */}
|
||||
<div className="relative h-[calc(100vh-64px)] flex flex-col px-6">
|
||||
<div className="relative flex h-[calc(100vh-64px)] flex-col px-6">
|
||||
<ChatMessageList />
|
||||
<ChatMessageAction />
|
||||
<div className="absolute right-6 top-6 w-8 h-8">
|
||||
<IconButton iconfont="icon-icon_chatroom_more" variant="ghost" size="small" onClick={handleOpenChatProfileDrawer} />
|
||||
<div className="absolute top-6 right-6 h-8 w-8">
|
||||
<IconButton
|
||||
iconfont="icon-icon_chatroom_more"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={handleOpenChatProfileDrawer}
|
||||
/>
|
||||
{isShowRedDot && <Badge variant="dot" className="absolute top-0 right-0" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative transition-all border-t border-solid border-outline-normal">
|
||||
<div className="border-outline-normal relative border-t border-solid transition-all">
|
||||
<DrawerLayerProvider>
|
||||
<ChatDrawers />
|
||||
</DrawerLayerProvider>
|
||||
|
|
@ -64,7 +70,7 @@ const ChatPage = () => {
|
|||
|
||||
<CoinInsufficientDialog />
|
||||
</ChatConfigProvider>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatPage;
|
||||
export default ChatPage
|
||||
|
|
|
|||
|
|
@ -1,31 +1,52 @@
|
|||
"use client";
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image';
|
||||
import * as React from 'react';
|
||||
import { useChatConfig } from '../context/chatConfig';
|
||||
import Image from 'next/image'
|
||||
import * as React from 'react'
|
||||
import { useChatConfig } from '../context/chatConfig'
|
||||
|
||||
const ChatBackground = () => {
|
||||
const { aiInfo } = useChatConfig();
|
||||
const { backgroundImg } = aiInfo || {};
|
||||
const { aiInfo } = useChatConfig()
|
||||
const { backgroundImg } = aiInfo || {}
|
||||
|
||||
return (
|
||||
<div className="bg-background-default absolute left-0 right-0 top-0 bottom-0 overflow-hidden">
|
||||
<div className="absolute w-[752px] left-1/2 -translate-x-1/2 top-0 bottom-0">
|
||||
{backgroundImg && <Image src={backgroundImg} alt="Background" className="object-cover h-full w-full pointer-events-none" width={720} height={1280} style={{ objectPosition: "center -48px" }} />}
|
||||
<div className="bg-background-default absolute top-0 right-0 bottom-0 left-0 overflow-hidden">
|
||||
<div className="absolute top-0 bottom-0 left-1/2 w-[752px] -translate-x-1/2">
|
||||
{backgroundImg && (
|
||||
<Image
|
||||
src={backgroundImg}
|
||||
alt="Background"
|
||||
className="pointer-events-none h-full w-full object-cover"
|
||||
width={720}
|
||||
height={1280}
|
||||
style={{ objectPosition: 'center -48px' }}
|
||||
/>
|
||||
)}
|
||||
{/* <div className="absolute h-full bottom-0 left-0 right-0 top-1/2 -translate-y-1/2 pointer-events-none min-h-[1280px]" style={{ background: 'radial-gradient(48.62% 48.62% at 50% 50%, rgba(33, 26, 43, 0.35) 0%, #211A2B 100%)' }} /> */}
|
||||
{/* todo */}
|
||||
<div className="absolute top-0 bottom-0 left-0 w-[240px]" style={{ background: "linear-gradient(-90deg, rgba(33, 26, 43, 0) 0%, rgba(33, 26, 43, 0.00838519) 11.9%, rgba(33, 26, 43, 0.0324148) 21.59%, rgba(33, 26, 43, 0.0704) 29.39%, rgba(33, 26, 43, 0.120652) 35.67%, rgba(33, 26, 43, 0.181481) 40.75%, rgba(33, 26, 43, 0.2512) 44.98%, rgba(33, 26, 43, 0.328119) 48.7%, rgba(33, 26, 43, 0.410548) 52.25%, rgba(33, 26, 43, 0.4968) 55.96%, rgba(33, 26, 43, 0.585185) 60.19%, rgba(33, 26, 43, 0.674015) 65.27%, rgba(33, 26, 43, 0.7616) 71.55%, rgba(33, 26, 43, 0.846252) 79.36%, rgba(33, 26, 43, 0.926281) 89.04%, #211A2B 100.94%)"}} />
|
||||
<div 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%)"}} />
|
||||
<div
|
||||
className="absolute top-0 bottom-0 left-0 w-[240px]"
|
||||
style={{
|
||||
background:
|
||||
'linear-gradient(-90deg, rgba(33, 26, 43, 0) 0%, rgba(33, 26, 43, 0.00838519) 11.9%, rgba(33, 26, 43, 0.0324148) 21.59%, rgba(33, 26, 43, 0.0704) 29.39%, rgba(33, 26, 43, 0.120652) 35.67%, rgba(33, 26, 43, 0.181481) 40.75%, rgba(33, 26, 43, 0.2512) 44.98%, rgba(33, 26, 43, 0.328119) 48.7%, rgba(33, 26, 43, 0.410548) 52.25%, rgba(33, 26, 43, 0.4968) 55.96%, rgba(33, 26, 43, 0.585185) 60.19%, rgba(33, 26, 43, 0.674015) 65.27%, rgba(33, 26, 43, 0.7616) 71.55%, rgba(33, 26, 43, 0.846252) 79.36%, rgba(33, 26, 43, 0.926281) 89.04%, #211A2B 100.94%)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
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
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background: "radial-gradient(157.27% 64.69% at 50% 37.71%, rgba(33, 26, 43, 0.35) 0%, rgba(33, 26, 43, 0.35545) 11.79%, rgba(33, 26, 43, 0.37107) 21.38%, rgba(33, 26, 43, 0.39576) 29.12%, rgba(33, 26, 43, 0.428424) 35.34%, rgba(33, 26, 43, 0.467963) 40.37%, rgba(33, 26, 43, 0.51328) 44.56%, rgba(33, 26, 43, 0.563277) 48.24%, rgba(33, 26, 43, 0.616856) 51.76%, rgba(33, 26, 43, 0.67292) 55.44%, rgba(33, 26, 43, 0.73037) 59.63%, rgba(33, 26, 43, 0.78811) 64.66%, rgba(33, 26, 43, 0.84504) 70.88%, rgba(33, 26, 43, 0.900064) 78.62%, rgba(33, 26, 43, 0.952083) 88.21%, #211A2B 100%)"
|
||||
background:
|
||||
'radial-gradient(157.27% 64.69% at 50% 37.71%, rgba(33, 26, 43, 0.35) 0%, rgba(33, 26, 43, 0.35545) 11.79%, rgba(33, 26, 43, 0.37107) 21.38%, rgba(33, 26, 43, 0.39576) 29.12%, rgba(33, 26, 43, 0.428424) 35.34%, rgba(33, 26, 43, 0.467963) 40.37%, rgba(33, 26, 43, 0.51328) 44.56%, rgba(33, 26, 43, 0.563277) 48.24%, rgba(33, 26, 43, 0.616856) 51.76%, rgba(33, 26, 43, 0.67292) 55.44%, rgba(33, 26, 43, 0.73037) 59.63%, rgba(33, 26, 43, 0.78811) 64.66%, rgba(33, 26, 43, 0.84504) 70.88%, rgba(33, 26, 43, 0.900064) 78.62%, rgba(33, 26, 43, 0.952083) 88.21%, #211A2B 100%)',
|
||||
}}
|
||||
>
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatBackground;
|
||||
export default ChatBackground
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1,81 +1,76 @@
|
|||
"use client";
|
||||
'use client'
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { SubtitleState } from "./ChatCallContainer";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { hasReceivedAiGreetingAtom } from "@/atoms/im";
|
||||
import { VoiceWaveAnimation } from "@/components/ui/voice-wave-animation";
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { SubtitleState } from './ChatCallContainer'
|
||||
import { useAtomValue } from 'jotai'
|
||||
import { hasReceivedAiGreetingAtom } from '@/atoms/im'
|
||||
import { VoiceWaveAnimation } from '@/components/ui/voice-wave-animation'
|
||||
|
||||
const ChatCallStatus = ({
|
||||
isConnected,
|
||||
subtitleState,
|
||||
onInterrupt,
|
||||
}: {
|
||||
isConnected: boolean;
|
||||
subtitleState: SubtitleState;
|
||||
onInterrupt?: () => void;
|
||||
isConnected: boolean
|
||||
subtitleState: SubtitleState
|
||||
onInterrupt?: () => void
|
||||
}) => {
|
||||
const hasReceivedAiGreeting = useAtomValue(hasReceivedAiGreetingAtom);
|
||||
const hasReceivedAiGreeting = useAtomValue(hasReceivedAiGreetingAtom)
|
||||
|
||||
const renderAction = () => {
|
||||
if (!subtitleState.hideInterrupt) {
|
||||
return (
|
||||
<Button variant="tertiary" size="large" onClick={onInterrupt}>Click to interrupt</Button>
|
||||
<Button variant="tertiary" size="large" onClick={onInterrupt}>
|
||||
Click to interrupt
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
if (subtitleState.isAiThinking) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-6">
|
||||
{/* 三个圆点动画 */}
|
||||
<div className="flex gap-2 items-center justify-center">
|
||||
<div className="w-3 h-3 bg-white rounded-full animate-calling-dots-1" />
|
||||
<div className="w-4 h-4 bg-white rounded-full animate-calling-dots-2" />
|
||||
<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 className="flex items-center justify-center gap-2">
|
||||
<div className="animate-calling-dots-1 h-3 w-3 rounded-full bg-white" />
|
||||
<div className="animate-calling-dots-2 h-4 w-4 rounded-full bg-white" />
|
||||
<div className="animate-calling-dots-3 h-3 w-3 rounded-full bg-white" />
|
||||
</div>
|
||||
<div className="txt-label-m text-txt-secondary-normal text-center">Thinking...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<div className="flex flex-col justify-center gap-3">
|
||||
<VoiceWaveAnimation animated={subtitleState.isUserSpeaking} barCount={22} />
|
||||
<div className="text-center txt-label-m text-txt-secondary-normal">
|
||||
Listening...
|
||||
<div className="txt-label-m text-txt-secondary-normal text-center">Listening...</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasReceivedAiGreeting) {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 items-center justify-center">
|
||||
<div className="flex flex-col items-center justify-center gap-6">
|
||||
{/* 三个圆点动画 */}
|
||||
<div className="flex gap-2 items-center justify-center">
|
||||
<div className="w-3 h-3 bg-white rounded-full animate-calling-dots-1" />
|
||||
<div className="w-4 h-4 bg-white rounded-full animate-calling-dots-2" />
|
||||
<div className="w-3 h-3 bg-white rounded-full animate-calling-dots-3" />
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<div className="animate-calling-dots-1 h-3 w-3 rounded-full bg-white" />
|
||||
<div className="animate-calling-dots-2 h-4 w-4 rounded-full bg-white" />
|
||||
<div className="animate-calling-dots-3 h-3 w-3 rounded-full bg-white" />
|
||||
</div>
|
||||
<div className="txt-label-m text-txt-secondary-normal text-center">
|
||||
Waiting to be connected
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<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 text-center max-w-[60vw]">{subtitleState.aiSubtitle}</div>
|
||||
<div className="relative my-4 flex size-full min-h-[220px] flex-col content-stretch items-center justify-between gap-4">
|
||||
<div className="txt-body-l max-w-[60vw] text-center">{subtitleState.aiSubtitle}</div>
|
||||
{renderAction()}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
// if (subtitleState.isAiSpeaking) {
|
||||
// return (
|
||||
// <div className="content-stretch flex flex-col gap-4 items-center justify-center relative size-full">
|
||||
|
|
@ -139,4 +134,4 @@ const ChatCallStatus = ({
|
|||
// )
|
||||
}
|
||||
|
||||
export default ChatCallStatus;
|
||||
export default ChatCallStatus
|
||||
|
|
|
|||
|
|
@ -1,16 +1,21 @@
|
|||
"use client";
|
||||
'use client'
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useDoRtcOperation } from "@/hooks/useIm";
|
||||
import { useChatConfig } from "../../context/chatConfig";
|
||||
import { RtcOperation } from "@/services/im";
|
||||
import React, { useState } from "react";
|
||||
import { useNimChat, useNimMsgContext } from "@/context/NimChat/useNimChat";
|
||||
import { CustomMessageType } from "@/types/im";
|
||||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { hasReceivedAiGreetingAtom, hasStartAICallAtom, isCallAtom, selectedConversationIdAtom } from "@/atoms/im";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { walletKeys } from "@/lib/query-keys";
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useDoRtcOperation } from '@/hooks/useIm'
|
||||
import { useChatConfig } from '../../context/chatConfig'
|
||||
import { RtcOperation } from '@/services/im'
|
||||
import React, { useState } from 'react'
|
||||
import { useNimChat, useNimMsgContext } from '@/context/NimChat/useNimChat'
|
||||
import { CustomMessageType } from '@/types/im'
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import {
|
||||
hasReceivedAiGreetingAtom,
|
||||
hasStartAICallAtom,
|
||||
isCallAtom,
|
||||
selectedConversationIdAtom,
|
||||
} from '@/atoms/im'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { walletKeys } from '@/lib/query-keys'
|
||||
|
||||
const ChatEndButton = ({
|
||||
roomId,
|
||||
|
|
@ -19,27 +24,27 @@ const ChatEndButton = ({
|
|||
callStartTime,
|
||||
abortController,
|
||||
}: {
|
||||
roomId: string;
|
||||
taskId: string;
|
||||
onLeave: () => Promise<void>;
|
||||
callStartTime: number | null;
|
||||
abortController: AbortController | null;
|
||||
roomId: string
|
||||
taskId: string
|
||||
onLeave: () => Promise<void>
|
||||
callStartTime: number | null
|
||||
abortController: AbortController | null
|
||||
}) => {
|
||||
const { aiId, handleUserMessage } = useChatConfig();
|
||||
const { mutateAsync: doRtcOperation } = useDoRtcOperation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { nim } = useNimChat();
|
||||
const { sendMessageActive } = useNimMsgContext();
|
||||
const selectedConversationId = useAtomValue(selectedConversationIdAtom);
|
||||
const hasReceivedAiGreeting = useAtomValue(hasReceivedAiGreetingAtom);
|
||||
const setIsCall = useSetAtom(isCallAtom);
|
||||
const [hasStartAICall, setHasStartAICall] = useAtom(hasStartAICallAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const { aiId, handleUserMessage } = useChatConfig()
|
||||
const { mutateAsync: doRtcOperation } = useDoRtcOperation()
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { nim } = useNimChat()
|
||||
const { sendMessageActive } = useNimMsgContext()
|
||||
const selectedConversationId = useAtomValue(selectedConversationIdAtom)
|
||||
const hasReceivedAiGreeting = useAtomValue(hasReceivedAiGreetingAtom)
|
||||
const setIsCall = useSetAtom(isCallAtom)
|
||||
const [hasStartAICall, setHasStartAICall] = useAtom(hasStartAICallAtom)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const handleEndCall = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const duration = Date.now() - (callStartTime || 0);
|
||||
setLoading(true)
|
||||
const duration = Date.now() - (callStartTime || 0)
|
||||
// 如果已开始AI通话,则停止AI通话
|
||||
try {
|
||||
if (hasStartAICall) {
|
||||
|
|
@ -50,8 +55,8 @@ const ChatEndButton = ({
|
|||
aiId: aiId,
|
||||
taskId: taskId,
|
||||
duration,
|
||||
}
|
||||
});
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await doRtcOperation({
|
||||
data: {
|
||||
|
|
@ -60,49 +65,54 @@ const ChatEndButton = ({
|
|||
aiId: aiId,
|
||||
taskId: taskId,
|
||||
duration,
|
||||
},
|
||||
})
|
||||
}
|
||||
});
|
||||
}
|
||||
setHasStartAICall(false);
|
||||
setHasStartAICall(false)
|
||||
} catch (error) {
|
||||
setHasStartAICall(false);
|
||||
setHasStartAICall(false)
|
||||
}
|
||||
|
||||
await onLeave();
|
||||
|
||||
await onLeave()
|
||||
|
||||
if (!hasReceivedAiGreeting) {
|
||||
const text = 'Call Canceled';
|
||||
const text = 'Call Canceled'
|
||||
const msg = nim.V2NIMMessageCreator.createCustomMessage(
|
||||
text,
|
||||
JSON.stringify({
|
||||
type: CustomMessageType.CALL_CANCEL,
|
||||
duration: Date.now() - (callStartTime || 0),
|
||||
})
|
||||
);
|
||||
)
|
||||
sendMessageActive({
|
||||
msg,
|
||||
conversationId: selectedConversationId || '',
|
||||
isLoading: false,
|
||||
});
|
||||
})
|
||||
|
||||
// 通知用户发送了消息,重置自动聊天定时器
|
||||
handleUserMessage();
|
||||
handleUserMessage()
|
||||
}
|
||||
setIsCall(false);
|
||||
await queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() });
|
||||
setIsCall(false)
|
||||
await queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() })
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
console.log(error)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button size="large" className="min-w-[80px]" variant="destructive" loading={loading} onClick={handleEndCall}>
|
||||
<Button
|
||||
size="large"
|
||||
className="min-w-[80px]"
|
||||
variant="destructive"
|
||||
loading={loading}
|
||||
onClick={handleEndCall}
|
||||
>
|
||||
<i className="iconfont icon-hang-up !text-[24px]" />
|
||||
</Button>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatEndButton;
|
||||
export default ChatEndButton
|
||||
|
|
|
|||
|
|
@ -1,35 +1,35 @@
|
|||
"use client"
|
||||
import React from "react";
|
||||
import RtcClient from "./rtc-client";
|
||||
import { LocalAudioPropertiesInfo } from "@byteplus/rtc";
|
||||
'use client'
|
||||
import React from 'react'
|
||||
import RtcClient from './rtc-client'
|
||||
import { LocalAudioPropertiesInfo } from '@byteplus/rtc'
|
||||
|
||||
interface IProps {
|
||||
onRef: (ref: any) => void;
|
||||
config: any;
|
||||
streamOptions: any;
|
||||
handleUserPublishStream: any;
|
||||
handleUserUnpublishStream: any;
|
||||
handleUserStartVideoCapture?: any;
|
||||
handleUserStopVideoCapture?: any;
|
||||
handleUserJoin: any;
|
||||
handleUserLeave: any;
|
||||
handleAutoPlayFail: any;
|
||||
handleEventError: any;
|
||||
handlePlayerEvent: any;
|
||||
handleRoomBinaryMessageReceived: any;
|
||||
handleLocalAudioPropertiesReport: (event: LocalAudioPropertiesInfo[]) => void;
|
||||
onRef: (ref: any) => void
|
||||
config: any
|
||||
streamOptions: any
|
||||
handleUserPublishStream: any
|
||||
handleUserUnpublishStream: any
|
||||
handleUserStartVideoCapture?: any
|
||||
handleUserStopVideoCapture?: any
|
||||
handleUserJoin: any
|
||||
handleUserLeave: any
|
||||
handleAutoPlayFail: any
|
||||
handleEventError: any
|
||||
handlePlayerEvent: any
|
||||
handleRoomBinaryMessageReceived: any
|
||||
handleLocalAudioPropertiesReport: (event: LocalAudioPropertiesInfo[]) => void
|
||||
}
|
||||
|
||||
export default class RtcComponent extends React.Component<IProps, any> {
|
||||
rtc: RtcClient;
|
||||
rtc: RtcClient
|
||||
constructor(props: IProps) {
|
||||
super(props);
|
||||
this.rtc = new RtcClient(props);
|
||||
super(props)
|
||||
this.rtc = new RtcClient(props)
|
||||
}
|
||||
componentDidMount() {
|
||||
this.props.onRef(this.rtc);
|
||||
this.props.onRef(this.rtc)
|
||||
}
|
||||
render() {
|
||||
return <></>;
|
||||
return <></>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,15 @@
|
|||
"use client";
|
||||
'use client'
|
||||
|
||||
import { isCallAtom } from "@/atoms/im";
|
||||
import ChatCallContainer from "./ChatCallContainer";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { isCallAtom } from '@/atoms/im'
|
||||
import ChatCallContainer from './ChatCallContainer'
|
||||
import { useAtomValue } from 'jotai'
|
||||
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,62 +1,62 @@
|
|||
import VERTC, { MediaType, RoomMode, StreamIndex } from '@byteplus/rtc';
|
||||
import VERTC, { MediaType, RoomMode, StreamIndex } from '@byteplus/rtc'
|
||||
|
||||
export default class RtcClient {
|
||||
constructor(props) {
|
||||
this.config = props.config;
|
||||
this.streamOptions = props.streamOptions;
|
||||
this.engine = VERTC.createEngine(props.config.appId);
|
||||
this.handleUserPublishStream = props.handleUserPublishStream;
|
||||
this.handleUserUnpublishStream = props.handleUserUnpublishStream;
|
||||
this.config = props.config
|
||||
this.streamOptions = props.streamOptions
|
||||
this.engine = VERTC.createEngine(props.config.appId)
|
||||
this.handleUserPublishStream = props.handleUserPublishStream
|
||||
this.handleUserUnpublishStream = props.handleUserUnpublishStream
|
||||
// this.handleUserStartVideoCapture = props.handleUserStartVideoCapture;
|
||||
// this.handleUserStopVideoCapture = props.handleUserStopVideoCapture;
|
||||
this.handleEventError = props.handleEventError;
|
||||
this.setRemoteVideoPlayer = this.setRemoteVideoPlayer.bind(this);
|
||||
this.handleUserJoin = props.handleUserJoin;
|
||||
this.handleUserLeave = props.handleUserLeave;
|
||||
this.handleAutoPlayFail = props.handleAutoPlayFail;
|
||||
this.handlePlayerEvent = props.handlePlayerEvent;
|
||||
this.handleRoomBinaryMessageReceived = props.handleRoomBinaryMessageReceived;
|
||||
this.handleLocalAudioPropertiesReport = props.handleLocalAudioPropertiesReport;
|
||||
this.bindEngineEvents();
|
||||
this.handleEventError = props.handleEventError
|
||||
this.setRemoteVideoPlayer = this.setRemoteVideoPlayer.bind(this)
|
||||
this.handleUserJoin = props.handleUserJoin
|
||||
this.handleUserLeave = props.handleUserLeave
|
||||
this.handleAutoPlayFail = props.handleAutoPlayFail
|
||||
this.handlePlayerEvent = props.handlePlayerEvent
|
||||
this.handleRoomBinaryMessageReceived = props.handleRoomBinaryMessageReceived
|
||||
this.handleLocalAudioPropertiesReport = props.handleLocalAudioPropertiesReport
|
||||
this.bindEngineEvents()
|
||||
}
|
||||
SDKVERSION = VERTC.getSdkVersion();
|
||||
SDKVERSION = VERTC.getSdkVersion()
|
||||
bindEngineEvents() {
|
||||
this.engine.on(VERTC.events.onUserPublishStream, this.handleUserPublishStream);
|
||||
this.engine.on(VERTC.events.onUserUnpublishStream, this.handleUserUnpublishStream);
|
||||
this.engine.on(VERTC.events.onUserPublishStream, this.handleUserPublishStream)
|
||||
this.engine.on(VERTC.events.onUserUnpublishStream, this.handleUserUnpublishStream)
|
||||
// this.engine.on(VERTC.events.onUserStartVideoCapture, this.handleUserStartVideoCapture);
|
||||
// this.engine.on(VERTC.events.onUserStopVideoCapture, this.handleUserStopVideoCapture);
|
||||
|
||||
this.engine.on(VERTC.events.onUserJoined, this.handleUserJoin);
|
||||
this.engine.on(VERTC.events.onUserLeave, this.handleUserLeave);
|
||||
this.engine.on(VERTC.events.onUserJoined, this.handleUserJoin)
|
||||
this.engine.on(VERTC.events.onUserLeave, this.handleUserLeave)
|
||||
this.engine.on(VERTC.events.onAutoplayFailed, (events) => {
|
||||
console.log('VERTC.events.onAutoplayFailed', events.userId);
|
||||
this.handleAutoPlayFail(events);
|
||||
});
|
||||
this.engine.on(VERTC.events.onPlayerEvent, this.handlePlayerEvent);
|
||||
this.engine.on(VERTC.events.onError, (e) => this.handleEventError(e, VERTC));
|
||||
this.engine.on(VERTC.events.onRoomBinaryMessageReceived, this.handleRoomBinaryMessageReceived);
|
||||
this.engine.on(VERTC.events.onLocalAudioPropertiesReport, this.handleLocalAudioPropertiesReport);
|
||||
console.log('VERTC.events.onAutoplayFailed', events.userId)
|
||||
this.handleAutoPlayFail(events)
|
||||
})
|
||||
this.engine.on(VERTC.events.onPlayerEvent, this.handlePlayerEvent)
|
||||
this.engine.on(VERTC.events.onError, (e) => this.handleEventError(e, VERTC))
|
||||
this.engine.on(VERTC.events.onRoomBinaryMessageReceived, this.handleRoomBinaryMessageReceived)
|
||||
this.engine.on(VERTC.events.onLocalAudioPropertiesReport, this.handleLocalAudioPropertiesReport)
|
||||
}
|
||||
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, {
|
||||
userId: remoteUserId,
|
||||
renderDom: domId,
|
||||
});
|
||||
})
|
||||
}
|
||||
/**
|
||||
* remove the listeners when `createEngine`
|
||||
*/
|
||||
removeEventListener() {
|
||||
this.engine.off(VERTC.events.onUserPublishStream, this.handleStreamAdd);
|
||||
this.engine.off(VERTC.events.onUserUnpublishStream, this.handleStreamRemove);
|
||||
this.engine.off(VERTC.events.onUserPublishStream, this.handleStreamAdd)
|
||||
this.engine.off(VERTC.events.onUserUnpublishStream, this.handleStreamRemove)
|
||||
// this.engine.off(VERTC.events.onUserStartVideoCapture, this.handleUserStartVideoCapture);
|
||||
// this.engine.off(VERTC.events.onUserStopVideoCapture, this.handleUserStopVideoCapture);
|
||||
this.engine.off(VERTC.events.onUserJoined, this.handleUserJoin);
|
||||
this.engine.off(VERTC.events.onUserLeave, this.handleUserLeave);
|
||||
this.engine.off(VERTC.events.onAutoplayFailed, this.handleAutoPlayFail);
|
||||
this.engine.off(VERTC.events.onPlayerEvent, this.handlePlayerEvent);
|
||||
this.engine.off(VERTC.events.onError, this.handleEventError);
|
||||
this.engine.off(VERTC.events.onUserJoined, this.handleUserJoin)
|
||||
this.engine.off(VERTC.events.onUserLeave, this.handleUserLeave)
|
||||
this.engine.off(VERTC.events.onAutoplayFailed, this.handleAutoPlayFail)
|
||||
this.engine.off(VERTC.events.onPlayerEvent, this.handlePlayerEvent)
|
||||
this.engine.off(VERTC.events.onError, this.handleEventError)
|
||||
}
|
||||
join(token, roomId, uid) {
|
||||
return this.engine.joinRoom(
|
||||
|
|
@ -71,29 +71,29 @@ export default class RtcClient {
|
|||
isAutoSubscribeVideo: false,
|
||||
roomMode: RoomMode.RTC,
|
||||
}
|
||||
);
|
||||
)
|
||||
}
|
||||
/**
|
||||
* get the devices
|
||||
* @returns
|
||||
*/
|
||||
async getDevices() {
|
||||
const devices = await VERTC.enumerateAudioCaptureDevices();
|
||||
const devices = await VERTC.enumerateAudioCaptureDevices()
|
||||
|
||||
return {
|
||||
audioInputs: devices,
|
||||
};
|
||||
}
|
||||
}
|
||||
/**
|
||||
* create the local stream with the config and publish the local stream
|
||||
* @param {*} callback
|
||||
*/
|
||||
async createLocalStream(userId, callback) {
|
||||
const devices = await this.getDevices();
|
||||
const devices = await this.getDevices()
|
||||
const devicesStatus = {
|
||||
video: 1,
|
||||
audio: 1,
|
||||
};
|
||||
}
|
||||
if (!devices.audioInputs.length && !devices.videoInputs.length) {
|
||||
callback({
|
||||
code: -1,
|
||||
|
|
@ -102,19 +102,19 @@ export default class RtcClient {
|
|||
video: 0,
|
||||
audio: 0,
|
||||
},
|
||||
});
|
||||
return;
|
||||
})
|
||||
return
|
||||
}
|
||||
if (this.streamOptions.audio && devices.audioInputs.length) {
|
||||
await this.engine.startAudioCapture(devices.audioInputs[0].deviceId);
|
||||
await this.engine.startAudioCapture(devices.audioInputs[0].deviceId)
|
||||
} else {
|
||||
devicesStatus['video'] = 0;
|
||||
devicesStatus['video'] = 0
|
||||
// this.engine.unpublishStream(MediaType.AUDIO);
|
||||
}
|
||||
if (this.streamOptions.video && devices.videoInputs.length) {
|
||||
// await this.engine.startVideoCapture(devices.videoInputs[0].deviceId);
|
||||
} else {
|
||||
devicesStatus['audio'] = 0;
|
||||
devicesStatus['audio'] = 0
|
||||
// this.engine.unpublishStream(MediaType.VIDEO);
|
||||
}
|
||||
// this.engine.setLocalVideoPlayer(StreamIndex.STREAM_INDEX_MAIN, {
|
||||
|
|
@ -129,14 +129,14 @@ export default class RtcClient {
|
|||
code: 0,
|
||||
msg: 'Failed to enumerate devices.',
|
||||
devicesStatus,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
async changeAudioState(isMicOn) {
|
||||
if (isMicOn) {
|
||||
await this.engine.publishStream(MediaType.AUDIO);
|
||||
await this.engine.publishStream(MediaType.AUDIO)
|
||||
} else {
|
||||
await this.engine.unpublishStream(MediaType.AUDIO);
|
||||
await this.engine.unpublishStream(MediaType.AUDIO)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -149,16 +149,14 @@ export default class RtcClient {
|
|||
// }
|
||||
|
||||
async leave() {
|
||||
await Promise.all([
|
||||
this.engine?.stopAudioCapture(),
|
||||
]);
|
||||
await this.engine?.unpublishStream(MediaType.AUDIO).catch(console.warn);
|
||||
this.engine.leaveRoom();
|
||||
this.engine.destroy();
|
||||
await Promise.all([this.engine?.stopAudioCapture()])
|
||||
await this.engine?.unpublishStream(MediaType.AUDIO).catch(console.warn)
|
||||
this.engine.leaveRoom()
|
||||
this.engine.destroy()
|
||||
}
|
||||
|
||||
async enableAudioPropertiesReport(config) {
|
||||
console.log('enableAudioPropertiesReport', config);
|
||||
await this.engine.enableAudioPropertiesReport(config);
|
||||
console.log('enableAudioPropertiesReport', config)
|
||||
await this.engine.enableAudioPropertiesReport(config)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,78 +1,78 @@
|
|||
import { IRTCEngine } from '@byteplus/rtc';
|
||||
import { IRTCEngine } from '@byteplus/rtc'
|
||||
|
||||
export interface AudioStats {
|
||||
CodecType: string;
|
||||
End2EndDelay: number;
|
||||
MuteState: boolean;
|
||||
PacketLossRate: number;
|
||||
RecvBitrate: number;
|
||||
RecvLevel: number;
|
||||
TotalFreezeTime: number;
|
||||
TotalPlayDuration: number;
|
||||
TransportDelay: number;
|
||||
CodecType: string
|
||||
End2EndDelay: number
|
||||
MuteState: boolean
|
||||
PacketLossRate: number
|
||||
RecvBitrate: number
|
||||
RecvLevel: number
|
||||
TotalFreezeTime: number
|
||||
TotalPlayDuration: number
|
||||
TransportDelay: number
|
||||
}
|
||||
|
||||
export interface RTCClient {
|
||||
engine: IRTCEngine;
|
||||
init: (...args: any[]) => void;
|
||||
join: (...args: any[]) => any;
|
||||
publishStream: (...args: any[]) => Promise<void>;
|
||||
unpublishStream: (...args: any[]) => Promise<void>;
|
||||
subscribe: (...args: any[]) => void;
|
||||
leave: (...args: any[]) => Promise<void>;
|
||||
on: (...args: any[]) => void;
|
||||
off: (...args: any[]) => void;
|
||||
setupLocalVideoPlayer: (...args: any[]) => void;
|
||||
createLocalStream: (...args: any[]) => void;
|
||||
setRemoteVideoPlayer: (...args: any[]) => void;
|
||||
removeEventListener: (...args: any[]) => void;
|
||||
changeAudioState: (...args: any[]) => void;
|
||||
changeVideoState: (...args: any[]) => void;
|
||||
bindEngineEvents: (...args: any[]) => void;
|
||||
enableAudioPropertiesReport: (...args: any[]) => void;
|
||||
engine: IRTCEngine
|
||||
init: (...args: any[]) => void
|
||||
join: (...args: any[]) => any
|
||||
publishStream: (...args: any[]) => Promise<void>
|
||||
unpublishStream: (...args: any[]) => Promise<void>
|
||||
subscribe: (...args: any[]) => void
|
||||
leave: (...args: any[]) => Promise<void>
|
||||
on: (...args: any[]) => void
|
||||
off: (...args: any[]) => void
|
||||
setupLocalVideoPlayer: (...args: any[]) => void
|
||||
createLocalStream: (...args: any[]) => void
|
||||
setRemoteVideoPlayer: (...args: any[]) => void
|
||||
removeEventListener: (...args: any[]) => void
|
||||
changeAudioState: (...args: any[]) => void
|
||||
changeVideoState: (...args: any[]) => void
|
||||
bindEngineEvents: (...args: any[]) => void
|
||||
enableAudioPropertiesReport: (...args: any[]) => void
|
||||
}
|
||||
|
||||
export interface Stream {
|
||||
userId: string;
|
||||
hasAudio: boolean;
|
||||
hasVideo: boolean;
|
||||
isScreen: boolean;
|
||||
videoStreamDescriptions: any[];
|
||||
userId: string
|
||||
hasAudio: boolean
|
||||
hasVideo: boolean
|
||||
isScreen: boolean
|
||||
videoStreamDescriptions: any[]
|
||||
stream: {
|
||||
screen: boolean;
|
||||
};
|
||||
getId: () => string;
|
||||
enableAudio: () => void;
|
||||
disableAudio: () => void;
|
||||
enableVideo: () => void;
|
||||
disableVideo: () => void;
|
||||
close: () => void;
|
||||
init: (...args: any[]) => void;
|
||||
play: (id: string, options?: any) => void;
|
||||
setVideoEncoderConfiguration: (...args: any[]) => void;
|
||||
getStats(): any;
|
||||
getAudioLevel(): number;
|
||||
playerComp: any;
|
||||
screen: boolean
|
||||
}
|
||||
getId: () => string
|
||||
enableAudio: () => void
|
||||
disableAudio: () => void
|
||||
enableVideo: () => void
|
||||
disableVideo: () => void
|
||||
close: () => void
|
||||
init: (...args: any[]) => void
|
||||
play: (id: string, options?: any) => void
|
||||
setVideoEncoderConfiguration: (...args: any[]) => void
|
||||
getStats(): any
|
||||
getAudioLevel(): number
|
||||
playerComp: any
|
||||
}
|
||||
|
||||
export type SubscribeOption = {
|
||||
video?: boolean;
|
||||
audio?: boolean;
|
||||
};
|
||||
video?: boolean
|
||||
audio?: boolean
|
||||
}
|
||||
|
||||
export type DeviceInstance = {
|
||||
deviceId: string;
|
||||
groupId: string;
|
||||
kind: 'audioinput' | 'audiooutput' | 'videoinput';
|
||||
label: string;
|
||||
};
|
||||
deviceId: string
|
||||
groupId: string
|
||||
kind: 'audioinput' | 'audiooutput' | 'videoinput'
|
||||
label: string
|
||||
}
|
||||
|
||||
export type StreamOption = {
|
||||
audio: boolean;
|
||||
video: boolean;
|
||||
data?: boolean;
|
||||
screen?: boolean;
|
||||
mediaStream?: MediaStream;
|
||||
microphoneId?: string;
|
||||
cameraId?: string;
|
||||
};
|
||||
audio: boolean
|
||||
video: boolean
|
||||
data?: boolean
|
||||
screen?: boolean
|
||||
mediaStream?: MediaStream
|
||||
microphoneId?: string
|
||||
cameraId?: string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,43 @@
|
|||
"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";
|
||||
'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'
|
||||
|
||||
const BackgroundImageViewerAction = ({
|
||||
aiId,
|
||||
|
|
@ -29,77 +48,111 @@ const BackgroundImageViewerAction = ({
|
|||
currentIndex,
|
||||
onChange,
|
||||
}: {
|
||||
aiId: number;
|
||||
datas: BackgroundImgListOutput[];
|
||||
backgroundId: number;
|
||||
onDeleted?: (nextIndex: number | null) => void;
|
||||
isSelected: boolean;
|
||||
currentIndex: number;
|
||||
onChange: (backgroundId: number) => void;
|
||||
aiId: number
|
||||
datas: BackgroundImgListOutput[]
|
||||
backgroundId: number
|
||||
onDeleted?: (nextIndex: number | null) => void
|
||||
isSelected: boolean
|
||||
currentIndex: number
|
||||
onChange: (backgroundId: number) => void
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const { mutateAsync: deleteBackground, isPending: isDeletingBackground } = useDelChatBackground({ aiId, backgroundId })
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const { mutateAsync: deleteBackground, isPending: isDeletingBackground } = useDelChatBackground({
|
||||
aiId,
|
||||
backgroundId,
|
||||
})
|
||||
|
||||
const handleDelete = async () => {
|
||||
await deleteBackground({ aiId, backgroundId })
|
||||
const nextLength = datas.length - 1;
|
||||
const nextLength = datas.length - 1
|
||||
if (nextLength <= 0) {
|
||||
onDeleted?.(null);
|
||||
return;
|
||||
onDeleted?.(null)
|
||||
return
|
||||
}
|
||||
const isLast = currentIndex >= nextLength;
|
||||
const nextIndex = isLast ? nextLength - 1 : currentIndex;
|
||||
onDeleted?.(nextIndex);
|
||||
const isLast = currentIndex >= nextLength
|
||||
const nextIndex = isLast ? nextLength - 1 : currentIndex
|
||||
onDeleted?.(nextIndex)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
const handleSelect = () => {
|
||||
// 如果只有一张背景且当前已选中,不允许取消选中
|
||||
if (datas.length === 1 && isSelected) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
onChange(backgroundId);
|
||||
onChange(backgroundId)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-px h-6 bg-outline-normal" />
|
||||
<div className="h-8 flex items-center justify-center bg-surface-element-light-normal rounded-full backdrop-blur-lg px-3 gap-2 cursor-pointer" onClick={() => handleSelect()}>
|
||||
<div className="bg-outline-normal h-6 w-px" />
|
||||
<div
|
||||
className="bg-surface-element-light-normal flex h-8 cursor-pointer items-center justify-center gap-2 rounded-full px-3 backdrop-blur-lg"
|
||||
onClick={() => handleSelect()}
|
||||
>
|
||||
<Checkbox shape="round" checked={isSelected} />
|
||||
<div className="txt-label-s">Select</div>
|
||||
</div>
|
||||
{!!backgroundId && <div className="w-px h-6 bg-outline-normal" />}
|
||||
{!!backgroundId && <IconButton iconfont="icon-trashcan" variant="tertiary" size="small" loading={isDeletingBackground} onClick={handleDelete} />}
|
||||
{!!backgroundId && <div className="bg-outline-normal h-6 w-px" />}
|
||||
{!!backgroundId && (
|
||||
<IconButton
|
||||
iconfont="icon-trashcan"
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
loading={isDeletingBackground}
|
||||
onClick={handleDelete}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const BackgroundItem = ({ item, selected, inUse, onClick, onImagePreview, totalCount }: { item: BackgroundImgListOutput, selected: boolean, inUse: boolean, onClick: () => void, onImagePreview: () => void, totalCount: number }) => {
|
||||
const BackgroundItem = ({
|
||||
item,
|
||||
selected,
|
||||
inUse,
|
||||
onClick,
|
||||
onImagePreview,
|
||||
totalCount,
|
||||
}: {
|
||||
item: BackgroundImgListOutput
|
||||
selected: boolean
|
||||
inUse: boolean
|
||||
onClick: () => void
|
||||
onImagePreview: () => void
|
||||
totalCount: number
|
||||
}) => {
|
||||
const handleClick = () => {
|
||||
// 如果只有一张背景且当前已选中,不允许取消选中
|
||||
if (totalCount === 1 && selected) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
onClick()
|
||||
}
|
||||
onClick();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative cursor-pointer group" onClick={handleClick}>
|
||||
<div className={cn(
|
||||
"bg-surface-element-normal relative aspect-[3/4] rounded-lg overflow-hidden",
|
||||
selected && "border-2 border-solid border-primary-normal"
|
||||
)}>
|
||||
<div className="group relative cursor-pointer" onClick={handleClick}>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-surface-element-normal relative aspect-[3/4] overflow-hidden rounded-lg',
|
||||
selected && 'border-primary-normal border-2 border-solid'
|
||||
)}
|
||||
>
|
||||
<Image src={item.imgUrl || ''} alt={''} fill className="object-cover" />
|
||||
</div>
|
||||
{item.isDefault && <Tag className="absolute top-2 left-2" variant="dark" size="small">Default</Tag>}
|
||||
{item.isDefault && (
|
||||
<Tag className="absolute top-2 left-2" variant="dark" size="small">
|
||||
Default
|
||||
</Tag>
|
||||
)}
|
||||
{inUse && <Checkbox shape="round" checked className="absolute top-2 right-2" />}
|
||||
<IconButton
|
||||
className="absolute bottom-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
className="absolute right-2 bottom-2 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
size="xs"
|
||||
variant="contrast"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onImagePreview();
|
||||
e.stopPropagation()
|
||||
onImagePreview()
|
||||
}}
|
||||
>
|
||||
<i className="iconfont icon-icon-fullImage" />
|
||||
|
|
@ -110,13 +163,14 @@ const BackgroundItem = ({ item, selected, inUse, onClick, onImagePreview, totalC
|
|||
|
||||
const ChatBackgroundDrawer = () => {
|
||||
const [selectId, setSelectId] = useState<number | undefined>()
|
||||
const { aiId, aiInfo } = useChatConfig();
|
||||
const [drawerState, setDrawerState] = useAtom(isChatBackgroundDrawerOpenAtom);
|
||||
const open = drawerState.open;
|
||||
const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open));
|
||||
const router = useRouter();
|
||||
const setCrushLevelDrawerState = useSetAtom(isCrushLevelDrawerOpenAtom);
|
||||
const setIsCrushLevelDrawerOpen = (open: boolean) => setCrushLevelDrawerState(createDrawerOpenState(open));
|
||||
const { aiId, aiInfo } = useChatConfig()
|
||||
const [drawerState, setDrawerState] = useAtom(isChatBackgroundDrawerOpenAtom)
|
||||
const open = drawerState.open
|
||||
const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
|
||||
const router = useRouter()
|
||||
const setCrushLevelDrawerState = useSetAtom(isCrushLevelDrawerOpenAtom)
|
||||
const setIsCrushLevelDrawerOpen = (open: boolean) =>
|
||||
setCrushLevelDrawerState(createDrawerOpenState(open))
|
||||
|
||||
// 图片查看器
|
||||
const {
|
||||
|
|
@ -125,55 +179,56 @@ const ChatBackgroundDrawer = () => {
|
|||
openViewer,
|
||||
closeViewer,
|
||||
handleIndexChange,
|
||||
} = useImageViewer();
|
||||
} = useImageViewer()
|
||||
|
||||
const { backgroundImg, aiUserHeartbeatRelation } = aiInfo || {}
|
||||
const { heartbeatLevelNum } = aiUserHeartbeatRelation || {}
|
||||
|
||||
const isUnlock = heartbeatLevelNum && heartbeatLevelNum >= 10;
|
||||
|
||||
const { data: originBackgroundList } = useGetChatBackgroundList({ aiId });
|
||||
const { mutateAsync: updateChatBackground, isPending: isUpdatingChatBackground } = useSetChatBackground({ aiId })
|
||||
const queryClient = useQueryClient();
|
||||
const isUnlock = heartbeatLevelNum && heartbeatLevelNum >= 10
|
||||
|
||||
const { data: originBackgroundList } = useGetChatBackgroundList({ aiId })
|
||||
const { mutateAsync: updateChatBackground, isPending: isUpdatingChatBackground } =
|
||||
useSetChatBackground({ aiId })
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const backgroundList = React.useMemo(() => {
|
||||
return originBackgroundList?.map(item => ({
|
||||
return originBackgroundList?.map((item) => ({
|
||||
...item,
|
||||
backgroundId: item.backgroundId || 0,
|
||||
}))
|
||||
}, [originBackgroundList])
|
||||
|
||||
useEffect(() => {
|
||||
if (!backgroundList?.length || !aiInfo) return;
|
||||
const defaultId = backgroundList.find(item => item.imgUrl === backgroundImg)?.backgroundId;
|
||||
if (!backgroundList?.length || !aiInfo) return
|
||||
const defaultId = backgroundList.find((item) => item.imgUrl === backgroundImg)?.backgroundId
|
||||
setSelectId(defaultId)
|
||||
}, [backgroundList, aiInfo]);
|
||||
}, [backgroundList, aiInfo])
|
||||
|
||||
const handleUnlock = () => {
|
||||
// // todo
|
||||
// router.push(`/generate/image-2-background?id=${aiId}`);
|
||||
// return;
|
||||
|
||||
if (!aiId) return;
|
||||
if (!aiUserHeartbeatRelation) return;
|
||||
if (!aiId) return
|
||||
if (!aiUserHeartbeatRelation) return
|
||||
|
||||
if (isUnlock) {
|
||||
router.push(`/generate/image-2-background?id=${aiId}`);
|
||||
router.push(`/generate/image-2-background?id=${aiId}`)
|
||||
} else {
|
||||
setIsCrushLevelDrawerOpen(true)
|
||||
}
|
||||
}
|
||||
|
||||
const handleConfirm = async () => {
|
||||
const { imgUrl, isDefault } = backgroundList?.find(item => item.backgroundId === selectId) || {};
|
||||
const { imgUrl, isDefault } =
|
||||
backgroundList?.find((item) => item.backgroundId === selectId) || {}
|
||||
const result = {
|
||||
aiId,
|
||||
backgroundId: selectId || '',
|
||||
}
|
||||
|
||||
if (selectId !== 0) {
|
||||
result.backgroundId = selectId || '';
|
||||
result.backgroundId = selectId || ''
|
||||
}
|
||||
await updateChatBackground(result)
|
||||
|
||||
|
|
@ -188,7 +243,7 @@ const ChatBackgroundDrawer = () => {
|
|||
}
|
||||
|
||||
const handleImagePreview = (index: number) => {
|
||||
openViewer(backgroundList?.map(item => item.imgUrl || '') || [], index)
|
||||
openViewer(backgroundList?.map((item) => item.imgUrl || '') || [], index)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -202,17 +257,20 @@ const ChatBackgroundDrawer = () => {
|
|||
<InlineDrawerHeader>Chat Background</InlineDrawerHeader>
|
||||
<InlineDrawerDescription className="overflow-y-auto">
|
||||
<div>
|
||||
<div className="bg-surface-element-normal rounded-lg flex justify-between items-center gap-4 p-4">
|
||||
<div className="bg-surface-element-normal flex items-center justify-between gap-4 rounded-lg p-4">
|
||||
<div className="flex-1">
|
||||
<div className="txt-title-s">Generate Image</div>
|
||||
<div className="txt-body-s text-txt-secondary-normal mt-1">{isUnlock ? 'Unlocked' : 'Unlocks at Lv.10'}</div>
|
||||
<div className="txt-body-s text-txt-secondary-normal mt-1">
|
||||
{isUnlock ? 'Unlocked' : 'Unlocks at Lv.10'}
|
||||
</div>
|
||||
<Button size="small" onClick={handleUnlock}>{isUnlock ? 'Generate' : 'Unlock'}</Button>
|
||||
</div>
|
||||
<Button size="small" onClick={handleUnlock}>
|
||||
{isUnlock ? 'Generate' : 'Unlock'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 mt-4">
|
||||
{
|
||||
backgroundList?.map((item, index) => (
|
||||
<div className="mt-4 grid grid-cols-2 gap-4">
|
||||
{backgroundList?.map((item, index) => (
|
||||
<BackgroundItem
|
||||
key={item.backgroundId}
|
||||
selected={selectId === item.backgroundId}
|
||||
|
|
@ -222,19 +280,27 @@ const ChatBackgroundDrawer = () => {
|
|||
onImagePreview={() => handleImagePreview(index)}
|
||||
totalCount={backgroundList?.length || 0}
|
||||
/>
|
||||
))
|
||||
}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</InlineDrawerDescription>
|
||||
<InlineDrawerFooter>
|
||||
<Button variant="tertiary" size="large" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button size="large" variant="primary" loading={isUpdatingChatBackground} onClick={handleConfirm}>Confirm</Button>
|
||||
<Button variant="tertiary" size="large" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="large"
|
||||
variant="primary"
|
||||
loading={isUpdatingChatBackground}
|
||||
onClick={handleConfirm}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
</InlineDrawerFooter>
|
||||
</InlineDrawerContent>
|
||||
|
||||
<ImageViewer
|
||||
images={backgroundList?.map(item => item.imgUrl || '') || []}
|
||||
images={backgroundList?.map((item) => item.imgUrl || '') || []}
|
||||
currentIndex={viewerIndex}
|
||||
open={isViewerOpen}
|
||||
onClose={closeViewer}
|
||||
|
|
@ -252,18 +318,18 @@ const ChatBackgroundDrawer = () => {
|
|||
onDeleted={(nextIndex) => {
|
||||
if (nextIndex === null) {
|
||||
// 删除后没有图片了
|
||||
closeViewer();
|
||||
return;
|
||||
closeViewer()
|
||||
return
|
||||
}
|
||||
// 调整到新的索引,避免越界
|
||||
handleIndexChange(nextIndex);
|
||||
handleIndexChange(nextIndex)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</InlineDrawer>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatBackgroundDrawer;
|
||||
export default ChatBackgroundDrawer
|
||||
|
|
|
|||
|
|
@ -1,39 +1,64 @@
|
|||
"use client"
|
||||
'use client'
|
||||
|
||||
import { isChatButtleDrawerOpenAtom, isCrushLevelDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat";
|
||||
import { InlineDrawer, InlineDrawerContent, InlineDrawerDescription, InlineDrawerFooter, InlineDrawerHeader } from "./InlineDrawer";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useGetChatBubbleDictList, useGetMyChatSetting, useSetChatBubble } from "@/hooks/useIm";
|
||||
import { useChatConfig } from "../../context/chatConfig";
|
||||
import { ChatBubbleListOutput, UnlockType } from "@/services/im/types";
|
||||
import Image from "next/image";
|
||||
import { Tag } from "@/components/ui/tag";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useHeartLevelTextFromLevel } from "@/hooks/useHeartLevel";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ChatBubble from "../ChatMessageItems/ChatBubble";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { isVipDrawerOpenAtom } from "@/atoms/im";
|
||||
import { useCurrentUser } from "@/hooks/auth";
|
||||
import { VipType } from "@/services/wallet";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { imKeys } from "@/lib/query-keys";
|
||||
|
||||
const ChatButtleItem = ({ item, selected, inUse, onClick }: { item: ChatBubbleListOutput, selected: boolean, inUse: boolean, onClick: () => void }) => {
|
||||
import {
|
||||
isChatButtleDrawerOpenAtom,
|
||||
isCrushLevelDrawerOpenAtom,
|
||||
createDrawerOpenState,
|
||||
} from '@/atoms/chat'
|
||||
import {
|
||||
InlineDrawer,
|
||||
InlineDrawerContent,
|
||||
InlineDrawerDescription,
|
||||
InlineDrawerFooter,
|
||||
InlineDrawerHeader,
|
||||
} from './InlineDrawer'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useGetChatBubbleDictList, useGetMyChatSetting, useSetChatBubble } from '@/hooks/useIm'
|
||||
import { useChatConfig } from '../../context/chatConfig'
|
||||
import { ChatBubbleListOutput, UnlockType } from '@/services/im/types'
|
||||
import Image from 'next/image'
|
||||
import { Tag } from '@/components/ui/tag'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useHeartLevelTextFromLevel } from '@/hooks/useHeartLevel'
|
||||
import { cn } from '@/lib/utils'
|
||||
import ChatBubble from '../ChatMessageItems/ChatBubble'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { isVipDrawerOpenAtom } from '@/atoms/im'
|
||||
import { useCurrentUser } from '@/hooks/auth'
|
||||
import { VipType } from '@/services/wallet'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { imKeys } from '@/lib/query-keys'
|
||||
|
||||
const ChatButtleItem = ({
|
||||
item,
|
||||
selected,
|
||||
inUse,
|
||||
onClick,
|
||||
}: {
|
||||
item: ChatBubbleListOutput
|
||||
selected: boolean
|
||||
inUse: boolean
|
||||
onClick: () => void
|
||||
}) => {
|
||||
return (
|
||||
<div className="cursor-pointer" onClick={onClick}>
|
||||
<div className={cn("bg-surface-element-normal rounded-lg aspect-[41/30] relative p-[2px] flex justify-center items-center", selected && "p-0 border-2 border-solid border-primary-normal")}>
|
||||
<ChatBubble
|
||||
isDefault={item.isDefault}
|
||||
img={item.webImgUrl}
|
||||
<div
|
||||
className={cn(
|
||||
'bg-surface-element-normal relative flex aspect-[41/30] items-center justify-center rounded-lg p-[2px]',
|
||||
selected && 'border-primary-normal border-2 border-solid p-0'
|
||||
)}
|
||||
>
|
||||
<ChatBubble isDefault={item.isDefault} img={item.webImgUrl}>
|
||||
Hi
|
||||
</ChatBubble>
|
||||
{inUse && <Checkbox checked={true} shape="round" className="absolute top-2 right-2" />}
|
||||
{item.isDefault && <Tag className="absolute top-2 left-2" variant="dark" size="small">Default</Tag>}
|
||||
{item.isDefault && (
|
||||
<Tag className="absolute top-2 left-2" variant="dark" size="small">
|
||||
Default
|
||||
</Tag>
|
||||
)}
|
||||
{!item.isUnlock && (
|
||||
<Tag className="absolute top-2 right-2" variant="dark" size="small">
|
||||
<i className="iconfont icon-private-border" />
|
||||
|
|
@ -41,15 +66,11 @@ const ChatButtleItem = ({ item, selected, inUse, onClick }: { item: ChatBubbleLi
|
|||
)}
|
||||
</div>
|
||||
{item.unlockType === 'MEMBER' ? (
|
||||
<div
|
||||
className="bg-gradient-to-r from-[#ff9696] via-[#aa90f9] to-[#8df3e2] bg-clip-text text-transparent txt-label-m text-center mt-2"
|
||||
>
|
||||
<div className="txt-label-m mt-2 bg-gradient-to-r from-[#ff9696] via-[#aa90f9] to-[#8df3e2] bg-clip-text text-center text-transparent">
|
||||
{item.name}
|
||||
</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>
|
||||
)
|
||||
|
|
@ -57,31 +78,32 @@ const ChatButtleItem = ({ item, selected, inUse, onClick }: { item: ChatBubbleLi
|
|||
|
||||
const ChatButtleDrawer = () => {
|
||||
const [selectedCode, setSelectedCode] = useState<string | undefined>()
|
||||
const [drawerState, setDrawerState] = useAtom(isChatButtleDrawerOpenAtom);
|
||||
const open = drawerState.open;
|
||||
const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open));
|
||||
const { aiId, aiInfo } = useChatConfig();
|
||||
const { chatBubble } = aiInfo || {};
|
||||
const setCrushLevelDrawerState = useSetAtom(isCrushLevelDrawerOpenAtom);
|
||||
const setIsCrushLevelDrawerOpen = (open: boolean) => setCrushLevelDrawerState(createDrawerOpenState(open));
|
||||
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const [drawerState, setDrawerState] = useAtom(isChatButtleDrawerOpenAtom)
|
||||
const open = drawerState.open
|
||||
const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
|
||||
const { aiId, aiInfo } = useChatConfig()
|
||||
const { chatBubble } = aiInfo || {}
|
||||
const setCrushLevelDrawerState = useSetAtom(isCrushLevelDrawerOpenAtom)
|
||||
const setIsCrushLevelDrawerOpen = (open: boolean) =>
|
||||
setCrushLevelDrawerState(createDrawerOpenState(open))
|
||||
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { data: chatBubbleDictList } = useGetChatBubbleDictList({ aiId })
|
||||
const { mutateAsync: setChatBubble, isPending: isSettingChatBubble } = useSetChatBubble({ aiId })
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatBubbleDictList?.length) return
|
||||
const defaultCode = chatBubble?.code?.toString();
|
||||
const defaultCode = chatBubble?.code?.toString()
|
||||
setSelectedCode(defaultCode || chatBubbleDictList[0]?.code?.toString())
|
||||
}, [chatBubbleDictList, chatBubble])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
queryClient.invalidateQueries({ queryKey: imKeys.imUserInfo(aiId) })
|
||||
const defaultCode = chatBubble?.code?.toString();
|
||||
const defaultCode = chatBubble?.code?.toString()
|
||||
if (defaultCode) {
|
||||
setSelectedCode(defaultCode);
|
||||
setSelectedCode(defaultCode)
|
||||
}
|
||||
}
|
||||
}, [open])
|
||||
|
|
@ -92,11 +114,18 @@ const ChatButtleDrawer = () => {
|
|||
}
|
||||
|
||||
const renderConfirmButton = () => {
|
||||
if (!selectedCode) return;
|
||||
const { isUnlock, unlockType, unlockHeartbeatLevel } = chatBubbleDictList?.find(item => item.code === selectedCode) || {};
|
||||
if (!selectedCode) return
|
||||
const { isUnlock, unlockType, unlockHeartbeatLevel } =
|
||||
chatBubbleDictList?.find((item) => item.code === selectedCode) || {}
|
||||
if (isUnlock || isUnlock === null) {
|
||||
return (
|
||||
<Button size="large" loading={isSettingChatBubble} onClick={() => handleSetChatBubble(selectedCode)}>Confirm</Button>
|
||||
<Button
|
||||
size="large"
|
||||
loading={isSettingChatBubble}
|
||||
onClick={() => handleSetChatBubble(selectedCode)}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -117,18 +146,24 @@ const ChatButtleDrawer = () => {
|
|||
|
||||
if (unlockType === UnlockType.HeartbeatLevel) {
|
||||
return (
|
||||
<Button size="large"
|
||||
onClick={() => setIsCrushLevelDrawerOpen(true)}
|
||||
>
|
||||
<Button size="large" onClick={() => setIsCrushLevelDrawerOpen(true)}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Image src="/icons/like-gradient.svg" alt="vip" className="block" width={24} height={24} />
|
||||
<span className="txt-label-l">{useHeartLevelTextFromLevel(unlockHeartbeatLevel)} Unlock</span>
|
||||
<Image
|
||||
src="/icons/like-gradient.svg"
|
||||
alt="vip"
|
||||
className="block"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span className="txt-label-l">
|
||||
{useHeartLevelTextFromLevel(unlockHeartbeatLevel)} Unlock
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -142,8 +177,7 @@ const ChatButtleDrawer = () => {
|
|||
<InlineDrawerHeader>Chat Buttles</InlineDrawerHeader>
|
||||
<InlineDrawerDescription>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{
|
||||
chatBubbleDictList?.map(item => (
|
||||
{chatBubbleDictList?.map((item) => (
|
||||
<ChatButtleItem
|
||||
key={item.code}
|
||||
item={item}
|
||||
|
|
@ -153,17 +187,18 @@ const ChatButtleDrawer = () => {
|
|||
setSelectedCode(item.code)
|
||||
}}
|
||||
/>
|
||||
))
|
||||
}
|
||||
))}
|
||||
</div>
|
||||
</InlineDrawerDescription>
|
||||
<InlineDrawerFooter>
|
||||
<Button variant="tertiary" size="large" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button variant="tertiary" size="large" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{renderConfirmButton()}
|
||||
</InlineDrawerFooter>
|
||||
</InlineDrawerContent>
|
||||
</InlineDrawer>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatButtleDrawer;
|
||||
export default ChatButtleDrawer
|
||||
|
|
|
|||
|
|
@ -1,24 +1,28 @@
|
|||
"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";
|
||||
|
||||
'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'
|
||||
|
||||
const ChatModelDrawer = () => {
|
||||
const [drawerState, setDrawerState] = useAtom(isChatModelDrawerOpenAtom);
|
||||
const open = drawerState.open;
|
||||
const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open));
|
||||
const [drawerState, setDrawerState] = useAtom(isChatModelDrawerOpenAtom)
|
||||
const open = drawerState.open
|
||||
const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
|
||||
|
||||
const { data: chatModelDictList } = useGetChatModelDictList();
|
||||
const { data: chatModelDictList } = useGetChatModelDictList()
|
||||
|
||||
console.log('chatModelDictList', chatModelDictList)
|
||||
|
||||
|
|
@ -32,32 +36,40 @@ const ChatModelDrawer = () => {
|
|||
<InlineDrawerContent>
|
||||
<InlineDrawerHeader>Chat Model</InlineDrawerHeader>
|
||||
<InlineDrawerDescription>
|
||||
<div className="p-4 bg-surface-element-normal rounded-lg overflow-hidden">
|
||||
<div className="bg-surface-element-normal overflow-hidden rounded-lg p-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="txt-title-s">Role-Playing Model</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconButton
|
||||
iconfont="icon-question"
|
||||
variant="tertiary"
|
||||
size="mini"
|
||||
/>
|
||||
<IconButton iconfont="icon-question" variant="tertiary" size="mini" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-[300px]">
|
||||
<div className="space-y-2">
|
||||
<p className="break-words">Text Message Price: Refers to the cost of chatting with the character via text messages, including sending text, images, or gifts. Charged per message.</p>
|
||||
<p className="break-words">Voice Message Price: Refers to the cost of sending a voice message to the character or playing the character’s 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>
|
||||
<p className="break-words">
|
||||
Text Message Price: Refers to the cost of chatting with the character via
|
||||
text messages, including sending text, images, or gifts. Charged per
|
||||
message.
|
||||
</p>
|
||||
<p className="break-words">
|
||||
Voice Message Price: Refers to the cost of sending a voice message to the
|
||||
character or playing the character’s 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>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Checkbox checked={true} shape="round" />
|
||||
</div>
|
||||
<div className="txt-body-m text-txt-secondary-normal mt-1">Role-play a conversation with AI</div>
|
||||
<div className="txt-body-m text-txt-secondary-normal mt-1">
|
||||
Role-play a conversation with AI
|
||||
</div>
|
||||
|
||||
<div className="mt-3 bg-surface-district-normal rounded-sm p-3">
|
||||
<div className="bg-surface-district-normal mt-3 rounded-sm p-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
||||
<span className="txt-label-m text-txt-primary-normal">
|
||||
|
|
@ -65,8 +77,8 @@ const ChatModelDrawer = () => {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-1 mt-3">
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<div className="mt-3 flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
||||
<span className="txt-label-m text-txt-primary-normal">
|
||||
{ChatPriceTypeMap[ChatPriceType.VOICE]}/Send or play voice
|
||||
|
|
@ -74,8 +86,8 @@ const ChatModelDrawer = () => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-1 mt-3">
|
||||
<div className="flex items-center gap-1 flex-1 min-w-0">
|
||||
<div className="mt-3 flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1">
|
||||
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
||||
<span className="txt-label-m text-txt-primary-normal">
|
||||
{ChatPriceTypeMap[ChatPriceType.VOICE_CALL]}/min Voice call
|
||||
|
|
@ -87,12 +99,16 @@ const ChatModelDrawer = () => {
|
|||
<div className="txt-body-m text-txt-secondary-normal mt-6">More models coming soon</div>
|
||||
</InlineDrawerDescription>
|
||||
<InlineDrawerFooter>
|
||||
<Button variant="tertiary" size="large" onClick={() => setOpen(false)}>Cancel</Button>
|
||||
<Button size="large" onClick={() => setOpen(false)}>Save</Button>
|
||||
<Button variant="tertiary" size="large" onClick={() => setOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button size="large" onClick={() => setOpen(false)}>
|
||||
Save
|
||||
</Button>
|
||||
</InlineDrawerFooter>
|
||||
</InlineDrawerContent>
|
||||
</InlineDrawer>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatModelDrawer;
|
||||
export default ChatModelDrawer
|
||||
|
|
|
|||
|
|
@ -1,27 +1,38 @@
|
|||
"use client"
|
||||
'use client'
|
||||
|
||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { IconButton } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import DeleteMessageDialog from "./DeleteMessageDialog";
|
||||
import { useState } from "react";
|
||||
import useShare from "@/hooks/useShare";
|
||||
import { useChatConfig } from "../../../context/chatConfig";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { IconButton } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import DeleteMessageDialog from './DeleteMessageDialog'
|
||||
import { useState } from 'react'
|
||||
import useShare from '@/hooks/useShare'
|
||||
import { useChatConfig } from '../../../context/chatConfig'
|
||||
|
||||
const ChatProfileAction = () => {
|
||||
const [deleteMessageDialogOpen, setDeleteMessageDialogOpen] = useState(false);
|
||||
const { shareFacebook, shareTwitter } = useShare();
|
||||
const { aiId } = useChatConfig();
|
||||
const [deleteMessageDialogOpen, setDeleteMessageDialogOpen] = useState(false)
|
||||
const { shareFacebook, shareTwitter } = useShare()
|
||||
const { aiId } = useChatConfig()
|
||||
|
||||
const handleDeleteMessage = () => {
|
||||
setDeleteMessageDialogOpen(true);
|
||||
setDeleteMessageDialogOpen(true)
|
||||
}
|
||||
|
||||
const handleShareFacebook = () => {
|
||||
shareFacebook({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}` });
|
||||
shareFacebook({
|
||||
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
|
||||
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}`,
|
||||
})
|
||||
}
|
||||
const handleShareTwitter = () => {
|
||||
shareTwitter({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}` });
|
||||
shareTwitter({
|
||||
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
|
||||
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -39,7 +50,7 @@ const ChatProfileAction = () => {
|
|||
<i className="iconfont icon-social-twitter text-txt-primary-normal !text-[16px]" />
|
||||
<span>Share to X</span>
|
||||
</DropdownMenuItem>
|
||||
<div className="px-2 my-3">
|
||||
<div className="my-3 px-2">
|
||||
<Separator className="bg-outline-normal" />
|
||||
</div>
|
||||
<DropdownMenuItem onClick={handleDeleteMessage}>
|
||||
|
|
@ -49,9 +60,12 @@ const ChatProfileAction = () => {
|
|||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<DeleteMessageDialog open={deleteMessageDialogOpen} onOpenChange={setDeleteMessageDialogOpen} />
|
||||
<DeleteMessageDialog
|
||||
open={deleteMessageDialogOpen}
|
||||
onOpenChange={setDeleteMessageDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatProfileAction;
|
||||
export default ChatProfileAction
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
"use client"
|
||||
import ChatProfileShareIcon from "./ChatProfileShareIcon";
|
||||
import ChatProfileLikeIcon from "./ChatProfileLikeIcon";
|
||||
'use client'
|
||||
import ChatProfileShareIcon from './ChatProfileShareIcon'
|
||||
import ChatProfileLikeIcon from './ChatProfileLikeIcon'
|
||||
|
||||
const ChatProfileLikeAction = () => {
|
||||
return (
|
||||
<div className="flex justify-center items-center gap-4">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<ChatProfileLikeIcon />
|
||||
<ChatProfileShareIcon />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatProfileLikeAction;
|
||||
export default ChatProfileLikeAction
|
||||
|
|
|
|||
|
|
@ -1,50 +1,50 @@
|
|||
"use client"
|
||||
import { IconButton } from "@/components/ui/button";
|
||||
import { useGetAIUserBaseInfo } from "@/hooks/aiUser";
|
||||
import { useDoAiUserLiked } from "@/hooks/useCommon";
|
||||
import { aiUserKeys, imKeys } from "@/lib/query-keys";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useChatConfig } from "../../../context/chatConfig";
|
||||
'use client'
|
||||
import { IconButton } from '@/components/ui/button'
|
||||
import { useGetAIUserBaseInfo } from '@/hooks/aiUser'
|
||||
import { useDoAiUserLiked } from '@/hooks/useCommon'
|
||||
import { aiUserKeys, imKeys } from '@/lib/query-keys'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { useParams } from 'next/navigation'
|
||||
import { useChatConfig } from '../../../context/chatConfig'
|
||||
|
||||
const ChatProfileLikeIcon = () => {
|
||||
const { aiId, aiInfo } = useChatConfig();
|
||||
const { aiId, aiInfo } = useChatConfig()
|
||||
|
||||
const { mutateAsync: doAiUserLiked } = useDoAiUserLiked();
|
||||
const { liked } = aiInfo || {};
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: doAiUserLiked } = useDoAiUserLiked()
|
||||
const { liked } = aiInfo || {}
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
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) => {
|
||||
return {
|
||||
...oldData,
|
||||
liked: !liked,
|
||||
}
|
||||
});
|
||||
})
|
||||
queryClient.setQueryData(imKeys.imUserInfo(Number(aiId)), (oldData: any) => {
|
||||
return {
|
||||
...oldData,
|
||||
liked: !liked,
|
||||
}
|
||||
});
|
||||
})
|
||||
queryClient.setQueryData(aiUserKeys.stat({ aiId: Number(aiId) }), (oldData: any) => {
|
||||
return {
|
||||
...oldData,
|
||||
likedNum: !liked ? oldData.likedNum + 1 : oldData.likedNum - 1,
|
||||
}
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<IconButton
|
||||
variant="tertiary"
|
||||
size="large"
|
||||
onClick={handleLike}
|
||||
>
|
||||
{!liked ? <i className="iconfont icon-Like" /> : <i className="iconfont icon-Like-fill text-important-normal" />}
|
||||
<IconButton variant="tertiary" size="large" onClick={handleLike}>
|
||||
{!liked ? (
|
||||
<i className="iconfont icon-Like" />
|
||||
) : (
|
||||
<i className="iconfont icon-Like-fill text-important-normal" />
|
||||
)}
|
||||
</IconButton>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatProfileLikeIcon;
|
||||
export default ChatProfileLikeIcon
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
"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";
|
||||
'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'
|
||||
|
||||
const ChatProfilePersona = () => {
|
||||
const { aiId } = useChatConfig();
|
||||
const setDrawerState = useSetAtom(isChatProfileEditDrawerOpenAtom);
|
||||
const setIsChatProfileEditDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open));
|
||||
const { data: chatSettingData } = useGetMyChatSetting({ aiId });
|
||||
const { aiId } = useChatConfig()
|
||||
const setDrawerState = useSetAtom(isChatProfileEditDrawerOpenAtom)
|
||||
const setIsChatProfileEditDrawerOpen = (open: boolean) =>
|
||||
setDrawerState(createDrawerOpenState(open))
|
||||
const { data: chatSettingData } = useGetMyChatSetting({ aiId })
|
||||
|
||||
const { nickname, sex, birthday, whoAmI } = chatSettingData || {};
|
||||
const { nickname, sex, birthday, whoAmI } = chatSettingData || {}
|
||||
|
||||
const genderMap = {
|
||||
[Gender.MALE]: 'Male',
|
||||
|
|
@ -23,32 +23,48 @@ const ChatProfilePersona = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 w-full">
|
||||
<div className="flex justify-between items-center gap-3">
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="txt-title-s">My Chat Persona</div>
|
||||
<div className="txt-label-m text-primary-variant-normal cursor-pointer" onClick={() => setIsChatProfileEditDrawerOpen(true)}>Edit</div>
|
||||
<div
|
||||
className="txt-label-m text-primary-variant-normal cursor-pointer"
|
||||
onClick={() => setIsChatProfileEditDrawerOpen(true)}
|
||||
>
|
||||
Edit
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-base-normal py-1 rounded-m">
|
||||
<div className="py-3 px-4 flex justify-between items-center gap-4">
|
||||
<div className="bg-surface-base-normal rounded-m py-1">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="txt-label-l text-txt-secondary-normal">Nickname</div>
|
||||
<div className="txt-body-l text-txt-primary-normal truncate flex-1 text-right">{nickname}</div>
|
||||
<div className="txt-body-l text-txt-primary-normal flex-1 truncate text-right">
|
||||
{nickname}
|
||||
</div>
|
||||
<div className="py-3 px-4 flex justify-between items-center gap-4">
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="txt-label-l text-txt-secondary-normal">Gender</div>
|
||||
<div className="txt-body-l text-txt-primary-normal">{genderMap[sex as keyof typeof genderMap]}</div>
|
||||
<div className="txt-body-l text-txt-primary-normal">
|
||||
{genderMap[sex as keyof typeof genderMap]}
|
||||
</div>
|
||||
<div className="py-3 px-4 flex justify-between items-center gap-4">
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="txt-label-l text-txt-secondary-normal">Age</div>
|
||||
<div className="txt-body-l text-txt-primary-normal">{getAge(Number(birthday))}</div>
|
||||
</div>
|
||||
<div className="py-3 px-4 flex justify-between items-center gap-4">
|
||||
<div className="flex items-center justify-between gap-4 px-4 py-3">
|
||||
<div className="txt-label-l text-txt-secondary-normal">Who am I</div>
|
||||
<div className={cn("txt-body-l text-txt-primary-normal truncate flex-1 text-right", whoAmI ? 'text-txt-primary-normal' : 'text-txt-secondary-normal')}>{whoAmI || 'Unfilled'}</div>
|
||||
<div
|
||||
className={cn(
|
||||
'txt-body-l text-txt-primary-normal flex-1 truncate text-right',
|
||||
whoAmI ? 'text-txt-primary-normal' : 'text-txt-secondary-normal'
|
||||
)}
|
||||
>
|
||||
{whoAmI || 'Unfilled'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatProfilePersona;
|
||||
export default ChatProfilePersona
|
||||
|
|
|
|||
|
|
@ -1,29 +1,37 @@
|
|||
"use client";
|
||||
'use client'
|
||||
|
||||
import { IconButton } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import useShare from "@/hooks/useShare";
|
||||
import { useParams } from "next/navigation";
|
||||
import { IconButton } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import useShare from '@/hooks/useShare'
|
||||
import { useParams } from 'next/navigation'
|
||||
|
||||
const ChatProfileShareIcon = () => {
|
||||
const { userId } = useParams();
|
||||
const { userId } = useParams()
|
||||
|
||||
const { shareFacebook, shareTwitter } = useShare();
|
||||
const { shareFacebook, shareTwitter } = useShare()
|
||||
|
||||
const handleShareFacebook = () => {
|
||||
shareFacebook({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${userId}` });
|
||||
shareFacebook({
|
||||
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
|
||||
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${userId}`,
|
||||
})
|
||||
}
|
||||
const handleShareTwitter = () => {
|
||||
shareTwitter({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${userId}` });
|
||||
shareTwitter({
|
||||
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
|
||||
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${userId}`,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
variant="tertiary"
|
||||
size="large"
|
||||
>
|
||||
<IconButton variant="tertiary" size="large">
|
||||
<i className="iconfont icon-Share-border" />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
|
|
@ -38,7 +46,7 @@ const ChatProfileShareIcon = () => {
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatProfileShareIcon;
|
||||
export default ChatProfileShareIcon
|
||||
|
|
|
|||
|
|
@ -1,45 +1,54 @@
|
|||
"use client"
|
||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||
import { useNimConversation, useNimMsgContext } from "@/context/NimChat/useNimChat";
|
||||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { selectedConversationIdAtom } from "@/atoms/im";
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { imKeys } from "@/lib/query-keys";
|
||||
import { useChatConfig } from "../../../context/chatConfig";
|
||||
import { useDeleteConversations } from "@/hooks/useIm";
|
||||
'use client'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useNimConversation, useNimMsgContext } from '@/context/NimChat/useNimChat'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { selectedConversationIdAtom } from '@/atoms/im'
|
||||
import { useState } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { imKeys } from '@/lib/query-keys'
|
||||
import { useChatConfig } from '../../../context/chatConfig'
|
||||
import { useDeleteConversations } from '@/hooks/useIm'
|
||||
|
||||
const DeleteMessageDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}) => {
|
||||
const { aiId } = useChatConfig();
|
||||
const { removeConversationById } = useNimConversation();
|
||||
const { clearHistoryMessage } = useNimMsgContext();
|
||||
const selectedConversationId = useAtomValue(selectedConversationIdAtom);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const setSelectedConversationId = useSetAtom(selectedConversationIdAtom);
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutateAsync: deleteConversations } = useDeleteConversations();
|
||||
const { aiId } = useChatConfig()
|
||||
const { removeConversationById } = useNimConversation()
|
||||
const { clearHistoryMessage } = useNimMsgContext()
|
||||
const selectedConversationId = useAtomValue(selectedConversationIdAtom)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const setSelectedConversationId = useSetAtom(selectedConversationIdAtom)
|
||||
const router = useRouter()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { mutateAsync: deleteConversations } = useDeleteConversations()
|
||||
|
||||
const handleDeleteMessage = async () => {
|
||||
if (!selectedConversationId) return;
|
||||
setLoading(true);
|
||||
await removeConversationById(selectedConversationId);
|
||||
await clearHistoryMessage(selectedConversationId);
|
||||
await deleteConversations({ aiIdList: [aiId] });
|
||||
setSelectedConversationId(null);
|
||||
router.push("/chat")
|
||||
if (!selectedConversationId) return
|
||||
setLoading(true)
|
||||
await removeConversationById(selectedConversationId)
|
||||
await clearHistoryMessage(selectedConversationId)
|
||||
await deleteConversations({ aiIdList: [aiId] })
|
||||
setSelectedConversationId(null)
|
||||
router.push('/chat')
|
||||
queryClient.invalidateQueries({ queryKey: imKeys.imUserInfo(aiId) })
|
||||
setLoading(false);
|
||||
onOpenChange(false);
|
||||
setLoading(false)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -48,14 +57,19 @@ const DeleteMessageDialog = ({
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogDescription>Deletion is permanent. Your accumulated Affection points and the character's memories will not be affected. Please confirm deletion.</AlertDialogDescription>
|
||||
<AlertDialogDescription>
|
||||
Deletion is permanent. Your accumulated Affection points and the character's memories will
|
||||
not be affected. Please confirm deletion.
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" disabled={loading} onClick={handleDeleteMessage}>Delete</AlertDialogAction>
|
||||
<AlertDialogAction variant="destructive" disabled={loading} onClick={handleDeleteMessage}>
|
||||
Delete
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteMessageDialog;
|
||||
export default DeleteMessageDialog
|
||||
|
|
|
|||
|
|
@ -1,68 +1,84 @@
|
|||
import { InlineDrawer, InlineDrawerContent } from "../InlineDrawer";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { isChatBackgroundDrawerOpenAtom, isChatProfileDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat";
|
||||
import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Tag } from "@/components/ui/tag";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useEffect, useState } from "react";
|
||||
import ChatProfileAction from "./ChatProfileAction";
|
||||
import { useChatConfig } from "../../../context/chatConfig";
|
||||
import Image from "next/image";
|
||||
import { formatNumberToKMB, getAge } from "@/lib/utils";
|
||||
import { useGetAIUserStat } from "@/hooks/aiUser";
|
||||
import { IconButton } from "@/components/ui/button";
|
||||
import { useGetMyChatSetting, useSetAutoPlayVoice } from "@/hooks/useIm";
|
||||
import ChatProfilePersona from "./ChatProfilePersona";
|
||||
import { isChatButtleDrawerOpenAtom, isChatModelDrawerOpenAtom } from "@/atoms/chat";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { useRedDot, RED_DOT_KEYS } from "@/hooks/useRedDot";
|
||||
import { imKeys } from "@/lib/query-keys";
|
||||
import { AiUserImBaseInfoOutput } from "@/services/im";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import CrushLevelAvatar from "../../CrushLevelAvatar";
|
||||
import ChatProfileLikeAction from "./ChatProfileLikeAction";
|
||||
import { isVipDrawerOpenAtom } from "@/atoms/im";
|
||||
import { useCurrentUser } from "@/hooks/auth";
|
||||
import { VipType } from "@/services/wallet";
|
||||
import Decimal from "decimal.js";
|
||||
import { InlineDrawer, InlineDrawerContent } from '../InlineDrawer'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import {
|
||||
isChatBackgroundDrawerOpenAtom,
|
||||
isChatProfileDrawerOpenAtom,
|
||||
createDrawerOpenState,
|
||||
} from '@/atoms/chat'
|
||||
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'
|
||||
import { Tag } from '@/components/ui/tag'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useEffect, useState } from 'react'
|
||||
import ChatProfileAction from './ChatProfileAction'
|
||||
import { useChatConfig } from '../../../context/chatConfig'
|
||||
import Image from 'next/image'
|
||||
import { formatNumberToKMB, getAge } from '@/lib/utils'
|
||||
import { useGetAIUserStat } from '@/hooks/aiUser'
|
||||
import { IconButton } from '@/components/ui/button'
|
||||
import { useGetMyChatSetting, useSetAutoPlayVoice } from '@/hooks/useIm'
|
||||
import ChatProfilePersona from './ChatProfilePersona'
|
||||
import { isChatButtleDrawerOpenAtom, isChatModelDrawerOpenAtom } from '@/atoms/chat'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { useRedDot, RED_DOT_KEYS } from '@/hooks/useRedDot'
|
||||
import { imKeys } from '@/lib/query-keys'
|
||||
import { AiUserImBaseInfoOutput } from '@/services/im'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import CrushLevelAvatar from '../../CrushLevelAvatar'
|
||||
import ChatProfileLikeAction from './ChatProfileLikeAction'
|
||||
import { isVipDrawerOpenAtom } from '@/atoms/im'
|
||||
import { useCurrentUser } from '@/hooks/auth'
|
||||
import { VipType } from '@/services/wallet'
|
||||
import Decimal from 'decimal.js'
|
||||
|
||||
const genderMap = {
|
||||
0: '/icons/male.svg',
|
||||
1: '/icons/female.svg',
|
||||
2: '/icons/gender-neutral.svg'
|
||||
2: '/icons/gender-neutral.svg',
|
||||
}
|
||||
|
||||
const ChatProfileDrawer = () => {
|
||||
const [drawerState, setDrawerState] = useAtom(isChatProfileDrawerOpenAtom);
|
||||
const isChatProfileDrawerOpen = drawerState.open;
|
||||
const setIsChatProfileDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open));
|
||||
const [drawerState, setDrawerState] = useAtom(isChatProfileDrawerOpenAtom)
|
||||
const isChatProfileDrawerOpen = drawerState.open
|
||||
const setIsChatProfileDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
|
||||
|
||||
const setModelDrawerState = useSetAtom(isChatModelDrawerOpenAtom);
|
||||
const setIsChatModelDrawerOpen = (open: boolean) => setModelDrawerState(createDrawerOpenState(open));
|
||||
const setModelDrawerState = useSetAtom(isChatModelDrawerOpenAtom)
|
||||
const setIsChatModelDrawerOpen = (open: boolean) =>
|
||||
setModelDrawerState(createDrawerOpenState(open))
|
||||
|
||||
const setButtleDrawerState = useSetAtom(isChatButtleDrawerOpenAtom);
|
||||
const setIsChatButtleDrawerOpen = (open: boolean) => setButtleDrawerState(createDrawerOpenState(open));
|
||||
const setButtleDrawerState = useSetAtom(isChatButtleDrawerOpenAtom)
|
||||
const setIsChatButtleDrawerOpen = (open: boolean) =>
|
||||
setButtleDrawerState(createDrawerOpenState(open))
|
||||
|
||||
const setBackgroundDrawerState = useSetAtom(isChatBackgroundDrawerOpenAtom);
|
||||
const setIsChatBackgroundDrawerOpen = (open: boolean) => setBackgroundDrawerState(createDrawerOpenState(open));
|
||||
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom);
|
||||
const setBackgroundDrawerState = useSetAtom(isChatBackgroundDrawerOpenAtom)
|
||||
const setIsChatBackgroundDrawerOpen = (open: boolean) =>
|
||||
setBackgroundDrawerState(createDrawerOpenState(open))
|
||||
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom)
|
||||
|
||||
const { aiId, aiInfo } = useChatConfig();
|
||||
const { data: user } = useCurrentUser() || {};
|
||||
const { isMember } = user || {};
|
||||
const { aiId, aiInfo } = useChatConfig()
|
||||
const { data: user } = useCurrentUser() || {}
|
||||
const { isMember } = user || {}
|
||||
|
||||
const isOwner = user?.userId === aiInfo?.userId;
|
||||
const isOwner = user?.userId === aiInfo?.userId
|
||||
|
||||
// 使用红点管理hooks
|
||||
const { hasRedDot, markAsViewed } = useRedDot();
|
||||
const { hasRedDot, markAsViewed } = useRedDot()
|
||||
|
||||
const { data: statData } = useGetAIUserStat({ aiId });
|
||||
const { sex, birthday, characterName, tagName, chatBubble, isDefaultBackground, isAutoPlayVoice, aiUserHeartbeatRelation } = aiInfo || {};
|
||||
const { likedNum, chatNum, conversationNum, coinNum } = statData || {};
|
||||
const { heartbeatLevel } = aiUserHeartbeatRelation || {};
|
||||
const { data: statData } = useGetAIUserStat({ aiId })
|
||||
const {
|
||||
sex,
|
||||
birthday,
|
||||
characterName,
|
||||
tagName,
|
||||
chatBubble,
|
||||
isDefaultBackground,
|
||||
isAutoPlayVoice,
|
||||
aiUserHeartbeatRelation,
|
||||
} = aiInfo || {}
|
||||
const { likedNum, chatNum, conversationNum, coinNum } = statData || {}
|
||||
const { heartbeatLevel } = aiUserHeartbeatRelation || {}
|
||||
|
||||
const { mutate: setAutoPlayVoice } = useSetAutoPlayVoice();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate: setAutoPlayVoice } = useSetAutoPlayVoice()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
useEffect(() => {
|
||||
if (isChatProfileDrawerOpen) {
|
||||
|
|
@ -87,11 +103,11 @@ const ChatProfileDrawer = () => {
|
|||
label: 'Crush Coin',
|
||||
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 = () => {
|
||||
setIsChatProfileDrawerOpen(false);
|
||||
};
|
||||
setIsChatProfileDrawerOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<InlineDrawer
|
||||
|
|
@ -102,21 +118,40 @@ const ChatProfileDrawer = () => {
|
|||
>
|
||||
<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="relative w-full h-full">
|
||||
<div className="relative h-full w-full">
|
||||
{/* Header with back and more buttons */}
|
||||
<ChatProfileAction />
|
||||
<IconButton iconfont="icon-arrow-right" variant="ghost" size="small" className="absolute top-6 left-6" onClick={handleClose} />
|
||||
<IconButton
|
||||
iconfont="icon-arrow-right"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
className="absolute top-6 left-6"
|
||||
onClick={handleClose}
|
||||
/>
|
||||
|
||||
<div className="w-full h-full overflow-y-auto" >
|
||||
|
||||
<div className="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" }}>
|
||||
<div className="h-full w-full overflow-y-auto">
|
||||
<div
|
||||
className="flex flex-col items-center justify-start gap-6 px-6 pt-12 pb-10"
|
||||
style={{
|
||||
background: heartbeatLevel
|
||||
? 'linear-gradient(0deg, rgba(194, 65, 230, 0) 0%, #F264A4 100%)'
|
||||
: undefined,
|
||||
backgroundSize: '100% 228px',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<CrushLevelAvatar showText />
|
||||
|
||||
{/* Name and Tags */}
|
||||
<div className="flex flex-col gap-4 items-start justify-start w-full">
|
||||
<div className="flex flex-wrap gap-2 items-start justify-center w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-4">
|
||||
<div className="flex w-full flex-wrap items-start justify-center gap-2">
|
||||
<Tag>
|
||||
<Image src={genderMap[sex as keyof typeof genderMap]} alt="Gender" width={16} height={16} />
|
||||
<Image
|
||||
src={genderMap[sex as keyof typeof genderMap]}
|
||||
alt="Gender"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
<div>{getAge(Number(birthday))}</div>
|
||||
</Tag>
|
||||
<Tag>{characterName}</Tag>
|
||||
|
|
@ -127,50 +162,45 @@ const ChatProfileDrawer = () => {
|
|||
<ChatProfileLikeAction />
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="flex flex-row items-start justify-start px-1 py-0 w-full">
|
||||
{
|
||||
statList.map((item, index) => (
|
||||
<div key={index} className="flex-1 flex flex-col gap-1 items-center justify-start px-1 py-0">
|
||||
<div className="flex w-full flex-row items-start justify-start px-1 py-0">
|
||||
{statList.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
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>
|
||||
))
|
||||
}
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ChatProfilePersona />
|
||||
|
||||
{/* Chat Setting */}
|
||||
<div className="flex flex-col gap-3 items-start justify-start w-full">
|
||||
<div className="text-left w-full txt-title-s">
|
||||
Chat Setting
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 items-start justify-start w-full">
|
||||
<div className="flex w-full flex-col items-start justify-start gap-3">
|
||||
<div className="txt-title-s w-full text-left">Chat Setting</div>
|
||||
<div className="flex w-full flex-col items-start justify-start gap-4">
|
||||
<div
|
||||
className="bg-surface-base-normal rounded-m flex justify-between items-center px-4 py-3 w-full cursor-pointer"
|
||||
className="bg-surface-base-normal rounded-m flex w-full cursor-pointer items-center justify-between px-4 py-3"
|
||||
onClick={() => {
|
||||
setIsChatModelDrawerOpen(true)
|
||||
}}
|
||||
>
|
||||
<div className="txt-label-l flex-shrink-0">Chat Model</div>
|
||||
<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 text-right">Role-Playing</div>
|
||||
<IconButton
|
||||
iconfont="icon-arrow-right-border"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 cursor-pointer items-center justify-end gap-2">
|
||||
<div className="txt-body-l text-txt-primary-normal text-right">
|
||||
Role-Playing
|
||||
</div>
|
||||
<IconButton iconfont="icon-arrow-right-border" size="small" variant="ghost" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-surface-base-normal rounded-m py-1 w-full">
|
||||
<div className="bg-surface-base-normal rounded-m w-full py-1">
|
||||
<div
|
||||
className="flex justify-between items-center px-4 py-3 w-full cursor-pointer"
|
||||
className="flex w-full cursor-pointer items-center justify-between px-4 py-3"
|
||||
onClick={() => {
|
||||
markAsViewed(RED_DOT_KEYS.CHAT_BUBBLE);
|
||||
setIsChatButtleDrawerOpen(true);
|
||||
markAsViewed(RED_DOT_KEYS.CHAT_BUBBLE)
|
||||
setIsChatButtleDrawerOpen(true)
|
||||
}}
|
||||
>
|
||||
<div className="txt-label-l flex-1">
|
||||
|
|
@ -179,9 +209,7 @@ const ChatProfileDrawer = () => {
|
|||
{hasRedDot(RED_DOT_KEYS.CHAT_BUBBLE) && <Badge variant="dot" />}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-end gap-2 cursor-pointer flex-1 min-w-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 cursor-pointer items-center justify-end gap-2">
|
||||
{/* <div className="txt-body-l text-txt-primary-normal truncate">{chatBubble?.name || 'Default'}</div> */}
|
||||
<IconButton
|
||||
iconfont="icon-arrow-right-border"
|
||||
|
|
@ -192,10 +220,10 @@ const ChatProfileDrawer = () => {
|
|||
</div>
|
||||
|
||||
<div
|
||||
className="flex justify-between items-center px-4 py-3 w-full cursor-pointer"
|
||||
className="flex w-full cursor-pointer items-center justify-between px-4 py-3"
|
||||
onClick={() => {
|
||||
markAsViewed(RED_DOT_KEYS.CHAT_BACKGROUND);
|
||||
setIsChatBackgroundDrawerOpen(true);
|
||||
markAsViewed(RED_DOT_KEYS.CHAT_BACKGROUND)
|
||||
setIsChatBackgroundDrawerOpen(true)
|
||||
}}
|
||||
>
|
||||
<div className="txt-label-l flex-1">
|
||||
|
|
@ -204,9 +232,7 @@ const ChatProfileDrawer = () => {
|
|||
{hasRedDot(RED_DOT_KEYS.CHAT_BACKGROUND) && <Badge variant="dot" />}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex items-center justify-end gap-2 cursor-pointer flex-1 min-w-0"
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 cursor-pointer items-center justify-end gap-2">
|
||||
{/* <div className="txt-body-l text-txt-primary-normal truncate">{isDefaultBackground ? 'Default' : ''}</div> */}
|
||||
<IconButton
|
||||
iconfont="icon-arrow-right-border"
|
||||
|
|
@ -218,28 +244,28 @@ const ChatProfileDrawer = () => {
|
|||
</div>
|
||||
|
||||
<div
|
||||
className="bg-surface-base-normal rounded-m flex justify-between items-center px-4 py-3 w-full cursor-pointer"
|
||||
className="bg-surface-base-normal rounded-m flex w-full cursor-pointer items-center justify-between px-4 py-3"
|
||||
onClick={() => {
|
||||
const checked = !isAutoPlayVoice;
|
||||
const checked = !isAutoPlayVoice
|
||||
if (!isMember) {
|
||||
setIsVipDrawerOpen({ open: true, vipType: VipType.AUTO_PLAY_VOICE })
|
||||
return;
|
||||
return
|
||||
}
|
||||
queryClient.setQueryData(imKeys.imUserInfo(aiId), (oldData: AiUserImBaseInfoOutput) => {
|
||||
queryClient.setQueryData(
|
||||
imKeys.imUserInfo(aiId),
|
||||
(oldData: AiUserImBaseInfoOutput) => {
|
||||
return {
|
||||
...oldData,
|
||||
isAutoPlayVoice: checked,
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
setAutoPlayVoice({ aiId, isAutoPlayVoice: checked })
|
||||
}}
|
||||
>
|
||||
<div className="txt-label-l flex-1">Auto play voice</div>
|
||||
<div className="h-8 flex items-center">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={!!isAutoPlayVoice}
|
||||
/>
|
||||
<div className="flex h-8 items-center">
|
||||
<Switch size="sm" checked={!!isAutoPlayVoice} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -249,7 +275,7 @@ const ChatProfileDrawer = () => {
|
|||
</div>
|
||||
</InlineDrawerContent>
|
||||
</InlineDrawer>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatProfileDrawer;
|
||||
export default ChatProfileDrawer
|
||||
|
|
|
|||
|
|
@ -1,22 +1,41 @@
|
|||
import { useEffect, useState, useCallback } from "react";
|
||||
import { InlineDrawer, InlineDrawerContent, InlineDrawerDescription, InlineDrawerFooter, InlineDrawerHeader } from "./InlineDrawer";
|
||||
import { z } from "zod";
|
||||
import dayjs from "dayjs";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
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 { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
InlineDrawer,
|
||||
InlineDrawerContent,
|
||||
InlineDrawerDescription,
|
||||
InlineDrawerFooter,
|
||||
InlineDrawerHeader,
|
||||
} from './InlineDrawer'
|
||||
import { z } from 'zod'
|
||||
import dayjs from 'dayjs'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
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 {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
|
|
@ -25,93 +44,110 @@ import {
|
|||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { useCheckNickname } from "@/hooks/auth";
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useCheckNickname } from '@/hooks/auth'
|
||||
|
||||
const currentYear = dayjs().year()
|
||||
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 characterFormSchema = z.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"),
|
||||
sex: z.enum(Gender, { message: "Please select gender" }),
|
||||
year: z.string().min(1, "Please select year"),
|
||||
month: z.string().min(1, "Please select month"),
|
||||
day: z.string().min(1, "Please select day"),
|
||||
const characterFormSchema = z
|
||||
.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'),
|
||||
sex: z.enum(Gender, { message: 'Please select gender' }),
|
||||
year: z.string().min(1, 'Please select year'),
|
||||
month: z.string().min(1, 'Please select month'),
|
||||
day: z.string().min(1, 'Please select day'),
|
||||
profile: z.string().trim().optional(),
|
||||
}).refine((data) => {
|
||||
})
|
||||
.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"]
|
||||
}).refine((data) => {
|
||||
},
|
||||
{
|
||||
message: 'Character age must be at least 18 years old',
|
||||
path: ['year'],
|
||||
}
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.profile) {
|
||||
if (data.profile.trim().length > 300) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
return data.profile.trim().length >= 10;
|
||||
return data.profile.trim().length >= 10
|
||||
}
|
||||
return true;
|
||||
}, {
|
||||
message: "At least 10 characters",
|
||||
path: ["profile"]
|
||||
})
|
||||
return true
|
||||
},
|
||||
{
|
||||
message: 'At least 10 characters',
|
||||
path: ['profile'],
|
||||
}
|
||||
)
|
||||
|
||||
const ChatProfileEditDrawer = () => {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
||||
const { aiId } = useChatConfig();
|
||||
const [drawerState, setDrawerState] = useAtom(isChatProfileEditDrawerOpenAtom);
|
||||
const open = drawerState.open;
|
||||
const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open));
|
||||
const { data: chatSettingData } = useGetMyChatSetting({ aiId });
|
||||
const { mutateAsync: setMyChatSetting } = useSetMyChatSetting({ aiId });
|
||||
const [showConfirmDialog, setShowConfirmDialog] = useState(false)
|
||||
const { aiId } = useChatConfig()
|
||||
const [drawerState, setDrawerState] = useAtom(isChatProfileEditDrawerOpenAtom)
|
||||
const open = drawerState.open
|
||||
const setOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
|
||||
const { data: chatSettingData } = useGetMyChatSetting({ 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>>({
|
||||
resolver: zodResolver(characterFormSchema),
|
||||
defaultValues: {
|
||||
nickname: chatSettingData?.nickname || "",
|
||||
nickname: chatSettingData?.nickname || '',
|
||||
sex: chatSettingData?.sex || undefined,
|
||||
year: birthday?.year().toString() || undefined,
|
||||
month: birthday?.month() !== undefined ? (birthday.month() + 1).toString().padStart(2, '0') : undefined,
|
||||
month:
|
||||
birthday?.month() !== undefined
|
||||
? (birthday.month() + 1).toString().padStart(2, '0')
|
||||
: undefined,
|
||||
day: birthday?.date().toString().padStart(2, '0') || undefined,
|
||||
profile: chatSettingData?.whoAmI || "",
|
||||
profile: chatSettingData?.whoAmI || '',
|
||||
},
|
||||
})
|
||||
|
||||
const { mutateAsync: checkNickname } = useCheckNickname({
|
||||
onError: (error) => {
|
||||
form.setError("nickname", {
|
||||
form.setError('nickname', {
|
||||
message: error.errorMsg,
|
||||
})
|
||||
}
|
||||
});
|
||||
},
|
||||
})
|
||||
|
||||
// 处理关闭抽屉的逻辑
|
||||
const handleCloseDrawer = useCallback(() => {
|
||||
if (form.formState.isDirty) {
|
||||
setShowConfirmDialog(true);
|
||||
setShowConfirmDialog(true)
|
||||
} else {
|
||||
setOpen(false);
|
||||
setOpen(false)
|
||||
}
|
||||
}, [form.formState.isDirty, setOpen]);
|
||||
}, [form.formState.isDirty, setOpen])
|
||||
|
||||
// 确认放弃修改
|
||||
const handleConfirmDiscard = useCallback(() => {
|
||||
form.reset();
|
||||
setShowConfirmDialog(false);
|
||||
setOpen(false);
|
||||
}, [form, setOpen]);
|
||||
form.reset()
|
||||
setShowConfirmDialog(false)
|
||||
setOpen(false)
|
||||
}, [form, setOpen])
|
||||
|
||||
// 取消放弃修改
|
||||
const handleCancelDiscard = useCallback(() => {
|
||||
setShowConfirmDialog(false);
|
||||
}, []);
|
||||
setShowConfirmDialog(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (chatSettingData) {
|
||||
|
|
@ -119,7 +155,10 @@ const ChatProfileEditDrawer = () => {
|
|||
nickname: chatSettingData.nickname,
|
||||
sex: chatSettingData.sex,
|
||||
year: birthday?.year().toString(),
|
||||
month: birthday?.month() !== undefined ? (birthday.month() + 1).toString().padStart(2, '0') : undefined,
|
||||
month:
|
||||
birthday?.month() !== undefined
|
||||
? (birthday.month() + 1).toString().padStart(2, '0')
|
||||
: undefined,
|
||||
day: birthday?.date().toString().padStart(2, '0'),
|
||||
profile: chatSettingData.whoAmI || '',
|
||||
})
|
||||
|
|
@ -130,23 +169,23 @@ const ChatProfileEditDrawer = () => {
|
|||
useEffect(() => {
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && open) {
|
||||
handleCloseDrawer();
|
||||
handleCloseDrawer()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open, handleCloseDrawer]);
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
}
|
||||
}, [open, handleCloseDrawer])
|
||||
|
||||
async function onSubmit(data: z.infer<typeof characterFormSchema>) {
|
||||
if (!form.formState.isDirty) {
|
||||
setOpen(false)
|
||||
return;
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
|
|
@ -154,10 +193,10 @@ const ChatProfileEditDrawer = () => {
|
|||
nickname: data.nickname.trim(),
|
||||
})
|
||||
if (isExist) {
|
||||
form.setError("nickname", {
|
||||
message: "This nickname is already taken",
|
||||
form.setError('nickname', {
|
||||
message: 'This nickname is already taken',
|
||||
})
|
||||
return;
|
||||
return
|
||||
}
|
||||
await setMyChatSetting({
|
||||
aiId,
|
||||
|
|
@ -173,8 +212,8 @@ const ChatProfileEditDrawer = () => {
|
|||
}
|
||||
}
|
||||
|
||||
const selectedYear = form.watch("year")
|
||||
const selectedMonth = form.watch("month")
|
||||
const selectedYear = form.watch('year')
|
||||
const selectedMonth = form.watch('month')
|
||||
const days = selectedYear && selectedMonth ? getDaysInMonth(selectedYear, selectedMonth) : []
|
||||
|
||||
const genderTexts = [
|
||||
|
|
@ -192,8 +231,8 @@ const ChatProfileEditDrawer = () => {
|
|||
},
|
||||
]
|
||||
|
||||
const gender = form.watch("sex");
|
||||
const genderText = genderTexts.find(text => text.value === gender)?.label;
|
||||
const gender = form.watch('sex')
|
||||
const genderText = genderTexts.find((text) => text.value === gender)?.label
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -202,9 +241,9 @@ const ChatProfileEditDrawer = () => {
|
|||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
handleCloseDrawer();
|
||||
handleCloseDrawer()
|
||||
} else {
|
||||
setOpen(isOpen);
|
||||
setOpen(isOpen)
|
||||
}
|
||||
}}
|
||||
timestamp={drawerState.timestamp}
|
||||
|
|
@ -236,7 +275,7 @@ const ChatProfileEditDrawer = () => {
|
|||
<div className="txt-label-m text-txt-secondary-normal">Gender</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="bg-surface-element-normal h-12 rounded-m flex items-center px-4 py-3 txt-body-l text-txt-secondary-disabled">
|
||||
<div className="bg-surface-element-normal rounded-m txt-body-l text-txt-secondary-disabled flex h-12 items-center px-4 py-3">
|
||||
{genderText}
|
||||
</div>
|
||||
<div className="txt-body-s text-txt-secondary-disabled mt-1">
|
||||
|
|
@ -261,7 +300,7 @@ const ChatProfileEditDrawer = () => {
|
|||
)}
|
||||
/> */}
|
||||
<div>
|
||||
<Label className="block txt-label-m mb-3">Birthday</Label>
|
||||
<Label className="txt-label-m mb-3 block">Birthday</Label>
|
||||
<div className="flex gap-2">
|
||||
<FormField
|
||||
control={form.control}
|
||||
|
|
@ -274,8 +313,10 @@ const ChatProfileEditDrawer = () => {
|
|||
<SelectValue placeholder="Year" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{years.map(year => (
|
||||
<SelectItem key={year} value={year}>{year}</SelectItem>
|
||||
{years.map((year) => (
|
||||
<SelectItem key={year} value={year}>
|
||||
{year}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
|
@ -294,7 +335,11 @@ const ChatProfileEditDrawer = () => {
|
|||
<SelectValue placeholder="Month" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{months.map((m, index) => <SelectItem key={m} value={m}>{monthTexts[index]}</SelectItem>)}
|
||||
{months.map((m, index) => (
|
||||
<SelectItem key={m} value={m}>
|
||||
{monthTexts[index]}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
|
@ -312,7 +357,11 @@ const ChatProfileEditDrawer = () => {
|
|||
<SelectValue placeholder="Day" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{days.map(d => <SelectItem key={d} value={d}>{d}</SelectItem>)}
|
||||
{days.map((d) => (
|
||||
<SelectItem key={d} value={d}>
|
||||
{d}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
|
@ -321,7 +370,9 @@ const ChatProfileEditDrawer = () => {
|
|||
/>
|
||||
</div>
|
||||
<FormMessage>
|
||||
{form.formState.errors.year?.message || form.formState.errors.month?.message || form.formState.errors.day?.message}
|
||||
{form.formState.errors.year?.message ||
|
||||
form.formState.errors.month?.message ||
|
||||
form.formState.errors.day?.message}
|
||||
</FormMessage>
|
||||
</div>
|
||||
|
||||
|
|
@ -330,7 +381,10 @@ const ChatProfileEditDrawer = () => {
|
|||
name="profile"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>My Persona<span className="txt-label-m text-txt-secondary-normal">(Optional)</span></FormLabel>
|
||||
<FormLabel>
|
||||
My Persona
|
||||
<span className="txt-label-m text-txt-secondary-normal">(Optional)</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
{...field}
|
||||
|
|
@ -347,8 +401,17 @@ const ChatProfileEditDrawer = () => {
|
|||
</Form>
|
||||
</InlineDrawerDescription>
|
||||
<InlineDrawerFooter>
|
||||
<Button variant="tertiary" size="large" onClick={handleCloseDrawer}>Cancel</Button>
|
||||
<Button type="submit" size="large" onClick={form.handleSubmit(onSubmit)} loading={loading}>Save</Button>
|
||||
<Button variant="tertiary" size="large" onClick={handleCloseDrawer}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
size="large"
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
loading={loading}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</InlineDrawerFooter>
|
||||
</InlineDrawerContent>
|
||||
</InlineDrawer>
|
||||
|
|
@ -359,13 +422,12 @@ const ChatProfileEditDrawer = () => {
|
|||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unsaved Edits</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
The edited content will not be saved after exiting. Please confirm whether to continue exiting?
|
||||
The edited content will not be saved after exiting. Please confirm whether to continue
|
||||
exiting?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel onClick={handleCancelDiscard}>
|
||||
Cancel
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogCancel onClick={handleCancelDiscard}>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction variant="destructive" onClick={handleConfirmDiscard}>
|
||||
Exit
|
||||
</AlertDialogAction>
|
||||
|
|
@ -373,7 +435,7 @@ const ChatProfileEditDrawer = () => {
|
|||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatProfileEditDrawer;
|
||||
export default ChatProfileEditDrawer
|
||||
|
|
|
|||
|
|
@ -1,31 +1,31 @@
|
|||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { useChatConfig } from "../../../context/chatConfig";
|
||||
import { useCurrentUser } from "@/hooks/auth";
|
||||
import Link from "next/link";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { useChatConfig } from '../../../context/chatConfig'
|
||||
import { useCurrentUser } from '@/hooks/auth'
|
||||
import Link from 'next/link'
|
||||
|
||||
const CrushLevelAvatarGroup = () => {
|
||||
const { aiInfo, aiId } = useChatConfig();
|
||||
const { data: currentUser } = useCurrentUser();
|
||||
const { headImage: currentUserHeadImg } = currentUser || {};
|
||||
const { headImg, nickname } = aiInfo || {};
|
||||
const { aiInfo, aiId } = useChatConfig()
|
||||
const { data: currentUser } = useCurrentUser()
|
||||
const { headImage: currentUserHeadImg } = currentUser || {}
|
||||
const { headImg, nickname } = aiInfo || {}
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-[42] h-[124px]">
|
||||
<Link className="w-20 h-20" href={`/@${aiId}`}>
|
||||
<Avatar className="w-20 h-20">
|
||||
<div className="flex h-[124px] items-center justify-between px-[42]">
|
||||
<Link className="h-20 w-20" href={`/@${aiId}`}>
|
||||
<Avatar className="h-20 w-20">
|
||||
<AvatarImage src={headImg} />
|
||||
<AvatarFallback>{nickname?.slice(0, 1)}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
|
||||
<Link className="w-20 h-20" href={`/profile`}>
|
||||
<Avatar className="w-20 h-20">
|
||||
<Link className="h-20 w-20" href={`/profile`}>
|
||||
<Avatar className="h-20 w-20">
|
||||
<AvatarImage src={currentUserHeadImg} />
|
||||
<AvatarFallback>{currentUser?.nickname?.slice(0, 1) || ''}</AvatarFallback>
|
||||
</Avatar>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default CrushLevelAvatarGroup ;
|
||||
export default CrushLevelAvatarGroup
|
||||
|
|
|
|||
|
|
@ -1,35 +1,35 @@
|
|||
import { IconButton } from "@/components/ui/button"
|
||||
import { Tag } from "@/components/ui/tag"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { HeartbeatLevelDictOutput } from "@/services/im"
|
||||
import Image from "next/image"
|
||||
|
||||
const HeartList = ({
|
||||
datas,
|
||||
}: {
|
||||
datas: HeartbeatLevelDictOutput[] | undefined
|
||||
}) => {
|
||||
import { IconButton } from '@/components/ui/button'
|
||||
import { Tag } from '@/components/ui/tag'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { HeartbeatLevelDictOutput } from '@/services/im'
|
||||
import Image from 'next/image'
|
||||
|
||||
const HeartList = ({ datas }: { datas: HeartbeatLevelDictOutput[] | undefined }) => {
|
||||
return (
|
||||
<div className="mt-6 grid grid-cols-2 gap-4">
|
||||
{
|
||||
datas?.map((item) => (
|
||||
{datas?.map((item) => (
|
||||
<div key={item.code}>
|
||||
<div className="rounded-lg overflow-hidden relative aspect-[41/30] bg-surface-element-normal">
|
||||
<Image src={item.imgUrl || ""} alt={item.name || ""} fill className="object-cover" />
|
||||
<div className="absolute left-3 bottom-2 txt-numMonotype-xs text-txt-secondary-normal">
|
||||
<div className="bg-surface-element-normal relative aspect-[41/30] overflow-hidden rounded-lg">
|
||||
<Image src={item.imgUrl || ''} alt={item.name || ''} fill className="object-cover" />
|
||||
<div className="txt-numMonotype-xs text-txt-secondary-normal absolute bottom-2 left-3">
|
||||
{`${item.startVal}℃`}
|
||||
</div>
|
||||
{!item.isUnlock && <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]" />
|
||||
</Tag>}
|
||||
</Tag>
|
||||
)}
|
||||
</div>
|
||||
<div className={cn("txt-label-m text-center mt-2", !item.isUnlock && "text-txt-secondary-normal")}>
|
||||
<div
|
||||
className={cn(
|
||||
'txt-label-m mt-2 text-center',
|
||||
!item.isUnlock && 'text-txt-secondary-normal'
|
||||
)}
|
||||
>
|
||||
{item.name}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,76 +1,96 @@
|
|||
import React from "react";
|
||||
import { InlineDrawer, InlineDrawerContent, InlineDrawerDescription, InlineDrawerHeader } from "../InlineDrawer";
|
||||
import { IconButton, Button } from "@/components/ui/button";
|
||||
import Image from "next/image";
|
||||
import CrushLevelAvatarGroup from "./CrushLevelAvatarGroup";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { isCrushLevelDrawerOpenAtom, isCrushLevelRetrieveDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { useGetHeartbeatLevel, useSetShowRelationship } from "@/hooks/useIm";
|
||||
import { useChatConfig } from "../../../context/chatConfig";
|
||||
import HeartList from "./HeartList";
|
||||
import { useHeartLevelTextFromLevel } from "@/hooks/useHeartLevel";
|
||||
import numeral from "numeral";
|
||||
import { imKeys } from "@/lib/query-keys";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { headerLevelDictMap } from "@/components/features/AIRelationTag";
|
||||
import React from 'react'
|
||||
import {
|
||||
InlineDrawer,
|
||||
InlineDrawerContent,
|
||||
InlineDrawerDescription,
|
||||
InlineDrawerHeader,
|
||||
} from '../InlineDrawer'
|
||||
import { IconButton, Button } from '@/components/ui/button'
|
||||
import Image from 'next/image'
|
||||
import CrushLevelAvatarGroup from './CrushLevelAvatarGroup'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import {
|
||||
isCrushLevelDrawerOpenAtom,
|
||||
isCrushLevelRetrieveDrawerOpenAtom,
|
||||
createDrawerOpenState,
|
||||
} from '@/atoms/chat'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { useGetHeartbeatLevel, useSetShowRelationship } from '@/hooks/useIm'
|
||||
import { useChatConfig } from '../../../context/chatConfig'
|
||||
import HeartList from './HeartList'
|
||||
import { useHeartLevelTextFromLevel } from '@/hooks/useHeartLevel'
|
||||
import numeral from 'numeral'
|
||||
import { imKeys } from '@/lib/query-keys'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { headerLevelDictMap } from '@/components/features/AIRelationTag'
|
||||
|
||||
const CrushLevelDrawer = () => {
|
||||
const [drawerState, setDrawerState] = useAtom(isCrushLevelDrawerOpenAtom);
|
||||
const isCrushLevelDrawerOpen = drawerState.open;
|
||||
const setIsCrushLevelDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open));
|
||||
const setRetrieveDrawerState = useSetAtom(isCrushLevelRetrieveDrawerOpenAtom);
|
||||
const setIsCrushLevelRetrieveDrawerOpen = (open: boolean) => setRetrieveDrawerState(createDrawerOpenState(open));
|
||||
const { aiId, aiInfo } = useChatConfig();
|
||||
const queryClient = useQueryClient();
|
||||
const [drawerState, setDrawerState] = useAtom(isCrushLevelDrawerOpenAtom)
|
||||
const isCrushLevelDrawerOpen = drawerState.open
|
||||
const setIsCrushLevelDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
|
||||
const setRetrieveDrawerState = useSetAtom(isCrushLevelRetrieveDrawerOpenAtom)
|
||||
const setIsCrushLevelRetrieveDrawerOpen = (open: boolean) =>
|
||||
setRetrieveDrawerState(createDrawerOpenState(open))
|
||||
const { aiId, aiInfo } = useChatConfig()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
// 图片加载状态管理
|
||||
const [isBgTopLoaded, setIsBgTopLoaded] = React.useState(false);
|
||||
const [showHeartImage, setShowHeartImage] = React.useState(false);
|
||||
|
||||
const { aiUserHeartbeatRelation } = aiInfo || {};
|
||||
const [isBgTopLoaded, setIsBgTopLoaded] = React.useState(false)
|
||||
const [showHeartImage, setShowHeartImage] = React.useState(false)
|
||||
|
||||
const { data, refetch } = useGetHeartbeatLevel({
|
||||
aiId: Number(aiId),
|
||||
enabled: false // 禁用自动查询,手动控制何时获取数据
|
||||
});
|
||||
const { heartbeatLeveLDictList } = data || {};
|
||||
const { heartbeatLevel, heartbeatVal, heartbeatScore, dayCount, subtractHeartbeatVal, isShow } = aiUserHeartbeatRelation || {};
|
||||
const heartLevelText = useHeartLevelTextFromLevel(heartbeatLevel);
|
||||
enabled: false, // 禁用自动查询,手动控制何时获取数据
|
||||
})
|
||||
const { heartbeatLeveLDictList, aiUserHeartbeatRelation } = data || {}
|
||||
const { heartbeatLevel, heartbeatVal, heartbeatScore, dayCount, subtractHeartbeatVal, isShow } =
|
||||
aiUserHeartbeatRelation || {}
|
||||
const heartLevelText = useHeartLevelTextFromLevel(heartbeatLevel)
|
||||
|
||||
const { mutate: setShowRelationship, isPending: isSetShowRelationshipPending } = useSetShowRelationship({ aiId: Number(aiId) });
|
||||
const { mutate: setShowRelationship, isPending: isSetShowRelationshipPending } =
|
||||
useSetShowRelationship({ aiId: Number(aiId) })
|
||||
|
||||
// 计算心的位置
|
||||
const calculateHeartPosition = () => {
|
||||
const defaultTop = 150;
|
||||
const defaultTop = 150
|
||||
if (!heartbeatVal || !heartbeatLeveLDictList || heartbeatLeveLDictList.length === 0) {
|
||||
return defaultTop; // 默认位置
|
||||
return defaultTop // 默认位置
|
||||
}
|
||||
|
||||
// 获取最低等级的起始值和最高等级的起始值
|
||||
const sortedLevels = [...heartbeatLeveLDictList].sort((a, b) => (a.startVal || 0) - (b.startVal || 0));
|
||||
const minStartVal = sortedLevels[0]?.startVal || 0;
|
||||
const maxStartVal = sortedLevels[sortedLevels.length - 1]?.startVal || 0;
|
||||
const sortedLevels = [...heartbeatLeveLDictList].sort(
|
||||
(a, b) => (a.startVal || 0) - (b.startVal || 0)
|
||||
)
|
||||
const minStartVal = sortedLevels[0]?.startVal || 0
|
||||
const maxStartVal = sortedLevels[sortedLevels.length - 1]?.startVal || 0
|
||||
|
||||
// 如果只有一个等级或者最大最小值相等,使用默认位置
|
||||
if (maxStartVal <= minStartVal) {
|
||||
return defaultTop;
|
||||
return defaultTop
|
||||
}
|
||||
|
||||
// 计算当前心动值在整个等级系统中的总进度(0-1)
|
||||
const totalProgress = Math.max(0, Math.min(1, (heartbeatVal - minStartVal) / (maxStartVal - minStartVal)));
|
||||
const totalProgress = Math.max(
|
||||
0,
|
||||
Math.min(1, (heartbeatVal - minStartVal) / (maxStartVal - minStartVal))
|
||||
)
|
||||
|
||||
// 将总进度映射到位置范围:top 75(空心)到 top 150(全心)
|
||||
const minTop = defaultTop; // 空心位置
|
||||
const maxTop = 75; // 全心位置
|
||||
const calculatedTop = minTop + (totalProgress * (maxTop - minTop));
|
||||
const minTop = defaultTop // 空心位置
|
||||
const maxTop = 75 // 全心位置
|
||||
const calculatedTop = minTop + totalProgress * (maxTop - minTop)
|
||||
|
||||
return calculatedTop;
|
||||
};
|
||||
return calculatedTop
|
||||
}
|
||||
|
||||
const heartTop = calculateHeartPosition();
|
||||
const heartTop = calculateHeartPosition()
|
||||
|
||||
// 根据位置决定心的显示状态
|
||||
const getHeartImageSrc = () => {
|
||||
|
|
@ -79,58 +99,58 @@ const CrushLevelDrawer = () => {
|
|||
|
||||
// 目前只有一个心的图片,可以通过透明度或其他CSS属性来模拟空心/全心效果
|
||||
// 或者后续可以添加不同的图片资源
|
||||
return "/images/crushlevel/heart.png";
|
||||
};
|
||||
return '/images/crushlevel/heart.png'
|
||||
}
|
||||
|
||||
// 当抽屉打开时获取心动等级数据
|
||||
React.useEffect(() => {
|
||||
if (isCrushLevelDrawerOpen) {
|
||||
refetch();
|
||||
refetch()
|
||||
// 重置图片加载状态
|
||||
setIsBgTopLoaded(false);
|
||||
setShowHeartImage(false);
|
||||
setIsBgTopLoaded(false)
|
||||
setShowHeartImage(false)
|
||||
}
|
||||
}, [isCrushLevelDrawerOpen, refetch]);
|
||||
}, [isCrushLevelDrawerOpen, refetch])
|
||||
|
||||
// 处理 bg-top 图片加载完成后的延迟显示逻辑
|
||||
React.useEffect(() => {
|
||||
if (isBgTopLoaded) {
|
||||
// bg-top 加载完成后延迟 300ms 显示心形图片
|
||||
const timer = setTimeout(() => {
|
||||
setShowHeartImage(true);
|
||||
}, 300);
|
||||
setShowHeartImage(true)
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isBgTopLoaded]);
|
||||
}, [isBgTopLoaded])
|
||||
|
||||
// bg-top 图片加载完成的处理函数
|
||||
const handleBgTopLoad = () => {
|
||||
setIsBgTopLoaded(true);
|
||||
};
|
||||
setIsBgTopLoaded(true)
|
||||
}
|
||||
|
||||
const renderLineText = () => {
|
||||
if (!heartbeatVal) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className="flex-1 h-px bg-outline-normal" />
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="bg-outline-normal h-px flex-1" />
|
||||
<span className="txt-title-m">No Crush Connection Yet</span>
|
||||
<div className="flex-1 h-px bg-outline-normal" />
|
||||
<div className="bg-outline-normal h-px flex-1" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (heartbeatLevel && isShow) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<div className="flex-1 h-px bg-outline-normal" />
|
||||
<div className="flex w-full items-center gap-2">
|
||||
<div className="bg-outline-normal h-px flex-1" />
|
||||
<span className="txt-display-s">· {headerLevelDictMap[heartbeatLevel]?.title} ·</span>
|
||||
<div className="flex-1 h-px bg-outline-normal" />
|
||||
<div className="bg-outline-normal h-px flex-1" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -142,14 +162,19 @@ const CrushLevelDrawer = () => {
|
|||
>
|
||||
<InlineDrawerContent className="overflow-y-auto">
|
||||
{/* 紫色渐变背景 */}
|
||||
<div className="absolute top-0 left-0 right-0 h-[480px] overflow-hidden">
|
||||
<Image src="/images/crushlevel/bg-bottom.png" alt="CrushLevel" fill className="object-cover" />
|
||||
<div className="absolute top-0 right-0 left-0 h-[480px] overflow-hidden">
|
||||
<Image
|
||||
src="/images/crushlevel/bg-bottom.png"
|
||||
alt="CrushLevel"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
{/* 心形图片 - 只在 bg-top 加载完成后延迟显示 */}
|
||||
{showHeartImage && (
|
||||
<Image
|
||||
src={getHeartImageSrc()}
|
||||
alt="Crush Level"
|
||||
className="absolute left-1/2 -translate-x-1/2 transition-all duration-500 ease-in-out animate-in fade-in-0 slide-in-from-bottom-4"
|
||||
className="animate-in fade-in-0 slide-in-from-bottom-4 absolute left-1/2 -translate-x-1/2 transition-all duration-500 ease-in-out"
|
||||
width={124}
|
||||
height={124}
|
||||
style={{
|
||||
|
|
@ -168,22 +193,27 @@ const CrushLevelDrawer = () => {
|
|||
|
||||
<div className="relative inset-0">
|
||||
<InlineDrawerHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="txt-title-m">CrushLevel</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<IconButton
|
||||
iconfont="icon-question"
|
||||
variant="tertiaryDark"
|
||||
size="mini"
|
||||
/>
|
||||
<IconButton iconfont="icon-question" variant="tertiaryDark" size="mini" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<div className="w-64 space-y-2">
|
||||
<p>* Increase your Crush Value by chatting or sending gifts. If you don’t interact for 24 hours, your Crush Value may gradually decrease.</p>
|
||||
<p>* Your virtual character’s 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>
|
||||
<p>
|
||||
* Increase your Crush Value by chatting or sending gifts. If you don’t
|
||||
interact for 24 hours, your Crush Value may gradually decrease.
|
||||
</p>
|
||||
<p>
|
||||
* Your virtual character’s 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>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -191,26 +221,22 @@ const CrushLevelDrawer = () => {
|
|||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
iconfont="icon-More"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
/>
|
||||
<IconButton iconfont="icon-More" variant="ghost" size="small" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault(); // 阻止默认行为
|
||||
e.stopPropagation(); // 阻止事件冒泡
|
||||
e.preventDefault() // 阻止默认行为
|
||||
e.stopPropagation() // 阻止事件冒泡
|
||||
|
||||
if (isSetShowRelationshipPending) return;
|
||||
if (isSetShowRelationshipPending) return
|
||||
queryClient.setQueryData(imKeys.heartbeatLevel(aiId), (old: any) => {
|
||||
return {
|
||||
...old,
|
||||
aiUserHeartbeatRelation: {
|
||||
...old.aiUserHeartbeatRelation,
|
||||
isShow: !old.aiUserHeartbeatRelation.isShow
|
||||
}
|
||||
isShow: !old.aiUserHeartbeatRelation.isShow,
|
||||
},
|
||||
}
|
||||
})
|
||||
queryClient.setQueryData(imKeys.imUserInfo(aiId), (old: any) => {
|
||||
|
|
@ -218,19 +244,24 @@ const CrushLevelDrawer = () => {
|
|||
...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) => {
|
||||
e.preventDefault(); // 阻止 onSelect 默认关闭行为
|
||||
e.preventDefault() // 阻止 onSelect 默认关闭行为
|
||||
}}
|
||||
>
|
||||
<div className="w-full flex items-center justify-between">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="txt-body-l flex-1">Hide Relationship</div>
|
||||
<Switch size="sm" className="cursor-pointer" checked={!isShow} disabled={isSetShowRelationshipPending} />
|
||||
<Switch
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
checked={!isShow}
|
||||
disabled={isSetShowRelationshipPending}
|
||||
/>
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
|
|
@ -241,16 +272,20 @@ const CrushLevelDrawer = () => {
|
|||
<div>
|
||||
{/* 等级和温度信息 */}
|
||||
<div className="flex flex-col items-center gap-4 px-6">
|
||||
{heartbeatVal ? <div className="flex items-center gap-2 txt-numDisplay-s">
|
||||
{heartbeatVal ? (
|
||||
<div className="txt-numDisplay-s flex items-center gap-2">
|
||||
<span>{heartLevelText || 'Lv.0'}</span>
|
||||
<div className="w-px h-[18px] bg-outline-normal" />
|
||||
<div className="bg-outline-normal h-[18px] w-px" />
|
||||
<span>
|
||||
{heartbeatVal || 0}<span className="txt-numMonotype-s">℃</span>
|
||||
{heartbeatVal || 0}
|
||||
<span className="txt-numMonotype-s">℃</span>
|
||||
</span>
|
||||
</div> : (
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-6 text-center">
|
||||
<span className="txt-numDisplay-s">
|
||||
{heartbeatVal || 0}<span className="txt-numMonotype-s">℃</span>
|
||||
{heartbeatVal || 0}
|
||||
<span className="txt-numMonotype-s">℃</span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -259,22 +294,30 @@ const CrushLevelDrawer = () => {
|
|||
{renderLineText()}
|
||||
|
||||
{/* 描述文本 */}
|
||||
{!!heartbeatVal && <p className="txt-body-s">
|
||||
{`Known for ${Math.max((dayCount || 0), 1)} days | Crush Value higher than ${numeral(heartbeatScore).format("0.00%")} of users`}
|
||||
</p>}
|
||||
{!!heartbeatVal && (
|
||||
<p className="txt-body-s">
|
||||
{`Known for ${Math.max(dayCount || 0, 1)} days | Crush Value higher than ${numeral(heartbeatScore).format('0.00%')} of users`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!!subtractHeartbeatVal && <div className="px-6 my-6">
|
||||
<div className="bg-surface-element-normal rounded-m p-4 flex justify-between items-center gap-2">
|
||||
<div className="flex-1 txt-body-s">{`Crush Value lost: -${subtractHeartbeatVal}℃`}</div>
|
||||
<Button variant="ghost" size="small" onClick={() => {
|
||||
setIsCrushLevelRetrieveDrawerOpen(true);
|
||||
}}>
|
||||
{!!subtractHeartbeatVal && (
|
||||
<div className="my-6 px-6">
|
||||
<div className="bg-surface-element-normal rounded-m flex items-center justify-between gap-2 p-4">
|
||||
<div className="txt-body-s flex-1">{`Crush Value lost: -${subtractHeartbeatVal}℃`}</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setIsCrushLevelRetrieveDrawerOpen(true)
|
||||
}}
|
||||
>
|
||||
Retrieve
|
||||
</Button>
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 权限卡片网格 */}
|
||||
<InlineDrawerDescription className="pb-6">
|
||||
|
|
@ -283,7 +326,7 @@ const CrushLevelDrawer = () => {
|
|||
</div>
|
||||
</InlineDrawerContent>
|
||||
</InlineDrawer>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default CrushLevelDrawer;
|
||||
export default CrushLevelDrawer
|
||||
|
|
|
|||
|
|
@ -1,90 +1,106 @@
|
|||
import Image from "next/image";
|
||||
import { InlineDrawer, InlineDrawerContent, InlineDrawerDescription, InlineDrawerFooter, InlineDrawerHeader } from "./InlineDrawer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useAtom, useSetAtom } from "jotai";
|
||||
import { isCrushLevelRetrieveDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat";
|
||||
import { useBuyHeartbeat, useGetHeartbeatLevel } from "@/hooks/useIm";
|
||||
import { useChatConfig } from "../../context/chatConfig";
|
||||
import numeral from "numeral";
|
||||
import { formatFromCents } from "@/utils/number";
|
||||
import React, { useEffect } from "react";
|
||||
import { useGetWalletBalance, useUpdateWalletBalance } from "@/hooks/useWallet";
|
||||
import { isChargeDrawerOpenAtom } from "@/atoms/im";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { imKeys, walletKeys } from "@/lib/query-keys";
|
||||
import Image from 'next/image'
|
||||
import {
|
||||
InlineDrawer,
|
||||
InlineDrawerContent,
|
||||
InlineDrawerDescription,
|
||||
InlineDrawerFooter,
|
||||
InlineDrawerHeader,
|
||||
} from './InlineDrawer'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useAtom, useSetAtom } from 'jotai'
|
||||
import { isCrushLevelRetrieveDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat'
|
||||
import { useBuyHeartbeat, useGetHeartbeatLevel } from '@/hooks/useIm'
|
||||
import { useChatConfig } from '../../context/chatConfig'
|
||||
import numeral from 'numeral'
|
||||
import { formatFromCents } from '@/utils/number'
|
||||
import React, { useEffect } from 'react'
|
||||
import { useGetWalletBalance, useUpdateWalletBalance } from '@/hooks/useWallet'
|
||||
import { isChargeDrawerOpenAtom } from '@/atoms/im'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { imKeys, walletKeys } from '@/lib/query-keys'
|
||||
|
||||
const CrushLevelRetrieveDrawer = () => {
|
||||
// 图片加载状态管理
|
||||
const [isBgTopLoaded, setIsBgTopLoaded] = React.useState(false);
|
||||
const [showHeartImage, setShowHeartImage] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [drawerState, setDrawerState] = useAtom(isCrushLevelRetrieveDrawerOpenAtom);
|
||||
const isCrushLevelRetrieveDrawerOpen = drawerState.open;
|
||||
const setIsCrushLevelRetrieveDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open));
|
||||
const { aiId, aiInfo } = useChatConfig();
|
||||
const [isBgTopLoaded, setIsBgTopLoaded] = React.useState(false)
|
||||
const [showHeartImage, setShowHeartImage] = React.useState(false)
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [drawerState, setDrawerState] = useAtom(isCrushLevelRetrieveDrawerOpenAtom)
|
||||
const isCrushLevelRetrieveDrawerOpen = drawerState.open
|
||||
const setIsCrushLevelRetrieveDrawerOpen = (open: boolean) =>
|
||||
setDrawerState(createDrawerOpenState(open))
|
||||
const { aiId, aiInfo } = useChatConfig()
|
||||
|
||||
const { data, refetch } = useGetHeartbeatLevel({ aiId: Number(aiId) });
|
||||
const { heartbeatLeveLDictList } = data || {};
|
||||
const { aiUserHeartbeatRelation } = aiInfo || {};
|
||||
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 { data, refetch } = useGetHeartbeatLevel({ aiId: Number(aiId) })
|
||||
const { heartbeatLeveLDictList } = data || {}
|
||||
const { aiUserHeartbeatRelation } = aiInfo || {}
|
||||
const { subtractHeartbeatVal, heartbeatVal, price } = aiUserHeartbeatRelation || {}
|
||||
|
||||
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 () => {
|
||||
if (loading) return;
|
||||
if (loading) return
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
if (!walletUpdate.checkSufficient(Math.max((price || 0) * (subtractHeartbeatVal || 0), 100) / 100)) {
|
||||
setIsChargeDrawerOpen(true);
|
||||
return;
|
||||
setLoading(true)
|
||||
if (
|
||||
!walletUpdate.checkSufficient(
|
||||
Math.max((price || 0) * (subtractHeartbeatVal || 0), 100) / 100
|
||||
)
|
||||
) {
|
||||
setIsChargeDrawerOpen(true)
|
||||
return
|
||||
}
|
||||
await retrieveHeartbeatVal({ aiId: Number(aiId), heartbeatVal: subtractHeartbeatVal || 0 });
|
||||
await retrieveHeartbeatVal({ aiId: Number(aiId), heartbeatVal: subtractHeartbeatVal || 0 })
|
||||
await queryClient.invalidateQueries({ queryKey: imKeys.heartbeatLevel(Number(aiId)) })
|
||||
await queryClient.invalidateQueries({ queryKey: imKeys.imUserInfo(Number(aiId)) })
|
||||
await queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() })
|
||||
setIsCrushLevelRetrieveDrawerOpen(false);
|
||||
setIsCrushLevelRetrieveDrawerOpen(false)
|
||||
} catch (error) {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 计算心的位置
|
||||
const calculateHeartPosition = () => {
|
||||
const defaultTop = 150;
|
||||
const defaultTop = 150
|
||||
if (!heartbeatVal || !heartbeatLeveLDictList || heartbeatLeveLDictList.length === 0) {
|
||||
return defaultTop; // 默认位置
|
||||
return defaultTop // 默认位置
|
||||
}
|
||||
|
||||
// 获取最低等级的起始值和最高等级的起始值
|
||||
const sortedLevels = [...heartbeatLeveLDictList].sort((a, b) => (a.startVal || 0) - (b.startVal || 0));
|
||||
const minStartVal = sortedLevels[0]?.startVal || 0;
|
||||
const maxStartVal = sortedLevels[sortedLevels.length - 1]?.startVal || 0;
|
||||
const sortedLevels = [...heartbeatLeveLDictList].sort(
|
||||
(a, b) => (a.startVal || 0) - (b.startVal || 0)
|
||||
)
|
||||
const minStartVal = sortedLevels[0]?.startVal || 0
|
||||
const maxStartVal = sortedLevels[sortedLevels.length - 1]?.startVal || 0
|
||||
|
||||
// 如果只有一个等级或者最大最小值相等,使用默认位置
|
||||
if (maxStartVal <= minStartVal) {
|
||||
return defaultTop;
|
||||
return defaultTop
|
||||
}
|
||||
|
||||
// 计算当前心动值在整个等级系统中的总进度(0-1)
|
||||
const totalProgress = Math.max(0, Math.min(1, (heartbeatVal - minStartVal) / (maxStartVal - minStartVal)));
|
||||
const totalProgress = Math.max(
|
||||
0,
|
||||
Math.min(1, (heartbeatVal - minStartVal) / (maxStartVal - minStartVal))
|
||||
)
|
||||
|
||||
// 将总进度映射到位置范围:top 75(空心)到 top 150(全心)
|
||||
const minTop = defaultTop; // 空心位置
|
||||
const maxTop = 75; // 全心位置
|
||||
const calculatedTop = minTop + (totalProgress * (maxTop - minTop));
|
||||
const minTop = defaultTop // 空心位置
|
||||
const maxTop = 75 // 全心位置
|
||||
const calculatedTop = minTop + totalProgress * (maxTop - minTop)
|
||||
|
||||
return calculatedTop;
|
||||
};
|
||||
return calculatedTop
|
||||
}
|
||||
|
||||
const heartTop = calculateHeartPosition();
|
||||
const heartTop = calculateHeartPosition()
|
||||
|
||||
// 根据位置决定心的显示状态
|
||||
const getHeartImageSrc = () => {
|
||||
|
|
@ -93,37 +109,36 @@ const CrushLevelRetrieveDrawer = () => {
|
|||
|
||||
// 目前只有一个心的图片,可以通过透明度或其他CSS属性来模拟空心/全心效果
|
||||
// 或者后续可以添加不同的图片资源
|
||||
return "/images/crushlevel/heart.png";
|
||||
};
|
||||
return '/images/crushlevel/heart.png'
|
||||
}
|
||||
|
||||
// 当抽屉打开时获取心动等级数据
|
||||
React.useEffect(() => {
|
||||
if (isCrushLevelRetrieveDrawerOpen) {
|
||||
refetch();
|
||||
refetch()
|
||||
// 重置图片加载状态
|
||||
setIsBgTopLoaded(false);
|
||||
setShowHeartImage(false);
|
||||
queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() });
|
||||
setIsBgTopLoaded(false)
|
||||
setShowHeartImage(false)
|
||||
queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() })
|
||||
}
|
||||
}, [isCrushLevelRetrieveDrawerOpen, refetch]);
|
||||
}, [isCrushLevelRetrieveDrawerOpen, refetch])
|
||||
|
||||
// 处理 bg-top 图片加载完成后的延迟显示逻辑
|
||||
React.useEffect(() => {
|
||||
if (isBgTopLoaded) {
|
||||
// bg-top 加载完成后延迟 300ms 显示心形图片
|
||||
const timer = setTimeout(() => {
|
||||
setShowHeartImage(true);
|
||||
}, 300);
|
||||
setShowHeartImage(true)
|
||||
}, 300)
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isBgTopLoaded]);
|
||||
}, [isBgTopLoaded])
|
||||
|
||||
// bg-top 图片加载完成的处理函数
|
||||
const handleBgTopLoad = () => {
|
||||
setIsBgTopLoaded(true);
|
||||
};
|
||||
|
||||
setIsBgTopLoaded(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<InlineDrawer
|
||||
|
|
@ -133,14 +148,19 @@ const CrushLevelRetrieveDrawer = () => {
|
|||
timestamp={drawerState.timestamp}
|
||||
>
|
||||
<InlineDrawerContent>
|
||||
<div className="absolute top-0 left-0 right-0 h-[480px] overflow-hidden">
|
||||
<Image src="/images/crushlevel/bg-bottom.png" alt="CrushLevel" fill className="object-cover" />
|
||||
<div className="absolute top-0 right-0 left-0 h-[480px] overflow-hidden">
|
||||
<Image
|
||||
src="/images/crushlevel/bg-bottom.png"
|
||||
alt="CrushLevel"
|
||||
fill
|
||||
className="object-cover"
|
||||
/>
|
||||
{/* 心形图片 - 只在 bg-top 加载完成后延迟显示 */}
|
||||
{showHeartImage && (
|
||||
<Image
|
||||
src={getHeartImageSrc()}
|
||||
alt="Crush Level"
|
||||
className="absolute left-1/2 -translate-x-1/2 transition-all duration-500 ease-in-out animate-in fade-in-0 slide-in-from-bottom-4"
|
||||
className="animate-in fade-in-0 slide-in-from-bottom-4 absolute left-1/2 -translate-x-1/2 transition-all duration-500 ease-in-out"
|
||||
width={124}
|
||||
height={124}
|
||||
style={{
|
||||
|
|
@ -160,36 +180,44 @@ const CrushLevelRetrieveDrawer = () => {
|
|||
<InlineDrawerHeader> </InlineDrawerHeader>
|
||||
<InlineDrawerDescription>
|
||||
<div className="pt-[124px]">
|
||||
<div className="text-center txt-title-m">Recover Crush Value</div>
|
||||
<div className="txt-title-m text-center">Recover Crush Value</div>
|
||||
<div className="mt-6">
|
||||
<div className="flex justify-between items-center py-3">
|
||||
<div className="flex-1 txt-label-l">Price per Unit</div>
|
||||
<div className="flex items-center gap-2 txt-numMonotype-s">
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="txt-label-l flex-1">Price per Unit</div>
|
||||
<div className="txt-numMonotype-s flex items-center gap-2">
|
||||
<Image src="/icons/diamond.svg" alt="Crush Level" width={16} height={16} />
|
||||
<div>{formatFromCents(price || 0)}/℃</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-3">
|
||||
<div className="flex-1 txt-label-l">Quantity</div>
|
||||
<div className="flex items-center gap-2 txt-numMonotype-s">
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="txt-label-l flex-1">Quantity</div>
|
||||
<div className="txt-numMonotype-s flex items-center gap-2">
|
||||
{/* <Image src="/icons/diamond.svg" alt="Crush Level" width={16} height={16} /> */}
|
||||
<div>{subtractHeartbeatVal}℃</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-3">
|
||||
<div className="flex-1 txt-label-l">Total</div>
|
||||
<div className="flex items-center gap-2 txt-numMonotype-s">
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="txt-label-l flex-1">Total</div>
|
||||
<div className="txt-numMonotype-s flex items-center gap-2">
|
||||
<Image src="/icons/diamond.svg" alt="Crush Level" width={16} height={16} />
|
||||
<div>{formatFromCents(Math.max((price || 0) * (subtractHeartbeatVal || 0), 100))}</div>
|
||||
<div>
|
||||
{formatFromCents(Math.max((price || 0) * (subtractHeartbeatVal || 0), 100))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</InlineDrawerDescription>
|
||||
<InlineDrawerFooter>
|
||||
<Button variant="tertiary" size="large" onClick={() => {
|
||||
setIsCrushLevelRetrieveDrawerOpen(false);
|
||||
}}>Cancel</Button>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
setIsCrushLevelRetrieveDrawerOpen(false)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="large"
|
||||
|
|
@ -202,7 +230,7 @@ const CrushLevelRetrieveDrawer = () => {
|
|||
</div>
|
||||
</InlineDrawerContent>
|
||||
</InlineDrawer>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default CrushLevelRetrieveDrawer;
|
||||
export default CrushLevelRetrieveDrawer
|
||||
|
|
|
|||
|
|
@ -1,75 +1,76 @@
|
|||
import { IconButton } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { createContext, useContext, useEffect, useMemo, useCallback, useState, useRef } from "react";
|
||||
import { IconButton } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { createContext, useContext, useEffect, useMemo, useCallback, useState, useRef } from 'react'
|
||||
|
||||
// 管理所有抽屉的层级顺序
|
||||
export const DrawerLayerContext = createContext<{
|
||||
openOrder: string[];
|
||||
registerDrawer: (id: string) => void;
|
||||
unregisterDrawer: (id: string) => void;
|
||||
bringToFront: (id: string) => void;
|
||||
getZIndex: (id: string) => number;
|
||||
openOrder: string[]
|
||||
registerDrawer: (id: string) => void
|
||||
unregisterDrawer: (id: string) => void
|
||||
bringToFront: (id: string) => void
|
||||
getZIndex: (id: string) => number
|
||||
}>({
|
||||
openOrder: [],
|
||||
registerDrawer: () => {},
|
||||
unregisterDrawer: () => {},
|
||||
bringToFront: () => {},
|
||||
getZIndex: () => 0,
|
||||
});
|
||||
})
|
||||
|
||||
export const DrawerLayerProvider = ({
|
||||
children,
|
||||
baseZIndex = 10,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
baseZIndex?: number;
|
||||
children: React.ReactNode
|
||||
baseZIndex?: number
|
||||
}) => {
|
||||
const [openOrder, setOpenOrder] = useState<string[]>([]);
|
||||
const [openOrder, setOpenOrder] = useState<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) => {
|
||||
setOpenOrder((prev) => prev.filter((item) => item !== id));
|
||||
}, []);
|
||||
setOpenOrder((prev) => prev.filter((item) => item !== id))
|
||||
}, [])
|
||||
|
||||
const bringToFront = useCallback((id: string) => {
|
||||
setOpenOrder((prev) => {
|
||||
// 如果该抽屉已经在最前面,则不需要更新
|
||||
if (prev.length > 0 && prev[prev.length - 1] === id) {
|
||||
return prev;
|
||||
return prev
|
||||
}
|
||||
const filtered = prev.filter((item) => item !== id);
|
||||
return [...filtered, id];
|
||||
});
|
||||
}, []);
|
||||
const filtered = prev.filter((item) => item !== id)
|
||||
return [...filtered, id]
|
||||
})
|
||||
}, [])
|
||||
|
||||
|
||||
|
||||
const getZIndex = useCallback((id: string) => {
|
||||
const index = openOrder.indexOf(id);
|
||||
if (index === -1) return baseZIndex;
|
||||
return baseZIndex + index;
|
||||
}, [openOrder, baseZIndex]);
|
||||
const getZIndex = useCallback(
|
||||
(id: string) => {
|
||||
const index = openOrder.indexOf(id)
|
||||
if (index === -1) return baseZIndex
|
||||
return baseZIndex + index
|
||||
},
|
||||
[openOrder, baseZIndex]
|
||||
)
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ 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<{
|
||||
id: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
id: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}>({
|
||||
id: "",
|
||||
id: '',
|
||||
open: false,
|
||||
onOpenChange: () => {},
|
||||
});
|
||||
})
|
||||
|
||||
export const InlineDrawer = ({
|
||||
id,
|
||||
|
|
@ -78,55 +79,55 @@ export const InlineDrawer = ({
|
|||
timestamp,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
timestamp?: number;
|
||||
children: React.ReactNode;
|
||||
id: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
timestamp?: number
|
||||
children: React.ReactNode
|
||||
}) => {
|
||||
const { registerDrawer, unregisterDrawer, bringToFront } = useContext(DrawerLayerContext);
|
||||
const { registerDrawer, unregisterDrawer, bringToFront } = useContext(DrawerLayerContext)
|
||||
|
||||
// 当抽屉打开时注册并置顶;当关闭或卸载时移除
|
||||
// 监听 timestamp 变化,确保每次重新打开时都会置顶
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
registerDrawer(id);
|
||||
bringToFront(id);
|
||||
registerDrawer(id)
|
||||
bringToFront(id)
|
||||
}
|
||||
}, [open, timestamp, id, registerDrawer, bringToFront]);
|
||||
}, [open, timestamp, id, registerDrawer, bringToFront])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
unregisterDrawer(id);
|
||||
};
|
||||
}, [id, unregisterDrawer, open]);
|
||||
unregisterDrawer(id)
|
||||
}
|
||||
}, [id, unregisterDrawer, open])
|
||||
|
||||
// 当抽屉关闭时不渲染任何内容
|
||||
if (!open) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<InlineDrawerContext.Provider value={{ id, open, onOpenChange }}>
|
||||
{children}
|
||||
</InlineDrawerContext.Provider>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export const InlineDrawerContent = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => {
|
||||
const { id } = useContext(InlineDrawerContext);
|
||||
const { getZIndex, bringToFront } = useContext(DrawerLayerContext);
|
||||
const zIndex = getZIndex(id);
|
||||
const { id } = useContext(InlineDrawerContext)
|
||||
const { getZIndex, bringToFront } = useContext(DrawerLayerContext)
|
||||
const zIndex = getZIndex(id)
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-background-default absolute inset-0 flex flex-col w-[400px] border-l border-solid border-outline-normal",
|
||||
'bg-background-default border-outline-normal absolute inset-0 flex w-[400px] flex-col border-l border-solid',
|
||||
className
|
||||
)}
|
||||
style={{ zIndex }}
|
||||
|
|
@ -134,41 +135,40 @@ export const InlineDrawerContent = ({
|
|||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export const InlineDrawerHeader = ({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) => {
|
||||
const { onOpenChange } = useContext(InlineDrawerContext);
|
||||
export const InlineDrawerHeader = ({ children }: { children: React.ReactNode }) => {
|
||||
const { onOpenChange } = useContext(InlineDrawerContext)
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-6">
|
||||
<IconButton iconfont="icon-arrow-right" variant="ghost" size="small" onClick={() => onOpenChange(false)} />
|
||||
<div className="txt-title-m flex-1 min-w-0">
|
||||
{children}
|
||||
<IconButton
|
||||
iconfont="icon-arrow-right"
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => onOpenChange(false)}
|
||||
/>
|
||||
<div className="txt-title-m min-w-0 flex-1">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export const InlineDrawerDescription = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) => {
|
||||
return <div className={cn("flex-1 px-6 overflow-y-auto", className)}>{children}</div>;
|
||||
};
|
||||
return <div className={cn('flex-1 overflow-y-auto px-6', className)}>{children}</div>
|
||||
}
|
||||
|
||||
export const InlineDrawerFooter = ({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
children: React.ReactNode
|
||||
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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,39 @@
|
|||
import { useAtom, useAtomValue, useSetAtom } from "jotai";
|
||||
import { isSendGiftsDrawerOpenAtom, createDrawerOpenState, isCrushLevelDrawerOpenAtom } from "@/atoms/chat";
|
||||
import { InlineDrawer, InlineDrawerContent, InlineDrawerDescription, InlineDrawerFooter, InlineDrawerHeader } from "./InlineDrawer";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Image from "next/image";
|
||||
import { useState, useEffect } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { GiftOutput } from "@/services/im";
|
||||
import { useGetGiftList, useSendGift } from "@/hooks/useIm";
|
||||
import { useChatConfig } from "../../context/chatConfig";
|
||||
import { Tag } from "@/components/ui/tag";
|
||||
import { isChargeDrawerOpenAtom, isVipDrawerOpenAtom, isWaitingForReplyAtom } from "@/atoms/im";
|
||||
import { toast } from "sonner";
|
||||
import numeral from "numeral";
|
||||
import { useGetWalletBalance, useUpdateWalletBalance } from "@/hooks/useWallet";
|
||||
import { useCurrentUser } from "@/hooks/auth";
|
||||
import { useHeartLevelTextFromLevel } from "@/hooks/useHeartLevel";
|
||||
import { VipType } from "@/services/wallet";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { walletKeys } from "@/lib/query-keys";
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
|
||||
import {
|
||||
isSendGiftsDrawerOpenAtom,
|
||||
createDrawerOpenState,
|
||||
isCrushLevelDrawerOpenAtom,
|
||||
} from '@/atoms/chat'
|
||||
import {
|
||||
InlineDrawer,
|
||||
InlineDrawerContent,
|
||||
InlineDrawerDescription,
|
||||
InlineDrawerFooter,
|
||||
InlineDrawerHeader,
|
||||
} from './InlineDrawer'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import Image from 'next/image'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { GiftOutput } from '@/services/im'
|
||||
import { useGetGiftList, useSendGift } from '@/hooks/useIm'
|
||||
import { useChatConfig } from '../../context/chatConfig'
|
||||
import { Tag } from '@/components/ui/tag'
|
||||
import { isChargeDrawerOpenAtom, isVipDrawerOpenAtom, isWaitingForReplyAtom } from '@/atoms/im'
|
||||
import { toast } from 'sonner'
|
||||
import numeral from 'numeral'
|
||||
import { useGetWalletBalance, useUpdateWalletBalance } from '@/hooks/useWallet'
|
||||
import { useCurrentUser } from '@/hooks/auth'
|
||||
import { useHeartLevelTextFromLevel } from '@/hooks/useHeartLevel'
|
||||
import { VipType } from '@/services/wallet'
|
||||
import { useQueryClient } from '@tanstack/react-query'
|
||||
import { walletKeys } from '@/lib/query-keys'
|
||||
|
||||
// 礼物价格转换工具函数(分转元)
|
||||
const convertGiftPriceToYuan = (priceInCents: number): number => {
|
||||
return priceInCents / 100;
|
||||
};
|
||||
return priceInCents / 100
|
||||
}
|
||||
|
||||
// 礼物卡片组件
|
||||
const GiftCard = ({
|
||||
|
|
@ -31,23 +41,22 @@ const GiftCard = ({
|
|||
isSelected,
|
||||
onSelect,
|
||||
heartbeatVal,
|
||||
isMember
|
||||
isMember,
|
||||
}: {
|
||||
gift: GiftOutput;
|
||||
isSelected: boolean;
|
||||
onSelect: (gift: GiftOutput) => void;
|
||||
heartbeatVal: number;
|
||||
isMember: boolean;
|
||||
gift: GiftOutput
|
||||
isSelected: boolean
|
||||
onSelect: (gift: GiftOutput) => void
|
||||
heartbeatVal: number
|
||||
isMember: boolean
|
||||
}) => {
|
||||
|
||||
const handleClick = () => {
|
||||
onSelect(gift);
|
||||
onSelect(gift)
|
||||
}
|
||||
|
||||
const renderDisabledTag = () => {
|
||||
if (gift.isMemberGift) {
|
||||
if (isMember) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Tag className="absolute top-0 left-0" size="small">
|
||||
|
|
@ -64,107 +73,109 @@ const GiftCard = ({
|
|||
)
|
||||
}
|
||||
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 min-w-24 p-2 rounded-2xl cursor-pointer transition-all duration-200",
|
||||
"bg-transparent flex flex-col items-center gap-1 hover:bg-surface-element-normal border border-transparent",
|
||||
isSelected && "border border-primary-variant-normal shadow-lg bg-surface-element-normal"
|
||||
'min-w-24 flex-1 cursor-pointer rounded-2xl p-2 transition-all duration-200',
|
||||
'hover:bg-surface-element-normal flex flex-col items-center gap-1 border border-transparent bg-transparent',
|
||||
isSelected && 'border-primary-variant-normal bg-surface-element-normal border shadow-lg'
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 礼物图片 */}
|
||||
<div className="relative w-full aspect-square">
|
||||
<div className="relative aspect-square w-full">
|
||||
<Image src={gift.icon} alt={gift.name} fill className="object-contain" />
|
||||
{renderDisabledTag()}
|
||||
</div>
|
||||
|
||||
{/* 礼物名称 */}
|
||||
<div className="txt-label-m text-txt-primary-normal text-center line-clamp-1">
|
||||
<div className="txt-label-m text-txt-primary-normal line-clamp-1 text-center">
|
||||
{gift.name}
|
||||
</div>
|
||||
|
||||
{/* 价格 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<Image src="/icons/diamond.svg" alt="diamond" width={12} height={12} />
|
||||
<span className="txt-numMonotype-xs text-txt-primary-normal">{numeral(convertGiftPriceToYuan(gift.price)).format('0,0')}</span>
|
||||
<span className="txt-numMonotype-xs text-txt-primary-normal">
|
||||
{numeral(convertGiftPriceToYuan(gift.price)).format('0,0')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
const SendGiftsDrawer = () => {
|
||||
const [drawerState, setDrawerState] = useAtom(isSendGiftsDrawerOpenAtom);
|
||||
const isSendGiftsDrawerOpen = drawerState.open;
|
||||
const setIsSendGiftsDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open));
|
||||
const [selectedGift, setSelectedGift] = useState<GiftOutput | null>(null);
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const { aiId, aiInfo, handleUserMessage } = useChatConfig();
|
||||
const [drawerState, setDrawerState] = useAtom(isSendGiftsDrawerOpenAtom)
|
||||
const isSendGiftsDrawerOpen = drawerState.open
|
||||
const setIsSendGiftsDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
|
||||
const [selectedGift, setSelectedGift] = useState<GiftOutput | null>(null)
|
||||
const [quantity, setQuantity] = useState(1)
|
||||
const { aiId, aiInfo, handleUserMessage } = useChatConfig()
|
||||
// const isWaitingForReply = useAtomValue(isWaitingForReplyAtom);
|
||||
const isWaitingForReply = false;
|
||||
const setIsChargeDrawerOpen = useSetAtom(isChargeDrawerOpenAtom);
|
||||
const { data: currentUser } = useCurrentUser();
|
||||
const { isMember } = currentUser || {};
|
||||
const setIsCrushLevelDrawerOpen = useSetAtom(isCrushLevelDrawerOpenAtom);
|
||||
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom);
|
||||
const queryClient = useQueryClient();
|
||||
const isWaitingForReply = false
|
||||
const setIsChargeDrawerOpen = useSetAtom(isChargeDrawerOpenAtom)
|
||||
const { data: currentUser } = useCurrentUser()
|
||||
const { isMember } = currentUser || {}
|
||||
const setIsCrushLevelDrawerOpen = useSetAtom(isCrushLevelDrawerOpenAtom)
|
||||
const setIsVipDrawerOpen = useSetAtom(isVipDrawerOpenAtom)
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const { aiUserHeartbeatRelation } = aiInfo || {};
|
||||
const { heartbeatVal } = aiUserHeartbeatRelation || {};
|
||||
const { aiUserHeartbeatRelation } = aiInfo || {}
|
||||
const { heartbeatVal } = aiUserHeartbeatRelation || {}
|
||||
|
||||
const { data } = useGetGiftList();
|
||||
const giftList = data?.datas || [];
|
||||
const isOwner = currentUser?.userId === aiInfo?.userId;
|
||||
const { data } = useGetGiftList()
|
||||
const giftList = data?.datas || []
|
||||
const isOwner = currentUser?.userId === aiInfo?.userId
|
||||
|
||||
const { mutateAsync: sendGift, isPending: isSendGiftPending } = useSendGift();
|
||||
const { mutateAsync: sendGift, isPending: isSendGiftPending } = useSendGift()
|
||||
|
||||
// 当礼物列表加载完成且抽屉打开时,自动选中第一个礼物
|
||||
useEffect(() => {
|
||||
if (isSendGiftsDrawerOpen && giftList.length > 0 && !selectedGift) {
|
||||
setSelectedGift(giftList[0]);
|
||||
setQuantity(1);
|
||||
setSelectedGift(giftList[0])
|
||||
setQuantity(1)
|
||||
}
|
||||
}, [isSendGiftsDrawerOpen, giftList, selectedGift]);
|
||||
}, [isSendGiftsDrawerOpen, giftList, selectedGift])
|
||||
|
||||
// 当抽屉关闭时,重置选中状态
|
||||
useEffect(() => {
|
||||
if (!isSendGiftsDrawerOpen) {
|
||||
setSelectedGift(null);
|
||||
setQuantity(1);
|
||||
setSelectedGift(null)
|
||||
setQuantity(1)
|
||||
}
|
||||
|
||||
if (isSendGiftsDrawerOpen) {
|
||||
queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() });
|
||||
queryClient.invalidateQueries({ queryKey: walletKeys.getWalletBalance() })
|
||||
}
|
||||
}, [isSendGiftsDrawerOpen]);
|
||||
}, [isSendGiftsDrawerOpen])
|
||||
|
||||
// 获取钱包余额和更新方法
|
||||
const { data: walletData } = useGetWalletBalance();
|
||||
const walletUpdate = useUpdateWalletBalance();
|
||||
const { data: walletData } = useGetWalletBalance()
|
||||
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 newQuantity = Math.max(1, Math.min(100, quantity + delta));
|
||||
setQuantity(newQuantity);
|
||||
};
|
||||
const newQuantity = Math.max(1, Math.min(100, quantity + delta))
|
||||
setQuantity(newQuantity)
|
||||
}
|
||||
|
||||
const handleGiftSelect = (gift: GiftOutput | null) => {
|
||||
setSelectedGift(gift);
|
||||
setSelectedGift(gift)
|
||||
// 重置数量为1
|
||||
setQuantity(1);
|
||||
};
|
||||
setQuantity(1)
|
||||
}
|
||||
|
||||
const handleSendGift = async () => {
|
||||
if (!selectedGift) return;
|
||||
if (!selectedGift) return
|
||||
|
||||
// if (isOwner) {
|
||||
// toast.error('You cannot send gifts to yourself.');
|
||||
|
|
@ -172,8 +183,8 @@ const SendGiftsDrawer = () => {
|
|||
// }
|
||||
|
||||
if (!canPurchase) {
|
||||
setIsChargeDrawerOpen(true);
|
||||
return;
|
||||
setIsChargeDrawerOpen(true)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -181,27 +192,27 @@ const SendGiftsDrawer = () => {
|
|||
giftId: selectedGift.id,
|
||||
aiId: Number(aiId),
|
||||
num: quantity,
|
||||
});
|
||||
walletUpdate.deduct(totalPrice, { skipInvalidation: true });
|
||||
})
|
||||
walletUpdate.deduct(totalPrice, { skipInvalidation: true })
|
||||
|
||||
// 成功后刷新余额数据
|
||||
walletUpdate.refresh();
|
||||
walletUpdate.refresh()
|
||||
|
||||
// 通知用户发送了消息,重置自动聊天定时器
|
||||
handleUserMessage();
|
||||
handleUserMessage()
|
||||
|
||||
// 重置选择状态
|
||||
setSelectedGift(null);
|
||||
setQuantity(1);
|
||||
setSelectedGift(null)
|
||||
setQuantity(1)
|
||||
} catch (error) {
|
||||
// 失败时回滚余额
|
||||
toast.error("Gift sending failed. Please try again.");
|
||||
console.error('送礼物失败:', error);
|
||||
toast.error('Gift sending failed. Please try again.')
|
||||
console.error('送礼物失败:', error)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getButton = () => {
|
||||
const { isMemberGift, startVal, heartbeatLevel } = selectedGift || {};
|
||||
const { isMemberGift, startVal, heartbeatLevel } = selectedGift || {}
|
||||
if (isMemberGift) {
|
||||
if (!isMember) {
|
||||
return (
|
||||
|
|
@ -211,23 +222,41 @@ const SendGiftsDrawer = () => {
|
|||
onClick={() => setIsVipDrawerOpen({ open: true, vipType: VipType.SPECIAL_GIFT })}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Image src="/icons/vip-black.svg" alt="vip" className="block" width={24} height={24} />
|
||||
<Image
|
||||
src="/icons/vip-black.svg"
|
||||
alt="vip"
|
||||
className="block"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span className="txt-label-l">Unlock</span>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (heartbeatLevel) {
|
||||
if ((startVal && typeof heartbeatVal === 'number' && heartbeatVal < startVal || !heartbeatVal)) {
|
||||
if (
|
||||
(startVal && typeof heartbeatVal === 'number' && heartbeatVal < startVal) ||
|
||||
!heartbeatVal
|
||||
) {
|
||||
return (
|
||||
<Button size="large"
|
||||
<Button
|
||||
size="large"
|
||||
onClick={() => setIsCrushLevelDrawerOpen(createDrawerOpenState(true))}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Image src="/icons/like-gradient.svg" alt="vip" className="block" width={24} height={24} />
|
||||
<span className="txt-label-l">{useHeartLevelTextFromLevel(heartbeatLevel)} Unlock</span>
|
||||
<Image
|
||||
src="/icons/like-gradient.svg"
|
||||
alt="vip"
|
||||
className="block"
|
||||
width={24}
|
||||
height={24}
|
||||
/>
|
||||
<span className="txt-label-l">
|
||||
{useHeartLevelTextFromLevel(heartbeatLevel)} Unlock
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
)
|
||||
|
|
@ -235,14 +264,10 @@ const SendGiftsDrawer = () => {
|
|||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
onClick={handleSendGift}
|
||||
disabled={isWaitingForReply}
|
||||
loading={isSendGiftPending}
|
||||
>
|
||||
<Button onClick={handleSendGift} disabled={isWaitingForReply} loading={isSendGiftPending}>
|
||||
Gift
|
||||
</Button>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -271,15 +296,15 @@ const SendGiftsDrawer = () => {
|
|||
</div>
|
||||
</InlineDrawerDescription>
|
||||
|
||||
<InlineDrawerFooter className="flex-col gap-4 bg-background-default/65 backdrop-blur-md">
|
||||
<InlineDrawerFooter className="bg-background-default/65 flex-col gap-4 backdrop-blur-md">
|
||||
{/* 数量选择器 */}
|
||||
{selectedGift && (
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="txt-label-m text-txt-primary-normal">Quantity</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="!w-12 px-0 min-w-0 rounded-sm"
|
||||
className="!w-12 min-w-0 rounded-sm px-0"
|
||||
onClick={() => handleQuantityChange(-1)}
|
||||
>
|
||||
<i className="iconfont icon-reduce" />
|
||||
|
|
@ -288,26 +313,26 @@ const SendGiftsDrawer = () => {
|
|||
className="w-20 text-center"
|
||||
value={quantity}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
const value = e.target.value
|
||||
|
||||
// 只能输入正整数
|
||||
if (!/^\d+$/.test(value)) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const numValue = Number(value);
|
||||
const numValue = Number(value)
|
||||
// 限制最大值为100
|
||||
if (numValue > 100) {
|
||||
setQuantity(100);
|
||||
return;
|
||||
setQuantity(100)
|
||||
return
|
||||
}
|
||||
|
||||
setQuantity(numValue);
|
||||
setQuantity(numValue)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="tertiary"
|
||||
className="!w-12 px-0 min-w-0 rounded-sm"
|
||||
className="!w-12 min-w-0 rounded-sm px-0"
|
||||
onClick={() => handleQuantityChange(1)}
|
||||
>
|
||||
<i className="iconfont icon-add" />
|
||||
|
|
@ -317,16 +342,20 @@ const SendGiftsDrawer = () => {
|
|||
)}
|
||||
|
||||
{/* 总价显示 */}
|
||||
{selectedGift && (<div className="flex justify-between items-center w-full">
|
||||
{selectedGift && (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="txt-label-m text-txt-primary-normal">Total</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<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 className="flex justify-between items-center w-full">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="txt-label-m text-txt-primary-normal">Balance:</span>
|
||||
<Image src="/icons/diamond.svg" alt="diamond" width={16} height={16} />
|
||||
|
|
@ -337,7 +366,7 @@ const SendGiftsDrawer = () => {
|
|||
</InlineDrawerFooter>
|
||||
</InlineDrawerContent>
|
||||
</InlineDrawer>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default SendGiftsDrawer;
|
||||
export default SendGiftsDrawer
|
||||
|
|
|
|||
|
|
@ -1,21 +1,20 @@
|
|||
import CrushLevelDrawer from "./CrushLevelDrawer";
|
||||
import SendGiftsDrawer from "./SendGiftsDrawer";
|
||||
import CrushLevelRetrieveDrawer from "./CrushLevelRetrieveDrawer";
|
||||
import ChatProfileDrawer from "./ChatProfileDrawer";
|
||||
import { DrawerLayerContext } from "./InlineDrawer";
|
||||
import { useContext } from "react";
|
||||
import { cn } from "@/lib/utils";
|
||||
import ChatProfileEditDrawer from "./ChatProfileEditDrawer";
|
||||
import ChatModelDrawer from "./ChatModelDrawer";
|
||||
import ChatButtleDrawer from "./ChatButtleDrawer";
|
||||
import ChatBackgroundDrawer from "./ChatBackgroundDrawer";
|
||||
import CrushLevelDrawer from './CrushLevelDrawer'
|
||||
import SendGiftsDrawer from './SendGiftsDrawer'
|
||||
import CrushLevelRetrieveDrawer from './CrushLevelRetrieveDrawer'
|
||||
import ChatProfileDrawer from './ChatProfileDrawer'
|
||||
import { DrawerLayerContext } from './InlineDrawer'
|
||||
import { useContext } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import ChatProfileEditDrawer from './ChatProfileEditDrawer'
|
||||
import ChatModelDrawer from './ChatModelDrawer'
|
||||
import ChatButtleDrawer from './ChatButtleDrawer'
|
||||
import ChatBackgroundDrawer from './ChatBackgroundDrawer'
|
||||
|
||||
const ChatDrawers = () => {
|
||||
const { openOrder } = useContext(DrawerLayerContext);
|
||||
|
||||
const { openOrder } = useContext(DrawerLayerContext)
|
||||
|
||||
return (
|
||||
<div className={cn("w-[400px]", openOrder.length === 0 && "hidden")}>
|
||||
<div className={cn('w-[400px]', openOrder.length === 0 && 'hidden')}>
|
||||
<SendGiftsDrawer />
|
||||
<CrushLevelDrawer />
|
||||
<CrushLevelRetrieveDrawer />
|
||||
|
|
@ -25,7 +24,7 @@ const ChatDrawers = () => {
|
|||
<ChatButtleDrawer />
|
||||
<ChatBackgroundDrawer />
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatDrawers;
|
||||
export default ChatDrawers
|
||||
|
|
|
|||
|
|
@ -1,24 +1,33 @@
|
|||
"use client"
|
||||
import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from "@/components/ui/alert-dialog";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useChatConfig } from "../../context/chatConfig";
|
||||
import { useRouter } from "next/navigation";
|
||||
'use client'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogCancel,
|
||||
AlertDialogAction,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useChatConfig } from '../../context/chatConfig'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
const ChatFirstGuideDialog = () => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { aiId } = useChatConfig();
|
||||
const router = useRouter();
|
||||
const [open, setOpen] = useState(false)
|
||||
const { aiId } = useChatConfig()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (!!localStorage.getItem('create-ai-show-guide')) {
|
||||
setOpen(true);
|
||||
localStorage.removeItem('create-ai-show-guide');
|
||||
setOpen(true)
|
||||
localStorage.removeItem('create-ai-show-guide')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConfirm = () => {
|
||||
setOpen(false);
|
||||
router.push(`/@${aiId}`);
|
||||
setOpen(false)
|
||||
router.push(`/@${aiId}`)
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -28,7 +37,10 @@ const ChatFirstGuideDialog = () => {
|
|||
<AlertDialogTitle>Create Album</AlertDialogTitle>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogDescription>
|
||||
<p>Go to the character’s profile to create an album, attract more chatters, and increase your earnings.</p>
|
||||
<p>
|
||||
Go to the character’s profile to create an album, attract more chatters, and increase
|
||||
your earnings.
|
||||
</p>
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Not now</AlertDialogCancel>
|
||||
|
|
@ -36,7 +48,7 @@ const ChatFirstGuideDialog = () => {
|
|||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatFirstGuideDialog;
|
||||
export default ChatFirstGuideDialog
|
||||
|
|
|
|||
|
|
@ -1,26 +1,26 @@
|
|||
'use client';
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { IconButton } from '@/components/ui/button';
|
||||
import Image from 'next/image';
|
||||
import React, { useState } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { IconButton } from '@/components/ui/button'
|
||||
import Image from 'next/image'
|
||||
|
||||
interface ReplySuggestion {
|
||||
id: string;
|
||||
text: string;
|
||||
isSkeleton?: boolean;
|
||||
id: string
|
||||
text: string
|
||||
isSkeleton?: boolean
|
||||
}
|
||||
|
||||
interface AiReplySuggestionsProps {
|
||||
suggestions: ReplySuggestion[];
|
||||
currentPage: number;
|
||||
totalPages: number;
|
||||
isLoading?: boolean;
|
||||
onSuggestionEdit: (suggestion: ReplySuggestion) => void;
|
||||
onSuggestionSend: (suggestion: ReplySuggestion) => void;
|
||||
onPageChange: (page: number) => void;
|
||||
onClose: () => void;
|
||||
className?: string;
|
||||
suggestions: ReplySuggestion[]
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
isLoading?: boolean
|
||||
onSuggestionEdit: (suggestion: ReplySuggestion) => void
|
||||
onSuggestionSend: (suggestion: ReplySuggestion) => void
|
||||
onPageChange: (page: number) => void
|
||||
onClose: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
|
||||
|
|
@ -32,50 +32,40 @@ export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
|
|||
onSuggestionSend,
|
||||
onPageChange,
|
||||
onClose,
|
||||
className
|
||||
className,
|
||||
}) => {
|
||||
// 检查是否显示骨架屏:当前页的建议中有骨架屏标记
|
||||
const showSkeleton = suggestions.some(s => s.isSkeleton);
|
||||
const showSkeleton = suggestions.some((s) => s.isSkeleton)
|
||||
|
||||
return (
|
||||
<div className={cn(
|
||||
"flex flex-col gap-4 items-start justify-start w-full",
|
||||
className
|
||||
)}>
|
||||
<div className={cn('flex w-full flex-col items-start justify-start gap-4', className)}>
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<p className="txt-label-m text-txt-secondary-normal">
|
||||
Choose one or edit
|
||||
</p>
|
||||
<IconButton
|
||||
variant="tertiaryDark"
|
||||
size="xs"
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<p className="txt-label-m text-txt-secondary-normal">Choose one or edit</p>
|
||||
<IconButton variant="tertiaryDark" size="xs" onClick={onClose}>
|
||||
<i className="iconfont icon-close" />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{/* 建议列表 */}
|
||||
{showSkeleton ? (
|
||||
// 骨架屏 - 固定显示3条建议的布局
|
||||
{showSkeleton
|
||||
? // 骨架屏 - 固定显示3条建议的布局
|
||||
suggestions.map((suggestion) => (
|
||||
<div
|
||||
key={suggestion.id}
|
||||
className="flex gap-4 items-end justify-start overflow-hidden pl-4 pr-4 py-2 rounded-xl w-full bg-surface-element-light-normal backdrop-blur-[16px]"
|
||||
className="bg-surface-element-light-normal flex w-full items-end justify-start gap-4 overflow-hidden rounded-xl py-2 pr-4 pl-4 backdrop-blur-[16px]"
|
||||
>
|
||||
<div className="flex-1 px-0 py-1">
|
||||
<div className="h-6 bg-surface-element-normal rounded w-full animate-pulse"></div>
|
||||
<div className="bg-surface-element-normal h-6 w-full animate-pulse rounded"></div>
|
||||
</div>
|
||||
<div className="size-8 bg-surface-element-normal rounded-full flex-shrink-0 animate-pulse"></div>
|
||||
<div className="bg-surface-element-normal size-8 flex-shrink-0 animate-pulse rounded-full"></div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
// 实际建议内容
|
||||
: // 实际建议内容
|
||||
suggestions.map((suggestion) => (
|
||||
<div
|
||||
key={suggestion.id}
|
||||
className="flex gap-4 items-end justify-start overflow-hidden pl-4 pr-4 py-2 rounded-xl w-full cursor-pointer bg-surface-element-light-normal hover:bg-surface-element-light-hover transition-colors backdrop-blur-[16px]"
|
||||
className="bg-surface-element-light-normal hover:bg-surface-element-light-hover flex w-full cursor-pointer items-end justify-start gap-4 overflow-hidden rounded-xl py-2 pr-4 pl-4 backdrop-blur-[16px] transition-colors"
|
||||
onClick={() => onSuggestionSend(suggestion)}
|
||||
>
|
||||
<div className="flex-1 px-0 py-1">
|
||||
|
|
@ -87,16 +77,15 @@ export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
|
|||
variant="ghost"
|
||||
size="small"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发卡片点击
|
||||
onSuggestionEdit(suggestion);
|
||||
e.stopPropagation() // 阻止事件冒泡,避免触发卡片点击
|
||||
onSuggestionEdit(suggestion)
|
||||
}}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<i className="iconfont icon-icon_order_remark" />
|
||||
</IconButton>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
))}
|
||||
|
||||
{/* VIP 解锁选项 */}
|
||||
{/* <div
|
||||
|
|
@ -125,8 +114,8 @@ export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
|
|||
</div> */}
|
||||
|
||||
{/* 分页控制 */}
|
||||
<div className="flex gap-4 items-center justify-center w-full">
|
||||
<div className="flex gap-2 items-start justify-start">
|
||||
<div className="flex w-full items-center justify-center gap-4">
|
||||
<div className="flex items-start justify-start gap-2">
|
||||
<IconButton
|
||||
variant="tertiaryDark"
|
||||
size="xs"
|
||||
|
|
@ -136,7 +125,7 @@ export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
|
|||
<i className="iconfont icon-arrow-left-border" />
|
||||
</IconButton>
|
||||
|
||||
<div className="flex gap-3 h-6 items-center justify-center min-w-6">
|
||||
<div className="flex h-6 min-w-6 items-center justify-center gap-3">
|
||||
<span className="txt-numMonotype-xs">
|
||||
{currentPage}/{totalPages}
|
||||
</span>
|
||||
|
|
@ -153,7 +142,7 @@ export const AiReplySuggestions: React.FC<AiReplySuggestionsProps> = ({
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default AiReplySuggestions;
|
||||
export default AiReplySuggestions
|
||||
|
|
|
|||
|
|
@ -1,109 +1,115 @@
|
|||
"use client"
|
||||
'use client'
|
||||
|
||||
import { IconButton } from "@/components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState, useCallback } from "react";
|
||||
import { useChatConfig } from "../../context/chatConfig";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { isCrushLevelDrawerOpenAtom, createDrawerOpenState } from "@/atoms/chat";
|
||||
import useShare from "@/hooks/useShare";
|
||||
import { ChatPriceType, useUpdateWalletBalance } from "@/hooks/useWallet";
|
||||
import { isCallAtom, isCoinInsufficientAtom } from "@/atoms/im";
|
||||
import { toast } from "sonner";
|
||||
import { useNimMsgContext } from "@/context/NimChat/useNimChat";
|
||||
import { IconButton } from '@/components/ui/button'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useChatConfig } from '../../context/chatConfig'
|
||||
import { useSetAtom } from 'jotai'
|
||||
import { isCrushLevelDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat'
|
||||
import useShare from '@/hooks/useShare'
|
||||
import { ChatPriceType, useUpdateWalletBalance } from '@/hooks/useWallet'
|
||||
import { isCallAtom, isCoinInsufficientAtom } from '@/atoms/im'
|
||||
import { toast } from 'sonner'
|
||||
import { useNimMsgContext } from '@/context/NimChat/useNimChat'
|
||||
|
||||
const ChatActionPlus = ({
|
||||
onUploadImage,
|
||||
}: {
|
||||
onUploadImage: () => void;
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { aiInfo, aiId } = useChatConfig();
|
||||
const { audioPlayer } = useNimMsgContext();
|
||||
const setDrawerState = useSetAtom(isCrushLevelDrawerOpenAtom);
|
||||
const setIsCrushLevelDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open));
|
||||
const { aiUserHeartbeatRelation } = aiInfo || {};
|
||||
const { heartbeatLevelNum } = aiUserHeartbeatRelation || {};
|
||||
const { shareFacebook, shareTwitter } = useShare();
|
||||
const { checkSufficientByType } = useUpdateWalletBalance();
|
||||
const setIsCoinInsufficient = useSetAtom(isCoinInsufficientAtom);
|
||||
const setIsCall = useSetAtom(isCallAtom);
|
||||
const ChatActionPlus = ({ onUploadImage }: { onUploadImage: () => void }) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const { aiInfo, aiId } = useChatConfig()
|
||||
const { audioPlayer } = useNimMsgContext()
|
||||
const setDrawerState = useSetAtom(isCrushLevelDrawerOpenAtom)
|
||||
const setIsCrushLevelDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
|
||||
const { aiUserHeartbeatRelation } = aiInfo || {}
|
||||
const { heartbeatLevelNum } = aiUserHeartbeatRelation || {}
|
||||
const { shareFacebook, shareTwitter } = useShare()
|
||||
const { checkSufficientByType } = useUpdateWalletBalance()
|
||||
const setIsCoinInsufficient = useSetAtom(isCoinInsufficientAtom)
|
||||
const setIsCall = useSetAtom(isCallAtom)
|
||||
|
||||
const handleShareFacebook = () => {
|
||||
shareFacebook({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}` });
|
||||
shareFacebook({
|
||||
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
|
||||
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}`,
|
||||
})
|
||||
}
|
||||
const handleShareTwitter = () => {
|
||||
shareTwitter({ text: 'Come to Crushlevel for chat, Crush, and AI - chat.', shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}` });
|
||||
shareTwitter({
|
||||
text: 'Come to Crushlevel for chat, Crush, and AI - chat.',
|
||||
shareUrl: `${process.env.NEXT_PUBLIC_APP_URL}/@${aiId}`,
|
||||
})
|
||||
}
|
||||
|
||||
// 请求麦克风权限
|
||||
const requestMicrophonePermission = useCallback(async (): Promise<boolean> => {
|
||||
try {
|
||||
// 先尝试同时请求麦克风和摄像头权限
|
||||
let stream;
|
||||
let stream
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false });
|
||||
console.log('成功获取麦克风和摄像头权限');
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true, video: false })
|
||||
console.log('成功获取麦克风和摄像头权限')
|
||||
} catch (error) {
|
||||
// 如果摄像头权限失败,只请求麦克风权限
|
||||
console.log('摄像头权限获取失败,仅请求麦克风权限:', error);
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
console.log('成功获取麦克风权限');
|
||||
console.log('摄像头权限获取失败,仅请求麦克风权限:', error)
|
||||
stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
console.log('成功获取麦克风权限')
|
||||
}
|
||||
|
||||
// 立即停止流,我们只是为了获取权限
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
return true;
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
return true
|
||||
} catch (error) {
|
||||
console.log('requestMicrophonePermission error', JSON.stringify(error));
|
||||
console.log('requestMicrophonePermission error', JSON.stringify(error))
|
||||
// 可以在这里显示用户友好的错误提示
|
||||
toast('Microphone permission is required to make a voice call. Please allow microphone access in your browser settings.');
|
||||
return false;
|
||||
toast(
|
||||
'Microphone permission is required to make a voice call. Please allow microphone access in your browser settings.'
|
||||
)
|
||||
return false
|
||||
}
|
||||
}, []);
|
||||
}, [])
|
||||
|
||||
const handleMakeCall = async () => {
|
||||
audioPlayer?.stop();
|
||||
audioPlayer?.stop()
|
||||
if (!heartbeatLevelNum || heartbeatLevelNum < 4) {
|
||||
setIsCrushLevelDrawerOpen(true);
|
||||
return;
|
||||
setIsCrushLevelDrawerOpen(true)
|
||||
return
|
||||
}
|
||||
if (!checkSufficientByType(ChatPriceType.VOICE_CALL)) {
|
||||
setIsCoinInsufficient(true);
|
||||
return;
|
||||
setIsCoinInsufficient(true)
|
||||
return
|
||||
}
|
||||
|
||||
// 在开始通话前请求麦克风权限
|
||||
const hasPermission = await requestMicrophonePermission();
|
||||
const hasPermission = await requestMicrophonePermission()
|
||||
if (!hasPermission) {
|
||||
return; // 如果没有权限,不继续进行通话
|
||||
return // 如果没有权限,不继续进行通话
|
||||
}
|
||||
|
||||
setIsCall(true);
|
||||
setIsCall(true)
|
||||
}
|
||||
|
||||
const handleUploadImage = () => {
|
||||
if (!heartbeatLevelNum || heartbeatLevelNum < 2) {
|
||||
setIsCrushLevelDrawerOpen(true);
|
||||
return;
|
||||
setIsCrushLevelDrawerOpen(true)
|
||||
return
|
||||
}
|
||||
if (!checkSufficientByType(ChatPriceType.TEXT)) {
|
||||
setIsCoinInsufficient(true);
|
||||
return;
|
||||
setIsCoinInsufficient(true)
|
||||
return
|
||||
}
|
||||
onUploadImage();
|
||||
onUploadImage()
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="small"
|
||||
>
|
||||
<i className={cn("iconfont", open ? "icon-close" : "icon-add")} />
|
||||
<IconButton variant="ghost" size="small">
|
||||
<i className={cn('iconfont', open ? 'icon-close' : 'icon-add')} />
|
||||
</IconButton>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
|
|
@ -115,10 +121,10 @@ const ChatActionPlus = ({
|
|||
<i className="iconfont icon-Call" />
|
||||
<span>Make a Call</span>
|
||||
</DropdownMenuItem>
|
||||
<div className="px-2 my-3">
|
||||
<div className="my-3 px-2">
|
||||
<Separator className="bg-outline-normal" />
|
||||
</div>
|
||||
<div className="px-2 py-3 txt-label-m text-txt-secondary-normal">Share to</div>
|
||||
<div className="txt-label-m text-txt-secondary-normal px-2 py-3">Share to</div>
|
||||
<DropdownMenuItem onClick={handleShareFacebook}>
|
||||
<i className="iconfont icon-social-facebook" />
|
||||
<span>Share to Facebook</span>
|
||||
|
|
@ -129,7 +135,7 @@ const ChatActionPlus = ({
|
|||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatActionPlus;
|
||||
export default ChatActionPlus
|
||||
|
|
|
|||
|
|
@ -1,60 +1,61 @@
|
|||
'use client';
|
||||
import { IconButton } from "@/components/ui/button";
|
||||
import { useMemo, useEffect } from "react";
|
||||
'use client'
|
||||
import { IconButton } from '@/components/ui/button'
|
||||
import { useMemo, useEffect } from 'react'
|
||||
|
||||
interface ChatImagePreviewProps {
|
||||
selectedImage: File | null;
|
||||
imageUploading: boolean;
|
||||
removeImage: () => void;
|
||||
selectedImage: File | null
|
||||
imageUploading: boolean
|
||||
removeImage: () => void
|
||||
}
|
||||
|
||||
const ChatImagePreview = ({ selectedImage, imageUploading, removeImage }: ChatImagePreviewProps) => {
|
||||
const ChatImagePreview = ({
|
||||
selectedImage,
|
||||
imageUploading,
|
||||
removeImage,
|
||||
}: ChatImagePreviewProps) => {
|
||||
// 使用useMemo缓存ObjectURL,避免每次渲染都创建新的URL
|
||||
const imageUrl = useMemo(() => {
|
||||
if (!selectedImage) return null;
|
||||
return URL.createObjectURL(selectedImage);
|
||||
}, [selectedImage]);
|
||||
if (!selectedImage) return null
|
||||
return URL.createObjectURL(selectedImage)
|
||||
}, [selectedImage])
|
||||
|
||||
// 组件卸载或图片变更时清理ObjectURL,避免内存泄漏
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (imageUrl) {
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
URL.revokeObjectURL(imageUrl)
|
||||
}
|
||||
};
|
||||
}, [imageUrl]);
|
||||
}
|
||||
}, [imageUrl])
|
||||
|
||||
if (!selectedImage || !imageUrl) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full ml-4">
|
||||
<div className="ml-4 w-full">
|
||||
<div className="relative inline-block">
|
||||
<div className="w-24 h-24 rounded-lg overflow-hidden bg-center bg-cover bg-no-repeat relative"
|
||||
style={{ backgroundImage: `url(${imageUrl})` }}>
|
||||
|
||||
<div
|
||||
className="relative h-24 w-24 overflow-hidden rounded-lg bg-cover bg-center bg-no-repeat"
|
||||
style={{ backgroundImage: `url(${imageUrl})` }}
|
||||
>
|
||||
{/* 上传进度遮罩 */}
|
||||
{imageUploading && (
|
||||
<div className="absolute inset-0 bg-black/50 flex items-center justify-center">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-2 border-white border-t-transparent"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div className="h-6 w-6 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除按钮 */}
|
||||
<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" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatImagePreview;
|
||||
export default ChatImagePreview
|
||||
|
|
|
|||
|
|
@ -1,47 +1,51 @@
|
|||
'use client';
|
||||
'use client'
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { IconButton } from '@/components/ui/button';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { isSendGiftsDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat';
|
||||
import { useNimChat, useNimMsgContext } from '@/context/NimChat/useNimChat';
|
||||
import { selectedConversationIdAtom, isWaitingForReplyAtom, isCoinInsufficientAtom } from '@/atoms/im';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useS3Upload } from '@/hooks/useS3Upload';
|
||||
import { BizTypeEnum } from '@/services/common/types';
|
||||
import ChatImagePreview from './ChatImagePreview';
|
||||
import { useGetTextFromAsrVoice } from '@/hooks/useIm';
|
||||
import { VoiceWaveAnimation } from '@/components/ui/voice-wave-animation';
|
||||
import { CustomMessageType } from '@/types/im';
|
||||
import ChatActionPlus from './ChatActionPlus';
|
||||
import AiReplySuggestions from './AiReplySuggestions';
|
||||
import { useAiReplySuggestions } from '@/hooks/useAiReplySuggestions';
|
||||
import { useChatConfig } from '../../context/chatConfig/useChatConfig';
|
||||
import { toast } from 'sonner';
|
||||
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';
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { IconButton } from '@/components/ui/button'
|
||||
import { useAtomValue, useSetAtom } from 'jotai'
|
||||
import { isSendGiftsDrawerOpenAtom, createDrawerOpenState } from '@/atoms/chat'
|
||||
import { useNimChat, useNimMsgContext } from '@/context/NimChat/useNimChat'
|
||||
import {
|
||||
selectedConversationIdAtom,
|
||||
isWaitingForReplyAtom,
|
||||
isCoinInsufficientAtom,
|
||||
} from '@/atoms/im'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { useS3Upload } from '@/hooks/useS3Upload'
|
||||
import { BizTypeEnum } from '@/services/common/types'
|
||||
import ChatImagePreview from './ChatImagePreview'
|
||||
import { useGetTextFromAsrVoice } from '@/hooks/useIm'
|
||||
import { VoiceWaveAnimation } from '@/components/ui/voice-wave-animation'
|
||||
import { CustomMessageType } from '@/types/im'
|
||||
import ChatActionPlus from './ChatActionPlus'
|
||||
import AiReplySuggestions from './AiReplySuggestions'
|
||||
import { useAiReplySuggestions } from '@/hooks/useAiReplySuggestions'
|
||||
import { useChatConfig } from '../../context/chatConfig/useChatConfig'
|
||||
import { toast } from 'sonner'
|
||||
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 {
|
||||
onSendMessage?: (message: string, images?: File[]) => void;
|
||||
onSendVoice?: (audioBlob: Blob) => void;
|
||||
userAvatar?: string;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
onSendMessage?: (message: string, images?: File[]) => void
|
||||
onSendVoice?: (audioBlob: Blob) => void
|
||||
userAvatar?: string
|
||||
className?: string
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
interface ImageError {
|
||||
type: 'format' | 'size';
|
||||
message: string;
|
||||
type: 'format' | 'size'
|
||||
message: string
|
||||
}
|
||||
|
||||
interface ImageInfo {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
file?: File;
|
||||
url: string
|
||||
width: number
|
||||
height: number
|
||||
file?: File
|
||||
}
|
||||
|
||||
const ChatInput: React.FC<ChatInputProps> = ({
|
||||
|
|
@ -49,36 +53,37 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
onSendVoice,
|
||||
userAvatar,
|
||||
className,
|
||||
placeholder = "Chat",
|
||||
placeholder = 'Chat',
|
||||
}) => {
|
||||
const searchParams = useSearchParams();
|
||||
const [message, setMessage] = useState('');
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
||||
const [imageInfo, setImageInfo] = useState<ImageInfo | null>(null);
|
||||
const [imageError, setImageError] = useState<ImageError | null>(null);
|
||||
const [isRecording, setIsRecording] = useState(false);
|
||||
const [isListening, setIsListening] = useState(false);
|
||||
const [isProcessingAudio, setIsProcessingAudio] = useState(false); // 音频处理状态(上传+ASR)
|
||||
const [isComposing, setIsComposing] = useState(false); // 中文输入法组合输入状态
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const audioChunksRef = useRef<Blob[]>([]);
|
||||
const isRecordingRef = useRef<boolean>(false);
|
||||
const isCancelledRef = useRef<boolean>(false);
|
||||
const recordingStartTimeRef = useRef<number>(0); // 录音开始时间
|
||||
const recordingTimerRef = useRef<NodeJS.Timeout | null>(null); // 60秒定时器
|
||||
const setDrawerState = useSetAtom(isSendGiftsDrawerOpenAtom);
|
||||
const setIsSendGiftsDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open));
|
||||
const { sendMessageActive, audioPlayer } = useNimMsgContext();
|
||||
const { nim } = useNimChat();
|
||||
const selectedConversationId = useAtomValue(selectedConversationIdAtom);
|
||||
const searchParams = useSearchParams()
|
||||
const [message, setMessage] = useState('')
|
||||
const [selectedImage, setSelectedImage] = useState<File | null>(null)
|
||||
const [imageInfo, setImageInfo] = useState<ImageInfo | null>(null)
|
||||
const [imageError, setImageError] = useState<ImageError | null>(null)
|
||||
const [isRecording, setIsRecording] = useState(false)
|
||||
const [isListening, setIsListening] = useState(false)
|
||||
const [isProcessingAudio, setIsProcessingAudio] = useState(false) // 音频处理状态(上传+ASR)
|
||||
const [isComposing, setIsComposing] = useState(false) // 中文输入法组合输入状态
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null)
|
||||
const audioChunksRef = useRef<Blob[]>([])
|
||||
const isRecordingRef = useRef<boolean>(false)
|
||||
const isCancelledRef = useRef<boolean>(false)
|
||||
const recordingStartTimeRef = useRef<number>(0) // 录音开始时间
|
||||
const recordingTimerRef = useRef<NodeJS.Timeout | null>(null) // 60秒定时器
|
||||
const setDrawerState = useSetAtom(isSendGiftsDrawerOpenAtom)
|
||||
const setIsSendGiftsDrawerOpen = (open: boolean) => setDrawerState(createDrawerOpenState(open))
|
||||
const { sendMessageActive, audioPlayer } = useNimMsgContext()
|
||||
const { nim } = useNimChat()
|
||||
const selectedConversationId = useAtomValue(selectedConversationIdAtom)
|
||||
// const isWaitingForReply = useAtomValue(isWaitingForReplyAtom);
|
||||
const isWaitingForReply = false;
|
||||
const { mutateAsync: getTextFromAsrVoice, isPending: isTextFromAsrVoicePending } = useGetTextFromAsrVoice();
|
||||
const { aiId, handleUserMessage } = useChatConfig();
|
||||
const { checkSufficientByType } = useUpdateWalletBalance();
|
||||
const setIsCoinInsufficient = useSetAtom(isCoinInsufficientAtom);
|
||||
const isWaitingForReply = false
|
||||
const { mutateAsync: getTextFromAsrVoice, isPending: isTextFromAsrVoicePending } =
|
||||
useGetTextFromAsrVoice()
|
||||
const { aiId, handleUserMessage } = useChatConfig()
|
||||
const { checkSufficientByType } = useUpdateWalletBalance()
|
||||
const setIsCoinInsufficient = useSetAtom(isCoinInsufficientAtom)
|
||||
|
||||
// AI建议回复相关状态
|
||||
const {
|
||||
|
|
@ -91,8 +96,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
hideSuggestions,
|
||||
handlePageChange,
|
||||
} = useAiReplySuggestions({
|
||||
aiId: Number(aiId)
|
||||
});
|
||||
aiId: Number(aiId),
|
||||
})
|
||||
|
||||
// S3上传钩子
|
||||
const {
|
||||
|
|
@ -100,141 +105,139 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
uploadFile,
|
||||
cancelUpload,
|
||||
error: uploadError,
|
||||
progress: uploadProgress
|
||||
progress: uploadProgress,
|
||||
} = useS3Upload({
|
||||
bizType: BizTypeEnum.IM,
|
||||
onSuccess: (url) => {
|
||||
console.log('Image uploaded successfully:', url);
|
||||
console.log('Image uploaded successfully:', url)
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Image upload failed:', error);
|
||||
setImageError({ type: 'size', message: 'Image upload failed, please try again' });
|
||||
}
|
||||
});
|
||||
console.error('Image upload failed:', error)
|
||||
setImageError({ type: 'size', message: 'Image upload failed, please try again' })
|
||||
},
|
||||
})
|
||||
|
||||
// 自动调整 Textarea 高度的函数
|
||||
const adjustTextareaHeight = () => {
|
||||
const textarea = textareaRef.current;
|
||||
const textarea = textareaRef.current
|
||||
if (textarea) {
|
||||
// 重置高度以获得准确的 scrollHeight
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = 'auto'
|
||||
|
||||
// 计算内容高度,但限制最大高度为视口的40%以防止过度扩展
|
||||
const maxHeight = Math.min(textarea.scrollHeight, window.innerHeight * 0.4);
|
||||
textarea.style.height = maxHeight + 'px';
|
||||
const maxHeight = Math.min(textarea.scrollHeight, window.innerHeight * 0.4)
|
||||
textarea.style.height = maxHeight + 'px'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 重置 Textarea 高度到初始状态
|
||||
const resetTextareaHeight = () => {
|
||||
const textarea = textareaRef.current;
|
||||
const textarea = textareaRef.current
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = '32px'; // 设置为最小高度
|
||||
textarea.style.height = 'auto'
|
||||
textarea.style.height = '32px' // 设置为最小高度
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 监听 message 变化,自动调整高度
|
||||
useEffect(() => {
|
||||
adjustTextareaHeight();
|
||||
}, [message]);
|
||||
adjustTextareaHeight()
|
||||
}, [message])
|
||||
|
||||
// 从 URL 参数中获取 text 并填充到输入框
|
||||
useEffect(() => {
|
||||
const textFromUrl = searchParams.get('text');
|
||||
const textFromUrl = searchParams.get('text')
|
||||
if (textFromUrl) {
|
||||
setMessage(textFromUrl);
|
||||
setMessage(textFromUrl)
|
||||
// 聚焦到输入框
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.focus()
|
||||
}
|
||||
} else {
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
textareaRef.current.focus()
|
||||
}
|
||||
}
|
||||
}, [searchParams]);
|
||||
}, [searchParams])
|
||||
|
||||
// 音频上传的S3配置
|
||||
const {
|
||||
uploadFile: uploadAudioFile
|
||||
} = useS3Upload({
|
||||
const { uploadFile: uploadAudioFile } = useS3Upload({
|
||||
bizType: BizTypeEnum.SOUND_PATH,
|
||||
});
|
||||
})
|
||||
|
||||
// 获取图片尺寸信息
|
||||
const getImageDimensions = (file: File): Promise<{ width: number; height: number }> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
const url = URL.createObjectURL(file);
|
||||
const img = new Image()
|
||||
const url = URL.createObjectURL(file)
|
||||
|
||||
img.onload = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
URL.revokeObjectURL(url)
|
||||
resolve({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight
|
||||
});
|
||||
};
|
||||
height: img.naturalHeight,
|
||||
})
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
URL.revokeObjectURL(url);
|
||||
reject(new Error('Failed to load image'));
|
||||
};
|
||||
URL.revokeObjectURL(url)
|
||||
reject(new Error('Failed to load image'))
|
||||
}
|
||||
|
||||
img.src = url;
|
||||
});
|
||||
};
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
// 验证图片格式和大小
|
||||
const validateImage = (file: File): ImageError | null => {
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png'];
|
||||
const maxSize = 10 * 1024 * 1024; // 10MB
|
||||
const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png']
|
||||
const maxSize = 10 * 1024 * 1024 // 10MB
|
||||
|
||||
if (!allowedTypes.includes(file.type)) {
|
||||
return {
|
||||
type: 'format',
|
||||
message: 'Supported formats: JPG, JPEG, PNG'
|
||||
};
|
||||
message: 'Supported formats: JPG, JPEG, PNG',
|
||||
}
|
||||
}
|
||||
|
||||
if (file.size > maxSize) {
|
||||
return {
|
||||
type: 'size',
|
||||
message: 'Image size must be less than 10MB'
|
||||
};
|
||||
message: 'Image size must be less than 10MB',
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
return null
|
||||
}
|
||||
|
||||
// 处理发送消息
|
||||
const handleSend = () => {
|
||||
if (isRecording) {
|
||||
// 手动停止录音时,也需要检查时长
|
||||
mediaRecorderRef.current?.stop();
|
||||
setIsRecording(false);
|
||||
isRecordingRef.current = false;
|
||||
return;
|
||||
mediaRecorderRef.current?.stop()
|
||||
setIsRecording(false)
|
||||
isRecordingRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
// 如果正在等待回复或正在上传图片,禁止发送新消息
|
||||
if (isWaitingForReply || imageUploading || isProcessingAudio) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
// 如果有图片,必须同时有文字内容
|
||||
if (imageInfo && !message.trim()) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
if (!checkSufficientByType(ChatPriceType.TEXT)) {
|
||||
setIsCoinInsufficient(true);
|
||||
return;
|
||||
setIsCoinInsufficient(true)
|
||||
return
|
||||
}
|
||||
|
||||
// 必须有文字内容或者同时有图片和文字
|
||||
if (message.trim() || (imageInfo && message.trim())) {
|
||||
let msg;
|
||||
let msg
|
||||
|
||||
if (imageInfo) {
|
||||
// 创建图文消息(自定义消息)
|
||||
|
|
@ -242,313 +245,313 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
type: CustomMessageType.IMAGE,
|
||||
url: `${imageInfo.url}`,
|
||||
width: imageInfo.width,
|
||||
height: imageInfo.height
|
||||
};
|
||||
height: imageInfo.height,
|
||||
}
|
||||
|
||||
msg = nim.V2NIMMessageCreator.createCustomMessage(
|
||||
message.trim(), // text字段
|
||||
JSON.stringify(customContent) // custom字段
|
||||
);
|
||||
)
|
||||
} else {
|
||||
// 纯文本消息
|
||||
msg = nim.V2NIMMessageCreator.createTextMessage(message.trim());
|
||||
msg = nim.V2NIMMessageCreator.createTextMessage(message.trim())
|
||||
}
|
||||
|
||||
sendMessageActive({
|
||||
msg,
|
||||
conversationId: selectedConversationId || '',
|
||||
});
|
||||
})
|
||||
|
||||
// 通知用户发送了消息,重置自动聊天定时器
|
||||
handleUserMessage();
|
||||
handleUserMessage()
|
||||
|
||||
// 清空输入
|
||||
setMessage('');
|
||||
setSelectedImage(null);
|
||||
setImageInfo(null);
|
||||
setImageError(null);
|
||||
setMessage('')
|
||||
setSelectedImage(null)
|
||||
setImageInfo(null)
|
||||
setImageError(null)
|
||||
|
||||
// 重置 Textarea 高度
|
||||
resetTextareaHeight();
|
||||
resetTextareaHeight()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理键盘事件
|
||||
const handleKeyPress = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !isComposing) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
e.preventDefault()
|
||||
handleSend()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 处理中文输入法组合事件
|
||||
const handleCompositionStart = () => {
|
||||
setIsComposing(true);
|
||||
};
|
||||
setIsComposing(true)
|
||||
}
|
||||
|
||||
const handleCompositionEnd = () => {
|
||||
setIsComposing(false);
|
||||
};
|
||||
setIsComposing(false)
|
||||
}
|
||||
|
||||
// 处理图片选择
|
||||
const handleImageSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
const file = e.target.files?.[0]
|
||||
if (file) {
|
||||
const error = validateImage(file);
|
||||
const error = validateImage(file)
|
||||
if (error) {
|
||||
setImageError(error);
|
||||
setSelectedImage(null);
|
||||
setImageInfo(null);
|
||||
setImageError(error)
|
||||
setSelectedImage(null)
|
||||
setImageInfo(null)
|
||||
} else {
|
||||
setSelectedImage(file);
|
||||
setImageError(null);
|
||||
setSelectedImage(file)
|
||||
setImageError(null)
|
||||
|
||||
try {
|
||||
// 获取图片尺寸
|
||||
const dimensions = await getImageDimensions(file);
|
||||
const dimensions = await getImageDimensions(file)
|
||||
|
||||
// 开始上传
|
||||
const uploadedUrl = await uploadFile(file);
|
||||
console.log('uploadedUrl', uploadedUrl);
|
||||
const uploadedUrl = await uploadFile(file)
|
||||
console.log('uploadedUrl', uploadedUrl)
|
||||
|
||||
if (uploadedUrl) {
|
||||
setImageInfo({
|
||||
url: `${uploadedUrl}`,
|
||||
width: dimensions.width,
|
||||
height: dimensions.height,
|
||||
file
|
||||
});
|
||||
file,
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('处理图片失败:', error);
|
||||
setImageError({ type: 'size', message: 'Image processing failed, please try again' });
|
||||
setSelectedImage(null);
|
||||
console.error('处理图片失败:', error)
|
||||
setImageError({ type: 'size', message: 'Image processing failed, please try again' })
|
||||
setSelectedImage(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
// 清空input的值,允许重新选择同一个文件
|
||||
e.target.value = '';
|
||||
};
|
||||
e.target.value = ''
|
||||
}
|
||||
|
||||
// 取消录音
|
||||
const handleCancelRecording = () => {
|
||||
if (mediaRecorderRef.current && isRecording) {
|
||||
// 标记为取消状态,防止在onstop中处理音频
|
||||
isCancelledRef.current = true;
|
||||
isCancelledRef.current = true
|
||||
|
||||
// 清除60秒定时器
|
||||
if (recordingTimerRef.current) {
|
||||
clearTimeout(recordingTimerRef.current);
|
||||
recordingTimerRef.current = null;
|
||||
clearTimeout(recordingTimerRef.current)
|
||||
recordingTimerRef.current = null
|
||||
}
|
||||
|
||||
mediaRecorderRef.current.stop();
|
||||
mediaRecorderRef.current.stop()
|
||||
// 获取所有音频轨道并停止
|
||||
if (mediaRecorderRef.current.stream) {
|
||||
mediaRecorderRef.current.stream.getTracks().forEach(track => track.stop());
|
||||
mediaRecorderRef.current.stream.getTracks().forEach((track) => track.stop())
|
||||
}
|
||||
setIsRecording(false)
|
||||
isRecordingRef.current = false
|
||||
setIsListening(false)
|
||||
setIsProcessingAudio(false) // 重置音频处理状态
|
||||
audioChunksRef.current = []
|
||||
recordingStartTimeRef.current = 0
|
||||
}
|
||||
setIsRecording(false);
|
||||
isRecordingRef.current = false;
|
||||
setIsListening(false);
|
||||
setIsProcessingAudio(false); // 重置音频处理状态
|
||||
audioChunksRef.current = [];
|
||||
recordingStartTimeRef.current = 0;
|
||||
}
|
||||
};
|
||||
|
||||
// 处理语音录制
|
||||
const handleVoiceRecord = async () => {
|
||||
if (!checkSufficientByType(ChatPriceType.VOICE)) {
|
||||
setIsCoinInsufficient(true);
|
||||
return;
|
||||
setIsCoinInsufficient(true)
|
||||
return
|
||||
}
|
||||
audioPlayer?.stop();
|
||||
audioPlayer?.stop()
|
||||
|
||||
if (!isRecording) {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
const mediaRecorder = new MediaRecorder(stream);
|
||||
mediaRecorderRef.current = mediaRecorder;
|
||||
audioChunksRef.current = [];
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
const mediaRecorder = new MediaRecorder(stream)
|
||||
mediaRecorderRef.current = mediaRecorder
|
||||
audioChunksRef.current = []
|
||||
|
||||
// 添加音频上下文来检测音量
|
||||
const audioContext = new AudioContext();
|
||||
const analyser = audioContext.createAnalyser();
|
||||
const microphone = audioContext.createMediaStreamSource(stream);
|
||||
microphone.connect(analyser);
|
||||
const audioContext = new AudioContext()
|
||||
const analyser = audioContext.createAnalyser()
|
||||
const microphone = audioContext.createMediaStreamSource(stream)
|
||||
microphone.connect(analyser)
|
||||
|
||||
analyser.smoothingTimeConstant = 0.8;
|
||||
analyser.fftSize = 1024;
|
||||
analyser.smoothingTimeConstant = 0.8
|
||||
analyser.fftSize = 1024
|
||||
|
||||
const bufferLength = analyser.frequencyBinCount;
|
||||
const dataArray = new Uint8Array(bufferLength);
|
||||
const bufferLength = analyser.frequencyBinCount
|
||||
const dataArray = new Uint8Array(bufferLength)
|
||||
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
audioChunksRef.current.push(event.data);
|
||||
};
|
||||
audioChunksRef.current.push(event.data)
|
||||
}
|
||||
|
||||
mediaRecorder.onstop = async () => {
|
||||
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' });
|
||||
stream.getTracks().forEach(track => track.stop());
|
||||
audioContext.close();
|
||||
const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' })
|
||||
stream.getTracks().forEach((track) => track.stop())
|
||||
audioContext.close()
|
||||
|
||||
setIsListening(false);
|
||||
isRecordingRef.current = false;
|
||||
setIsListening(false)
|
||||
isRecordingRef.current = false
|
||||
|
||||
// 清除60秒定时器
|
||||
if (recordingTimerRef.current) {
|
||||
clearTimeout(recordingTimerRef.current);
|
||||
recordingTimerRef.current = null;
|
||||
clearTimeout(recordingTimerRef.current)
|
||||
recordingTimerRef.current = null
|
||||
}
|
||||
|
||||
// 如果是取消录音,直接返回,不处理音频
|
||||
if (isCancelledRef.current) {
|
||||
isCancelledRef.current = false; // 重置取消状态
|
||||
recordingStartTimeRef.current = 0;
|
||||
return;
|
||||
isCancelledRef.current = false // 重置取消状态
|
||||
recordingStartTimeRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
// 检查录音时长
|
||||
const recordingDuration = Date.now() - recordingStartTimeRef.current;
|
||||
const recordingDuration = Date.now() - recordingStartTimeRef.current
|
||||
if (recordingDuration < 1000) {
|
||||
// 录音时间少于1秒,显示提示
|
||||
toast.error("Voice too short");
|
||||
recordingStartTimeRef.current = 0;
|
||||
return;
|
||||
toast.error('Voice too short')
|
||||
recordingStartTimeRef.current = 0
|
||||
return
|
||||
}
|
||||
|
||||
// 开始音频处理(上传+ASR)
|
||||
setIsProcessingAudio(true);
|
||||
setIsProcessingAudio(true)
|
||||
|
||||
try {
|
||||
// 将 Blob 转换为 base64
|
||||
const base64Data = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string;
|
||||
console.log('result', result);
|
||||
const result = reader.result as string
|
||||
console.log('result', result)
|
||||
// 移除 data:audio/mp3;base64, 前缀
|
||||
const base64 = result.split(',')[1];
|
||||
resolve(base64);
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(audioBlob);
|
||||
});
|
||||
const base64 = result.split(',')[1]
|
||||
resolve(base64)
|
||||
}
|
||||
reader.onerror = reject
|
||||
reader.readAsDataURL(audioBlob)
|
||||
})
|
||||
|
||||
// 调用ASR接口获取文字
|
||||
const resp = await getTextFromAsrVoice({
|
||||
data: base64Data,
|
||||
aiId: Number(aiId),
|
||||
});
|
||||
const text = resp?.content;
|
||||
const trimmedText = text?.trim();
|
||||
})
|
||||
const text = resp?.content
|
||||
const trimmedText = text?.trim()
|
||||
if (trimmedText) {
|
||||
// 纯文本消息
|
||||
const msg = nim.V2NIMMessageCreator.createTextMessage(trimmedText);
|
||||
const msg = nim.V2NIMMessageCreator.createTextMessage(trimmedText)
|
||||
sendMessageActive({
|
||||
msg,
|
||||
conversationId: selectedConversationId || '',
|
||||
});
|
||||
})
|
||||
|
||||
// 通知用户发送了消息,重置自动聊天定时器
|
||||
handleUserMessage();
|
||||
handleUserMessage()
|
||||
|
||||
// 重置 Textarea 高度(虽然语音消息不会改变输入框内容,但为了保持一致性)
|
||||
resetTextareaHeight();
|
||||
resetTextareaHeight()
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('音频上传或转换失败:', error);
|
||||
console.error('音频上传或转换失败:', error)
|
||||
// 可以在这里添加错误提示给用户
|
||||
} finally {
|
||||
// 完成音频处理
|
||||
setIsProcessingAudio(false);
|
||||
recordingStartTimeRef.current = 0;
|
||||
setIsProcessingAudio(false)
|
||||
recordingStartTimeRef.current = 0
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
mediaRecorder.start();
|
||||
setIsRecording(true);
|
||||
isRecordingRef.current = true;
|
||||
isCancelledRef.current = false; // 重置取消状态
|
||||
mediaRecorder.start()
|
||||
setIsRecording(true)
|
||||
isRecordingRef.current = true
|
||||
isCancelledRef.current = false // 重置取消状态
|
||||
|
||||
// 记录录音开始时间
|
||||
recordingStartTimeRef.current = Date.now();
|
||||
recordingStartTimeRef.current = Date.now()
|
||||
|
||||
// 设置60秒定时器,自动停止录音
|
||||
recordingTimerRef.current = setTimeout(() => {
|
||||
if (mediaRecorderRef.current && isRecordingRef.current) {
|
||||
mediaRecorderRef.current.stop();
|
||||
setIsRecording(false);
|
||||
isRecordingRef.current = false;
|
||||
mediaRecorderRef.current.stop()
|
||||
setIsRecording(false)
|
||||
isRecordingRef.current = false
|
||||
}
|
||||
}, 60000); // 60秒
|
||||
}, 60000) // 60秒
|
||||
|
||||
// 检测音量的函数
|
||||
const checkVolume = () => {
|
||||
if (isRecordingRef.current) {
|
||||
analyser.getByteFrequencyData(dataArray);
|
||||
const volume = dataArray.reduce((sum, value) => sum + value, 0) / bufferLength;
|
||||
setIsListening(volume > 10); // 设置音量阈值
|
||||
requestAnimationFrame(checkVolume);
|
||||
analyser.getByteFrequencyData(dataArray)
|
||||
const volume = dataArray.reduce((sum, value) => sum + value, 0) / bufferLength
|
||||
setIsListening(volume > 10) // 设置音量阈值
|
||||
requestAnimationFrame(checkVolume)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 确保在设置录音状态后再开始检测音量
|
||||
checkVolume();
|
||||
checkVolume()
|
||||
} catch (error) {
|
||||
console.error('Error accessing microphone:', error);
|
||||
console.error('Error accessing microphone:', error)
|
||||
}
|
||||
} else {
|
||||
// 发送动作改在发送消息的按钮上
|
||||
return;
|
||||
return
|
||||
|
||||
// 手动停止录音时,也需要检查时长
|
||||
mediaRecorderRef.current?.stop();
|
||||
setIsRecording(false);
|
||||
isRecordingRef.current = false;
|
||||
mediaRecorderRef.current?.stop()
|
||||
setIsRecording(false)
|
||||
isRecordingRef.current = false
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 移除选中的图片
|
||||
const removeImage = () => {
|
||||
// 如果正在上传,取消上传
|
||||
if (imageUploading) {
|
||||
cancelUpload();
|
||||
cancelUpload()
|
||||
}
|
||||
|
||||
setSelectedImage(null);
|
||||
setImageInfo(null);
|
||||
setImageError(null);
|
||||
};
|
||||
setSelectedImage(null)
|
||||
setImageInfo(null)
|
||||
setImageError(null)
|
||||
}
|
||||
|
||||
// 处理AI建议编辑(放到输入框)
|
||||
const handleSuggestionEdit = (suggestion: any) => {
|
||||
setMessage(suggestion.text);
|
||||
hideSuggestions();
|
||||
};
|
||||
setMessage(suggestion.text)
|
||||
hideSuggestions()
|
||||
}
|
||||
|
||||
// 处理AI建议发送(直接发送消息)
|
||||
const handleSuggestionSend = (suggestion: any) => {
|
||||
// 直接发送建议内容作为消息
|
||||
if (suggestion.text.trim()) {
|
||||
const msg = nim.V2NIMMessageCreator.createTextMessage(suggestion.text.trim());
|
||||
const msg = nim.V2NIMMessageCreator.createTextMessage(suggestion.text.trim())
|
||||
|
||||
sendMessageActive({
|
||||
msg,
|
||||
conversationId: selectedConversationId || '',
|
||||
});
|
||||
})
|
||||
|
||||
// 通知用户发送了消息,重置自动聊天定时器
|
||||
handleUserMessage();
|
||||
handleUserMessage()
|
||||
|
||||
hideSuggestions();
|
||||
hideSuggestions()
|
||||
// 重置 Textarea 高度
|
||||
resetTextareaHeight();
|
||||
resetTextareaHeight()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex flex-col w-full", className)}>
|
||||
<div className={cn('relative flex w-full flex-col', className)}>
|
||||
{/* AI建议回复面板 */}
|
||||
{suggestionsVisible && (
|
||||
<div className="mb-4 rounded-xl px-16 pt-6">
|
||||
|
|
@ -567,8 +570,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
|
||||
{/* 错误提示 */}
|
||||
{imageError && (
|
||||
<div className="mx-3 mb-2 px-3 py-2 bg-red-500/20 border border-red-500/30 rounded-lg">
|
||||
<p className="text-red-400 text-sm">{imageError.message}</p>
|
||||
<div className="mx-3 mb-2 rounded-lg border border-red-500/30 bg-red-500/20 px-3 py-2">
|
||||
<p className="text-sm text-red-400">{imageError.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -579,10 +582,10 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
</IconButton>
|
||||
|
||||
{/* 输入框容器 */}
|
||||
<div className="flex-1 relative">
|
||||
<div className="relative flex-1">
|
||||
{isRecording ? (
|
||||
/* 录音状态界面 - 按照Figma设计稿样式 */
|
||||
<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]">
|
||||
<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">
|
||||
{/* 左侧语音按钮 */}
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
|
|
@ -594,7 +597,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
</IconButton>
|
||||
|
||||
{/* 中间声波纹 */}
|
||||
<div className="flex-1 flex items-center justify-center px-4">
|
||||
<div className="flex flex-1 items-center justify-center px-4">
|
||||
<VoiceWaveAnimation
|
||||
animated={isListening}
|
||||
barCount={40}
|
||||
|
|
@ -603,20 +606,18 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 右侧删除按钮 */}
|
||||
<IconButton
|
||||
variant="tertiary"
|
||||
size="small"
|
||||
onClick={handleCancelRecording}
|
||||
>
|
||||
<IconButton variant="tertiary" size="small" onClick={handleCancelRecording}>
|
||||
<i className="iconfont icon-close" />
|
||||
</IconButton>
|
||||
</div>
|
||||
) : (
|
||||
/* 正常输入状态界面 */
|
||||
<div className={cn(
|
||||
"relative flex items-end min-h-[48px] bg-surface-element-light-press backdrop-blur-sm rounded-xl p-2",
|
||||
isWaitingForReply && "opacity-60",
|
||||
)}>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-surface-element-light-press relative flex min-h-[48px] items-end rounded-xl p-2 backdrop-blur-sm',
|
||||
isWaitingForReply && 'opacity-60'
|
||||
)}
|
||||
>
|
||||
{/* 语音录制按钮 */}
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
|
|
@ -628,9 +629,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
</IconButton>
|
||||
|
||||
{/* 文本输入 */}
|
||||
<div className={cn(
|
||||
"w-full",
|
||||
)}>
|
||||
<div className={cn('w-full')}>
|
||||
<ChatImagePreview
|
||||
selectedImage={selectedImage}
|
||||
imageUploading={imageUploading}
|
||||
|
|
@ -647,7 +646,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
placeholder={placeholder}
|
||||
// placeholder={isWaitingForReply ? "等待AI回复中..." : placeholder}
|
||||
// disabled={isWaitingForReply}
|
||||
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"
|
||||
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"
|
||||
rows={1}
|
||||
maxLength={500}
|
||||
style={{
|
||||
|
|
@ -661,25 +660,23 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
</div>
|
||||
|
||||
{/* 右侧按钮组 */}
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<div className="ml-2 flex items-center gap-2">
|
||||
{/* 提示词提示按钮 */}
|
||||
<IconButton
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
if (!checkSufficientByType(ChatPriceType.TEXT)) {
|
||||
setIsCoinInsufficient(true);
|
||||
return;
|
||||
setIsCoinInsufficient(true)
|
||||
return
|
||||
}
|
||||
if (suggestionsVisible) {
|
||||
hideSuggestions();
|
||||
hideSuggestions()
|
||||
} else {
|
||||
showSuggestions();
|
||||
showSuggestions()
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
suggestionsVisible && "bg-surface-element-hover"
|
||||
)}
|
||||
className={cn(suggestionsVisible && 'bg-surface-element-hover')}
|
||||
>
|
||||
<i className="iconfont icon-prompt" />
|
||||
</IconButton>
|
||||
|
|
@ -689,19 +686,25 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{(
|
||||
{
|
||||
/* 发送按钮 */
|
||||
<IconButton
|
||||
variant="default"
|
||||
size="large"
|
||||
loading={isProcessingAudio}
|
||||
onClick={() => handleSend()}
|
||||
disabled={!isRecording && ((!message.trim() && !imageInfo) || (imageInfo && !message.trim()) || isWaitingForReply || imageUploading)}
|
||||
disabled={
|
||||
!isRecording &&
|
||||
((!message.trim() && !imageInfo) ||
|
||||
(imageInfo && !message.trim()) ||
|
||||
isWaitingForReply ||
|
||||
imageUploading)
|
||||
}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<i className="iconfont icon-icon-send" />
|
||||
</IconButton>
|
||||
)}
|
||||
}
|
||||
|
||||
{/* 隐藏的文件输入 */}
|
||||
<input
|
||||
|
|
@ -713,7 +716,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
|||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatInput;
|
||||
export default ChatInput
|
||||
|
|
|
|||
|
|
@ -1,24 +1,26 @@
|
|||
import ChatInput from "./ChatInput";
|
||||
|
||||
import ChatInput from './ChatInput'
|
||||
|
||||
const ChatMessageAction = () => {
|
||||
const handleSendMessage = (message: string, images?: File[]) => {
|
||||
console.log('发送消息:', message);
|
||||
console.log('发送消息:', message)
|
||||
if (images && images.length > 0) {
|
||||
console.log('发送图片:', images);
|
||||
console.log('发送图片:', images)
|
||||
}
|
||||
// TODO: 实现发送消息的逻辑
|
||||
};
|
||||
}
|
||||
|
||||
const handleSendVoice = (audioBlob: Blob) => {
|
||||
console.log('发送语音:', audioBlob);
|
||||
console.log('发送语音:', audioBlob)
|
||||
// TODO: 实现发送语音的逻辑
|
||||
};
|
||||
}
|
||||
return (
|
||||
<div className="pb-6">
|
||||
<div className="max-w-[752px] mx-auto flex items-center gap-4 relative">
|
||||
<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%)" }} />
|
||||
<div className="w-full relative">
|
||||
<div className="relative mx-auto flex max-w-[752px] items-center gap-4">
|
||||
<div
|
||||
className="absolute right-0 bottom-0 left-0 h-[120px]"
|
||||
style={{ background: 'linear-gradient(180deg, rgba(33, 26, 43, 0) 0%, #211A2B 40.83%)' }}
|
||||
/>
|
||||
<div className="relative w-full">
|
||||
<ChatInput
|
||||
onSendMessage={handleSendMessage}
|
||||
onSendVoice={handleSendVoice}
|
||||
|
|
@ -27,7 +29,7 @@ const ChatMessageAction = () => {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatMessageAction;
|
||||
export default ChatMessageAction
|
||||
|
|
|
|||
|
|
@ -1,45 +1,42 @@
|
|||
"use client";
|
||||
'use client'
|
||||
|
||||
import { ExtendedMessage } from '@/atoms/im';
|
||||
import { useNimMsgContext } from '@/context/NimChat/useNimChat';
|
||||
import { useVoiceTTS } from '@/hooks/useVoiceTTS';
|
||||
import * as React from 'react';
|
||||
import { useChatConfig } from '../../context/chatConfig';
|
||||
import { LoadingIcon } from '@/components/ui/button';
|
||||
import { WaveAnimation } from '@/components/ui/wave-animation';
|
||||
import { extractTextForVoice, calculateAudioDuration, formatAudioDuration } from '@/utils/textParser';
|
||||
import { ExtendedMessage } from '@/atoms/im'
|
||||
import { useNimMsgContext } from '@/context/NimChat/useNimChat'
|
||||
import { useVoiceTTS } from '@/hooks/useVoiceTTS'
|
||||
import * as React from 'react'
|
||||
import { useChatConfig } from '../../context/chatConfig'
|
||||
import { LoadingIcon } from '@/components/ui/button'
|
||||
import { WaveAnimation } from '@/components/ui/wave-animation'
|
||||
import {
|
||||
extractTextForVoice,
|
||||
calculateAudioDuration,
|
||||
formatAudioDuration,
|
||||
} from '@/utils/textParser'
|
||||
|
||||
const ChatAudioTag = ({
|
||||
message,
|
||||
}: {
|
||||
message: ExtendedMessage;
|
||||
}) => {
|
||||
const { audioPlayer } = useNimMsgContext();
|
||||
const { aiInfo, aiId } = useChatConfig();
|
||||
const { dialoguePitch, dialogueSpeechRate, voiceType } = aiInfo || {};
|
||||
const ChatAudioTag = ({ message }: { message: ExtendedMessage }) => {
|
||||
const { audioPlayer } = useNimMsgContext()
|
||||
const { aiInfo, aiId } = useChatConfig()
|
||||
const { dialoguePitch, dialogueSpeechRate, voiceType } = aiInfo || {}
|
||||
|
||||
const {
|
||||
isGenerating,
|
||||
generateAudioUrl,
|
||||
} = useVoiceTTS({
|
||||
const { isGenerating, generateAudioUrl } = useVoiceTTS({
|
||||
cacheEnabled: true,
|
||||
needCheckSufficient: true,
|
||||
});
|
||||
})
|
||||
|
||||
const audioText = extractTextForVoice(message.text || '');
|
||||
const audioText = extractTextForVoice(message.text || '')
|
||||
|
||||
// 计算预估的音频时长
|
||||
const estimatedDuration = React.useMemo(() => {
|
||||
return calculateAudioDuration(
|
||||
message.text || '',
|
||||
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 audioUrl = await generateAudioUrl({
|
||||
|
|
@ -48,22 +45,22 @@ const ChatAudioTag = ({
|
|||
speechRate: dialogueSpeechRate,
|
||||
pitchRate: dialoguePitch,
|
||||
aiId: aiId,
|
||||
});
|
||||
})
|
||||
if (!audioUrl) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
audioPlayer?.play(message.messageClientId, audioUrl);
|
||||
audioPlayer?.play(message.messageClientId, audioUrl)
|
||||
}
|
||||
|
||||
// const duration = message.duration;
|
||||
|
||||
const renderIcon = () => {
|
||||
if (isGenerating) {
|
||||
return <LoadingIcon className='!text-[16px] leading-none' />;
|
||||
return <LoadingIcon className="!text-[16px] leading-none" />
|
||||
}
|
||||
|
||||
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" />
|
||||
|
|
@ -76,18 +73,18 @@ const ChatAudioTag = ({
|
|||
// }
|
||||
|
||||
if (!audioText) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
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"
|
||||
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"
|
||||
onClick={handlePlay}
|
||||
>
|
||||
<div className="h-4 w-4 flex items-center">{renderIcon()}</div>
|
||||
<div className="flex h-4 w-4 items-center">{renderIcon()}</div>
|
||||
<span className="txt-label-s">{formattedDuration}</span>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatAudioTag;
|
||||
export default ChatAudioTag
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
"use client"
|
||||
'use client'
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ReactNode } from "react";
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ReactNode } from 'react'
|
||||
|
||||
interface ChatBubbleProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
isDefault?: boolean;
|
||||
img?: string;
|
||||
children: ReactNode
|
||||
className?: string
|
||||
isDefault?: boolean
|
||||
img?: string
|
||||
}
|
||||
|
||||
const ChatBubble = ({ children, isDefault, img, className }: ChatBubbleProps) => {
|
||||
|
|
@ -15,15 +15,13 @@ const ChatBubble = ({ children, isDefault, img, className }: ChatBubbleProps) =>
|
|||
<div className="relative max-w-[496px] p-4">
|
||||
<div
|
||||
className={cn(
|
||||
isDefault && [
|
||||
"absolute inset-0 bg-primary-normal backdrop-blur-[32px] rounded-lg",
|
||||
],
|
||||
isDefault && ['bg-primary-normal absolute inset-0 rounded-lg backdrop-blur-[32px]'],
|
||||
!isDefault && [
|
||||
"absolute -inset-2",
|
||||
"border-[30px] border-transparent",
|
||||
"[border-image-slice:70_fill]",
|
||||
"[border-image-width:30px]",
|
||||
"[border-image-repeat:stretch]"
|
||||
'absolute -inset-2',
|
||||
'border-[30px] border-transparent',
|
||||
'[border-image-slice:70_fill]',
|
||||
'[border-image-width:30px]',
|
||||
'[border-image-repeat:stretch]',
|
||||
],
|
||||
className
|
||||
)}
|
||||
|
|
@ -31,14 +29,10 @@ const ChatBubble = ({ children, isDefault, img, className }: ChatBubbleProps) =>
|
|||
borderImageSource: isDefault ? 'none' : `url(${img || ''})`,
|
||||
// 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>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export default ChatBubble;
|
||||
export default ChatBubble
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue