feat(P01): 迁移 Taro 小程序项目代码
- 迁移前端源码 (src/) - 迁移后端服务 (server/) - 迁移配置文件 (package.json, tsconfig.json 等) - 更新需求概要文档 - 更新架构设计文档 - 更新接口定义文档 - 更新环境配置文档 - 创建测试目录结构和配置 项目技术栈: - Taro 4.1.9 (跨端框架) - React 18 - TypeScript - NestJS (后端) - Tailwind CSS 4 - shadcn/ui 组件库
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "Dev AI",
|
||||
"role": "代码开发者",
|
||||
"responsibilities": [
|
||||
"编写业务代码",
|
||||
"生成技术文档",
|
||||
"维护代码质量"
|
||||
],
|
||||
"allowed_paths": [
|
||||
"projects/*/src/",
|
||||
"projects/*/docs/",
|
||||
"shared/",
|
||||
"review/*/task.md",
|
||||
"review/*/acceptance.md",
|
||||
"review/*/impact.md"
|
||||
],
|
||||
"forbidden_paths": [
|
||||
"projects/*/tests/",
|
||||
"reports/",
|
||||
"review/*/feedback/"
|
||||
],
|
||||
"prompt_templates": {
|
||||
"coding": ".ai/prompts/coding/",
|
||||
"documentation": ".ai/prompts/coding/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "QA AI",
|
||||
"role": "测试工程师",
|
||||
"responsibilities": [
|
||||
"编写测试用例",
|
||||
"执行测试",
|
||||
"生成测试报告",
|
||||
"提供反馈"
|
||||
],
|
||||
"allowed_paths": [
|
||||
"projects/*/tests/",
|
||||
"reports/",
|
||||
"review/*/acceptance.md",
|
||||
"review/*/feedback/"
|
||||
],
|
||||
"forbidden_paths": [
|
||||
"projects/*/src/",
|
||||
"projects/*/docs/",
|
||||
"shared/",
|
||||
"review/*/task.md",
|
||||
"review/*/impact.md"
|
||||
],
|
||||
"prompt_templates": {
|
||||
"testing": ".ai/prompts/testing/"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"workflow": "human-ai-collaboration",
|
||||
"roles": ["human", "dev-ai", "qa-ai"],
|
||||
"stages": [
|
||||
{
|
||||
"name": "需求分析",
|
||||
"actor": "human",
|
||||
"output": "review/{task_id}/task.md"
|
||||
},
|
||||
{
|
||||
"name": "开发实现",
|
||||
"actor": "dev-ai",
|
||||
"input": "review/{task_id}/task.md",
|
||||
"output": ["projects/*/src/", "projects/*/docs/"]
|
||||
},
|
||||
{
|
||||
"name": "影响评估",
|
||||
"actor": "dev-ai",
|
||||
"output": "review/{task_id}/impact.md"
|
||||
},
|
||||
{
|
||||
"name": "验收标准定义",
|
||||
"actor": "dev-ai",
|
||||
"output": "review/{task_id}/acceptance.md"
|
||||
},
|
||||
{
|
||||
"name": "测试设计",
|
||||
"actor": "qa-ai",
|
||||
"input": ["review/{task_id}/task.md", "review/{task_id}/acceptance.md"],
|
||||
"output": "projects/*/tests/"
|
||||
},
|
||||
{
|
||||
"name": "测试执行",
|
||||
"actor": "qa-ai",
|
||||
"output": ["reports/test-results/", "reports/reviews/"]
|
||||
},
|
||||
{
|
||||
"name": "反馈提交",
|
||||
"actor": "qa-ai",
|
||||
"output": "review/{task_id}/feedback/round{round}.md"
|
||||
},
|
||||
{
|
||||
"name": "验收确认",
|
||||
"actor": "human",
|
||||
"input": ["review/{task_id}/feedback/", "reports/test-results/"]
|
||||
}
|
||||
],
|
||||
"ci_triggers": {
|
||||
"on_push_to_main": ["run-tests", "generate-reports"],
|
||||
"on_pr_open": ["run-tests", "code-review"],
|
||||
"on_task_update": ["notify-qa-ai"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# coding
|
||||
@@ -0,0 +1 @@
|
||||
# testing
|
||||
@@ -0,0 +1,188 @@
|
||||
# AI 角色定义与权限约定
|
||||
|
||||
## 团队架构
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ 人类负责人 │
|
||||
│ 需求分析 · 架构设计 · 最终决策 │
|
||||
└───────────────────┬─────────────────────────┘
|
||||
│
|
||||
┌───────────┴───────────┐
|
||||
▼ ▼
|
||||
┌───────────────┐ ┌───────────────┐
|
||||
│ Dev AI │ │ QA AI │
|
||||
│ 代码编写 │ │ 测试设计 │
|
||||
│ 文档生成 │ │ 测试执行 │
|
||||
│ 影响评估 │ │ 质量保障 │
|
||||
└───────────────┘ └───────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 角色职责
|
||||
|
||||
### Dev AI (编码AI)
|
||||
**职责范围:**
|
||||
- ✅ 编写业务代码 (`projects/*/src/`)
|
||||
- ✅ 生成技术文档 (`projects/*/docs/`)
|
||||
- ✅ 定义验收标准 (`review/*/acceptance.md`)
|
||||
- ✅ 评估变更影响 (`review/*/impact.md`)
|
||||
- ✅ 维护共享资源 (`shared/`)
|
||||
|
||||
**禁止操作:**
|
||||
- ❌ 修改测试代码 (`projects/*/tests/`)
|
||||
- ❌ 修改测试报告 (`reports/`)
|
||||
- ❌ 提交测试反馈 (`review/*/feedback/`)
|
||||
|
||||
### QA AI (测试AI)
|
||||
**职责范围:**
|
||||
- ✅ 编写测试用例 (`projects/*/tests/`)
|
||||
- ✅ 执行测试并生成报告 (`reports/`)
|
||||
- ✅ 补充验收标准 (`review/*/acceptance.md`)
|
||||
- ✅ 提交测试反馈 (`review/*/feedback/`)
|
||||
|
||||
**禁止操作:**
|
||||
- ❌ 修改业务代码 (`projects/*/src/`)
|
||||
- ❌ 修改技术文档 (`projects/*/docs/`)
|
||||
- ❌ 修改共享资源 (`shared/`)
|
||||
- ❌ 修改任务描述和影响评估
|
||||
|
||||
### 人类负责人
|
||||
**职责范围:**
|
||||
- ✅ 可以修改所有目录
|
||||
- ✅ 审核 AI 输出质量
|
||||
- ✅ 解决 AI 之间的冲突
|
||||
- ✅ 最终决策和验收
|
||||
|
||||
---
|
||||
|
||||
## 工作流程
|
||||
|
||||
```
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ 需求分析 │ ──→ │ 开发实现 │ ──→ │ 影响评估 │
|
||||
│ (人类) │ │ (Dev AI) │ │ (Dev AI) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
|
||||
│ 验收标准 │ ──→ │ 测试设计 │ ──→ │ 测试执行 │
|
||||
│ (Dev AI) │ │ (QA AI) │ │ (QA AI) │
|
||||
└─────────────┘ └─────────────┘ └─────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────┐ ┌─────────────┐
|
||||
│ 反馈提交 │ ──→ │ 验收确认 │
|
||||
│ (QA AI) │ │ (人类) │
|
||||
└─────────────┘ └─────────────┘
|
||||
```
|
||||
|
||||
### 详细流程说明
|
||||
|
||||
**1. 需求分析阶段**
|
||||
- 人类负责人创建任务单
|
||||
- 输出: `review/{task_id}/task.md`
|
||||
|
||||
**2. 开发实现阶段**
|
||||
- Dev AI 根据任务描述编写代码
|
||||
- 输出: `projects/*/src/`, `projects/*/docs/`
|
||||
|
||||
**3. 影响评估阶段**
|
||||
- Dev AI 分析变更影响范围
|
||||
- 输出: `review/{task_id}/impact.md`
|
||||
|
||||
**4. 验收标准定义**
|
||||
- Dev AI 定义验收标准
|
||||
- QA AI 可补充测试要点
|
||||
- 输出: `review/{task_id}/acceptance.md`
|
||||
|
||||
**5. 测试设计阶段**
|
||||
- QA AI 根据验收标准编写测试用例
|
||||
- 输出: `projects/*/tests/`
|
||||
|
||||
**6. 测试执行阶段**
|
||||
- QA AI 执行测试并生成报告
|
||||
- 输出: `reports/test-results/`, `reports/quality-reports/`
|
||||
|
||||
**7. 反馈提交阶段**
|
||||
- QA AI 提交测试反馈
|
||||
- 输出: `review/{task_id}/feedback/round{round}.md`
|
||||
|
||||
**8. 验收确认阶段**
|
||||
- 人类负责人审核测试结果
|
||||
- 确认任务完成或返回修改
|
||||
|
||||
---
|
||||
|
||||
## 目录权限矩阵
|
||||
|
||||
| 目录路径 | Dev AI | QA AI | 人类 |
|
||||
|---------|--------|-------|------|
|
||||
| `.ai/` | ❌ | ❌ | ✅ |
|
||||
| `shared/` | ✅ | ❌ | ✅ |
|
||||
| `projects/*/src/` | ✅ | ❌ | ✅ |
|
||||
| `projects/*/tests/` | ❌ | ✅ | ✅ |
|
||||
| `projects/*/docs/` | ✅ | ❌ | ✅ |
|
||||
| `review/*/task.md` | ❌ | ❌ | ✅ |
|
||||
| `review/*/acceptance.md` | ✅ | ✅ | ✅ |
|
||||
| `review/*/impact.md` | ✅ | ❌ | ✅ |
|
||||
| `review/*/feedback/` | ❌ | ✅ | ✅ |
|
||||
| `reports/` | ❌ | ✅ | ✅ |
|
||||
| `.github/` | ❌ | ❌ | ✅ |
|
||||
|
||||
---
|
||||
|
||||
## 沟通规范
|
||||
|
||||
### Dev AI → QA AI
|
||||
在 `review/{task_id}/` 目录提交:
|
||||
- **验收标准** (`acceptance.md`) - 明确测试目标
|
||||
- **变更影响范围** (`impact.md`) - 指导回归测试
|
||||
- **环境准备** 参考项目级 `ENVIRONMENT.md`
|
||||
|
||||
### QA AI → Dev AI
|
||||
在 `review/{task_id}/feedback/` 目录提交:
|
||||
- **测试结果报告** (`round{round}.md`)
|
||||
- **Bug清单** - 列出问题和严重程度
|
||||
- **改进建议** - 代码优化建议
|
||||
|
||||
---
|
||||
|
||||
## 命名规范
|
||||
|
||||
### 项目命名
|
||||
```
|
||||
P01_项目名称 # P01 表示项目编号
|
||||
```
|
||||
|
||||
### 任务编号
|
||||
```
|
||||
P01-001 # P01 项目编号 + 001 任务编号
|
||||
```
|
||||
|
||||
### 分支命名
|
||||
```
|
||||
feature/P01-001-login # 功能开发
|
||||
bugfix/P01-001-password # Bug修复
|
||||
test/P01-001-testcases # 测试用例
|
||||
```
|
||||
|
||||
### 提交信息
|
||||
```
|
||||
feat(P01-001): 实现用户登录功能
|
||||
fix(P01-001): 修复密码验证问题
|
||||
docs(P01-001): 更新接口文档
|
||||
test(P01-001): 添加登录测试用例
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI 配置文件说明
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `.ai/config/coder.json` | Dev AI 配置(权限、职责) |
|
||||
| `.ai/config/tester.json` | QA AI 配置(权限、职责) |
|
||||
| `.ai/config/workflow.json` | 工作流配置(阶段、触发器) |
|
||||
| `.ai/prompts/coding/` | 编码提示词模板 |
|
||||
| `.ai/prompts/testing/` | 测试提示词模板 |
|
||||
@@ -0,0 +1,41 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
node-compile-cache/
|
||||
|
||||
# Production
|
||||
dist/
|
||||
build/
|
||||
dist-*/
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.env
|
||||
# .env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
|
||||
# Logs
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
|
||||
# Editor directories and files
|
||||
.idea/
|
||||
.vscode/
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# Taro specific
|
||||
.taro/
|
||||
|
||||
# OS X
|
||||
.DS_Store
|
||||
|
||||
# Key
|
||||
# key/
|
||||
@@ -0,0 +1,21 @@
|
||||
loglevel=error
|
||||
registry=https://registry.npmmirror.com
|
||||
|
||||
strictStorePkgContentCheck=false
|
||||
verifyStoreIntegrity=false
|
||||
|
||||
network-concurrency=16
|
||||
fetch-retries=3
|
||||
fetch-timeout=60000
|
||||
|
||||
strict-peer-dependencies=false
|
||||
|
||||
auto-install-peers=true
|
||||
|
||||
lockfile=true
|
||||
prefer-frozen-lockfile=true
|
||||
|
||||
resolution-mode=highest
|
||||
|
||||
# 不提示 npm 更新
|
||||
update-notifier=false
|
||||
@@ -1,37 +1,233 @@
|
||||
# P01_errlens_app - 环境准备指南
|
||||
|
||||
## 依赖要求
|
||||
- Node.js >= 20.x
|
||||
- npm >= 10.x
|
||||
- 数据库: PostgreSQL 15+
|
||||
## 项目信息
|
||||
|
||||
- **项目名称**: ErrLens 小程序应用
|
||||
- **技术栈**: Taro 4 + React 18 + NestJS
|
||||
- **支持平台**: 微信小程序、抖音小程序、H5
|
||||
|
||||
---
|
||||
|
||||
## 环境要求
|
||||
|
||||
### 基础环境
|
||||
|
||||
| 依赖项 | 版本要求 | 说明 |
|
||||
|--------|---------|------|
|
||||
| Node.js | >= 20.x | 推荐使用 nvm 管理 |
|
||||
| pnpm | >= 9.0.0 | 包管理器 |
|
||||
| Git | 最新版 | 代码版本控制 |
|
||||
|
||||
### 数据库(后端服务)
|
||||
|
||||
| 依赖项 | 版本要求 | 说明 |
|
||||
|--------|---------|------|
|
||||
| PostgreSQL | 15+ | 主数据库 |
|
||||
| Supabase | 可选 | 云端数据库替代方案 |
|
||||
|
||||
### 开发工具(可选)
|
||||
|
||||
| 工具 | 说明 |
|
||||
|------|------|
|
||||
| VSCode | 推荐编辑器 |
|
||||
| 微信开发者工具 | 微信小程序调试 |
|
||||
| 抖音开发者工具 | 抖音小程序调试 |
|
||||
|
||||
---
|
||||
|
||||
## 安装步骤
|
||||
|
||||
### 1. 克隆项目
|
||||
|
||||
```bash
|
||||
# 安装依赖
|
||||
npm install
|
||||
|
||||
# 配置环境变量
|
||||
cp .env.example .env
|
||||
|
||||
# 初始化数据库
|
||||
npm run db:migrate
|
||||
git clone <repository-url>
|
||||
cd errlens-project/projects/P01_errlens_app
|
||||
```
|
||||
|
||||
## 环境变量
|
||||
| 变量名 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| PORT | 服务端口 | 3000 |
|
||||
| DATABASE_URL | 数据库连接 | postgresql://localhost:5432/errlens |
|
||||
| NODE_ENV | 运行环境 | development |
|
||||
### 2. 安装前端依赖
|
||||
|
||||
## 运行命令
|
||||
```bash
|
||||
# 开发模式
|
||||
npm run dev
|
||||
# 使用 pnpm(推荐)
|
||||
pnpm install
|
||||
|
||||
# 生产构建
|
||||
npm run build
|
||||
# 或使用 npm
|
||||
npm install
|
||||
```
|
||||
|
||||
# 运行测试
|
||||
npm test
|
||||
```
|
||||
### 3. 安装后端依赖
|
||||
|
||||
```bash
|
||||
cd server
|
||||
pnpm install
|
||||
cd ..
|
||||
```
|
||||
|
||||
### 4. 配置环境变量
|
||||
|
||||
```bash
|
||||
# 复制环境变量模板
|
||||
cp .env.example .env
|
||||
|
||||
# 编辑 .env 文件,配置以下变量
|
||||
```
|
||||
|
||||
**前端环境变量 (`.env`)**:
|
||||
```bash
|
||||
# 项目域名(可选,用于生产环境)
|
||||
PROJECT_DOMAIN=
|
||||
|
||||
# API 基础路径
|
||||
API_BASE_URL=/api
|
||||
```
|
||||
|
||||
**后端环境变量 (`server/.env`)**:
|
||||
```bash
|
||||
# 服务端口
|
||||
PORT=3000
|
||||
|
||||
# 数据库连接
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/errlens
|
||||
|
||||
# Supabase(可选)
|
||||
SUPABASE_URL=
|
||||
SUPABASE_KEY=
|
||||
|
||||
# JWT 密钥
|
||||
JWT_SECRET=your-secret-key
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 开发命令
|
||||
|
||||
### 前端开发
|
||||
|
||||
```bash
|
||||
# 启动前端开发服务器(H5)
|
||||
pnpm dev:web
|
||||
|
||||
# 启动微信小程序开发模式
|
||||
pnpm dev:weapp
|
||||
|
||||
# 启动抖音小程序开发模式
|
||||
pnpm dev:tt
|
||||
```
|
||||
|
||||
### 后端开发
|
||||
|
||||
```bash
|
||||
# 启动后端开发服务器
|
||||
pnpm dev:server
|
||||
|
||||
# 或
|
||||
cd server
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 完整开发模式
|
||||
|
||||
```bash
|
||||
# 同时启动前端和后端
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 构建命令
|
||||
|
||||
```bash
|
||||
# 构建所有平台
|
||||
pnpm build
|
||||
|
||||
# 仅构建前端
|
||||
pnpm build:web
|
||||
pnpm build:weapp
|
||||
pnpm build:tt
|
||||
|
||||
# 仅构建后端
|
||||
pnpm build:server
|
||||
```
|
||||
|
||||
### 代码检查
|
||||
|
||||
```bash
|
||||
# 运行 ESLint
|
||||
pnpm lint
|
||||
|
||||
# 运行 TypeScript 检查
|
||||
pnpm tsc
|
||||
|
||||
# 运行完整验证
|
||||
pnpm validate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
### 开发环境
|
||||
|
||||
```bash
|
||||
cd server
|
||||
|
||||
# 运行迁移
|
||||
pnpm drizzle-kit migrate
|
||||
|
||||
# 或生成 SQL
|
||||
pnpm drizzle-kit generate
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
P01_errlens_app/
|
||||
├── src/ # 前端源码
|
||||
│ ├── components/ # UI 组件
|
||||
│ ├── pages/ # 页面
|
||||
│ ├── lib/ # 工具库
|
||||
│ └── network.ts # API 封装
|
||||
│
|
||||
├── server/ # 后端源码 (NestJS)
|
||||
│ └── src/
|
||||
│
|
||||
├── tests/ # 测试代码
|
||||
│ ├── unit/ # 单元测试
|
||||
│ ├── integration/ # 集成测试
|
||||
│ └── e2e/ # E2E 测试
|
||||
│
|
||||
├── docs/ # 项目文档
|
||||
│ ├── 01_需求概要.md
|
||||
│ ├── 02_架构设计.md
|
||||
│ └── 03_接口定义.md
|
||||
│
|
||||
└── config/ # 构建配置
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: pnpm install 失败?
|
||||
A: 确保 Node.js >= 20.x,pnpm >= 9.0.0
|
||||
|
||||
### Q: H5 开发正常,但小程序报错?
|
||||
A: 检查是否使用了不支持的 API 或组件,参考跨端兼容性文档
|
||||
|
||||
### Q: 后端服务启动失败?
|
||||
A: 检查 PostgreSQL 是否运行,环境变量是否正确配置
|
||||
|
||||
---
|
||||
|
||||
## 端口说明
|
||||
|
||||
| 服务 | 端口 | 说明 |
|
||||
|------|------|------|
|
||||
| 前端 H5 | 5000 | 开发服务器 |
|
||||
| 后端 API | 3000 | NestJS 服务 |
|
||||
| 微信开发者工具 | - | 自动加载 |
|
||||
| 抖音开发者工具 | - | 自动加载 |
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0.0
|
||||
**最后更新**:2026-05-22
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// babel-preset-taro 更多选项和默认值:
|
||||
// https://docs.taro.zone/docs/next/babel-config
|
||||
module.exports = {
|
||||
presets: [
|
||||
['taro', {
|
||||
framework: 'react',
|
||||
ts: true,
|
||||
compiler: 'vite',
|
||||
useBuiltIns: false
|
||||
}]
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { UserConfigExport } from "@tarojs/cli"
|
||||
|
||||
export default {
|
||||
|
||||
mini: {
|
||||
debugReact: true,
|
||||
},
|
||||
h5: {}
|
||||
} satisfies UserConfigExport<'vite'>
|
||||
@@ -0,0 +1,238 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import tailwindcss from '@tailwindcss/postcss';
|
||||
import { UnifiedViteWeappTailwindcssPlugin } from 'weapp-tailwindcss/vite';
|
||||
import { defineConfig, type UserConfigExport } from '@tarojs/cli';
|
||||
import type { PluginItem } from '@tarojs/taro/types/compile/config/project';
|
||||
import dotenv from 'dotenv';
|
||||
import devConfig from './dev';
|
||||
import prodConfig from './prod';
|
||||
import pkg from '../package.json';
|
||||
|
||||
dotenv.config({ path: path.resolve(__dirname, '../.env.local') });
|
||||
|
||||
const generateTTProjectConfig = (outputRoot: string) => {
|
||||
const config = {
|
||||
miniprogramRoot: './',
|
||||
projectname: 'coze-mini-program',
|
||||
appid: process.env.TARO_APP_TT_APPID || '',
|
||||
setting: {
|
||||
urlCheck: false,
|
||||
es6: false,
|
||||
postcss: false,
|
||||
minified: false,
|
||||
},
|
||||
};
|
||||
const outputDir = path.resolve(__dirname, '..', outputRoot);
|
||||
if (!fs.existsSync(outputDir)) {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.resolve(outputDir, 'project.config.json'),
|
||||
JSON.stringify(config, null, 2),
|
||||
);
|
||||
};
|
||||
|
||||
// https://taro-docs.jd.com/docs/next/config#defineconfig-辅助函数
|
||||
export default defineConfig<'vite'>(async (merge, _env) => {
|
||||
const outputRootMap: Record<string, string> = {
|
||||
weapp: 'dist',
|
||||
tt: 'dist-tt',
|
||||
h5: 'dist-web',
|
||||
};
|
||||
const defaultOutputRoot = outputRootMap[process.env.TARO_ENV || ''] || 'dist';
|
||||
const outputRoot = process.env.OUTPUT_ROOT?.trim() || defaultOutputRoot;
|
||||
const isH5 = process.env.TARO_ENV === 'h5';
|
||||
|
||||
const buildMiniCIPluginConfig = () => {
|
||||
const hasWeappConfig = !!process.env.TARO_APP_WEAPP_APPID;
|
||||
const hasTTConfig = !!process.env.TARO_APP_TT_EMAIL;
|
||||
if (!hasWeappConfig && !hasTTConfig) {
|
||||
return [];
|
||||
}
|
||||
const miniCIConfig: Record<string, any> = {
|
||||
version: pkg.version,
|
||||
desc: pkg.description,
|
||||
};
|
||||
if (hasWeappConfig) {
|
||||
miniCIConfig.weapp = {
|
||||
appid: process.env.TARO_APP_WEAPP_APPID,
|
||||
privateKeyPath: 'key/private.appid.key',
|
||||
};
|
||||
}
|
||||
if (hasTTConfig) {
|
||||
miniCIConfig.tt = {
|
||||
email: process.env.TARO_APP_TT_EMAIL,
|
||||
password: process.env.TARO_APP_TT_PASSWORD,
|
||||
setting: {
|
||||
skipDomainCheck: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
return [['@tarojs/plugin-mini-ci', miniCIConfig]] as PluginItem[];
|
||||
};
|
||||
|
||||
const baseConfig: UserConfigExport<'vite'> = {
|
||||
projectName: 'coze-mini-program',
|
||||
date: '2026-1-13',
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '..', 'src'),
|
||||
},
|
||||
designWidth: 750,
|
||||
deviceRatio: {
|
||||
640: 2.34 / 2,
|
||||
750: 1,
|
||||
375: 2,
|
||||
828: 1.81 / 2,
|
||||
},
|
||||
sourceRoot: 'src',
|
||||
outputRoot,
|
||||
plugins: ['@tarojs/plugin-generator', ...buildMiniCIPluginConfig()],
|
||||
defineConstants: {
|
||||
PROJECT_DOMAIN: JSON.stringify(
|
||||
process.env.PROJECT_DOMAIN ||
|
||||
process.env.COZE_PROJECT_DOMAIN_DEFAULT ||
|
||||
'',
|
||||
),
|
||||
TARO_ENV: JSON.stringify(process.env.TARO_ENV),
|
||||
},
|
||||
copy: {
|
||||
patterns: [],
|
||||
options: {},
|
||||
},
|
||||
...(process.env.TARO_ENV === 'tt' && {
|
||||
tt: {
|
||||
appid: process.env.TARO_APP_TT_APPID,
|
||||
projectName: 'coze-mini-program',
|
||||
},
|
||||
}),
|
||||
jsMinimizer: 'esbuild',
|
||||
framework: 'react',
|
||||
compiler: {
|
||||
type: 'vite',
|
||||
vitePlugins: [
|
||||
{
|
||||
name: 'postcss-config-loader-plugin',
|
||||
config(config) {
|
||||
// 通过 postcss 配置注册 tailwindcss 插件
|
||||
if (typeof config.css?.postcss === 'object') {
|
||||
config.css?.postcss.plugins?.unshift(tailwindcss());
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'hmr-config-plugin',
|
||||
config() {
|
||||
if (!process.env.PROJECT_DOMAIN) {
|
||||
return;
|
||||
}
|
||||
return {
|
||||
server: {
|
||||
hmr: {
|
||||
overlay: true,
|
||||
path: '/hot/vite-hmr',
|
||||
port: 6000,
|
||||
clientPort: 443,
|
||||
timeout: 30000,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
...(isH5
|
||||
? []
|
||||
: [
|
||||
UnifiedViteWeappTailwindcssPlugin({
|
||||
rem2rpx: true,
|
||||
cssEntries: [path.resolve(__dirname, '../src/app.css')],
|
||||
}),
|
||||
]),
|
||||
...(process.env.TARO_ENV === 'tt'
|
||||
? [
|
||||
{
|
||||
name: 'generate-tt-project-config',
|
||||
closeBundle() {
|
||||
generateTTProjectConfig(outputRoot);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
mini: {
|
||||
postcss: {
|
||||
pxtransform: {
|
||||
enable: true,
|
||||
config: {},
|
||||
},
|
||||
cssModules: {
|
||||
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
|
||||
config: {
|
||||
namingPattern: 'module', // 转换模式,取值为 global/module
|
||||
generateScopedName: '[name]__[local]___[hash:base64:5]',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
h5: {
|
||||
publicPath: './',
|
||||
staticDirectory: 'static',
|
||||
router: {
|
||||
mode: 'hash',
|
||||
},
|
||||
devServer: {
|
||||
port: 5000,
|
||||
host: '0.0.0.0',
|
||||
open: false,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
miniCssExtractPluginOption: {
|
||||
ignoreOrder: true,
|
||||
filename: 'css/[name].[hash].css',
|
||||
chunkFilename: 'css/[name].[chunkhash].css',
|
||||
},
|
||||
postcss: {
|
||||
autoprefixer: {
|
||||
enable: true,
|
||||
config: {},
|
||||
},
|
||||
pxtransform: {
|
||||
enable: true,
|
||||
config: {
|
||||
platform: 'h5',
|
||||
},
|
||||
},
|
||||
cssModules: {
|
||||
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
|
||||
config: {
|
||||
namingPattern: 'module', // 转换模式,取值为 global/module
|
||||
generateScopedName: '[name]__[local]___[hash:base64:5]',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
rn: {
|
||||
appName: 'coze-mini-program',
|
||||
postcss: {
|
||||
cssModules: {
|
||||
enable: false, // 默认为 false,如需使用 css modules 功能,则设为 true
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
process.env.BROWSERSLIST_ENV = process.env.NODE_ENV;
|
||||
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
// 本地开发构建配置(不混淆压缩)
|
||||
return merge({}, baseConfig, devConfig);
|
||||
}
|
||||
// 生产构建配置(默认开启压缩混淆等)
|
||||
return merge({}, baseConfig, prodConfig);
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { UserConfigExport } from '@tarojs/cli';
|
||||
|
||||
export default {
|
||||
mini: {},
|
||||
h5: {
|
||||
legacy: false,
|
||||
/**
|
||||
* WebpackChain 插件配置
|
||||
* @docs https://github.com/neutrinojs/webpack-chain
|
||||
*/
|
||||
// webpackChain (chain) {
|
||||
// /**
|
||||
// * 如果 h5 端编译后体积过大,可以使用 webpack-bundle-analyzer 插件对打包体积进行分析。
|
||||
// * @docs https://github.com/webpack-contrib/webpack-bundle-analyzer
|
||||
// */
|
||||
// chain.plugin('analyzer')
|
||||
// .use(require('webpack-bundle-analyzer').BundleAnalyzerPlugin, [])
|
||||
// /**
|
||||
// * 如果 h5 端首屏加载时间过长,可以使用 prerender-spa-plugin 插件预加载首页。
|
||||
// * @docs https://github.com/chrisvfritz/prerender-spa-plugin
|
||||
// */
|
||||
// const path = require('path')
|
||||
// const Prerender = require('prerender-spa-plugin')
|
||||
// const staticDir = path.join(__dirname, '..', 'dist')
|
||||
// chain
|
||||
// .plugin('prerender')
|
||||
// .use(new Prerender({
|
||||
// staticDir,
|
||||
// routes: [ '/pages/index/index' ],
|
||||
// postProcess: (context) => ({ ...context, outputPath: path.join(staticDir, 'index.html') })
|
||||
// }))
|
||||
// }
|
||||
},
|
||||
} satisfies UserConfigExport<'vite'>;
|
||||
@@ -1,21 +1,108 @@
|
||||
# P01_errlens_app - 需求概要
|
||||
|
||||
## 项目概述
|
||||
ErrLens 是一个 AI 辅助编程工具,旨在帮助开发者快速定位和修复代码错误。
|
||||
|
||||
## 核心功能
|
||||
1. **错误检测** - 实时分析代码中的潜在问题
|
||||
2. **智能修复** - 自动生成修复建议
|
||||
3. **代码审查** - 提供代码质量评估
|
||||
4. **学习建议** - 根据错误类型提供学习资源
|
||||
ErrLens 小程序应用是一个基于 **Taro 4 框架**开发的多端小程序项目,支持微信小程序、抖音小程序和 H5 平台。
|
||||
|
||||
## 用户角色
|
||||
- 初级开发者:学习编程时获得实时反馈
|
||||
- 中级开发者:提高代码质量和效率
|
||||
- 团队负责人:监控团队代码质量
|
||||
## 项目定位
|
||||
|
||||
- **产品类型**:AI 辅助编程工具的移动端入口
|
||||
- **目标用户**:开发者、编程学习者、代码审查人员
|
||||
- **核心价值**:随时随地访问 ErrLens 的代码错误检测和修复建议功能
|
||||
|
||||
## 技术栈
|
||||
- 前端:React + TypeScript
|
||||
- 后端:Node.js + Express
|
||||
- 数据库:PostgreSQL
|
||||
- AI 服务:自定义模型 + OpenAI API
|
||||
|
||||
### 前端框架
|
||||
| 技术 | 版本 | 说明 |
|
||||
|------|------|------|
|
||||
| Taro | 4.1.9 | 跨端开发框架 |
|
||||
| React | 18.x | UI 框架 |
|
||||
| TypeScript | 5.x | 类型安全 |
|
||||
| Tailwind CSS | 4.x | 原子化样式 |
|
||||
| Zustand | 5.x | 状态管理 |
|
||||
|
||||
### 后端框架
|
||||
| 技术 | 版本 | 说明 |
|
||||
|------|------|------|
|
||||
| NestJS | 10.x | Node.js 服务端框架 |
|
||||
| Express | 5.x | HTTP 服务器 |
|
||||
| PostgreSQL | 15+ | 关系数据库 |
|
||||
| Drizzle ORM | 0.45.x | ORM 工具 |
|
||||
|
||||
### 集成服务
|
||||
| 服务 | 说明 |
|
||||
|------|------|
|
||||
| Supabase | 数据库连接 |
|
||||
| S3 兼容存储 | 文件存储 |
|
||||
| Coze SDK | AI 能力集成 |
|
||||
|
||||
## 核心功能模块
|
||||
|
||||
### 1. 首页模块
|
||||
- [ ] 欢迎页面展示
|
||||
- [ ] 功能快捷入口
|
||||
- [ ] 最新动态/公告
|
||||
|
||||
### 2. 代码分析模块
|
||||
- [ ] 代码上传/粘贴
|
||||
- [ ] 错误检测结果展示
|
||||
- [ ] 修复建议生成
|
||||
|
||||
### 3. 用户模块
|
||||
- [ ] 用户登录/注册
|
||||
- [ ] 个人中心
|
||||
- [ ] 历史记录
|
||||
|
||||
### 4. 设置模块
|
||||
- [ ] 主题切换
|
||||
- [ ] 通知设置
|
||||
- [ ] 关于我们
|
||||
|
||||
## 页面结构
|
||||
|
||||
```
|
||||
pages/
|
||||
├── index/ # 首页
|
||||
├── analyze/ # 代码分析
|
||||
├── history/ # 历史记录
|
||||
├── profile/ # 个人中心
|
||||
└── settings/ # 设置页面
|
||||
```
|
||||
|
||||
## 组件库
|
||||
|
||||
项目使用 **Taro 版 shadcn/ui** 组件库,位于 `src/components/ui/`:
|
||||
|
||||
| 组件类型 | 示例组件 |
|
||||
|---------|---------|
|
||||
| 基础组件 | Button, Input, Textarea |
|
||||
| 布局组件 | Card, Dialog, Drawer, Sheet |
|
||||
| 数据展示 | Table, Badge, Avatar |
|
||||
| 导航组件 | Tabs, Breadcrumb |
|
||||
| 反馈组件 | Toast, Alert, Progress |
|
||||
|
||||
## 多端支持
|
||||
|
||||
| 平台 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| 微信小程序 | ✅ 支持 | 主流平台 |
|
||||
| 抖音小程序 | ✅ 支持 | 字节系平台 |
|
||||
| H5 | ✅ 支持 | Web 端预览 |
|
||||
|
||||
## 用户体验目标
|
||||
|
||||
- **加载速度**:首屏加载 < 2s
|
||||
- **交互流畅**:帧率 >= 60fps
|
||||
- **跨端一致**:各端 UI 表现一致
|
||||
- **离线可用**:支持本地缓存
|
||||
|
||||
## 安全要求
|
||||
|
||||
- 用户数据加密存储
|
||||
- API 请求鉴权
|
||||
- 敏感信息脱敏
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0.0
|
||||
**最后更新**:2026-05-22
|
||||
|
||||
@@ -1,59 +1,207 @@
|
||||
# P01_errlens_app - 架构设计
|
||||
|
||||
## 系统架构
|
||||
采用微服务架构,前后端分离。
|
||||
## 整体架构
|
||||
|
||||
### 架构图
|
||||
```
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ 前端层 │
|
||||
│ React + TypeScript + Tailwind CSS │
|
||||
└──────────────────────┬──────────────────────────────┘
|
||||
│ HTTP/WebSocket
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────┐
|
||||
│ API网关 │
|
||||
│ Express + Middleware │
|
||||
└──────────────────────┬──────────────────────────────┘
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌──────────┐ ┌──────────┐ ┌──────────┐
|
||||
│ 代码分析 │ │ AI服务 │ │ 用户管理 │
|
||||
│ Service │ │ Service │ │ Service │
|
||||
└──────────┘ └──────────┘ └──────────┘
|
||||
│ │ │
|
||||
└─────────────────┼─────────────────┘
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ PostgreSQL │
|
||||
└──────────────┘
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ 小程序客户端 │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ 首页 │ │ 分析 │ │ 历史 │ │ 我的 │ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ┌────┴────────────┴────────────┴────────────┴────┐ │
|
||||
│ │ 组件库 (shadcn/ui) │ │
|
||||
│ └────────────────────┬─────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────┴─────────────────────────┐ │
|
||||
│ │ 状态管理 (Zustand) │ │
|
||||
│ └────────────────────┬─────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────┴─────────────────────────┐ │
|
||||
│ │ Network 层 (API 封装) │ │
|
||||
│ └────────────────────┬─────────────────────────┘ │
|
||||
└───────────────────────┼───────────────────────────────────┘
|
||||
│ HTTP
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────┐
|
||||
│ 后端服务 (NestJS) │
|
||||
│ ┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐ │
|
||||
│ │ 用户模块 │ │ 分析模块 │ │ 历史模块 │ │ 系统模块 │ │
|
||||
│ └────┬────┘ └────┬────┘ └────┬────┘ └────┬────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ ┌────┴────────────┴────────────┴────────────┴────┐ │
|
||||
│ │ Service 层 │ │
|
||||
│ └────────────────────┬─────────────────────────┘ │
|
||||
│ │ │
|
||||
│ ┌────────────────────┴─────────────────────────┐ │
|
||||
│ │ 数据层 (Drizzle ORM) │ │
|
||||
│ └────────────────────┬─────────────────────────┘ │
|
||||
└───────────────────────┼───────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────┐
|
||||
│ PostgreSQL │
|
||||
│ Supabase │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
## 核心模块
|
||||
|
||||
### 1. 代码分析模块
|
||||
- 解析源代码
|
||||
- 静态分析检测
|
||||
- 错误分类与评级
|
||||
|
||||
### 2. AI 服务模块
|
||||
- 调用 AI 模型
|
||||
- 生成修复建议
|
||||
- 优化提示词
|
||||
|
||||
### 3. 用户管理模块
|
||||
- 用户认证授权
|
||||
- 使用统计
|
||||
- 个性化配置
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # REST API 路由
|
||||
├── controllers/ # 业务逻辑
|
||||
├── services/ # 核心服务
|
||||
├── models/ # 数据模型
|
||||
├── middleware/ # 中间件
|
||||
└── utils/ # 工具函数
|
||||
```
|
||||
P01_errlens_app/
|
||||
├── src/ # 前端源码
|
||||
│ ├── app.config.ts # Taro 应用配置
|
||||
│ ├── app.tsx # 根组件
|
||||
│ ├── app.css # 全局样式
|
||||
│ ├── index.html # H5 入口
|
||||
│ │
|
||||
│ ├── components/ # 组件
|
||||
│ │ └── ui/ # shadcn/ui 组件库
|
||||
│ │ ├── button.tsx
|
||||
│ │ ├── card.tsx
|
||||
│ │ ├── dialog.tsx
|
||||
│ │ └── ... (50+ 组件)
|
||||
│ │
|
||||
│ ├── pages/ # 页面
|
||||
│ │ └── index/ # 首页
|
||||
│ │ ├── index.tsx
|
||||
│ │ ├── index.config.ts
|
||||
│ │ └── index.css
|
||||
│ │
|
||||
│ ├── lib/ # 工具库
|
||||
│ │ ├── utils.ts # 通用工具
|
||||
│ │ ├── platform.ts # 平台检测
|
||||
│ │ ├── measure.ts # 尺寸测量
|
||||
│ │ └── hooks/ # 自定义 Hooks
|
||||
│ │
|
||||
│ ├── presets/ # 预设配置
|
||||
│ │ ├── index.tsx
|
||||
│ │ ├── env.ts
|
||||
│ │ ├── h5-container.tsx
|
||||
│ │ └── ...
|
||||
│ │
|
||||
│ └── network.ts # API 请求封装
|
||||
│
|
||||
├── server/ # 后端源码 (NestJS)
|
||||
│ ├── src/
|
||||
│ │ ├── app.module.ts # 根模块
|
||||
│ │ ├── app.controller.ts # 根控制器
|
||||
│ │ ├── app.service.ts # 根服务
|
||||
│ │ └── main.ts # 入口文件
|
||||
│ │
|
||||
│ ├── nest-cli.json
|
||||
│ ├── tsconfig.json
|
||||
│ └── package.json
|
||||
│
|
||||
├── config/ # 构建配置
|
||||
│ ├── index.ts # 通用配置
|
||||
│ ├── dev.ts # 开发环境配置
|
||||
│ └── prod.ts # 生产环境配置
|
||||
│
|
||||
├── types/ # 类型定义
|
||||
│ ├── global.d.ts
|
||||
│ └── lucide.d.ts
|
||||
│
|
||||
├── tests/ # 测试代码
|
||||
│ ├── unit/ # 单元测试
|
||||
│ ├── integration/ # 集成测试
|
||||
│ └── e2e/ # E2E 测试
|
||||
│
|
||||
├── docs/ # 项目文档
|
||||
│ ├── 01_需求概要.md
|
||||
│ ├── 02_架构设计.md
|
||||
│ └── 03_接口定义.md
|
||||
│
|
||||
├── package.json # 前端依赖
|
||||
├── tsconfig.json # TypeScript 配置
|
||||
├── babel.config.js # Babel 配置
|
||||
├── eslint.config.mjs # ESLint 配置
|
||||
├── stylelint.config.mjs # Stylelint 配置
|
||||
├── project.config.json # 微信小程序配置
|
||||
└── ENVIRONMENT.md # 环境准备指南
|
||||
```
|
||||
|
||||
## 核心模块设计
|
||||
|
||||
### 1. Network 层
|
||||
|
||||
```typescript
|
||||
// src/network.ts
|
||||
// API 请求封装,自动添加项目域名前缀
|
||||
// 支持 request / uploadFile / downloadFile
|
||||
```
|
||||
|
||||
**职责**:
|
||||
- 统一处理 API 请求
|
||||
- 自动添加域名和路径前缀
|
||||
- 请求/响应日志打印
|
||||
- 错误处理
|
||||
|
||||
### 2. 组件库
|
||||
|
||||
**位置**:`src/components/ui/`
|
||||
|
||||
**组件分类**:
|
||||
- 基础组件:Button, Input, Badge, Avatar
|
||||
- 布局组件:Card, Dialog, Drawer, Sheet
|
||||
- 数据展示:Table, Progress, Skeleton
|
||||
- 导航组件:Tabs, Breadcrumb
|
||||
- 反馈组件:Toast, Alert, Tooltip
|
||||
|
||||
### 3. 状态管理
|
||||
|
||||
**方案**:Zustand
|
||||
|
||||
**特点**:
|
||||
- 轻量级
|
||||
- 无 Provider 嵌套
|
||||
- TypeScript 友好
|
||||
|
||||
### 4. 后端模块
|
||||
|
||||
```
|
||||
server/src/
|
||||
├── controllers/ # 控制器
|
||||
├── services/ # 业务逻辑
|
||||
├── modules/ # NestJS 模块
|
||||
├── entities/ # 数据实体
|
||||
├── dto/ # 数据传输对象
|
||||
└── interceptors/ # 拦截器
|
||||
```
|
||||
|
||||
## 多端适配策略
|
||||
|
||||
### 平台检测
|
||||
|
||||
```typescript
|
||||
import { Taro, ENV_TYPE } from '@tarojs/taro'
|
||||
|
||||
const isWeapp = Taro.getEnv() === ENV_TYPE.WEAPP // 微信小程序
|
||||
const isTT = Taro.getEnv() === ENV_TYPE.TT // 抖音小程序
|
||||
const isH5 = Taro.getEnv() === ENV_TYPE.WEB // H5
|
||||
```
|
||||
|
||||
### 跨端规则
|
||||
|
||||
| 场景 | 适配方案 |
|
||||
|------|---------|
|
||||
| Text 换行 | 添加 `block` 类 |
|
||||
| Input 样式 | View 包裹,样式放 View |
|
||||
| Fixed + Flex | 使用 inline style |
|
||||
| 原生组件 | 平台检测 + 降级方案 |
|
||||
|
||||
## 部署架构
|
||||
|
||||
### 开发环境
|
||||
- 前端:H5 端口 5000
|
||||
- 后端:Node 端口 3000
|
||||
|
||||
### 生产环境
|
||||
- 微信小程序:构建 weapp 包
|
||||
- 抖音小程序:构建 tt 包
|
||||
- H5:构建 web 静态资源
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0.0
|
||||
**最后更新**:2026-05-22
|
||||
|
||||
@@ -1,54 +1,41 @@
|
||||
# P01_errlens_app - 接口定义
|
||||
|
||||
## API 基础路径
|
||||
`/api/v1`
|
||||
## 接口规范
|
||||
|
||||
## 认证方式
|
||||
JWT Token,放在 Authorization 头:
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
### 基础信息
|
||||
- **Base URL**: `/api`(开发环境通过 Vite Proxy 代理到 `http://localhost:3000/api`)
|
||||
- **请求格式**: JSON
|
||||
- **响应格式**: Envelope Pattern `{ code, msg, data }`
|
||||
|
||||
## 接口列表
|
||||
### 通用响应结构
|
||||
|
||||
### 1. 代码分析
|
||||
|
||||
#### POST /api/v1/analyze
|
||||
分析代码中的错误
|
||||
|
||||
**请求体:**
|
||||
```json
|
||||
```typescript
|
||||
// 成功响应
|
||||
{
|
||||
"code": "string",
|
||||
"language": "string",
|
||||
"options": {
|
||||
"strict": true
|
||||
}
|
||||
code: 200,
|
||||
msg: "success",
|
||||
data: { ... }
|
||||
}
|
||||
|
||||
// 错误响应
|
||||
{
|
||||
code: 400 | 401 | 403 | 404 | 500,
|
||||
msg: "错误信息",
|
||||
data: null
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"errors": [
|
||||
{
|
||||
"line": 10,
|
||||
"column": 5,
|
||||
"type": "error",
|
||||
"message": "变量未定义",
|
||||
"suggestion": "建议在使用前定义变量"
|
||||
}
|
||||
]
|
||||
}
|
||||
---
|
||||
|
||||
## 用户模块
|
||||
|
||||
### 1. 用户登录
|
||||
|
||||
```
|
||||
POST /api/auth/login
|
||||
```
|
||||
|
||||
### 2. 用户管理
|
||||
|
||||
#### POST /api/v1/users/login
|
||||
用户登录
|
||||
|
||||
**请求体:**
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"email": "string",
|
||||
@@ -56,40 +43,252 @@ Authorization: Bearer <token>
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"token": "string",
|
||||
"user": {
|
||||
"id": "string",
|
||||
"email": "string",
|
||||
"name": "string"
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"token": "jwt_token_here",
|
||||
"user": {
|
||||
"id": "uuid",
|
||||
"email": "user@example.com",
|
||||
"nickname": "用户名"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 修复建议
|
||||
---
|
||||
|
||||
#### POST /api/v1/fix
|
||||
获取修复建议
|
||||
### 2. 用户注册
|
||||
|
||||
**请求体:**
|
||||
```
|
||||
POST /api/auth/register
|
||||
```
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"email": "string",
|
||||
"password": "string",
|
||||
"nickname": "string"
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"email": "user@example.com",
|
||||
"nickname": "用户名"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 获取用户信息
|
||||
|
||||
```
|
||||
GET /api/users/profile
|
||||
```
|
||||
|
||||
**请求头**:
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"id": "uuid",
|
||||
"email": "user@example.com",
|
||||
"nickname": "用户名",
|
||||
"avatar": "https://...",
|
||||
"createdAt": "2026-05-22T00:00:00Z"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 代码分析模块
|
||||
|
||||
### 1. 上传代码分析
|
||||
|
||||
```
|
||||
POST /api/analyze
|
||||
```
|
||||
|
||||
**请求头**:
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
**请求参数**:
|
||||
```json
|
||||
{
|
||||
"code": "string",
|
||||
"error": {
|
||||
"type": "string",
|
||||
"message": "string"
|
||||
"language": "javascript | python | typescript | ..."
|
||||
}
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"taskId": "uuid",
|
||||
"status": "completed",
|
||||
"results": [
|
||||
{
|
||||
"line": 10,
|
||||
"column": 5,
|
||||
"severity": "error",
|
||||
"message": "缺少分号",
|
||||
"suggestion": "在行末添加分号"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**响应:**
|
||||
---
|
||||
|
||||
### 2. 获取分析结果
|
||||
|
||||
```
|
||||
GET /api/analyze/:taskId
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"fixedCode": "string",
|
||||
"explanation": "string"
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"taskId": "uuid",
|
||||
"status": "completed",
|
||||
"results": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 获取历史记录
|
||||
|
||||
```
|
||||
GET /api/analyze/history
|
||||
```
|
||||
|
||||
**查询参数**:
|
||||
```
|
||||
page: number (default: 1)
|
||||
pageSize: number (default: 20)
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"id": "uuid",
|
||||
"codeSnippet": "function hello() {...}",
|
||||
"language": "javascript",
|
||||
"resultCount": 3,
|
||||
"createdAt": "2026-05-22T00:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 100,
|
||||
"page": 1,
|
||||
"pageSize": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 文件上传模块
|
||||
|
||||
### 1. 上传文件
|
||||
|
||||
```
|
||||
POST /api/upload
|
||||
```
|
||||
|
||||
**请求头**:
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
**请求参数**:
|
||||
```
|
||||
file: binary
|
||||
```
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"url": "https://storage.example.com/files/xxx.png",
|
||||
"filename": "xxx.png",
|
||||
"size": 1024
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 错误码定义
|
||||
|
||||
| 错误码 | 说明 |
|
||||
|-------|------|
|
||||
| 200 | 成功 |
|
||||
| 400 | 请求参数错误 |
|
||||
| 401 | 未授权 / Token 过期 |
|
||||
| 403 | 权限不足 |
|
||||
| 404 | 资源不存在 |
|
||||
| 500 | 服务器内部错误 |
|
||||
|
||||
---
|
||||
|
||||
## API 测试命令
|
||||
|
||||
### 开发环境测试
|
||||
|
||||
```bash
|
||||
# 登录接口
|
||||
curl -X POST http://localhost:3000/api/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@example.com","password":"123456"}'
|
||||
|
||||
# 获取用户信息
|
||||
curl -X GET http://localhost:3000/api/users/profile \
|
||||
-H "Authorization: Bearer <token>"
|
||||
|
||||
# 代码分析
|
||||
curl -X POST http://localhost:3000/api/analyze \
|
||||
-H "Authorization: Bearer <token>" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"code":"console.log(1)","language":"javascript"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0.0
|
||||
**最后更新**:2026-05-22
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { FlatCompat } from '@eslint/eslintrc';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import path from 'node:path';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: __dirname,
|
||||
});
|
||||
|
||||
const REMOTE_CSS_IMPORT_PATTERN =
|
||||
/@import\s+(?:url\(\s*['"]?((?:https?:)?\/\/[^'")\s]+)['"]?\s*\)|['"]((?:https?:)?\/\/[^'"\s]+)['"])/g;
|
||||
|
||||
const cssImportGuardPlugin = {
|
||||
processors: {
|
||||
css: {
|
||||
preprocess(text) {
|
||||
const lines = text.split('\n');
|
||||
const virtualLines = lines.map(line => {
|
||||
const matches = [...line.matchAll(REMOTE_CSS_IMPORT_PATTERN)];
|
||||
|
||||
if (matches.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return matches
|
||||
.map(match => {
|
||||
const url = match[1] ?? match[2];
|
||||
|
||||
return `__cssExternalImport(${JSON.stringify(url)});`;
|
||||
})
|
||||
.join(' ');
|
||||
});
|
||||
|
||||
return [virtualLines.join('\n')];
|
||||
},
|
||||
postprocess(messages) {
|
||||
return messages.flat();
|
||||
},
|
||||
supportsAutofix: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const baseRestrictedSyntaxRules = [
|
||||
{
|
||||
selector: "MemberExpression[object.name='process'][property.name='env']",
|
||||
message:
|
||||
'工程规范:请勿在 src 目录下直接使用 process.env\n如需获取 URL 请求前缀,请使用已经注入全局的 PROJECT_DOMAIN',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
":matches(ExportNamedDeclaration, ExportDefaultDeclaration) :matches([id.name='Network'], [declaration.id.name='Network'])",
|
||||
message:
|
||||
"工程规范:禁止自行定义 Network,项目已提供 src/network.ts,请直接使用: import { Network } from '@/network'",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
'Literal[value=/(^|\\s)(?:[^\\s:]+:)*(bg|text|border|divide|outline|ring|ring-offset|from|to|via|decoration|shadow|accent|caret|fill|stroke)-[a-z0-9-]+\\/([0-9]+|\\[[^\\]]+\\])/], TemplateElement[value.raw=/(^|\\s)(?:[^\\s:]+:)*(bg|text|border|divide|outline|ring|ring-offset|from|to|via|decoration|shadow|accent|caret|fill|stroke)-[a-z0-9-]+\\/([0-9]+|\\[[^\\]]+\\])/]',
|
||||
message:
|
||||
'微信小程序兼容性:禁用 Tailwind 颜色不透明度简写(如 bg-primary/10),该语法在微信小程序下 opacity 会丢失。请拆分写(如 bg-primary bg-opacity-10)。',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
'Literal[value=/(^|\\s)peer-[a-z0-9-]+\\b/], TemplateElement[value.raw=/(^|\\s)peer-[a-z0-9-]+\\b/]',
|
||||
message:
|
||||
'微信小程序兼容性:不支持 Tailwind 的 peer-*(如 peer-checked、peer-disabled)。',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
'Literal[value=/(^|\\s)group-[a-z0-9-]+\\b/], TemplateElement[value.raw=/(^|\\s)group-[a-z0-9-]+\\b/]',
|
||||
message: '微信小程序兼容性:不支持 Tailwind 的 group-*(如 group-hover)。',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
'Literal[value=/\\b(?!gap(?:-x|-y)?-)[a-zA-Z0-9-]+\\-[0-9]+\\.[0-9]+\\b/], TemplateElement[value.raw=/\\b(?!gap(?:-x|-y)?-)[a-zA-Z0-9-]+\\-[0-9]+\\.[0-9]+\\b/]',
|
||||
message:
|
||||
'微信小程序兼容性:禁用 Tailwind 小数值类名(如 space-y-1.5、w-0.5),请用整数替代(如 space-y-2、w-1)。',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
":matches(JSXAttribute[name.name='className'], CallExpression[callee.name=/^(cn|cva)$/]) :matches(Literal[value=/\\:has\\(/], TemplateElement[value.raw=/\\:has\\(/])",
|
||||
message: '微信小程序兼容性:WXSS 不支持 :has(...)(会导致预览上传失败)。',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
":matches(JSXAttribute[name.name='className'], CallExpression[callee.name=/^(cn|cva)$/]) :matches(Literal[value=/(^|\\s)has-[^\\s]+/], TemplateElement[value.raw=/(^|\\s)has-[^\\s]+/])",
|
||||
message:
|
||||
'微信小程序兼容性:禁用 Tailwind 的 has-* 变体(会生成 :has,导致预览上传失败)。',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
":matches(JSXAttribute[name.name='className'], CallExpression[callee.name=/^(cn|cva)$/]) :matches(Literal[value=/\\[&>\\*/], TemplateElement[value.raw=/\\[&>\\*/])",
|
||||
message:
|
||||
'微信小程序兼容性:禁用 [&>*...](可能生成非法 WXSS,如 >:last-child)。请改为 [&>view] 等明确标签。',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
":matches(JSXAttribute[name.name='className'], CallExpression[callee.name=/^(cn|cva)$/]) :matches(Literal[value=/\\[&[^\\]]*\\[data-/], TemplateElement[value.raw=/\\[&[^\\]]*\\[data-/])",
|
||||
message:
|
||||
'微信小程序兼容性:禁用 Tailwind 任意选择器里的属性选择器(如 [&>[data-...]]),可能导致预览上传失败。',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
":matches(JSXAttribute[name.name='className'], CallExpression[callee.name=/^(cn|cva)$/]) :matches(Literal[value=/\\[[^\\]]*&[^\\]]*~[^\\]]*\\]/], TemplateElement[value.raw=/\\[[^\\]]*&[^\\]]*~[^\\]]*\\]/])",
|
||||
message: '微信小程序兼容性:WXSS 不支持 ~(会导致预览上传失败)。',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"CallExpression[callee.name='__cssExternalImport'] > Literal[value=/^(?:https?:)?\\/\\//]",
|
||||
message:
|
||||
'微信小程序兼容性:禁止在 CSS/WXSS 中使用远程 @import(如 Google Fonts)。请改为本地静态资源或删除该导入。',
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"JSXAttribute[name.name='color'][value.type='Literal'][value.value='currentColor'], JSXAttribute[name.name='color'] > JSXExpressionContainer > Literal[value='currentColor']",
|
||||
message:
|
||||
'lucide-react-taro 规范:禁止使用 color="currentColor",小程序端不会按预期继承颜色。请改为显式颜色值,或通过 LucideTaroProvider 提供默认颜色。',
|
||||
},
|
||||
];
|
||||
|
||||
const pageRestrictedSyntaxRules = [
|
||||
{
|
||||
selector:
|
||||
"ImportDeclaration[source.value='@tarojs/components'] ImportSpecifier[imported.name='Button']",
|
||||
message:
|
||||
"组件规范:Button 优先使用 '@/components/ui/button',不要在页面中直接使用 '@tarojs/components' 的 Button。",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"ImportDeclaration[source.value='@tarojs/components'] ImportSpecifier[imported.name='Input']",
|
||||
message:
|
||||
"组件规范:Input 优先使用 '@/components/ui/input',不要在页面中直接使用 '@tarojs/components' 的 Input。",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"ImportDeclaration[source.value='@tarojs/components'] ImportSpecifier[imported.name='Textarea']",
|
||||
message:
|
||||
"组件规范:Textarea 优先使用 '@/components/ui/textarea',不要在页面中直接使用 '@tarojs/components' 的 Textarea。",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"ImportDeclaration[source.value='@tarojs/components'] ImportSpecifier[imported.name='Label']",
|
||||
message:
|
||||
"组件规范:Label 优先使用 '@/components/ui/label',不要在页面中直接使用 '@tarojs/components' 的 Label。",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"ImportDeclaration[source.value='@tarojs/components'] ImportSpecifier[imported.name='Switch']",
|
||||
message:
|
||||
"组件规范:Switch 优先使用 '@/components/ui/switch',不要在页面中直接使用 '@tarojs/components' 的 Switch。",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"ImportDeclaration[source.value='@tarojs/components'] ImportSpecifier[imported.name='Slider']",
|
||||
message:
|
||||
"组件规范:Slider 优先使用 '@/components/ui/slider',不要在页面中直接使用 '@tarojs/components' 的 Slider。",
|
||||
},
|
||||
{
|
||||
selector:
|
||||
"ImportDeclaration[source.value='@tarojs/components'] ImportSpecifier[imported.name='Progress']",
|
||||
message:
|
||||
"组件规范:Progress 优先使用 '@/components/ui/progress',不要在页面中直接使用 '@tarojs/components' 的 Progress。",
|
||||
},
|
||||
];
|
||||
|
||||
const indexPageRestrictedSyntaxRules = [
|
||||
{
|
||||
selector: 'JSXText[value=/\\s*应用开发中\\s*/]',
|
||||
message:
|
||||
'工程规范:检测到首页 (src/pages/index/index.tsx) 仍为默认占位页面,这会导致用户无法进入新增页面,请根据用户需求开发实际的首页功能与界面。如果已经开发了新的首页,也需要删除旧首页,并更新 src/app.config.ts 文件',
|
||||
},
|
||||
];
|
||||
|
||||
export default [
|
||||
...compat.extends('taro/react'),
|
||||
{
|
||||
rules: {
|
||||
'react/jsx-uses-react': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
'jsx-quotes': ['error', 'prefer-double'],
|
||||
'react-hooks/exhaustive-deps': 'off',
|
||||
'tailwindcss/classnames-order': 'off',
|
||||
'tailwindcss/no-custom-classname': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.{js,jsx,ts,tsx}'],
|
||||
ignores: ['src/network.ts'],
|
||||
rules: {
|
||||
'no-restricted-syntax': ['error', ...baseRestrictedSyntaxRules],
|
||||
'no-restricted-properties': [
|
||||
'error',
|
||||
{
|
||||
object: 'Taro',
|
||||
property: 'request',
|
||||
message:
|
||||
"工程规范:请使用 Network.request 替代 Taro.request,导入方式: import { Network } from '@/network'",
|
||||
},
|
||||
{
|
||||
object: 'Taro',
|
||||
property: 'uploadFile',
|
||||
message:
|
||||
"工程规范:请使用 Network.uploadFile 替代 Taro.uploadFile,导入方式: import { Network } from '@/network'",
|
||||
},
|
||||
{
|
||||
object: 'Taro',
|
||||
property: 'downloadFile',
|
||||
message:
|
||||
"工程规范:请使用 Network.downloadFile 替代 Taro.downloadFile,导入方式: import { Network } from '@/network'",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.css'],
|
||||
plugins: {
|
||||
local: cssImportGuardPlugin,
|
||||
},
|
||||
processor: 'local/css',
|
||||
rules: {
|
||||
'no-undef': 'off',
|
||||
'no-restricted-syntax': ['error', ...baseRestrictedSyntaxRules],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/pages/**/*.tsx'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
...baseRestrictedSyntaxRules,
|
||||
...pageRestrictedSyntaxRules,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/pages/index/index.tsx'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
...baseRestrictedSyntaxRules,
|
||||
...pageRestrictedSyntaxRules,
|
||||
...indexPageRestrictedSyntaxRules,
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
ignores: ['dist/**', 'dist-*/**', 'node_modules/**'],
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,113 @@
|
||||
{
|
||||
"name": "@errlens/p01-mini-program",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "ErrLens 小程序应用 - 基于 Taro 框架的多端小程序项目",
|
||||
"scripts": {
|
||||
"build": "pnpm exec concurrently --kill-others-on-fail --kill-signal SIGKILL -n lint,tsc,web,weapp,tt,server -c red,blue,green,yellow,cyan,magenta \"pnpm lint:build\" \"pnpm tsc\" \"pnpm build:web\" \"pnpm build:weapp\" \"pnpm build:tt\" \"pnpm build:server\"",
|
||||
"build:pack": "pnpm exec concurrently --kill-others-on-fail --kill-signal SIGKILL -n weapp,tt -c yellow,cyan \"pnpm build:weapp\" \"pnpm build:tt\"",
|
||||
"build:server": "pnpm --filter server build",
|
||||
"build:tt": "taro build --type tt",
|
||||
"build:weapp": "taro build --type weapp",
|
||||
"build:web": "taro build --type h5",
|
||||
"dev": "pnpm exec concurrently --kill-others --kill-signal SIGKILL -n web,server -c blue,green \"pnpm dev:web\" \"pnpm dev:server\"",
|
||||
"dev:server": "pnpm --filter server dev",
|
||||
"dev:tt": "taro build --type tt --watch",
|
||||
"dev:weapp": "taro build --type weapp --watch",
|
||||
"dev:web": "taro build --type h5 --watch",
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"postinstall": "weapp-tw patch",
|
||||
"kill:all": "pkill -9 -f 'concurrently' 2>/dev/null || true; pkill -9 -f 'nest start' 2>/dev/null || true; pkill -9 -f 'taro build' 2>/dev/null || true; pkill -9 -f 'node.*dev' 2>/dev/null || true; echo 'All dev processes cleaned'",
|
||||
"lint": "eslint \"src/**/*.{js,jsx,ts,tsx,css}\"",
|
||||
"lint:build": "eslint \"src/**/*.{js,jsx,ts,tsx,css}\" --max-warnings=0 --quiet",
|
||||
"lint:fix": "eslint \"src/**/*.{js,jsx,ts,tsx,css}\" --fix",
|
||||
"new": "taro new",
|
||||
"preview:tt": "taro build --type tt --preview",
|
||||
"preview:weapp": "taro build --type weapp --preview",
|
||||
"tsc": "npx tsc --noEmit --skipLibCheck",
|
||||
"validate": "pnpm exec concurrently --kill-others-on-fail --kill-signal SIGKILL -n lint,tsc -c red,blue \"pnpm lint:build\" \"pnpm tsc\""
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.{js,jsx,ts,tsx,css}": [
|
||||
"eslint"
|
||||
]
|
||||
},
|
||||
"browserslist": [
|
||||
"last 3 versions",
|
||||
"Android >= 4.1",
|
||||
"ios >= 8"
|
||||
],
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.24.4",
|
||||
"@tarojs/components": "4.1.9",
|
||||
"@tarojs/helper": "4.1.9",
|
||||
"@tarojs/plugin-framework-react": "4.1.9",
|
||||
"@tarojs/plugin-platform-h5": "4.1.9",
|
||||
"@tarojs/plugin-platform-tt": "4.1.9",
|
||||
"@tarojs/plugin-platform-weapp": "4.1.9",
|
||||
"@tarojs/react": "4.1.9",
|
||||
"@tarojs/runtime": "4.1.9",
|
||||
"@tarojs/shared": "4.1.9",
|
||||
"@tarojs/taro": "4.1.9",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react-taro": "^1.4.1",
|
||||
"react": "^18.0.0",
|
||||
"react-dom": "^18.0.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/plugin-transform-class-properties": "7.25.9",
|
||||
"@babel/preset-react": "^7.24.1",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tarojs/cli": "4.1.9",
|
||||
"@tarojs/plugin-generator": "4.1.9",
|
||||
"@tarojs/plugin-mini-ci": "^4.1.9",
|
||||
"@tarojs/vite-runner": "4.1.9",
|
||||
"@types/minimatch": "^5",
|
||||
"@types/react": "^18.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"babel-preset-taro": "4.1.9",
|
||||
"concurrently": "^9.2.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-taro": "4.1.9",
|
||||
"eslint-plugin-react": "^7.34.1",
|
||||
"eslint-plugin-react-hooks": "^4.4.0",
|
||||
"eslint-plugin-tailwindcss": "^3.18.2",
|
||||
"less": "^4.2.0",
|
||||
"lint-staged": "^16.1.2",
|
||||
"miniprogram-ci": "^2.1.26",
|
||||
"only-allow": "^1.2.2",
|
||||
"postcss": "^8.5.6",
|
||||
"react-refresh": "^0.14.0",
|
||||
"stylelint": "^16.4.0",
|
||||
"stylelint-config-standard": "^38.0.0",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"terser": "^5.30.4",
|
||||
"tt-ide-cli": "^0.1.31",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^4.2.0",
|
||||
"weapp-tailwindcss": "^4.10.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.0.0",
|
||||
"engines": {
|
||||
"pnpm": ">=9.0.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@tarojs/plugin-mini-ci@4.1.9": "patches/@tarojs__plugin-mini-ci@4.1.9.patch"
|
||||
}
|
||||
},
|
||||
"templateInfo": {
|
||||
"name": "default",
|
||||
"typescript": true,
|
||||
"css": "Less",
|
||||
"framework": "React"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"miniprogramRoot": "./dist",
|
||||
"projectname": "coze-mini-program",
|
||||
"description": "test",
|
||||
"appid": "touristappid",
|
||||
"setting": {
|
||||
"urlCheck": true,
|
||||
"es6": false,
|
||||
"enhance": false,
|
||||
"compileHotReLoad": false,
|
||||
"postcss": false,
|
||||
"minified": false
|
||||
},
|
||||
"compileType": "miniprogram",
|
||||
"packOptions": { "ignore": [{ "type": "folder", "value": "./assets" }] }
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"exclude": ["node_modules", "dist", ".git", "../dist", "../src"]
|
||||
},
|
||||
"webpack": true
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "NestJS server application",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "nest start",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"start:prod": "node dist/main"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.958.0",
|
||||
"@aws-sdk/lib-storage": "^3.958.0",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@supabase/supabase-js": "2.95.3",
|
||||
"coze-coding-dev-sdk": "^0.7.16",
|
||||
"dotenv": "^17.2.3",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
"drizzle-zod": "^0.8.3",
|
||||
"express": "5.2.1",
|
||||
"pg": "^8.16.3",
|
||||
"rxjs": "^7.8.1",
|
||||
"zod": "^4.3.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/node": "^22.10.2",
|
||||
"drizzle-kit": "^0.31.8",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from '@/app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get('hello')
|
||||
getHello(): { status: string; data: string } {
|
||||
return {
|
||||
status: 'success',
|
||||
data: this.appService.getHello()
|
||||
};
|
||||
}
|
||||
|
||||
@Get('health')
|
||||
getHealth(): { status: string; data: string } {
|
||||
return {
|
||||
status: 'success',
|
||||
data: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppController } from '@/app.controller';
|
||||
import { AppService } from '@/app.service';
|
||||
|
||||
@Module({
|
||||
imports: [],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello, welcome to coze coding mini-program server!';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
ExecutionContext,
|
||||
CallHandler,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
@Injectable()
|
||||
export class HttpStatusInterceptor implements NestInterceptor {
|
||||
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const response = context.switchToHttp().getResponse();
|
||||
|
||||
// 如果是 POST 请求且状态码为 201,改为 200
|
||||
if (request.method === 'POST' && response.statusCode === 201) {
|
||||
response.status(200);
|
||||
}
|
||||
|
||||
return next.handle();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from '@/app.module';
|
||||
import * as express from 'express';
|
||||
import { HttpStatusInterceptor } from '@/interceptors/http-status.interceptor';
|
||||
|
||||
function parsePort(): number {
|
||||
const args = process.argv.slice(2);
|
||||
const portIndex = args.indexOf('-p');
|
||||
if (portIndex !== -1 && args[portIndex + 1]) {
|
||||
const port = parseInt(args[portIndex + 1], 10);
|
||||
if (!isNaN(port) && port > 0 && port < 65536) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
return 3000;
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.enableCors({
|
||||
origin: true,
|
||||
credentials: true,
|
||||
});
|
||||
app.setGlobalPrefix('api');
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use(express.urlencoded({ limit: '50mb', extended: true }));
|
||||
|
||||
// 全局拦截器:统一将 POST 请求的 201 状态码改为 200
|
||||
app.useGlobalInterceptors(new HttpStatusInterceptor());
|
||||
// 1. 开启优雅关闭 Hooks (关键!)
|
||||
app.enableShutdownHooks();
|
||||
|
||||
// 2. 解析端口
|
||||
const port = parsePort();
|
||||
try {
|
||||
await app.listen(port);
|
||||
console.log(`Server running on http://localhost:${port}`);
|
||||
} catch (err) {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
console.error(`❌ 端口 \({port} 被占用! 请运行 'npx kill-port \){port}' 然后重试。`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
console.log(`Application is running on: http://localhost:3000`);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
# src
|
||||
@@ -0,0 +1,11 @@
|
||||
export default defineAppConfig({
|
||||
pages: [
|
||||
'pages/index/index'
|
||||
],
|
||||
window: {
|
||||
backgroundTextStyle: 'light',
|
||||
navigationBarBackgroundColor: '#fff',
|
||||
navigationBarTitleText: 'WeChat',
|
||||
navigationBarTextStyle: 'black'
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
/* stylelint-disable selector-type-no-unknown */
|
||||
/* stylelint-disable at-rule-no-unknown */
|
||||
/* stylelint-disable number-max-precision */
|
||||
@import url('weapp-tailwindcss');
|
||||
|
||||
/* 小程序页面容器高度设置,确保垂直居中生效 */
|
||||
page {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:root,
|
||||
page,
|
||||
root-portal {
|
||||
--background: lab(100% 0 0);
|
||||
--foreground: lab(2.75381% 0 0);
|
||||
--card: lab(100% 0 0);
|
||||
--card-foreground: lab(2.75381% 0 0);
|
||||
--popover: lab(100% 0 0);
|
||||
--popover-foreground: lab(2.75381% 0 0);
|
||||
--primary: lab(7.78201% -0.0000149012 0);
|
||||
--primary-foreground: lab(98.26% 0 0);
|
||||
--secondary: lab(96.52% -0.0000298023 0.0000119209);
|
||||
--secondary-foreground: lab(7.78201% -0.0000149012 0);
|
||||
--muted: lab(96.52% -0.0000298023 0.0000119209);
|
||||
--muted-foreground: lab(48.496% 0 0);
|
||||
--accent: lab(96.52% -0.0000298023 0.0000119209);
|
||||
--accent-foreground: lab(7.78201% -0.0000149012 0);
|
||||
--destructive: lab(48.4493% 77.4328 61.5452);
|
||||
--destructive-foreground: lab(96.4152% 3.22586 1.14673);
|
||||
--border: lab(90.952% 0 -0.0000119209);
|
||||
--input: lab(90.952% 0 -0.0000119209);
|
||||
--ring: lab(66.128% -0.0000298023 0.0000119209);
|
||||
--sidebar: lab(98.26% 0 0);
|
||||
--sidebar-foreground: lab(2.75381% 0 0);
|
||||
--sidebar-primary: lab(7.78201% -0.0000149012 0);
|
||||
--sidebar-primary-foreground: lab(98.26% 0 0);
|
||||
--sidebar-accent: lab(96.52% -0.0000298023 0.0000119209);
|
||||
--sidebar-accent-foreground: lab(7.78201% -0.0000149012 0);
|
||||
--sidebar-border: lab(90.952% 0 -0.0000119209);
|
||||
--sidebar-ring: lab(66.128% -0.0000298023 0.0000119209);
|
||||
--surface: lab(97.68% -0.0000298023 0.0000119209);
|
||||
--code: var(--surface);
|
||||
--code-highlight: lab(95.36% 0 0);
|
||||
--code-number: lab(48.96% 0 0);
|
||||
--selection: lab(2.75381% 0 0);
|
||||
--selection-foreground: lab(100% 0 0);
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-color: initial;
|
||||
--tw-shadow-alpha: 100%;
|
||||
--tw-inset-shadow: 0 0 #0000;
|
||||
--tw-inset-shadow-color: initial;
|
||||
--tw-inset-shadow-alpha: 100%;
|
||||
--tw-ring-color: initial;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-inset-ring-color: initial;
|
||||
--tw-inset-ring-shadow: 0 0 #0000;
|
||||
--tw-ring-inset: initial;
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: lab(2.75381% 0 0);
|
||||
--foreground: lab(98.26% 0 0);
|
||||
--card: lab(7.78201% -0.0000149012 0);
|
||||
--card-foreground: lab(98.26% 0 0);
|
||||
--popover: lab(7.78201% -0.0000149012 0);
|
||||
--popover-foreground: lab(98.26% 0 0);
|
||||
--primary: lab(90.952% 0 -0.0000119209);
|
||||
--primary-foreground: lab(7.78201% -0.0000149012 0);
|
||||
--secondary: lab(15.204% 0 -0.00000596046);
|
||||
--secondary-foreground: lab(98.26% 0 0);
|
||||
--muted: lab(15.204% 0 -0.00000596046);
|
||||
--muted-foreground: lab(66.128% -0.0000298023 0.0000119209);
|
||||
--accent: lab(27.036% 0 0);
|
||||
--accent-foreground: lab(98.26% 0 0);
|
||||
--destructive: lab(63.7053% 60.745 31.3109);
|
||||
--destructive-foreground: lab(49.0747% 69.3434 49.6251);
|
||||
--border: lab(100% 0 0 / 10%);
|
||||
--input: lab(100% 0 0 / 15%);
|
||||
--ring: lab(48.496% 0 0);
|
||||
--sidebar: lab(7.78201% -0.0000149012 0);
|
||||
--sidebar-foreground: lab(98.26% 0 0);
|
||||
--sidebar-primary: lab(36.9089% 35.0961 -85.6872);
|
||||
--sidebar-primary-foreground: lab(98.26% 0 0);
|
||||
--sidebar-accent: lab(15.204% 0 -0.00000596046);
|
||||
--sidebar-accent-foreground: lab(98.26% 0 0);
|
||||
--sidebar-border: lab(100% 0 0 / 10%);
|
||||
--sidebar-ring: lab(34.924% 0 0);
|
||||
--surface: lab(7.22637% -0.0000149012 0);
|
||||
--surface-foreground: lab(66.128% -0.0000298023 0.0000119209);
|
||||
--code: var(--surface);
|
||||
--code-highlight: lab(15.32% 0 0);
|
||||
--code-number: lab(67.52% -0.0000298023 0);
|
||||
--selection: lab(90.952% 0 -0.0000119209);
|
||||
--selection-foreground: lab(7.78201% -0.0000149012 0);
|
||||
}
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-background: var(--background);
|
||||
--color-selection: var(--selection);
|
||||
--color-selection-foreground: var(--selection-foreground);
|
||||
--color-code: var(--code);
|
||||
--radius-full: 9999px;
|
||||
}
|
||||
|
||||
page,
|
||||
view,
|
||||
text,
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
label,
|
||||
scroll-view,
|
||||
image {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: var(--selection);
|
||||
color: var(--selection-foreground);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { LucideTaroProvider } from 'lucide-react-taro';
|
||||
import '@/app.css';
|
||||
import { Toaster } from '@/components/ui/toast';
|
||||
import { Preset } from './presets';
|
||||
|
||||
const App = ({ children }: PropsWithChildren) => {
|
||||
return (
|
||||
<LucideTaroProvider defaultColor="#000" defaultSize={24}>
|
||||
<Preset>{children}</Preset>
|
||||
<Toaster />
|
||||
</LucideTaroProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -0,0 +1,159 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { ChevronsUpDown } from "lucide-react-taro"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const AccordionContext = React.createContext<{
|
||||
value?: string | string[]
|
||||
onValueChange?: (value: string | string[]) => void
|
||||
type?: "single" | "multiple"
|
||||
} | null>(null)
|
||||
|
||||
const Accordion = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
type?: "single" | "multiple"
|
||||
value?: string | string[]
|
||||
defaultValue?: string | string[]
|
||||
onValueChange?: (value: string | string[]) => void
|
||||
collapsible?: boolean
|
||||
}
|
||||
>(({ className, type = "single", value: valueProp, defaultValue, onValueChange, collapsible = false, ...props }, ref) => {
|
||||
const [valueState, setValueState] = React.useState<string | string[]>(
|
||||
defaultValue || (type === "multiple" ? [] : "")
|
||||
)
|
||||
const value = valueProp !== undefined ? valueProp : valueState
|
||||
|
||||
const handleValueChange = (itemValue: string) => {
|
||||
let newValue: string | string[]
|
||||
if (type === "multiple") {
|
||||
const current = Array.isArray(value) ? value : []
|
||||
if (current.includes(itemValue)) {
|
||||
newValue = current.filter(v => v !== itemValue)
|
||||
} else {
|
||||
newValue = [...current, itemValue]
|
||||
}
|
||||
} else {
|
||||
if (value === itemValue && collapsible) {
|
||||
newValue = ""
|
||||
} else {
|
||||
newValue = itemValue
|
||||
}
|
||||
}
|
||||
|
||||
if (valueProp === undefined) {
|
||||
setValueState(newValue)
|
||||
}
|
||||
onValueChange?.(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<AccordionContext.Provider value={{ value, onValueChange: handleValueChange, type }}>
|
||||
<View ref={ref} className={className} {...props} />
|
||||
</AccordionContext.Provider>
|
||||
)
|
||||
})
|
||||
Accordion.displayName = "Accordion"
|
||||
|
||||
const AccordionItem = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { value: string }
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<View ref={ref} className={cn("border-b", className)} {...props} data-value={value} />
|
||||
))
|
||||
AccordionItem.displayName = "AccordionItem"
|
||||
|
||||
const AccordionTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
// Need to find the parent AccordionItem's value.
|
||||
// In React Native/Taro we can't easily traverse up DOM.
|
||||
// So we assume AccordionItem passes context or we need to explicitly pass value?
|
||||
// Radix does this via context nesting.
|
||||
// Let's create a context for Item.
|
||||
return (
|
||||
<AccordionItemContext.Consumer>
|
||||
{(itemValue) => <AccordionTriggerInternal itemValue={itemValue} className={className} ref={ref} {...props}>{children}</AccordionTriggerInternal>}
|
||||
</AccordionItemContext.Consumer>
|
||||
)
|
||||
})
|
||||
AccordionTrigger.displayName = "AccordionTrigger"
|
||||
|
||||
// Helper context for Item
|
||||
const AccordionItemContext = React.createContext<string>("")
|
||||
|
||||
// Update AccordionItem to provide context
|
||||
const AccordionItemWithContext = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { value: string }
|
||||
>(({ className, value, children, ...props }, ref) => (
|
||||
<AccordionItemContext.Provider value={value}>
|
||||
<View ref={ref} className={cn("border-b", className)} {...props}>
|
||||
{children}
|
||||
</View>
|
||||
</AccordionItemContext.Provider>
|
||||
))
|
||||
AccordionItemWithContext.displayName = "AccordionItem"
|
||||
|
||||
|
||||
const AccordionTriggerInternal = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { itemValue: string }
|
||||
>(({ className, children, itemValue, ...props }, ref) => {
|
||||
const context = React.useContext(AccordionContext)
|
||||
const isOpen = Array.isArray(context?.value)
|
||||
? context?.value.includes(itemValue)
|
||||
: context?.value === itemValue
|
||||
|
||||
return (
|
||||
<View className="flex">
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 items-center justify-between py-4 font-medium transition-all",
|
||||
className
|
||||
)}
|
||||
onClick={() => context?.onValueChange?.(itemValue)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronsUpDown className={cn("shrink-0 transition-transform duration-200", isOpen && "rotate-180")} size={16} color="inherit" />
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
const AccordionContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<AccordionItemContext.Consumer>
|
||||
{(itemValue) => <AccordionContentInternal itemValue={itemValue} className={className} ref={ref} {...props}>{children}</AccordionContentInternal>}
|
||||
</AccordionItemContext.Consumer>
|
||||
))
|
||||
AccordionContent.displayName = "AccordionContent"
|
||||
|
||||
const AccordionContentInternal = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { itemValue: string }
|
||||
>(({ className, children, itemValue, ...props }, ref) => {
|
||||
const context = React.useContext(AccordionContext)
|
||||
const isOpen = Array.isArray(context?.value)
|
||||
? context?.value.includes(itemValue)
|
||||
: context?.value === itemValue
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className="overflow-hidden text-sm transition-all data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down"
|
||||
{...props}
|
||||
>
|
||||
<View className={cn("pb-4 pt-0", className)}>{children}</View>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
export { Accordion, AccordionItemWithContext as AccordionItem, AccordionTrigger, AccordionContent }
|
||||
@@ -0,0 +1,260 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { buttonVariants } from "@/components/ui/button"
|
||||
import { Portal } from "@/components/ui/portal"
|
||||
import { useKeyboardOffset } from "@/lib/hooks/use-keyboard-offset"
|
||||
|
||||
const AlertDialogContext = React.createContext<{
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
} | null>(null)
|
||||
|
||||
const usePresence = (open: boolean | undefined, durationMs: number) => {
|
||||
const [present, setPresent] = React.useState(!!open)
|
||||
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
setPresent(true)
|
||||
return
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => setPresent(false), durationMs)
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
}, [open, durationMs])
|
||||
|
||||
return present
|
||||
}
|
||||
|
||||
const AlertDialog = ({
|
||||
children,
|
||||
open: openProp,
|
||||
defaultOpen = false,
|
||||
onOpenChange
|
||||
}: {
|
||||
children: React.ReactNode,
|
||||
open?: boolean,
|
||||
defaultOpen?: boolean,
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}) => {
|
||||
const [openState, setOpenState] = React.useState(defaultOpen || false)
|
||||
const open = openProp !== undefined ? openProp : openState
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (openProp === undefined) {
|
||||
setOpenState(newOpen)
|
||||
}
|
||||
onOpenChange?.(newOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialogContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
|
||||
{children}
|
||||
</AlertDialogContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const AlertDialogTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const context = React.useContext(AlertDialogContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
context?.onOpenChange?.(true)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
AlertDialogTrigger.displayName = "AlertDialogTrigger"
|
||||
|
||||
const AlertDialogPortal = ({ children }) => {
|
||||
const context = React.useContext(AlertDialogContext)
|
||||
const present = usePresence(context?.open, 200)
|
||||
if (!present) return null
|
||||
return <Portal>{children}</Portal>
|
||||
}
|
||||
|
||||
const AlertDialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const context = React.useContext(AlertDialogContext)
|
||||
const state = context?.open ? "open" : "closed"
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black bg-opacity-10 transition-opacity duration-100 supports-[backdrop-filter]:backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// Unlike Dialog, AlertDialog typically forces explicit action/cancel.
|
||||
// But user might want it to close on overlay click.
|
||||
// Standard shadcn/radix alert dialog usually does NOT close on overlay click?
|
||||
// Radix Alert Dialog does NOT close on overlay click by default.
|
||||
// We will leave it as is (no close on click) or optional?
|
||||
// For now, let's follow standard pattern: it blocks interaction.
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
AlertDialogOverlay.displayName = "AlertDialogOverlay"
|
||||
|
||||
const AlertDialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, children, style, ...props }, ref) => {
|
||||
const context = React.useContext(AlertDialogContext)
|
||||
const offset = useKeyboardOffset()
|
||||
const state = context?.open ? "open" : "closed"
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<View className="fixed inset-0 z-50">
|
||||
<AlertDialogOverlay />
|
||||
<View
|
||||
ref={ref}
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-xl",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
...(style as object),
|
||||
top: offset > 0 ? `calc(50% - ${offset / 2}px)` : undefined
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
})
|
||||
AlertDialogContent.displayName = "AlertDialogContent"
|
||||
|
||||
const AlertDialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View>) => (
|
||||
<View
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogHeader.displayName = "AlertDialogHeader"
|
||||
|
||||
const AlertDialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View>) => (
|
||||
<View
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
AlertDialogFooter.displayName = "AlertDialogFooter"
|
||||
|
||||
const AlertDialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogTitle.displayName = "AlertDialogTitle"
|
||||
|
||||
const AlertDialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDialogDescription.displayName = "AlertDialogDescription"
|
||||
|
||||
const AlertDialogAction = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & VariantProps<typeof buttonVariants>
|
||||
>(({ className, variant, size, onClick, ...props }, ref) => {
|
||||
const context = React.useContext(AlertDialogContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(buttonVariants({ variant, size }), "w-full sm:w-auto", className)}
|
||||
onClick={(e) => {
|
||||
context?.onOpenChange?.(false)
|
||||
onClick?.(e)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
AlertDialogAction.displayName = "AlertDialogAction"
|
||||
|
||||
const AlertDialogCancel = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & VariantProps<typeof buttonVariants>
|
||||
>(({ className, variant = "outline", size, onClick, ...props }, ref) => {
|
||||
const context = React.useContext(AlertDialogContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
buttonVariants({ variant, size }),
|
||||
"mt-2 sm:mt-0 w-full sm:w-auto",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
context?.onOpenChange?.(false)
|
||||
onClick?.(e)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
AlertDialogCancel.displayName = "AlertDialogCancel"
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogPortal,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogTrigger,
|
||||
AlertDialogContent,
|
||||
AlertDialogHeader,
|
||||
AlertDialogFooter,
|
||||
AlertDialogTitle,
|
||||
AlertDialogDescription,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border p-4 [&>svg+*]:pl-7 [&>svg+*+*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive border-opacity-50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,36 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
|
||||
const AspectRatio = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
Omit<React.ComponentPropsWithoutRef<typeof View>, "style"> & {
|
||||
ratio?: number
|
||||
style?: React.CSSProperties
|
||||
}
|
||||
>(({ className, ratio = 1 / 1, style, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
paddingBottom: `${100 / ratio}%`,
|
||||
...style
|
||||
}}
|
||||
className={className}
|
||||
{...props}
|
||||
>
|
||||
<View style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
</View>
|
||||
</View>
|
||||
))
|
||||
AspectRatio.displayName = "AspectRatio"
|
||||
|
||||
export { AspectRatio }
|
||||
@@ -0,0 +1,84 @@
|
||||
import * as React from "react"
|
||||
import { View, Image } from "@tarojs/components"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const AvatarContext = React.createContext<{
|
||||
status: "loading" | "error" | "loaded"
|
||||
setStatus: (status: "loading" | "error" | "loaded") => void
|
||||
} | null>(null)
|
||||
|
||||
const Avatar = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const [status, setStatus] = React.useState<"loading" | "error" | "loaded">("loading")
|
||||
return (
|
||||
<AvatarContext.Provider value={{ status, setStatus }}>
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AvatarContext.Provider>
|
||||
)
|
||||
})
|
||||
Avatar.displayName = "Avatar"
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof Image>,
|
||||
React.ComponentPropsWithoutRef<typeof Image>
|
||||
>(({ className, src, ...props }, ref) => {
|
||||
const context = React.useContext(AvatarContext)
|
||||
|
||||
const handleLoad = (e) => {
|
||||
context?.setStatus("loaded")
|
||||
props.onLoad?.(e)
|
||||
}
|
||||
|
||||
const handleError = (e) => {
|
||||
context?.setStatus("error")
|
||||
props.onError?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<Image
|
||||
ref={ref}
|
||||
src={src}
|
||||
className={cn(
|
||||
"aspect-square h-full w-full",
|
||||
className,
|
||||
context?.status !== "loaded" && "w-0 h-0 opacity-0 absolute"
|
||||
)}
|
||||
onLoad={handleLoad}
|
||||
onError={handleError}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
AvatarImage.displayName = "AvatarImage"
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const context = React.useContext(AvatarContext)
|
||||
|
||||
if (context?.status === "loaded") return null
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full items-center justify-center rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
AvatarFallback.displayName = "AvatarFallback"
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback }
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary hover:bg-opacity-80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary hover:bg-opacity-80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive hover:bg-opacity-80",
|
||||
outline: "text-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.ComponentPropsWithoutRef<typeof View>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<View className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -0,0 +1,117 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { ChevronRight, Ellipsis } from "lucide-react-taro"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Breadcrumb = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
separator?: React.ReactNode
|
||||
}
|
||||
>(({ ...props }, ref) => <View ref={ref} aria-label="breadcrumb" {...props} />)
|
||||
Breadcrumb.displayName = "Breadcrumb"
|
||||
|
||||
const BreadcrumbList = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-2 break-words text-sm text-muted-foreground sm:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbList.displayName = "BreadcrumbList"
|
||||
|
||||
const BreadcrumbItem = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("inline-flex items-center gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbItem.displayName = "BreadcrumbItem"
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
asChild?: boolean
|
||||
href?: string
|
||||
}
|
||||
>(({ asChild, className, href, ...props }, ref) => {
|
||||
const linkProps = href ? { url: href } : {}
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("transition-colors hover:text-foreground", className)}
|
||||
{...linkProps}
|
||||
{...props as any}
|
||||
/>
|
||||
)
|
||||
})
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink"
|
||||
|
||||
const BreadcrumbPage = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("font-normal text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
BreadcrumbPage.displayName = "BreadcrumbPage"
|
||||
|
||||
const BreadcrumbSeparator = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View>) => (
|
||||
<View
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:w-4 [&>svg]:h-4 flex items-center", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight size={16} color="inherit" />}
|
||||
</View>
|
||||
)
|
||||
BreadcrumbSeparator.displayName = "BreadcrumbSeparator"
|
||||
|
||||
const BreadcrumbEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View>) => (
|
||||
<View
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<Ellipsis color="#737373" size={16} />
|
||||
<View className="sr-only">More</View>
|
||||
</View>
|
||||
)
|
||||
BreadcrumbEllipsis.displayName = "BreadcrumbElipssis"
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>view:nth-child(n+2)]:rounded-l-none [&>view:nth-child(n+2)]:border-l-0 [&>view:nth-last-child(n+2)]:rounded-r-none",
|
||||
vertical:
|
||||
"flex-col [&>view:nth-child(n+2)]:rounded-t-none [&>view:nth-child(n+2)]:border-t-0 [&>view:nth-last-child(n+2)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<View
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
|
||||
return (
|
||||
<View
|
||||
className={cn(
|
||||
"bg-muted shadow-xs flex items-center gap-2 rounded-md border px-4 text-sm font-medium [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background active:translate-y-px disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary hover:bg-opacity-90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive hover:bg-opacity-90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary hover:bg-opacity-80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ComponentPropsWithoutRef<typeof View>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
const Button = React.forwardRef<React.ElementRef<typeof View>, ButtonProps>(
|
||||
({ className, variant, size, asChild = false, disabled, ...props }, ref) => {
|
||||
const tabIndex = (props as { tabIndex?: number }).tabIndex ?? (disabled ? -1 : 0)
|
||||
return (
|
||||
<View
|
||||
className={cn(
|
||||
buttonVariants({ variant, size, className }),
|
||||
disabled && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
ref={ref}
|
||||
{...({ tabIndex } as { tabIndex?: number })}
|
||||
hoverClass={
|
||||
disabled
|
||||
? undefined
|
||||
: "border-ring ring-2 ring-ring ring-offset-2 ring-offset-background"
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
)
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -0,0 +1,394 @@
|
||||
import * as React from "react"
|
||||
import { Picker, Text, View } from "@tarojs/components"
|
||||
import { ChevronDown, ChevronLeft, ChevronRight } from "lucide-react-taro"
|
||||
import {
|
||||
addDays,
|
||||
addMonths,
|
||||
endOfMonth,
|
||||
endOfWeek,
|
||||
format,
|
||||
isAfter,
|
||||
isBefore,
|
||||
isSameDay,
|
||||
isSameMonth,
|
||||
startOfMonth,
|
||||
startOfWeek,
|
||||
subMonths,
|
||||
} from "date-fns"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type DateRange = { from?: Date; to?: Date }
|
||||
|
||||
type CommonProps = {
|
||||
className?: string
|
||||
month?: Date
|
||||
defaultMonth?: Date
|
||||
onMonthChange?: (month: Date) => void
|
||||
showOutsideDays?: boolean
|
||||
weekStartsOn?: 0 | 1 | 2 | 3 | 4 | 5 | 6
|
||||
disabled?: ((date: Date) => boolean) | Date[]
|
||||
captionLayout?: "label" | "dropdown"
|
||||
fromYear?: number
|
||||
toYear?: number
|
||||
}
|
||||
|
||||
type SingleProps = CommonProps & {
|
||||
mode?: "single"
|
||||
selected?: Date
|
||||
onSelect?: (date: Date | undefined) => void
|
||||
}
|
||||
|
||||
type RangeProps = CommonProps & {
|
||||
mode: "range"
|
||||
selected?: DateRange
|
||||
onSelect?: (range: DateRange | undefined) => void
|
||||
}
|
||||
|
||||
type CalendarProps = SingleProps | RangeProps
|
||||
|
||||
function isDateDisabled(date: Date, disabled?: CalendarProps["disabled"]) {
|
||||
if (!disabled) return false
|
||||
if (Array.isArray(disabled)) return disabled.some((d) => isSameDay(d, date))
|
||||
return disabled(date)
|
||||
}
|
||||
|
||||
function isInRange(date: Date, range?: DateRange) {
|
||||
if (!range?.from || !range?.to) return false
|
||||
return (
|
||||
(isAfter(date, range.from) || isSameDay(date, range.from)) &&
|
||||
(isBefore(date, range.to) || isSameDay(date, range.to))
|
||||
)
|
||||
}
|
||||
|
||||
function getSingleSelected(props: CalendarProps) {
|
||||
return props.mode === "range" ? undefined : props.selected
|
||||
}
|
||||
|
||||
function getRangeSelected(props: CalendarProps) {
|
||||
return props.mode === "range" ? props.selected : undefined
|
||||
}
|
||||
|
||||
function Calendar({
|
||||
className,
|
||||
month,
|
||||
defaultMonth,
|
||||
onMonthChange,
|
||||
showOutsideDays = true,
|
||||
weekStartsOn = 0,
|
||||
disabled,
|
||||
captionLayout = "dropdown",
|
||||
fromYear,
|
||||
toYear,
|
||||
...props
|
||||
}: CalendarProps) {
|
||||
const singleSelected = getSingleSelected({ month, defaultMonth, onMonthChange, showOutsideDays, weekStartsOn, disabled, className, ...props } as CalendarProps)
|
||||
const rangeSelected = getRangeSelected({ month, defaultMonth, onMonthChange, showOutsideDays, weekStartsOn, disabled, className, ...props } as CalendarProps)
|
||||
|
||||
const initialMonth = React.useMemo(() => {
|
||||
if (month) return month
|
||||
if (defaultMonth) return defaultMonth
|
||||
if (singleSelected) return singleSelected
|
||||
if (rangeSelected?.from) return rangeSelected.from
|
||||
return new Date()
|
||||
}, [defaultMonth, month, rangeSelected?.from, singleSelected])
|
||||
|
||||
const [uncontrolledMonth, setUncontrolledMonth] = React.useState<Date>(
|
||||
initialMonth
|
||||
)
|
||||
const visibleMonth = month ?? uncontrolledMonth
|
||||
|
||||
const setMonth = React.useCallback(
|
||||
(next: Date) => {
|
||||
if (!month) setUncontrolledMonth(next)
|
||||
onMonthChange?.(next)
|
||||
},
|
||||
[month, onMonthChange]
|
||||
)
|
||||
|
||||
const captionHasDropdown = captionLayout === "dropdown"
|
||||
const captionHasButtons = true
|
||||
|
||||
const yearOptions = React.useMemo(() => {
|
||||
const baseYear = new Date().getFullYear()
|
||||
const visibleYear = visibleMonth.getFullYear()
|
||||
const min = fromYear ?? baseYear - 100
|
||||
const max = toYear ?? baseYear + 20
|
||||
const start = Math.min(min, visibleYear)
|
||||
const end = Math.max(max, visibleYear)
|
||||
return Array.from({ length: end - start + 1 }, (_, i) => start + i)
|
||||
}, [fromYear, toYear, visibleMonth])
|
||||
|
||||
const monthOptions = React.useMemo(() => {
|
||||
return Array.from({ length: 12 }, (_, i) => i + 1)
|
||||
}, [])
|
||||
|
||||
const yearIndex = React.useMemo(() => {
|
||||
const y = visibleMonth.getFullYear()
|
||||
const idx = yearOptions.indexOf(y)
|
||||
return idx >= 0 ? idx : 0
|
||||
}, [visibleMonth, yearOptions])
|
||||
|
||||
const monthIndex = React.useMemo(() => {
|
||||
return visibleMonth.getMonth()
|
||||
}, [visibleMonth])
|
||||
|
||||
const setYear = React.useCallback(
|
||||
(year: number) => {
|
||||
setMonth(new Date(year, visibleMonth.getMonth(), 1))
|
||||
},
|
||||
[setMonth, visibleMonth]
|
||||
)
|
||||
|
||||
const setMonthOfYear = React.useCallback(
|
||||
(monthOfYear: number) => {
|
||||
setMonth(new Date(visibleMonth.getFullYear(), monthOfYear - 1, 1))
|
||||
},
|
||||
[setMonth, visibleMonth]
|
||||
)
|
||||
|
||||
const gridStart = React.useMemo(() => {
|
||||
return startOfWeek(startOfMonth(visibleMonth), { weekStartsOn })
|
||||
}, [visibleMonth, weekStartsOn])
|
||||
|
||||
const gridEnd = React.useMemo(() => {
|
||||
return endOfWeek(endOfMonth(visibleMonth), { weekStartsOn })
|
||||
}, [visibleMonth, weekStartsOn])
|
||||
|
||||
const weeks = React.useMemo(() => {
|
||||
const days: Date[] = []
|
||||
for (
|
||||
let d = gridStart;
|
||||
!isAfter(d, gridEnd);
|
||||
d = addDays(d, 1)
|
||||
) {
|
||||
days.push(d)
|
||||
}
|
||||
const rows: Date[][] = []
|
||||
for (let i = 0; i < days.length; i += 7) rows.push(days.slice(i, i + 7))
|
||||
return rows
|
||||
}, [gridEnd, gridStart])
|
||||
|
||||
const weekdays = React.useMemo(() => {
|
||||
const labels = ["日", "一", "二", "三", "四", "五", "六"]
|
||||
return Array.from({ length: 7 }).map((_, i) => labels[(i + weekStartsOn) % 7])
|
||||
}, [weekStartsOn])
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(date: Date) => {
|
||||
if (isDateDisabled(date, disabled)) return
|
||||
if (props.mode === "range") {
|
||||
const current = props.selected
|
||||
let next: DateRange
|
||||
if (!current?.from || (current.from && current.to)) {
|
||||
next = { from: date, to: undefined }
|
||||
} else if (current.from && !current.to) {
|
||||
if (isBefore(date, current.from)) {
|
||||
next = { from: date, to: current.from }
|
||||
} else {
|
||||
next = { from: current.from, to: date }
|
||||
}
|
||||
} else {
|
||||
next = { from: date, to: undefined }
|
||||
}
|
||||
props.onSelect?.(next)
|
||||
return
|
||||
}
|
||||
props.onSelect?.(date)
|
||||
},
|
||||
[disabled, props]
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
className={cn(
|
||||
"bg-background w-fit rounded-md p-3",
|
||||
"flex flex-col gap-3 border-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<View className="flex items-center justify-between">
|
||||
{captionHasButtons ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setMonth(subMonths(visibleMonth, 1))}
|
||||
>
|
||||
<ChevronLeft size={16} color="inherit" />
|
||||
</Button>
|
||||
) : (
|
||||
<View className="h-8 w-8" />
|
||||
)}
|
||||
|
||||
{captionHasDropdown ? (
|
||||
<View className="flex items-center gap-2">
|
||||
<Picker
|
||||
mode="selector"
|
||||
range={yearOptions}
|
||||
value={yearIndex}
|
||||
onChange={(e) => setYear(yearOptions[Number(e.detail.value)]!)}
|
||||
>
|
||||
<Button variant="ghost" className="h-8 px-2">
|
||||
<Text className="text-sm font-medium">
|
||||
{visibleMonth.getFullYear()}
|
||||
</Text>
|
||||
<ChevronDown size={16} className="opacity-50" color="inherit" />
|
||||
</Button>
|
||||
</Picker>
|
||||
<Picker
|
||||
mode="selector"
|
||||
range={monthOptions}
|
||||
value={monthIndex}
|
||||
onChange={(e) =>
|
||||
setMonthOfYear(monthOptions[Number(e.detail.value)]!)
|
||||
}
|
||||
>
|
||||
<Button variant="ghost" className="h-8 px-2">
|
||||
<Text className="text-sm font-medium">
|
||||
{String(visibleMonth.getMonth() + 1).padStart(2, "0")}
|
||||
月
|
||||
</Text>
|
||||
<ChevronDown size={16} className="opacity-50" color="inherit" />
|
||||
</Button>
|
||||
</Picker>
|
||||
</View>
|
||||
) : (
|
||||
<Text className="text-sm font-medium">
|
||||
{format(visibleMonth, "yyyy年MM月")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{captionHasButtons ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => setMonth(addMonths(visibleMonth, 1))}
|
||||
>
|
||||
<ChevronRight size={16} color="inherit" />
|
||||
</Button>
|
||||
) : (
|
||||
<View className="h-8 w-8" />
|
||||
)}
|
||||
</View>
|
||||
|
||||
<View className="flex">
|
||||
{weekdays.map((label) => (
|
||||
<View key={label} className="flex flex-1 items-center justify-center">
|
||||
<Text className="text-muted-foreground text-xs font-normal">
|
||||
{label}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
|
||||
<View className="flex flex-col gap-2">
|
||||
{weeks.map((week, rowIndex) => (
|
||||
<View key={rowIndex} className="flex">
|
||||
{week.map((date) => {
|
||||
const outside = !isSameMonth(date, visibleMonth)
|
||||
const hidden = outside && !showOutsideDays
|
||||
const disabledDay = isDateDisabled(date, disabled)
|
||||
const today = isSameDay(date, new Date())
|
||||
|
||||
const range = rangeSelected
|
||||
const selectedSingle = singleSelected
|
||||
? isSameDay(date, singleSelected)
|
||||
: false
|
||||
|
||||
const rangeStart = range?.from ? isSameDay(date, range.from) : false
|
||||
const rangeEnd = range?.to ? isSameDay(date, range.to) : false
|
||||
const rangeMiddle =
|
||||
!!range?.from && !!range?.to && isInRange(date, range) && !rangeStart && !rangeEnd
|
||||
|
||||
return (
|
||||
<View
|
||||
key={date.toISOString()}
|
||||
className={cn("flex flex-1 items-center justify-center", hidden && "invisible")}
|
||||
>
|
||||
<CalendarDayButton
|
||||
date={date}
|
||||
outside={outside}
|
||||
today={today}
|
||||
disabled={disabledDay}
|
||||
selectedSingle={selectedSingle}
|
||||
rangeStart={rangeStart}
|
||||
rangeMiddle={rangeMiddle}
|
||||
rangeEnd={rangeEnd}
|
||||
onPress={handleSelect}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
type CalendarDayButtonProps = {
|
||||
date: Date
|
||||
outside: boolean
|
||||
today: boolean
|
||||
disabled: boolean
|
||||
selectedSingle: boolean
|
||||
rangeStart: boolean
|
||||
rangeMiddle: boolean
|
||||
rangeEnd: boolean
|
||||
onPress: (date: Date) => void
|
||||
}
|
||||
|
||||
function CalendarDayButton({
|
||||
date,
|
||||
outside,
|
||||
today,
|
||||
disabled,
|
||||
selectedSingle,
|
||||
rangeStart,
|
||||
rangeMiddle,
|
||||
rangeEnd,
|
||||
onPress,
|
||||
}: CalendarDayButtonProps) {
|
||||
const base = "h-8 w-8 p-0 flex items-center justify-center rounded-md"
|
||||
const outsideClass = outside ? "text-muted-foreground" : ""
|
||||
const todayClass = today ? "bg-accent text-accent-foreground" : ""
|
||||
const selectedSingleClass = selectedSingle
|
||||
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
|
||||
: ""
|
||||
const rangeStartClass = rangeStart
|
||||
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
|
||||
: ""
|
||||
const rangeEndClass = rangeEnd
|
||||
? "bg-primary text-primary-foreground hover:bg-primary hover:text-primary-foreground"
|
||||
: ""
|
||||
const rangeMiddleClass = rangeMiddle
|
||||
? "bg-accent text-accent-foreground rounded-none"
|
||||
: ""
|
||||
const rangeCapClass = rangeStart || rangeEnd ? "rounded-md" : ""
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
base,
|
||||
outsideClass,
|
||||
todayClass,
|
||||
selectedSingleClass,
|
||||
rangeMiddleClass,
|
||||
rangeStartClass,
|
||||
rangeEndClass,
|
||||
rangeCapClass,
|
||||
disabled && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
onClick={disabled ? undefined : () => onPress(date)}
|
||||
>
|
||||
<Text className="text-sm">{format(date, "d")}</Text>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export { Calendar, CalendarDayButton }
|
||||
@@ -0,0 +1,108 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// 创建一个上下文来跟踪卡片内部的状态
|
||||
const CardContext = React.createContext<{ hasHeader: boolean }>({
|
||||
hasHeader: false,
|
||||
})
|
||||
|
||||
const Card = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
// 检查子元素中是否有 CardHeader
|
||||
const hasHeader = React.Children.toArray(children).some(
|
||||
(child) => React.isValidElement(child) && (child.type as any).displayName === "CardHeader"
|
||||
)
|
||||
|
||||
return (
|
||||
<CardContext.Provider value={{ hasHeader }}>
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</CardContext.Provider>
|
||||
)
|
||||
})
|
||||
Card.displayName = "Card"
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-2 p-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { hasHeader } = React.useContext(CardContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("p-6", hasHeader && "pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
CardContent.displayName = "CardContent"
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { hasHeader } = React.useContext(CardContext)
|
||||
// 注意:Footer 通常也跟在 Content 后面,所以这里逻辑可以更精细,
|
||||
// 但为了简单通用,如果卡片有 Header,Footer 默认 pt-0 也是合理的。
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6", hasHeader && "pt-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
CardFooter.displayName = "CardFooter"
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -0,0 +1,228 @@
|
||||
import * as React from "react"
|
||||
import { View, Swiper, SwiperItem } from "@tarojs/components"
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react-taro"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: {
|
||||
loop?: boolean
|
||||
autoplay?: boolean
|
||||
interval?: number
|
||||
duration?: number
|
||||
displayMultipleItems?: number
|
||||
}
|
||||
orientation?: "horizontal" | "vertical"
|
||||
setApi?: (api: CarouselApi) => void
|
||||
className?: string
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export type CarouselApi = {
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
scrollTo: (index: number) => void
|
||||
selectedScrollSnap: () => number
|
||||
}
|
||||
|
||||
type CarouselContextProps = {
|
||||
orientation: "horizontal" | "vertical"
|
||||
current: number
|
||||
setCurrent: (index: number) => void
|
||||
count: number
|
||||
setCount: (count: number) => void
|
||||
scrollPrev: () => void
|
||||
scrollNext: () => void
|
||||
canScrollPrev: boolean
|
||||
canScrollNext: boolean
|
||||
opts?: CarouselProps["opts"]
|
||||
}
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext)
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />")
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
const Carousel = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
CarouselProps
|
||||
>(({ opts, orientation = "horizontal", setApi, className, children, ...props }, ref) => {
|
||||
const [current, setCurrent] = React.useState(0)
|
||||
const [count, setCount] = React.useState(0)
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
setCurrent((prev) => Math.max(0, prev - 1))
|
||||
}, [])
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
setCurrent((prev) => Math.min(count - 1, prev + 1))
|
||||
}, [count])
|
||||
|
||||
const canScrollPrev = current > 0
|
||||
const canScrollNext = current < count - 1
|
||||
|
||||
const scrollTo = React.useCallback((index: number) => {
|
||||
setCurrent(index)
|
||||
}, [])
|
||||
|
||||
const selectedScrollSnap = React.useCallback(() => current, [current])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (setApi) {
|
||||
setApi({
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
scrollTo,
|
||||
selectedScrollSnap,
|
||||
})
|
||||
}
|
||||
}, [setApi, scrollPrev, scrollNext, canScrollPrev, canScrollNext, scrollTo, selectedScrollSnap])
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
orientation,
|
||||
current,
|
||||
setCurrent,
|
||||
count,
|
||||
setCount,
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
opts,
|
||||
}}
|
||||
>
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("relative w-full", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</CarouselContext.Provider>
|
||||
)
|
||||
})
|
||||
Carousel.displayName = "Carousel"
|
||||
|
||||
const CarouselContent = React.forwardRef<
|
||||
React.ElementRef<typeof Swiper>,
|
||||
React.ComponentPropsWithoutRef<typeof Swiper>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { orientation, current, setCurrent, setCount, opts } = useCarousel()
|
||||
|
||||
React.useEffect(() => {
|
||||
const childCount = React.Children.count(children)
|
||||
setCount(childCount)
|
||||
}, [children, setCount])
|
||||
|
||||
return (
|
||||
<View className={cn("overflow-hidden", className)}>
|
||||
<Swiper
|
||||
ref={ref}
|
||||
className="h-full w-full"
|
||||
vertical={orientation === "vertical"}
|
||||
current={current}
|
||||
onChange={(e) => setCurrent(e.detail.current)}
|
||||
circular={opts?.loop}
|
||||
autoplay={opts?.autoplay}
|
||||
interval={opts?.interval || 5000}
|
||||
duration={opts?.duration || 500}
|
||||
displayMultipleItems={opts?.displayMultipleItems || 1}
|
||||
{...props}
|
||||
>
|
||||
{React.Children.map(children, (child) => {
|
||||
if (React.isValidElement(child)) {
|
||||
return <SwiperItem className="h-full w-full">{child}</SwiperItem>
|
||||
}
|
||||
return null
|
||||
})}
|
||||
</Swiper>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
CarouselContent.displayName = "CarouselContent"
|
||||
|
||||
const CarouselItem = ({ className, children, ...props }: React.ComponentProps<typeof View>) => {
|
||||
return (
|
||||
<View className={cn("h-full w-full", className)} {...props}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
CarouselItem.displayName = "CarouselItem"
|
||||
|
||||
const CarouselPrevious = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full z-10 bg-background bg-opacity-80 backdrop-blur-sm",
|
||||
orientation === "horizontal"
|
||||
? "-left-12 top-0 bottom-0 my-auto"
|
||||
: "-top-12 left-0 right-0 mx-auto rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft size={16} color="inherit" />
|
||||
<View className="sr-only">Previous slide</View>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselPrevious.displayName = "CarouselPrevious"
|
||||
|
||||
const CarouselNext = React.forwardRef<
|
||||
React.ElementRef<typeof Button>,
|
||||
React.ComponentProps<typeof Button>
|
||||
>(({ className, variant = "outline", size = "icon", ...props }, ref) => {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel()
|
||||
|
||||
return (
|
||||
<Button
|
||||
ref={ref}
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute h-8 w-8 rounded-full z-10 bg-background bg-opacity-80 backdrop-blur-sm",
|
||||
orientation === "horizontal"
|
||||
? "-right-12 top-0 bottom-0 my-auto"
|
||||
: "-bottom-12 left-0 right-0 mx-auto rotate-90",
|
||||
className
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight size={16} color="inherit" />
|
||||
<View className="sr-only">Next slide</View>
|
||||
</Button>
|
||||
)
|
||||
})
|
||||
CarouselNext.displayName = "CarouselNext"
|
||||
|
||||
export {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { Check } from "lucide-react-taro"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Checkbox = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
Omit<React.ComponentPropsWithoutRef<typeof View>, "onClick"> & {
|
||||
checked?: boolean
|
||||
defaultChecked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
>(({ className, checked: checkedProp, defaultChecked, onCheckedChange, disabled, ...props }, ref) => {
|
||||
const [checkedState, setCheckedState] = React.useState<boolean>(
|
||||
defaultChecked ?? false
|
||||
)
|
||||
|
||||
const isControlled = checkedProp !== undefined
|
||||
const checked = isControlled ? checkedProp : checkedState
|
||||
|
||||
const handleClick = (e) => {
|
||||
if (disabled) return
|
||||
e.stopPropagation()
|
||||
const newChecked = !checked
|
||||
if (!isControlled) {
|
||||
setCheckedState(newChecked)
|
||||
}
|
||||
onCheckedChange?.(newChecked)
|
||||
}
|
||||
|
||||
const tabIndex = (props as any).tabIndex ?? (disabled ? -1 : 0)
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-4 w-4 shrink-0 rounded-sm border-2 border-primary ring-offset-background flex items-center justify-center focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50",
|
||||
checked ? "bg-primary text-primary-foreground" : "bg-transparent",
|
||||
className
|
||||
)}
|
||||
{...({ tabIndex } as any)}
|
||||
hoverClass={
|
||||
disabled
|
||||
? undefined
|
||||
: "border-ring ring-2 ring-ring ring-offset-2 ring-offset-background"
|
||||
}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
>
|
||||
{checked && <Check color="#fff" size={12} strokeWidth={3} className="text-current" />}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
Checkbox.displayName = "Checkbox"
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,169 @@
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { useMemo } from 'react'
|
||||
import { Copy } from 'lucide-react-taro'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { cn } from '@/lib/utils'
|
||||
import Taro from '@tarojs/taro'
|
||||
import type { FC } from 'react'
|
||||
|
||||
type TokenType =
|
||||
| 'keyword'
|
||||
| 'string'
|
||||
| 'comment'
|
||||
| 'number'
|
||||
| 'function'
|
||||
| 'tag'
|
||||
| 'attr'
|
||||
| 'operator'
|
||||
| 'plain'
|
||||
|
||||
interface Token {
|
||||
type: TokenType
|
||||
content: string
|
||||
}
|
||||
|
||||
const RULES: { type: TokenType; regex: RegExp }[] = (
|
||||
[
|
||||
{ type: 'comment', regex: /\/\/.*/ },
|
||||
{ type: 'comment', regex: /\/\*[\s\S]*?\*\// },
|
||||
{
|
||||
type: 'string',
|
||||
regex: /"(?:\\.|[^\\"])*"|'(?:\\.|[^\\'])*'|`(?:\\.|[^\\`])*`/,
|
||||
},
|
||||
{
|
||||
type: 'keyword',
|
||||
regex: /\b(?:import|from|export|default|const|let|var|function|return|if|else|switch|case|break|continue|for|while|do|try|catch|finally|throw|new|this|super|class|extends|implements|interface|type|enum|namespace|as|async|await|yield|void|delete|typeof|instanceof|in|of|null|undefined|true|false)\b/,
|
||||
},
|
||||
{ type: 'number', regex: /\b\d+(\.\d+)?\b/ },
|
||||
{ type: 'tag', regex: /<\/?[a-zA-Z][a-zA-Z0-9]*\b/ },
|
||||
{ type: 'attr', regex: /\b[a-z][a-z0-9]*(?==)/i },
|
||||
{ type: 'function', regex: /\b[a-zA-Z_$][a-zA-Z0-9_$]*(?=\s*\()/ },
|
||||
{ type: 'operator', regex: /[+\-*/%=<>!&|^~]/ },
|
||||
] as { type: TokenType; regex: RegExp }[]
|
||||
).map(rule => ({
|
||||
...rule,
|
||||
regex: new RegExp(rule.regex.source, (rule.regex.flags || '') + 'y'),
|
||||
}))
|
||||
|
||||
function tokenize(code: string): Token[] {
|
||||
const tokens: Token[] = []
|
||||
let lastIndex = 0
|
||||
|
||||
while (lastIndex < code.length) {
|
||||
let matchFound = false
|
||||
|
||||
for (const rule of RULES) {
|
||||
rule.regex.lastIndex = lastIndex
|
||||
const match = rule.regex.exec(code)
|
||||
if (match) {
|
||||
tokens.push({ type: rule.type, content: match[0] })
|
||||
lastIndex += match[0].length
|
||||
matchFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchFound) {
|
||||
let plainContent = code[lastIndex]
|
||||
lastIndex++
|
||||
|
||||
// Look ahead for next match to group plain text
|
||||
while (lastIndex < code.length) {
|
||||
let nextMatch = false
|
||||
for (const rule of RULES) {
|
||||
rule.regex.lastIndex = lastIndex
|
||||
const match = rule.regex.exec(code)
|
||||
if (match) {
|
||||
nextMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (nextMatch) break
|
||||
plainContent += code[lastIndex]
|
||||
lastIndex++
|
||||
}
|
||||
tokens.push({ type: 'plain', content: plainContent })
|
||||
}
|
||||
}
|
||||
|
||||
return tokens
|
||||
}
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
scrollAreaClassName?: string
|
||||
showCopyButton?: boolean
|
||||
language?: string
|
||||
}
|
||||
|
||||
const getTokenColor = (type: TokenType) => {
|
||||
switch (type) {
|
||||
case 'keyword': return '#D73A49' // red
|
||||
case 'string': return '#032F62' // dark blue
|
||||
case 'comment': return '#6A737D' // gray
|
||||
case 'number': return '#005CC5' // blue
|
||||
case 'function': return '#6F42C1' // purple
|
||||
case 'tag': return '#005CC5' // blue
|
||||
case 'attr': return '#6F42C1' // purple
|
||||
case 'operator': return '#D73A49' // red
|
||||
default: return '#24292E'
|
||||
}
|
||||
}
|
||||
|
||||
const CodeBlock: FC<CodeBlockProps> = ({
|
||||
code,
|
||||
className,
|
||||
style,
|
||||
scrollAreaClassName,
|
||||
showCopyButton = true,
|
||||
language
|
||||
}) => {
|
||||
const tokens = useMemo(() => tokenize(code), [code])
|
||||
|
||||
const copyCode = async () => {
|
||||
await Taro.setClipboardData({ data: code })
|
||||
Taro.showToast({ title: '已复制', icon: 'success' })
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={cn("relative w-full overflow-hidden", className)} style={style}>
|
||||
<ScrollArea
|
||||
orientation="both"
|
||||
className={cn("bg-code rounded-lg w-full", scrollAreaClassName)}
|
||||
>
|
||||
<View className="p-4 inline-block box-border min-w-full">
|
||||
{language && (
|
||||
<Text className="absolute top-2 left-2 text-xs text-muted-foreground uppercase font-mono pointer-events-none">
|
||||
{language}
|
||||
</Text>
|
||||
)}
|
||||
<Text className="text-xs font-mono whitespace-pre">
|
||||
{tokens.map((token, i) => (
|
||||
<Text
|
||||
key={i}
|
||||
style={{ color: getTokenColor(token.type) }}
|
||||
>
|
||||
{token.content}
|
||||
</Text>
|
||||
))}
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollArea>
|
||||
{showCopyButton && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-1 right-1 h-6 w-6"
|
||||
onClick={copyCode}
|
||||
>
|
||||
<Copy size={12} color="#a3a3a3" />
|
||||
</Button>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export { CodeBlock }
|
||||
@@ -0,0 +1,71 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
|
||||
const CollapsibleContext = React.createContext<{
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
} | null>(null)
|
||||
|
||||
const Collapsible = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
>(({ open: openProp, defaultOpen, onOpenChange, disabled, ...props }, ref) => {
|
||||
const [openState, setOpenState] = React.useState(defaultOpen || false)
|
||||
const open = openProp !== undefined ? openProp : openState
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (disabled) return
|
||||
if (openProp === undefined) {
|
||||
setOpenState(newOpen)
|
||||
}
|
||||
onOpenChange?.(newOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<CollapsibleContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
|
||||
<View ref={ref} {...props} />
|
||||
</CollapsibleContext.Provider>
|
||||
)
|
||||
})
|
||||
Collapsible.displayName = "Collapsible"
|
||||
|
||||
const CollapsibleTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
asChild?: boolean
|
||||
}
|
||||
>(({ className, onClick, asChild, ...props }, ref) => {
|
||||
const context = React.useContext(CollapsibleContext)
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
context?.onOpenChange(!context.open)
|
||||
onClick?.(e)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
CollapsibleTrigger.displayName = "CollapsibleTrigger"
|
||||
|
||||
const CollapsibleContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const context = React.useContext(CollapsibleContext)
|
||||
|
||||
if (!context?.open) return null
|
||||
|
||||
return <View ref={ref} className={className} {...props} />
|
||||
})
|
||||
CollapsibleContent.displayName = "CollapsibleContent"
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -0,0 +1,385 @@
|
||||
import * as React from "react"
|
||||
import { View, Input, ScrollView } from "@tarojs/components"
|
||||
import Taro from "@tarojs/taro"
|
||||
import { Search } from "lucide-react-taro"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Dialog, DialogContent } from "@/components/ui/dialog"
|
||||
|
||||
const CommandContext = React.createContext<{
|
||||
search: string
|
||||
deferredSearch: string
|
||||
setSearch: (search: string) => void
|
||||
} | null>(null)
|
||||
|
||||
const CommandItemsContext = React.createContext<{
|
||||
setItemState: (id: string, state: ItemState) => void
|
||||
removeItem: (id: string) => void
|
||||
hasAnyMatch: () => boolean
|
||||
groupHasAnyMatch: (groupId: string) => boolean
|
||||
itemsSize: number
|
||||
} | null>(null)
|
||||
|
||||
type ItemState = { match: boolean; groupId?: string }
|
||||
|
||||
const GroupContext = React.createContext<{ groupId?: string } | null>(null)
|
||||
|
||||
function getNodeText(node: React.ReactNode): string {
|
||||
if (node == null || typeof node === "boolean") return ""
|
||||
if (typeof node === "string" || typeof node === "number") return String(node)
|
||||
if (Array.isArray(node)) return node.map(getNodeText).join(" ")
|
||||
if (React.isValidElement(node)) return getNodeText(node.props?.children)
|
||||
return ""
|
||||
}
|
||||
|
||||
const Command = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const [search, setSearch] = React.useState("")
|
||||
// 使用 deferredSearch 来延迟搜索过滤逻辑,确保输入框在输入时保持响应,解决微信小程序中的输入抖动和文字消失问题
|
||||
const deferredSearch = React.useDeferredValue(search)
|
||||
const [, setItemsTick] = React.useState(0)
|
||||
const itemsRef = React.useRef<Map<string, ItemState>>(new Map())
|
||||
const tickRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (tickRef.current) clearTimeout(tickRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const triggerItemsUpdate = React.useCallback(() => {
|
||||
if (tickRef.current) return
|
||||
// 使用短延时批处理项目状态更新,减少重绘频率
|
||||
tickRef.current = setTimeout(() => {
|
||||
setItemsTick((v) => v + 1)
|
||||
tickRef.current = null
|
||||
}, 16)
|
||||
}, [])
|
||||
|
||||
const setItemState = React.useCallback((id: string, state: ItemState) => {
|
||||
const prev = itemsRef.current.get(id)
|
||||
if (prev?.match === state.match && prev?.groupId === state.groupId) return
|
||||
itemsRef.current.set(id, state)
|
||||
triggerItemsUpdate()
|
||||
}, [triggerItemsUpdate])
|
||||
|
||||
const removeItem = React.useCallback((id: string) => {
|
||||
if (!itemsRef.current.has(id)) return
|
||||
itemsRef.current.delete(id)
|
||||
triggerItemsUpdate()
|
||||
}, [triggerItemsUpdate])
|
||||
|
||||
const hasAnyMatch = React.useCallback(() => {
|
||||
for (const s of itemsRef.current.values()) {
|
||||
if (s.match) return true
|
||||
}
|
||||
return false
|
||||
}, [])
|
||||
|
||||
const groupHasAnyMatch = React.useCallback((groupId: string) => {
|
||||
for (const s of itemsRef.current.values()) {
|
||||
if (s.groupId === groupId && s.match) return true
|
||||
}
|
||||
return false
|
||||
}, [])
|
||||
|
||||
const searchContextValue = React.useMemo(() => ({
|
||||
search,
|
||||
deferredSearch,
|
||||
setSearch,
|
||||
}), [search, deferredSearch])
|
||||
|
||||
const itemsContextValue = React.useMemo(() => ({
|
||||
setItemState,
|
||||
removeItem,
|
||||
hasAnyMatch,
|
||||
groupHasAnyMatch,
|
||||
itemsSize: itemsRef.current.size,
|
||||
}), [setItemState, removeItem, hasAnyMatch, groupHasAnyMatch])
|
||||
|
||||
return (
|
||||
<CommandContext.Provider value={searchContextValue}>
|
||||
<CommandItemsContext.Provider value={itemsContextValue}>
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</CommandItemsContext.Provider>
|
||||
</CommandContext.Provider>
|
||||
)
|
||||
})
|
||||
Command.displayName = "Command"
|
||||
|
||||
const CommandDialog = ({ children, ...props }) => {
|
||||
const { open: openProp, defaultOpen, onOpenChange, ...rest } = props as any
|
||||
const [openState, setOpenState] = React.useState(defaultOpen || false)
|
||||
const open = openProp !== undefined ? openProp : openState
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (openProp === undefined) setOpenState(newOpen)
|
||||
onOpenChange?.(newOpen)
|
||||
}
|
||||
|
||||
const enhancedChildren = React.useMemo(() => {
|
||||
const enhance = (node: React.ReactNode): React.ReactNode =>
|
||||
React.Children.map(node, (child) => {
|
||||
if (!React.isValidElement(child)) return child
|
||||
if (child.type === CommandInput) {
|
||||
if (child.props?.focus === false) return child
|
||||
return React.cloneElement(child as any, {
|
||||
focus: open,
|
||||
className: cn(child.props?.className, "pr-11")
|
||||
})
|
||||
}
|
||||
if (!child.props?.children) return child
|
||||
return React.cloneElement(child as any, undefined, enhance(child.props.children))
|
||||
})
|
||||
|
||||
return enhance(children)
|
||||
}, [children, open])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange} {...rest}>
|
||||
<DialogContent
|
||||
className="overflow-hidden p-0 shadow-lg"
|
||||
closeClassName="top-3"
|
||||
>
|
||||
<Command>{enhancedChildren}</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const CommandInput = React.forwardRef<
|
||||
React.ElementRef<typeof Input>,
|
||||
React.ComponentPropsWithoutRef<typeof Input> & { focus?: boolean }
|
||||
>(({ className, placeholderClass, focus = true, ...props }, ref) => {
|
||||
const context = React.useContext(CommandContext)
|
||||
const [localValue, setLocalValue] = React.useState(context?.search ?? "")
|
||||
const lastSyncedSearchRef = React.useRef(context?.search ?? "")
|
||||
const [inputFocus, setInputFocus] = React.useState(false)
|
||||
const focusTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
// 只有当 context.search 与上次同步的值不同,且与当前输入值也不同时,才进行强制同步(通常是外部重置了搜索内容)
|
||||
if (context?.search !== lastSyncedSearchRef.current && context?.search !== localValue) {
|
||||
setLocalValue(context?.search ?? "")
|
||||
lastSyncedSearchRef.current = context?.search ?? ""
|
||||
}
|
||||
}, [context?.search, localValue])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (focusTimerRef.current) clearTimeout(focusTimerRef.current)
|
||||
focusTimerRef.current = null
|
||||
|
||||
if (!focus) {
|
||||
setInputFocus(false)
|
||||
return
|
||||
}
|
||||
|
||||
setInputFocus(false)
|
||||
|
||||
const schedule = () => {
|
||||
focusTimerRef.current = setTimeout(() => {
|
||||
setInputFocus(true)
|
||||
focusTimerRef.current = null
|
||||
}, 0)
|
||||
}
|
||||
|
||||
if (typeof (Taro as any)?.nextTick === "function") {
|
||||
;(Taro as any).nextTick(schedule)
|
||||
} else {
|
||||
schedule()
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (focusTimerRef.current) clearTimeout(focusTimerRef.current)
|
||||
focusTimerRef.current = null
|
||||
}
|
||||
}, [focus])
|
||||
|
||||
return (
|
||||
<View
|
||||
className="flex h-11 items-center border-b px-3"
|
||||
data-slot="command-input-wrapper"
|
||||
>
|
||||
<Search className="mr-2 shrink-0 opacity-50" size={16} color="inherit" />
|
||||
<Input
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-w-0 flex-1 rounded-md bg-transparent text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
placeholderClass={cn("text-muted-foreground", placeholderClass)}
|
||||
value={localValue}
|
||||
onInput={(e) => {
|
||||
const v = e.detail.value
|
||||
setLocalValue(v)
|
||||
lastSyncedSearchRef.current = v
|
||||
context?.setSearch(v)
|
||||
}}
|
||||
focus={inputFocus}
|
||||
{...props}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
CommandInput.displayName = "CommandInput"
|
||||
|
||||
const CommandList = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollView>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollView>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<ScrollView
|
||||
scrollY
|
||||
ref={ref}
|
||||
className={cn("overflow-y-auto overflow-x-hidden", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandList.displayName = "CommandList"
|
||||
|
||||
const CommandEmpty = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const context = React.useContext(CommandItemsContext)
|
||||
|
||||
const show = context ? context.itemsSize > 0 && !context.hasAnyMatch() : false
|
||||
if (!show) return null
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("py-6 text-center text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
CommandEmpty.displayName = "CommandEmpty"
|
||||
|
||||
const CommandGroup = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { heading?: React.ReactNode }
|
||||
>(({ className, heading, children, ...props }, ref) => {
|
||||
const context = React.useContext(CommandItemsContext)
|
||||
const groupId = React.useId()
|
||||
|
||||
const show =
|
||||
!context || context.itemsSize === 0 || context.groupHasAnyMatch(groupId)
|
||||
|
||||
return (
|
||||
<GroupContext.Provider value={{ groupId }}>
|
||||
<View
|
||||
ref={ref}
|
||||
data-slot="command-group"
|
||||
className={cn("overflow-hidden p-1 text-foreground", className)}
|
||||
style={!show ? ({ display: "none" } as any) : undefined}
|
||||
{...props}
|
||||
>
|
||||
{heading && (
|
||||
<View
|
||||
data-slot="command-group-heading"
|
||||
className="px-2 py-2 text-xs font-medium text-muted-foreground"
|
||||
>
|
||||
{heading}
|
||||
</View>
|
||||
)}
|
||||
{children}
|
||||
</View>
|
||||
</GroupContext.Provider>
|
||||
)
|
||||
})
|
||||
CommandGroup.displayName = "CommandGroup"
|
||||
|
||||
const CommandSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("-mx-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CommandSeparator.displayName = "CommandSeparator"
|
||||
|
||||
const CommandItem = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
value?: string
|
||||
onSelect?: (value: string) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
>(({ className, value, onSelect, disabled, children, ...props }, ref) => {
|
||||
const context = React.useContext(CommandContext)
|
||||
const itemsContext = React.useContext(CommandItemsContext)
|
||||
const group = React.useContext(GroupContext)
|
||||
const id = React.useId()
|
||||
|
||||
const computedValue = React.useMemo(() => (value ?? getNodeText(children)).trim(), [value, children])
|
||||
const search = (context?.deferredSearch ?? "").trim().toLowerCase()
|
||||
|
||||
const match = React.useMemo(() =>
|
||||
!search || (!!computedValue && computedValue.toLowerCase().includes(search))
|
||||
, [search, computedValue])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!itemsContext) return
|
||||
itemsContext.setItemState(id, { match, groupId: group?.groupId })
|
||||
return () => itemsContext.removeItem(id)
|
||||
}, [itemsContext, id, match, group?.groupId])
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-2 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
disabled && "opacity-50 pointer-events-none",
|
||||
className
|
||||
)}
|
||||
style={!match ? ({ display: "none" } as any) : undefined}
|
||||
onClick={() => !disabled && onSelect?.(computedValue)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
CommandItem.displayName = "CommandItem"
|
||||
|
||||
const CommandShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View>) => {
|
||||
return (
|
||||
<View
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
CommandShortcut.displayName = "CommandShortcut"
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
import * as React from "react"
|
||||
import { View, ScrollView } from "@tarojs/components"
|
||||
import Taro from "@tarojs/taro"
|
||||
import { Check, ChevronRight, Circle } from "lucide-react-taro"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { isH5 } from "@/lib/platform"
|
||||
import { computePosition, getRectById, getViewport } from "@/lib/measure"
|
||||
import { Portal } from "@/components/ui/portal"
|
||||
|
||||
const ContextMenuContext = React.createContext<{
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
position: { x: number; y: number }
|
||||
setPosition: (pos: { x: number; y: number }) => void
|
||||
activeSubId?: string | null
|
||||
setActiveSubId: (id: string | null) => void
|
||||
} | null>(null)
|
||||
|
||||
interface ContextMenuProps {
|
||||
children: React.ReactNode
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const ContextMenu = ({ children, onOpenChange }: ContextMenuProps) => {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [position, setPosition] = React.useState({ x: 0, y: 0 })
|
||||
const [activeSubId, setActiveSubId] = React.useState<string | null>(null)
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setOpen(newOpen)
|
||||
if (!newOpen) setActiveSubId(null)
|
||||
onOpenChange?.(newOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenuContext.Provider
|
||||
value={{ open, onOpenChange: handleOpenChange, position, setPosition, activeSubId, setActiveSubId }}
|
||||
>
|
||||
{children}
|
||||
</ContextMenuContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ContextMenuTrigger = React.forwardRef<
|
||||
any,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { disabled?: boolean }
|
||||
>(({ className, children, disabled, ...props }, ref) => {
|
||||
const context = React.useContext(ContextMenuContext)
|
||||
const touchPos = React.useRef({ x: 0, y: 0 })
|
||||
|
||||
const handleTrigger = (x: number, y: number) => {
|
||||
if (disabled) return
|
||||
context?.setPosition({ x, y })
|
||||
context?.onOpenChange?.(true)
|
||||
}
|
||||
|
||||
if (isH5()) {
|
||||
const { onLongPress: _onLongPress, onTouchStart: _onTouchStart, ...rest } = props as any
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={className}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
handleTrigger(e.clientX, e.clientY)
|
||||
}}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={className}
|
||||
onTouchStart={(e) => {
|
||||
const touch = (e as unknown as { touches?: Array<{ pageX: number; pageY: number }> }).touches?.[0]
|
||||
if (!touch) return
|
||||
touchPos.current = { x: touch.pageX, y: touch.pageY }
|
||||
}}
|
||||
onLongPress={(e) => {
|
||||
e.stopPropagation()
|
||||
handleTrigger(touchPos.current.x, touchPos.current.y)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
ContextMenuTrigger.displayName = "ContextMenuTrigger"
|
||||
|
||||
const ContextMenuGroup = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View ref={ref} className={className} {...props} />
|
||||
))
|
||||
ContextMenuGroup.displayName = "ContextMenuGroup"
|
||||
|
||||
const ContextMenuPortal = ({ children }: { children: React.ReactNode }) => {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const ContextMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const context = React.useContext(ContextMenuContext)
|
||||
const contentId = React.useRef(`context-menu-${Math.random().toString(36).slice(2, 10)}`)
|
||||
const [adjustedPos, setAdjustedPos] = React.useState<{ x: number; y: number } | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!context?.open) {
|
||||
setAdjustedPos(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const compute = async () => {
|
||||
const { width: vw, height: vh } = getViewport()
|
||||
let { x, y } = context.position
|
||||
|
||||
if (isH5() && typeof document !== "undefined") {
|
||||
const el = document.getElementById(contentId.current)
|
||||
const rect = el?.getBoundingClientRect()
|
||||
if (rect) {
|
||||
if (x + rect.width > vw) x = vw - rect.width - 8
|
||||
if (y + rect.height > vh) y = vh - rect.height - 8
|
||||
}
|
||||
if (!cancelled) setAdjustedPos({ x, y })
|
||||
return
|
||||
}
|
||||
|
||||
const query = Taro.createSelectorQuery()
|
||||
query
|
||||
.select(`#${contentId.current}`)
|
||||
.boundingClientRect((res) => {
|
||||
if (cancelled) return
|
||||
const rect = Array.isArray(res) ? res[0] : res
|
||||
if (rect?.width) {
|
||||
if (x + rect.width > vw) x = vw - rect.width - 8
|
||||
if (y + rect.height > vh) y = vh - rect.height - 8
|
||||
}
|
||||
setAdjustedPos({ x, y })
|
||||
})
|
||||
.exec()
|
||||
}
|
||||
|
||||
const raf = (() => {
|
||||
if (typeof requestAnimationFrame !== "undefined") {
|
||||
return requestAnimationFrame(() => compute())
|
||||
}
|
||||
return setTimeout(() => compute(), 0) as unknown as number
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (typeof cancelAnimationFrame !== "undefined") {
|
||||
cancelAnimationFrame(raf)
|
||||
} else {
|
||||
clearTimeout(raf)
|
||||
}
|
||||
}
|
||||
}, [context?.open, context?.position])
|
||||
|
||||
if (!context?.open) return null
|
||||
|
||||
const contentStyle: React.CSSProperties = adjustedPos
|
||||
? { left: adjustedPos.x, top: adjustedPos.y }
|
||||
: { left: context.position.x, top: context.position.y }
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<View
|
||||
className="fixed inset-0 z-50 bg-transparent"
|
||||
onClick={() => context.onOpenChange?.(false)}
|
||||
// @ts-ignore
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault()
|
||||
context.onOpenChange?.(false)
|
||||
}}
|
||||
/>
|
||||
<View
|
||||
ref={ref}
|
||||
id={contentId.current}
|
||||
data-slot="context-menu-content"
|
||||
data-state="open"
|
||||
className={cn(
|
||||
"fixed z-50 min-w-32 overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground ring-opacity-10 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
className
|
||||
)}
|
||||
style={contentStyle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
>
|
||||
<ScrollView scrollY className="max-h-[50vh] overflow-x-hidden overflow-y-auto">
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Portal>
|
||||
)
|
||||
})
|
||||
ContextMenuContent.displayName = "ContextMenuContent"
|
||||
|
||||
const ContextMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
inset?: boolean
|
||||
disabled?: boolean
|
||||
closeOnSelect?: boolean
|
||||
}
|
||||
>(({ className, inset, disabled, closeOnSelect = true, children, onClick, ...props }, ref) => {
|
||||
const context = React.useContext(ContextMenuContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset ? "" : undefined}
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-1.5 rounded-md px-2 py-1 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-7",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (disabled) return
|
||||
e.stopPropagation()
|
||||
onClick?.(e)
|
||||
if (closeOnSelect) context?.onOpenChange?.(false)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
ContextMenuItem.displayName = "ContextMenuItem"
|
||||
|
||||
const ContextMenuRadioGroupContext = React.createContext<{
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
} | null>(null)
|
||||
|
||||
interface ContextMenuRadioGroupProps extends React.ComponentPropsWithoutRef<typeof View> {
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
onValueChange?: (value: string) => void
|
||||
}
|
||||
|
||||
const ContextMenuRadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
ContextMenuRadioGroupProps
|
||||
>(({ value: valueProp, defaultValue, onValueChange, ...props }, ref) => {
|
||||
const [valueState, setValueState] = React.useState<string | undefined>(defaultValue)
|
||||
const value = valueProp !== undefined ? valueProp : valueState
|
||||
|
||||
const handleValueChange = (next: string) => {
|
||||
if (valueProp === undefined) {
|
||||
setValueState(next)
|
||||
}
|
||||
onValueChange?.(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenuRadioGroupContext.Provider value={{ value, onValueChange: handleValueChange }}>
|
||||
<View ref={ref} {...props} />
|
||||
</ContextMenuRadioGroupContext.Provider>
|
||||
)
|
||||
})
|
||||
ContextMenuRadioGroup.displayName = "ContextMenuRadioGroup"
|
||||
|
||||
const ContextMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
checked?: boolean
|
||||
inset?: boolean
|
||||
disabled?: boolean
|
||||
closeOnSelect?: boolean
|
||||
}
|
||||
>(({ className, children, checked, inset, disabled, closeOnSelect = false, onClick, ...props }, ref) => {
|
||||
const context = React.useContext(ContextMenuContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
data-slot="context-menu-checkbox-item"
|
||||
data-inset={inset ? "" : undefined}
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
data-state={checked ? "checked" : "unchecked"}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-1.5 rounded-md py-1 pr-8 pl-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-7",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (disabled) return
|
||||
e.stopPropagation()
|
||||
onClick?.(e)
|
||||
if (closeOnSelect) context?.onOpenChange?.(false)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<View className="pointer-events-none absolute right-2 flex items-center justify-center">
|
||||
{checked && <Check size={16} color="inherit" />}
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
ContextMenuCheckboxItem.displayName = "ContextMenuCheckboxItem"
|
||||
|
||||
const ContextMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
value: string
|
||||
checked?: boolean
|
||||
inset?: boolean
|
||||
disabled?: boolean
|
||||
closeOnSelect?: boolean
|
||||
}
|
||||
>(({ className, children, value, checked: checkedProp, inset, disabled, closeOnSelect = false, onClick, ...props }, ref) => {
|
||||
const context = React.useContext(ContextMenuContext)
|
||||
const group = React.useContext(ContextMenuRadioGroupContext)
|
||||
const checked = checkedProp !== undefined ? checkedProp : group?.value === value
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
data-slot="context-menu-radio-item"
|
||||
data-inset={inset ? "" : undefined}
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
data-state={checked ? "checked" : "unchecked"}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-1.5 rounded-md py-1 pr-8 pl-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-7",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (disabled) return
|
||||
e.stopPropagation()
|
||||
group?.onValueChange?.(value)
|
||||
onClick?.(e)
|
||||
if (closeOnSelect) context?.onOpenChange?.(false)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<View className="pointer-events-none absolute right-2 flex items-center justify-center">
|
||||
{checked && <Circle className="fill-current" size={8} color="inherit" />}
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
ContextMenuRadioItem.displayName = "ContextMenuRadioItem"
|
||||
|
||||
const ContextMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs font-medium text-muted-foreground",
|
||||
inset && "pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuLabel.displayName = "ContextMenuLabel"
|
||||
|
||||
const ContextMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
ContextMenuSeparator.displayName = "ContextMenuSeparator"
|
||||
|
||||
const ContextMenuShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View>) => {
|
||||
return (
|
||||
<View
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
ContextMenuShortcut.displayName = "ContextMenuShortcut"
|
||||
|
||||
const ContextMenuSubContext = React.createContext<{
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
triggerId: string
|
||||
} | null>(null)
|
||||
|
||||
interface ContextMenuSubProps {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const ContextMenuSub = ({ open: openProp, defaultOpen, onOpenChange, children }: ContextMenuSubProps) => {
|
||||
const parent = React.useContext(ContextMenuContext)
|
||||
const baseIdRef = React.useRef(`context-menu-sub-${Math.random().toString(36).slice(2, 10)}`)
|
||||
const [openState, setOpenState] = React.useState(defaultOpen || false)
|
||||
const isActive = parent?.activeSubId === baseIdRef.current
|
||||
const open = openProp !== undefined ? openProp : openState && isActive
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(nextOpen: boolean) => {
|
||||
if (openProp === undefined) {
|
||||
setOpenState(nextOpen)
|
||||
if (nextOpen) {
|
||||
parent?.setActiveSubId(baseIdRef.current)
|
||||
} else if (parent?.activeSubId === baseIdRef.current) {
|
||||
parent?.setActiveSubId(null)
|
||||
}
|
||||
} else {
|
||||
if (nextOpen) {
|
||||
parent?.setActiveSubId(baseIdRef.current)
|
||||
} else if (parent?.activeSubId === baseIdRef.current) {
|
||||
parent?.setActiveSubId(null)
|
||||
}
|
||||
}
|
||||
onOpenChange?.(nextOpen)
|
||||
},
|
||||
[onOpenChange, openProp, parent]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (defaultOpen) {
|
||||
setOpenState(true)
|
||||
parent?.setActiveSubId(baseIdRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (parent?.open === false && open) {
|
||||
handleOpenChange(false)
|
||||
}
|
||||
}, [handleOpenChange, open, parent?.open])
|
||||
|
||||
return (
|
||||
<ContextMenuSubContext.Provider value={{ open, onOpenChange: handleOpenChange, triggerId: baseIdRef.current }}>
|
||||
{children}
|
||||
</ContextMenuSubContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const ContextMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
inset?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
>(({ className, inset, disabled, children, onClick, ...props }, ref) => {
|
||||
const subContext = React.useContext(ContextMenuSubContext)
|
||||
return (
|
||||
<View
|
||||
{...props}
|
||||
ref={ref}
|
||||
id={subContext?.triggerId}
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset ? "" : undefined}
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
data-state={subContext?.open ? "open" : "closed"}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-1.5 rounded-md px-2 py-1 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-7",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (disabled) return
|
||||
subContext?.onOpenChange?.(!subContext.open)
|
||||
onClick?.(e)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto opacity-50" size={16} color="inherit" />
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
interface ContextMenuSubContentProps extends React.ComponentPropsWithoutRef<typeof View> {
|
||||
align?: "start" | "center" | "end"
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
sideOffset?: number
|
||||
}
|
||||
|
||||
const ContextMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
ContextMenuSubContentProps
|
||||
>(({ className, align = "start", side = "right", sideOffset = 4, children, ...props }, ref) => {
|
||||
const parent = React.useContext(ContextMenuContext)
|
||||
const subContext = React.useContext(ContextMenuSubContext)
|
||||
const contentId = React.useRef(`context-menu-sub-content-${Math.random().toString(36).slice(2, 10)}`)
|
||||
const [position, setPosition] = React.useState<{ left: number; top: number } | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!parent?.open || !subContext?.open) {
|
||||
setPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const compute = async () => {
|
||||
if (!subContext?.triggerId) return
|
||||
const [triggerRect, contentRect] = await Promise.all([
|
||||
getRectById(subContext.triggerId),
|
||||
getRectById(contentId.current),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
if (!triggerRect?.width || !contentRect?.width) return
|
||||
|
||||
setPosition(
|
||||
computePosition({
|
||||
triggerRect,
|
||||
contentRect,
|
||||
align,
|
||||
side,
|
||||
sideOffset,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const raf = (() => {
|
||||
if (typeof requestAnimationFrame !== "undefined") {
|
||||
return requestAnimationFrame(() => compute())
|
||||
}
|
||||
return setTimeout(() => compute(), 0) as unknown as number
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (typeof cancelAnimationFrame !== "undefined") {
|
||||
cancelAnimationFrame(raf)
|
||||
} else {
|
||||
clearTimeout(raf)
|
||||
}
|
||||
}
|
||||
}, [align, parent?.open, side, sideOffset, subContext?.open, subContext?.triggerId])
|
||||
|
||||
if (!parent?.open || !subContext?.open) return null
|
||||
|
||||
const baseClassName =
|
||||
"fixed z-50 min-w-[96px] overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground ring-opacity-10 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
|
||||
const contentStyle = position
|
||||
? ({ left: position.left, top: position.top } as React.CSSProperties)
|
||||
: ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
} as React.CSSProperties)
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<View
|
||||
{...props}
|
||||
ref={ref}
|
||||
id={contentId.current}
|
||||
data-slot="context-menu-sub-content"
|
||||
data-state="open"
|
||||
data-side={side}
|
||||
className={cn(baseClassName, className)}
|
||||
style={contentStyle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ScrollView scrollY className="max-h-[50vh] overflow-x-hidden overflow-y-auto">
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Portal>
|
||||
)
|
||||
})
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { X } from "lucide-react-taro"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Portal } from "@/components/ui/portal"
|
||||
import { useKeyboardOffset } from "@/lib/hooks/use-keyboard-offset"
|
||||
|
||||
const DialogContext = React.createContext<{
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
} | null>(null)
|
||||
|
||||
const usePresence = (open: boolean | undefined, durationMs: number) => {
|
||||
const [present, setPresent] = React.useState(!!open)
|
||||
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
setPresent(true)
|
||||
return
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => setPresent(false), durationMs)
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
}, [open, durationMs])
|
||||
|
||||
return present
|
||||
}
|
||||
|
||||
interface DialogProps {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
modal?: boolean
|
||||
}
|
||||
|
||||
const Dialog = ({ children, open: openProp, defaultOpen, onOpenChange }: DialogProps) => {
|
||||
const [openState, setOpenState] = React.useState(defaultOpen || false)
|
||||
const open = openProp !== undefined ? openProp : openState
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (openProp === undefined) {
|
||||
setOpenState(newOpen)
|
||||
}
|
||||
onOpenChange?.(newOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const DialogTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { asChild?: boolean }
|
||||
>(({ className, children, asChild, ...props }, ref) => {
|
||||
const context = React.useContext(DialogContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("w-fit", className)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
context?.onOpenChange?.(true)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
DialogTrigger.displayName = "DialogTrigger"
|
||||
|
||||
const DialogPortal = ({ children }: { children: React.ReactNode }) => {
|
||||
const context = React.useContext(DialogContext)
|
||||
const present = usePresence(context?.open, 200)
|
||||
if (!present) return null
|
||||
|
||||
return <Portal>{children}</Portal>
|
||||
}
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, onClick, ...props }, ref) => {
|
||||
const context = React.useContext(DialogContext)
|
||||
const state = context?.open ? "open" : "closed"
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black bg-opacity-10 transition-opacity duration-100 supports-[backdrop-filter]:backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClick?.(e)
|
||||
context?.onOpenChange?.(false)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
DialogOverlay.displayName = "DialogOverlay"
|
||||
|
||||
interface DialogContentProps extends React.ComponentPropsWithoutRef<typeof View> {
|
||||
closeClassName?: string
|
||||
}
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
DialogContentProps
|
||||
>(({ className, children, style, closeClassName, ...props }, ref) => {
|
||||
const context = React.useContext(DialogContext)
|
||||
const offset = useKeyboardOffset()
|
||||
const state = context?.open ? "open" : "closed"
|
||||
|
||||
return (
|
||||
<DialogPortal>
|
||||
<View
|
||||
className="fixed inset-0 z-50"
|
||||
onClick={() => context?.onOpenChange?.(false)}
|
||||
>
|
||||
<DialogOverlay />
|
||||
<View
|
||||
ref={ref}
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] rounded-xl",
|
||||
className
|
||||
)}
|
||||
style={{
|
||||
...(style as object),
|
||||
top: offset > 0 ? `calc(50% - ${offset / 2}px)` : undefined
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<View
|
||||
data-slot="dialog-close"
|
||||
className={cn(
|
||||
"absolute right-4 top-4 flex h-6 w-6 items-center justify-center rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground",
|
||||
closeClassName
|
||||
)}
|
||||
data-state={state}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
context?.onOpenChange?.(false)
|
||||
}}
|
||||
>
|
||||
<X size={16} color="inherit" />
|
||||
<View className="sr-only">Close</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</DialogPortal>
|
||||
)
|
||||
})
|
||||
DialogContent.displayName = "DialogContent"
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View>) => (
|
||||
<View
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = "DialogHeader"
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View>) => (
|
||||
<View
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = "DialogFooter"
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = "DialogTitle"
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = "DialogDescription"
|
||||
|
||||
const DialogClose = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const context = React.useContext(DialogContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
context?.onOpenChange?.(false)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
DialogClose.displayName = "DialogClose"
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogTrigger,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Portal } from "@/components/ui/portal"
|
||||
|
||||
const DrawerContext = React.createContext<{
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
} | null>(null)
|
||||
|
||||
interface DrawerProps extends React.ComponentPropsWithoutRef<typeof View> {
|
||||
shouldScaleBackground?: boolean
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const Drawer = ({
|
||||
shouldScaleBackground = true,
|
||||
children,
|
||||
open: openProp,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
...props
|
||||
}: DrawerProps) => {
|
||||
const [openState, setOpenState] = React.useState(defaultOpen || false)
|
||||
const open = openProp !== undefined ? openProp : openState
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (openProp === undefined) {
|
||||
setOpenState(newOpen)
|
||||
}
|
||||
onOpenChange?.(newOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<DrawerContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
|
||||
<View {...props}>{children}</View>
|
||||
</DrawerContext.Provider>
|
||||
)
|
||||
}
|
||||
Drawer.displayName = "Drawer"
|
||||
|
||||
const DrawerTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { asChild?: boolean }
|
||||
>(({ className, children, asChild, ...props }, ref) => {
|
||||
const context = React.useContext(DrawerContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("w-fit", className)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
context?.onOpenChange?.(true)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
DrawerTrigger.displayName = "DrawerTrigger"
|
||||
|
||||
const DrawerPortal = ({ children }: { children: React.ReactNode }) => {
|
||||
const context = React.useContext(DrawerContext)
|
||||
if (!context?.open) return null
|
||||
return <Portal>{children}</Portal>
|
||||
}
|
||||
|
||||
const DrawerClose = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { asChild?: boolean }
|
||||
>(({ className, children, asChild, ...props }, ref) => {
|
||||
const context = React.useContext(DrawerContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
context?.onOpenChange?.(false)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
DrawerClose.displayName = "DrawerClose"
|
||||
|
||||
const DrawerOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const context = React.useContext(DrawerContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black bg-opacity-10 transition-opacity duration-100 supports-[backdrop-filter]:backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
onClick={() => context?.onOpenChange?.(false)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
DrawerOverlay.displayName = "DrawerOverlay"
|
||||
|
||||
const DrawerContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DrawerPortal>
|
||||
<DrawerOverlay />
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] border bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom duration-300",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<View className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||
{children}
|
||||
</View>
|
||||
</DrawerPortal>
|
||||
))
|
||||
DrawerContent.displayName = "DrawerContent"
|
||||
|
||||
const DrawerHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View>) => (
|
||||
<View
|
||||
className={cn("grid gap-2 p-4 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerHeader.displayName = "DrawerHeader"
|
||||
|
||||
const DrawerFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View>) => (
|
||||
<View
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DrawerFooter.displayName = "DrawerFooter"
|
||||
|
||||
const DrawerTitle = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-lg font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerTitle.displayName = "DrawerTitle"
|
||||
|
||||
const DrawerDescription = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DrawerDescription.displayName = "DrawerDescription"
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
import * as React from "react"
|
||||
import { ScrollView, View } from "@tarojs/components"
|
||||
import { Check, ChevronRight } from "lucide-react-taro"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { isH5 } from "@/lib/platform"
|
||||
import { computePosition, getRectById } from "@/lib/measure"
|
||||
import { Portal } from "@/components/ui/portal"
|
||||
|
||||
const DropdownMenuContext = React.createContext<{
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
triggerId: string
|
||||
} | null>(null)
|
||||
|
||||
interface DropdownMenuProps {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const DropdownMenu = ({ open: openProp, defaultOpen, onOpenChange, children }: DropdownMenuProps) => {
|
||||
const baseIdRef = React.useRef(`dropdown-menu-${Math.random().toString(36).slice(2, 10)}`)
|
||||
const [openState, setOpenState] = React.useState(defaultOpen || false)
|
||||
const open = openProp !== undefined ? openProp : openState
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (openProp === undefined) {
|
||||
setOpenState(newOpen)
|
||||
}
|
||||
onOpenChange?.(newOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuContext.Provider
|
||||
value={{ open, onOpenChange: handleOpenChange, triggerId: baseIdRef.current }}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const DropdownMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, children, onClick, ...props }, ref) => {
|
||||
const context = React.useContext(DropdownMenuContext)
|
||||
return (
|
||||
<View
|
||||
{...props}
|
||||
ref={ref}
|
||||
id={context?.triggerId}
|
||||
data-slot="dropdown-menu-trigger"
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
context?.onOpenChange?.(!context.open)
|
||||
onClick?.(e)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
DropdownMenuTrigger.displayName = "DropdownMenuTrigger"
|
||||
|
||||
const DropdownMenuGroup = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View ref={ref} data-slot="dropdown-menu-group" className={className} {...props} />
|
||||
))
|
||||
DropdownMenuGroup.displayName = "DropdownMenuGroup"
|
||||
|
||||
const DropdownMenuPortal = ({ children }: { children: React.ReactNode }) => {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const DropdownMenuRadioGroupContext = React.createContext<{
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
} | null>(null)
|
||||
|
||||
interface DropdownMenuRadioGroupProps extends React.ComponentPropsWithoutRef<typeof View> {
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
onValueChange?: (value: string) => void
|
||||
}
|
||||
|
||||
const DropdownMenuRadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
DropdownMenuRadioGroupProps
|
||||
>(({ value: valueProp, defaultValue, onValueChange, ...props }, ref) => {
|
||||
const [valueState, setValueState] = React.useState<string | undefined>(defaultValue)
|
||||
const value = valueProp !== undefined ? valueProp : valueState
|
||||
|
||||
const handleValueChange = (next: string) => {
|
||||
if (valueProp === undefined) {
|
||||
setValueState(next)
|
||||
}
|
||||
onValueChange?.(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuRadioGroupContext.Provider value={{ value, onValueChange: handleValueChange }}>
|
||||
<View ref={ref} {...props} />
|
||||
</DropdownMenuRadioGroupContext.Provider>
|
||||
)
|
||||
})
|
||||
DropdownMenuRadioGroup.displayName = "DropdownMenuRadioGroup"
|
||||
|
||||
const DropdownMenuSubContext = React.createContext<{
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
triggerId: string
|
||||
} | null>(null)
|
||||
|
||||
interface DropdownMenuSubProps {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const DropdownMenuSub = ({ open: openProp, defaultOpen, onOpenChange, children }: DropdownMenuSubProps) => {
|
||||
const parent = React.useContext(DropdownMenuContext)
|
||||
const baseIdRef = React.useRef(`dropdown-menu-sub-${Math.random().toString(36).slice(2, 10)}`)
|
||||
const [openState, setOpenState] = React.useState(defaultOpen || false)
|
||||
const open = openProp !== undefined ? openProp : openState
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (openProp === undefined) {
|
||||
setOpenState(newOpen)
|
||||
}
|
||||
onOpenChange?.(newOpen)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (parent?.open === false && open) {
|
||||
handleOpenChange(false)
|
||||
}
|
||||
}, [open, parent?.open])
|
||||
|
||||
return (
|
||||
<DropdownMenuSubContext.Provider
|
||||
value={{ open, onOpenChange: handleOpenChange, triggerId: baseIdRef.current }}
|
||||
>
|
||||
{children}
|
||||
</DropdownMenuSubContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
interface DropdownMenuContentProps extends React.ComponentPropsWithoutRef<typeof View> {
|
||||
align?: "start" | "center" | "end"
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
sideOffset?: number
|
||||
}
|
||||
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
DropdownMenuContentProps
|
||||
>(({ className, align = "start", side = "bottom", sideOffset = 4, children, ...props }, ref) => {
|
||||
const context = React.useContext(DropdownMenuContext)
|
||||
const contentId = React.useRef(`dropdown-menu-content-${Math.random().toString(36).slice(2, 10)}`)
|
||||
const [position, setPosition] = React.useState<{ left: number; top: number } | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!context?.open) {
|
||||
setPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const compute = async () => {
|
||||
if (!context?.triggerId) return
|
||||
const [triggerRect, contentRect] = await Promise.all([
|
||||
getRectById(context.triggerId),
|
||||
getRectById(contentId.current),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
if (!triggerRect?.width || !contentRect?.width) return
|
||||
|
||||
setPosition(
|
||||
computePosition({
|
||||
triggerRect,
|
||||
contentRect,
|
||||
align,
|
||||
side,
|
||||
sideOffset,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const raf = (() => {
|
||||
if (typeof requestAnimationFrame !== "undefined") {
|
||||
return requestAnimationFrame(() => compute())
|
||||
}
|
||||
return setTimeout(() => compute(), 0) as unknown as number
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (typeof cancelAnimationFrame !== "undefined") {
|
||||
cancelAnimationFrame(raf)
|
||||
} else {
|
||||
clearTimeout(raf)
|
||||
}
|
||||
}
|
||||
}, [align, context?.open, context?.triggerId, side, sideOffset])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!context?.open) return
|
||||
if (!isH5() || typeof document === "undefined") return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
context?.onOpenChange?.(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown)
|
||||
return () => document.removeEventListener("keydown", onKeyDown)
|
||||
}, [context?.open])
|
||||
|
||||
if (!context?.open) return null
|
||||
|
||||
const baseClassName =
|
||||
"fixed z-50 min-w-32 overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground ring-opacity-10 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
|
||||
const contentStyle = position
|
||||
? ({ left: position.left, top: position.top } as React.CSSProperties)
|
||||
: ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
} as React.CSSProperties)
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<View className="fixed inset-0 z-50 bg-transparent" onClick={() => context.onOpenChange?.(false)} />
|
||||
<View
|
||||
{...props}
|
||||
ref={ref}
|
||||
id={contentId.current}
|
||||
data-slot="dropdown-menu-content"
|
||||
data-state="open"
|
||||
data-side={side}
|
||||
className={cn(baseClassName, className)}
|
||||
style={contentStyle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ScrollView scrollY className="max-h-[50vh] overflow-x-hidden overflow-y-auto">
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Portal>
|
||||
)
|
||||
})
|
||||
DropdownMenuContent.displayName = "DropdownMenuContent"
|
||||
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
disabled?: boolean
|
||||
closeOnSelect?: boolean
|
||||
}
|
||||
>(({ className, inset, variant = "default", disabled, closeOnSelect = true, onClick, ...props }, ref) => {
|
||||
const context = React.useContext(DropdownMenuContext)
|
||||
return (
|
||||
<View
|
||||
{...props}
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset ? "" : undefined}
|
||||
data-variant={variant}
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-1.5 rounded-md px-2 py-1 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive data-[variant=destructive]:focus:bg-opacity-10 data-[variant=destructive]:focus:text-destructive",
|
||||
inset && "pl-7",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (disabled) return
|
||||
onClick?.(e)
|
||||
if (closeOnSelect) context?.onOpenChange?.(false)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
DropdownMenuItem.displayName = "DropdownMenuItem"
|
||||
|
||||
const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
checked?: boolean
|
||||
inset?: boolean
|
||||
disabled?: boolean
|
||||
closeOnSelect?: boolean
|
||||
}
|
||||
>(({ className, children, checked, inset, disabled, closeOnSelect = false, onClick, ...props }, ref) => {
|
||||
const context = React.useContext(DropdownMenuContext)
|
||||
return (
|
||||
<View
|
||||
{...props}
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset ? "" : undefined}
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
data-state={checked ? "checked" : "unchecked"}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-1.5 rounded-md py-1 pr-8 pl-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-7",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (disabled) return
|
||||
onClick?.(e)
|
||||
if (closeOnSelect) context?.onOpenChange?.(false)
|
||||
}}
|
||||
>
|
||||
<View className="pointer-events-none absolute right-2 flex items-center justify-center">
|
||||
{checked && <Check size={16} color="inherit" />}
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
DropdownMenuCheckboxItem.displayName = "DropdownMenuCheckboxItem"
|
||||
|
||||
const DropdownMenuRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
value: string
|
||||
checked?: boolean
|
||||
inset?: boolean
|
||||
disabled?: boolean
|
||||
closeOnSelect?: boolean
|
||||
}
|
||||
>(({ className, children, value, checked: checkedProp, inset, disabled, closeOnSelect = false, onClick, ...props }, ref) => {
|
||||
const context = React.useContext(DropdownMenuContext)
|
||||
const group = React.useContext(DropdownMenuRadioGroupContext)
|
||||
const checked = checkedProp !== undefined ? checkedProp : group?.value === value
|
||||
return (
|
||||
<View
|
||||
{...props}
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset ? "" : undefined}
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
data-state={checked ? "checked" : "unchecked"}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-1.5 rounded-md py-1 pr-8 pl-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-7",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (disabled) return
|
||||
group?.onValueChange?.(value)
|
||||
onClick?.(e)
|
||||
if (closeOnSelect) context?.onOpenChange?.(false)
|
||||
}}
|
||||
>
|
||||
<View className="pointer-events-none absolute right-2 flex items-center justify-center">
|
||||
{checked && <Check size={16} color="inherit" />}
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
DropdownMenuRadioItem.displayName = "DropdownMenuRadioItem"
|
||||
|
||||
const DropdownMenuLabel = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset ? "" : undefined}
|
||||
className={cn("px-2 py-1 text-xs font-medium text-muted-foreground", inset && "pl-7", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuLabel.displayName = "DropdownMenuLabel"
|
||||
|
||||
const DropdownMenuSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DropdownMenuSeparator.displayName = "DropdownMenuSeparator"
|
||||
|
||||
const DropdownMenuShortcut = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) => {
|
||||
return (
|
||||
<View
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
|
||||
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
inset?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
>(({ className, inset, disabled, children, onClick, ...props }, ref) => {
|
||||
const subContext = React.useContext(DropdownMenuSubContext)
|
||||
return (
|
||||
<View
|
||||
{...props}
|
||||
ref={ref}
|
||||
id={subContext?.triggerId}
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset ? "" : undefined}
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
data-state={subContext?.open ? "open" : "closed"}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-1.5 rounded-md px-2 py-1 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-7",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (disabled) return
|
||||
subContext?.onOpenChange?.(!subContext.open)
|
||||
onClick?.(e)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto opacity-50" size={16} color="inherit" />
|
||||
</View>
|
||||
)
|
||||
})
|
||||
DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger"
|
||||
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
DropdownMenuContentProps
|
||||
>(({ className, align = "start", side = "right", sideOffset = 4, children, ...props }, ref) => {
|
||||
const parent = React.useContext(DropdownMenuContext)
|
||||
const subContext = React.useContext(DropdownMenuSubContext)
|
||||
const contentId = React.useRef(`dropdown-menu-sub-content-${Math.random().toString(36).slice(2, 10)}`)
|
||||
const [position, setPosition] = React.useState<{ left: number; top: number } | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!parent?.open || !subContext?.open) {
|
||||
setPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const compute = async () => {
|
||||
if (!subContext?.triggerId) return
|
||||
const [triggerRect, contentRect] = await Promise.all([
|
||||
getRectById(subContext.triggerId),
|
||||
getRectById(contentId.current),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
if (!triggerRect?.width || !contentRect?.width) return
|
||||
|
||||
setPosition(
|
||||
computePosition({
|
||||
triggerRect,
|
||||
contentRect,
|
||||
align,
|
||||
side,
|
||||
sideOffset,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const raf = (() => {
|
||||
if (typeof requestAnimationFrame !== "undefined") {
|
||||
return requestAnimationFrame(() => compute())
|
||||
}
|
||||
return setTimeout(() => compute(), 0) as unknown as number
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (typeof cancelAnimationFrame !== "undefined") {
|
||||
cancelAnimationFrame(raf)
|
||||
} else {
|
||||
clearTimeout(raf)
|
||||
}
|
||||
}
|
||||
}, [align, parent?.open, side, sideOffset, subContext?.open, subContext?.triggerId])
|
||||
|
||||
if (!parent?.open || !subContext?.open) return null
|
||||
|
||||
const baseClassName =
|
||||
"fixed z-50 min-w-[96px] overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground ring-opacity-10 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
|
||||
const contentStyle = position
|
||||
? ({ left: position.left, top: position.top } as React.CSSProperties)
|
||||
: ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
} as React.CSSProperties)
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<View
|
||||
{...props}
|
||||
ref={ref}
|
||||
id={contentId.current}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
data-state="open"
|
||||
data-side={side}
|
||||
className={cn(baseClassName, className)}
|
||||
style={contentStyle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ScrollView scrollY className="max-h-[50vh] overflow-x-hidden overflow-y-auto">
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Portal>
|
||||
)
|
||||
})
|
||||
DropdownMenuSubContent.displayName = "DropdownMenuSubContent"
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubContent,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuRadioGroup,
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import * as React from "react"
|
||||
import { View, Text } from "@tarojs/components"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
function FieldSet({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) {
|
||||
return (
|
||||
<View
|
||||
data-slot="field-set"
|
||||
className={cn(
|
||||
"flex flex-col gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLegend({
|
||||
className,
|
||||
variant = "legend",
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View> & { variant?: "legend" | "label" }) {
|
||||
return (
|
||||
<View
|
||||
data-slot="field-legend"
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"mb-1 font-medium",
|
||||
"data-[variant=legend]:text-base",
|
||||
"data-[variant=label]:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldGroup({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) {
|
||||
return (
|
||||
<View
|
||||
data-slot="field-group"
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-3 data-[slot=checkbox-group]:gap-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const fieldVariants = cva(
|
||||
"data-[invalid=true]:text-destructive flex w-full gap-1",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
vertical: ["flex-col [&>view]:w-full [&>label]:w-full"],
|
||||
horizontal: [
|
||||
"flex-row items-center",
|
||||
],
|
||||
responsive: ["flex-col [&>view]:w-full [&>label]:w-full"],
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "vertical",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Field({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View> & VariantProps<typeof fieldVariants>) {
|
||||
return (
|
||||
<View
|
||||
role="group"
|
||||
data-slot="field"
|
||||
data-orientation={orientation}
|
||||
className={cn(fieldVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldContent({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) {
|
||||
return (
|
||||
<View
|
||||
data-slot="field-content"
|
||||
className={cn(
|
||||
"flex flex-1 flex-col gap-2 leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldLabel({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof Label>) {
|
||||
return (
|
||||
<Label
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit gap-2 leading-snug",
|
||||
"[&>view]:p-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldTitle({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) {
|
||||
return (
|
||||
<View
|
||||
data-slot="field-label"
|
||||
className={cn(
|
||||
"flex w-fit items-center gap-2 text-sm font-medium leading-snug",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldDescription({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) {
|
||||
return (
|
||||
<View
|
||||
data-slot="field-description"
|
||||
className={cn(
|
||||
"text-muted-foreground text-sm font-normal leading-normal",
|
||||
// "group-has-[[data-orientation=horizontal]]/field:text-balance", // text-balance not supported in Taro
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View> & {
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<View
|
||||
data-slot="field-separator"
|
||||
data-content={!!children}
|
||||
className={cn(
|
||||
"relative -my-2 h-5 text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Separator className="absolute inset-0 top-1/2" />
|
||||
{children && (
|
||||
<View
|
||||
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
|
||||
data-slot="field-separator-content"
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function FieldError({
|
||||
className,
|
||||
children,
|
||||
errors,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View> & {
|
||||
errors?: Array<{ message?: string } | undefined>
|
||||
}) {
|
||||
// Memoize content if needed, or just render.
|
||||
let content = children
|
||||
|
||||
if (!content && errors) {
|
||||
if (errors.length === 1 && errors[0]?.message) {
|
||||
content = <Text>{errors[0].message}</Text>
|
||||
} else if (errors.length > 0) {
|
||||
content = (
|
||||
<View className="ml-4 flex flex-col gap-1">
|
||||
{errors.map((error, index) =>
|
||||
error?.message && <Text key={index} className="text-xs">{`\u2022 ${error.message}`}</Text>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!content) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
role="alert"
|
||||
data-slot="field-error"
|
||||
className={cn("text-destructive text-sm font-normal", className)}
|
||||
{...props}
|
||||
>
|
||||
{content}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Field,
|
||||
FieldLabel,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLegend,
|
||||
FieldSeparator,
|
||||
FieldSet,
|
||||
FieldContent,
|
||||
FieldTitle,
|
||||
}
|
||||
@@ -0,0 +1,282 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { isH5 } from "@/lib/platform"
|
||||
import { getRectById, getViewport } from "@/lib/measure"
|
||||
import { Portal } from "@/components/ui/portal"
|
||||
|
||||
type HoverCardProps = {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const HoverCardContext = React.createContext<{
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
triggerId: string
|
||||
setHoverPart?: (part: "trigger" | "content", hovering: boolean) => void
|
||||
} | null>(null)
|
||||
|
||||
const HoverCard = ({
|
||||
open: openProp,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
}: HoverCardProps) => {
|
||||
const baseIdRef = React.useRef(
|
||||
`hover-card-${Math.random().toString(36).slice(2, 10)}`
|
||||
)
|
||||
const [openState, setOpenState] = React.useState(defaultOpen)
|
||||
const open = openProp !== undefined ? openProp : openState
|
||||
const hoverRef = React.useRef({ trigger: false, content: false })
|
||||
const closeTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (openProp === undefined) {
|
||||
setOpenState(newOpen)
|
||||
}
|
||||
onOpenChange?.(newOpen)
|
||||
}
|
||||
|
||||
const setHoverPart = React.useCallback(
|
||||
(part: "trigger" | "content", hovering: boolean) => {
|
||||
if (!isH5()) return
|
||||
hoverRef.current[part] = hovering
|
||||
if (hovering) {
|
||||
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = null
|
||||
handleOpenChange(true)
|
||||
return
|
||||
}
|
||||
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
if (!hoverRef.current.trigger && !hoverRef.current.content) {
|
||||
handleOpenChange(false)
|
||||
}
|
||||
}, 80)
|
||||
},
|
||||
[handleOpenChange]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<HoverCardContext.Provider
|
||||
value={{
|
||||
open,
|
||||
onOpenChange: handleOpenChange,
|
||||
triggerId: baseIdRef.current,
|
||||
setHoverPart,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</HoverCardContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const HoverCardTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
onMouseEnter?: (e: React.MouseEvent) => void
|
||||
onMouseLeave?: (e: React.MouseEvent) => void
|
||||
}
|
||||
>(({ className, children, onClick, onMouseEnter, onMouseLeave, ...props }, ref) => {
|
||||
const context = React.useContext(HoverCardContext)
|
||||
return (
|
||||
<View
|
||||
{...props}
|
||||
ref={ref}
|
||||
id={context?.triggerId}
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
onClick?.(e)
|
||||
e.stopPropagation()
|
||||
context?.onOpenChange?.(!context.open)
|
||||
}}
|
||||
{...(isH5()
|
||||
? ({
|
||||
onMouseEnter: (e: React.MouseEvent) => {
|
||||
onMouseEnter?.(e)
|
||||
context?.setHoverPart?.("trigger", true)
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent) => {
|
||||
onMouseLeave?.(e)
|
||||
context?.setHoverPart?.("trigger", false)
|
||||
},
|
||||
} as React.ComponentPropsWithoutRef<typeof View>)
|
||||
: {})}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
HoverCardTrigger.displayName = "HoverCardTrigger"
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
align?: "start" | "center" | "end"
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
sideOffset?: number
|
||||
onMouseEnter?: (e: React.MouseEvent) => void
|
||||
onMouseLeave?: (e: React.MouseEvent) => void
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
align = "center",
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const context = React.useContext(HoverCardContext)
|
||||
const contentId = React.useRef(
|
||||
`hover-card-content-${Math.random().toString(36).slice(2, 10)}`
|
||||
)
|
||||
const [position, setPosition] = React.useState<
|
||||
| {
|
||||
left: number
|
||||
top: number
|
||||
}
|
||||
| null
|
||||
>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!context?.open) {
|
||||
setPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const compute = async () => {
|
||||
const [triggerRect, contentRect] = await Promise.all([
|
||||
getRectById(context.triggerId),
|
||||
getRectById(contentId.current),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
if (!triggerRect?.width || !contentRect?.width) return
|
||||
|
||||
const vw = getViewport().width
|
||||
const vh = getViewport().height
|
||||
const margin = 8
|
||||
|
||||
const crossStart = (side === "left" || side === "right")
|
||||
? triggerRect.top
|
||||
: triggerRect.left
|
||||
const crossSize = (side === "left" || side === "right")
|
||||
? triggerRect.height
|
||||
: triggerRect.width
|
||||
const contentCrossSize = (side === "left" || side === "right")
|
||||
? contentRect.height
|
||||
: contentRect.width
|
||||
|
||||
const cross = (() => {
|
||||
if (align === "start") return crossStart
|
||||
if (align === "end") return crossStart + crossSize - contentCrossSize
|
||||
return crossStart + crossSize / 2 - contentCrossSize / 2
|
||||
})()
|
||||
|
||||
let left = 0
|
||||
let top = 0
|
||||
|
||||
if (side === "bottom" || side === "top") {
|
||||
left = cross
|
||||
top =
|
||||
side === "bottom"
|
||||
? triggerRect.top + triggerRect.height + sideOffset
|
||||
: triggerRect.top - contentRect.height - sideOffset
|
||||
} else {
|
||||
top = cross
|
||||
left =
|
||||
side === "right"
|
||||
? triggerRect.left + triggerRect.width + sideOffset
|
||||
: triggerRect.left - contentRect.width - sideOffset
|
||||
}
|
||||
|
||||
left = Math.min(Math.max(left, margin), vw - contentRect.width - margin)
|
||||
top = Math.min(Math.max(top, margin), vh - contentRect.height - margin)
|
||||
|
||||
setPosition({ left, top })
|
||||
}
|
||||
|
||||
const raf = (() => {
|
||||
if (typeof requestAnimationFrame !== "undefined") {
|
||||
return requestAnimationFrame(() => compute())
|
||||
}
|
||||
return setTimeout(() => compute(), 0) as unknown as number
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (typeof cancelAnimationFrame !== "undefined") {
|
||||
cancelAnimationFrame(raf)
|
||||
} else {
|
||||
clearTimeout(raf)
|
||||
}
|
||||
}
|
||||
}, [align, context?.open, context?.triggerId, side, sideOffset])
|
||||
|
||||
if (!context?.open) return null
|
||||
|
||||
const baseClassName =
|
||||
"fixed z-50 w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
||||
|
||||
const contentStyle = position
|
||||
? ({ left: position.left, top: position.top } as React.CSSProperties)
|
||||
: ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
} as React.CSSProperties)
|
||||
|
||||
const overlay =
|
||||
!isH5() ? (
|
||||
<View
|
||||
className="fixed inset-0 z-50 bg-transparent"
|
||||
onClick={() => context.onOpenChange?.(false)}
|
||||
/>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
{overlay}
|
||||
<View
|
||||
{...props}
|
||||
ref={ref}
|
||||
id={contentId.current}
|
||||
className={cn(baseClassName, className)}
|
||||
style={contentStyle}
|
||||
{...(isH5()
|
||||
? ({
|
||||
onMouseEnter: (e: React.MouseEvent) => {
|
||||
onMouseEnter?.(e)
|
||||
context?.setHoverPart?.("content", true)
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent) => {
|
||||
onMouseLeave?.(e)
|
||||
context?.setHoverPart?.("content", false)
|
||||
},
|
||||
} as React.ComponentPropsWithoutRef<typeof View>)
|
||||
: {})}
|
||||
/>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
)
|
||||
HoverCardContent.displayName = "HoverCardContent"
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent }
|
||||
@@ -0,0 +1,197 @@
|
||||
import * as React from "react"
|
||||
import { View, Text, Input, Textarea } from "@tarojs/components"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
interface InputGroupContextValue {
|
||||
isFocused: boolean
|
||||
setIsFocused: (value: boolean) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const InputGroupContext = React.createContext<InputGroupContextValue>({
|
||||
isFocused: false,
|
||||
setIsFocused: () => {},
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
function InputGroup({ className, disabled, ...props }: React.ComponentPropsWithoutRef<typeof View> & { disabled?: boolean }) {
|
||||
const [isFocused, setIsFocused] = React.useState(false)
|
||||
|
||||
return (
|
||||
<InputGroupContext.Provider value={{ isFocused, setIsFocused, disabled }}>
|
||||
<View
|
||||
data-slot="input-group"
|
||||
className={cn(
|
||||
"border-input dark:bg-input dark:bg-opacity-30 shadow-xs relative flex w-full min-h-9 flex-wrap items-center rounded-md border outline-none transition-[color,box-shadow]",
|
||||
isFocused && "ring-2 ring-ring ring-offset-2 ring-offset-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</InputGroupContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupAddonVariants = cva(
|
||||
"text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1 text-sm font-medium [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4",
|
||||
{
|
||||
variants: {
|
||||
align: {
|
||||
"inline-start":
|
||||
"order-first pl-3",
|
||||
"inline-end":
|
||||
"order-last pr-3",
|
||||
"block-start":
|
||||
"[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3",
|
||||
"block-end":
|
||||
"[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
align: "inline-start",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupAddon({
|
||||
className,
|
||||
align = "inline-start",
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View> & VariantProps<typeof inputGroupAddonVariants>) {
|
||||
const { disabled } = React.useContext(InputGroupContext)
|
||||
return (
|
||||
<View
|
||||
data-slot="input-group-addon"
|
||||
data-align={align}
|
||||
className={cn(inputGroupAddonVariants({ align }), disabled && "opacity-50", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const inputGroupButtonVariants = cva(
|
||||
"flex items-center gap-2 text-sm shadow-none",
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 [&>svg:not([class*='size-'])]:size-3",
|
||||
sm: "h-8 gap-2 rounded-md px-2",
|
||||
"icon-xs":
|
||||
"size-6 rounded-[calc(var(--radius)-5px)] p-0",
|
||||
"icon-sm": "size-8 p-0",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: "xs",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function InputGroupButton({
|
||||
className,
|
||||
variant = "ghost",
|
||||
size = "xs",
|
||||
...props
|
||||
}: Omit<React.ComponentProps<typeof Button>, "size"> &
|
||||
VariantProps<typeof inputGroupButtonVariants>) {
|
||||
return (
|
||||
<Button
|
||||
data-size={size}
|
||||
variant={variant}
|
||||
className={cn(inputGroupButtonVariants({ size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupText({ className, ...props }: React.ComponentPropsWithoutRef<typeof Text>) {
|
||||
return (
|
||||
<Text
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-2 text-sm [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupInput({
|
||||
className,
|
||||
onFocus,
|
||||
onBlur,
|
||||
autoFocus,
|
||||
focus,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof Input> & { autoFocus?: boolean }) {
|
||||
const { setIsFocused } = React.useContext(InputGroupContext)
|
||||
|
||||
return (
|
||||
<View className="flex h-full flex-1 items-center px-2 py-2">
|
||||
<Input
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 bg-transparent text-base text-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
placeholderClass="text-muted-foreground"
|
||||
onFocus={(e) => {
|
||||
setIsFocused(true)
|
||||
onFocus?.(e)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setIsFocused(false)
|
||||
onBlur?.(e)
|
||||
}}
|
||||
focus={autoFocus || focus}
|
||||
{...props}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function InputGroupTextarea({
|
||||
className,
|
||||
onFocus,
|
||||
onBlur,
|
||||
autoFocus,
|
||||
focus,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof Textarea> & { autoFocus?: boolean }) {
|
||||
const { setIsFocused } = React.useContext(InputGroupContext)
|
||||
|
||||
return (
|
||||
<View className="flex h-full flex-1 min-w-20 m-2">
|
||||
<Textarea
|
||||
data-slot="input-group-control"
|
||||
className={cn(
|
||||
"flex-1 w-full h-full bg-transparent text-base text-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
placeholderClass="text-muted-foreground"
|
||||
onFocus={(e) => {
|
||||
setIsFocused(true)
|
||||
onFocus?.(e)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setIsFocused(false)
|
||||
onBlur?.(e)
|
||||
}}
|
||||
focus={autoFocus || focus}
|
||||
{...props}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupButton,
|
||||
InputGroupText,
|
||||
InputGroupInput,
|
||||
InputGroupTextarea,
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import * as React from "react"
|
||||
import { View, Input, type BaseEventOrig } from "@tarojs/components"
|
||||
import { type InputProps } from "@tarojs/components/types/Input"
|
||||
import { Dot } from "lucide-react-taro"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const InputOTPContext = React.createContext<{
|
||||
value: string
|
||||
maxLength: number
|
||||
isFocused: boolean
|
||||
} | null>(null)
|
||||
|
||||
const InputOTP = React.forwardRef<
|
||||
any,
|
||||
{
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
onChange?: (value: string) => void
|
||||
maxLength: number
|
||||
containerClassName?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
autoFocus?: boolean
|
||||
children: React.ReactNode
|
||||
}
|
||||
>(({ value: valueProp, defaultValue, onChange, maxLength, containerClassName, className, disabled, autoFocus, children }, ref) => {
|
||||
const [valueState, setValueState] = React.useState(defaultValue || "")
|
||||
const [isFocused, setIsFocused] = React.useState(false)
|
||||
const value = valueProp !== undefined ? valueProp : valueState
|
||||
|
||||
const handleChange = (e: BaseEventOrig<InputProps.inputValueEventDetail>) => {
|
||||
const newValue = e.detail.value
|
||||
if (newValue.length <= maxLength) {
|
||||
if (valueProp === undefined) {
|
||||
setValueState(newValue)
|
||||
}
|
||||
onChange?.(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<InputOTPContext.Provider value={{ value, maxLength, isFocused }}>
|
||||
<View
|
||||
className={cn(
|
||||
"relative flex items-center gap-2",
|
||||
disabled && "opacity-50",
|
||||
containerClassName
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
className="z-10 taro-otp-hidden-input"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
opacity: 0,
|
||||
zIndex: 10,
|
||||
backgroundColor: "transparent",
|
||||
borderWidth: 0,
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
outline: "none",
|
||||
color: "transparent",
|
||||
caretColor: "transparent",
|
||||
}}
|
||||
value={value}
|
||||
onInput={handleChange}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
maxlength={maxLength}
|
||||
type="number"
|
||||
disabled={disabled}
|
||||
focus={autoFocus}
|
||||
ref={ref}
|
||||
/>
|
||||
<View className={cn("flex items-center gap-2", className)}>
|
||||
{children}
|
||||
</View>
|
||||
</View>
|
||||
</InputOTPContext.Provider>
|
||||
)
|
||||
})
|
||||
InputOTP.displayName = "InputOTP"
|
||||
|
||||
const InputOTPGroup = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View ref={ref} className={cn("flex items-center", className)} {...props} />
|
||||
))
|
||||
InputOTPGroup.displayName = "InputOTPGroup"
|
||||
|
||||
const InputOTPSlot = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { index: number }
|
||||
>(({ index, className, ...props }, ref) => {
|
||||
const context = React.useContext(InputOTPContext)
|
||||
if (!context) return null
|
||||
|
||||
const char = context.value[index]
|
||||
const isActive = context.isFocused && context.value.length === index
|
||||
const hasFakeCaret = isActive
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
|
||||
isActive && "z-10 ring-2 ring-ring ring-offset-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<View>{char}</View>
|
||||
{hasFakeCaret && (
|
||||
<View className="pointer-events-none absolute inset-0 flex items-center justify-center">
|
||||
<View className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
InputOTPSlot.displayName = "InputOTPSlot"
|
||||
|
||||
const InputOTPSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ ...props }, ref) => (
|
||||
<View ref={ref} {...props}>
|
||||
<Dot size={24} color="inherit" />
|
||||
</View>
|
||||
))
|
||||
InputOTPSeparator.displayName = "InputOTPSeparator"
|
||||
|
||||
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
|
||||
@@ -0,0 +1,56 @@
|
||||
import * as React from "react"
|
||||
import { Input as TaroInput, View } from "@tarojs/components"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface InputProps
|
||||
extends React.ComponentPropsWithoutRef<typeof TaroInput> {
|
||||
className?: string
|
||||
type?: React.ComponentProps<typeof TaroInput>['type']
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
const Input = React.forwardRef<React.ElementRef<typeof TaroInput>, InputProps>(
|
||||
({ className, type, autoFocus, focus, onFocus, onBlur, ...props }, ref) => {
|
||||
const [isFocused, setIsFocused] = React.useState(false)
|
||||
const disabled = !!(props as any).disabled
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoFocus || focus) setIsFocused(true)
|
||||
}, [autoFocus, focus])
|
||||
|
||||
return (
|
||||
<View
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-within:border-ring focus-within:ring-4 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background",
|
||||
isFocused &&
|
||||
"border-ring ring-4 ring-ring ring-offset-2 ring-offset-background",
|
||||
className
|
||||
)}
|
||||
onTouchStart={() => {
|
||||
if (disabled) return
|
||||
setIsFocused(true)
|
||||
}}
|
||||
>
|
||||
<TaroInput
|
||||
type={type}
|
||||
className="w-full flex-1 bg-transparent text-sm text-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 selection:bg-selection selection:text-selection-foreground"
|
||||
placeholderClass="text-muted-foreground"
|
||||
ref={ref}
|
||||
focus={autoFocus || focus}
|
||||
onFocus={(e) => {
|
||||
setIsFocused(true)
|
||||
onFocus?.(e)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setIsFocused(false)
|
||||
onBlur?.(e)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
)
|
||||
Input.displayName = "Input"
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,24 @@
|
||||
import * as React from "react"
|
||||
import { Label as TaroLabel } from "@tarojs/components"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const labelVariants = cva(
|
||||
"text-sm font-medium leading-none"
|
||||
)
|
||||
|
||||
const Label = React.forwardRef<
|
||||
React.ElementRef<typeof TaroLabel>,
|
||||
React.ComponentPropsWithoutRef<typeof TaroLabel> &
|
||||
VariantProps<typeof labelVariants>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TaroLabel
|
||||
ref={ref}
|
||||
className={cn(labelVariants(), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Label.displayName = "Label"
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,595 @@
|
||||
import * as React from "react"
|
||||
import { View, ScrollView } from "@tarojs/components"
|
||||
import { Check, ChevronRight } from "lucide-react-taro"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { isH5 } from "@/lib/platform"
|
||||
import { computePosition, getRectById } from "@/lib/measure"
|
||||
import { Portal } from "@/components/ui/portal"
|
||||
|
||||
const MenubarContext = React.createContext<{
|
||||
openMenu?: string
|
||||
setOpenMenu?: (id: string | undefined) => void
|
||||
} | null>(null)
|
||||
|
||||
const Menubar = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const [openMenu, setOpenMenu] = React.useState<string | undefined>()
|
||||
|
||||
return (
|
||||
<MenubarContext.Provider value={{ openMenu, setOpenMenu }}>
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex h-10 items-center space-x-1 rounded-md border border-border bg-background p-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarContext.Provider>
|
||||
)
|
||||
})
|
||||
Menubar.displayName = "Menubar"
|
||||
|
||||
const MenubarMenuContext = React.createContext<{
|
||||
id: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
triggerId: string
|
||||
} | null>(null)
|
||||
|
||||
let menubarMenuIdCounter = 0
|
||||
|
||||
const MenubarMenu = ({ children }: { children: React.ReactNode }) => {
|
||||
const id = React.useMemo(() => `menubar-menu-${menubarMenuIdCounter++}`, [])
|
||||
const triggerId = React.useMemo(() => `${id}-trigger`, [id])
|
||||
const context = React.useContext(MenubarContext)
|
||||
|
||||
const open = context?.openMenu === id
|
||||
const onOpenChange = (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
context?.setOpenMenu?.(id)
|
||||
} else {
|
||||
context?.setOpenMenu?.(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<MenubarMenuContext.Provider value={{ id, open, onOpenChange, triggerId }}>
|
||||
{children}
|
||||
</MenubarMenuContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const MenubarTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const context = React.useContext(MenubarMenuContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
id={context?.triggerId}
|
||||
data-slot="menubar-trigger"
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center rounded-sm px-3 py-2 text-sm font-medium outline-none focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
onClick={() => context?.onOpenChange(!context.open)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
MenubarTrigger.displayName = "MenubarTrigger"
|
||||
|
||||
const MenubarPortal = ({ children }: { children: React.ReactNode }) => {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
const MenubarContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
align?: "start" | "center" | "end"
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
sideOffset?: number
|
||||
}
|
||||
>(({ className, align = "start", side = "bottom", sideOffset = 4, children, ...props }, ref) => {
|
||||
const context = React.useContext(MenubarMenuContext)
|
||||
const contentId = React.useRef(`menubar-content-${Math.random().toString(36).slice(2, 10)}`)
|
||||
const [position, setPosition] = React.useState<{ left: number; top: number } | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!context?.open) {
|
||||
setPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const compute = async () => {
|
||||
if (!context?.triggerId) return
|
||||
const [triggerRect, contentRect] = await Promise.all([
|
||||
getRectById(context.triggerId),
|
||||
getRectById(contentId.current),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
if (!triggerRect?.width || !contentRect?.width) return
|
||||
|
||||
setPosition(
|
||||
computePosition({
|
||||
triggerRect,
|
||||
contentRect,
|
||||
align,
|
||||
side,
|
||||
sideOffset,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const raf = (() => {
|
||||
if (typeof requestAnimationFrame !== "undefined") {
|
||||
return requestAnimationFrame(() => compute())
|
||||
}
|
||||
return setTimeout(() => compute(), 0) as unknown as number
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (typeof cancelAnimationFrame !== "undefined") {
|
||||
cancelAnimationFrame(raf)
|
||||
} else {
|
||||
clearTimeout(raf)
|
||||
}
|
||||
}
|
||||
}, [align, context?.open, context?.triggerId, side, sideOffset])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!context?.open) return
|
||||
if (!isH5() || typeof document === "undefined") return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
context?.onOpenChange(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown)
|
||||
return () => document.removeEventListener("keydown", onKeyDown)
|
||||
}, [context?.open])
|
||||
|
||||
if (!context?.open) return null
|
||||
|
||||
const baseClassName =
|
||||
"fixed z-50 min-w-32 overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground ring-opacity-10 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
|
||||
const contentStyle = position
|
||||
? ({ left: position.left, top: position.top } as React.CSSProperties)
|
||||
: ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
} as React.CSSProperties)
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<View
|
||||
className="fixed inset-0 z-50 bg-transparent"
|
||||
onClick={() => context.onOpenChange(false)}
|
||||
/>
|
||||
<View
|
||||
ref={ref}
|
||||
id={contentId.current}
|
||||
data-slot="menubar-content"
|
||||
data-state="open"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
baseClassName,
|
||||
className
|
||||
)}
|
||||
style={contentStyle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
>
|
||||
<ScrollView scrollY className="max-h-[50vh] overflow-x-hidden overflow-y-auto">
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Portal>
|
||||
)
|
||||
})
|
||||
MenubarContent.displayName = "MenubarContent"
|
||||
|
||||
const MenubarItem = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
disabled?: boolean
|
||||
closeOnSelect?: boolean
|
||||
}
|
||||
>(({ className, inset, variant = "default", disabled, closeOnSelect = true, children, onClick, ...props }, ref) => {
|
||||
const context = React.useContext(MenubarMenuContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
data-slot="menubar-item"
|
||||
data-inset={inset ? "" : undefined}
|
||||
data-variant={variant}
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-1.5 rounded-md px-2 py-1 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive data-[variant=destructive]:focus:bg-opacity-10 data-[variant=destructive]:focus:text-destructive",
|
||||
inset && "pl-7",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (disabled) return
|
||||
onClick?.(e)
|
||||
if (closeOnSelect) context?.onOpenChange(false)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
MenubarItem.displayName = "MenubarItem"
|
||||
|
||||
const MenubarCheckboxItem = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
checked?: boolean
|
||||
inset?: boolean
|
||||
disabled?: boolean
|
||||
closeOnSelect?: boolean
|
||||
}
|
||||
>(({ className, children, checked, inset, disabled, closeOnSelect = false, onClick, ...props }, ref) => {
|
||||
const context = React.useContext(MenubarMenuContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
data-slot="menubar-checkbox-item"
|
||||
data-inset={inset ? "" : undefined}
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
data-state={checked ? "checked" : "unchecked"}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-1.5 rounded-md py-1 pr-8 pl-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-7",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (disabled) return
|
||||
onClick?.(e)
|
||||
if (closeOnSelect) context?.onOpenChange(false)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<View className="pointer-events-none absolute right-2 flex items-center justify-center">
|
||||
{checked && <Check size={16} color="inherit" />}
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
MenubarCheckboxItem.displayName = "MenubarCheckboxItem"
|
||||
|
||||
const MenubarRadioGroupContext = React.createContext<{
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
} | null>(null)
|
||||
|
||||
interface MenubarRadioGroupProps extends React.ComponentPropsWithoutRef<typeof View> {
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
onValueChange?: (value: string) => void
|
||||
}
|
||||
|
||||
const MenubarRadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
MenubarRadioGroupProps
|
||||
>(({ value: valueProp, defaultValue, onValueChange, ...props }, ref) => {
|
||||
const [valueState, setValueState] = React.useState<string | undefined>(defaultValue)
|
||||
const value = valueProp !== undefined ? valueProp : valueState
|
||||
|
||||
const handleValueChange = (next: string) => {
|
||||
if (valueProp === undefined) {
|
||||
setValueState(next)
|
||||
}
|
||||
onValueChange?.(next)
|
||||
}
|
||||
|
||||
return (
|
||||
<MenubarRadioGroupContext.Provider value={{ value, onValueChange: handleValueChange }}>
|
||||
<View ref={ref} {...props} />
|
||||
</MenubarRadioGroupContext.Provider>
|
||||
)
|
||||
})
|
||||
MenubarRadioGroup.displayName = "MenubarRadioGroup"
|
||||
|
||||
const MenubarRadioItem = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
value: string
|
||||
checked?: boolean
|
||||
inset?: boolean
|
||||
disabled?: boolean
|
||||
closeOnSelect?: boolean
|
||||
}
|
||||
>(({ className, children, value, checked: checkedProp, inset, disabled, closeOnSelect = false, onClick, ...props }, ref) => {
|
||||
const context = React.useContext(MenubarMenuContext)
|
||||
const group = React.useContext(MenubarRadioGroupContext)
|
||||
const checked = checkedProp !== undefined ? checkedProp : group?.value === value
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
data-slot="menubar-radio-item"
|
||||
data-inset={inset ? "" : undefined}
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
data-state={checked ? "checked" : "unchecked"}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-1.5 rounded-md py-1 pr-8 pl-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
inset && "pl-7",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
if (disabled) return
|
||||
group?.onValueChange?.(value)
|
||||
onClick?.(e)
|
||||
if (closeOnSelect) context?.onOpenChange(false)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<View className="pointer-events-none absolute right-2 flex items-center justify-center">
|
||||
{checked && <Check size={16} color="inherit" />}
|
||||
</View>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
MenubarRadioItem.displayName = "MenubarRadioItem"
|
||||
|
||||
const MenubarLabel = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
inset?: boolean
|
||||
}
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
data-slot="menubar-label"
|
||||
data-inset={inset ? "" : undefined}
|
||||
className={cn(
|
||||
"px-2 py-1 text-xs font-medium text-muted-foreground",
|
||||
inset && "pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarLabel.displayName = "MenubarLabel"
|
||||
|
||||
const MenubarSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
data-slot="menubar-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
MenubarSeparator.displayName = "MenubarSeparator"
|
||||
|
||||
const MenubarShortcut = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View>) => {
|
||||
return (
|
||||
<View
|
||||
data-slot="menubar-shortcut"
|
||||
className={cn("ml-auto text-xs tracking-widest text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
MenubarShortcut.displayName = "MenubarShortcut"
|
||||
|
||||
const MenubarGroup = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View ref={ref} data-slot="menubar-group" className={className} {...props} />
|
||||
))
|
||||
MenubarGroup.displayName = "MenubarGroup"
|
||||
|
||||
const MenubarSubContext = React.createContext<{
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
triggerId: string
|
||||
} | null>(null)
|
||||
|
||||
interface MenubarSubProps {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const MenubarSub = ({ open: openProp, defaultOpen, onOpenChange, children }: MenubarSubProps) => {
|
||||
const parent = React.useContext(MenubarMenuContext)
|
||||
const baseIdRef = React.useRef(`menubar-sub-${Math.random().toString(36).slice(2, 10)}`)
|
||||
const [openState, setOpenState] = React.useState(defaultOpen || false)
|
||||
const open = openProp !== undefined ? openProp : openState
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (openProp === undefined) {
|
||||
setOpenState(newOpen)
|
||||
}
|
||||
onOpenChange?.(newOpen)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (parent?.open === false && open) {
|
||||
handleOpenChange(false)
|
||||
}
|
||||
}, [open, parent?.open])
|
||||
|
||||
return (
|
||||
<MenubarSubContext.Provider value={{ open, onOpenChange: handleOpenChange, triggerId: baseIdRef.current }}>
|
||||
{children}
|
||||
</MenubarSubContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const MenubarSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { inset?: boolean; disabled?: boolean }
|
||||
>(({ className, inset, disabled, children, onClick, ...props }, ref) => {
|
||||
const subContext = React.useContext(MenubarSubContext)
|
||||
return (
|
||||
<View
|
||||
{...props}
|
||||
ref={ref}
|
||||
id={subContext?.triggerId}
|
||||
data-slot="menubar-sub-trigger"
|
||||
data-inset={inset ? "" : undefined}
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
data-state={subContext?.open ? "open" : "closed"}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-1.5 rounded-md px-2 py-1 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground",
|
||||
inset && "pl-7",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (disabled) return
|
||||
subContext?.onOpenChange?.(!subContext.open)
|
||||
onClick?.(e)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<ChevronRight className="ml-auto opacity-50" size={16} color="inherit" />
|
||||
</View>
|
||||
)
|
||||
})
|
||||
MenubarSubTrigger.displayName = "MenubarSubTrigger"
|
||||
|
||||
const MenubarSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
align?: "start" | "center" | "end"
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
sideOffset?: number
|
||||
}
|
||||
>(({ className, align = "start", side = "right", sideOffset = 4, children, ...props }, ref) => {
|
||||
const parent = React.useContext(MenubarMenuContext)
|
||||
const subContext = React.useContext(MenubarSubContext)
|
||||
const contentId = React.useRef(`menubar-sub-content-${Math.random().toString(36).slice(2, 10)}`)
|
||||
const [position, setPosition] = React.useState<{ left: number; top: number } | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!parent?.open || !subContext?.open) {
|
||||
setPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const compute = async () => {
|
||||
if (!subContext?.triggerId) return
|
||||
const [triggerRect, contentRect] = await Promise.all([
|
||||
getRectById(subContext.triggerId),
|
||||
getRectById(contentId.current),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
if (!triggerRect?.width || !contentRect?.width) return
|
||||
|
||||
setPosition(
|
||||
computePosition({
|
||||
triggerRect,
|
||||
contentRect,
|
||||
align,
|
||||
side,
|
||||
sideOffset,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const raf = (() => {
|
||||
if (typeof requestAnimationFrame !== "undefined") {
|
||||
return requestAnimationFrame(() => compute())
|
||||
}
|
||||
return setTimeout(() => compute(), 0) as unknown as number
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (typeof cancelAnimationFrame !== "undefined") {
|
||||
cancelAnimationFrame(raf)
|
||||
} else {
|
||||
clearTimeout(raf)
|
||||
}
|
||||
}
|
||||
}, [align, parent?.open, side, sideOffset, subContext?.open, subContext?.triggerId])
|
||||
|
||||
if (!parent?.open || !subContext?.open) return null
|
||||
|
||||
const baseClassName =
|
||||
"fixed z-50 min-w-[96px] overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground ring-opacity-10 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
|
||||
const contentStyle = position
|
||||
? ({ left: position.left, top: position.top } as React.CSSProperties)
|
||||
: ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
} as React.CSSProperties)
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<View
|
||||
{...props}
|
||||
ref={ref}
|
||||
id={contentId.current}
|
||||
data-slot="menubar-sub-content"
|
||||
data-state="open"
|
||||
data-side={side}
|
||||
className={cn(baseClassName, className)}
|
||||
style={contentStyle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ScrollView scrollY className="max-h-[50vh] overflow-x-hidden overflow-y-auto">
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Portal>
|
||||
)
|
||||
})
|
||||
MenubarSubContent.displayName = "MenubarSubContent"
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarItem,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarPortal,
|
||||
MenubarSubContent,
|
||||
MenubarSubTrigger,
|
||||
MenubarGroup,
|
||||
MenubarSub,
|
||||
MenubarShortcut,
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import * as React from "react"
|
||||
import { View, ScrollView } from "@tarojs/components"
|
||||
import { ChevronDown } from "lucide-react-taro"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { isH5 } from "@/lib/platform"
|
||||
import { computePosition, getRectById, getViewport } from "@/lib/measure"
|
||||
import { Portal } from "@/components/ui/portal"
|
||||
|
||||
const NavigationMenuContext = React.createContext<{
|
||||
value?: string
|
||||
onValueChange?: (value: string | undefined) => void
|
||||
} | null>(null)
|
||||
|
||||
const NavigationMenu = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
value?: string
|
||||
onValueChange?: (value: string | undefined) => void
|
||||
}
|
||||
>(({ className, children, value: valueProp, onValueChange, ...props }, ref) => {
|
||||
const [valueState, setValueState] = React.useState<string | undefined>()
|
||||
const value = valueProp !== undefined ? valueProp : valueState
|
||||
|
||||
const handleValueChange = (newValue: string | undefined) => {
|
||||
if (valueProp === undefined) {
|
||||
setValueState(newValue)
|
||||
}
|
||||
onValueChange?.(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<NavigationMenuContext.Provider value={{ value, onValueChange: handleValueChange }}>
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-10 flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
</NavigationMenuContext.Provider>
|
||||
)
|
||||
})
|
||||
NavigationMenu.displayName = "NavigationMenu"
|
||||
|
||||
const NavigationMenuList = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center space-x-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
NavigationMenuList.displayName = "NavigationMenuList"
|
||||
|
||||
const NavigationMenuItemContext = React.createContext<{ value: string; triggerId: string } | null>(null)
|
||||
|
||||
const NavigationMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { value?: string }
|
||||
>(({ children, value: valueProp, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
const value = valueProp || id
|
||||
const triggerIdRef = React.useRef(`navigation-menu-trigger-${Math.random().toString(36).slice(2, 10)}`)
|
||||
return (
|
||||
<NavigationMenuItemContext.Provider value={{ value, triggerId: triggerIdRef.current }}>
|
||||
<View ref={ref} {...props}>
|
||||
{children}
|
||||
</View>
|
||||
</NavigationMenuItemContext.Provider>
|
||||
)
|
||||
})
|
||||
NavigationMenuItem.displayName = "NavigationMenuItem"
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:bg-opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:focus:bg-accent"
|
||||
)
|
||||
|
||||
const NavigationMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const context = React.useContext(NavigationMenuContext)
|
||||
const item = React.useContext(NavigationMenuItemContext)
|
||||
const isOpen = context?.value === item?.value
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
id={item?.triggerId}
|
||||
data-slot="navigation-menu-trigger"
|
||||
data-state={isOpen ? "open" : "closed"}
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
onClick={() => context?.onValueChange?.(isOpen ? undefined : item?.value)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
"relative top-[1px] ml-1 h-3 w-3 transition duration-200",
|
||||
isOpen && "rotate-180"
|
||||
)}
|
||||
size={12}
|
||||
color="inherit"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
NavigationMenuTrigger.displayName = "NavigationMenuTrigger"
|
||||
|
||||
const NavigationMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
align?: "start" | "center" | "end"
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
sideOffset?: number
|
||||
}
|
||||
>(({ className, align = "start", side = "bottom", sideOffset = 4, children, ...props }, ref) => {
|
||||
const context = React.useContext(NavigationMenuContext)
|
||||
const item = React.useContext(NavigationMenuItemContext)
|
||||
const isOpen = context?.value === item?.value
|
||||
const contentId = React.useRef(`navigation-menu-content-${Math.random().toString(36).slice(2, 10)}`)
|
||||
const [position, setPosition] = React.useState<{ left: number; top: number } | null>(null)
|
||||
const [contentWidth, setContentWidth] = React.useState<number | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setPosition(null)
|
||||
setContentWidth(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const compute = async () => {
|
||||
const triggerId = item?.triggerId
|
||||
if (!triggerId) return
|
||||
const [triggerRect, contentRect] = await Promise.all([
|
||||
getRectById(triggerId),
|
||||
getRectById(contentId.current),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
if (!triggerRect?.width || !contentRect?.width) return
|
||||
|
||||
setContentWidth(contentRect.width)
|
||||
setPosition(
|
||||
computePosition({
|
||||
triggerRect,
|
||||
contentRect,
|
||||
align,
|
||||
side,
|
||||
sideOffset,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
const raf = (() => {
|
||||
if (typeof requestAnimationFrame !== "undefined") {
|
||||
return requestAnimationFrame(() => compute())
|
||||
}
|
||||
return setTimeout(() => compute(), 0) as unknown as number
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (typeof cancelAnimationFrame !== "undefined") {
|
||||
cancelAnimationFrame(raf)
|
||||
} else {
|
||||
clearTimeout(raf)
|
||||
}
|
||||
}
|
||||
}, [align, isOpen, item?.triggerId, side, sideOffset])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) return
|
||||
if (!isH5() || typeof document === "undefined") return
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
context?.onValueChange?.(undefined)
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown)
|
||||
return () => document.removeEventListener("keydown", onKeyDown)
|
||||
}, [context, isOpen])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const baseClassName =
|
||||
"fixed z-50 min-w-32 w-max max-w-[95vw] overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground ring-opacity-10 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2"
|
||||
|
||||
const vw = getViewport().width
|
||||
const isMobileLayout = vw < 640
|
||||
const shouldFullWidth = isMobileLayout && contentWidth !== null && contentWidth > vw - 16
|
||||
|
||||
const contentStyle = position
|
||||
? (shouldFullWidth
|
||||
? ({ left: 8, right: 8, top: position.top } as React.CSSProperties)
|
||||
: ({ left: position.left, top: position.top } as React.CSSProperties))
|
||||
: ({
|
||||
left: shouldFullWidth ? 8 : 0,
|
||||
right: shouldFullWidth ? 8 : undefined,
|
||||
top: 0,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
} as React.CSSProperties)
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<View
|
||||
className="fixed inset-0 z-50 bg-transparent"
|
||||
onClick={() => context?.onValueChange?.(undefined)}
|
||||
/>
|
||||
<View
|
||||
ref={ref}
|
||||
id={contentId.current}
|
||||
data-slot="navigation-menu-content"
|
||||
data-state="open"
|
||||
data-side={side}
|
||||
className={cn(
|
||||
baseClassName,
|
||||
className
|
||||
)}
|
||||
style={contentStyle}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
>
|
||||
<ScrollView scrollY className="max-h-[70vh] overflow-x-hidden overflow-y-auto">
|
||||
{children}
|
||||
</ScrollView>
|
||||
</View>
|
||||
</Portal>
|
||||
)
|
||||
})
|
||||
NavigationMenuContent.displayName = "NavigationMenuContent"
|
||||
|
||||
const NavigationMenuLink = ({ children, className, ...props }: any) => (
|
||||
<View className={cn("block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground", className)} {...props}>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
|
||||
const NavigationMenuViewport = () => null
|
||||
|
||||
const NavigationMenuIndicator = () => null
|
||||
|
||||
export {
|
||||
navigationMenuTriggerStyle,
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import * as React from "react"
|
||||
import { View, Text } from "@tarojs/components"
|
||||
import { ChevronLeft, ChevronRight, Ellipsis } from "lucide-react-taro"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ButtonProps, buttonVariants } from "@/components/ui/button"
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) => (
|
||||
<View
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn("mx-auto flex w-full justify-center", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Pagination.displayName = "Pagination"
|
||||
|
||||
const PaginationContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("flex flex-row items-center gap-1", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
PaginationContent.displayName = "PaginationContent"
|
||||
|
||||
const PaginationItem = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View ref={ref} className={cn("", className)} {...props} />
|
||||
))
|
||||
PaginationItem.displayName = "PaginationItem"
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<ButtonProps, "size"> &
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
|
||||
const PaginationLink = ({
|
||||
className,
|
||||
isActive,
|
||||
size = "icon",
|
||||
...props
|
||||
}: PaginationLinkProps) => (
|
||||
<View
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? "outline" : "ghost",
|
||||
size,
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
PaginationLink.displayName = "PaginationLink"
|
||||
|
||||
const PaginationPrevious = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to previous page"
|
||||
size="default"
|
||||
className={cn("gap-1 pl-3", className)}
|
||||
{...props}
|
||||
>
|
||||
<ChevronLeft size={16} color="inherit" />
|
||||
<Text>上一页</Text>
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationPrevious.displayName = "PaginationPrevious"
|
||||
|
||||
const PaginationNext = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink
|
||||
aria-label="Go to next page"
|
||||
size="default"
|
||||
className={cn("gap-1 pr-3", className)}
|
||||
{...props}
|
||||
>
|
||||
<Text>下一页</Text>
|
||||
<ChevronRight size={16} color="inherit" />
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationNext.displayName = "PaginationNext"
|
||||
|
||||
const PaginationEllipsis = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View>) => (
|
||||
<View
|
||||
aria-hidden
|
||||
className={cn("flex h-9 w-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<Ellipsis size={16} color="inherit" />
|
||||
<View className="sr-only">More pages</View>
|
||||
</View>
|
||||
)
|
||||
PaginationEllipsis.displayName = "PaginationEllipsis"
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious,
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import Taro from "@tarojs/taro"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Portal } from "@/components/ui/portal"
|
||||
import { useKeyboardOffset } from "@/lib/hooks/use-keyboard-offset"
|
||||
import { isH5 } from "@/lib/platform"
|
||||
|
||||
const PopoverContext = React.createContext<{
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
triggerId: string
|
||||
} | null>(null)
|
||||
|
||||
interface PopoverProps {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
const Popover = ({ open: openProp, defaultOpen, onOpenChange, children }: PopoverProps) => {
|
||||
const baseIdRef = React.useRef(
|
||||
`popover-${Math.random().toString(36).slice(2, 10)}`
|
||||
)
|
||||
const [openState, setOpenState] = React.useState(defaultOpen || false)
|
||||
const open = openProp !== undefined ? openProp : openState
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (openProp === undefined) {
|
||||
setOpenState(newOpen)
|
||||
}
|
||||
onOpenChange?.(newOpen)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isH5()) return
|
||||
if (!open) return
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key !== "Escape") return
|
||||
e.stopPropagation()
|
||||
handleOpenChange(false)
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown, true)
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKeyDown, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<PopoverContext.Provider value={{ open, onOpenChange: handleOpenChange, triggerId: baseIdRef.current }}>
|
||||
{children}
|
||||
</PopoverContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const PopoverTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { asChild?: boolean }
|
||||
>(({ className, children, asChild, ...props }, ref) => {
|
||||
const context = React.useContext(PopoverContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
id={context?.triggerId}
|
||||
className={cn("w-fit", className)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
context?.onOpenChange?.(!context.open)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
PopoverTrigger.displayName = "PopoverTrigger"
|
||||
|
||||
interface PopoverContentProps extends React.ComponentPropsWithoutRef<typeof View> {
|
||||
align?: "center" | "start" | "end"
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
position?: "top" | "bottom" | "left" | "right"
|
||||
sideOffset?: number
|
||||
}
|
||||
|
||||
const PopoverContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
PopoverContentProps
|
||||
>(({ className, align = "center", side, position: positionProp, sideOffset = 4, style, ...props }, ref) => {
|
||||
const context = React.useContext(PopoverContext)
|
||||
const offset = useKeyboardOffset()
|
||||
const resolvedSide = positionProp ?? side ?? "bottom"
|
||||
const contentId = React.useRef(
|
||||
`popover-content-${Math.random().toString(36).slice(2, 10)}`
|
||||
)
|
||||
const [contentPosition, setContentPosition] = React.useState<
|
||||
| {
|
||||
left: number
|
||||
top: number
|
||||
}
|
||||
| null
|
||||
>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!context?.open) {
|
||||
setContentPosition(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const getViewport = () => {
|
||||
if (isH5() && typeof window !== "undefined") {
|
||||
return { width: window.innerWidth, height: window.innerHeight }
|
||||
}
|
||||
try {
|
||||
const info = Taro.getSystemInfoSync()
|
||||
return { width: info.windowWidth, height: info.windowHeight }
|
||||
} catch {
|
||||
return { width: 375, height: 667 }
|
||||
}
|
||||
}
|
||||
|
||||
const getRectH5 = (id: string) => {
|
||||
if (!isH5() || typeof document === "undefined") return null
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return null
|
||||
const r = el.getBoundingClientRect()
|
||||
return { left: r.left, top: r.top, width: r.width, height: r.height }
|
||||
}
|
||||
|
||||
const getRect = (id: string) => {
|
||||
const h5Rect = getRectH5(id)
|
||||
if (h5Rect) return Promise.resolve(h5Rect)
|
||||
return new Promise<any>((resolve) => {
|
||||
const query = Taro.createSelectorQuery()
|
||||
query
|
||||
.select(`#${id}`)
|
||||
.boundingClientRect((res) => {
|
||||
const rect = Array.isArray(res) ? res[0] : res
|
||||
resolve(rect || null)
|
||||
})
|
||||
.exec()
|
||||
})
|
||||
}
|
||||
|
||||
const compute = async () => {
|
||||
const [triggerRect, contentRect] = await Promise.all([
|
||||
getRect(context.triggerId),
|
||||
getRect(contentId.current),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
if (!triggerRect?.width || !contentRect?.width) return
|
||||
|
||||
const viewport = getViewport()
|
||||
const vw = viewport.width
|
||||
const vh = Math.max(0, viewport.height - (isH5() ? 0 : offset || 0))
|
||||
const margin = 8
|
||||
|
||||
const crossStart =
|
||||
resolvedSide === "left" || resolvedSide === "right" ? triggerRect.top : triggerRect.left
|
||||
const crossSize =
|
||||
resolvedSide === "left" || resolvedSide === "right" ? triggerRect.height : triggerRect.width
|
||||
const contentCrossSize =
|
||||
resolvedSide === "left" || resolvedSide === "right" ? contentRect.height : contentRect.width
|
||||
|
||||
const cross = (() => {
|
||||
if (align === "start") return crossStart
|
||||
if (align === "end") return crossStart + crossSize - contentCrossSize
|
||||
return crossStart + crossSize / 2 - contentCrossSize / 2
|
||||
})()
|
||||
|
||||
let left = 0
|
||||
let top = 0
|
||||
|
||||
if (resolvedSide === "bottom" || resolvedSide === "top") {
|
||||
left = cross
|
||||
top =
|
||||
resolvedSide === "bottom"
|
||||
? triggerRect.top + triggerRect.height + sideOffset
|
||||
: triggerRect.top - contentRect.height - sideOffset
|
||||
} else {
|
||||
top = cross
|
||||
left =
|
||||
resolvedSide === "right"
|
||||
? triggerRect.left + triggerRect.width + sideOffset
|
||||
: triggerRect.left - contentRect.width - sideOffset
|
||||
}
|
||||
|
||||
left = Math.min(Math.max(left, margin), vw - contentRect.width - margin)
|
||||
top = Math.min(Math.max(top, margin), vh - contentRect.height - margin)
|
||||
|
||||
setContentPosition({ left, top })
|
||||
}
|
||||
|
||||
const raf = (() => {
|
||||
if (typeof requestAnimationFrame !== "undefined") {
|
||||
return requestAnimationFrame(() => compute())
|
||||
}
|
||||
return setTimeout(() => compute(), 0) as unknown as number
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (typeof cancelAnimationFrame !== "undefined") {
|
||||
cancelAnimationFrame(raf)
|
||||
} else {
|
||||
clearTimeout(raf)
|
||||
}
|
||||
}
|
||||
}, [align, context?.open, context?.triggerId, offset, resolvedSide, sideOffset])
|
||||
|
||||
if (!context?.open) return null
|
||||
|
||||
const baseClassName =
|
||||
"fixed z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95"
|
||||
|
||||
const contentStyle = contentPosition
|
||||
? ({
|
||||
...(style as object),
|
||||
left: contentPosition.left,
|
||||
top: contentPosition.top,
|
||||
} as React.CSSProperties)
|
||||
: ({
|
||||
...(style as object),
|
||||
left: 0,
|
||||
top: 0,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
} as React.CSSProperties)
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<View
|
||||
className="fixed inset-0 z-50 bg-transparent"
|
||||
onClick={() => context.onOpenChange?.(false)}
|
||||
/>
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
baseClassName,
|
||||
className
|
||||
)}
|
||||
id={contentId.current}
|
||||
style={contentStyle}
|
||||
{...props}
|
||||
/>
|
||||
</Portal>
|
||||
)
|
||||
})
|
||||
PopoverContent.displayName = "PopoverContent"
|
||||
|
||||
const PopoverHeader = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View ref={ref} className={cn("grid gap-1.5", className)} {...props} />
|
||||
))
|
||||
PopoverHeader.displayName = "PopoverHeader"
|
||||
|
||||
const PopoverTitle = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View ref={ref} className={cn("font-medium leading-none", className)} {...props} />
|
||||
))
|
||||
PopoverTitle.displayName = "PopoverTitle"
|
||||
|
||||
const PopoverDescription = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
PopoverDescription.displayName = "PopoverDescription"
|
||||
|
||||
export {
|
||||
Popover,
|
||||
PopoverTrigger,
|
||||
PopoverContent,
|
||||
PopoverHeader,
|
||||
PopoverTitle,
|
||||
PopoverDescription,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
import * as TaroComponents from "@tarojs/components"
|
||||
import { createPortal } from "react-dom"
|
||||
import { isH5 } from "@/lib/platform"
|
||||
|
||||
const RootPortal = (TaroComponents as any).RootPortal
|
||||
|
||||
const Portal = ({ children }: { children: React.ReactNode }) => {
|
||||
if (isH5()) {
|
||||
if (typeof document === "undefined") return <>{children}</>
|
||||
return createPortal(children, document.body)
|
||||
}
|
||||
if (!RootPortal) {
|
||||
return <>{children}</>
|
||||
}
|
||||
return <RootPortal>{children}</RootPortal>
|
||||
}
|
||||
|
||||
export { Portal }
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Progress = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
value?: number | null
|
||||
}
|
||||
>(({ className, value, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative h-1 w-full overflow-hidden rounded-full bg-secondary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<View
|
||||
className="h-full w-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</View>
|
||||
))
|
||||
Progress.displayName = "Progress"
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,64 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const RadioGroupContext = React.createContext<{
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
} | null>(null)
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
defaultValue?: string
|
||||
}
|
||||
>(({ className, value: valueProp, defaultValue, onValueChange, ...props }, ref) => {
|
||||
const [valueState, setValueState] = React.useState<string | undefined>(defaultValue)
|
||||
const value = valueProp !== undefined ? valueProp : valueState
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
if (valueProp === undefined) {
|
||||
setValueState(newValue)
|
||||
}
|
||||
onValueChange?.(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<RadioGroupContext.Provider value={{ value, onValueChange: handleValueChange }}>
|
||||
<View
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
</RadioGroupContext.Provider>
|
||||
)
|
||||
})
|
||||
RadioGroup.displayName = "RadioGroup"
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
value: string
|
||||
}
|
||||
>(({ className, value, ...props }, ref) => {
|
||||
const context = React.useContext(RadioGroupContext)
|
||||
const checked = context?.value === value
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
checked && "border-10",
|
||||
className
|
||||
)}
|
||||
onClick={() => context?.onValueChange?.(value)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
RadioGroupItem.displayName = "RadioGroupItem"
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -0,0 +1,346 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import Taro from "@tarojs/taro"
|
||||
import { GripVertical } from "lucide-react-taro"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { isH5 } from "@/lib/platform"
|
||||
|
||||
type Direction = "horizontal" | "vertical"
|
||||
|
||||
function getPoint(e: any) {
|
||||
const touch = e?.touches?.[0] || e?.changedTouches?.[0]
|
||||
return touch || e
|
||||
}
|
||||
|
||||
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
|
||||
|
||||
const ResizablePanelGroup = ({
|
||||
className,
|
||||
children,
|
||||
direction = "horizontal",
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View> & {
|
||||
direction?: Direction
|
||||
}) => {
|
||||
const idRef = React.useRef(`resizable-${Math.random().toString(36).slice(2, 11)}`)
|
||||
const groupRef = React.useRef<any>(null)
|
||||
const sizesRef = React.useRef<number[] | null>(null)
|
||||
const [sizes, setSizes] = React.useState<number[] | null>(null)
|
||||
|
||||
const rectSizeRef = React.useRef<{ width: number; height: number } | null>(null)
|
||||
const dragRef = React.useRef<{
|
||||
axis: "x" | "y"
|
||||
input: "mouse" | "touch"
|
||||
leftIndex: number
|
||||
startPos: number
|
||||
startSizes: number[]
|
||||
containerSize: number
|
||||
} | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
sizesRef.current = sizes
|
||||
}, [sizes])
|
||||
|
||||
const isPanelElement = React.useCallback((child: any) => {
|
||||
return child?.type?.displayName === "ResizablePanel"
|
||||
}, [])
|
||||
|
||||
const isHandleElement = React.useCallback((child: any) => {
|
||||
return child?.type?.displayName === "ResizableHandle"
|
||||
}, [])
|
||||
|
||||
const panels = React.useMemo(() => {
|
||||
const out: React.ReactElement[] = []
|
||||
React.Children.forEach(children as any, (child) => {
|
||||
if (!React.isValidElement(child)) return
|
||||
if (isPanelElement(child)) out.push(child as any)
|
||||
})
|
||||
return out
|
||||
}, [children, isPanelElement])
|
||||
|
||||
const measure = React.useCallback(() => {
|
||||
const el = groupRef.current
|
||||
if (isH5() && typeof el?.getBoundingClientRect === "function") {
|
||||
const rect = el.getBoundingClientRect()
|
||||
const width = rect?.width
|
||||
const height = rect?.height
|
||||
if (typeof width === "number" && typeof height === "number") {
|
||||
rectSizeRef.current = { width, height }
|
||||
return Promise.resolve(rectSizeRef.current)
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise<{ width: number; height: number } | null>((resolve) => {
|
||||
const query = Taro.createSelectorQuery()
|
||||
query
|
||||
.select(`#${idRef.current}`)
|
||||
.boundingClientRect((res) => {
|
||||
const r = Array.isArray(res) ? res[0] : res
|
||||
const width = r?.width
|
||||
const height = r?.height
|
||||
if (typeof width === "number" && typeof height === "number") {
|
||||
rectSizeRef.current = { width, height }
|
||||
resolve(rectSizeRef.current)
|
||||
return
|
||||
}
|
||||
resolve(null)
|
||||
})
|
||||
.exec()
|
||||
})
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
const defaults = panels.map((p) => {
|
||||
const v = (p.props as any)?.defaultSize
|
||||
return typeof v === "number" && Number.isFinite(v) ? v : null
|
||||
})
|
||||
const hasAnyDefault = defaults.some((v) => v != null)
|
||||
const next =
|
||||
hasAnyDefault
|
||||
? defaults.map((v) => (v == null ? 0 : v))
|
||||
: panels.map(() => 100 / Math.max(1, panels.length))
|
||||
const total = next.reduce((a, b) => a + b, 0) || 1
|
||||
const normalized = next.map((v) => (v / total) * 100)
|
||||
|
||||
setSizes((prev) => {
|
||||
if (prev && prev.length === normalized.length) return prev
|
||||
return normalized
|
||||
})
|
||||
}, [panels])
|
||||
|
||||
React.useEffect(() => {
|
||||
void measure()
|
||||
if (!isH5() || typeof window === "undefined") return
|
||||
const onResize = () => void measure()
|
||||
window.addEventListener("resize", onResize)
|
||||
return () => window.removeEventListener("resize", onResize)
|
||||
}, [measure])
|
||||
|
||||
const applyMove = React.useCallback(
|
||||
(e: any) => {
|
||||
const drag = dragRef.current
|
||||
const currentSizes = sizesRef.current
|
||||
if (!drag || !currentSizes) return
|
||||
const p = getPoint(e)
|
||||
const pos = drag.axis === "x" ? p?.pageX : p?.pageY
|
||||
if (typeof pos !== "number") return
|
||||
if (!drag.containerSize) return
|
||||
|
||||
const deltaPercent = ((pos - drag.startPos) / drag.containerSize) * 100
|
||||
const left = drag.leftIndex
|
||||
const right = left + 1
|
||||
const total = drag.startSizes[left] + drag.startSizes[right]
|
||||
const min = 10
|
||||
const nextLeft = clamp(drag.startSizes[left] + deltaPercent, min, total - min)
|
||||
const next = drag.startSizes.slice()
|
||||
next[left] = nextLeft
|
||||
next[right] = total - nextLeft
|
||||
setSizes(next)
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
const endDrag = React.useCallback(() => {
|
||||
dragRef.current = null
|
||||
if (!isH5() || typeof document === "undefined") return
|
||||
document.removeEventListener("mousemove", onDocumentMouseMove as any)
|
||||
document.removeEventListener("mouseup", onDocumentMouseUp as any)
|
||||
document.removeEventListener("touchmove", onDocumentTouchMove as any, { passive: false } as any)
|
||||
document.removeEventListener("touchend", onDocumentTouchEnd as any)
|
||||
document.removeEventListener("touchcancel", onDocumentTouchEnd as any)
|
||||
}, [])
|
||||
|
||||
const onDocumentMouseMove = React.useCallback((e: any) => applyMove(e), [applyMove])
|
||||
const onDocumentMouseUp = React.useCallback(() => endDrag(), [endDrag])
|
||||
const onDocumentTouchMove = React.useCallback(
|
||||
(e: any) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || drag.input !== "touch") return
|
||||
e?.preventDefault?.()
|
||||
applyMove(e)
|
||||
},
|
||||
[applyMove]
|
||||
)
|
||||
const onDocumentTouchEnd = React.useCallback(() => endDrag(), [endDrag])
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => endDrag()
|
||||
}, [endDrag])
|
||||
|
||||
const startDrag = React.useCallback(
|
||||
async (leftIndex: number, e: any) => {
|
||||
const currentSizes = sizesRef.current
|
||||
if (!currentSizes) return
|
||||
if (leftIndex < 0 || leftIndex >= currentSizes.length - 1) return
|
||||
|
||||
const axis = direction === "horizontal" ? "x" : "y"
|
||||
const p = getPoint(e)
|
||||
const pos = axis === "x" ? p?.pageX : p?.pageY
|
||||
if (typeof pos !== "number") return
|
||||
const input: "mouse" | "touch" = (e as any)?.touches?.length ? "touch" : "mouse"
|
||||
|
||||
const rect = rectSizeRef.current || (await measure())
|
||||
const containerSize = axis === "x" ? rect?.width : rect?.height
|
||||
if (!containerSize) return
|
||||
|
||||
dragRef.current = {
|
||||
axis,
|
||||
input,
|
||||
leftIndex,
|
||||
startPos: pos,
|
||||
startSizes: currentSizes.slice(),
|
||||
containerSize,
|
||||
}
|
||||
e?.preventDefault?.()
|
||||
|
||||
if (isH5() && typeof document !== "undefined") {
|
||||
if (input === "mouse") {
|
||||
document.addEventListener("mousemove", onDocumentMouseMove as any)
|
||||
document.addEventListener("mouseup", onDocumentMouseUp as any)
|
||||
} else {
|
||||
document.addEventListener("touchmove", onDocumentTouchMove as any, { passive: false } as any)
|
||||
document.addEventListener("touchend", onDocumentTouchEnd as any)
|
||||
document.addEventListener("touchcancel", onDocumentTouchEnd as any)
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
direction,
|
||||
measure,
|
||||
onDocumentMouseMove,
|
||||
onDocumentMouseUp,
|
||||
onDocumentTouchEnd,
|
||||
onDocumentTouchMove,
|
||||
]
|
||||
)
|
||||
|
||||
const onGroupTouchMove = React.useCallback(
|
||||
(e: any) => {
|
||||
const drag = dragRef.current
|
||||
if (!drag || drag.input !== "touch") return
|
||||
e?.preventDefault?.()
|
||||
applyMove(e)
|
||||
},
|
||||
[applyMove]
|
||||
)
|
||||
|
||||
const onGroupTouchEnd = React.useCallback(() => endDrag(), [endDrag])
|
||||
|
||||
let panelIndex = 0
|
||||
let handleIndex = 0
|
||||
|
||||
return (
|
||||
<View
|
||||
id={idRef.current}
|
||||
ref={groupRef}
|
||||
className={cn(
|
||||
"flex h-full w-full items-stretch overflow-hidden",
|
||||
direction === "vertical" ? "flex-col" : "flex-row",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
onTouchMove={onGroupTouchMove}
|
||||
onTouchEnd={onGroupTouchEnd}
|
||||
onTouchCancel={onGroupTouchEnd as any}
|
||||
>
|
||||
{React.Children.map(children as any, (child) => {
|
||||
if (!React.isValidElement(child)) return child
|
||||
|
||||
if (isPanelElement(child)) {
|
||||
const size = sizes?.[panelIndex]
|
||||
const cloned = React.cloneElement(child as any, {
|
||||
__size: typeof size === "number" ? size : undefined,
|
||||
__direction: direction,
|
||||
})
|
||||
panelIndex += 1
|
||||
return cloned
|
||||
}
|
||||
|
||||
if (isHandleElement(child)) {
|
||||
const leftIndex = panelIndex - 1
|
||||
const cursorClass =
|
||||
direction === "horizontal" ? "cursor-col-resize" : "cursor-row-resize"
|
||||
const cloned = React.cloneElement(child as any, {
|
||||
__direction: direction,
|
||||
className: cn(cursorClass, (child.props as any)?.className),
|
||||
onTouchStart: (e: any) => void startDrag(leftIndex, e),
|
||||
// @ts-ignore
|
||||
onMouseDown: (e: any) => void startDrag(leftIndex, e),
|
||||
"data-handle-index": handleIndex,
|
||||
})
|
||||
handleIndex += 1
|
||||
return cloned
|
||||
}
|
||||
|
||||
return child
|
||||
})}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const ResizablePanel = ({
|
||||
className,
|
||||
children,
|
||||
defaultSize,
|
||||
__size,
|
||||
__direction,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View> & {
|
||||
defaultSize?: number
|
||||
__size?: number
|
||||
__direction?: Direction
|
||||
}) => (
|
||||
<View
|
||||
{...props}
|
||||
className={cn("flex min-h-0 min-w-0 flex-col overflow-hidden", className)}
|
||||
style={{
|
||||
flexBasis: 0,
|
||||
flexGrow: __size ?? defaultSize ?? 1,
|
||||
flexShrink: 1,
|
||||
minWidth: 0,
|
||||
minHeight: 0,
|
||||
...(props as any)?.style,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
|
||||
const ResizableHandle = ({
|
||||
withHandle,
|
||||
className,
|
||||
__direction,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View> & {
|
||||
withHandle?: boolean
|
||||
__direction?: Direction
|
||||
}) => (
|
||||
<View
|
||||
{...props}
|
||||
className={cn(
|
||||
"relative flex shrink-0 items-center justify-center bg-transparent",
|
||||
__direction === "vertical" ? "h-3 self-stretch" : "w-3 self-stretch",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<View
|
||||
className={cn(
|
||||
"absolute bg-border",
|
||||
__direction === "vertical"
|
||||
? "inset-x-0 top-1/2 -translate-y-1/2"
|
||||
: "inset-y-0 left-1/2 -translate-x-1/2"
|
||||
)}
|
||||
style={__direction === "vertical" ? { height: "1PX" } : { width: "1PX" }}
|
||||
/>
|
||||
{withHandle && (
|
||||
<View className="z-10 flex h-4 w-3 items-center justify-center rounded-sm border bg-border">
|
||||
<GripVertical className="h-3 w-3" size={12} color="inherit" />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
|
||||
;(ResizablePanel as any).displayName = "ResizablePanel"
|
||||
;(ResizableHandle as any).displayName = "ResizableHandle"
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
@@ -0,0 +1,34 @@
|
||||
import * as React from "react"
|
||||
import { ScrollView } from "@tarojs/components"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const ScrollArea = React.forwardRef<
|
||||
React.ElementRef<typeof ScrollView>,
|
||||
React.ComponentPropsWithoutRef<typeof ScrollView> & {
|
||||
orientation?: "vertical" | "horizontal" | "both"
|
||||
}
|
||||
>(({ className, children, orientation = "vertical", ...props }, ref) => {
|
||||
const scrollX = orientation === "horizontal" || orientation === "both"
|
||||
const scrollY = orientation === "vertical" || orientation === "both"
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
ref={ref}
|
||||
className={cn("relative", className)}
|
||||
scrollY={scrollY}
|
||||
scrollX={scrollX}
|
||||
style={{
|
||||
overflowX: scrollX ? 'auto' : 'hidden',
|
||||
overflowY: scrollY ? 'auto' : 'hidden',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ScrollView>
|
||||
)
|
||||
})
|
||||
ScrollArea.displayName = "ScrollArea"
|
||||
|
||||
const ScrollBar = () => null // Taro ScrollView handles scrollbars natively
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,438 @@
|
||||
import * as React from "react"
|
||||
import { View, ScrollView } from "@tarojs/components"
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react-taro"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { getRectById, getViewport } from "@/lib/measure"
|
||||
import { Portal } from "@/components/ui/portal"
|
||||
|
||||
type SelectContextValue = {
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
triggerId: string
|
||||
selectedLabel?: string
|
||||
setSelectedLabel?: (label?: string) => void
|
||||
}
|
||||
|
||||
const SelectContext = React.createContext<SelectContextValue | null>(null)
|
||||
|
||||
interface SelectProps {
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
onValueChange?: (value: string) => void
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const Select: React.FC<SelectProps> = ({
|
||||
value: valueProp,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
open: openProp,
|
||||
defaultOpen,
|
||||
onOpenChange,
|
||||
children,
|
||||
}) => {
|
||||
const baseIdRef = React.useRef(
|
||||
`select-${Math.random().toString(36).slice(2, 10)}`
|
||||
)
|
||||
const [openState, setOpenState] = React.useState(defaultOpen || false)
|
||||
const open = openProp !== undefined ? openProp : openState
|
||||
const [valueState, setValueState] = React.useState(defaultValue || "")
|
||||
const value = valueProp !== undefined ? valueProp : valueState
|
||||
const [selectedLabel, setSelectedLabel] = React.useState<string | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (openProp === undefined) {
|
||||
setOpenState(newOpen)
|
||||
}
|
||||
onOpenChange?.(newOpen)
|
||||
}
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
if (valueProp === undefined) {
|
||||
setValueState(newValue)
|
||||
}
|
||||
onValueChange?.(newValue)
|
||||
handleOpenChange(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<SelectContext.Provider
|
||||
value={{
|
||||
value,
|
||||
onValueChange: handleValueChange,
|
||||
open,
|
||||
onOpenChange: handleOpenChange,
|
||||
triggerId: baseIdRef.current,
|
||||
selectedLabel,
|
||||
setSelectedLabel,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SelectContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const SelectGroup = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View ref={ref} className={cn("scroll-my-1 p-1", className)} {...props} />
|
||||
))
|
||||
SelectGroup.displayName = "SelectGroup"
|
||||
|
||||
const SelectValue = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { placeholder?: string }
|
||||
>(({ className, placeholder, children, ...props }, ref) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
const hasValue = !!context?.value
|
||||
const displayValue = children
|
||||
? children
|
||||
: context?.selectedLabel || context?.value || placeholder
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex flex-1 text-left",
|
||||
!hasValue && !children && "text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{displayValue}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
SelectValue.displayName = "SelectValue"
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
size?: "sm" | "default"
|
||||
disabled?: boolean
|
||||
}
|
||||
>(({ className, size = "default", disabled, children, onClick, ...props }, ref) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
{...props}
|
||||
id={context?.triggerId}
|
||||
className={cn(
|
||||
"flex w-fit items-center justify-between gap-2 rounded-lg border border-input bg-transparent pr-2 pl-3 text-sm whitespace-nowrap transition-colors outline-none select-none focus:border-ring focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:size-4",
|
||||
size === "default" && "h-8 py-2",
|
||||
size === "sm" && "h-7 py-1 rounded-[10px]",
|
||||
context?.open &&
|
||||
"border-ring ring-2 ring-ring ring-offset-2 ring-offset-background",
|
||||
className
|
||||
)}
|
||||
hoverClass={
|
||||
disabled
|
||||
? undefined
|
||||
: "border-ring ring-2 ring-ring ring-offset-2 ring-offset-background"
|
||||
}
|
||||
onClick={(e) => {
|
||||
if (disabled) return
|
||||
onClick?.(e)
|
||||
e.stopPropagation()
|
||||
context?.onOpenChange?.(!context.open)
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<ChevronDown className="text-muted-foreground" size={16} color="inherit" />
|
||||
</View>
|
||||
)
|
||||
})
|
||||
SelectTrigger.displayName = "SelectTrigger"
|
||||
|
||||
const SelectScrollUpButton = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) => (
|
||||
<View className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
|
||||
<ChevronUp size={16} color="inherit" />
|
||||
</View>
|
||||
)
|
||||
SelectScrollUpButton.displayName = "SelectScrollUpButton"
|
||||
|
||||
const SelectScrollDownButton = ({ className, ...props }: React.ComponentPropsWithoutRef<typeof View>) => (
|
||||
<View className={cn("flex cursor-default items-center justify-center py-1", className)} {...props}>
|
||||
<ChevronDown size={16} color="inherit" />
|
||||
</View>
|
||||
)
|
||||
SelectScrollDownButton.displayName = "SelectScrollDownButton"
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> &
|
||||
Pick<
|
||||
{
|
||||
align?: "start" | "center" | "end"
|
||||
alignOffset?: number
|
||||
side?: "top" | "bottom" | "left" | "right"
|
||||
sideOffset?: number
|
||||
alignItemWithTrigger?: boolean
|
||||
},
|
||||
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
|
||||
>
|
||||
>(
|
||||
(
|
||||
{
|
||||
className,
|
||||
children,
|
||||
side = "bottom",
|
||||
sideOffset = 4,
|
||||
align = "center",
|
||||
alignOffset = 0,
|
||||
alignItemWithTrigger = true,
|
||||
onClick,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
const contentId = React.useRef(
|
||||
`select-content-${Math.random().toString(36).slice(2, 10)}`
|
||||
)
|
||||
const [position, setPosition] = React.useState<{
|
||||
left: number
|
||||
top: number
|
||||
} | null>(null)
|
||||
const [anchorWidth, setAnchorWidth] = React.useState<number | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!context?.open) {
|
||||
setPosition(null)
|
||||
setAnchorWidth(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
const compute = async () => {
|
||||
if (!context?.triggerId) return
|
||||
const [triggerRect, contentRect] = await Promise.all([
|
||||
getRectById(context.triggerId),
|
||||
getRectById(contentId.current),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
if (!triggerRect?.width || !contentRect?.width) return
|
||||
|
||||
const { width: vw, height: vh } = getViewport()
|
||||
const margin = 8
|
||||
|
||||
const contentMainSize =
|
||||
alignItemWithTrigger && (side === "bottom" || side === "top")
|
||||
? triggerRect.width
|
||||
: side === "left" || side === "right"
|
||||
? contentRect.height
|
||||
: contentRect.width
|
||||
|
||||
const crossStart =
|
||||
side === "left" || side === "right" ? triggerRect.top : triggerRect.left
|
||||
const crossSize =
|
||||
side === "left" || side === "right" ? triggerRect.height : triggerRect.width
|
||||
const contentCrossSize = (() => {
|
||||
if (side === "left" || side === "right") return contentRect.height
|
||||
return alignItemWithTrigger ? triggerRect.width : contentRect.width
|
||||
})()
|
||||
|
||||
const cross = (() => {
|
||||
if (align === "start") return crossStart
|
||||
if (align === "end") return crossStart + crossSize - contentCrossSize
|
||||
return crossStart + crossSize / 2 - contentCrossSize / 2
|
||||
})()
|
||||
|
||||
let left = 0
|
||||
let top = 0
|
||||
|
||||
if (side === "bottom" || side === "top") {
|
||||
left = cross + alignOffset
|
||||
top =
|
||||
side === "bottom"
|
||||
? triggerRect.top + triggerRect.height + sideOffset
|
||||
: triggerRect.top - contentRect.height - sideOffset
|
||||
} else {
|
||||
top = cross + alignOffset
|
||||
left =
|
||||
side === "right"
|
||||
? triggerRect.left + triggerRect.width + sideOffset
|
||||
: triggerRect.left - contentRect.width - sideOffset
|
||||
}
|
||||
|
||||
const clampWidth =
|
||||
side === "bottom" || side === "top" ? contentMainSize : contentRect.width
|
||||
const clampHeight =
|
||||
side === "left" || side === "right" ? contentMainSize : contentRect.height
|
||||
|
||||
left = Math.min(Math.max(left, margin), vw - clampWidth - margin)
|
||||
top = Math.min(Math.max(top, margin), vh - clampHeight - margin)
|
||||
|
||||
setAnchorWidth(triggerRect.width)
|
||||
setPosition({ left, top })
|
||||
}
|
||||
|
||||
const raf = (() => {
|
||||
if (typeof requestAnimationFrame !== "undefined") {
|
||||
return requestAnimationFrame(() => compute())
|
||||
}
|
||||
return setTimeout(() => compute(), 0) as unknown as number
|
||||
})()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (typeof cancelAnimationFrame !== "undefined") {
|
||||
cancelAnimationFrame(raf)
|
||||
} else {
|
||||
clearTimeout(raf)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
align,
|
||||
alignOffset,
|
||||
alignItemWithTrigger,
|
||||
context?.open,
|
||||
context?.triggerId,
|
||||
side,
|
||||
sideOffset,
|
||||
])
|
||||
|
||||
if (!context?.open) return null
|
||||
|
||||
const contentStyle: React.CSSProperties = position
|
||||
? {
|
||||
left: position.left,
|
||||
top: position.top,
|
||||
width: alignItemWithTrigger && anchorWidth ? anchorWidth : undefined,
|
||||
}
|
||||
: {
|
||||
left: 0,
|
||||
top: 0,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
}
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<View
|
||||
className="fixed inset-0 z-50 bg-transparent"
|
||||
onClick={() => context.onOpenChange?.(false)}
|
||||
/>
|
||||
<View
|
||||
ref={ref}
|
||||
id={contentId.current}
|
||||
className={cn(
|
||||
"fixed z-50 min-w-36 overflow-x-hidden overflow-y-auto rounded-lg border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
className
|
||||
)}
|
||||
style={contentStyle}
|
||||
onClick={(e) => {
|
||||
onClick?.(e)
|
||||
e.stopPropagation()
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton className="hidden" />
|
||||
<ScrollView scrollY className="max-h-[50vh]">
|
||||
{children}
|
||||
</ScrollView>
|
||||
<SelectScrollDownButton className="hidden" />
|
||||
</View>
|
||||
</Portal>
|
||||
)
|
||||
})
|
||||
SelectContent.displayName = "SelectContent"
|
||||
|
||||
const SelectLabel = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("px-2 py-1 text-xs text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectLabel.displayName = "SelectLabel"
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { value: string; disabled?: boolean }
|
||||
>(({ className, children, value, disabled, onClick, ...props }, ref) => {
|
||||
const context = React.useContext(SelectContext)
|
||||
const isSelected = context?.value === value
|
||||
|
||||
const labelText = React.useMemo(() => {
|
||||
if (typeof children === "string") return children
|
||||
if (Array.isArray(children) && children.every((c) => typeof c === "string")) {
|
||||
return children.join("")
|
||||
}
|
||||
return undefined
|
||||
}, [children])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isSelected && labelText) {
|
||||
context?.setSelectedLabel?.(labelText)
|
||||
}
|
||||
}, [context, isSelected, labelText])
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1 pr-8 pl-2 text-sm outline-none select-none transition-colors focus:bg-accent focus:text-accent-foreground",
|
||||
disabled && "opacity-50 pointer-events-none",
|
||||
className
|
||||
)}
|
||||
onClick={(e) => {
|
||||
onClick?.(e)
|
||||
if (disabled) return
|
||||
e.stopPropagation()
|
||||
context?.setSelectedLabel?.(labelText)
|
||||
context?.onValueChange?.(value)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<View className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
|
||||
{children}
|
||||
</View>
|
||||
{isSelected ? (
|
||||
<View className="pointer-events-none absolute right-2 flex h-4 w-4 items-center justify-center">
|
||||
<Check size={16} color="inherit" />
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
SelectItem.displayName = "SelectItem"
|
||||
|
||||
const SelectSeparator = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SelectSeparator.displayName = "SelectSeparator"
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectLabel,
|
||||
SelectItem,
|
||||
SelectSeparator,
|
||||
SelectScrollUpButton,
|
||||
SelectScrollDownButton,
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
orientation?: "horizontal" | "vertical"
|
||||
decorative?: boolean
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = "Separator"
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,262 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { X } from "lucide-react-taro"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Portal } from "@/components/ui/portal"
|
||||
|
||||
const SheetContext = React.createContext<{
|
||||
open?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
} | null>(null)
|
||||
|
||||
const usePresence = (open: boolean | undefined, durationMs: number) => {
|
||||
const [present, setPresent] = React.useState(!!open)
|
||||
const timeoutRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
setPresent(true)
|
||||
return
|
||||
}
|
||||
|
||||
timeoutRef.current = setTimeout(() => setPresent(false), durationMs)
|
||||
return () => {
|
||||
if (timeoutRef.current) clearTimeout(timeoutRef.current)
|
||||
timeoutRef.current = null
|
||||
}
|
||||
}, [open, durationMs])
|
||||
|
||||
return present
|
||||
}
|
||||
|
||||
interface SheetProps {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
modal?: boolean
|
||||
}
|
||||
|
||||
const Sheet = ({ children, open: openProp, defaultOpen, onOpenChange }: SheetProps) => {
|
||||
const [openState, setOpenState] = React.useState(defaultOpen || false)
|
||||
const open = openProp !== undefined ? openProp : openState
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (openProp === undefined) {
|
||||
setOpenState(newOpen)
|
||||
}
|
||||
onOpenChange?.(newOpen)
|
||||
}
|
||||
|
||||
return (
|
||||
<SheetContext.Provider value={{ open, onOpenChange: handleOpenChange }}>
|
||||
{children}
|
||||
</SheetContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const SheetTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { asChild?: boolean }
|
||||
>(({ className, children, asChild, ...props }, ref) => {
|
||||
const context = React.useContext(SheetContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("w-fit", className)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
context?.onOpenChange?.(true)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
SheetTrigger.displayName = "SheetTrigger"
|
||||
|
||||
const SheetClose = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { asChild?: boolean }
|
||||
>(({ className, children, asChild, ...props }, ref) => {
|
||||
const context = React.useContext(SheetContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={className}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
context?.onOpenChange?.(false)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
SheetClose.displayName = "SheetClose"
|
||||
|
||||
const SheetPortal = ({ children }: { children: React.ReactNode }) => {
|
||||
const context = React.useContext(SheetContext)
|
||||
const present = usePresence(context?.open, 300)
|
||||
if (!present) return null
|
||||
return <Portal>{children}</Portal>
|
||||
}
|
||||
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, onClick, ...props }, ref) => {
|
||||
const context = React.useContext(SheetContext)
|
||||
const state = context?.open ? "open" : "closed"
|
||||
return (
|
||||
<View
|
||||
data-state={state}
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black bg-opacity-10 transition-opacity duration-100 supports-[backdrop-filter]:backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onClick?.(e)
|
||||
context?.onOpenChange?.(false)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
SheetOverlay.displayName = "SheetOverlay"
|
||||
|
||||
const sheetVariants = cva(
|
||||
"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
{
|
||||
variants: {
|
||||
side: {
|
||||
top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
|
||||
bottom:
|
||||
"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
|
||||
left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
|
||||
right:
|
||||
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
side: "right",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface SheetContentProps
|
||||
extends React.ComponentPropsWithoutRef<typeof View>,
|
||||
VariantProps<typeof sheetVariants> {}
|
||||
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
SheetContentProps
|
||||
>(({ side = "right", className, children, ...props }, ref) => {
|
||||
const context = React.useContext(SheetContext)
|
||||
const state = context?.open ? "open" : "closed"
|
||||
return (
|
||||
<SheetPortal>
|
||||
<View
|
||||
className="fixed inset-0 z-50"
|
||||
onClick={() => context?.onOpenChange?.(false)}
|
||||
>
|
||||
<SheetOverlay />
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(sheetVariants({ side }), "sheet-content", className)}
|
||||
data-state={state}
|
||||
data-side={side}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<View
|
||||
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary"
|
||||
data-state={state}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
context?.onOpenChange?.(false)
|
||||
}}
|
||||
>
|
||||
<X size={16} color="inherit" />
|
||||
<View className="sr-only">Close</View>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
</SheetPortal>
|
||||
)
|
||||
})
|
||||
SheetContent.displayName = "SheetContent"
|
||||
|
||||
const SheetHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View>) => (
|
||||
<View
|
||||
className={cn(
|
||||
"flex flex-col space-y-2 text-center sm:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetHeader.displayName = "SheetHeader"
|
||||
|
||||
const SheetFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View>) => (
|
||||
<View
|
||||
className={cn(
|
||||
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
SheetFooter.displayName = "SheetFooter"
|
||||
|
||||
const SheetTitle = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("text-lg font-semibold text-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetTitle.displayName = "SheetTitle"
|
||||
|
||||
const SheetDescription = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
SheetDescription.displayName = "SheetDescription"
|
||||
|
||||
export {
|
||||
Sheet,
|
||||
SheetPortal,
|
||||
SheetOverlay,
|
||||
SheetTrigger,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetHeader,
|
||||
SheetFooter,
|
||||
SheetTitle,
|
||||
SheetDescription,
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { View } from "@tarojs/components"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Skeleton({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof View>) {
|
||||
return (
|
||||
<View
|
||||
className={cn("animate-pulse rounded-md bg-muted", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Skeleton }
|
||||
@@ -0,0 +1,203 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import Taro from "@tarojs/taro"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Slider = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
Omit<React.ComponentPropsWithoutRef<typeof View>, "value" | "onChange"> & {
|
||||
value?: number[]
|
||||
defaultValue?: number[]
|
||||
min?: number
|
||||
max?: number
|
||||
step?: number
|
||||
onValueChange?: (value: number[]) => void
|
||||
disabled?: boolean
|
||||
orientation?: "horizontal" | "vertical"
|
||||
trackClassName?: string
|
||||
rangeClassName?: string
|
||||
thumbClassName?: string
|
||||
}
|
||||
>(({ className, trackClassName, rangeClassName, thumbClassName, value: valueProp, defaultValue, min = 0, max = 100, step = 1, onValueChange, disabled, orientation = "horizontal", ...props }, ref) => {
|
||||
const [localValue, setLocalValue] = React.useState<number[]>(
|
||||
valueProp || defaultValue || [min]
|
||||
)
|
||||
const [isDragging, setIsDragging] = React.useState(false)
|
||||
const [rect, setRect] = React.useState<{ left: number; top: number; width: number; height: number } | null>(null)
|
||||
const rectRef = React.useRef<{ left: number; top: number; width: number; height: number } | null>(null)
|
||||
const idRef = React.useRef(`slider-${Math.random().toString(36).substr(2, 9)}`)
|
||||
|
||||
const value = valueProp !== undefined ? valueProp : localValue
|
||||
const currentValue = value[0] ?? min
|
||||
|
||||
React.useEffect(() => {
|
||||
rectRef.current = rect
|
||||
}, [rect])
|
||||
|
||||
React.useEffect(() => {
|
||||
// Delay measurement to ensure the component is mounted and layout is ready
|
||||
const timer = setTimeout(() => {
|
||||
const query = Taro.createSelectorQuery()
|
||||
query
|
||||
.select(`#${idRef.current}`)
|
||||
.boundingClientRect((res) => {
|
||||
const measuredRect = Array.isArray(res) ? res[0] : res
|
||||
if (measuredRect) {
|
||||
setRect({ left: measuredRect.left, top: measuredRect.top, width: measuredRect.width, height: measuredRect.height })
|
||||
rectRef.current = { left: measuredRect.left, top: measuredRect.top, width: measuredRect.width, height: measuredRect.height }
|
||||
}
|
||||
})
|
||||
.exec()
|
||||
}, 100)
|
||||
return () => clearTimeout(timer)
|
||||
}, [])
|
||||
|
||||
const updateValue = (pageX: number, pageY: number, passedRect?: { left: number; top: number; width: number; height: number } | null) => {
|
||||
const currentRect = passedRect || rectRef.current
|
||||
if (!currentRect || disabled) return
|
||||
|
||||
let percentage = 0
|
||||
if (orientation === "horizontal") {
|
||||
const { left, width } = currentRect
|
||||
percentage = Math.min(Math.max((pageX - left) / width, 0), 1)
|
||||
} else {
|
||||
const { top, height } = currentRect
|
||||
percentage = Math.min(Math.max(1 - (pageY - top) / height, 0), 1)
|
||||
}
|
||||
|
||||
const rawValue = min + percentage * (max - min)
|
||||
const steppedValue = Math.round((rawValue - min) / step) * step + min
|
||||
const newValue = Math.min(Math.max(steppedValue, min), max)
|
||||
|
||||
if (newValue !== currentValue) {
|
||||
const nextValue = [newValue]
|
||||
if (valueProp === undefined) {
|
||||
setLocalValue(nextValue)
|
||||
}
|
||||
onValueChange?.(nextValue)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchStart = (e: any) => {
|
||||
if (disabled) return
|
||||
setIsDragging(true)
|
||||
// Try to update rect on touch start in case of layout changes
|
||||
const query = Taro.createSelectorQuery()
|
||||
query
|
||||
.select(`#${idRef.current}`)
|
||||
.boundingClientRect((res) => {
|
||||
const measuredRect = Array.isArray(res) ? res[0] : res
|
||||
if (measuredRect) {
|
||||
setRect({ left: measuredRect.left, top: measuredRect.top, width: measuredRect.width, height: measuredRect.height })
|
||||
rectRef.current = { left: measuredRect.left, top: measuredRect.top, width: measuredRect.width, height: measuredRect.height }
|
||||
// If we have a touch event, update value immediately after getting fresh rect
|
||||
const touch = e.touches[0] || e.changedTouches[0]
|
||||
if (touch) {
|
||||
updateValue(touch.pageX, touch.pageY, rectRef.current)
|
||||
}
|
||||
}
|
||||
})
|
||||
.exec()
|
||||
}
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if (disabled) return
|
||||
setIsDragging(true)
|
||||
|
||||
const query = Taro.createSelectorQuery()
|
||||
query
|
||||
.select(`#${idRef.current}`)
|
||||
.boundingClientRect((res) => {
|
||||
const measuredRect = Array.isArray(res) ? res[0] : res
|
||||
if (measuredRect) {
|
||||
setRect({ left: measuredRect.left, top: measuredRect.top, width: measuredRect.width, height: measuredRect.height })
|
||||
rectRef.current = { left: measuredRect.left, top: measuredRect.top, width: measuredRect.width, height: measuredRect.height }
|
||||
updateValue(e.pageX, e.pageY, rectRef.current)
|
||||
}
|
||||
})
|
||||
.exec()
|
||||
|
||||
const onMouseMove = (moveEvent: MouseEvent) => {
|
||||
updateValue(moveEvent.pageX, moveEvent.pageY)
|
||||
}
|
||||
|
||||
const onMouseUp = (upEvent: MouseEvent) => {
|
||||
setIsDragging(false)
|
||||
updateValue(upEvent.pageX, upEvent.pageY)
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: any) => {
|
||||
if (disabled) return
|
||||
const touch = e.touches[0] || e.changedTouches[0]
|
||||
if (touch) {
|
||||
updateValue(touch.pageX, touch.pageY)
|
||||
}
|
||||
}
|
||||
|
||||
const handleTouchEnd = (e: any) => {
|
||||
if (disabled) return
|
||||
setIsDragging(false)
|
||||
const touch = e.touches[0] || e.changedTouches[0]
|
||||
if (touch) {
|
||||
updateValue(touch.pageX, touch.pageY)
|
||||
}
|
||||
}
|
||||
|
||||
const percentage = ((currentValue - min) / (max - min)) * 100
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
id={idRef.current}
|
||||
className={cn(
|
||||
"relative flex touch-none select-none items-center",
|
||||
orientation === "horizontal" ? "w-full py-4" : "h-full flex-col px-4",
|
||||
className
|
||||
)}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
// @ts-ignore
|
||||
onMouseDown={handleMouseDown}
|
||||
{...props}
|
||||
>
|
||||
<View
|
||||
className={cn(
|
||||
"relative grow overflow-hidden rounded-full bg-secondary",
|
||||
orientation === "horizontal" ? "h-1 w-full" : "w-1 h-full",
|
||||
trackClassName
|
||||
)}
|
||||
>
|
||||
<View
|
||||
className={cn("absolute bg-primary", orientation === "horizontal" ? "h-full" : "w-full bottom-0", rangeClassName)}
|
||||
style={orientation === "horizontal" ? { width: `${percentage}%` } : { height: `${percentage}%` }}
|
||||
/>
|
||||
</View>
|
||||
<View
|
||||
className={cn(
|
||||
"absolute block h-3 w-3 rounded-full border-2 border-primary bg-background ring-offset-background transition-colors disabled:pointer-events-none disabled:opacity-50",
|
||||
isDragging && "ring-4 ring-primary ring-opacity-30",
|
||||
disabled && "opacity-50",
|
||||
thumbClassName
|
||||
)}
|
||||
style={
|
||||
orientation === "horizontal"
|
||||
? { left: `${percentage}%`, transform: 'translateX(-50%)' }
|
||||
: { bottom: `${percentage}%`, transform: 'translateY(50%)' }
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
Slider.displayName = "Slider"
|
||||
|
||||
export { Slider }
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./toast"
|
||||
@@ -0,0 +1,55 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
checked?: boolean
|
||||
defaultChecked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
>(({ className, checked, defaultChecked, onCheckedChange, disabled, ...props }, ref) => {
|
||||
const [localChecked, setLocalChecked] = React.useState(defaultChecked || false)
|
||||
const isControlled = checked !== undefined
|
||||
const currentChecked = isControlled ? checked : localChecked
|
||||
|
||||
return (
|
||||
<View
|
||||
className={cn(
|
||||
"inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background [-webkit-tap-highlight-color:transparent]",
|
||||
disabled && "cursor-not-allowed opacity-50",
|
||||
currentChecked ? "bg-primary" : "bg-input",
|
||||
className
|
||||
)}
|
||||
data-state={currentChecked ? "checked" : "unchecked"}
|
||||
hoverClass={
|
||||
disabled
|
||||
? undefined
|
||||
: "border-ring ring-2 ring-ring ring-offset-2 ring-offset-background"
|
||||
}
|
||||
{...props}
|
||||
ref={ref}
|
||||
onClick={(e) => {
|
||||
if (disabled) return
|
||||
e.stopPropagation()
|
||||
const newChecked = !currentChecked
|
||||
if (!isControlled) {
|
||||
setLocalChecked(newChecked)
|
||||
}
|
||||
onCheckedChange?.(newChecked)
|
||||
}}
|
||||
>
|
||||
<View
|
||||
className={cn(
|
||||
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform",
|
||||
currentChecked ? "translate-x-5" : "translate-x-0"
|
||||
)}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
Switch.displayName = "Switch"
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,142 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Table = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View className="relative w-full overflow-auto">
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</View>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View ref={ref} className={cn("[&>view]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("[&>view:last-child]:border-b-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted bg-opacity-50 font-medium [&>view:last-child]:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted hover:bg-opacity-50 data-[state=selected]:bg-muted flex flex-row",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground flex flex-1 basis-0 min-w-0 items-center justify-start",
|
||||
typeof className === "string" && /(^|\s)text-right(\s|$)/.test(className)
|
||||
? "justify-end"
|
||||
: null,
|
||||
typeof className === "string" && /(^|\s)w-/.test(className)
|
||||
? "flex-none basis-auto"
|
||||
: null,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & { colSpan?: number }
|
||||
>(({ className, colSpan, style, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
style={
|
||||
colSpan != null &&
|
||||
typeof style === "object" &&
|
||||
style != null &&
|
||||
!Array.isArray(style) &&
|
||||
!(typeof className === "string" && /(^|\s)w-/.test(className))
|
||||
? { ...(style as React.CSSProperties), flexGrow: colSpan }
|
||||
: style
|
||||
}
|
||||
className={cn(
|
||||
"p-4 align-middle flex flex-1 basis-0 min-w-0 items-center justify-start",
|
||||
typeof className === "string" && /(^|\s)text-right(\s|$)/.test(className)
|
||||
? "justify-end"
|
||||
: null,
|
||||
typeof className === "string" && /(^|\s)w-/.test(className)
|
||||
? "flex-none basis-auto"
|
||||
: null,
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const TabsContext = React.createContext<{
|
||||
value?: string
|
||||
onValueChange?: (value: string) => void
|
||||
} | null>(null)
|
||||
|
||||
const Tabs = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
value?: string
|
||||
defaultValue?: string
|
||||
onValueChange?: (value: string) => void
|
||||
}
|
||||
>(({ className, value: valueProp, defaultValue, onValueChange, ...props }, ref) => {
|
||||
const [valueState, setValueState] = React.useState<string | undefined>(defaultValue)
|
||||
const value = valueProp !== undefined ? valueProp : valueState
|
||||
|
||||
const handleValueChange = (newValue: string) => {
|
||||
if (valueProp === undefined) {
|
||||
setValueState(newValue)
|
||||
}
|
||||
onValueChange?.(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={{ value, onValueChange: handleValueChange }}>
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
</TabsContext.Provider>
|
||||
)
|
||||
})
|
||||
Tabs.displayName = "Tabs"
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = "TabsList"
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
value: string
|
||||
disabled?: boolean
|
||||
}
|
||||
>(({ className, value, onClick, disabled, ...props }, ref) => {
|
||||
const context = React.useContext(TabsContext)
|
||||
const isActive = context?.value === value
|
||||
|
||||
const handleClick = (e: any) => {
|
||||
if (disabled) return
|
||||
context?.onValueChange?.(value)
|
||||
onClick?.(e)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1 text-sm font-medium ring-offset-background transition-all focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:pointer-events-none disabled:opacity-50",
|
||||
isActive && "bg-background text-foreground shadow-sm",
|
||||
disabled && "opacity-50 pointer-events-none",
|
||||
className
|
||||
)}
|
||||
hoverClass={
|
||||
disabled
|
||||
? undefined
|
||||
: "border-ring ring-2 ring-ring ring-offset-2 ring-offset-background"
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
TabsTrigger.displayName = "TabsTrigger"
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
value: string
|
||||
}
|
||||
>(({ className, value, ...props }, ref) => {
|
||||
const context = React.useContext(TabsContext)
|
||||
if (context?.value !== value) return null
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
TabsContent.displayName = "TabsContent"
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from "react"
|
||||
import { Textarea as TaroTextarea, View } from "@tarojs/components"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
export interface TextareaProps
|
||||
extends React.ComponentPropsWithoutRef<typeof TaroTextarea> {
|
||||
className?: string
|
||||
autoFocus?: boolean
|
||||
}
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
React.ElementRef<typeof TaroTextarea>,
|
||||
TextareaProps
|
||||
>(({ className, autoFocus, focus, onFocus, onBlur, ...props }, ref) => {
|
||||
const [isFocused, setIsFocused] = React.useState(false)
|
||||
const disabled = !!(props as any).disabled
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoFocus || focus) setIsFocused(true)
|
||||
}, [autoFocus, focus])
|
||||
return (
|
||||
<View
|
||||
className={cn(
|
||||
"flex h-20 w-full rounded-md border border-input bg-background px-3 py-2 ring-offset-background focus-within:border-ring focus-within:ring-4 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background",
|
||||
isFocused && "border-ring ring-4 ring-ring ring-offset-2 ring-offset-background",
|
||||
className
|
||||
)}
|
||||
onTouchStart={() => {
|
||||
if (disabled) return
|
||||
setIsFocused(true)
|
||||
}}
|
||||
>
|
||||
<TaroTextarea
|
||||
className="flex-1 w-full h-full bg-transparent text-base text-foreground placeholder:text-muted-foreground focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm selection:bg-selection selection:text-selection-foreground"
|
||||
placeholderClass="text-muted-foreground"
|
||||
ref={ref}
|
||||
focus={autoFocus || focus}
|
||||
onFocus={(e) => {
|
||||
setIsFocused(true)
|
||||
onFocus?.(e)
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
setIsFocused(false)
|
||||
onBlur?.(e)
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,517 @@
|
||||
import * as React from "react"
|
||||
import { Text, View } from "@tarojs/components"
|
||||
import Taro from "@tarojs/taro"
|
||||
import {
|
||||
Check,
|
||||
Info,
|
||||
Loader,
|
||||
TriangleAlert,
|
||||
X,
|
||||
CircleAlert
|
||||
} from "lucide-react-taro"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Portal } from "@/components/ui/portal"
|
||||
|
||||
export type ToastPosition =
|
||||
| "top-left"
|
||||
| "top-right"
|
||||
| "bottom-left"
|
||||
| "bottom-right"
|
||||
| "top-center"
|
||||
| "bottom-center"
|
||||
|
||||
export type ToastType = "success" | "info" | "warning" | "error" | "loading" | "default"
|
||||
|
||||
export interface ToastAction {
|
||||
label: string
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export interface ToastData {
|
||||
id?: string | number
|
||||
title?: React.ReactNode
|
||||
description?: React.ReactNode
|
||||
type?: ToastType
|
||||
duration?: number
|
||||
position?: ToastPosition
|
||||
invert?: boolean
|
||||
dismissible?: boolean
|
||||
descriptionClassName?: string
|
||||
action?: ToastAction
|
||||
cancel?: ToastAction
|
||||
onDismiss?: (toast: Toast) => void
|
||||
onAutoClose?: (toast: Toast) => void
|
||||
richColors?: boolean
|
||||
closeButton?: boolean
|
||||
style?: React.CSSProperties
|
||||
className?: string
|
||||
jsx?: React.ReactNode | ((id: string | number) => React.ReactNode)
|
||||
}
|
||||
|
||||
export interface Toast extends ToastData {
|
||||
id: string | number
|
||||
}
|
||||
|
||||
const listeners: Array<(toasts: Toast[]) => void> = []
|
||||
let toasts: Toast[] = []
|
||||
|
||||
const notify = () => {
|
||||
listeners.forEach((l) => l([...toasts]))
|
||||
}
|
||||
|
||||
const createToast = (title: React.ReactNode, data: ToastData = {}) => {
|
||||
const id = data.id || Date.now().toString() + Math.random().toString(36).substring(2, 9)
|
||||
|
||||
const existingToast = toasts.find((t) => t.id === id)
|
||||
|
||||
if (existingToast) {
|
||||
toasts = toasts.map((t) =>
|
||||
t.id === id ? { ...t, ...data, title: title || t.title } : t
|
||||
)
|
||||
} else {
|
||||
const newToast: Toast = {
|
||||
...data,
|
||||
id,
|
||||
title,
|
||||
type: data.type || "default",
|
||||
dismissible: data.dismissible ?? true,
|
||||
}
|
||||
toasts = [...toasts, newToast]
|
||||
}
|
||||
|
||||
notify()
|
||||
return id
|
||||
}
|
||||
|
||||
const dismiss = (id?: string | number) => {
|
||||
if (!id) {
|
||||
toasts = []
|
||||
} else {
|
||||
toasts = toasts.filter((t) => t.id !== id)
|
||||
}
|
||||
notify()
|
||||
}
|
||||
|
||||
type ToastFunction = (title: string | React.ReactNode, data?: ToastData) => string | number
|
||||
|
||||
const toastFn: ToastFunction = (title, data) => createToast(title, data)
|
||||
|
||||
const toast = Object.assign(toastFn, {
|
||||
success: (title: string | React.ReactNode, data?: ToastData) =>
|
||||
createToast(title, { ...data, type: "success" }),
|
||||
error: (title: string | React.ReactNode, data?: ToastData) =>
|
||||
createToast(title, { ...data, type: "error" }),
|
||||
warning: (title: string | React.ReactNode, data?: ToastData) =>
|
||||
createToast(title, { ...data, type: "warning" }),
|
||||
info: (title: string | React.ReactNode, data?: ToastData) =>
|
||||
createToast(title, { ...data, type: "info" }),
|
||||
loading: (title: string | React.ReactNode, data?: ToastData) =>
|
||||
createToast(title, { ...data, type: "loading" }),
|
||||
message: (title: string | React.ReactNode, data?: ToastData) =>
|
||||
createToast(title, { ...data, type: "default" }),
|
||||
custom: (jsx: (id: string | number) => React.ReactNode, data?: ToastData) => {
|
||||
const id = data?.id || Date.now().toString()
|
||||
return createToast(null, { ...data, id, jsx })
|
||||
},
|
||||
dismiss,
|
||||
promise: <T,>(
|
||||
promise: Promise<T> | (() => Promise<T>),
|
||||
data: {
|
||||
loading?: string | React.ReactNode
|
||||
success?: string | React.ReactNode | ((data: T) => React.ReactNode)
|
||||
error?: string | React.ReactNode | ((error: unknown) => React.ReactNode)
|
||||
finally?: () => void
|
||||
} & ToastData
|
||||
) => {
|
||||
const id = toast.loading(data.loading, { ...data })
|
||||
|
||||
const p = typeof promise === "function" ? promise() : promise
|
||||
|
||||
p.then((res) => {
|
||||
const successMessage = typeof data.success === "function" ? data.success(res) : data.success
|
||||
toast.success(successMessage, { id, ...data })
|
||||
})
|
||||
.catch((err) => {
|
||||
const errorMessage = typeof data.error === "function" ? data.error(err) : data.error
|
||||
toast.error(errorMessage, { id, ...data })
|
||||
})
|
||||
.finally(() => {
|
||||
data.finally?.()
|
||||
})
|
||||
|
||||
return id
|
||||
}
|
||||
})
|
||||
|
||||
interface ToasterProps {
|
||||
position?: ToastPosition
|
||||
richColors?: boolean
|
||||
expand?: boolean
|
||||
closeButton?: boolean
|
||||
offset?: number
|
||||
dir?: "rtl" | "ltr" | "auto"
|
||||
visibleToasts?: number
|
||||
duration?: number
|
||||
gap?: number
|
||||
theme?: "light" | "dark" | "system"
|
||||
className?: string
|
||||
style?: React.CSSProperties
|
||||
toastOptions?: Omit<ToastData, "id">
|
||||
}
|
||||
|
||||
const Toaster = ({
|
||||
position = "bottom-right",
|
||||
richColors = false,
|
||||
expand = false,
|
||||
closeButton = false,
|
||||
visibleToasts = 3,
|
||||
duration = 4000,
|
||||
gap = 14,
|
||||
className,
|
||||
style,
|
||||
toastOptions
|
||||
}: ToasterProps) => {
|
||||
const [activeToasts, setActiveToasts] = React.useState<Toast[]>([])
|
||||
const [closingIds, setClosingIds] = React.useState<Set<string | number>>(() => new Set())
|
||||
const [frontHeight, setFrontHeight] = React.useState<number | null>(null)
|
||||
const frontIdRef = React.useRef(`toaster-front-${Math.random().toString(36).slice(2, 9)}`)
|
||||
|
||||
React.useEffect(() => {
|
||||
listeners.push(setActiveToasts)
|
||||
return () => {
|
||||
const index = listeners.indexOf(setActiveToasts)
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const getPositionStyle = (pos: ToastPosition) => {
|
||||
switch (pos) {
|
||||
case "top-left": return "top-0 left-0 right-0 justify-start"
|
||||
case "top-right": return "top-0 left-0 right-0 justify-end"
|
||||
case "bottom-left": return "bottom-0 left-0 right-0 justify-start"
|
||||
case "bottom-right": return "bottom-0 left-0 right-0 justify-end"
|
||||
case "top-center": return "top-0 left-0 right-0 justify-center"
|
||||
case "bottom-center": return "bottom-0 left-0 right-0 justify-center"
|
||||
default: return "bottom-0 left-0 right-0 justify-end"
|
||||
}
|
||||
}
|
||||
|
||||
const isTop = position.includes("top")
|
||||
|
||||
React.useEffect(() => {
|
||||
if (expand) return
|
||||
if (activeToasts.length <= visibleToasts) return
|
||||
|
||||
const overflow = activeToasts.slice(0, activeToasts.length - visibleToasts)
|
||||
const nextToClose = overflow.find((t) => !closingIds.has(t.id))
|
||||
if (!nextToClose) return
|
||||
|
||||
setClosingIds((prev) => {
|
||||
if (prev.has(nextToClose.id)) return prev
|
||||
const next = new Set(prev)
|
||||
next.add(nextToClose.id)
|
||||
return next
|
||||
})
|
||||
}, [activeToasts, closingIds, expand, visibleToasts])
|
||||
|
||||
React.useEffect(() => {
|
||||
setClosingIds((prev) => {
|
||||
if (prev.size === 0) return prev
|
||||
const activeIds = new Set(activeToasts.map((t) => t.id))
|
||||
const next = new Set<string | number>()
|
||||
prev.forEach((id) => {
|
||||
if (activeIds.has(id)) next.add(id)
|
||||
})
|
||||
return next.size === prev.size ? prev : next
|
||||
})
|
||||
}, [activeToasts])
|
||||
|
||||
const toastsToRender = React.useMemo(() => {
|
||||
if (expand) return activeToasts
|
||||
const keep = activeToasts.slice(-visibleToasts)
|
||||
const overflowCount = Math.max(0, activeToasts.length - visibleToasts)
|
||||
const overflowIds = new Set(activeToasts.slice(0, overflowCount).map((t) => t.id))
|
||||
const closing = activeToasts.filter((t) => overflowIds.has(t.id) && closingIds.has(t.id))
|
||||
return [...closing, ...keep]
|
||||
}, [activeToasts, closingIds, expand, visibleToasts])
|
||||
|
||||
const listStyle = expand ? ({ gap } as React.CSSProperties) : undefined
|
||||
|
||||
React.useEffect(() => {
|
||||
if (toastsToRender.length === 0) return
|
||||
const timer = setTimeout(() => {
|
||||
const query = Taro.createSelectorQuery()
|
||||
query
|
||||
.select(`#${frontIdRef.current}`)
|
||||
.boundingClientRect((res) => {
|
||||
const rect = Array.isArray(res) ? res[0] : res
|
||||
if (rect?.height) setFrontHeight(rect.height)
|
||||
})
|
||||
.exec()
|
||||
}, 50)
|
||||
return () => clearTimeout(timer)
|
||||
}, [toastsToRender.length])
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<View
|
||||
className={cn(
|
||||
"toaster fixed z-[2147483647] flex w-full pointer-events-none p-4",
|
||||
getPositionStyle(position),
|
||||
className
|
||||
)}
|
||||
data-position={position}
|
||||
style={style}
|
||||
>
|
||||
<View
|
||||
className={cn(
|
||||
"toaster-list relative w-full flex",
|
||||
isTop ? "flex-col-reverse" : "flex-col"
|
||||
)}
|
||||
style={listStyle}
|
||||
>
|
||||
{toastsToRender.map((t, index) => {
|
||||
const isFront = index === toastsToRender.length - 1
|
||||
const stackIndex = toastsToRender.length - 1 - index
|
||||
|
||||
return (
|
||||
<ToastItem
|
||||
key={t.id}
|
||||
elementId={isFront ? frontIdRef.current : undefined}
|
||||
item={t}
|
||||
isExpanded={expand}
|
||||
isFront={isFront}
|
||||
stackIndex={stackIndex}
|
||||
isTop={isTop}
|
||||
gap={gap}
|
||||
forceClose={!expand && closingIds.has(t.id)}
|
||||
frontHeight={frontHeight}
|
||||
duration={t.duration || duration}
|
||||
richColors={t.richColors ?? richColors}
|
||||
closeButton={t.closeButton ?? closeButton}
|
||||
toastOptions={toastOptions}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
const ToastItem = ({
|
||||
elementId,
|
||||
item,
|
||||
isExpanded,
|
||||
isFront,
|
||||
stackIndex,
|
||||
isTop,
|
||||
gap,
|
||||
forceClose,
|
||||
frontHeight,
|
||||
duration,
|
||||
richColors,
|
||||
closeButton,
|
||||
toastOptions
|
||||
}: {
|
||||
elementId?: string
|
||||
item: Toast
|
||||
isExpanded: boolean
|
||||
isFront: boolean
|
||||
stackIndex: number
|
||||
isTop: boolean
|
||||
gap: number
|
||||
forceClose: boolean
|
||||
frontHeight: number | null
|
||||
duration: number
|
||||
richColors: boolean
|
||||
closeButton: boolean
|
||||
toastOptions?: Omit<ToastData, "id">
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = React.useState(false)
|
||||
const [isRemoved, setIsRemoved] = React.useState(false)
|
||||
const timeOutRef = React.useRef<NodeJS.Timeout>()
|
||||
|
||||
React.useEffect(() => {
|
||||
const timer = setTimeout(() => setIsVisible(true), 16)
|
||||
|
||||
if (item.duration === Infinity) return
|
||||
|
||||
const d = item.duration || duration
|
||||
timeOutRef.current = setTimeout(() => {
|
||||
handleDismiss()
|
||||
}, d)
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer)
|
||||
clearTimeout(timeOutRef.current)
|
||||
}
|
||||
}, [item.duration, duration])
|
||||
|
||||
const handleDismiss = () => {
|
||||
setIsVisible(false)
|
||||
setTimeout(() => {
|
||||
setIsRemoved(true)
|
||||
item.onDismiss?.(item)
|
||||
dismiss(item.id)
|
||||
}, 400)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!forceClose) return
|
||||
const t = setTimeout(() => handleDismiss(), 16)
|
||||
return () => clearTimeout(t)
|
||||
}, [forceClose])
|
||||
|
||||
const TypeIcon = {
|
||||
success: Check,
|
||||
error: CircleAlert,
|
||||
warning: TriangleAlert,
|
||||
info: Info,
|
||||
loading: Loader,
|
||||
default: null
|
||||
}[item.type || "default"]
|
||||
|
||||
const palette = React.useMemo(() => {
|
||||
if (!richColors) return null
|
||||
const type = item.type || "default"
|
||||
if (type === "success") {
|
||||
return { backgroundColor: "hsl(143, 85%, 96%)", borderColor: "hsl(145, 92%, 87%)", color: "hsl(140, 100%, 27%)" }
|
||||
}
|
||||
if (type === "info") {
|
||||
return { backgroundColor: "hsl(208, 100%, 97%)", borderColor: "hsl(221, 91%, 93%)", color: "hsl(210, 92%, 45%)" }
|
||||
}
|
||||
if (type === "warning") {
|
||||
return { backgroundColor: "hsl(49, 100%, 97%)", borderColor: "hsl(49, 91%, 84%)", color: "hsl(31, 92%, 45%)" }
|
||||
}
|
||||
if (type === "error") {
|
||||
return { backgroundColor: "hsl(359, 100%, 97%)", borderColor: "hsl(359, 100%, 94%)", color: "hsl(360, 100%, 45%)" }
|
||||
}
|
||||
return null
|
||||
}, [item.type, richColors])
|
||||
|
||||
const iconColor = palette?.color ?? "inherit"
|
||||
|
||||
const isCollapsedStack = !isExpanded && !isFront
|
||||
const lift = isTop ? 1 : -1
|
||||
const enterOffset = (frontHeight ?? 80) * 1.5
|
||||
const stackTranslate = lift * gap * stackIndex
|
||||
const stackScale = 1 - stackIndex * 0.05
|
||||
const transform = isCollapsedStack
|
||||
? `translateY(${stackTranslate}px) scale(${stackScale})`
|
||||
: isVisible
|
||||
? "translateY(0px)"
|
||||
: `translateY(${(-lift * enterOffset).toFixed(0)}px)`
|
||||
|
||||
const motionStyle: React.CSSProperties = {
|
||||
transform,
|
||||
opacity: isVisible ? 1 : 0,
|
||||
transition: "transform 400ms ease, opacity 400ms ease, height 400ms ease, box-shadow 200ms ease",
|
||||
transformOrigin: isCollapsedStack ? (isTop ? "bottom" : "top") : "center"
|
||||
}
|
||||
|
||||
if (isRemoved) return null
|
||||
|
||||
const isCustom = typeof item.jsx !== "undefined"
|
||||
|
||||
const baseClasses = cn(
|
||||
"toast pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-4 shadow-lg",
|
||||
!palette && "bg-background border-border text-foreground",
|
||||
item.className,
|
||||
toastOptions?.className
|
||||
)
|
||||
|
||||
const finalStyle: React.CSSProperties = {
|
||||
...(palette || {}),
|
||||
...item.style,
|
||||
...motionStyle,
|
||||
position: isCollapsedStack ? "absolute" : "relative",
|
||||
...(isCollapsedStack ? (isTop ? { bottom: 0 } : { top: 0 }) : (isTop ? { top: 0 } : { bottom: 0 })),
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: isFront ? 50 : 40 - stackIndex,
|
||||
pointerEvents: isCollapsedStack ? "none" : "auto",
|
||||
...(isCollapsedStack && frontHeight ? { height: frontHeight } : {})
|
||||
}
|
||||
|
||||
if (isCustom) {
|
||||
const content = typeof item.jsx === "function" ? item.jsx(item.id) : item.jsx
|
||||
return (
|
||||
<View
|
||||
className={cn(
|
||||
"pointer-events-auto w-full"
|
||||
)}
|
||||
id={elementId}
|
||||
style={finalStyle}
|
||||
>
|
||||
<View style={{ opacity: isCollapsedStack ? 0 : 1, transition: "opacity 400ms ease" }}>
|
||||
{content}
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View className={baseClasses} style={finalStyle} id={elementId}>
|
||||
<View className="flex gap-3 items-center flex-1" style={{ opacity: isCollapsedStack ? 0 : 1, transition: "opacity 400ms ease" }}>
|
||||
{TypeIcon && (
|
||||
<TypeIcon className={cn("shrink-0", item.type === "loading" && "animate-spin")} color={iconColor} size={20} />
|
||||
)}
|
||||
<View className="flex flex-col gap-1 flex-1">
|
||||
{item.title && <Text className="text-sm font-semibold leading-none" style={palette ? { color: palette.color } : undefined}>{item.title}</Text>}
|
||||
{item.description && (
|
||||
<Text className={cn("text-xs opacity-90 leading-normal", item.descriptionClassName)} style={palette ? { color: palette.color } : undefined}>
|
||||
{item.description}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{(item.action || item.cancel) && (
|
||||
<View className="flex flex-nowrap items-center gap-2 shrink-0" style={{ opacity: isCollapsedStack ? 0 : 1, transition: "opacity 400ms ease" }}>
|
||||
{item.cancel && (
|
||||
<View
|
||||
className="text-xs font-medium opacity-70 active:opacity-100 whitespace-nowrap"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
item.cancel?.onClick()
|
||||
handleDismiss()
|
||||
}}
|
||||
>
|
||||
{item.cancel.label}
|
||||
</View>
|
||||
)}
|
||||
{item.action && (
|
||||
<View
|
||||
className="text-xs font-medium active:opacity-80 px-3 py-2 rounded-md bg-primary text-primary-foreground shadow hover:bg-primary hover:bg-opacity-90 whitespace-nowrap"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
item.action?.onClick()
|
||||
handleDismiss()
|
||||
}}
|
||||
>
|
||||
{item.action.label}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
{closeButton && (
|
||||
<View
|
||||
className="absolute right-2 top-2 rounded-md p-1 opacity-50 transition-opacity hover:opacity-100 focus:opacity-100 focus:outline-none focus:ring-2"
|
||||
style={{ opacity: isCollapsedStack ? 0 : undefined, transition: "opacity 400ms ease" }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDismiss()
|
||||
}}
|
||||
>
|
||||
<X color={iconColor} size={16} />
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster, toast }
|
||||
@@ -0,0 +1,120 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
value?: string | string[]
|
||||
onValueChange?: (value: string) => void
|
||||
type: "single" | "multiple"
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
type: "single"
|
||||
})
|
||||
|
||||
const ToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
type?: "single" | "multiple"
|
||||
value?: string | string[]
|
||||
defaultValue?: string | string[]
|
||||
onValueChange?: (value: string | string[]) => void
|
||||
}
|
||||
>(({ className, variant, size, children, type = "single", value: valueProp, defaultValue, onValueChange, ...props }, ref) => {
|
||||
const [valueState, setValueState] = React.useState<string | string[]>(
|
||||
defaultValue || (type === "multiple" ? [] : "")
|
||||
)
|
||||
const value = valueProp !== undefined ? valueProp : valueState
|
||||
|
||||
const handleValueChange = (itemValue: string) => {
|
||||
let newValue: string | string[]
|
||||
if (type === "multiple") {
|
||||
const current = Array.isArray(value) ? value : []
|
||||
if (current.includes(itemValue)) {
|
||||
newValue = current.filter(v => v !== itemValue)
|
||||
} else {
|
||||
newValue = [...current, itemValue]
|
||||
}
|
||||
} else {
|
||||
// In Radix ToggleGroup "single", clicking active item deselects it?
|
||||
// Actually yes, unless `rovingFocus` logic etc.
|
||||
// But usually it behaves like radio if required=true.
|
||||
// Radix primitive has `rovingFocus` and `loop`.
|
||||
// We'll implement simple toggle logic.
|
||||
if (value === itemValue) {
|
||||
newValue = "" // Deselect
|
||||
} else {
|
||||
newValue = itemValue
|
||||
}
|
||||
}
|
||||
|
||||
if (valueProp === undefined) {
|
||||
setValueState(newValue)
|
||||
}
|
||||
onValueChange?.(newValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn("flex items-center justify-center gap-1", className)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, value, onValueChange: handleValueChange, type }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
ToggleGroup.displayName = "ToggleGroup"
|
||||
|
||||
const ToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
value: string
|
||||
disabled?: boolean
|
||||
}
|
||||
>(({ className, children, variant, size, value, disabled, ...props }, ref) => {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
const checked = context.type === "multiple"
|
||||
? Array.isArray(context.value) && context.value.includes(value)
|
||||
: context.value === value
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
className,
|
||||
checked && "bg-accent text-accent-foreground",
|
||||
disabled && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
data-state={checked ? "on" : "off"}
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
onClick={(e) => {
|
||||
if (disabled) return
|
||||
context.onValueChange?.(value)
|
||||
props.onClick?.(e)
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
})
|
||||
|
||||
ToggleGroupItem.displayName = "ToggleGroupItem"
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -0,0 +1,77 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const toggleVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0 gap-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline:
|
||||
"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-3 min-w-10",
|
||||
sm: "h-9 px-3 min-w-9",
|
||||
lg: "h-11 px-5 min-w-11",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Toggle = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
pressed?: boolean
|
||||
onPressedChange?: (pressed: boolean) => void
|
||||
defaultPressed?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
>(({ className, variant, size, pressed: pressedProp, defaultPressed, onPressedChange, disabled, ...props }, ref) => {
|
||||
const [pressedState, setPressedState] = React.useState(defaultPressed || false)
|
||||
const pressed = pressedProp !== undefined ? pressedProp : pressedState
|
||||
|
||||
const handleClick = (e) => {
|
||||
if (disabled) return
|
||||
const newPressed = !pressed
|
||||
if (pressedProp === undefined) {
|
||||
setPressedState(newPressed)
|
||||
}
|
||||
onPressedChange?.(newPressed)
|
||||
props.onClick?.(e)
|
||||
}
|
||||
const tabIndex = (props as { tabIndex?: number }).tabIndex ?? (disabled ? -1 : 0)
|
||||
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
className={cn(
|
||||
toggleVariants({ variant, size, className }),
|
||||
pressed && "bg-accent text-accent-foreground",
|
||||
disabled && "opacity-50 pointer-events-none"
|
||||
)}
|
||||
data-state={pressed ? "on" : "off"}
|
||||
data-disabled={disabled ? "" : undefined}
|
||||
{...({ tabIndex } as { tabIndex?: number })}
|
||||
hoverClass={
|
||||
disabled
|
||||
? undefined
|
||||
: "border-ring ring-2 ring-ring ring-offset-2 ring-offset-background"
|
||||
}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
Toggle.displayName = "Toggle"
|
||||
|
||||
export { Toggle, toggleVariants }
|
||||
@@ -0,0 +1,455 @@
|
||||
import * as React from "react"
|
||||
import { View } from "@tarojs/components"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { isH5 } from "@/lib/platform"
|
||||
import { getRectById, getViewport } from "@/lib/measure"
|
||||
import { Portal } from "@/components/ui/portal"
|
||||
|
||||
type TooltipProviderProps = {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
type TooltipProps = {
|
||||
children: React.ReactNode
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
type TooltipSide = "top" | "bottom" | "left" | "right"
|
||||
type TooltipAlign = "start" | "center" | "end"
|
||||
|
||||
const TooltipContext = React.createContext<{
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
triggerId: string
|
||||
setHoverPart?: (part: "trigger" | "content", hovering: boolean) => void
|
||||
} | null>(null)
|
||||
|
||||
const TooltipProvider = ({ children }: TooltipProviderProps) => <>{children}</>
|
||||
|
||||
const Tooltip = ({
|
||||
children,
|
||||
open: openProp,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
}: TooltipProps) => {
|
||||
const baseIdRef = React.useRef(
|
||||
`tooltip-${Math.random().toString(36).slice(2, 10)}`
|
||||
)
|
||||
const [openState, setOpenState] = React.useState(defaultOpen)
|
||||
const open = openProp !== undefined ? openProp : openState
|
||||
const hoverRef = React.useRef({ trigger: false, content: false })
|
||||
const closeTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (openProp === undefined) {
|
||||
setOpenState(newOpen)
|
||||
}
|
||||
onOpenChange?.(newOpen)
|
||||
}
|
||||
|
||||
const setHoverPart = React.useCallback(
|
||||
(part: "trigger" | "content", hovering: boolean) => {
|
||||
if (!isH5()) return
|
||||
hoverRef.current[part] = hovering
|
||||
if (hovering) {
|
||||
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = null
|
||||
handleOpenChange(true)
|
||||
return
|
||||
}
|
||||
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
|
||||
closeTimerRef.current = setTimeout(() => {
|
||||
if (!hoverRef.current.trigger && !hoverRef.current.content) {
|
||||
handleOpenChange(false)
|
||||
}
|
||||
}, 80)
|
||||
},
|
||||
[handleOpenChange]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimerRef.current) clearTimeout(closeTimerRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<TooltipContext.Provider
|
||||
value={{
|
||||
open,
|
||||
onOpenChange: handleOpenChange,
|
||||
triggerId: baseIdRef.current,
|
||||
setHoverPart,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</TooltipContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const TooltipTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
onMouseEnter?: (e: React.MouseEvent) => void
|
||||
onMouseLeave?: (e: React.MouseEvent) => void
|
||||
}
|
||||
>(
|
||||
(
|
||||
{ className, children, onClick, onMouseEnter, onMouseLeave, ...props },
|
||||
ref
|
||||
) => {
|
||||
const context = React.useContext(TooltipContext)
|
||||
return (
|
||||
<View
|
||||
ref={ref}
|
||||
id={context?.triggerId}
|
||||
className={cn("inline-flex w-fit justify-self-start", className)}
|
||||
onClick={(e) => {
|
||||
onClick?.(e)
|
||||
e.stopPropagation()
|
||||
context?.onOpenChange(!context.open)
|
||||
}}
|
||||
{...(isH5()
|
||||
? ({
|
||||
onMouseEnter: (e: React.MouseEvent) => {
|
||||
onMouseEnter?.(e)
|
||||
context?.setHoverPart?.("trigger", true)
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent) => {
|
||||
onMouseLeave?.(e)
|
||||
context?.setHoverPart?.("trigger", false)
|
||||
},
|
||||
} as React.ComponentPropsWithoutRef<typeof View>)
|
||||
: {})}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
)
|
||||
TooltipTrigger.displayName = "TooltipTrigger"
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof View>,
|
||||
React.ComponentPropsWithoutRef<typeof View> & {
|
||||
align?: TooltipAlign
|
||||
side?: TooltipSide
|
||||
sideOffset?: number
|
||||
avoidCollisions?: boolean
|
||||
collisionPadding?: number
|
||||
showArrow?: boolean
|
||||
arrowSize?: number
|
||||
onMouseEnter?: (e: React.MouseEvent) => void
|
||||
onMouseLeave?: (e: React.MouseEvent) => void
|
||||
}
|
||||
>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
align = "center",
|
||||
side = "top",
|
||||
sideOffset = 4,
|
||||
avoidCollisions = true,
|
||||
collisionPadding = 8,
|
||||
showArrow = true,
|
||||
arrowSize = 8,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
...props
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const context = React.useContext(TooltipContext)
|
||||
const contentId = React.useRef(
|
||||
`tooltip-content-${Math.random().toString(36).slice(2, 10)}`
|
||||
)
|
||||
const [layout, setLayout] = React.useState<
|
||||
| {
|
||||
left: number
|
||||
top: number
|
||||
side: TooltipSide
|
||||
arrowLeft?: number
|
||||
arrowTop?: number
|
||||
}
|
||||
| null
|
||||
>(null)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!context?.open) {
|
||||
setLayout(null)
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
|
||||
let rafId: number | null = null
|
||||
|
||||
const compute = async () => {
|
||||
const [triggerRect, contentRect] = await Promise.all([
|
||||
getRectById(context.triggerId),
|
||||
getRectById(contentId.current),
|
||||
])
|
||||
|
||||
if (cancelled) return
|
||||
if (!triggerRect?.width || !contentRect?.width) return
|
||||
|
||||
const vw = getViewport().width
|
||||
const vh = getViewport().height
|
||||
const padding = Math.max(0, collisionPadding)
|
||||
|
||||
const computeForSide = (s: TooltipSide) => {
|
||||
const isLR = s === "left" || s === "right"
|
||||
const crossStart = isLR ? triggerRect.top : triggerRect.left
|
||||
const crossSize = isLR ? triggerRect.height : triggerRect.width
|
||||
const contentCrossSize = isLR ? contentRect.height : contentRect.width
|
||||
const mainOffset = sideOffset + (showArrow ? arrowSize / 2 : 0)
|
||||
|
||||
const cross = (() => {
|
||||
if (align === "start") return crossStart
|
||||
if (align === "end") return crossStart + crossSize - contentCrossSize
|
||||
return crossStart + crossSize / 2 - contentCrossSize / 2
|
||||
})()
|
||||
|
||||
if (s === "bottom" || s === "top") {
|
||||
const left = cross
|
||||
const top =
|
||||
s === "bottom"
|
||||
? triggerRect.top + triggerRect.height + mainOffset
|
||||
: triggerRect.top - contentRect.height - mainOffset
|
||||
return { left, top }
|
||||
}
|
||||
|
||||
const top = cross
|
||||
const left =
|
||||
s === "right"
|
||||
? triggerRect.left + triggerRect.width + mainOffset
|
||||
: triggerRect.left - contentRect.width - mainOffset
|
||||
return { left, top }
|
||||
}
|
||||
|
||||
const oppositeSide = (s: TooltipSide): TooltipSide => {
|
||||
if (s === "top") return "bottom"
|
||||
if (s === "bottom") return "top"
|
||||
if (s === "left") return "right"
|
||||
return "left"
|
||||
}
|
||||
|
||||
const wouldOverflowMainAxis = (s: TooltipSide, left: number, top: number) => {
|
||||
if (s === "top") return top < padding
|
||||
if (s === "bottom") return top + contentRect.height > vh - padding
|
||||
if (s === "left") return left < padding
|
||||
return left + contentRect.width > vw - padding
|
||||
}
|
||||
|
||||
let resolvedSide: TooltipSide = side
|
||||
let { left, top } = computeForSide(resolvedSide)
|
||||
|
||||
if (avoidCollisions && wouldOverflowMainAxis(resolvedSide, left, top)) {
|
||||
const flipped = oppositeSide(resolvedSide)
|
||||
const flippedPos = computeForSide(flipped)
|
||||
if (!wouldOverflowMainAxis(flipped, flippedPos.left, flippedPos.top)) {
|
||||
resolvedSide = flipped
|
||||
left = flippedPos.left
|
||||
top = flippedPos.top
|
||||
}
|
||||
}
|
||||
|
||||
const maxLeft = Math.max(padding, vw - contentRect.width - padding)
|
||||
const maxTop = Math.max(padding, vh - contentRect.height - padding)
|
||||
left = Math.min(Math.max(left, padding), maxLeft)
|
||||
top = Math.min(Math.max(top, padding), maxTop)
|
||||
|
||||
const triggerCenterX = triggerRect.left + triggerRect.width / 2
|
||||
const triggerCenterY = triggerRect.top + triggerRect.height / 2
|
||||
const arrowGap = 6
|
||||
|
||||
if (resolvedSide === "top" || resolvedSide === "bottom") {
|
||||
const rawArrowLeft = triggerCenterX - left - arrowSize / 2
|
||||
const minArrowLeft = arrowGap
|
||||
const maxArrowLeft = contentRect.width - arrowSize - arrowGap
|
||||
const arrowLeft = Math.min(Math.max(rawArrowLeft, minArrowLeft), maxArrowLeft)
|
||||
setLayout({ left, top, side: resolvedSide, arrowLeft })
|
||||
return
|
||||
}
|
||||
|
||||
const rawArrowTop = triggerCenterY - top - arrowSize / 2
|
||||
const minArrowTop = arrowGap
|
||||
const maxArrowTop = contentRect.height - arrowSize - arrowGap
|
||||
const arrowTop = Math.min(Math.max(rawArrowTop, minArrowTop), maxArrowTop)
|
||||
setLayout({ left, top, side: resolvedSide, arrowTop })
|
||||
}
|
||||
|
||||
const schedule = () => {
|
||||
if (rafId != null) {
|
||||
if (typeof cancelAnimationFrame !== "undefined") {
|
||||
cancelAnimationFrame(rafId)
|
||||
} else {
|
||||
clearTimeout(rafId)
|
||||
}
|
||||
rafId = null
|
||||
}
|
||||
if (typeof requestAnimationFrame !== "undefined") {
|
||||
rafId = requestAnimationFrame(() => compute())
|
||||
} else {
|
||||
rafId = setTimeout(() => compute(), 0) as unknown as number
|
||||
}
|
||||
}
|
||||
|
||||
schedule()
|
||||
|
||||
const onWindowChange = () => schedule()
|
||||
|
||||
if (isH5() && typeof window !== "undefined") {
|
||||
window.addEventListener("resize", onWindowChange)
|
||||
window.addEventListener("scroll", onWindowChange, true)
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (rafId != null) {
|
||||
if (typeof cancelAnimationFrame !== "undefined") {
|
||||
cancelAnimationFrame(rafId)
|
||||
} else {
|
||||
clearTimeout(rafId)
|
||||
}
|
||||
}
|
||||
if (isH5() && typeof window !== "undefined") {
|
||||
window.removeEventListener("resize", onWindowChange)
|
||||
window.removeEventListener("scroll", onWindowChange, true)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
align,
|
||||
avoidCollisions,
|
||||
collisionPadding,
|
||||
context?.open,
|
||||
context?.triggerId,
|
||||
side,
|
||||
sideOffset,
|
||||
arrowSize,
|
||||
])
|
||||
|
||||
if (!context?.open) return null
|
||||
|
||||
const baseClassName =
|
||||
"fixed z-50 overflow-visible rounded-md bg-black px-3 py-2 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95"
|
||||
|
||||
const px = (n: number) => `${n}px`
|
||||
|
||||
const contentStyle = layout
|
||||
? (isH5()
|
||||
? ({ left: px(layout.left), top: px(layout.top) } as React.CSSProperties)
|
||||
: ({ left: layout.left, top: layout.top } as React.CSSProperties))
|
||||
: ({
|
||||
left: 0,
|
||||
top: 0,
|
||||
opacity: 0,
|
||||
pointerEvents: "none",
|
||||
} as React.CSSProperties)
|
||||
|
||||
const arrow =
|
||||
showArrow && layout ? (
|
||||
<View
|
||||
className="absolute rotate-45 bg-black"
|
||||
style={
|
||||
layout.side === "top"
|
||||
? (isH5()
|
||||
? ({
|
||||
width: px(arrowSize),
|
||||
height: px(arrowSize),
|
||||
bottom: px(-arrowSize / 2),
|
||||
left: px(layout.arrowLeft ?? 0),
|
||||
} as React.CSSProperties)
|
||||
: ({
|
||||
width: arrowSize,
|
||||
height: arrowSize,
|
||||
bottom: -arrowSize / 2,
|
||||
left: layout.arrowLeft ?? 0,
|
||||
} as React.CSSProperties))
|
||||
: layout.side === "bottom"
|
||||
? (isH5()
|
||||
? ({
|
||||
width: px(arrowSize),
|
||||
height: px(arrowSize),
|
||||
top: px(-arrowSize / 2),
|
||||
left: px(layout.arrowLeft ?? 0),
|
||||
} as React.CSSProperties)
|
||||
: ({
|
||||
width: arrowSize,
|
||||
height: arrowSize,
|
||||
top: -arrowSize / 2,
|
||||
left: layout.arrowLeft ?? 0,
|
||||
} as React.CSSProperties))
|
||||
: layout.side === "left"
|
||||
? (isH5()
|
||||
? ({
|
||||
width: px(arrowSize),
|
||||
height: px(arrowSize),
|
||||
right: px(-arrowSize / 2),
|
||||
top: px(layout.arrowTop ?? 0),
|
||||
} as React.CSSProperties)
|
||||
: ({
|
||||
width: arrowSize,
|
||||
height: arrowSize,
|
||||
right: -arrowSize / 2,
|
||||
top: layout.arrowTop ?? 0,
|
||||
} as React.CSSProperties))
|
||||
: (isH5()
|
||||
? ({
|
||||
width: px(arrowSize),
|
||||
height: px(arrowSize),
|
||||
left: px(-arrowSize / 2),
|
||||
top: px(layout.arrowTop ?? 0),
|
||||
} as React.CSSProperties)
|
||||
: ({
|
||||
width: arrowSize,
|
||||
height: arrowSize,
|
||||
left: -arrowSize / 2,
|
||||
top: layout.arrowTop ?? 0,
|
||||
} as React.CSSProperties))
|
||||
}
|
||||
/>
|
||||
) : null
|
||||
|
||||
const overlay = !isH5() ? (
|
||||
<View
|
||||
className="fixed inset-0 z-50 bg-transparent"
|
||||
onClick={() => context.onOpenChange(false)}
|
||||
/>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
{overlay}
|
||||
<View
|
||||
ref={ref}
|
||||
id={contentId.current}
|
||||
className={cn(baseClassName, className)}
|
||||
style={contentStyle}
|
||||
{...(isH5()
|
||||
? ({
|
||||
onMouseEnter: (e: React.MouseEvent) => {
|
||||
onMouseEnter?.(e)
|
||||
context?.setHoverPart?.("content", true)
|
||||
},
|
||||
onMouseLeave: (e: React.MouseEvent) => {
|
||||
onMouseLeave?.(e)
|
||||
context?.setHoverPart?.("content", false)
|
||||
},
|
||||
} as React.ComponentPropsWithoutRef<typeof View>)
|
||||
: {})}
|
||||
{...props}
|
||||
>
|
||||
{arrow}
|
||||
{children}
|
||||
</View>
|
||||
</Portal>
|
||||
)
|
||||
})
|
||||
TooltipContent.displayName = "TooltipContent"
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -0,0 +1,39 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta content="text/html; charset=utf-8" http-equiv="Content-Type">
|
||||
<meta content="width=device-width,initial-scale=1,user-scalable=no" name="viewport">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-touch-fullscreen" content="yes">
|
||||
<meta name="format-detection" content="telephone=no,address=no">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="white">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<title>coze-mini-program</title>
|
||||
<script><%= htmlWebpackPlugin.options.script %></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="app">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="lab(2.86037 0.455312 0.568903)"
|
||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
animation: __app-loading-spin 1s linear infinite;
|
||||
">
|
||||
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
||||
</svg>
|
||||
<style>
|
||||
@keyframes __app-loading-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -0,0 +1,37 @@
|
||||
import * as React from 'react'
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
// Global state to track keyboard height across the app
|
||||
let globalKeyboardHeight = 0
|
||||
const listeners = new Set<(height: number) => void>()
|
||||
|
||||
const isNotWeb = Taro.getEnv() !== Taro.ENV_TYPE.WEB
|
||||
|
||||
if (isNotWeb && typeof Taro.onKeyboardHeightChange === 'function') {
|
||||
Taro.onKeyboardHeightChange(res => {
|
||||
globalKeyboardHeight = res.height
|
||||
listeners.forEach(listener => listener(globalKeyboardHeight))
|
||||
})
|
||||
}
|
||||
|
||||
export function useKeyboardOffset() {
|
||||
const [offset, setOffset] = React.useState(globalKeyboardHeight)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isNotWeb) return
|
||||
|
||||
const handler = (height: number) => {
|
||||
setOffset(height)
|
||||
}
|
||||
|
||||
listeners.add(handler)
|
||||
// Update immediately with current global value in case it changed
|
||||
setOffset(globalKeyboardHeight)
|
||||
|
||||
return () => {
|
||||
listeners.delete(handler)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return offset
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
import { canUseDOM, isH5 } from './platform'
|
||||
|
||||
export type Rect = { left: number; top: number; width: number; height: number }
|
||||
|
||||
const toNumber = (v: unknown) => {
|
||||
if (typeof v === 'number') return v
|
||||
if (typeof v === 'string') {
|
||||
const n = parseFloat(v)
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const normalizeRect = (r: Record<string, unknown> | DOMRect | null | undefined): Rect | null => {
|
||||
if (!r) return null
|
||||
if ('left' in r && 'top' in r && 'width' in r && 'height' in r) {
|
||||
return {
|
||||
left: toNumber(r.left),
|
||||
top: toNumber(r.top),
|
||||
width: toNumber(r.width),
|
||||
height: toNumber(r.height),
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const getViewport = () => {
|
||||
if (isH5() && canUseDOM()) {
|
||||
return { width: window.innerWidth, height: window.innerHeight }
|
||||
}
|
||||
try {
|
||||
const info = Taro.getSystemInfoSync()
|
||||
return {
|
||||
width: toNumber(info.windowWidth),
|
||||
height: toNumber(info.windowHeight),
|
||||
}
|
||||
} catch {
|
||||
return { width: 375, height: 667 }
|
||||
}
|
||||
}
|
||||
|
||||
const getRectH5 = (id: string): Rect | null => {
|
||||
if (!isH5() || !canUseDOM()) return null
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return null
|
||||
const r = el.getBoundingClientRect()
|
||||
return normalizeRect(r)
|
||||
}
|
||||
|
||||
export const getRectById = (id: string): Promise<Rect | null> => {
|
||||
const h5Rect = getRectH5(id)
|
||||
if (h5Rect) return Promise.resolve(h5Rect)
|
||||
return new Promise(resolve => {
|
||||
const query = Taro.createSelectorQuery()
|
||||
query
|
||||
.select(`#${id}`)
|
||||
.boundingClientRect(res => {
|
||||
const rect = Array.isArray(res) ? res[0] : res
|
||||
if (rect && 'left' in rect) {
|
||||
resolve(normalizeRect(rect))
|
||||
} else {
|
||||
resolve(null)
|
||||
}
|
||||
})
|
||||
.exec()
|
||||
})
|
||||
}
|
||||
|
||||
export const computePosition = (params: {
|
||||
triggerRect: Rect
|
||||
contentRect: Rect
|
||||
align: 'start' | 'center' | 'end'
|
||||
side: 'top' | 'bottom' | 'left' | 'right'
|
||||
sideOffset: number
|
||||
}) => {
|
||||
const { triggerRect, contentRect, align, side, sideOffset } = params
|
||||
const { width: vw, height: vh } = getViewport()
|
||||
const margin = 8
|
||||
|
||||
const crossStart =
|
||||
side === 'left' || side === 'right' ? triggerRect.top : triggerRect.left
|
||||
const crossSize =
|
||||
side === 'left' || side === 'right'
|
||||
? triggerRect.height
|
||||
: triggerRect.width
|
||||
const contentCrossSize =
|
||||
side === 'left' || side === 'right'
|
||||
? contentRect.height
|
||||
: contentRect.width
|
||||
|
||||
const cross = (() => {
|
||||
if (align === 'start') return crossStart
|
||||
if (align === 'end') return crossStart + crossSize - contentCrossSize
|
||||
return crossStart + crossSize / 2 - contentCrossSize / 2
|
||||
})()
|
||||
|
||||
let left = 0
|
||||
let top = 0
|
||||
|
||||
if (side === 'bottom' || side === 'top') {
|
||||
left = cross
|
||||
top =
|
||||
side === 'bottom'
|
||||
? triggerRect.top + triggerRect.height + sideOffset
|
||||
: triggerRect.top - contentRect.height - sideOffset
|
||||
} else {
|
||||
top = cross
|
||||
left =
|
||||
side === 'right'
|
||||
? triggerRect.left + triggerRect.width + sideOffset
|
||||
: triggerRect.left - contentRect.width - sideOffset
|
||||
}
|
||||
|
||||
const maxLeft = Math.max(margin, vw - contentRect.width - margin)
|
||||
const maxTop = Math.max(margin, vh - contentRect.height - margin)
|
||||
|
||||
left = Math.min(Math.max(left, margin), maxLeft)
|
||||
top = Math.min(Math.max(top, margin), maxTop)
|
||||
|
||||
return { left, top }
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import Taro from "@tarojs/taro"
|
||||
|
||||
export const canUseDOM = () => typeof window !== "undefined" && typeof document !== "undefined"
|
||||
|
||||
export const isH5 = () => {
|
||||
try {
|
||||
return Taro.getEnv() === Taro.ENV_TYPE.WEB
|
||||
} catch {
|
||||
return canUseDOM()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import Taro from '@tarojs/taro'
|
||||
|
||||
/**
|
||||
* 网络请求模块
|
||||
* 封装 Taro.request、Taro.uploadFile、Taro.downloadFile,自动添加项目域名前缀
|
||||
* 如果请求的 url 以 http:// 或 https:// 开头,则不会添加域名前缀
|
||||
*
|
||||
* IMPORTANT: 项目已经全局注入 PROJECT_DOMAIN
|
||||
* IMPORTANT: 除非你需要添加全局参数,如给所有请求加上 header,否则不能修改此文件
|
||||
*/
|
||||
export namespace Network {
|
||||
const createUrl = (url: string): string => {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url
|
||||
}
|
||||
return `${PROJECT_DOMAIN}${url}`
|
||||
}
|
||||
|
||||
export const request: typeof Taro.request = option => {
|
||||
return Taro.request({
|
||||
...option,
|
||||
url: createUrl(option.url),
|
||||
})
|
||||
}
|
||||
|
||||
export const uploadFile: typeof Taro.uploadFile = option => {
|
||||
return Taro.uploadFile({
|
||||
...option,
|
||||
url: createUrl(option.url),
|
||||
})
|
||||
}
|
||||
|
||||
export const downloadFile: typeof Taro.downloadFile = option => {
|
||||
return Taro.downloadFile({
|
||||
...option,
|
||||
url: createUrl(option.url),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export default definePageConfig({
|
||||
navigationBarTitleText: '首页'
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
/* 优先使用 tailwindcss,如无必要请不要使用css */
|
||||
@@ -0,0 +1,99 @@
|
||||
import { View, Text } from '@tarojs/components'
|
||||
import { useLoad } from '@tarojs/taro'
|
||||
import { Network } from '@/network'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import './index.css'
|
||||
|
||||
/**
|
||||
* 首页 - 欢迎页面
|
||||
*/
|
||||
const IndexPage = () => {
|
||||
useLoad(async () => {
|
||||
try {
|
||||
const res = await Network.request({ url: '/api/hello' })
|
||||
console.log('API Response:', res.data)
|
||||
} catch (error) {
|
||||
console.error('API Error:', error)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<View className="min-h-screen bg-background p-4">
|
||||
{/* 头部区域 */}
|
||||
<View className="mb-6">
|
||||
<Text className="block text-2xl font-bold text-foreground mb-2">
|
||||
欢迎使用
|
||||
</Text>
|
||||
<Text className="block text-sm text-muted-foreground">
|
||||
基于 Taro 框架开发的小程序
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{/* 功能卡片 */}
|
||||
<View className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<View className="flex items-center justify-between mb-2">
|
||||
<Text className="block text-lg font-semibold text-foreground">
|
||||
快速开始
|
||||
</Text>
|
||||
<Badge variant="secondary">新手指南</Badge>
|
||||
</View>
|
||||
<Text className="block text-sm text-muted-foreground mb-4">
|
||||
通过简洁的操作流程,快速上手使用小程序各项功能
|
||||
</Text>
|
||||
<Button className="w-full">
|
||||
<Text className="text-primary-foreground">立即体验</Text>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<View className="flex items-center justify-between mb-2">
|
||||
<Text className="block text-lg font-semibold text-foreground">
|
||||
核心功能
|
||||
</Text>
|
||||
<Badge variant="outline">推荐</Badge>
|
||||
</View>
|
||||
<View className="flex flex-col gap-2">
|
||||
<View className="flex items-center gap-2">
|
||||
<View className="w-2 h-2 rounded-full bg-primary" />
|
||||
<Text className="block text-sm text-foreground">
|
||||
功能一:高效便捷的操作体验
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex items-center gap-2">
|
||||
<View className="w-2 h-2 rounded-full bg-primary" />
|
||||
<Text className="block text-sm text-foreground">
|
||||
功能二:安全可靠的数据保障
|
||||
</Text>
|
||||
</View>
|
||||
<View className="flex items-center gap-2">
|
||||
<View className="w-2 h-2 rounded-full bg-primary" />
|
||||
<Text className="block text-sm text-foreground">
|
||||
功能三:持续迭代的版本更新
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<Text className="block text-lg font-semibold text-foreground mb-2">
|
||||
关于我们
|
||||
</Text>
|
||||
<Text className="block text-sm text-muted-foreground">
|
||||
致力于为用户提供优质的产品体验,不断优化和创新,期待您的反馈与建议
|
||||
</Text>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</View>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
export default IndexPage
|
||||
@@ -0,0 +1,23 @@
|
||||
import Taro from '@tarojs/taro';
|
||||
|
||||
/**
|
||||
* 小程序调试工具
|
||||
* 在开发版/体验版自动开启调试模式
|
||||
* 支持微信小程序
|
||||
*/
|
||||
export function devDebug() {
|
||||
const env = Taro.getEnv();
|
||||
if (env === Taro.ENV_TYPE.WEAPP) {
|
||||
try {
|
||||
const accountInfo = Taro.getAccountInfoSync();
|
||||
const envVersion = accountInfo.miniProgram.envVersion;
|
||||
console.log('[Debug] envVersion:', envVersion);
|
||||
|
||||
if (envVersion !== 'release') {
|
||||
Taro.setEnableDebug({ enableDebug: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Debug] 开启调试模式失败:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const IS_H5_ENV = TARO_ENV === 'h5';
|
||||
@@ -0,0 +1,16 @@
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { IS_H5_ENV } from './env';
|
||||
import { H5NavBar } from './h5-navbar';
|
||||
|
||||
export const H5Container = ({ children }: PropsWithChildren) => {
|
||||
if (!IS_H5_ENV) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<H5NavBar />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,391 @@
|
||||
import { View } from '@tarojs/components';
|
||||
import { Component, PropsWithChildren, useSyncExternalStore } from 'react';
|
||||
import { CircleAlert, Copy, RefreshCw, X } from 'lucide-react-taro';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Portal } from '@/components/ui/portal';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { toast } from '@/components/ui/toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { IS_H5_ENV } from './env';
|
||||
|
||||
type ErrorState = {
|
||||
error: Error | null;
|
||||
};
|
||||
|
||||
type ErrorBoundaryProps = PropsWithChildren;
|
||||
|
||||
type ErrorReportOptions = {
|
||||
componentStack?: string;
|
||||
source?: string;
|
||||
};
|
||||
|
||||
type OverlayStore = {
|
||||
error: Error | null;
|
||||
report: string;
|
||||
source: string;
|
||||
visible: boolean;
|
||||
open: boolean;
|
||||
timestamp: string;
|
||||
};
|
||||
|
||||
const EMPTY_STORE: OverlayStore = {
|
||||
error: null,
|
||||
report: '',
|
||||
source: '',
|
||||
visible: false,
|
||||
open: false,
|
||||
timestamp: '',
|
||||
};
|
||||
|
||||
const ERROR_ACCENT_COLOR = 'hsl(360, 100%, 45%)';
|
||||
|
||||
let handlersInstalled = false;
|
||||
let overlayStore: OverlayStore = EMPTY_STORE;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const emitChange = () => {
|
||||
listeners.forEach(listener => listener());
|
||||
};
|
||||
|
||||
const subscribe = (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
};
|
||||
|
||||
const getSnapshot = () => overlayStore;
|
||||
|
||||
const setOverlayStore = (nextStore: OverlayStore) => {
|
||||
overlayStore = nextStore;
|
||||
emitChange();
|
||||
};
|
||||
|
||||
const copyText = async (text: string) => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
try {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[H5ErrorBoundary] Clipboard API copy failed:', error);
|
||||
}
|
||||
|
||||
try {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.setAttribute('readonly', 'true');
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const copied = document.execCommand('copy');
|
||||
document.body.removeChild(textarea);
|
||||
return copied;
|
||||
} catch (error) {
|
||||
console.warn('[H5ErrorBoundary] Fallback copy failed:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeError = (value: unknown) => {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
return new Error(value);
|
||||
}
|
||||
|
||||
try {
|
||||
return new Error(JSON.stringify(value));
|
||||
} catch {
|
||||
return new Error(String(value));
|
||||
}
|
||||
};
|
||||
|
||||
const buildErrorReport = (error: Error, options: ErrorReportOptions = {}) => {
|
||||
const lines = [
|
||||
'[H5 Runtime Error]',
|
||||
`Time: ${new Date().toISOString()}`,
|
||||
options.source ? `Source: ${options.source}` : '',
|
||||
`Name: ${error.name}`,
|
||||
`Message: ${error.message}`,
|
||||
error.stack ? `Stack:\n${error.stack}` : '',
|
||||
options.componentStack ? `Component Stack:\n${options.componentStack}` : '',
|
||||
typeof navigator !== 'undefined'
|
||||
? `User Agent: ${navigator.userAgent}`
|
||||
: '',
|
||||
].filter(Boolean);
|
||||
|
||||
return lines.join('\n\n');
|
||||
};
|
||||
|
||||
const setPanelOpen = (open: boolean) => {
|
||||
if (!overlayStore.visible) return;
|
||||
|
||||
setOverlayStore({
|
||||
...overlayStore,
|
||||
open,
|
||||
});
|
||||
};
|
||||
|
||||
export const showH5ErrorOverlay = (
|
||||
input: unknown,
|
||||
options: ErrorReportOptions = {},
|
||||
) => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const error = normalizeError(input);
|
||||
const report = buildErrorReport(error, options);
|
||||
const timestamp = new Date().toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
});
|
||||
|
||||
setOverlayStore({
|
||||
error,
|
||||
report,
|
||||
source: options.source || 'runtime',
|
||||
timestamp,
|
||||
visible: true,
|
||||
open: false,
|
||||
});
|
||||
|
||||
console.error('[H5ErrorOverlay] Showing error overlay:', error, options);
|
||||
};
|
||||
|
||||
const handleWindowError = (event: ErrorEvent) => {
|
||||
const error =
|
||||
event.error || new Error(event.message || 'Unknown H5 runtime error');
|
||||
showH5ErrorOverlay(error, { source: 'window.error' });
|
||||
};
|
||||
|
||||
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
|
||||
showH5ErrorOverlay(event.reason, { source: 'window.unhandledrejection' });
|
||||
};
|
||||
|
||||
export const initializeH5ErrorHandling = () => {
|
||||
if (!IS_H5_ENV || typeof window === 'undefined' || handlersInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
handlersInstalled = true;
|
||||
window.addEventListener('error', handleWindowError);
|
||||
window.addEventListener('unhandledrejection', handleUnhandledRejection);
|
||||
};
|
||||
|
||||
const H5ErrorOverlayHost = () => {
|
||||
const store = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
if (!IS_H5_ENV || !store.visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const errorName = store.error?.name || 'Error';
|
||||
|
||||
return (
|
||||
<Portal>
|
||||
<View className="pointer-events-none fixed inset-0 z-[2147483646]">
|
||||
<View className="pointer-events-auto fixed bottom-5 left-5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className={cn(
|
||||
'h-11 w-11 rounded-full shadow-md transition-transform',
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: 'hsl(359, 100%, 97%)',
|
||||
borderColor: 'hsl(359, 100%, 94%)',
|
||||
color: ERROR_ACCENT_COLOR,
|
||||
}}
|
||||
onClick={() => setPanelOpen(!store.open)}
|
||||
>
|
||||
<CircleAlert size={22} color={ERROR_ACCENT_COLOR} />
|
||||
</Button>
|
||||
</View>
|
||||
|
||||
{store.open && (
|
||||
<View className="pointer-events-none fixed inset-0 bg-white bg-opacity-15 supports-[backdrop-filter]:backdrop-blur-md">
|
||||
<View className="absolute inset-0 flex items-center justify-center px-4 py-4">
|
||||
<View
|
||||
className="w-full max-w-md"
|
||||
style={{
|
||||
width:
|
||||
'min(calc(100vw - 32px), var(--h5-phone-width, 390px))',
|
||||
height: 'min(calc(100vh - 32px), 900px)',
|
||||
}}
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
'pointer-events-auto h-full rounded-2xl border border-border bg-background text-foreground shadow-2xl',
|
||||
)}
|
||||
>
|
||||
<View className="relative flex h-full flex-col">
|
||||
<CardHeader className="gap-2 p-4 pb-2">
|
||||
<View className="flex items-start justify-between gap-3">
|
||||
<View className="flex flex-wrap items-center gap-2">
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="border-none bg-red-500 px-3 py-1 text-xs font-medium text-white"
|
||||
>
|
||||
Runtime Error
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="px-3 py-1 text-xs"
|
||||
>
|
||||
{store.source}
|
||||
</Badge>
|
||||
</View>
|
||||
|
||||
<View className="flex shrink-0 items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => window.location.reload()}
|
||||
>
|
||||
<RefreshCw size={15} color="inherit" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full"
|
||||
onClick={() => setPanelOpen(false)}
|
||||
>
|
||||
<X size={17} color="inherit" />
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="flex items-center justify-between gap-3">
|
||||
<CardTitle className="text-left text-lg">
|
||||
{errorName}
|
||||
</CardTitle>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="shrink-0 rounded-lg"
|
||||
onClick={async () => {
|
||||
const copied = await copyText(store.report);
|
||||
if (copied) {
|
||||
toast.success('已复制错误信息', {
|
||||
description: '可发送给 Agent 进行自动修复',
|
||||
position: 'top-center',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
toast.warning('复制失败', {
|
||||
description: '请直接选中文本后手动复制。',
|
||||
position: 'top-center',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Copy size={15} color="inherit" />
|
||||
<View>复制错误</View>
|
||||
</Button>
|
||||
</View>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="min-h-0 flex-1 overflow-hidden px-4 pb-4 pt-2">
|
||||
<View className="flex h-full min-h-0 flex-col gap-2">
|
||||
<View className="flex flex-wrap items-center gap-x-4 gap-y-2 rounded-lg border border-border px-3 py-2 text-sm">
|
||||
<View className="flex items-center gap-2">
|
||||
<View className="text-muted-foreground">Error</View>
|
||||
<View className="font-medium text-foreground">
|
||||
{store.error?.name || 'Error'}
|
||||
</View>
|
||||
</View>
|
||||
<View className="h-4 w-px bg-border" />
|
||||
<View className="flex items-center gap-2">
|
||||
<View className="text-muted-foreground">
|
||||
Source
|
||||
</View>
|
||||
<View className="font-medium text-foreground">
|
||||
{store.source}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className="min-h-0 flex flex-1 flex-col overflow-hidden rounded-xl border border-border bg-black text-white">
|
||||
<View className="flex items-center justify-between border-b border-white border-opacity-10 px-3 py-3">
|
||||
<View className="text-xs font-medium uppercase tracking-wide text-zinc-400">
|
||||
Full Report
|
||||
</View>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-zinc-700 bg-transparent px-2 py-1 text-xs text-zinc-400"
|
||||
>
|
||||
{store.timestamp}
|
||||
</Badge>
|
||||
</View>
|
||||
|
||||
<ScrollArea
|
||||
className="min-h-0 flex-1 w-full"
|
||||
orientation="both"
|
||||
>
|
||||
<View className="inline-block min-w-full whitespace-pre px-3 py-3 pb-8 font-mono text-xs leading-6 text-zinc-200">
|
||||
{store.report}
|
||||
</View>
|
||||
</ScrollArea>
|
||||
</View>
|
||||
</View>
|
||||
</CardContent>
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Portal>
|
||||
);
|
||||
};
|
||||
|
||||
class H5ErrorBoundaryInner extends Component<ErrorBoundaryProps, ErrorState> {
|
||||
static getDerivedStateFromError(error: Error): Partial<ErrorState> {
|
||||
return { error };
|
||||
}
|
||||
|
||||
state: ErrorState = {
|
||||
error: null,
|
||||
};
|
||||
|
||||
componentDidUpdate(prevProps: ErrorBoundaryProps) {
|
||||
if (this.state.error && prevProps.children !== this.props.children) {
|
||||
this.setState({ error: null });
|
||||
}
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: { componentStack: string }) {
|
||||
showH5ErrorOverlay(error, {
|
||||
source: 'React Error Boundary',
|
||||
componentStack: info.componentStack || '',
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<H5ErrorOverlayHost />
|
||||
{this.state.error ? null : this.props.children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const H5ErrorBoundary = ({ children }: PropsWithChildren) => {
|
||||
if (!IS_H5_ENV) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return <H5ErrorBoundaryInner>{children}</H5ErrorBoundaryInner>;
|
||||
};
|
||||
@@ -0,0 +1,239 @@
|
||||
import { View, Text } from '@tarojs/components';
|
||||
import Taro, { useDidShow, usePageScroll } from '@tarojs/taro';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { ChevronLeft, House } from 'lucide-react-taro';
|
||||
import { IS_H5_ENV } from './env';
|
||||
|
||||
interface NavConfig {
|
||||
navigationBarTitleText?: string;
|
||||
navigationBarBackgroundColor?: string;
|
||||
navigationBarTextStyle?: 'black' | 'white';
|
||||
navigationStyle?: 'default' | 'custom';
|
||||
transparentTitle?: 'none' | 'always' | 'auto';
|
||||
}
|
||||
|
||||
enum LeftIcon {
|
||||
Back = 'back',
|
||||
Home = 'home',
|
||||
None = 'none',
|
||||
}
|
||||
|
||||
interface NavState {
|
||||
visible: boolean;
|
||||
title: string;
|
||||
bgColor: string;
|
||||
textStyle: 'black' | 'white';
|
||||
navStyle: 'default' | 'custom';
|
||||
transparent: 'none' | 'always' | 'auto';
|
||||
leftIcon: LeftIcon;
|
||||
}
|
||||
|
||||
const DEFAULT_NAV_STATE: NavState = {
|
||||
visible: false,
|
||||
title: '',
|
||||
bgColor: '#ffffff',
|
||||
textStyle: 'black',
|
||||
navStyle: 'default',
|
||||
transparent: 'none',
|
||||
leftIcon: LeftIcon.None,
|
||||
};
|
||||
|
||||
const getGlobalWindowConfig = (): Partial<NavConfig> => {
|
||||
const app = Taro.getApp();
|
||||
return app?.config?.window || {};
|
||||
};
|
||||
|
||||
const getTabBarPages = (): Set<string> => {
|
||||
const tabBar = Taro.getApp()?.config?.tabBar;
|
||||
return new Set(
|
||||
tabBar?.list?.map((item: { pagePath: string }) => item.pagePath) || [],
|
||||
);
|
||||
};
|
||||
|
||||
const getHomePage = (): string => {
|
||||
const app = Taro.getApp();
|
||||
return app?.config?.pages?.[0] || 'pages/index/index';
|
||||
};
|
||||
|
||||
const cleanPath = (path: string): string => path.replace(/^\//, '');
|
||||
|
||||
const computeLeftIcon = (
|
||||
route: string,
|
||||
tabBarPages: Set<string>,
|
||||
historyLength: number,
|
||||
homePage: string,
|
||||
): LeftIcon => {
|
||||
if (!route) return LeftIcon.None;
|
||||
|
||||
const cleanRoute = cleanPath(route);
|
||||
const cleanHomePage = cleanPath(homePage);
|
||||
const isHomePage = cleanRoute === cleanHomePage;
|
||||
const isTabBarPage =
|
||||
tabBarPages.has(cleanRoute) || tabBarPages.has(`/${cleanRoute}`);
|
||||
const hasHistory = historyLength > 1;
|
||||
|
||||
if (isTabBarPage || isHomePage) return LeftIcon.None;
|
||||
if (hasHistory) return LeftIcon.Back;
|
||||
return LeftIcon.Home;
|
||||
};
|
||||
|
||||
export const H5NavBar = () => {
|
||||
const [navState, setNavState] = useState<NavState>(DEFAULT_NAV_STATE);
|
||||
const [scrollOpacity, setScrollOpacity] = useState(0);
|
||||
|
||||
const updateNavState = useCallback(() => {
|
||||
const pages = Taro.getCurrentPages();
|
||||
if (pages.length === 0) {
|
||||
setNavState(prev => ({ ...prev, visible: false }));
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPage = pages[pages.length - 1];
|
||||
const route = currentPage?.route || '';
|
||||
if (!route) return;
|
||||
|
||||
const pageConfig: NavConfig = (currentPage as any)?.config || {};
|
||||
const globalConfig = getGlobalWindowConfig();
|
||||
const tabBarPages = getTabBarPages();
|
||||
const homePage = getHomePage();
|
||||
|
||||
const cleanRoute = cleanPath(route);
|
||||
const cleanHomePage = cleanPath(homePage);
|
||||
|
||||
const isHomePage = cleanRoute === cleanHomePage;
|
||||
const isTabBarPage =
|
||||
tabBarPages.has(cleanRoute) || tabBarPages.has(`/${cleanRoute}`);
|
||||
|
||||
const shouldHideNav =
|
||||
tabBarPages.size <= 1 &&
|
||||
pages.length <= 1 &&
|
||||
(isHomePage || isTabBarPage);
|
||||
|
||||
setNavState({
|
||||
visible: !shouldHideNav,
|
||||
title:
|
||||
document.title ||
|
||||
pageConfig.navigationBarTitleText ||
|
||||
globalConfig.navigationBarTitleText ||
|
||||
'',
|
||||
bgColor:
|
||||
pageConfig.navigationBarBackgroundColor ||
|
||||
globalConfig.navigationBarBackgroundColor ||
|
||||
'#ffffff',
|
||||
textStyle:
|
||||
pageConfig.navigationBarTextStyle ||
|
||||
globalConfig.navigationBarTextStyle ||
|
||||
'black',
|
||||
navStyle:
|
||||
pageConfig.navigationStyle || globalConfig.navigationStyle || 'default',
|
||||
transparent:
|
||||
pageConfig.transparentTitle || globalConfig.transparentTitle || 'none',
|
||||
leftIcon: shouldHideNav
|
||||
? LeftIcon.None
|
||||
: computeLeftIcon(cleanRoute, tabBarPages, pages.length, cleanHomePage),
|
||||
});
|
||||
}, []);
|
||||
|
||||
useDidShow(() => {
|
||||
updateNavState();
|
||||
});
|
||||
|
||||
usePageScroll(({ scrollTop }) => {
|
||||
if (navState.transparent === 'auto') {
|
||||
setScrollOpacity(Math.min(scrollTop / 100, 1));
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_H5_ENV) return;
|
||||
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
updateNavState();
|
||||
}, 50);
|
||||
});
|
||||
|
||||
observer.observe(document.head, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
updateNavState();
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (timer) clearTimeout(timer);
|
||||
};
|
||||
}, [updateNavState]);
|
||||
|
||||
const shouldRender =
|
||||
IS_H5_ENV && navState.visible && navState.navStyle !== 'custom';
|
||||
|
||||
useEffect(() => {
|
||||
if (!IS_H5_ENV) return;
|
||||
if (shouldRender) {
|
||||
document.body.classList.add('h5-navbar-visible');
|
||||
} else {
|
||||
document.body.classList.remove('h5-navbar-visible');
|
||||
}
|
||||
}, [shouldRender]);
|
||||
|
||||
if (!shouldRender) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const iconColor = navState.textStyle === 'white' ? '#fff' : '#333';
|
||||
const textColorClass =
|
||||
navState.textStyle === 'white' ? 'text-white' : 'text-gray-800';
|
||||
|
||||
const getBgStyle = () => {
|
||||
if (navState.transparent === 'always') {
|
||||
return { backgroundColor: 'transparent' };
|
||||
}
|
||||
if (navState.transparent === 'auto') {
|
||||
return { backgroundColor: navState.bgColor, opacity: scrollOpacity };
|
||||
}
|
||||
return { backgroundColor: navState.bgColor };
|
||||
};
|
||||
|
||||
const handleBack = () => Taro.navigateBack();
|
||||
const handleGoHome = () => {
|
||||
const homePage = getHomePage();
|
||||
Taro.reLaunch({ url: `/${homePage}` });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<View
|
||||
className="fixed top-0 left-0 right-0 h-11 flex items-center justify-center z-1000"
|
||||
style={getBgStyle()}
|
||||
>
|
||||
{navState.leftIcon === LeftIcon.Back && (
|
||||
<View
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 p-1 flex items-center justify-center"
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ChevronLeft size={24} color={iconColor} />
|
||||
</View>
|
||||
)}
|
||||
{navState.leftIcon === LeftIcon.Home && (
|
||||
<View
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 p-1 flex items-center justify-center"
|
||||
onClick={handleGoHome}
|
||||
>
|
||||
<House size={22} color={iconColor} />
|
||||
</View>
|
||||
)}
|
||||
<Text
|
||||
className={`text-base font-medium max-w-3/5 truncate ${textColorClass}`}
|
||||
>
|
||||
{navState.title}
|
||||
</Text>
|
||||
</View>
|
||||
<View className="h-11 shrink-0" />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* H5 端特殊样式注入
|
||||
* 如无必要,请勿修改本文件
|
||||
*/
|
||||
|
||||
import { IS_H5_ENV } from './env';
|
||||
|
||||
const H5_BASE_STYLES = `
|
||||
/* H5 端隐藏 TabBar 空图标(只隐藏没有 src 的图标) */
|
||||
.weui-tabbar__icon:not([src]),
|
||||
.weui-tabbar__icon[src=''] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.weui-tabbar__item:has(.weui-tabbar__icon:not([src])) .weui-tabbar__label,
|
||||
.weui-tabbar__item:has(.weui-tabbar__icon[src='']) .weui-tabbar__label {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
/* Vite 错误覆盖层无法选择文本的问题 */
|
||||
vite-error-overlay {
|
||||
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||
-webkit-user-select: text !important;
|
||||
}
|
||||
|
||||
vite-error-overlay::part(window) {
|
||||
max-width: 90vw;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.taro_page {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* H5 导航栏页面自动添加顶部间距 */
|
||||
body.h5-navbar-visible .taro_page {
|
||||
padding-top: 44px;
|
||||
}
|
||||
|
||||
body.h5-navbar-visible .toaster[data-position^="top"] {
|
||||
top: 44px !important;
|
||||
}
|
||||
|
||||
/* Sheet 组件在 H5 导航栏下的位置修正 */
|
||||
body.h5-navbar-visible .sheet-content:not([data-side="bottom"]) {
|
||||
top: 44px !important;
|
||||
}
|
||||
|
||||
/*
|
||||
* H5 端 rem 适配:与小程序 rpx 缩放一致
|
||||
* 375px 屏幕:1rem = 16px,小程序 32rpx = 16px
|
||||
*/
|
||||
html {
|
||||
font-size: 4vw !important;
|
||||
}
|
||||
|
||||
/* H5 端组件默认样式修复 */
|
||||
taro-view-core {
|
||||
display: block;
|
||||
}
|
||||
|
||||
taro-text-core {
|
||||
display: inline;
|
||||
}
|
||||
|
||||
taro-input-core {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
taro-input-core input {
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
taro-input-core.taro-otp-hidden-input input {
|
||||
color: transparent;
|
||||
caret-color: transparent;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
/* 全局按钮样式重置 */
|
||||
taro-button-core,
|
||||
button {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
line-height: inherit;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
taro-button-core::after,
|
||||
button::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
taro-textarea-core > textarea,
|
||||
.taro-textarea,
|
||||
textarea.taro-textarea {
|
||||
resize: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const PC_WIDESCREEN_STYLES = `
|
||||
/* PC 宽屏适配 - 基础布局 */
|
||||
@media (min-width: 769px) {
|
||||
html {
|
||||
font-size: 15px !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #f3f4f6 !important;
|
||||
display: flex !important;
|
||||
justify-content: center !important;
|
||||
align-items: center !important;
|
||||
min-height: 100vh !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const PC_WIDESCREEN_PHONE_FRAME = `
|
||||
/* PC 宽屏适配 - 手机框样式(有 TabBar 页面) */
|
||||
@media (min-width: 769px) {
|
||||
.taro-tabbar__container {
|
||||
width: 375px !important;
|
||||
max-width: 375px !important;
|
||||
height: calc(100vh - 40px) !important;
|
||||
max-height: 900px !important;
|
||||
background-color: #fff !important;
|
||||
transform: translateX(0) !important;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1) !important;
|
||||
border-radius: 20px !important;
|
||||
overflow: hidden !important;
|
||||
position: relative !important;
|
||||
}
|
||||
|
||||
.taro-tabbar__panel {
|
||||
height: 100% !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* PC 宽屏适配 - Toast 定位到手机框范围内 */
|
||||
@media (min-width: 769px) {
|
||||
body .toaster {
|
||||
left: 50% !important;
|
||||
right: auto !important;
|
||||
width: 375px !important;
|
||||
max-width: 375px !important;
|
||||
transform: translateX(-50%) !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* PC 宽屏适配 - 手机框样式(无 TabBar 页面,通过 JS 添加 no-tabbar 类) */
|
||||
@media (min-width: 769px) {
|
||||
body.no-tabbar #app {
|
||||
width: 375px !important;
|
||||
max-width: 375px !important;
|
||||
height: calc(100vh - 40px) !important;
|
||||
max-height: 900px !important;
|
||||
background-color: #fff !important;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.1) !important;
|
||||
border-radius: 20px !important;
|
||||
overflow: hidden !important;
|
||||
position: relative !important;
|
||||
transform: translateX(0) !important;
|
||||
}
|
||||
|
||||
body.no-tabbar #app .taro_router {
|
||||
height: 100% !important;
|
||||
overflow: auto !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
function injectStyles() {
|
||||
const style = document.createElement('style');
|
||||
style.innerHTML =
|
||||
H5_BASE_STYLES + PC_WIDESCREEN_STYLES + PC_WIDESCREEN_PHONE_FRAME;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
function setupTabbarDetection() {
|
||||
const checkTabbar = () => {
|
||||
const hasTabbar = !!document.querySelector('.taro-tabbar__container');
|
||||
document.body.classList.toggle('no-tabbar', !hasTabbar);
|
||||
};
|
||||
|
||||
checkTabbar();
|
||||
|
||||
const observer = new MutationObserver(checkTabbar);
|
||||
observer.observe(document.body, { childList: true, subtree: true });
|
||||
}
|
||||
|
||||
export function injectH5Styles() {
|
||||
if (!IS_H5_ENV) return;
|
||||
|
||||
injectStyles();
|
||||
setupTabbarDetection();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useLaunch } from '@tarojs/taro';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { injectH5Styles } from './h5-styles';
|
||||
import { devDebug } from './dev-debug';
|
||||
import { H5Container } from './h5-container';
|
||||
import {
|
||||
H5ErrorBoundary,
|
||||
initializeH5ErrorHandling,
|
||||
} from './h5-error-boundary';
|
||||
import { IS_H5_ENV } from './env';
|
||||
|
||||
export const Preset = ({ children }: PropsWithChildren) => {
|
||||
if (IS_H5_ENV) {
|
||||
initializeH5ErrorHandling();
|
||||
}
|
||||
|
||||
useLaunch(() => {
|
||||
devDebug();
|
||||
injectH5Styles();
|
||||
});
|
||||
|
||||
if (IS_H5_ENV) {
|
||||
return (
|
||||
<H5ErrorBoundary>
|
||||
<H5Container>{children}</H5Container>
|
||||
</H5ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
@@ -0,0 +1,4 @@
|
||||
/** @type {import('stylelint').Config} */
|
||||
export default {
|
||||
extends: "stylelint-config-standard",
|
||||
};
|
||||
@@ -1 +1,90 @@
|
||||
# tests
|
||||
# P01_errlens_app - 测试目录
|
||||
|
||||
## 测试结构
|
||||
|
||||
```
|
||||
tests/
|
||||
├── unit/ # 单元测试
|
||||
│ ├── components/ # 组件测试
|
||||
│ ├── lib/ # 工具函数测试
|
||||
│ └── pages/ # 页面测试
|
||||
│
|
||||
├── integration/ # 集成测试
|
||||
│ ├── api/ # API 集成测试
|
||||
│ └── components/ # 组件集成测试
|
||||
│
|
||||
├── e2e/ # 端到端测试
|
||||
│ └── scenarios/ # 用户场景测试
|
||||
│
|
||||
├── __mocks__/ # Mock 文件
|
||||
│ ├── network.ts
|
||||
│ └── taro.ts
|
||||
│
|
||||
├── setup.ts # 测试环境配置
|
||||
├── jest.config.js # Jest 配置
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 测试框架
|
||||
|
||||
| 框架 | 用途 |
|
||||
|------|------|
|
||||
| Jest | 测试运行器 |
|
||||
| @testing-library/react | React 组件测试 |
|
||||
| Vitest | 替代 Jest(可选) |
|
||||
| Supertest | HTTP API 测试 |
|
||||
|
||||
## 测试命令
|
||||
|
||||
```bash
|
||||
# 运行所有测试
|
||||
npm test
|
||||
|
||||
# 运行单元测试
|
||||
npm run test:unit
|
||||
|
||||
# 运行集成测试
|
||||
npm run test:integration
|
||||
|
||||
# 运行 E2E 测试
|
||||
npm run test:e2e
|
||||
|
||||
# 监听模式
|
||||
npm run test:watch
|
||||
|
||||
# 生成覆盖率报告
|
||||
npm run test:coverage
|
||||
```
|
||||
|
||||
## 测试覆盖率目标
|
||||
|
||||
| 类型 | 目标覆盖率 |
|
||||
|------|-----------|
|
||||
| 单元测试 | >= 80% |
|
||||
| 集成测试 | >= 60% |
|
||||
| E2E 测试 | 3+ 核心场景 |
|
||||
|
||||
## 待编写测试
|
||||
|
||||
### 单元测试
|
||||
- [ ] Button 组件测试
|
||||
- [ ] Input 组件测试
|
||||
- [ ] Card 组件测试
|
||||
- [ ] Dialog 组件测试
|
||||
- [ ] utils 函数测试
|
||||
- [ ] platform 检测测试
|
||||
|
||||
### 集成测试
|
||||
- [ ] 用户登录流程测试
|
||||
- [ ] 代码分析流程测试
|
||||
- [ ] 历史记录查询测试
|
||||
|
||||
### E2E 测试
|
||||
- [ ] 完整登录-分析-查看结果流程
|
||||
- [ ] 错误处理流程
|
||||
- [ ] 多端兼容性测试
|
||||
|
||||
---
|
||||
|
||||
**文档版本**:v1.0.0
|
||||
**最后更新**:2026-05-22
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
/** @type {import('jest').Config} */
|
||||
module.exports = {
|
||||
preset: 'jest-preset-taro',
|
||||
|
||||
testEnvironment: 'jsdom',
|
||||
|
||||
testMatch: [
|
||||
'**/tests/**/*.test.{ts,tsx}',
|
||||
'**/tests/**/*.spec.{ts,tsx}'
|
||||
],
|
||||
|
||||
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
|
||||
|
||||
transform: {
|
||||
'^.+\\.(ts|tsx)$': ['babel-jest', { presets: ['@babel/preset-typescript'] }],
|
||||
'^.+\\.(js|jsx)$': 'babel-jest'
|
||||
},
|
||||
|
||||
moduleNameMapper: {
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
'\\.(css|less|scss)$': 'identity-obj-proxy'
|
||||
},
|
||||
|
||||
collectCoverageFrom: [
|
||||
'src/**/*.{ts,tsx}',
|
||||
'!src/**/*.d.ts',
|
||||
'!src/types/**',
|
||||
'!src/**/index.{ts,tsx}'
|
||||
],
|
||||
|
||||
coverageThreshold: {
|
||||
global: {
|
||||
branches: 60,
|
||||
functions: 70,
|
||||
lines: 70,
|
||||
statements: 70
|
||||
}
|
||||
},
|
||||
|
||||
setupFilesAfterEnv: ['<rootDir>/tests/setup.ts'],
|
||||
|
||||
testPathIgnorePatterns: [
|
||||
'/node_modules/',
|
||||
'/dist/'
|
||||
],
|
||||
|
||||
verbose: true
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Jest 测试环境配置
|
||||
import '@testing-library/jest-dom'
|
||||
|
||||
// Mock Taro API
|
||||
jest.mock('@tarojs/taro', () => ({
|
||||
getEnv: jest.fn(() => 'WEB'),
|
||||
ENV_TYPE: {
|
||||
WEAPP: 'WEAPP',
|
||||
WEB: 'WEB',
|
||||
RN: 'RN',
|
||||
TT: 'TT'
|
||||
},
|
||||
request: jest.fn(),
|
||||
uploadFile: jest.fn(),
|
||||
downloadFile: jest.fn(),
|
||||
createSelectorQuery: jest.fn(() => ({
|
||||
select: jest.fn().mockReturnThis(),
|
||||
boundingClientRect: jest.fn().mockReturnThis(),
|
||||
exec: jest.fn(cb => cb([{}]))
|
||||
})),
|
||||
getSystemInfoSync: jest.fn(() => ({
|
||||
windowWidth: 375,
|
||||
windowHeight: 667
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock Network
|
||||
jest.mock('../src/network', () => ({
|
||||
Network: {
|
||||
request: jest.fn(),
|
||||
uploadFile: jest.fn(),
|
||||
downloadFile: jest.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// 全局测试超时
|
||||
jest.setTimeout(10000)
|
||||
|
||||
// 清理
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user