diff --git a/.ai.backup/config/coder.json b/.ai.backup/config/coder.json new file mode 100644 index 0000000..ffe122f --- /dev/null +++ b/.ai.backup/config/coder.json @@ -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/" + } +} \ No newline at end of file diff --git a/.ai.backup/config/tester.json b/.ai.backup/config/tester.json new file mode 100644 index 0000000..eef3c42 --- /dev/null +++ b/.ai.backup/config/tester.json @@ -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/" + } +} \ No newline at end of file diff --git a/.ai.backup/config/workflow.json b/.ai.backup/config/workflow.json new file mode 100644 index 0000000..5fe31c7 --- /dev/null +++ b/.ai.backup/config/workflow.json @@ -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"] + } +} \ No newline at end of file diff --git a/.ai.backup/prompts/coding/README.md b/.ai.backup/prompts/coding/README.md new file mode 100644 index 0000000..2c25e4e --- /dev/null +++ b/.ai.backup/prompts/coding/README.md @@ -0,0 +1 @@ +# coding diff --git a/.ai.backup/prompts/testing/README.md b/.ai.backup/prompts/testing/README.md new file mode 100644 index 0000000..5697da2 --- /dev/null +++ b/.ai.backup/prompts/testing/README.md @@ -0,0 +1 @@ +# testing diff --git a/AGENTS.md.backup b/AGENTS.md.backup new file mode 100644 index 0000000..320e789 --- /dev/null +++ b/AGENTS.md.backup @@ -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/` | 测试提示词模板 | \ No newline at end of file diff --git a/projects/P01_errlens_app/.gitignore b/projects/P01_errlens_app/.gitignore new file mode 100644 index 0000000..4bce367 --- /dev/null +++ b/projects/P01_errlens_app/.gitignore @@ -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/ diff --git a/projects/P01_errlens_app/.npmrc b/projects/P01_errlens_app/.npmrc new file mode 100644 index 0000000..03194f9 --- /dev/null +++ b/projects/P01_errlens_app/.npmrc @@ -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 diff --git a/projects/P01_errlens_app/ENVIRONMENT.md b/projects/P01_errlens_app/ENVIRONMENT.md index 9c65f7f..dc2bb42 100644 --- a/projects/P01_errlens_app/ENVIRONMENT.md +++ b/projects/P01_errlens_app/ENVIRONMENT.md @@ -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 +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 -``` \ No newline at end of file +### 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 diff --git a/projects/P01_errlens_app/babel.config.js b/projects/P01_errlens_app/babel.config.js new file mode 100644 index 0000000..4bbe90e --- /dev/null +++ b/projects/P01_errlens_app/babel.config.js @@ -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 + }] + ] +} diff --git a/projects/P01_errlens_app/config/dev.ts b/projects/P01_errlens_app/config/dev.ts new file mode 100644 index 0000000..b2002c9 --- /dev/null +++ b/projects/P01_errlens_app/config/dev.ts @@ -0,0 +1,9 @@ +import type { UserConfigExport } from "@tarojs/cli" + +export default { + + mini: { + debugReact: true, + }, + h5: {} +} satisfies UserConfigExport<'vite'> diff --git a/projects/P01_errlens_app/config/index.ts b/projects/P01_errlens_app/config/index.ts new file mode 100644 index 0000000..0795b72 --- /dev/null +++ b/projects/P01_errlens_app/config/index.ts @@ -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 = { + 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 = { + 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); +}); diff --git a/projects/P01_errlens_app/config/prod.ts b/projects/P01_errlens_app/config/prod.ts new file mode 100644 index 0000000..2f1dc21 --- /dev/null +++ b/projects/P01_errlens_app/config/prod.ts @@ -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'>; diff --git a/projects/P01_errlens_app/docs/01_需求概要.md b/projects/P01_errlens_app/docs/01_需求概要.md index 5d2d5ab..f89f72c 100644 --- a/projects/P01_errlens_app/docs/01_需求概要.md +++ b/projects/P01_errlens_app/docs/01_需求概要.md @@ -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 \ No newline at end of file + +### 前端框架 +| 技术 | 版本 | 说明 | +|------|------|------| +| 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 diff --git a/projects/P01_errlens_app/docs/02_架构设计.md b/projects/P01_errlens_app/docs/02_架构设计.md index 482506f..471b05a 100644 --- a/projects/P01_errlens_app/docs/02_架构设计.md +++ b/projects/P01_errlens_app/docs/02_架构设计.md @@ -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/ # 工具函数 -``` \ No newline at end of file +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 diff --git a/projects/P01_errlens_app/docs/03_接口定义.md b/projects/P01_errlens_app/docs/03_接口定义.md index 50f8317..5751325 100644 --- a/projects/P01_errlens_app/docs/03_接口定义.md +++ b/projects/P01_errlens_app/docs/03_接口定义.md @@ -1,54 +1,41 @@ # P01_errlens_app - 接口定义 -## API 基础路径 -`/api/v1` +## 接口规范 -## 认证方式 -JWT Token,放在 Authorization 头: -``` -Authorization: Bearer -``` +### 基础信息 +- **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 } ``` -**响应:** +**响应示例**: ```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 +``` + +**响应示例**: +```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 +``` + +**请求参数**: ```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": [...] + } } -``` \ No newline at end of file +``` + +--- + +### 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 +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 " + +# 代码分析 +curl -X POST http://localhost:3000/api/analyze \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{"code":"console.log(1)","language":"javascript"}' +``` + +--- + +**文档版本**:v1.0.0 +**最后更新**:2026-05-22 diff --git a/projects/P01_errlens_app/eslint.config.mjs b/projects/P01_errlens_app/eslint.config.mjs new file mode 100644 index 0000000..0b14b7e --- /dev/null +++ b/projects/P01_errlens_app/eslint.config.mjs @@ -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/**'], + }, +]; diff --git a/projects/P01_errlens_app/package.json b/projects/P01_errlens_app/package.json new file mode 100644 index 0000000..68d3526 --- /dev/null +++ b/projects/P01_errlens_app/package.json @@ -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" + } +} diff --git a/projects/P01_errlens_app/project.config.json b/projects/P01_errlens_app/project.config.json new file mode 100644 index 0000000..514b051 --- /dev/null +++ b/projects/P01_errlens_app/project.config.json @@ -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" }] } +} diff --git a/projects/P01_errlens_app/server/nest-cli.json b/projects/P01_errlens_app/server/nest-cli.json new file mode 100644 index 0000000..e96b983 --- /dev/null +++ b/projects/P01_errlens_app/server/nest-cli.json @@ -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 +} diff --git a/projects/P01_errlens_app/server/package.json b/projects/P01_errlens_app/server/package.json new file mode 100644 index 0000000..70c231f --- /dev/null +++ b/projects/P01_errlens_app/server/package.json @@ -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" + } +} diff --git a/projects/P01_errlens_app/server/src/app.controller.ts b/projects/P01_errlens_app/server/src/app.controller.ts new file mode 100644 index 0000000..c8f87c9 --- /dev/null +++ b/projects/P01_errlens_app/server/src/app.controller.ts @@ -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(), + }; + } +} diff --git a/projects/P01_errlens_app/server/src/app.module.ts b/projects/P01_errlens_app/server/src/app.module.ts new file mode 100644 index 0000000..f298059 --- /dev/null +++ b/projects/P01_errlens_app/server/src/app.module.ts @@ -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 {} diff --git a/projects/P01_errlens_app/server/src/app.service.ts b/projects/P01_errlens_app/server/src/app.service.ts new file mode 100644 index 0000000..d20483d --- /dev/null +++ b/projects/P01_errlens_app/server/src/app.service.ts @@ -0,0 +1,8 @@ +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class AppService { + getHello(): string { + return 'Hello, welcome to coze coding mini-program server!'; + } +} diff --git a/projects/P01_errlens_app/server/src/interceptors/http-status.interceptor.ts b/projects/P01_errlens_app/server/src/interceptors/http-status.interceptor.ts new file mode 100644 index 0000000..4b26638 --- /dev/null +++ b/projects/P01_errlens_app/server/src/interceptors/http-status.interceptor.ts @@ -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 { + 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(); + } +} diff --git a/projects/P01_errlens_app/server/src/main.ts b/projects/P01_errlens_app/server/src/main.ts new file mode 100644 index 0000000..9abed0f --- /dev/null +++ b/projects/P01_errlens_app/server/src/main.ts @@ -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(); diff --git a/projects/P01_errlens_app/server/tsconfig.json b/projects/P01_errlens_app/server/tsconfig.json new file mode 100644 index 0000000..eea51fa --- /dev/null +++ b/projects/P01_errlens_app/server/tsconfig.json @@ -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/*"] + } + } +} diff --git a/projects/P01_errlens_app/src/README.md b/projects/P01_errlens_app/src/README.md deleted file mode 100644 index f19b33e..0000000 --- a/projects/P01_errlens_app/src/README.md +++ /dev/null @@ -1 +0,0 @@ -# src diff --git a/projects/P01_errlens_app/src/app.config.ts b/projects/P01_errlens_app/src/app.config.ts new file mode 100644 index 0000000..15c683b --- /dev/null +++ b/projects/P01_errlens_app/src/app.config.ts @@ -0,0 +1,11 @@ +export default defineAppConfig({ + pages: [ + 'pages/index/index' + ], + window: { + backgroundTextStyle: 'light', + navigationBarBackgroundColor: '#fff', + navigationBarTitleText: 'WeChat', + navigationBarTextStyle: 'black' + } +}) diff --git a/projects/P01_errlens_app/src/app.css b/projects/P01_errlens_app/src/app.css new file mode 100644 index 0000000..87f33c8 --- /dev/null +++ b/projects/P01_errlens_app/src/app.css @@ -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); +} diff --git a/projects/P01_errlens_app/src/app.tsx b/projects/P01_errlens_app/src/app.tsx new file mode 100644 index 0000000..d1bd411 --- /dev/null +++ b/projects/P01_errlens_app/src/app.tsx @@ -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 ( + + {children} + + + ); +}; + +export default App; diff --git a/projects/P01_errlens_app/src/components/ui/accordion.tsx b/projects/P01_errlens_app/src/components/ui/accordion.tsx new file mode 100644 index 0000000..f4f68cf --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/accordion.tsx @@ -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, + React.ComponentPropsWithoutRef & { + 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( + 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 ( + + + + ) +}) +Accordion.displayName = "Accordion" + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { value: string } +>(({ className, value, ...props }, ref) => ( + +)) +AccordionItem.displayName = "AccordionItem" + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ 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 ( + + {(itemValue) => {children}} + + ) +}) +AccordionTrigger.displayName = "AccordionTrigger" + +// Helper context for Item +const AccordionItemContext = React.createContext("") + +// Update AccordionItem to provide context +const AccordionItemWithContext = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { value: string } +>(({ className, value, children, ...props }, ref) => ( + + + {children} + + +)) +AccordionItemWithContext.displayName = "AccordionItem" + + +const AccordionTriggerInternal = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { 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 ( + + context?.onValueChange?.(itemValue)} + {...props} + > + {children} + + + + ) +}) + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + {(itemValue) => {children}} + +)) +AccordionContent.displayName = "AccordionContent" + +const AccordionContentInternal = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { 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 ( + + {children} + + ) +}) + +export { Accordion, AccordionItemWithContext as AccordionItem, AccordionTrigger, AccordionContent } diff --git a/projects/P01_errlens_app/src/components/ui/alert-dialog.tsx b/projects/P01_errlens_app/src/components/ui/alert-dialog.tsx new file mode 100644 index 0000000..b985790 --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/alert-dialog.tsx @@ -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 | 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 ( + + {children} + + ) +} + +const AlertDialogTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => { + const context = React.useContext(AlertDialogContext) + return ( + { + e.stopPropagation() + context?.onOpenChange?.(true) + }} + {...props} + > + {children} + + ) +}) +AlertDialogTrigger.displayName = "AlertDialogTrigger" + +const AlertDialogPortal = ({ children }) => { + const context = React.useContext(AlertDialogContext) + const present = usePresence(context?.open, 200) + if (!present) return null + return {children} +} + +const AlertDialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const context = React.useContext(AlertDialogContext) + const state = context?.open ? "open" : "closed" + return ( + { + 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, + React.ComponentPropsWithoutRef +>(({ className, children, style, ...props }, ref) => { + const context = React.useContext(AlertDialogContext) + const offset = useKeyboardOffset() + const state = context?.open ? "open" : "closed" + return ( + + + + 0 ? `calc(50% - ${offset / 2}px)` : undefined + }} + onClick={(e) => e.stopPropagation()} + {...props} + > + {children} + + + + ) +}) +AlertDialogContent.displayName = "AlertDialogContent" + +const AlertDialogHeader = ({ + className, + ...props +}: React.ComponentPropsWithoutRef) => ( + +) +AlertDialogHeader.displayName = "AlertDialogHeader" + +const AlertDialogFooter = ({ + className, + ...props +}: React.ComponentPropsWithoutRef) => ( + +) +AlertDialogFooter.displayName = "AlertDialogFooter" + +const AlertDialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogTitle.displayName = "AlertDialogTitle" + +const AlertDialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDialogDescription.displayName = "AlertDialogDescription" + +const AlertDialogAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, variant, size, onClick, ...props }, ref) => { + const context = React.useContext(AlertDialogContext) + return ( + { + context?.onOpenChange?.(false) + onClick?.(e) + }} + {...props} + /> + ) +}) +AlertDialogAction.displayName = "AlertDialogAction" + +const AlertDialogCancel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, variant = "outline", size, onClick, ...props }, ref) => { + const context = React.useContext(AlertDialogContext) + return ( + { + context?.onOpenChange?.(false) + onClick?.(e) + }} + {...props} + /> + ) +}) +AlertDialogCancel.displayName = "AlertDialogCancel" + +export { + AlertDialog, + AlertDialogPortal, + AlertDialogOverlay, + AlertDialogTrigger, + AlertDialogContent, + AlertDialogHeader, + AlertDialogFooter, + AlertDialogTitle, + AlertDialogDescription, + AlertDialogAction, + AlertDialogCancel, +} diff --git a/projects/P01_errlens_app/src/components/ui/alert.tsx b/projects/P01_errlens_app/src/components/ui/alert.tsx new file mode 100644 index 0000000..f9c8d8a --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/alert.tsx @@ -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, + React.ComponentPropsWithoutRef & VariantProps +>(({ className, variant, ...props }, ref) => ( + +)) +Alert.displayName = "Alert" + +const AlertTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertTitle.displayName = "AlertTitle" + +const AlertDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AlertDescription.displayName = "AlertDescription" + +export { Alert, AlertTitle, AlertDescription } diff --git a/projects/P01_errlens_app/src/components/ui/aspect-ratio.tsx b/projects/P01_errlens_app/src/components/ui/aspect-ratio.tsx new file mode 100644 index 0000000..5928c42 --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/aspect-ratio.tsx @@ -0,0 +1,36 @@ +import * as React from "react" +import { View } from "@tarojs/components" + +const AspectRatio = React.forwardRef< + React.ElementRef, + Omit, "style"> & { + ratio?: number + style?: React.CSSProperties + } +>(({ className, ratio = 1 / 1, style, ...props }, ref) => ( + + + {props.children} + + +)) +AspectRatio.displayName = "AspectRatio" + +export { AspectRatio } diff --git a/projects/P01_errlens_app/src/components/ui/avatar.tsx b/projects/P01_errlens_app/src/components/ui/avatar.tsx new file mode 100644 index 0000000..5f05d42 --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/avatar.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const [status, setStatus] = React.useState<"loading" | "error" | "loaded">("loading") + return ( + + + + ) +}) +Avatar.displayName = "Avatar" + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ 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 ( + + ) +}) +AvatarImage.displayName = "AvatarImage" + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const context = React.useContext(AvatarContext) + + if (context?.status === "loaded") return null + + return ( + + ) +}) +AvatarFallback.displayName = "AvatarFallback" + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/projects/P01_errlens_app/src/components/ui/badge.tsx b/projects/P01_errlens_app/src/components/ui/badge.tsx new file mode 100644 index 0000000..f16c25b --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/badge.tsx @@ -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, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( + + ) +} + +export { Badge, badgeVariants } diff --git a/projects/P01_errlens_app/src/components/ui/breadcrumb.tsx b/projects/P01_errlens_app/src/components/ui/breadcrumb.tsx new file mode 100644 index 0000000..36a02fa --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/breadcrumb.tsx @@ -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, + React.ComponentPropsWithoutRef & { + separator?: React.ReactNode + } +>(({ ...props }, ref) => ) +Breadcrumb.displayName = "Breadcrumb" + +const BreadcrumbList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +BreadcrumbList.displayName = "BreadcrumbList" + +const BreadcrumbItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +BreadcrumbItem.displayName = "BreadcrumbItem" + +const BreadcrumbLink = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + asChild?: boolean + href?: string + } +>(({ asChild, className, href, ...props }, ref) => { + const linkProps = href ? { url: href } : {} + + return ( + + ) +}) +BreadcrumbLink.displayName = "BreadcrumbLink" + +const BreadcrumbPage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +BreadcrumbPage.displayName = "BreadcrumbPage" + +const BreadcrumbSeparator = ({ + children, + className, + ...props +}: React.ComponentPropsWithoutRef) => ( + +) +BreadcrumbSeparator.displayName = "BreadcrumbSeparator" + +const BreadcrumbEllipsis = ({ + className, + ...props +}: React.ComponentPropsWithoutRef) => ( + +) +BreadcrumbEllipsis.displayName = "BreadcrumbElipssis" + +export { + Breadcrumb, + BreadcrumbList, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbPage, + BreadcrumbSeparator, + BreadcrumbEllipsis, +} diff --git a/projects/P01_errlens_app/src/components/ui/button-group.tsx b/projects/P01_errlens_app/src/components/ui/button-group.tsx new file mode 100644 index 0000000..4e9d8c6 --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/button-group.tsx @@ -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 & VariantProps) { + return ( + + ) +} + +function ButtonGroupText({ + className, + asChild = false, + ...props +}: React.ComponentPropsWithoutRef & { + asChild?: boolean +}) { + + return ( + + ) +} + +function ButtonGroupSeparator({ + className, + orientation = "vertical", + ...props +}: React.ComponentPropsWithoutRef) { + return ( + + ) +} + +export { + ButtonGroup, + ButtonGroupSeparator, + ButtonGroupText, + buttonGroupVariants, +} diff --git a/projects/P01_errlens_app/src/components/ui/button.tsx b/projects/P01_errlens_app/src/components/ui/button.tsx new file mode 100644 index 0000000..f213cd1 --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/button.tsx @@ -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, + VariantProps { + asChild?: boolean + disabled?: boolean + className?: string +} + +const Button = React.forwardRef, ButtonProps>( + ({ className, variant, size, asChild = false, disabled, ...props }, ref) => { + const tabIndex = (props as { tabIndex?: number }).tabIndex ?? (disabled ? -1 : 0) + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/projects/P01_errlens_app/src/components/ui/calendar.tsx b/projects/P01_errlens_app/src/components/ui/calendar.tsx new file mode 100644 index 0000000..8a7230a --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/calendar.tsx @@ -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( + 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 ( + + + {captionHasButtons ? ( + + ) : ( + + )} + + {captionHasDropdown ? ( + + setYear(yearOptions[Number(e.detail.value)]!)} + > + + + + setMonthOfYear(monthOptions[Number(e.detail.value)]!) + } + > + + + + ) : ( + + {format(visibleMonth, "yyyy年MM月")} + + )} + + {captionHasButtons ? ( + + ) : ( + + )} + + + + {weekdays.map((label) => ( + + + {label} + + + ))} + + + + {weeks.map((week, rowIndex) => ( + + {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 ( + + ) + })} + + ))} + + + ) +} + +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 ( + + ) +} + +export { Calendar, CalendarDayButton } diff --git a/projects/P01_errlens_app/src/components/ui/card.tsx b/projects/P01_errlens_app/src/components/ui/card.tsx new file mode 100644 index 0000000..e6febbf --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/card.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => { + // 检查子元素中是否有 CardHeader + const hasHeader = React.Children.toArray(children).some( + (child) => React.isValidElement(child) && (child.type as any).displayName === "CardHeader" + ) + + return ( + + + {children} + + + ) +}) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { hasHeader } = React.useContext(CardContext) + return ( + + ) +}) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const { hasHeader } = React.useContext(CardContext) + // 注意:Footer 通常也跟在 Content 后面,所以这里逻辑可以更精细, + // 但为了简单通用,如果卡片有 Header,Footer 默认 pt-0 也是合理的。 + return ( + + ) +}) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/projects/P01_errlens_app/src/components/ui/carousel.tsx b/projects/P01_errlens_app/src/components/ui/carousel.tsx new file mode 100644 index 0000000..f4330da --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/carousel.tsx @@ -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(null) + +function useCarousel() { + const context = React.useContext(CarouselContext) + if (!context) { + throw new Error("useCarousel must be used within a ") + } + return context +} + +const Carousel = React.forwardRef< + React.ElementRef, + 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 ( + + + {children} + + + ) +}) +Carousel.displayName = "Carousel" + +const CarouselContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => { + const { orientation, current, setCurrent, setCount, opts } = useCarousel() + + React.useEffect(() => { + const childCount = React.Children.count(children) + setCount(childCount) + }, [children, setCount]) + + return ( + + 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 {child} + } + return null + })} + + + ) +}) +CarouselContent.displayName = "CarouselContent" + +const CarouselItem = ({ className, children, ...props }: React.ComponentProps) => { + return ( + + {children} + + ) +} +CarouselItem.displayName = "CarouselItem" + +const CarouselPrevious = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollPrev, canScrollPrev } = useCarousel() + + return ( + + ) +}) +CarouselPrevious.displayName = "CarouselPrevious" + +const CarouselNext = React.forwardRef< + React.ElementRef, + React.ComponentProps +>(({ className, variant = "outline", size = "icon", ...props }, ref) => { + const { orientation, scrollNext, canScrollNext } = useCarousel() + + return ( + + ) +}) +CarouselNext.displayName = "CarouselNext" + +export { + Carousel, + CarouselContent, + CarouselItem, + CarouselPrevious, + CarouselNext, +} diff --git a/projects/P01_errlens_app/src/components/ui/checkbox.tsx b/projects/P01_errlens_app/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..97f30e7 --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/checkbox.tsx @@ -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, + Omit, "onClick"> & { + checked?: boolean + defaultChecked?: boolean + onCheckedChange?: (checked: boolean) => void + disabled?: boolean + } +>(({ className, checked: checkedProp, defaultChecked, onCheckedChange, disabled, ...props }, ref) => { + const [checkedState, setCheckedState] = React.useState( + 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 ( + + {checked && } + + ) +}) +Checkbox.displayName = "Checkbox" + +export { Checkbox } diff --git a/projects/P01_errlens_app/src/components/ui/code-block.tsx b/projects/P01_errlens_app/src/components/ui/code-block.tsx new file mode 100644 index 0000000..783403e --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/code-block.tsx @@ -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 = ({ + 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 ( + + + + {language && ( + + {language} + + )} + + {tokens.map((token, i) => ( + + {token.content} + + ))} + + + + {showCopyButton && ( + + )} + + ) +} + +export { CodeBlock } diff --git a/projects/P01_errlens_app/src/components/ui/collapsible.tsx b/projects/P01_errlens_app/src/components/ui/collapsible.tsx new file mode 100644 index 0000000..56eb9a0 --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/collapsible.tsx @@ -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, + React.ComponentPropsWithoutRef & { + 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 ( + + + + ) +}) +Collapsible.displayName = "Collapsible" + +const CollapsibleTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + asChild?: boolean + } +>(({ className, onClick, asChild, ...props }, ref) => { + const context = React.useContext(CollapsibleContext) + + return ( + { + context?.onOpenChange(!context.open) + onClick?.(e) + }} + {...props} + /> + ) +}) +CollapsibleTrigger.displayName = "CollapsibleTrigger" + +const CollapsibleContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const context = React.useContext(CollapsibleContext) + + if (!context?.open) return null + + return +}) +CollapsibleContent.displayName = "CollapsibleContent" + +export { Collapsible, CollapsibleTrigger, CollapsibleContent } diff --git a/projects/P01_errlens_app/src/components/ui/command.tsx b/projects/P01_errlens_app/src/components/ui/command.tsx new file mode 100644 index 0000000..368e14a --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/command.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => { + const [search, setSearch] = React.useState("") + // 使用 deferredSearch 来延迟搜索过滤逻辑,确保输入框在输入时保持响应,解决微信小程序中的输入抖动和文字消失问题 + const deferredSearch = React.useDeferredValue(search) + const [, setItemsTick] = React.useState(0) + const itemsRef = React.useRef>(new Map()) + const tickRef = React.useRef | 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 ( + + + + {children} + + + + ) +}) +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 ( + + + {enhancedChildren} + + + ) +} + +const CommandInput = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { 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 | 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 ( + + + { + const v = e.detail.value + setLocalValue(v) + lastSyncedSearchRef.current = v + context?.setSearch(v) + }} + focus={inputFocus} + {...props} + /> + + ) +}) +CommandInput.displayName = "CommandInput" + +const CommandList = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandList.displayName = "CommandList" + +const CommandEmpty = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const context = React.useContext(CommandItemsContext) + + const show = context ? context.itemsSize > 0 && !context.hasAnyMatch() : false + if (!show) return null + + return ( + + ) +}) +CommandEmpty.displayName = "CommandEmpty" + +const CommandGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { 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 ( + + + {heading && ( + + {heading} + + )} + {children} + + + ) +}) +CommandGroup.displayName = "CommandGroup" + +const CommandSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +CommandSeparator.displayName = "CommandSeparator" + +const CommandItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + 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 ( + !disabled && onSelect?.(computedValue)} + {...props} + > + {children} + + ) +}) +CommandItem.displayName = "CommandItem" + +const CommandShortcut = ({ + className, + ...props +}: React.ComponentPropsWithoutRef) => { + return ( + + ) +} +CommandShortcut.displayName = "CommandShortcut" + +export { + Command, + CommandDialog, + CommandInput, + CommandList, + CommandEmpty, + CommandGroup, + CommandItem, + CommandShortcut, + CommandSeparator, +} diff --git a/projects/P01_errlens_app/src/components/ui/context-menu.tsx b/projects/P01_errlens_app/src/components/ui/context-menu.tsx new file mode 100644 index 0000000..c181ec5 --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/context-menu.tsx @@ -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(null) + + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen) + if (!newOpen) setActiveSubId(null) + onOpenChange?.(newOpen) + } + + return ( + + {children} + + ) +} + +const ContextMenuTrigger = React.forwardRef< + any, + React.ComponentPropsWithoutRef & { 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 ( +
{ + e.preventDefault() + e.stopPropagation() + handleTrigger(e.clientX, e.clientY) + }} + {...rest} + > + {children} +
+ ) + } + + return ( + { + 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} + + ) +}) +ContextMenuTrigger.displayName = "ContextMenuTrigger" + +const ContextMenuGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuGroup.displayName = "ContextMenuGroup" + +const ContextMenuPortal = ({ children }: { children: React.ReactNode }) => { + return <>{children} +} + +const ContextMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ 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 ( + + context.onOpenChange?.(false)} + // @ts-ignore + onContextMenu={(e) => { + e.preventDefault() + context.onOpenChange?.(false) + }} + /> + e.stopPropagation()} + {...props} + > + + {children} + + + + ) +}) +ContextMenuContent.displayName = "ContextMenuContent" + +const ContextMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + disabled?: boolean + closeOnSelect?: boolean + } +>(({ className, inset, disabled, closeOnSelect = true, children, onClick, ...props }, ref) => { + const context = React.useContext(ContextMenuContext) + return ( + { + if (disabled) return + e.stopPropagation() + onClick?.(e) + if (closeOnSelect) context?.onOpenChange?.(false) + }} + {...props} + > + {children} + + ) +}) +ContextMenuItem.displayName = "ContextMenuItem" + +const ContextMenuRadioGroupContext = React.createContext<{ + value?: string + onValueChange?: (value: string) => void +} | null>(null) + +interface ContextMenuRadioGroupProps extends React.ComponentPropsWithoutRef { + value?: string + defaultValue?: string + onValueChange?: (value: string) => void +} + +const ContextMenuRadioGroup = React.forwardRef< + React.ElementRef, + ContextMenuRadioGroupProps +>(({ value: valueProp, defaultValue, onValueChange, ...props }, ref) => { + const [valueState, setValueState] = React.useState(defaultValue) + const value = valueProp !== undefined ? valueProp : valueState + + const handleValueChange = (next: string) => { + if (valueProp === undefined) { + setValueState(next) + } + onValueChange?.(next) + } + + return ( + + + + ) +}) +ContextMenuRadioGroup.displayName = "ContextMenuRadioGroup" + +const ContextMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + checked?: boolean + inset?: boolean + disabled?: boolean + closeOnSelect?: boolean + } +>(({ className, children, checked, inset, disabled, closeOnSelect = false, onClick, ...props }, ref) => { + const context = React.useContext(ContextMenuContext) + return ( + { + if (disabled) return + e.stopPropagation() + onClick?.(e) + if (closeOnSelect) context?.onOpenChange?.(false) + }} + {...props} + > + + {checked && } + + {children} + + ) +}) +ContextMenuCheckboxItem.displayName = "ContextMenuCheckboxItem" + +const ContextMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + 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 ( + { + if (disabled) return + e.stopPropagation() + group?.onValueChange?.(value) + onClick?.(e) + if (closeOnSelect) context?.onOpenChange?.(false) + }} + {...props} + > + + {checked && } + + {children} + + ) +}) +ContextMenuRadioItem.displayName = "ContextMenuRadioItem" + +const ContextMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +ContextMenuLabel.displayName = "ContextMenuLabel" + +const ContextMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ContextMenuSeparator.displayName = "ContextMenuSeparator" + +const ContextMenuShortcut = ({ + className, + ...props +}: React.ComponentPropsWithoutRef) => { + return ( + + ) +} +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 ( + + {children} + + ) +} + +const ContextMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + disabled?: boolean + } +>(({ className, inset, disabled, children, onClick, ...props }, ref) => { + const subContext = React.useContext(ContextMenuSubContext) + return ( + { + e.stopPropagation() + if (disabled) return + subContext?.onOpenChange?.(!subContext.open) + onClick?.(e) + }} + > + {children} + + + ) +}) + +interface ContextMenuSubContentProps extends React.ComponentPropsWithoutRef { + align?: "start" | "center" | "end" + side?: "top" | "bottom" | "left" | "right" + sideOffset?: number +} + +const ContextMenuSubContent = React.forwardRef< + React.ElementRef, + 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 ( + + e.stopPropagation()} + > + + {children} + + + + ) +}) + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +} diff --git a/projects/P01_errlens_app/src/components/ui/dialog.tsx b/projects/P01_errlens_app/src/components/ui/dialog.tsx new file mode 100644 index 0000000..3c0f123 --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/dialog.tsx @@ -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 | 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 ( + + {children} + + ) +} + +const DialogTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { asChild?: boolean } +>(({ className, children, asChild, ...props }, ref) => { + const context = React.useContext(DialogContext) + return ( + { + e.stopPropagation() + context?.onOpenChange?.(true) + }} + {...props} + > + {children} + + ) +}) +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 {children} +} + +const DialogOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, onClick, ...props }, ref) => { + const context = React.useContext(DialogContext) + const state = context?.open ? "open" : "closed" + return ( + { + e.stopPropagation() + onClick?.(e) + context?.onOpenChange?.(false) + }} + {...props} + /> + ) +}) +DialogOverlay.displayName = "DialogOverlay" + +interface DialogContentProps extends React.ComponentPropsWithoutRef { + closeClassName?: string +} + +const DialogContent = React.forwardRef< + React.ElementRef, + DialogContentProps +>(({ className, children, style, closeClassName, ...props }, ref) => { + const context = React.useContext(DialogContext) + const offset = useKeyboardOffset() + const state = context?.open ? "open" : "closed" + + return ( + + context?.onOpenChange?.(false)} + > + + 0 ? `calc(50% - ${offset / 2}px)` : undefined + }} + onClick={(e) => e.stopPropagation()} + {...props} + > + {children} + { + e.stopPropagation() + context?.onOpenChange?.(false) + }} + > + + Close + + + + + ) +}) +DialogContent.displayName = "DialogContent" + +const DialogHeader = ({ + className, + ...props +}: React.ComponentPropsWithoutRef) => ( + +) +DialogHeader.displayName = "DialogHeader" + +const DialogFooter = ({ + className, + ...props +}: React.ComponentPropsWithoutRef) => ( + +) +DialogFooter.displayName = "DialogFooter" + +const DialogTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = "DialogTitle" + +const DialogDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogDescription.displayName = "DialogDescription" + +const DialogClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const context = React.useContext(DialogContext) + return ( + { + e.stopPropagation() + context?.onOpenChange?.(false) + }} + {...props} + /> + ) +}) +DialogClose.displayName = "DialogClose" + +export { + Dialog, + DialogPortal, + DialogOverlay, + DialogTrigger, + DialogClose, + DialogContent, + DialogHeader, + DialogFooter, + DialogTitle, + DialogDescription, +} diff --git a/projects/P01_errlens_app/src/components/ui/drawer.tsx b/projects/P01_errlens_app/src/components/ui/drawer.tsx new file mode 100644 index 0000000..a8ed68e --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/drawer.tsx @@ -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 { + 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 ( + + {children} + + ) +} +Drawer.displayName = "Drawer" + +const DrawerTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { asChild?: boolean } +>(({ className, children, asChild, ...props }, ref) => { + const context = React.useContext(DrawerContext) + return ( + { + e.stopPropagation() + context?.onOpenChange?.(true) + }} + {...props} + > + {children} + + ) +}) +DrawerTrigger.displayName = "DrawerTrigger" + +const DrawerPortal = ({ children }: { children: React.ReactNode }) => { + const context = React.useContext(DrawerContext) + if (!context?.open) return null + return {children} +} + +const DrawerClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { asChild?: boolean } +>(({ className, children, asChild, ...props }, ref) => { + const context = React.useContext(DrawerContext) + return ( + { + e.stopPropagation() + context?.onOpenChange?.(false) + }} + {...props} + > + {children} + + ) +}) +DrawerClose.displayName = "DrawerClose" + +const DrawerOverlay = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => { + const context = React.useContext(DrawerContext) + return ( + context?.onOpenChange?.(false)} + {...props} + /> + ) +}) +DrawerOverlay.displayName = "DrawerOverlay" + +const DrawerContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + {children} + + +)) +DrawerContent.displayName = "DrawerContent" + +const DrawerHeader = ({ + className, + ...props +}: React.ComponentPropsWithoutRef) => ( + +) +DrawerHeader.displayName = "DrawerHeader" + +const DrawerFooter = ({ + className, + ...props +}: React.ComponentPropsWithoutRef) => ( + +) +DrawerFooter.displayName = "DrawerFooter" + +const DrawerTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerTitle.displayName = "DrawerTitle" + +const DrawerDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DrawerDescription.displayName = "DrawerDescription" + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +} diff --git a/projects/P01_errlens_app/src/components/ui/dropdown-menu.tsx b/projects/P01_errlens_app/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..e752f06 --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/dropdown-menu.tsx @@ -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 ( + + {children} + + ) +} + +const DropdownMenuTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, onClick, ...props }, ref) => { + const context = React.useContext(DropdownMenuContext) + return ( + { + e.stopPropagation() + context?.onOpenChange?.(!context.open) + onClick?.(e) + }} + > + {children} + + ) +}) +DropdownMenuTrigger.displayName = "DropdownMenuTrigger" + +const DropdownMenuGroup = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +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 { + value?: string + defaultValue?: string + onValueChange?: (value: string) => void +} + +const DropdownMenuRadioGroup = React.forwardRef< + React.ElementRef, + DropdownMenuRadioGroupProps +>(({ value: valueProp, defaultValue, onValueChange, ...props }, ref) => { + const [valueState, setValueState] = React.useState(defaultValue) + const value = valueProp !== undefined ? valueProp : valueState + + const handleValueChange = (next: string) => { + if (valueProp === undefined) { + setValueState(next) + } + onValueChange?.(next) + } + + return ( + + + + ) +}) +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 ( + + {children} + + ) +} + +interface DropdownMenuContentProps extends React.ComponentPropsWithoutRef { + align?: "start" | "center" | "end" + side?: "top" | "bottom" | "left" | "right" + sideOffset?: number +} + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + 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 ( + + context.onOpenChange?.(false)} /> + e.stopPropagation()} + > + + {children} + + + + ) +}) +DropdownMenuContent.displayName = "DropdownMenuContent" + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + 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 ( + { + if (disabled) return + onClick?.(e) + if (closeOnSelect) context?.onOpenChange?.(false) + }} + /> + ) +}) +DropdownMenuItem.displayName = "DropdownMenuItem" + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + checked?: boolean + inset?: boolean + disabled?: boolean + closeOnSelect?: boolean + } +>(({ className, children, checked, inset, disabled, closeOnSelect = false, onClick, ...props }, ref) => { + const context = React.useContext(DropdownMenuContext) + return ( + { + if (disabled) return + onClick?.(e) + if (closeOnSelect) context?.onOpenChange?.(false) + }} + > + + {checked && } + + {children} + + ) +}) +DropdownMenuCheckboxItem.displayName = "DropdownMenuCheckboxItem" + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + 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 ( + { + if (disabled) return + group?.onValueChange?.(value) + onClick?.(e) + if (closeOnSelect) context?.onOpenChange?.(false) + }} + > + + {checked && } + + {children} + + ) +}) +DropdownMenuRadioItem.displayName = "DropdownMenuRadioItem" + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = "DropdownMenuLabel" + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = "DropdownMenuSeparator" + +const DropdownMenuShortcut = ({ className, ...props }: React.ComponentPropsWithoutRef) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +const DropdownMenuSubTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + disabled?: boolean + } +>(({ className, inset, disabled, children, onClick, ...props }, ref) => { + const subContext = React.useContext(DropdownMenuSubContext) + return ( + { + e.stopPropagation() + if (disabled) return + subContext?.onOpenChange?.(!subContext.open) + onClick?.(e) + }} + > + {children} + + + ) +}) +DropdownMenuSubTrigger.displayName = "DropdownMenuSubTrigger" + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + 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 ( + + e.stopPropagation()} + > + + {children} + + + + ) +}) +DropdownMenuSubContent.displayName = "DropdownMenuSubContent" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/projects/P01_errlens_app/src/components/ui/field.tsx b/projects/P01_errlens_app/src/components/ui/field.tsx new file mode 100644 index 0000000..c3f8fe7 --- /dev/null +++ b/projects/P01_errlens_app/src/components/ui/field.tsx @@ -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) { + return ( + + ) +} + +function FieldLegend({ + className, + variant = "legend", + ...props +}: React.ComponentPropsWithoutRef & { variant?: "legend" | "label" }) { + return ( + + ) +} + +function FieldGroup({ className, ...props }: React.ComponentPropsWithoutRef) { + return ( + + ) +} + +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 & VariantProps) { + return ( + + ) +} + +function FieldContent({ className, ...props }: React.ComponentPropsWithoutRef) { + return ( + + ) +} + +function FieldLabel({ + className, + ...props +}: React.ComponentPropsWithoutRef) { + return ( +