feat: FischerX开发底座初始化提交

- Monorepo架构(pnpm + Turborepo)
- 前端:Next.js + TypeScript + Tailwind CSS + Shadcn UI
- 后端:NestJS + Prisma + PostgreSQL + Redis
- 核心模块:用户管理、认证授权、权限控制、文件存储
- 业务模块:支付系统、消息通知、内容管理
- 基础设施:Docker、K8s、Terraform、CI/CD
- 监控告警:Prometheus + Grafana + Loki + Jaeger
- CLI工具:@fischerx/cli
- 文档体系:9大类30+文档
This commit is contained in:
fischer 2026-05-25 09:50:16 +08:00
commit af4de6b86a
427 changed files with 53988 additions and 0 deletions

21
.editorconfig Normal file
View File

@ -0,0 +1,21 @@
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
[*.md]
trim_trailing_whitespace = false
[Makefile]
indent_style = tab
[*.{json,yml,yaml}]
indent_size = 2
[*.{ts,tsx,js,jsx}]
indent_size = 2

25
.env.example Normal file
View File

@ -0,0 +1,25 @@
NODE_ENV=development
DB_HOST=postgres
DB_PORT=5432
DB_USER=fischerx
DB_PASSWORD=fischerx
DB_NAME=fischerx
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=
API_PORT=3001
WEB_PORT=3000
NEXT_PUBLIC_API_URL=http://localhost:3001
JWT_SECRET=your-super-secret-jwt-key-change-in-production
JWT_EXPIRES_IN=7d
ALIBABA_CLOUD_ACCESS_KEY_ID=
ALIBABA_CLOUD_ACCESS_KEY_SECRET=
OSS_REGION=
OSS_BUCKET=
OSS_ENDPOINT=

24
.eslintrc.js Normal file
View File

@ -0,0 +1,24 @@
module.exports = {
root: true,
env: {
node: true,
es2020: true,
},
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 2020,
sourceType: "module",
},
plugins: ["@typescript-eslint"],
extends: [
"eslint:recommended",
"plugin:@typescript-eslint/recommended",
],
rules: {
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
"@typescript-eslint/no-explicit-any": "warn",
},
ignorePatterns: ["node_modules", "dist", ".next", "coverage"],
};

44
.github/dependabot.yml vendored Normal file
View File

@ -0,0 +1,44 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 10
reviewers:
- "your-team-member"
labels:
- "dependencies"
- "npm"
allow:
- dependency-type: "direct"
ignore:
- dependency-name: "*"
update-types: ["version-update:semver-major"]
commit-message:
prefix: "deps"
include: "scope"
groups:
production-dependencies:
dependency-type: "production"
development-dependencies:
dependency-type: "development"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Shanghai"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "github-actions"
commit-message:
prefix: "ci"
include: "scope"

37
.github/release-please.yml vendored Normal file
View File

@ -0,0 +1,37 @@
release-type: node
package-name: fischerx
bump-minor-pre-major: true
bump-patch-for-minor-pre-major: true
changelog-type: "markdown"
include-v-in-tag: true
changelog-file: CHANGELOG.md
skip-github-release: false
skip-git-push: false
signoff: "false"
draft: false
prerelease: false
pull-request-title-pattern: "chore: release ${version}"
labels:
- "release"
changelog-sections:
- type: feat
section: Features
- type: fix
section: Bug Fixes
- type: perf
section: Performance Improvements
- type: refactor
section: Refactors
- type: docs
section: Documentation
- type: style
section: Style
- type: test
section: Tests
- type: ci
section: CI/CD
- type: build
section: Build
- type: chore
section: Chores

33
.github/semantic.yml vendored Normal file
View File

@ -0,0 +1,33 @@
titleAndCommits: true
alwaysValidate: true
allowMergeCommits: false
allowRevertCommits: false
enabled: true
types:
- feat
- fix
- docs
- style
- refactor
- perf
- test
- build
- ci
- chore
- revert
scopes:
- api
- web
- admin
- core
- infra
- docs
- ci
- build
- deps
- release
allowTicketNumberPrefix: false

131
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,131 @@
name: Build and Docker
on:
push:
branches: [main, develop]
tags:
- 'v*'
pull_request:
branches: [main, develop]
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
env:
REGISTRY: registry.cn-hangzhou.aliyuncs.com
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
name: Build Project
runs-on: ubuntu-latest
timeout-minutes: 30
outputs:
image_tag: ${{ steps.meta.outputs.tags }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Setup Turbo cache
uses: actions/cache@v4
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project
run: pnpm build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: build-artifacts
path: |
apps/**/dist
apps/**/.next
services/**/dist
packages/**/dist
retention-days: 7
docker:
name: Build and Push Docker Image
needs: build
runs-on: ubuntu-latest
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || github.ref == 'refs/heads/develop' || startsWith(github.ref, 'refs/tags/v'))
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: build-artifacts
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Aliyun Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.ALIYUN_REGISTRY_USERNAME }}
password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

75
.github/workflows/deploy-dev.yml vendored Normal file
View File

@ -0,0 +1,75 @@
name: Deploy to Development Environment
on:
push:
branches: [develop]
workflow_dispatch:
concurrency:
group: deploy-dev
cancel-in-progress: false
env:
ENVIRONMENT: development
REGISTRY: registry.cn-hangzhou.aliyuncs.com
IMAGE_NAME: ${{ github.repository }}
jobs:
deploy:
name: Deploy to Dev
runs-on: ubuntu-latest
environment:
name: development
url: https://dev.fischerx.com
timeout-minutes: 30
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup kubectl
uses: azure/setup-kubectl@v4
with:
version: 'v1.28.0'
- name: Configure Kubernetes credentials
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBE_CONFIG_DEV }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Verify Kubernetes connection
run: kubectl cluster-info
- name: Log in to Aliyun Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.ALIYUN_REGISTRY_USERNAME }}
password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }}
- name: Deploy to Kubernetes
run: |
echo "Deploying to development environment..."
# 这里可以添加具体的部署脚本,比如使用 kubectl apply 或 helm
# 示例:
# kubectl set image deployment/fischerx-api fischerx-api=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:develop
# kubectl rollout status deployment/fischerx-api
- name: Run database migrations
run: |
echo "Running database migrations..."
# 这里可以添加数据库迁移命令
- name: Health check
run: |
echo "Running health checks..."
# 这里可以添加健康检查脚本
- name: Send deployment notification
if: always()
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
text: 'Deployment to development environment ${{ job.status }}'
webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }}

99
.github/workflows/deploy-prod.yml vendored Normal file
View File

@ -0,0 +1,99 @@
name: Deploy to Production Environment
on:
push:
tags:
- 'v*-prod'
workflow_dispatch:
concurrency:
group: deploy-prod
cancel-in-progress: false
env:
ENVIRONMENT: production
REGISTRY: registry.cn-hangzhou.aliyuncs.com
IMAGE_NAME: ${{ github.repository }}
jobs:
approval:
name: Approval Required
runs-on: ubuntu-latest
environment:
name: production
url: https://fischerx.com
steps:
- name: Wait for approval
run: echo "Deployment waiting for manual approval..."
deploy:
name: Deploy to Production
needs: approval
runs-on: ubuntu-latest
environment:
name: production
url: https://fischerx.com
timeout-minutes: 60
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup kubectl
uses: azure/setup-kubectl@v4
with:
version: 'v1.28.0'
- name: Configure Kubernetes credentials
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBE_CONFIG_PROD }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Verify Kubernetes connection
run: kubectl cluster-info
- name: Log in to Aliyun Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.ALIYUN_REGISTRY_USERNAME }}
password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }}
- name: Extract tag version
id: extract_version
run: |
TAG=${GITHUB_REF#refs/tags/}
VERSION=${TAG%%-prod}
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
- name: Run pre-deployment checks
run: |
echo "Running pre-deployment checks..."
# 这里添加部署前检查
- name: Deploy to Kubernetes (Canary/Blue-Green)
run: |
echo "Deploying version ${{ steps.extract_version.outputs.VERSION }} to production..."
# 这里添加具体的部署脚本,支持 Canary 或 Blue-Green 部署
# 示例:
# kubectl set image deployment/fischerx-api fischerx-api=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.extract_version.outputs.VERSION }}
# kubectl rollout status deployment/fischerx-api
- name: Run database migrations
run: |
echo "Running database migrations..."
# 这里添加数据库迁移命令
- name: Health check
run: |
echo "Running health checks..."
# 这里添加健康检查脚本
- name: Send deployment notification
if: always()
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
text: 'Deployment to production environment ${{ job.status }}'
webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }}

88
.github/workflows/deploy-test.yml vendored Normal file
View File

@ -0,0 +1,88 @@
name: Deploy to Test Environment
on:
push:
tags:
- 'v*-test'
workflow_dispatch:
concurrency:
group: deploy-test
cancel-in-progress: false
env:
ENVIRONMENT: test
REGISTRY: registry.cn-hangzhou.aliyuncs.com
IMAGE_NAME: ${{ github.repository }}
jobs:
deploy:
name: Deploy to Test
runs-on: ubuntu-latest
environment:
name: test
url: https://test.fischerx.com
timeout-minutes: 45
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup kubectl
uses: azure/setup-kubectl@v4
with:
version: 'v1.28.0'
- name: Configure Kubernetes credentials
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBE_CONFIG_TEST }}" | base64 -d > ~/.kube/config
chmod 600 ~/.kube/config
- name: Verify Kubernetes connection
run: kubectl cluster-info
- name: Log in to Aliyun Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.ALIYUN_REGISTRY_USERNAME }}
password: ${{ secrets.ALIYUN_REGISTRY_PASSWORD }}
- name: Extract tag version
id: extract_version
run: |
TAG=${GITHUB_REF#refs/tags/}
VERSION=${TAG%%-test}
echo "VERSION=$VERSION" >> $GITHUB_OUTPUT
- name: Deploy to Kubernetes
run: |
echo "Deploying version ${{ steps.extract_version.outputs.VERSION }} to test environment..."
# 这里添加具体的部署脚本
# 示例:
# kubectl set image deployment/fischerx-api fischerx-api=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.extract_version.outputs.VERSION }}
# kubectl rollout status deployment/fischerx-api
- name: Run database migrations
run: |
echo "Running database migrations..."
# 这里添加数据库迁移命令
- name: Run integration tests
run: |
echo "Running integration tests..."
# 这里添加集成测试命令
- name: Health check
run: |
echo "Running health checks..."
# 这里添加健康检查脚本
- name: Send deployment notification
if: always()
uses: 8398a7/action-slack@v3
with:
status: ${{ job.status }}
text: 'Deployment to test environment ${{ job.status }}'
webhook_url: ${{ secrets.SLACK_WEBHOOK_URL }}

72
.github/workflows/lint.yml vendored Normal file
View File

@ -0,0 +1,72 @@
name: Lint and Code Quality Check
on:
push:
branches: [main, develop]
paths-ignore:
- '**/*.md'
- '**/*.gitignore'
- '**/*.gitattributes'
pull_request:
branches: [main, develop]
paths-ignore:
- '**/*.md'
- '**/*.gitignore'
- '**/*.gitattributes'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
lint:
name: Lint Check
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'pnpm'
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: TypeScript type check
run: pnpm exec tsc --noEmit
- name: ESLint check
run: pnpm lint
- name: Prettier format check
run: pnpm format --check
- name: Check commit messages (PR only)
if: github.event_name == 'pull_request'
uses: wagoid/commitlint-github-action@v5

20
.github/workflows/release-please.yml vendored Normal file
View File

@ -0,0 +1,20 @@
name: Release Please
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
steps:
- name: Release Please
uses: google-github-actions/release-please-action@v4
with:
token: ${{ secrets.GITHUB_TOKEN }}
release-type: node
package-name: fischerx

25
.github/workflows/semantic-pr.yml vendored Normal file
View File

@ -0,0 +1,25 @@
name: Semantic PR
on:
pull_request_target:
types:
- opened
- edited
- synchronize
- labeled
- unlabeled
permissions:
pull-requests: read
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
steps:
- name: Semantic Pull Request
uses: amannn/action-semantic-pull-request@v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
configPath: .github/semantic.yml

82
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,82 @@
name: Test and Coverage
on:
push:
branches: [main, develop]
paths-ignore:
- '**/*.md'
pull_request:
branches: [main, develop]
paths-ignore:
- '**/*.md'
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
test:
name: Run Tests
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
matrix:
node-version: [18, 20]
fail-fast: false
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 8
run_install: false
- name: Get pnpm store directory
id: pnpm-cache
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT
- name: Setup pnpm cache
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-cache.outputs.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build project for test
run: pnpm build
- name: Run tests with coverage
run: pnpm test -- --coverage
- name: Upload coverage to Codecov
if: matrix.node-version == 20
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
files: ./coverage/lcov.info
flags: unittests
name: codecov-umbrella
fail_ci_if_error: false
- name: Upload coverage artifacts
uses: actions/upload-artifact@v4
with:
name: coverage-report-${{ matrix.node-version }}
path: coverage/
retention-days: 30

54
.gitignore vendored Normal file
View File

@ -0,0 +1,54 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build outputs
dist/
build/
.next/
out/
# Testing
coverage/
.nyc_output/
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# Turbo
.turbo/
# Prisma
packages/core/prisma/migrations/
# Documentation generated
docs/api/generated/
# Temporary files
*.tmp
*.temp
.cache/

11
.prettierrc Normal file
View File

@ -0,0 +1,11 @@
{
"semi": true,
"trailingComma": "es5",
"singleQuote": false,
"printWidth": 80,
"tabWidth": 2,
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "always",
"endOfLine": "lf"
}

View File

@ -0,0 +1,282 @@
# FischerX 开发底座验收清单
## 第一阶段验收标准:基础架构搭建
### 项目架构验收
- [ ] Monorepo项目结构完整创建apps、packages、services、infra、docs、tools目录
- [ ] pnpm工作空间配置正确依赖管理正常
- [ ] Turborepo构建系统配置正确增量构建工作正常
- [ ] ESLint、Prettier、TypeScript配置正确代码规范检查通过
- [ ] README.md文档完整包含项目介绍、快速开始、开发指南
### 前端应用验收
- [ ] Next.js 14+ Web应用创建成功App Router配置正确
- [ ] TypeScript配置正确类型检查通过npx tsc --noEmit
- [ ] Tailwind CSS配置正确样式系统工作正常
- [ ] Shadcn UI组件库集成成功组件可正常使用
- [ ] Zustand状态管理配置正确状态管理功能正常
- [ ] React Query配置正确数据获取功能正常
- [ ] Vitest和Playwright测试框架配置正确测试可正常运行
- [ ] 前端应用可正常启动npm run dev页面可正常访问
### 后端API服务验收
- [ ] NestJS API服务创建成功服务可正常启动
- [ ] Prisma ORM配置正确数据库连接正常
- [ ] 数据库Schema设计完整用户、权限、文件等基础表
- [ ] API路由和控制器结构清晰符合RESTful规范
- [ ] JWT认证中间件配置正确Token生成和验证正常
- [ ] Redis缓存连接正常缓存功能可用
- [ ] 后端服务可正常启动npm run start:devAPI可正常访问
### 共享包验收
- [ ] packages/core核心业务逻辑包创建成功
- [ ] packages/ui共享UI组件包创建成功组件可复用
- [ ] packages/utils工具函数包创建成功函数可复用
- [ ] packages/types类型定义包创建成功类型可共享
- [ ] packages/config配置管理包创建成功配置可共享
- [ ] packages/constants常量定义包创建成功常量可共享
### 用户管理模块验收
- [ ] 用户注册功能正常(手机号、邮箱注册)
- [ ] 用户登录功能正常(验证码登录、密码登录)
- [ ] 用户信息管理功能正常(查询、更新、删除)
- [ ] 用户头像上传和管理功能正常
- [ ] 用户管理前端页面正常显示,功能完整
- [ ] 用户管理单元测试通过,覆盖率>80%
### 认证授权模块验收
- [ ] JWT Token生成和验证功能正常
- [ ] Session管理功能正常创建、刷新、销毁
- [ ] 手机号验证码登录功能正常(阿里云短信集成)
- [ ] 微信OAuth登录功能正常
- [ ] 支付宝OAuth登录功能正常
- [ ] 实名认证功能正常(阿里云实名认证集成)
- [ ] 登录注册前端页面正常显示,功能完整
- [ ] 认证授权单元测试通过,覆盖率>80%
### 权限控制模块验收
- [ ] RBAC权限模型设计完整角色、权限、资源
- [ ] 角色管理功能正常(创建、更新、删除)
- [ ] 权限分配功能正常
- [ ] 权限验证中间件工作正常,未授权访问被拒绝
- [ ] 动态权限检查功能正常
- [ ] 权限管理前端页面正常显示,功能完整
- [ ] 权限控制单元测试通过,覆盖率>80%
### 文件存储模块验收
- [ ] 阿里云OSS存储适配器实现正确上传下载功能正常
- [ ] 腾讯云COS存储适配器实现正确备选方案
- [ ] MinIO私有化存储适配器实现正确备选方案
- [ ] 文件上传功能正常(单文件、多文件)
- [ ] 文件下载和删除功能正常
- [ ] 图片处理功能正常(压缩、裁剪、水印)
- [ ] CDN加速配置正确访问速度优化
- [ ] 文件管理前端页面正常显示,功能完整
- [ ] 文件存储单元测试通过,覆盖率>80%
### 基础设施验收
- [ ] Docker配置文件正确容器可正常构建和运行
- [ ] Kubernetes配置文件正确Pod可正常部署
- [ ] 阿里云ACK集群配置正确集群可正常访问
- [ ] 阿里云RDS PostgreSQL数据库配置正确数据库可正常访问
- [ ] 阿里云Redis缓存配置正确缓存可正常访问
- [ ] 阿里云OSS对象存储配置正确存储可正常访问
- [ ] 阿里云CDN加速配置正确加速功能正常
- [ ] 阿里云DNS解析配置正确域名解析正常
### CI/CD流程验收
- [ ] 阿里云云效CI/CD流程配置正确流程可正常运行
- [ ] 代码规范检查流程正常,检查结果准确
- [ ] 单元测试流程正常,测试结果准确
- [ ] 构建流程正常,构建产物正确
- [ ] 自动部署流程正常,部署成功
- [ ] 代码提交钩子正常,钩子触发正确
### 开发文档验收
- [ ] 架构设计文档完整,包含架构图和说明
- [ ] 快速开始指南完整,开发者可快速上手
- [ ] 开发规范文档完整,规范清晰明确
- [ ] API接口文档完整接口说明清晰
- [ ] 部署指南完整,部署步骤清晰
- [ ] 常见问题解答完整,问题覆盖全面
- [ ] 底座使用方式指南完整,使用方式清晰明确
### CLI工具验收
- [ ] CLI工具框架创建成功工具可正常运行
- [ ] create-app命令功能正常新应用创建成功
- [ ] init命令功能正常独立项目初始化成功
- [ ] deploy-service命令功能正常共享服务部署成功
- [ ] update命令功能正常底座版本更新成功
- [ ] generate-module命令功能正常业务模块生成成功
- [ ] 项目模板创建完整standalone、monorepo、lightweight
- [ ] CLI工具文档完整使用指南清晰
### 底座使用方式验收
- [ ] Monorepo模式使用正常新应用可正常创建和运行
- [ ] 独立项目模式使用正常,独立项目可正常创建和运行
- [ ] API服务模式使用正常共享服务可正常部署和调用
- [ ] 核心功能共享策略明确,共享功能划分清晰
- [ ] 版本管理和更新策略完善,更新流程清晰
- [ ] 配置化驱动功能正常,配置定制功能可用
## 第二阶段验收标准:业务模块开发
### 支付系统模块验收
- [ ] 微信支付集成正确扫码支付、H5支付、小程序支付功能正常
- [ ] 支付宝支付集成正确扫码支付、H5支付功能正常
- [ ] 银联支付集成正确(备选方案)
- [ ] 订单管理功能正常(创建、查询、状态管理)
- [ ] 退款处理功能正常
- [ ] 支付回调处理正确,回调验证成功
- [ ] 支付管理前端页面正常显示,功能完整
- [ ] 支付系统单元测试通过,覆盖率>80%
### 消息通知模块验收
- [ ] 阿里云短信服务集成正确,验证码、通知短信发送正常
- [ ] 邮件通知功能正常,邮件发送成功
- [ ] 小程序推送通知功能正常,推送成功
- [ ] App推送通知功能正常阿里云移动推送推送成功
- [ ] 站内消息功能正常,消息发送和接收正常
- [ ] 消息通知管理前端页面正常显示,功能完整
- [ ] 消息通知单元测试通过,覆盖率>80%
### 内容管理模块验收
- [ ] 内容发布功能正常(文章、图片、视频)
- [ ] 内容审核功能正常(阿里云内容审核集成),审核结果准确
- [ ] 评论管理功能正常,评论发布和管理正常
- [ ] 标签分类功能正常,标签创建和管理正常
- [ ] 内容搜索功能正常,搜索结果准确
- [ ] 内容管理前端页面正常显示,功能完整
- [ ] 内容管理单元测试通过,覆盖率>80%
### 订单系统模块验收
- [ ] 订单创建功能正常,订单创建成功
- [ ] 订单状态管理功能正常,状态流转正确
- [ ] 订单查询功能正常,查询结果准确
- [ ] 订单统计功能正常,统计数据准确
- [ ] 订单管理前端页面正常显示,功能完整
- [ ] 订单系统单元测试通过,覆盖率>80%
### 第三方服务集成验收
- [ ] 高德地图服务集成正确,地图功能正常
- [ ] 百度AI服务集成正确OCR、人脸识别AI功能正常
- [ ] 阿里云AI服务集成正确备选方案
- [ ] 实名认证服务集成正确(阿里云、腾讯云),认证功能正常
- [ ] OCR识别服务集成正确身份证、银行卡识别功能正常
- [ ] 第三方服务集成单元测试通过,覆盖率>80%
### 监控告警系统验收
- [ ] 阿里云ARMS应用监控配置正确监控数据准确
- [ ] Prometheus + Grafana监控配置正确备选方案
- [ ] Sentry错误追踪配置正确错误追踪正常
- [ ] 性能指标监控正常,监控数据准确
- [ ] 业务指标监控正常,监控数据准确
- [ ] 告警规则配置正确,告警触发正常
- [ ] 监控仪表盘前端页面正常显示,数据可视化清晰
### 日志服务验收
- [ ] 阿里云SLS日志服务配置正确日志收集正常
- [ ] 日志收集功能正常,日志完整收集
- [ ] 日志分析功能正常,日志查询和统计准确
- [ ] 日志告警功能正常,告警触发正确
- [ ] 日志留存策略配置正确,留存时间>6个月
### 测试完善验收
- [ ] 前端单元测试覆盖率>80%
- [ ] 后端单元测试覆盖率>80%
- [ ] 集成测试通过,测试结果准确
- [ ] E2E测试通过测试结果准确
- [ ] 测试报告生成正常,报告清晰完整
- [ ] 测试覆盖率报告生成正常,覆盖率数据准确
## 第三阶段验收标准:优化和上线
### 性能优化验收
- [ ] 前端性能优化完成,首屏加载时间<3秒
- [ ] 后端性能优化完成API响应时间<200ms
- [ ] CDN加速优化完成静态资源加载速度提升
- [ ] 负载均衡配置正确,流量分发正常
- [ ] 数据库索引优化完成,查询性能提升
- [ ] 性能测试通过,性能指标达标
- [ ] 压力测试通过,系统稳定性达标
### 安全加固验收
- [ ] 安全审计完成,无高危漏洞
- [ ] 漏洞扫描完成,漏洞已修复
- [ ] 数据加密完成,敏感数据加密存储
- [ ] SQL注入防护完成注入攻击被阻止
- [ ] XSS防护完成XSS攻击被阻止
- [ ] CSRF防护完成CSRF攻击被阻止
- [ ] API访问频率限制完成频率限制正常
### 合规性检查验收
- [ ] ICP备案申请完成备案成功
- [ ] 数据本地化存储配置完成,数据存储在国内
- [ ] 实名认证流程完善,认证流程合规
- [ ] 内容审核流程完善,审核流程合规
- [ ] 数据安全法合规检查完成,合规达标
- [ ] 个人信息保护法合规检查完成,合规达标
### 生产环境部署验收
- [ ] 生产环境资源配置完成,资源充足
- [ ] 生产环境配置文件准备完成,配置正确
- [ ] 数据迁移脚本编写完成,迁移脚本正确
- [ ] 灰度发布配置完成,灰度发布正常
- [ ] 全量上线部署完成,部署成功
- [ ] 上线后验证测试通过,功能正常
### 运维体系验收
- [ ] 监控告警完善,监控覆盖全面
- [ ] 日志分析系统完善,日志分析准确
- [ ] 故障响应流程建立,流程清晰明确
- [ ] 备份恢复机制建立,备份恢复正常
- [ ] 运维文档编写完成,文档完整清晰
- [ ] 运维培训完成,培训效果良好
## 整体验收标准
### 功能完整性验收
- [ ] 所有核心功能模块实现完整,功能可用
- [ ] 所有业务功能模块实现完整,功能可用
- [ ] 所有第三方服务集成完整,服务可用
- [ ] 所有前端页面实现完整,页面可用
### 性能指标验收
- [ ] 前端首屏加载时间<3秒
- [ ] API响应时间<200msP95
- [ ] 系统吞吐量>1000 QPS
- [ ] 系统可用性>99.9%
### 安全性验收
- [ ] 无高危安全漏洞
- [ ] 数据加密存储
- [ ] 访问控制完善
- [ ] 安全审计通过
### 合规性验收
- [ ] ICP备案完成
- [ ] 数据本地化存储
- [ ] 实名认证合规
- [ ] 内容审核合规
- [ ] 数据安全法合规
- [ ] 个人信息保护法合规
### 文档完整性验收
- [ ] 架构设计文档完整
- [ ] API文档完整
- [ ] 开发指南完整
- [ ] 部署指南完整
- [ ] 运维文档完整
### 测试覆盖率验收
- [ ] 前端单元测试覆盖率>80%
- [ ] 后端单元测试覆盖率>80%
- [ ] 集成测试覆盖完整
- [ ] E2E测试覆盖完整
### 可维护性验收
- [ ] 代码规范统一
- [ ] 模块划分清晰
- [ ] 文档完善
- [ ] 监控告警完善
- [ ] 日志系统完善

View File

@ -0,0 +1,333 @@
# FischerX 开发底座初始化 Spec
## Why
Fischer公司需要一个适配国内运行环境的开发底座类似John Rush的Mars Stack/Mars Foundation理念用于快速构建业务模块降低开发成本提升开发效率。当前项目为全新项目需要先明确整体架构和未来使用方式然后根据国内环境逐一完善功能模块。
## What Changes
- 创建完整的Monorepo项目架构
- 搭建基础开发框架(前端、后端、数据库)
- 实现核心基础模块(用户管理、认证授权、权限控制、文件存储)
- 集成国内云服务和第三方服务(阿里云、微信支付、支付宝等)
- 建立完整的开发文档和规范体系
- 配置CI/CD流程和基础设施
## Impact
- Affected specs: 全新项目无现有spec
- Affected code: 全新项目,需要从零开始构建
## ADDED Requirements
### Requirement: 项目架构设计
系统SHALL采用Monorepo架构支持多应用、多包的统一管理。
#### Scenario: Monorepo架构搭建
- **WHEN** 项目初始化时
- **THEN** 系统应创建以下目录结构:
- apps/应用层web、admin、mobile、miniapp
- packages/共享包core、ui、utils、types、config
- services/后端服务api、worker、realtime
- infra/:基础设施配置
- docs/:文档目录
- tools/开发工具包含CLI工具
#### Scenario: 技术栈选择
- **WHEN** 选择技术栈时
- **THEN** 系统应采用以下技术组合推荐Node.js全栈方案
- 前端Next.js 14+ + TypeScript + Tailwind CSS + Shadcn UI
- 后端NestJS + Prisma + PostgreSQL
- 缓存Redis
- 消息队列RabbitMQ
- 存储阿里云OSS
- 部署Docker + Kubernetes + 阿里云ACK
### Requirement: 底座使用方式设计
系统SHALL提供灵活的底座使用方式支持多种业务场景。
#### Scenario: Monorepo模式使用适合同一团队的多项目
- **WHEN** Fischer公司内部开发新业务系统时
- **THEN** 系统应支持:
- 在FischerX Monorepo中创建新应用apps/<app-name>
- 自动继承packages中的共享包core、ui、utils等
- 共享基础设施配置和CI/CD流程
- 统一维护和升级核心功能
#### Scenario: 独立项目模式使用(适合不同客户的项目)
- **WHEN** 为客户开发定制系统时
- **THEN** 系统应支持:
- 从FischerX模板创建独立项目
- 复制核心包到新项目packages/core、packages/ui等
- 自由定制业务功能,不影响其他项目
- 选择性更新底座功能
#### Scenario: API服务模式使用适合轻量级集成
- **WHEN** 需要快速集成底座功能时
- **THEN** 系统应支持:
- 部署FischerX作为共享服务auth-service、file-service等
- 新业务系统通过API调用底座功能
- 使用客户端SDK简化集成@fischerx/client-sdk
- 统一维护底座服务,自动升级
#### Scenario: CLI工具支持
- **WHEN** 开发者需要创建新项目或应用时
- **THEN** 系统应提供CLI工具fischerx-cli支持
- 创建新应用:`fischerx-cli create-app <app-name> --type=web|admin|mobile|miniapp`
- 初始化独立项目:`fischerx-cli init <project-name> --template=standalone|monorepo|lightweight`
- 部署共享服务:`fischerx-cli deploy-service --services=<service-list>`
- 更新底座版本:`fischerx-cli update --scope=core|ui|all`
- 生成业务模块:`fischerx-cli generate-module <module-name> --template=crud|cms|ecommerce`
#### Scenario: 核心功能共享策略
- **WHEN** 设计底座功能模块时
- **THEN** 系统应明确区分:
- **核心共享功能**packages/core用户管理、认证授权、权限控制、文件存储、消息通知
- **可选共享功能**packages/ui、packages/utilsUI组件、工具函数、类型定义
- **业务独立功能**apps/<app-name>/src业务逻辑、特定UI、定制功能
- **配置化驱动**:通过配置而非代码实现业务定制
#### Scenario: 版本管理和更新策略
- **WHEN** 底座功能更新时
- **THEN** 系统应支持:
- Monorepo模式自动更新所有应用
- 独立项目模式选择性更新通过CLI工具
- API服务模式自动更新所有调用方
- 版本兼容性检查和迁移指南
### Requirement: 用户管理模块
系统SHALL提供完整的用户管理功能支持国内主流认证方式。
#### Scenario: 用户注册登录
- **WHEN** 用户需要注册或登录时
- **THEN** 系统应支持以下认证方式:
- 手机号验证码登录(阿里云短信)
- 微信登录OAuth
- 支付宝登录OAuth
- 传统邮箱密码登录
#### Scenario: 实名认证
- **WHEN** 用户需要进行实名认证时
- **THEN** 系统应集成阿里云实名认证服务,支持:
- 身份证信息验证
- 人脸识别验证
- 身份证OCR识别
### Requirement: 权限控制模块
系统SHALL提供基于RBAC的权限控制机制。
#### Scenario: 角色权限管理
- **WHEN** 管理员需要配置权限时
- **THEN** 系统应支持:
- 角色创建和管理
- 权限分配和继承
- 资源访问控制
- 动态权限检查
#### Scenario: 权限验证
- **WHEN** 用户访问受保护资源时
- **THEN** 系统应验证用户权限,拒绝未授权访问
### Requirement: 认证授权模块
系统SHALL提供安全的认证授权机制。
#### Scenario: JWT Token管理
- **WHEN** 用户登录成功时
- **THEN** 系统应生成JWT Token包含
- 用户基本信息
- 权限信息
- 过期时间
- 签名验证
#### Scenario: Session管理
- **WHEN** 用户活跃时
- **THEN** 系统应管理用户会话,支持:
- 会话创建和销毁
- 会话刷新
- 多设备登录管理
- 登录状态同步
### Requirement: 文件存储模块
系统SHALL提供统一的文件存储服务适配国内云存储。
#### Scenario: 对象存储集成
- **WHEN** 用户上传文件时
- **THEN** 系统应支持:
- 阿里云OSS存储
- 腾讯云COS存储备选
- MinIO私有化部署备选
- 文件上传、下载、删除
- 图片处理(压缩、裁剪、水印)
- CDN加速
#### Scenario: 文件访问控制
- **WHEN** 用户访问文件时
- **THEN** 系统应验证访问权限生成临时访问URL
### Requirement: 支付系统模块
系统SHALL集成国内主流支付渠道。
#### Scenario: 微信支付集成
- **WHEN** 用户选择微信支付时
- **THEN** 系统应支持:
- 扫码支付
- H5支付
- 小程序支付
- 订单创建和查询
- 退款处理
#### Scenario: 支付宝支付集成
- **WHEN** 用户选择支付宝支付时
- **THEN** 系统应支持:
- 扫码支付
- H5支付
- 订单创建和查询
- 退款处理
### Requirement: 消息通知模块
系统SHALL提供多渠道消息通知服务。
#### Scenario: 短信通知
- **WHEN** 需要发送短信通知时
- **THEN** 系统应集成阿里云短信服务,支持:
- 验证码发送
- 通知短信发送
- 营销短信发送(需审核)
- 发送记录查询
#### Scenario: 推送通知
- **WHEN** 需要发送推送通知时
- **THEN** 系统应支持:
- 小程序推送
- App推送阿里云移动推送
- 站内消息推送
### Requirement: 内容审核模块
系统SHALL提供内容审核服务满足国内合规要求。
#### Scenario: 文本审核
- **WHEN** 用户提交文本内容时
- **THEN** 系统应调用阿里云内容审核服务,检测:
- 敏感词过滤
- 政治敏感内容
- 色情暴力内容
- 广告垃圾内容
#### Scenario: 图片审核
- **WHEN** 用户上传图片时
- **THEN** 系统应调用阿里云图片审核服务,检测:
- 色情图片
- 暴力图片
- 政治敏感图片
- 广告图片
### Requirement: 监控告警模块
系统SHALL提供完善的监控告警体系。
#### Scenario: 性能监控
- **WHEN** 系统运行时
- **THEN** 系统应监控:
- 应用性能指标(响应时间、吞吐量)
- 资源使用情况CPU、内存、磁盘
- 错误率和异常情况
- 用户行为数据
#### Scenario: 告警通知
- **WHEN** 监控指标异常时
- **THEN** 系统应发送告警通知:
- 邀请相关人员
- 提供告警详情
- 建议处理方案
### Requirement: 开发文档体系
系统SHALL提供完整的开发文档。
#### Scenario: 架构文档
- **WHEN** 开发者需要了解架构时
- **THEN** 系统应提供:
- 整体架构设计文档
- 模块划分说明
- 技术选型说明
- 数据流图
#### Scenario: API文档
- **WHEN** 开发者需要调用API时
- **THEN** 系统应提供:
- API接口文档
- 参数说明
- 返回值说明
- 错误码说明
- 示例代码
#### Scenario: 开发指南
- **WHEN** 新开发者加入时
- **THEN** 系统应提供:
- 快速开始指南
- 开发规范文档
- 最佳实践指南
- 常见问题解答
### Requirement: CI/CD流程
系统SHALL建立自动化CI/CD流程。
#### Scenario: 代码提交
- **WHEN** 开发者提交代码时
- **THEN** 系统应自动执行:
- 代码规范检查
- 类型检查
- 单元测试
- 构建验证
#### Scenario: 自动部署
- **WHEN** 代码合并到主分支时
- **THEN** 系统应自动部署:
- 开发环境部署
- 测试环境部署
- 生产环境部署(需审批)
### Requirement: 国内环境适配
系统SHALL全面适配国内运行环境。
#### Scenario: 云服务适配
- **WHEN** 使用云服务时
- **THEN** 系统应优先使用国内云服务:
- 阿里云(首选)
- 腾讯云(备选)
- 华为云(备选)
#### Scenario: 网络环境适配
- **WHEN** 部署应用时
- **THEN** 系统应优化国内网络:
- 使用国内CDN加速
- 配置国内DNS解析
- 多地域部署(华北、华东、华南)
#### Scenario: 合规性适配
- **WHEN** 系统上线时
- **THEN** 系统应满足国内合规要求:
- ICP备案
- 数据本地化存储
- 实名认证要求
- 内容审核要求
- 数据安全法要求
## MODIFIED Requirements
无修改需求(全新项目)
## REMOVED Requirements
无删除需求(全新项目)

View File

@ -0,0 +1,248 @@
# Tasks
## 第一阶段基础架构搭建1-2个月
- [x] Task 1: 项目初始化和Monorepo架构搭建
- [x] SubTask 1.1: 创建Monorepo项目结构apps、packages、services、infra、docs、tools目录
- [x] SubTask 1.2: 配置pnpm工作空间和Turborepo构建系统
- [x] SubTask 1.3: 初始化package.json和基础配置文件
- [x] SubTask 1.4: 配置ESLint、Prettier、TypeScript等开发工具
- [x] SubTask 1.5: 创建README.md和基础文档结构
- [x] Task 2: 前端应用框架搭建
- [x] SubTask 2.1: 创建Next.js 14+ Web应用apps/web
- [x] SubTask 2.2: 配置TypeScript和Tailwind CSS
- [x] SubTask 2.3: 集成Shadcn UI组件库
- [x] SubTask 2.4: 配置Zustand状态管理和React Query
- [x] SubTask 2.5: 创建基础页面布局和路由结构
- [x] SubTask 2.6: 配置Vitest和Playwright测试框架
- [x] Task 3: 后端API服务框架搭建
- [x] SubTask 3.1: 创建NestJS API服务services/api
- [x] SubTask 3.2: 配置Prisma ORM和数据库连接
- [x] SubTask 3.3: 设计数据库Schema用户、权限、文件等基础表
- [x] SubTask 3.4: 创建基础API路由和控制器结构
- [x] SubTask 3.5: 配置JWT认证中间件
- [x] SubTask 3.6: 配置Redis缓存连接
- [x] Task 4: 共享包开发
- [x] SubTask 4.1: 创建核心业务逻辑包packages/core
- [x] SubTask 4.2: 创建共享UI组件包packages/ui
- [x] SubTask 4.3: 创建工具函数包packages/utils
- [x] SubTask 4.4: 创建类型定义包packages/types
- [x] SubTask 4.5: 创建配置管理包packages/config
- [x] SubTask 4.6: 创建常量定义包packages/constants
- [x] Task 5: 用户管理模块开发
- [x] SubTask 5.1: 实现用户注册功能(手机号、邮箱)
- [x] SubTask 5.2: 实现用户登录功能(验证码、密码)
- [x] SubTask 5.3: 实现用户信息管理(查询、更新、删除)
- [x] SubTask 5.4: 实现用户头像上传和管理
- [x] SubTask 5.5: 创建用户管理前端页面
- [x] SubTask 5.6: 编写用户管理单元测试
- [x] Task 6: 认证授权模块开发
- [x] SubTask 6.1: 实现JWT Token生成和验证
- [x] SubTask 6.2: 实现Session管理创建、刷新、销毁
- [x] SubTask 6.3: 实现手机号验证码登录(集成阿里云短信)
- [x] SubTask 6.4: 实现微信OAuth登录
- [x] SubTask 6.5: 实现支付宝OAuth登录
- [x] SubTask 6.6: 实现实名认证功能(集成阿里云实名认证)
- [x] SubTask 6.7: 创建登录注册前端页面
- [x] SubTask 6.8: 编写认证授权单元测试
- [x] Task 7: 权限控制模块开发
- [x] SubTask 7.1: 设计RBAC权限模型角色、权限、资源
- [x] SubTask 7.2: 实现角色管理功能(创建、更新、删除)
- [x] SubTask 7.3: 实现权限分配功能
- [x] SubTask 7.4: 实现权限验证中间件
- [x] SubTask 7.5: 实现动态权限检查
- [x] SubTask 7.6: 创建权限管理前端页面
- [x] SubTask 7.7: 编写权限控制单元测试
- [x] Task 8: 文件存储模块开发
- [x] SubTask 8.1: 实现阿里云OSS存储适配器
- [x] SubTask 8.2: 实现腾讯云COS存储适配器备选
- [x] SubTask 8.3: 实现MinIO私有化存储适配器备选
- [x] SubTask 8.4: 实现文件上传功能(单文件、多文件)
- [x] SubTask 8.5: 实现文件下载和删除功能
- [x] SubTask 8.6: 实现图片处理功能(压缩、裁剪、水印)
- [x] SubTask 8.7: 实现CDN加速配置
- [x] SubTask 8.8: 创建文件管理前端页面
- [x] SubTask 8.9: 编写文件存储单元测试
- [ ] Task 9: 基础设施搭建
- [ ] SubTask 9.1: 创建Docker配置文件Dockerfile、docker-compose.yml
- [ ] SubTask 9.2: 创建Kubernetes配置文件Deployment、Service、ConfigMap
- [ ] SubTask 9.3: 配置阿里云ACK集群
- [ ] SubTask 9.4: 配置阿里云RDS PostgreSQL数据库
- [ ] SubTask 9.5: 配置阿里云Redis缓存
- [ ] SubTask 9.6: 配置阿里云OSS对象存储
- [ ] SubTask 9.7: 配置阿里云CDN加速
- [ ] SubTask 9.8: 配置阿里云DNS解析
- [ ] Task 10: CI/CD流程配置
- [ ] SubTask 10.1: 配置阿里云云效CI/CD流程
- [ ] SubTask 10.2: 创建代码规范检查流程ESLint、TypeScript
- [ ] SubTask 10.3: 创建单元测试流程
- [ ] SubTask 10.4: 创建构建流程
- [ ] SubTask 10.5: 创建自动部署流程(开发、测试、生产环境)
- [ ] SubTask 10.6: 配置代码提交钩子pre-commit、pre-push
- [ ] Task 11: CLI工具开发
- [ ] SubTask 11.1: 创建CLI工具框架tools/cli
- [ ] SubTask 11.2: 实现create-app命令创建新应用
- [ ] SubTask 11.3: 实现init命令初始化独立项目
- [ ] SubTask 11.4: 实现deploy-service命令部署共享服务
- [ ] SubTask 11.5: 实现update命令更新底座版本
- [ ] SubTask 11.6: 实现generate-module命令生成业务模块
- [ ] SubTask 11.7: 创建项目模板standalone、monorepo、lightweight
- [ ] SubTask 11.8: 编写CLI工具文档和使用指南
- [ ] Task 12: 开发文档编写
- [ ] SubTask 12.1: 编写架构设计文档docs/architecture
- [ ] SubTask 12.2: 编写快速开始指南docs/development/quick-start.md
- [ ] SubTask 12.3: 编写开发规范文档docs/development/standards.md
- [ ] SubTask 12.4: 编写API接口文档docs/api
- [ ] SubTask 12.5: 编写部署指南docs/deployment
- [ ] SubTask 12.6: 编写常见问题解答docs/FAQ.md
- [ ] SubTask 12.7: 编写底座使用方式指南docs/usage-guide.md
## 第二阶段业务模块开发2-3个月
- [ ] Task 12: 支付系统模块开发
- [ ] SubTask 12.1: 实现微信支付集成扫码支付、H5支付、小程序支付
- [ ] SubTask 12.2: 实现支付宝支付集成扫码支付、H5支付
- [ ] SubTask 12.3: 实现银联支付集成(备选)
- [ ] SubTask 12.4: 实现订单管理功能(创建、查询、状态管理)
- [ ] SubTask 12.5: 实现退款处理功能
- [ ] SubTask 12.6: 实现支付回调处理
- [ ] SubTask 12.7: 创建支付管理前端页面
- [ ] SubTask 12.8: 编写支付系统单元测试
- [ ] Task 13: 消息通知模块开发
- [ ] SubTask 13.1: 实现阿里云短信服务集成(验证码、通知短信)
- [ ] SubTask 13.2: 实现邮件通知功能
- [ ] SubTask 13.3: 实现小程序推送通知
- [ ] SubTask 13.4: 实现App推送通知阿里云移动推送
- [ ] SubTask 13.5: 实现站内消息功能
- [ ] SubTask 13.6: 创建消息通知管理前端页面
- [ ] SubTask 13.7: 编写消息通知单元测试
- [ ] Task 14: 内容管理模块开发
- [ ] SubTask 14.1: 实现内容发布功能(文章、图片、视频)
- [ ] SubTask 14.2: 实现内容审核功能(集成阿里云内容审核)
- [ ] SubTask 14.3: 实现评论管理功能
- [ ] SubTask 14.4: 实现标签分类功能
- [ ] SubTask 14.5: 实现内容搜索功能
- [ ] SubTask 14.6: 创建内容管理前端页面
- [ ] SubTask 14.7: 编写内容管理单元测试
- [ ] Task 15: 订单系统模块开发
- [ ] SubTask 15.1: 实现订单创建功能
- [ ] SubTask 15.2: 实现订单状态管理(待支付、已支付、已完成、已取消)
- [ ] SubTask 15.3: 实现订单查询功能(用户订单、商家订单)
- [ ] SubTask 15.4: 实现订单统计功能(销售额、订单量)
- [ ] SubTask 15.5: 创建订单管理前端页面
- [ ] SubTask 15.6: 编写订单系统单元测试
- [ ] Task 16: 第三方服务集成
- [ ] SubTask 16.1: 实现高德地图服务集成
- [ ] SubTask 16.2: 实现百度AI服务集成OCR、人脸识别
- [ ] SubTask 16.3: 实现阿里云AI服务集成备选
- [ ] SubTask 16.4: 实现实名认证服务集成(阿里云、腾讯云)
- [ ] SubTask 16.5: 实现OCR识别服务集成身份证、银行卡
- [ ] SubTask 16.6: 编写第三方服务集成单元测试
- [ ] Task 17: 监控告警系统搭建
- [ ] SubTask 17.1: 配置阿里云ARMS应用监控
- [ ] SubTask 17.2: 配置Prometheus + Grafana监控备选
- [ ] SubTask 17.3: 配置Sentry错误追踪
- [ ] SubTask 17.4: 实现性能指标监控(响应时间、吞吐量)
- [ ] SubTask 17.5: 实现业务指标监控(用户量、订单量、支付量)
- [ ] SubTask 17.6: 配置告警规则和通知渠道
- [ ] SubTask 17.7: 创建监控仪表盘前端页面
- [ ] Task 18: 日志服务搭建
- [ ] SubTask 18.1: 配置阿里云SLS日志服务
- [ ] SubTask 18.2: 实现日志收集功能(应用日志、系统日志)
- [ ] SubTask 18.3: 实现日志分析功能(日志查询、日志统计)
- [ ] SubTask 18.4: 实现日志告警功能
- [ ] SubTask 18.5: 配置日志留存策略6个月以上
- [ ] Task 19: 测试完善
- [ ] SubTask 19.1: 编写前端单元测试(覆盖率>80%
- [ ] SubTask 19.2: 编写后端单元测试(覆盖率>80%
- [ ] SubTask 19.3: 编写集成测试
- [ ] SubTask 19.4: 编写E2E测试
- [ ] SubTask 19.5: 配置测试报告生成
- [ ] SubTask 19.6: 配置测试覆盖率报告
## 第三阶段优化和上线1-2个月
- [ ] Task 20: 性能优化
- [ ] SubTask 20.1: 前端性能优化(代码分割、懒加载、图片优化)
- [ ] SubTask 20.2: 后端性能优化(缓存优化、数据库查询优化)
- [ ] SubTask 20.3: CDN加速优化
- [ ] SubTask 20.4: 负载均衡配置
- [ ] SubTask 20.5: 数据库索引优化
- [ ] SubTask 20.6: 性能测试和压力测试
- [ ] Task 21: 安全加固
- [ ] SubTask 21.1: 安全审计(代码审计、配置审计)
- [ ] SubTask 21.2: 漏洞扫描和修复
- [ ] SubTask 21.3: 数据加密(敏感数据加密存储)
- [ ] SubTask 21.4: SQL注入防护
- [ ] SubTask 21.5: XSS防护
- [ ] SubTask 21.6: CSRF防护
- [ ] SubTask 21.7: API访问频率限制
- [ ] Task 22: 合规性检查
- [ ] SubTask 22.1: ICP备案申请
- [ ] SubTask 22.2: 数据本地化存储配置
- [ ] SubTask 22.3: 实名认证流程完善
- [ ] SubTask 22.4: 内容审核流程完善
- [ ] SubTask 22.5: 数据安全法合规检查
- [ ] SubTask 22.6: 个人信息保护法合规检查
- [ ] Task 23: 生产环境部署
- [ ] SubTask 23.1: 生产环境资源配置(服务器、数据库、缓存)
- [ ] SubTask 23.2: 生产环境配置文件准备
- [ ] SubTask 23.3: 数据迁移脚本编写
- [ ] SubTask 23.4: 灰度发布配置
- [ ] SubTask 23.5: 全量上线部署
- [ ] SubTask 23.6: 上线后验证测试
- [ ] Task 24: 运维体系建立
- [ ] SubTask 24.1: 监控告警完善
- [ ] SubTask 24.2: 日志分析系统完善
- [ ] SubTask 24.3: 故障响应流程建立
- [ ] SubTask 24.4: 备份恢复机制建立
- [ ] SubTask 24.5: 运维文档编写
- [ ] SubTask 24.6: 运维培训
# Task Dependencies
- [Task 2] depends on [Task 1]
- [Task 3] depends on [Task 1]
- [Task 4] depends on [Task 1]
- [Task 5] depends on [Task 2, Task 3, Task 4]
- [Task 6] depends on [Task 5]
- [Task 7] depends on [Task 5, Task 6]
- [Task 8] depends on [Task 3, Task 4]
- [Task 9] depends on [Task 1]
- [Task 10] depends on [Task 1, Task 9]
- [Task 11] depends on [Task 1]
- [Task 12] depends on [Task 5, Task 6, Task 9]
- [Task 13] depends on [Task 5, Task 9]
- [Task 14] depends on [Task 5, Task 8, Task 9]
- [Task 15] depends on [Task 12]
- [Task 16] depends on [Task 6, Task 9]
- [Task 17] depends on [Task 9]
- [Task 18] depends on [Task 9]
- [Task 19] depends on [Task 5, Task 6, Task 7, Task 8, Task 12, Task 13, Task 14, Task 15]
- [Task 20] depends on [Task 19]
- [Task 21] depends on [Task 19]
- [Task 22] depends on [Task 21]
- [Task 23] depends on [Task 20, Task 21, Task 22]
- [Task 24] depends on [Task 23]

49
CHANGELOG.md Normal file
View File

@ -0,0 +1,49 @@
# Changelog
## [0.1.0] - 2026-05-25
### Added
- 初始化项目结构
- Monorepo 架构 (Turborepo + pnpm)
- 前端应用 (Next.js 16 + Tailwind CSS)
- API 服务 (NestJS 11 + Prisma)
- 共享包 (types, constants, utils, config, ui, core)
- Docker Compose 配置
- 完整的开发文档
- 快速开始指南
- 项目结构说明
- 开发规范
- 最佳实践
- 常见问题解答
- 贡献指南
- 技术栈文档
- 迁移指南
- 架构文档
- 技术选型文档
- 架构决策记录 (ADR) 目录
### Changed
- 无
### Deprecated
- 无
### Removed
- 无
### Fixed
- 无
### Security
- 无
---
格式遵循 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/)
版本遵循 [Semantic Versioning](https://semver.org/lang/zh-CN/)

1279
FischerX开发计划.md Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

299
README.md Normal file
View File

@ -0,0 +1,299 @@
# FischerX
Fischer公司开发底座 — 适配国内运行环境的全栈Monorepo项目理念对标John Rush的Mars Stack/Mars Foundation。
## 技术栈
### 前端
- **框架**: Next.js 14+ (App Router)
- **语言**: TypeScript (严格模式)
- **样式**: Tailwind CSS
- **UI组件**: Shadcn UI
- **状态管理**: Zustand + React Query
- **表单**: React Hook Form + Zod
- **测试**: Vitest + Playwright
### 后端
- **框架**: NestJS
- **ORM**: Prisma
- **数据库**: PostgreSQL
- **缓存**: Redis
- **认证**: JWT + Passport
- **测试**: Jest
### 基础设施
- **容器化**: Docker + Docker Compose
- **编排**: Kubernetes (阿里云ACK)
- **IaC**: Terraform
- **CI/CD**: GitHub Actions
- **监控**: Prometheus + Grafana + Loki + Jaeger
### 工具链
- **包管理**: pnpm
- **构建系统**: Turborepo
- **代码规范**: ESLint + Prettier
- **CLI工具**: @fischerx/cli
## 核心模块
| 模块 | 说明 | 状态 |
|------|------|------|
| 用户管理 | 用户CRUD、头像上传 | ✅ 完成 |
| 认证授权 | JWT、Session、手机号登录、微信/支付宝OAuth、实名认证、MFA | ✅ 完成 |
| 权限控制 | RBAC模型、角色管理、权限分配、装饰器守卫 | ✅ 完成 |
| 文件存储 | 阿里云OSS、腾讯云COS、MinIO、图片处理、CDN | ✅ 完成 |
| 支付系统 | 支付宝、微信支付、银联适配器、退款、对账 | ✅ 完成 |
| 消息通知 | 邮件、短信、推送、站内信(适配器模式) | ✅ 完成 |
| 内容管理 | 文章、分类、标签、评论、版本控制 | ✅ 后端完成 |
| 监控告警 | Prometheus + Grafana + AlertManager + Loki + Jaeger | ✅ 完成 |
| CLI工具 | 项目初始化、代码生成、部署命令 | ✅ 完成 |
## 快速开始
### 环境要求
- Node.js >= 18.0.0
- pnpm >= 8.0.0
- Docker & Docker Compose
- PostgreSQL 15+
- Redis 7+
### 安装
```bash
# 克隆项目
git clone http://8.153.107.96/fischer/fischerX.git
cd fischerX
# 安装依赖
pnpm install
```
### 本地开发
```bash
# 1. 启动基础设施PostgreSQL + Redis
docker-compose up -d postgres redis
# 2. 配置环境变量
cp services/api/.env.example services/api/.env
# 编辑 .env 填入数据库连接、Redis连接、JWT密钥等
# 3. 运行数据库迁移
cd services/api
npx prisma migrate dev
cd ../..
# 4. 启动后端API服务
cd services/api
pnpm start:dev
# 5. 启动前端Web应用
cd apps/web
pnpm dev
```
### 访问地址
| 服务 | 地址 |
|------|------|
| 前端Web | http://localhost:3000 |
| 后端API | http://localhost:3001/api/v1/health |
| Grafana | http://localhost:3001 (admin/fischerx123) |
| Prometheus | http://localhost:9090 |
| Jaeger | http://localhost:16686 |
## 项目结构
```
FischerX/
├── apps/ # 应用层
│ ├── web/ # Web应用 (Next.js)
│ └── admin/ # 管理后台 (占位)
├── packages/ # 共享包
│ ├── core/ # 核心业务逻辑 (@fischerx/core)
│ ├── ui/ # 共享UI组件 (@fischerx/ui)
│ ├── utils/ # 工具函数 (@fischerx/utils)
│ ├── types/ # 类型定义 (@fischerx/types)
│ ├── config/ # 配置管理 (@fischerx/config)
│ └── constants/ # 常量定义 (@fischerx/constants)
├── services/ # 后端服务
│ └── api/ # API服务 (NestJS)
│ ├── prisma/ # 数据库Schema
│ └── src/modules/ # 业务模块
│ ├── auth/ # 认证授权
│ ├── rbac/ # 权限控制
│ ├── user/ # 用户管理
│ ├── file/ # 文件存储
│ ├── payment/ # 支付系统
│ ├── notification/ # 消息通知
│ ├── content/ # 内容管理
│ ├── health/ # 健康检查
│ ├── cache/ # 缓存服务
│ └── monitoring/ # 监控集成
├── infra/ # 基础设施
│ ├── db/ # 数据库Docker配置
│ ├── k8s/ # Kubernetes配置
│ ├── monitoring/ # 监控栈 (Prometheus/Grafana/Loki/Jaeger)
│ ├── terraform/ # 阿里云资源管理
│ └── envs/ # 多环境配置 (dev/test/prod)
├── tools/ # 开发工具
│ └── cli/ # CLI工具 (@fischerx/cli)
├── docs/ # 文档
│ ├── architecture/ # 架构文档
│ ├── requirements/ # 需求文档
│ ├── design/ # 设计文档
│ ├── development/ # 开发文档
│ ├── api/ # API文档
│ ├── testing/ # 测试文档
│ ├── deployment/ # 部署文档
│ ├── operations/ # 运维文档
│ ├── user/ # 用户文档
│ └── templates/ # 文档模板
├── .github/workflows/ # CI/CD (8个Workflow)
├── docker-compose.yml # Docker Compose
├── turbo.json # Turborepo配置
├── pnpm-workspace.yaml # pnpm工作空间
└── package.json # 根package.json
```
## API端点
### 认证授权
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/v1/auth/register | 用户注册 |
| POST | /api/v1/auth/login | 密码登录 |
| POST | /api/v1/auth/sms/send | 发送验证码 |
| POST | /api/v1/auth/sms/login | 验证码登录 |
| POST | /api/v1/auth/wechat/login | 微信OAuth登录 |
| POST | /api/v1/auth/alipay/login | 支付宝OAuth登录 |
| POST | /api/v1/auth/realname/verify | 实名认证 |
| POST | /api/v1/auth/mfa/enable | 启用MFA |
| POST | /api/v1/auth/mfa/verify | 验证MFA |
### 用户管理
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | /api/v1/users/me | 当前用户信息 |
| PUT | /api/v1/users/me | 更新用户信息 |
| POST | /api/v1/users/me/avatar | 上传头像 |
| GET | /api/v1/users | 用户列表 (管理员) |
### 权限控制
| 方法 | 路径 | 说明 |
|------|------|------|
| CRUD | /api/v1/roles | 角色管理 |
| CRUD | /api/v1/permissions | 权限管理 |
| POST | /api/v1/roles/:id/permissions | 分配权限 |
| POST | /api/v1/users/:id/roles | 分配角色 |
### 支付系统
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/v1/payment/orders | 创建支付订单 |
| GET | /api/v1/payment/orders/:id | 查询订单 |
| POST | /api/v1/payment/refunds | 申请退款 |
| POST | /api/v1/payment/callback/:channel | 支付回调 |
### 文件存储
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/v1/files/upload | 上传文件 |
| GET | /api/v1/files | 文件列表 |
| GET | /api/v1/files/:id | 文件详情 |
| DELETE | /api/v1/files/:id | 删除文件 |
### 消息通知
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | /api/v1/notifications | 发送通知 |
| GET | /api/v1/notifications | 通知列表 |
| PUT | /api/v1/notifications/:id/read | 标记已读 |
## 底座使用方式
### 方式一Monorepo模式推荐
在FischerX Monorepo中创建新应用自动继承共享包
```bash
fischerx create-app my-crm --type=admin
```
### 方式二:独立项目模式
从FischerX模板创建独立项目
```bash
fischerx init client-project --template=standalone
```
### 方式三API服务模式
部署FischerX作为共享服务通过API调用底座功能。
## CLI工具
```bash
# 安装
cd tools/cli && pnpm install && pnpm build
# 项目管理
fischerx init <project-name> # 初始化新项目
fischerx create-app <app-name> # 创建新应用
fischerx create-package <pkg-name> # 创建新共享包
# 代码生成
fischerx generate page <name> # 生成页面
fischerx generate component <name> # 生成组件
fischerx generate api <name> # 生成API
fischerx generate module <name> # 生成完整模块
# 部署
fischerx deploy --env dev # 部署到开发环境
fischerx deploy --env prod # 部署到生产环境
# 其他
fischerx doctor # 检查项目健康
fischerx info # 显示项目信息
```
## 国内环境适配
| 维度 | 适配方案 |
|------|---------|
| 云服务 | 阿里云(首选)、腾讯云(备选)、华为云(备选) |
| 认证 | 手机号验证码、微信OAuth、支付宝OAuth、实名认证 |
| 支付 | 微信支付、支付宝、银联 |
| 存储 | 阿里云OSS、腾讯云COS、MinIO私有化 |
| 短信 | 阿里云短信 |
| 邮件 | SMTP / 阿里云邮件 |
| 监控 | Prometheus + Grafana / 阿里云ARMS |
| 日志 | Loki / 阿里云SLS |
| 部署 | 阿里云ACK / 自建K8s |
| CDN | 阿里云CDN |
## 可用脚本
| 脚本 | 说明 |
|------|------|
| `pnpm install` | 安装所有依赖 |
| `pnpm build` | 构建所有包和应用 |
| `pnpm dev` | 启动开发服务器 |
| `pnpm lint` | 运行ESLint检查 |
| `pnpm test` | 运行所有测试 |
| `pnpm format` | 使用Prettier格式化代码 |
## 待完善功能
| 优先级 | 功能 | 说明 |
|--------|------|------|
| P0 | 订单系统模块 | 独立Order模块订单状态机 |
| P0 | 企业微信通知 | WeComChannelService适配器 |
| P0 | 邮件真实发送 | 集成Nodemailer/阿里云邮件 |
| P0 | 短信真实发送 | 集成阿里云短信SDK |
| P1 | 用户管理前端页面 | 用户列表/编辑/删除 |
| P1 | 通知中心前端页面 | 通知列表/设置 |
| P1 | 订单前端页面 | 订单列表/详情 |
| P2 | 支付真实SDK集成 | 支付宝/微信支付真实SDK |
| P2 | 内容审核 | 阿里云绿网内容安全 |
| P2 | @fischerx/client-sdk | 客户端SDK包 |
## License
Private - Fischer Company

0
apps/admin/.gitkeep Normal file
View File

41
apps/web/.gitignore vendored Normal file
View File

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

26
apps/web/Dockerfile Normal file
View File

@ -0,0 +1,26 @@
FROM node:20-alpine AS base
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN npm install -g pnpm@8.15.0 && pnpm install --frozen-lockfile
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm install -g pnpm@8.15.0 && pnpm build
FROM base AS runner
WORKDIR /app
ENV NODE_ENV production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT 3000
CMD ["node", "server.js"]

36
apps/web/README.md Normal file
View File

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

16
apps/web/components.json Normal file
View File

@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/app/globals.css",
"baseColor": "slate",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

13
apps/web/e2e/home.spec.ts Normal file
View File

@ -0,0 +1,13 @@
import { test, expect } from '@playwright/test'
test.describe('Home Page', () => {
test('should load the home page', async ({ page }) => {
await page.goto('/')
await expect(page).toHaveTitle(/FischerX/)
})
test('should display navigation', async ({ page }) => {
await page.goto('/')
await expect(page.getByRole('navigation')).toBeVisible()
})
})

View File

@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

7
apps/web/next.config.ts Normal file
View File

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

56
apps/web/package.json Normal file
View File

@ -0,0 +1,56 @@
{
"name": "web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"test": "vitest",
"test:ui": "vitest --ui",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
"dependencies": {
"@hookform/resolvers": "^5.4.0",
"@radix-ui/react-slot": "^1.2.4",
"@tanstack/react-query": "^5.100.13",
"@sentry/nextjs": "^8.0.0",
"@opentelemetry/api": "^1.8.0",
"@opentelemetry/sdk-trace-web": "^1.21.0",
"@opentelemetry/context-zone": "^1.21.0",
"@opentelemetry/instrumentation-fetch": "^0.48.0",
"@opentelemetry/instrumentation-xml-http-request": "^0.48.0",
"axios": "^1.16.1",
"web-vitals": "^4.0.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.16.0",
"next": "16.2.6",
"react": "18.0.0",
"react-dom": "18.0.0",
"react-hook-form": "^7.76.1",
"tailwind-merge": "^3.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^4.4.3",
"zustand": "^5.0.13"
},
"devDependencies": {
"@playwright/test": "^1.60.0",
"@tailwindcss/postcss": "^4",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/node": "^20",
"@types/react": "^18.3.29",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.0.0",
"eslint": "^9",
"eslint-config-next": "16.2.6",
"jsdom": "^29.1.1",
"tailwindcss": "^4",
"typescript": "^5",
"vite": "^8.0.14",
"vitest": "^4.1.7"
}
}

View File

@ -0,0 +1,33 @@
import { defineConfig, devices } from '@playwright/test'
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
},
})

5718
apps/web/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,6 @@
allowBuilds:
sharp: set this to true or false
unrs-resolver: set this to true or false
ignoredBuiltDependencies:
- sharp
- unrs-resolver

View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

1
apps/web/public/file.svg Normal file
View File

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

1
apps/web/public/next.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@ -0,0 +1,11 @@
export default function AuthLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<div className="flex min-h-screen items-center justify-center">
{children}
</div>
)
}

View File

@ -0,0 +1,353 @@
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import api from '@/lib/api';
import { useUserStore } from '@/stores/userStore';
type LoginTab = 'password' | 'sms';
const passwordLoginSchema = z.object({
email: z.string().email('Invalid email format').optional().or(z.literal('')),
phone: z.string().regex(/^1[3-9]\d{9}$/, 'Invalid phone format').optional().or(z.literal('')),
password: z.string().min(1, 'Password is required'),
}).refine((data) => {
if (!data.email && !data.phone) {
return {
path: ['email'],
message: 'Email or phone is required',
};
}
return { path: ['email'], message: '' };
});
const smsLoginSchema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, 'Invalid phone format'),
code: z.string().regex(/^\d{6}$/, 'Verification code must be 6 digits'),
});
type PasswordLoginFormData = z.infer<typeof passwordLoginSchema>;
type SmsLoginFormData = z.infer<typeof smsLoginSchema>;
export default function LoginPage() {
const router = useRouter();
const setUser = useUserStore((state) => state.setUser);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [activeTab, setActiveTab] = useState<LoginTab>('password');
const [smsSent, setSmsSent] = useState(false);
const [countdown, setCountdown] = useState(0);
const passwordForm = useForm<PasswordLoginFormData>({
resolver: zodResolver(passwordLoginSchema),
});
const smsForm = useForm<SmsLoginFormData>({
resolver: zodResolver(smsLoginSchema),
});
const handleSendSms = async () => {
const phone = smsForm.getValues('phone');
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
smsForm.setError('phone', { message: 'Please enter a valid phone number' });
return;
}
try {
setError(null);
await api.post('/auth/sms/send', { phone });
setSmsSent(true);
setCountdown(60);
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer);
setSmsSent(false);
return 0;
}
return prev - 1;
});
}, 1000);
} catch (err) {
const error = err as { response?: { data?: { message?: string } } };
setError(error.response?.data?.message || 'Failed to send verification code');
}
};
const onPasswordSubmit = async (data: PasswordLoginFormData) => {
try {
setError(null);
setLoading(true);
const response = await api.post('/auth/login', {
email: data.email || undefined,
phone: data.phone || undefined,
password: data.password,
});
const { accessToken, refreshToken, user } = response.data.data;
localStorage.setItem('token', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
router.push('/dashboard');
} catch (err) {
const error = err as { response?: { data?: { message?: string } } };
setError(error.response?.data?.message || 'Login failed');
} finally {
setLoading(false);
}
};
const onSmsSubmit = async (data: SmsLoginFormData) => {
try {
setError(null);
setLoading(true);
const response = await api.post('/auth/sms/login', {
phone: data.phone,
code: data.code,
});
const { accessToken, refreshToken, user } = response.data.data;
localStorage.setItem('token', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
router.push('/dashboard');
} catch (err) {
const error = err as { response?: { data?: { message?: string } } };
setError(error.response?.data?.message || 'SMS login failed');
} finally {
setLoading(false);
}
};
const handleWechatLogin = async () => {
try {
setError(null);
setLoading(true);
const response = await api.post('/auth/wechat/login', {
code: 'mock-wechat-code',
});
const { accessToken, refreshToken, user } = response.data.data;
localStorage.setItem('token', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
router.push('/dashboard');
} catch (err) {
const error = err as { response?: { data?: { message?: string } } };
setError(error.response?.data?.message || 'WeChat login failed');
} finally {
setLoading(false);
}
};
const handleAlipayLogin = async () => {
try {
setError(null);
setLoading(true);
const response = await api.post('/auth/alipay/login', {
code: 'mock-alipay-code',
});
const { accessToken, refreshToken, user } = response.data.data;
localStorage.setItem('token', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
router.push('/dashboard');
} catch (err) {
const error = err as { response?: { data?: { message?: string } } };
setError(error.response?.data?.message || 'Alipay login failed');
} finally {
setLoading(false);
}
};
return (
<div className="w-full max-w-md space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-2xl font-bold">Sign In</h1>
<p className="text-muted-foreground">Choose your preferred login method</p>
</div>
<div className="flex border-b">
<button
className={`flex-1 py-2 text-center font-medium ${
activeTab === 'password'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground'
}`}
onClick={() => setActiveTab('password')}
>
Password Login
</button>
<button
className={`flex-1 py-2 text-center font-medium ${
activeTab === 'sms'
? 'border-b-2 border-primary text-primary'
: 'text-muted-foreground'
}`}
onClick={() => setActiveTab('sms')}
>
SMS Login
</button>
</div>
{activeTab === 'password' && (
<form onSubmit={passwordForm.handleSubmit(onPasswordSubmit)} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Email</label>
<input
type="email"
{...passwordForm.register('email')}
className="w-full px-3 py-2 border rounded-md"
placeholder="your@email.com"
/>
{passwordForm.formState.errors.email && (
<p className="text-sm text-red-500">{passwordForm.formState.errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Phone</label>
<input
type="tel"
{...passwordForm.register('phone')}
className="w-full px-3 py-2 border rounded-md"
placeholder="13800138000"
/>
{passwordForm.formState.errors.phone && (
<p className="text-sm text-red-500">{passwordForm.formState.errors.phone.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Password</label>
<input
type="password"
{...passwordForm.register('password')}
className="w-full px-3 py-2 border rounded-md"
placeholder="••••••"
/>
{passwordForm.formState.errors.password && (
<p className="text-sm text-red-500">{passwordForm.formState.errors.password.message}</p>
)}
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
)}
{activeTab === 'sms' && (
<form onSubmit={smsForm.handleSubmit(onSmsSubmit)} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Phone</label>
<input
type="tel"
{...smsForm.register('phone')}
className="w-full px-3 py-2 border rounded-md"
placeholder="13800138000"
/>
{smsForm.formState.errors.phone && (
<p className="text-sm text-red-500">{smsForm.formState.errors.phone.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Verification Code</label>
<div className="flex gap-2">
<input
type="text"
{...smsForm.register('code')}
className="flex-1 px-3 py-2 border rounded-md"
placeholder="123456"
maxLength={6}
/>
<button
type="button"
onClick={handleSendSms}
disabled={smsSent || loading}
className="px-4 py-2 bg-secondary text-secondary-foreground rounded-md hover:bg-secondary/90 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
>
{smsSent ? `${countdown}s` : 'Send Code'}
</button>
</div>
{smsForm.formState.errors.code && (
<p className="text-sm text-red-500">{smsForm.formState.errors.code.message}</p>
)}
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Signing in...' : 'Sign In with SMS'}
</button>
</form>
)}
<div className="relative">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">Or continue with</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<button
onClick={handleWechatLogin}
disabled={loading}
className="flex items-center justify-center gap-2 py-2 px-4 border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="#07C160">
<path d="M8.691 2.188C3.891 2.188 0 5.476 0 9.504c0 2.212 1.17 4.203 3.002 5.55a.59.59 0 0 1 .213.665l-.39 1.48c-.019.07-.048.141-.048.213 0 .163.13.295.29.295a.326.326 0 0 0 .167-.054l1.903-1.114a.864.864 0 0 1 .717-.098 10.16 10.16 0 0 0 2.837.403c.276 0 .543-.027.811-.05-.857-2.578.157-4.972 1.934-6.446 1.707-1.415 3.738-2.19 5.77-2.19 3.568 0 6.69 2.19 6.69 5.504 0 3.314-3.122 5.504-6.69 5.504-.492 0-.973-.055-1.449-.136a.69.69 0 0 0-.578.136l-1.56 1.114a.326.326 0 0 1-.167.054.295.295 0 0 1-.29-.295c0-.072.029-.143.048-.213l.39-1.48a.59.59 0 0 0-.213-.665C19.829 13.707 21 11.716 21 9.504c0-4.028-3.891-7.316-8.691-7.316H8.691z"/>
</svg>
WeChat
</button>
<button
onClick={handleAlipayLogin}
disabled={loading}
className="flex items-center justify-center gap-2 py-2 px-4 border rounded-md hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="#1677FF">
<path d="M21.422 14.752c-1.386-.612-3.016-1.29-4.742-1.968.566-1.176.99-2.535 1.178-4.034h-3.57V7.5h4.5V6h-4.5V4.5h-3v1.5H8.25V7.5h4.5v1.25h-3.57c.188 1.499.612 2.858 1.178 4.034-1.726.678-3.356 1.356-4.742 1.968L4.5 18h15l-1.578-3.248zM12 16.5c-.828 0-1.5-.672-1.5-1.5s.672-1.5 1.5-1.5 1.5.672 1.5 1.5-.672 1.5-1.5 1.5z"/>
</svg>
Alipay
</button>
</div>
<div className="flex justify-between text-sm">
<Link href="/auth/register" className="text-primary hover:underline">
Create account
</Link>
<Link href="/auth/forgot-password" className="text-primary hover:underline">
Forgot password?
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,197 @@
'use client';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import api from '@/lib/api';
import { useUserStore } from '@/stores/userStore';
const registerSchema = z.object({
email: z.string().email('Invalid email format').optional().or(z.literal('')),
phone: z.string().regex(/^1[3-9]\d{9}$/, 'Invalid phone format').optional().or(z.literal('')),
username: z.string().min(3, 'Username must be at least 3 characters'),
password: z.string().min(6, 'Password must be at least 6 characters'),
confirmPassword: z.string(),
agreeTerms: z.boolean().refine((val) => val === true, {
message: 'You must agree to the terms and conditions',
}),
}).refine((data) => {
if (!data.email && !data.phone) {
return {
path: ['email'],
message: 'Email or phone is required',
};
}
if (data.password !== data.confirmPassword) {
return {
path: ['confirmPassword'],
message: 'Passwords do not match',
};
}
return { path: ['email'], message: '' };
});
type RegisterFormData = z.infer<typeof registerSchema>;
export default function RegisterPage() {
const router = useRouter();
const setUser = useUserStore((state) => state.setUser);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
defaultValues: {
agreeTerms: false,
},
});
const onSubmit = async (data: RegisterFormData) => {
try {
setError(null);
setLoading(true);
const response = await api.post('/auth/register', {
email: data.email || undefined,
phone: data.phone || undefined,
username: data.username,
password: data.password,
});
const { accessToken, refreshToken, user } = response.data.data;
localStorage.setItem('token', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
router.push('/dashboard');
} catch (err) {
const error = err as { response?: { data?: { message?: string } } };
setError(error.response?.data?.message || 'Registration failed');
} finally {
setLoading(false);
}
};
return (
<div className="w-full max-w-md space-y-6">
<div className="space-y-2 text-center">
<h1 className="text-2xl font-bold">Create Account</h1>
<p className="text-muted-foreground">Enter your details to get started</p>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Email</label>
<input
type="email"
{...register('email')}
className="w-full px-3 py-2 border rounded-md"
placeholder="your@email.com"
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Phone</label>
<input
type="tel"
{...register('phone')}
className="w-full px-3 py-2 border rounded-md"
placeholder="13800138000"
/>
{errors.phone && (
<p className="text-sm text-red-500">{errors.phone.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Username</label>
<input
type="text"
{...register('username')}
className="w-full px-3 py-2 border rounded-md"
placeholder="johndoe"
/>
{errors.username && (
<p className="text-sm text-red-500">{errors.username.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Password</label>
<input
type="password"
{...register('password')}
className="w-full px-3 py-2 border rounded-md"
placeholder="••••••"
/>
{errors.password && (
<p className="text-sm text-red-500">{errors.password.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Confirm Password</label>
<input
type="password"
{...register('confirmPassword')}
className="w-full px-3 py-2 border rounded-md"
placeholder="••••••"
/>
{errors.confirmPassword && (
<p className="text-sm text-red-500">{errors.confirmPassword.message}</p>
)}
</div>
<div className="flex items-start gap-2">
<input
type="checkbox"
{...register('agreeTerms')}
className="mt-1"
/>
<label className="text-sm">
I agree to the{' '}
<Link href="/terms" className="text-primary hover:underline">
Terms of Service
</Link>{' '}
and{' '}
<Link href="/privacy" className="text-primary hover:underline">
Privacy Policy
</Link>
</label>
</div>
{errors.agreeTerms && (
<p className="text-sm text-red-500">{errors.agreeTerms.message}</p>
)}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Creating account...' : 'Create Account'}
</button>
</form>
<div className="text-center text-sm">
Already have an account?{' '}
<Link href="/auth/login" className="text-primary hover:underline">
Sign in
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,311 @@
'use client';
import { useState, useEffect } from 'react';
import api from '@/lib/api';
interface Permission {
id: string;
name: string;
description: string | null;
resource: string;
action: string;
createdAt: Date;
updatedAt: Date;
}
interface Pagination {
page: number;
limit: number;
total: number;
totalPages: number;
}
export default function PermissionsManagementPage() {
const [permissions, setPermissions] = useState<Permission[]>([]);
const [pagination, setPagination] = useState<Pagination>({
page: 1,
limit: 50,
total: 0,
totalPages: 0,
});
const [resourceFilter, setResourceFilter] = useState('');
const [loading, setLoading] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingPermission, setEditingPermission] = useState<Permission | null>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
resource: '',
action: '',
});
useEffect(() => {
fetchPermissions();
}, [pagination.page, resourceFilter]);
const fetchPermissions = async () => {
try {
setLoading(true);
const response = await api.get('/permissions', {
params: {
page: pagination.page,
limit: pagination.limit,
resource: resourceFilter || undefined,
},
});
setPermissions(response.data.data.permissions);
setPagination(response.data.data.pagination);
} catch (err) {
console.error('Failed to fetch permissions:', err);
} finally {
setLoading(false);
}
};
const handleCreate = async () => {
try {
await api.post('/permissions', formData);
setShowCreateModal(false);
setFormData({ name: '', description: '', resource: '', action: '' });
fetchPermissions();
} catch (err) {
console.error('Failed to create permission:', err);
alert('Failed to create permission');
}
};
const handleUpdate = async () => {
if (!editingPermission) return;
try {
await api.put(`/permissions/${editingPermission.id}`, formData);
setEditingPermission(null);
setFormData({ name: '', description: '', resource: '', action: '' });
fetchPermissions();
} catch (err) {
console.error('Failed to update permission:', err);
alert('Failed to update permission');
}
};
const handleDelete = async (permissionId: string) => {
if (!confirm('Are you sure you want to delete this permission?')) return;
try {
await api.delete(`/permissions/${permissionId}`);
fetchPermissions();
} catch (err) {
console.error('Failed to delete permission:', err);
alert('Failed to delete permission');
}
};
const openEditModal = (permission: Permission) => {
setEditingPermission(permission);
setFormData({
name: permission.name,
description: permission.description || '',
resource: permission.resource,
action: permission.action,
});
};
const resources = Array.from(new Set(permissions.map((p) => p.resource)));
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">Permission Management</h1>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Create Permission
</button>
</div>
<div className="flex space-x-4">
<select
value={resourceFilter}
onChange={(e) => setResourceFilter(e.target.value)}
className="px-3 py-2 border rounded-md"
>
<option value="">All Resources</option>
{resources.map((resource) => (
<option key={resource} value={resource}>
{resource}
</option>
))}
</select>
</div>
<div className="border rounded-md overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Name
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Resource
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Action
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Description
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
Loading...
</td>
</tr>
) : permissions.length === 0 ? (
<tr>
<td colSpan={5} className="px-4 py-8 text-center text-gray-500">
No permissions found
</td>
</tr>
) : (
permissions.map((permission) => (
<tr key={permission.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{permission.name}</td>
<td className="px-4 py-3">
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded">
{permission.resource}
</span>
</td>
<td className="px-4 py-3">
<span className="px-2 py-1 text-xs bg-green-100 text-green-700 rounded">
{permission.action}
</span>
</td>
<td className="px-4 py-3 text-sm">{permission.description || '-'}</td>
<td className="px-4 py-3 text-right space-x-2">
<button
onClick={() => openEditModal(permission)}
className="text-sm text-blue-600 hover:underline"
>
Edit
</button>
<button
onClick={() => handleDelete(permission.id)}
className="text-sm text-red-600 hover:underline"
>
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{pagination.totalPages > 1 && (
<div className="flex justify-between items-center">
<p className="text-sm text-gray-500">
Showing {(pagination.page - 1) * pagination.limit + 1} to{' '}
{Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total}{' '}
permissions
</p>
<div className="flex space-x-2">
<button
onClick={() => setPagination({ ...pagination, page: pagination.page - 1 })}
disabled={pagination.page === 1}
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="px-3 py-1">
Page {pagination.page} of {pagination.totalPages}
</span>
<button
onClick={() => setPagination({ ...pagination, page: pagination.page + 1 })}
disabled={pagination.page === pagination.totalPages}
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
)}
{(showCreateModal || editingPermission) && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">
{editingPermission ? 'Edit Permission' : 'Create Permission'}
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border rounded-md"
placeholder="e.g., user:create"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Resource</label>
<input
type="text"
value={formData.resource}
onChange={(e) => setFormData({ ...formData, resource: e.target.value })}
className="w-full px-3 py-2 border rounded-md"
placeholder="e.g., user, file, role"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Action</label>
<input
type="text"
value={formData.action}
onChange={(e) => setFormData({ ...formData, action: e.target.value })}
className="w-full px-3 py-2 border rounded-md"
placeholder="e.g., create, read, update, delete"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border rounded-md"
rows={3}
/>
</div>
</div>
<div className="flex justify-end space-x-2 mt-6">
<button
onClick={() => {
setShowCreateModal(false);
setEditingPermission(null);
setFormData({ name: '', description: '', resource: '', action: '' });
}}
className="px-4 py-2 border rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={editingPermission ? handleUpdate : handleCreate}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
{editingPermission ? 'Update' : 'Create'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,302 @@
'use client';
import { useState, useEffect } from 'react';
import api from '@/lib/api';
interface Role {
id: string;
name: string;
description: string | null;
isSystem: boolean;
createdAt: Date;
updatedAt: Date;
_count: {
users: number;
permissions: number;
};
}
interface Pagination {
page: number;
limit: number;
total: number;
totalPages: number;
}
export default function RolesManagementPage() {
const [roles, setRoles] = useState<Role[]>([]);
const [pagination, setPagination] = useState<Pagination>({
page: 1,
limit: 20,
total: 0,
totalPages: 0,
});
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(false);
const [showCreateModal, setShowCreateModal] = useState(false);
const [editingRole, setEditingRole] = useState<Role | null>(null);
const [formData, setFormData] = useState({
name: '',
description: '',
isSystem: false,
});
useEffect(() => {
fetchRoles();
}, [pagination.page, search]);
const fetchRoles = async () => {
try {
setLoading(true);
const response = await api.get('/roles', {
params: {
page: pagination.page,
limit: pagination.limit,
search: search || undefined,
},
});
setRoles(response.data.data.roles);
setPagination(response.data.data.pagination);
} catch (err) {
console.error('Failed to fetch roles:', err);
} finally {
setLoading(false);
}
};
const handleCreate = async () => {
try {
await api.post('/roles', formData);
setShowCreateModal(false);
setFormData({ name: '', description: '', isSystem: false });
fetchRoles();
} catch (err) {
console.error('Failed to create role:', err);
alert('Failed to create role');
}
};
const handleUpdate = async () => {
if (!editingRole) return;
try {
await api.put(`/roles/${editingRole.id}`, formData);
setEditingRole(null);
setFormData({ name: '', description: '', isSystem: false });
fetchRoles();
} catch (err) {
console.error('Failed to update role:', err);
alert('Failed to update role');
}
};
const handleDelete = async (roleId: string) => {
if (!confirm('Are you sure you want to delete this role?')) return;
try {
await api.delete(`/roles/${roleId}`);
fetchRoles();
} catch (err) {
console.error('Failed to delete role:', err);
alert('Failed to delete role');
}
};
const openEditModal = (role: Role) => {
setEditingRole(role);
setFormData({
name: role.name,
description: role.description || '',
isSystem: role.isSystem,
});
};
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">Role Management</h1>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Create Role
</button>
</div>
<div className="flex space-x-4">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search roles..."
className="flex-1 px-3 py-2 border rounded-md"
/>
</div>
<div className="border rounded-md overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Name
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Description
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Users
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Permissions
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
System
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
Loading...
</td>
</tr>
) : roles.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
No roles found
</td>
</tr>
) : (
roles.map((role) => (
<tr key={role.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium">{role.name}</td>
<td className="px-4 py-3 text-sm">{role.description || '-'}</td>
<td className="px-4 py-3 text-sm">{role._count.users}</td>
<td className="px-4 py-3 text-sm">{role._count.permissions}</td>
<td className="px-4 py-3">
<span
className={`px-2 py-1 text-xs rounded-full ${
role.isSystem
? 'bg-purple-100 text-purple-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{role.isSystem ? 'Yes' : 'No'}
</span>
</td>
<td className="px-4 py-3 text-right space-x-2">
<button
onClick={() => openEditModal(role)}
className="text-sm text-blue-600 hover:underline"
>
Edit
</button>
{!role.isSystem && (
<button
onClick={() => handleDelete(role.id)}
className="text-sm text-red-600 hover:underline"
>
Delete
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{pagination.totalPages > 1 && (
<div className="flex justify-between items-center">
<p className="text-sm text-gray-500">
Showing {(pagination.page - 1) * pagination.limit + 1} to{' '}
{Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total}{' '}
roles
</p>
<div className="flex space-x-2">
<button
onClick={() => setPagination({ ...pagination, page: pagination.page - 1 })}
disabled={pagination.page === 1}
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="px-3 py-1">
Page {pagination.page} of {pagination.totalPages}
</span>
<button
onClick={() => setPagination({ ...pagination, page: pagination.page + 1 })}
disabled={pagination.page === pagination.totalPages}
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
)}
{(showCreateModal || editingRole) && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
<div className="bg-white rounded-lg p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">
{editingRole ? 'Edit Role' : 'Create Role'}
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">Name</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 border rounded-md"
placeholder="e.g., admin, user, editor"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">Description</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full px-3 py-2 border rounded-md"
rows={3}
/>
</div>
<div className="flex items-center">
<input
type="checkbox"
checked={formData.isSystem}
onChange={(e) => setFormData({ ...formData, isSystem: e.target.checked })}
className="mr-2"
/>
<label className="text-sm">System Role (cannot be deleted)</label>
</div>
</div>
<div className="flex justify-end space-x-2 mt-6">
<button
onClick={() => {
setShowCreateModal(false);
setEditingRole(null);
setFormData({ name: '', description: '', isSystem: false });
}}
className="px-4 py-2 border rounded-md hover:bg-gray-50"
>
Cancel
</button>
<button
onClick={editingRole ? handleUpdate : handleCreate}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
{editingRole ? 'Update' : 'Create'}
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,256 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import api from '@/lib/api';
interface Role {
id: string;
name: string;
description: string | null;
permissions: Array<{
id: string;
permission: {
id: string;
name: string;
resource: string;
action: string;
};
}>;
}
interface Permission {
id: string;
name: string;
resource: string;
action: string;
}
interface AvailableRole {
id: string;
name: string;
description: string | null;
}
export default function UserPermissionsPage() {
const params = useParams();
const userId = params.id as string;
const [userRoles, setUserRoles] = useState<Role[]>([]);
const [userPermissions, setUserPermissions] = useState<Permission[]>([]);
const [availableRoles, setAvailableRoles] = useState<AvailableRole[]>([]);
const [loading, setLoading] = useState(false);
const [selectedRoleId, setSelectedRoleId] = useState('');
useEffect(() => {
fetchUserPermissions();
fetchAvailableRoles();
}, [userId]);
const fetchUserPermissions = async () => {
try {
setLoading(true);
const [rolesRes, permsRes] = await Promise.all([
api.get(`/users/${userId}/roles`),
api.get(`/users/${userId}/permissions`),
]);
setUserRoles(rolesRes.data.data);
setUserPermissions(permsRes.data.data);
} catch (err) {
console.error('Failed to fetch user permissions:', err);
} finally {
setLoading(false);
}
};
const fetchAvailableRoles = async () => {
try {
const response = await api.get('/roles', {
params: { limit: 100 },
});
setAvailableRoles(response.data.data.roles);
} catch (err) {
console.error('Failed to fetch available roles:', err);
}
};
const handleAssignRole = async () => {
if (!selectedRoleId) return;
try {
await api.post(`/users/${userId}/roles`, { roleId: selectedRoleId });
setSelectedRoleId('');
fetchUserPermissions();
} catch (err) {
console.error('Failed to assign role:', err);
alert('Failed to assign role');
}
};
const handleRemoveRole = async (roleId: string) => {
if (!confirm('Are you sure you want to remove this role from the user?')) return;
try {
await api.delete(`/users/${userId}/roles/${roleId}`);
fetchUserPermissions();
} catch (err) {
console.error('Failed to remove role:', err);
alert('Failed to remove role');
}
};
const unassignedRoles = availableRoles.filter(
(role) => !userRoles.some((ur) => ur.id === role.id),
);
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">User Permissions</h1>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="border rounded-md p-6">
<h2 className="text-lg font-semibold mb-4">User Roles</h2>
<div className="mb-4 flex space-x-2">
<select
value={selectedRoleId}
onChange={(e) => setSelectedRoleId(e.target.value)}
className="flex-1 px-3 py-2 border rounded-md"
>
<option value="">Select a role...</option>
{unassignedRoles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
<button
onClick={handleAssignRole}
disabled={!selectedRoleId}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
Assign
</button>
</div>
{loading ? (
<p className="text-center text-gray-500 py-4">Loading...</p>
) : userRoles.length === 0 ? (
<p className="text-center text-gray-500 py-4">No roles assigned</p>
) : (
<div className="space-y-2">
{userRoles.map((role) => (
<div
key={role.id}
className="flex justify-between items-center p-3 bg-gray-50 rounded-md"
>
<div>
<p className="font-medium">{role.name}</p>
{role.description && (
<p className="text-sm text-gray-500">{role.description}</p>
)}
</div>
<button
onClick={() => handleRemoveRole(role.id)}
className="text-sm text-red-600 hover:underline"
>
Remove
</button>
</div>
))}
</div>
)}
</div>
<div className="border rounded-md p-6">
<h2 className="text-lg font-semibold mb-4">User Permissions</h2>
{loading ? (
<p className="text-center text-gray-500 py-4">Loading...</p>
) : userPermissions.length === 0 ? (
<p className="text-center text-gray-500 py-4">No permissions</p>
) : (
<div className="space-y-2">
{userPermissions.map((permission) => (
<div
key={permission.id}
className="flex items-center space-x-2 p-2 bg-gray-50 rounded-md"
>
<span className="px-2 py-1 text-xs bg-blue-100 text-blue-700 rounded">
{permission.resource}
</span>
<span className="px-2 py-1 text-xs bg-green-100 text-green-700 rounded">
{permission.action}
</span>
<span className="text-sm text-gray-600">{permission.name}</span>
</div>
))}
</div>
)}
</div>
</div>
<div className="border rounded-md p-6">
<h2 className="text-lg font-semibold mb-4">Permission Test</h2>
<PermissionTester userId={userId} />
</div>
</div>
);
}
function PermissionTester({ userId }: { userId: string }) {
const [resource, setResource] = useState('user');
const [action, setAction] = useState('create');
const [result, setResult] = useState<boolean | null>(null);
const testPermission = async () => {
try {
const response = await api.get(`/users/${userId}/permissions`);
const permissions = response.data.data;
const hasPermission = permissions.some(
(p: Permission) => p.resource === resource && p.action === action,
);
setResult(hasPermission);
} catch (err) {
console.error('Failed to test permission:', err);
}
};
return (
<div className="space-y-4">
<div className="flex space-x-4">
<input
type="text"
value={resource}
onChange={(e) => setResource(e.target.value)}
placeholder="Resource"
className="px-3 py-2 border rounded-md"
/>
<input
type="text"
value={action}
onChange={(e) => setAction(e.target.value)}
placeholder="Action"
className="px-3 py-2 border rounded-md"
/>
<button
onClick={testPermission}
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
>
Test
</button>
</div>
{result !== null && (
<div
className={`p-3 rounded-md ${
result ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}
>
{result ? '✓ Permission granted' : '✗ Permission denied'}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,205 @@
'use client';
import { useState, useEffect } from 'react';
import api from '@/lib/api';
interface User {
id: string;
email: string | null;
phone: string | null;
username: string;
firstName: string | null;
lastName: string | null;
avatar: string | null;
isActive: boolean;
emailVerified: boolean;
phoneVerified: boolean;
lastLoginAt: Date | null;
createdAt: Date;
}
interface Pagination {
page: number;
limit: number;
total: number;
totalPages: number;
}
export default function UsersManagementPage() {
const [users, setUsers] = useState<User[]>([]);
const [pagination, setPagination] = useState<Pagination>({
page: 1,
limit: 20,
total: 0,
totalPages: 0,
});
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchUsers = async () => {
try {
setLoading(true);
const response = await api.get('/users', {
params: {
page: pagination.page,
limit: pagination.limit,
search: search || undefined,
},
});
setUsers(response.data.data.users);
setPagination(response.data.data.pagination);
} catch (err) {
console.error('Failed to fetch users:', err);
} finally {
setLoading(false);
}
};
fetchUsers();
}, [pagination.page, search]);
const handleDelete = async (userId: string) => {
if (!confirm('Are you sure you want to delete this user?')) return;
try {
await api.delete(`/users/${userId}`);
setUsers(users.filter((u) => u.id !== userId));
} catch (err) {
console.error('Failed to delete user:', err);
alert('Failed to delete user');
}
};
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<h1 className="text-2xl font-bold">User Management</h1>
</div>
<div className="flex space-x-4">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search users..."
className="flex-1 px-3 py-2 border rounded-md"
/>
</div>
<div className="border rounded-md overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
User
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Email
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Phone
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Status
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">
Created
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">
Actions
</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
Loading...
</td>
</tr>
) : users.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-gray-500">
No users found
</td>
</tr>
) : (
users.map((user) => (
<tr key={user.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
{user.avatar ? (
<img src={user.avatar} alt="" className="w-full h-full object-cover" />
) : (
<span className="text-sm font-bold text-gray-500">
{user.firstName?.[0] || user.username[0]}
</span>
)}
</div>
<span className="font-medium">{user.username}</span>
</div>
</td>
<td className="px-4 py-3 text-sm">{user.email || '-'}</td>
<td className="px-4 py-3 text-sm">{user.phone || '-'}</td>
<td className="px-4 py-3">
<span
className={`px-2 py-1 text-xs rounded-full ${
user.isActive
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}
>
{user.isActive ? 'Active' : 'Inactive'}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{new Date(user.createdAt).toLocaleDateString()}
</td>
<td className="px-4 py-3 text-right space-x-2">
<button
onClick={() => handleDelete(user.id)}
className="text-sm text-red-600 hover:underline"
>
Delete
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{pagination.totalPages > 1 && (
<div className="flex justify-between items-center">
<p className="text-sm text-gray-500">
Showing {(pagination.page - 1) * pagination.limit + 1} to{' '}
{Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total}{' '}
users
</p>
<div className="flex space-x-2">
<button
onClick={() => setPagination({ ...pagination, page: pagination.page - 1 })}
disabled={pagination.page === 1}
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
<span className="px-3 py-1">
Page {pagination.page} of {pagination.totalPages}
</span>
<button
onClick={() => setPagination({ ...pagination, page: pagination.page + 1 })}
disabled={pagination.page === pagination.totalPages}
className="px-3 py-1 border rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,8 @@
export default function DashboardPage() {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
<p className="text-muted-foreground">Welcome to your dashboard</p>
</div>
)
}

View File

@ -0,0 +1,154 @@
'use client';
import { useState, useCallback } from 'react';
import { FileUploader } from '@/components/file/file-uploader';
import { FilePreview } from '@/components/file/file-preview';
import { useFiles } from '@/hooks/use-files';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export default function FilesPage() {
const [params, setParams] = useState({ page: 1, limit: 20 });
const [search, setSearch] = useState('');
const [category, setCategory] = useState<string | undefined>();
const { data, isLoading, error, refetch } = useFiles({
...params,
search: search || undefined,
category,
});
const handleUploadSuccess = useCallback(() => {
refetch();
}, [refetch]);
const handleDelete = useCallback(() => {
refetch();
}, [refetch]);
const handleNextPage = () => {
if (data?.meta && params.page < Math.ceil(data.meta.total / data.meta.limit)) {
setParams((prev) => ({ ...prev, page: prev.page + 1 }));
}
};
const handlePrevPage = () => {
if (params.page > 1) {
setParams((prev) => ({ ...prev, page: prev.page - 1 }));
}
};
const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
setSearch(e.target.value);
setParams({ page: 1, limit: 20 });
};
const files = data?.data || [];
const meta = data?.meta;
return (
<div className="container mx-auto py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold mb-2">File Management</h1>
<p className="text-muted-foreground">Upload, manage, and share your files</p>
</div>
<FileUploader
onUploadSuccess={handleUploadSuccess}
multiple
category={category}
className="mb-8"
/>
<div className="mb-6 flex flex-wrap gap-4 items-center">
<div className="flex-1 min-w-[200px]">
<input
type="text"
placeholder="Search files..."
value={search}
onChange={handleSearch}
className="w-full px-4 py-2 border rounded-md"
/>
</div>
<select
value={category || ''}
onChange={(e) => {
setCategory(e.target.value || undefined);
setParams({ page: 1, limit: 20 });
}}
className="px-4 py-2 border rounded-md"
>
<option value="">All Categories</option>
<option value="documents">Documents</option>
<option value="images">Images</option>
<option value="videos">Videos</option>
<option value="other">Other</option>
</select>
</div>
{isLoading && (
<div className="text-center py-8">
<div className="text-lg">Loading files...</div>
</div>
)}
{error && (
<Card className="mb-6 border-destructive">
<CardContent className="p-4 text-destructive">
Error loading files: {error.message}
<Button variant="outline" className="ml-4" onClick={() => refetch()}>
Retry
</Button>
</CardContent>
</Card>
)}
{!isLoading && !error && files.length === 0 && (
<Card className="mb-6">
<CardContent className="p-8 text-center">
<div className="text-4xl mb-4">📭</div>
<h3 className="text-lg font-medium mb-2">No files found</h3>
<p className="text-muted-foreground">
{search || category
? 'Try adjusting your search or filters'
: 'Upload your first file above'}
</p>
</CardContent>
</Card>
)}
{files.length > 0 && (
<>
<div className="grid gap-4 mb-8">
{files.map((file) => (
<FilePreview key={file.id} file={file} onDelete={handleDelete} />
))}
</div>
{meta && meta.total > meta.limit && (
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Showing {((meta.page - 1) * meta.limit) + 1} - {Math.min(meta.page * meta.limit, meta.total)} of {meta.total} files
</p>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handlePrevPage}
disabled={meta.page <= 1}
>
Previous
</Button>
<Button
variant="outline"
onClick={handleNextPage}
disabled={meta.page >= Math.ceil(meta.total / meta.limit)}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</div>
);
}

View File

@ -0,0 +1,7 @@
export default function DashboardLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

View File

@ -0,0 +1,145 @@
'use client';
import { useParams } from 'next/navigation';
import { usePaymentOrder, useQueryOrderStatus, useCancelOrder, useCreateRefund } from '@/hooks/use-payment';
import { Button } from '@/components/ui/button';
import { useState } from 'react';
export default function PaymentDetailPage() {
const params = useParams();
const orderId = params.id as string;
const [showRefundForm, setShowRefundForm] = useState(false);
const [refundAmount, setRefundAmount] = useState('');
const { data: orderResult, isLoading } = usePaymentOrder(orderId);
const queryStatusMutation = useQueryOrderStatus();
const cancelOrderMutation = useCancelOrder();
const createRefundMutation = useCreateRefund();
const order = orderResult?.data;
if (isLoading) {
return <div>...</div>;
}
if (!order) {
return <div></div>;
}
const handleQueryStatus = () => {
queryStatusMutation.mutate(orderId);
};
const handleCancelOrder = () => {
if (confirm('确定要取消此订单吗?')) {
cancelOrderMutation.mutate(orderId);
}
};
const handleCreateRefund = () => {
const amount = parseFloat(refundAmount);
if (amount > 0 && amount <= order.amount) {
createRefundMutation.mutate({
orderId: order.id,
amount,
reason: '用户申请退款',
});
setShowRefundForm(false);
}
};
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6"></h1>
<div className="max-w-2xl space-y-6">
<div className="border rounded-lg p-6">
<h2 className="text-lg font-medium mb-4"></h2>
<div className="space-y-3">
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="font-medium">{order.orderNo}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="font-medium text-xl">¥{order.amount.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${
order.status === 'paid' ? 'bg-green-100 text-green-800' :
order.status === 'pending' ? 'bg-yellow-100 text-yellow-800' :
order.status === 'failed' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'
}`}>
{order.status === 'paid' ? '已支付' :
order.status === 'pending' ? '待支付' :
order.status === 'failed' ? '失败' :
order.status === 'cancelled' ? '已取消' :
order.status === 'refunded' ? '已退款' : order.status}
</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span className="font-medium">{order.subject}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600"></span>
<span>{new Date(order.createdAt).toLocaleString()}</span>
</div>
</div>
</div>
<div className="flex gap-4">
{order.status === 'pending' && (
<>
<Button onClick={handleQueryStatus} disabled={queryStatusMutation.isPending}>
</Button>
<Button variant="secondary" onClick={handleCancelOrder} disabled={cancelOrderMutation.isPending}>
</Button>
</>
)}
{order.status === 'paid' && !showRefundForm && (
<Button onClick={() => setShowRefundForm(true)}>
退
</Button>
)}
</div>
{showRefundForm && (
<div className="border rounded-lg p-6">
<h3 className="text-lg font-medium mb-4">退</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">退</label>
<input
type="number"
min="0.01"
max={order.amount}
step="0.01"
value={refundAmount}
onChange={(e) => setRefundAmount(e.target.value)}
placeholder={`最多可退 ¥${order.amount.toFixed(2)}`}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div className="flex gap-4">
<Button
onClick={handleCreateRefund}
disabled={!refundAmount || createRefundMutation.isPending}
>
退
</Button>
<Button variant="secondary" onClick={() => setShowRefundForm(false)}>
</Button>
</div>
</div>
</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,67 @@
'use client';
import { usePaymentOrders } from '@/hooks/use-payment';
export default function PaymentsPage() {
const { data: ordersData, isLoading } = usePaymentOrders(1, 20);
if (isLoading) {
return <div>...</div>;
}
const orders = ordersData?.data?.orders || [];
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-6"></h1>
<div className="border rounded-lg overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
</th>
<th className="px-4 py-3 text-left text-sm font-medium text-gray-600">
</th>
</tr>
</thead>
<tbody className="divide-y">
{orders.map((order: any) => (
<tr key={order.id}>
<td className="px-4 py-3 text-sm">{order.orderNo}</td>
<td className="px-4 py-3 text-sm font-medium">
¥{order.amount.toFixed(2)}
</td>
<td className="px-4 py-3">
<span className={`inline-flex px-2 py-1 text-xs rounded-full ${
order.status === 'paid' ? 'bg-green-100 text-green-800' :
order.status === 'pending' ? 'bg-yellow-100 text-yellow-800' :
order.status === 'failed' ? 'bg-red-100 text-red-800' :
'bg-gray-100 text-gray-800'
}`}>
{order.status === 'paid' ? '已支付' :
order.status === 'pending' ? '待支付' :
order.status === 'failed' ? '失败' :
order.status === 'cancelled' ? '已取消' :
order.status === 'refunded' ? '已退款' : order.status}
</span>
</td>
<td className="px-4 py-3 text-sm text-gray-500">
{new Date(order.createdAt).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@ -0,0 +1,254 @@
'use client';
import { useState, useEffect, useRef } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import api from '@/lib/api';
import { useUserStore } from '@/stores/userStore';
const profileSchema = z.object({
firstName: z.string().optional(),
lastName: z.string().optional(),
email: z.string().email('Invalid email format').optional().or(z.literal('')),
phone: z.string().regex(/^1[3-9]\d{9}$/, 'Invalid phone format').optional().or(z.literal('')),
});
type ProfileFormData = z.infer<typeof profileSchema>;
export default function ProfilePage() {
const { user, setUser } = useUserStore();
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const {
register,
handleSubmit,
reset,
formState: { errors },
} = useForm<ProfileFormData>({
resolver: zodResolver(profileSchema),
});
useEffect(() => {
if (user) {
reset({
firstName: user.firstName || '',
lastName: user.lastName || '',
email: user.email || '',
phone: user.phone || '',
});
}
}, [user, reset]);
const onSubmit = async (data: ProfileFormData) => {
try {
setError(null);
setMessage(null);
setLoading(true);
const response = await api.put('/users/me', {
firstName: data.firstName || undefined,
lastName: data.lastName || undefined,
email: data.email || undefined,
phone: data.phone || undefined,
});
const updatedUser = response.data.data;
setUser({
...user,
...updatedUser,
});
setMessage('Profile updated successfully');
} catch (err) {
const error = err as { response?: { data?: { message?: string } } };
setError(error.response?.data?.message || 'Failed to update profile');
} finally {
setLoading(false);
}
};
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
setError('Please upload an image file');
return;
}
if (file.size > 5 * 1024 * 1024) {
setError('Image size must be less than 5MB');
return;
}
try {
setError(null);
setLoading(true);
const formData = new FormData();
formData.append('avatar', file);
const response = await api.post('/users/me/avatar', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
const updatedUser = response.data.data;
setUser({
...user,
avatar: updatedUser.avatar,
});
setMessage('Avatar updated successfully');
} catch (err) {
const error = err as { response?: { data?: { message?: string } } };
setError(error.response?.data?.message || 'Failed to upload avatar');
} finally {
setLoading(false);
}
};
const handleLogout = () => {
localStorage.removeItem('token');
setUser(null);
window.location.href = '/auth/login';
};
if (!user) {
return <div className="p-6">Loading...</div>;
}
return (
<div className="max-w-2xl mx-auto p-6 space-y-6">
<h1 className="text-2xl font-bold">Profile Settings</h1>
<div className="space-y-4">
<div className="flex items-center space-x-4">
<div className="w-20 h-20 rounded-full bg-gray-200 flex items-center justify-center overflow-hidden">
{user.avatar ? (
<img src={user.avatar} alt="Avatar" className="w-full h-full object-cover" />
) : (
<span className="text-2xl font-bold text-gray-500">
{user.firstName?.[0] || user.username?.[0] || 'U'}
</span>
)}
</div>
<div>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="px-4 py-2 text-sm border rounded-md hover:bg-gray-50"
disabled={loading}
>
Change Avatar
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleAvatarUpload}
className="hidden"
/>
<p className="text-xs text-gray-500 mt-1">JPG, PNG, GIF or WebP (max 5MB)</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium">First Name</label>
<input
type="text"
{...register('firstName')}
className="w-full px-3 py-2 border rounded-md"
placeholder="John"
/>
{errors.firstName && (
<p className="text-sm text-red-500">{errors.firstName.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Last Name</label>
<input
type="text"
{...register('lastName')}
className="w-full px-3 py-2 border rounded-md"
placeholder="Doe"
/>
{errors.lastName && (
<p className="text-sm text-red-500">{errors.lastName.message}</p>
)}
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Username</label>
<input
type="text"
value={user.username || ''}
disabled
className="w-full px-3 py-2 border rounded-md bg-gray-50"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Email</label>
<input
type="email"
{...register('email')}
className="w-full px-3 py-2 border rounded-md"
placeholder="your@email.com"
/>
{errors.email && (
<p className="text-sm text-red-500">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Phone</label>
<input
type="tel"
{...register('phone')}
className="w-full px-3 py-2 border rounded-md"
placeholder="13800138000"
/>
{errors.phone && (
<p className="text-sm text-red-500">{errors.phone.message}</p>
)}
</div>
{message && (
<div className="p-3 bg-green-50 border border-green-200 rounded-md text-green-600 text-sm">
{message}
</div>
)}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
{error}
</div>
)}
<div className="flex space-x-4">
<button
type="submit"
disabled={loading}
className="flex-1 py-2 px-4 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Saving...' : 'Save Changes'}
</button>
<button
type="button"
onClick={handleLogout}
className="py-2 px-4 border border-red-300 text-red-600 rounded-md hover:bg-red-50"
>
Logout
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,180 @@
'use client';
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import api from '@/lib/api';
const realnameSchema = z.object({
realName: z.string().min(2, 'Name must be at least 2 characters'),
idCardNumber: z.string().regex(/^[1-9]\d{5}(18|19|20)\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}[\dXx]$/, 'Invalid ID card number'),
idCardFrontUrl: z.string().optional(),
idCardBackUrl: z.string().optional(),
});
type RealnameFormData = z.infer<typeof realnameSchema>;
export default function RealnamePage() {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [authStatus, setAuthStatus] = useState<string | null>(null);
const [rejectReason, setRejectReason] = useState<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RealnameFormData>({
resolver: zodResolver(realnameSchema),
});
useEffect(() => {
fetchStatus();
}, []);
const fetchStatus = async () => {
try {
const response = await api.get('/auth/realname/status');
if (response.data.data) {
setAuthStatus(response.data.data.status);
setRejectReason(response.data.data.rejectReason);
}
} catch (err) {
console.error('Failed to fetch realname status');
}
};
const onSubmit = async (data: RealnameFormData) => {
try {
setError(null);
setSuccess(null);
setLoading(true);
await api.post('/auth/realname/verify', data);
setSuccess('Realname authentication submitted successfully');
setAuthStatus('pending');
} catch (err) {
const error = err as { response?: { data?: { message?: string } } };
setError(error.response?.data?.message || 'Submission failed');
} finally {
setLoading(false);
}
};
if (authStatus === 'verified') {
return (
<div className="w-full max-w-2xl mx-auto space-y-6">
<div className="p-6 bg-green-50 border border-green-200 rounded-lg text-center">
<svg className="w-16 h-16 mx-auto text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2 className="mt-4 text-xl font-semibold text-green-800">Verified</h2>
<p className="mt-2 text-green-600">Your realname authentication has been verified successfully.</p>
</div>
</div>
);
}
if (authStatus === 'pending') {
return (
<div className="w-full max-w-2xl mx-auto space-y-6">
<div className="p-6 bg-yellow-50 border border-yellow-200 rounded-lg text-center">
<svg className="w-16 h-16 mx-auto text-yellow-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2 className="mt-4 text-xl font-semibold text-yellow-800">Pending Review</h2>
<p className="mt-2 text-yellow-600">Your realname authentication is under review. Please wait.</p>
</div>
</div>
);
}
return (
<div className="w-full max-w-2xl mx-auto space-y-6">
<div className="space-y-2">
<h1 className="text-2xl font-bold">Realname Authentication</h1>
<p className="text-muted-foreground">Submit your ID information for verification</p>
</div>
{authStatus === 'rejected' && (
<div className="p-4 bg-red-50 border border-red-200 rounded-lg">
<h3 className="font-medium text-red-800">Previous Application Rejected</h3>
{rejectReason && (
<p className="mt-2 text-sm text-red-600">Reason: {rejectReason}</p>
)}
</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Real Name</label>
<input
type="text"
{...register('realName')}
className="w-full px-3 py-2 border rounded-md"
placeholder="Enter your real name"
/>
{errors.realName && (
<p className="text-sm text-red-500">{errors.realName.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">ID Card Number</label>
<input
type="text"
{...register('idCardNumber')}
className="w-full px-3 py-2 border rounded-md"
placeholder="Enter your ID card number"
/>
{errors.idCardNumber && (
<p className="text-sm text-red-500">{errors.idCardNumber.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">ID Card Front Photo URL</label>
<input
type="text"
{...register('idCardFrontUrl')}
className="w-full px-3 py-2 border rounded-md"
placeholder="https://example.com/front.jpg"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium">ID Card Back Photo URL</label>
<input
type="text"
{...register('idCardBackUrl')}
className="w-full px-3 py-2 border rounded-md"
placeholder="https://example.com/back.jpg"
/>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
{error}
</div>
)}
{success && (
<div className="p-3 bg-green-50 border border-green-200 rounded-md text-green-600 text-sm">
{success}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Submitting...' : 'Submit Authentication'}
</button>
</form>
</div>
);
}

View File

@ -0,0 +1,353 @@
'use client';
import { useState, useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import api from '@/lib/api';
import { useUserStore } from '@/stores/userStore';
const passwordSchema = z.object({
currentPassword: z.string().min(1, 'Current password is required'),
newPassword: z.string().min(6, 'New password must be at least 6 characters'),
confirmPassword: z.string(),
}).refine((data) => {
if (data.newPassword !== data.confirmPassword) {
return {
path: ['confirmPassword'],
message: 'Passwords do not match',
};
}
return { path: ['currentPassword'], message: '' };
});
const mfaSchema = z.object({
token: z.string().regex(/^\d{6}$/, 'MFA code must be 6 digits'),
});
type PasswordFormData = z.infer<typeof passwordSchema>;
type MfaFormData = z.infer<typeof mfaSchema>;
interface Session {
id: string;
deviceName: string | null;
ipAddress: string | null;
createdAt: string;
expiresAt: string;
}
export default function SecurityPage() {
const user = useUserStore((state) => state.user);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [mfaEnabled, setMfaEnabled] = useState(false);
const [mfaSetup, setMfaSetup] = useState<{ secret: string; qrCodeUrl: string } | null>(null);
const [sessions, setSessions] = useState<Session[]>([]);
const passwordForm = useForm<PasswordFormData>({
resolver: zodResolver(passwordSchema),
});
const mfaForm = useForm<MfaFormData>({
resolver: zodResolver(mfaSchema),
});
useEffect(() => {
fetchSessions();
}, []);
const fetchSessions = async () => {
try {
const response = await api.get('/auth/sessions');
setSessions(response.data.data || []);
} catch (err) {
console.error('Failed to fetch sessions');
}
};
const handleChangePassword = async (data: PasswordFormData) => {
try {
setError(null);
setSuccess(null);
setLoading(true);
await api.post('/auth/change-password', {
currentPassword: data.currentPassword,
newPassword: data.newPassword,
});
setSuccess('Password changed successfully');
passwordForm.reset();
} catch (err) {
const error = err as { response?: { data?: { message?: string } } };
setError(error.response?.data?.message || 'Password change failed');
} finally {
setLoading(false);
}
};
const handleSetupMfa = async () => {
try {
setError(null);
const response = await api.post('/auth/mfa/setup');
setMfaSetup(response.data.data);
} catch (err) {
const error = err as { response?: { data?: { message?: string } } };
setError(error.response?.data?.message || 'MFA setup failed');
}
};
const handleEnableMfa = async (data: MfaFormData) => {
if (!mfaSetup) return;
try {
setError(null);
setSuccess(null);
setLoading(true);
await api.post('/auth/mfa/enable', {
secret: mfaSetup.secret,
token: data.token,
});
setMfaEnabled(true);
setMfaSetup(null);
setSuccess('MFA enabled successfully');
mfaForm.reset();
} catch (err) {
const error = err as { response?: { data?: { message?: string } } };
setError(error.response?.data?.message || 'MFA enable failed');
} finally {
setLoading(false);
}
};
const handleDisableMfa = async (data: MfaFormData) => {
try {
setError(null);
setSuccess(null);
setLoading(true);
await api.post('/auth/mfa/disable', {
token: data.token,
});
setMfaEnabled(false);
setSuccess('MFA disabled successfully');
mfaForm.reset();
} catch (err) {
const error = err as { response?: { data?: { message?: string } } };
setError(error.response?.data?.message || 'MFA disable failed');
} finally {
setLoading(false);
}
};
const handleRevokeSession = async (sessionId: string) => {
try {
await api.post(`/auth/sessions/${sessionId}/revoke`);
fetchSessions();
} catch (err) {
console.error('Failed to revoke session');
}
};
return (
<div className="w-full max-w-2xl mx-auto space-y-8">
<div className="space-y-2">
<h1 className="text-2xl font-bold">Security Settings</h1>
<p className="text-muted-foreground">Manage your account security</p>
</div>
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-md text-red-600 text-sm">
{error}
</div>
)}
{success && (
<div className="p-3 bg-green-50 border border-green-200 rounded-md text-green-600 text-sm">
{success}
</div>
)}
<div className="space-y-4">
<h2 className="text-lg font-semibold">Change Password</h2>
<form onSubmit={passwordForm.handleSubmit(handleChangePassword)} className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">Current Password</label>
<input
type="password"
{...passwordForm.register('currentPassword')}
className="w-full px-3 py-2 border rounded-md"
placeholder="Enter current password"
/>
{passwordForm.formState.errors.currentPassword && (
<p className="text-sm text-red-500">{passwordForm.formState.errors.currentPassword.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">New Password</label>
<input
type="password"
{...passwordForm.register('newPassword')}
className="w-full px-3 py-2 border rounded-md"
placeholder="Enter new password"
/>
{passwordForm.formState.errors.newPassword && (
<p className="text-sm text-red-500">{passwordForm.formState.errors.newPassword.message}</p>
)}
</div>
<div className="space-y-2">
<label className="text-sm font-medium">Confirm New Password</label>
<input
type="password"
{...passwordForm.register('confirmPassword')}
className="w-full px-3 py-2 border rounded-md"
placeholder="Confirm new password"
/>
{passwordForm.formState.errors.confirmPassword && (
<p className="text-sm text-red-500">{passwordForm.formState.errors.confirmPassword.message}</p>
)}
</div>
<button
type="submit"
disabled={loading}
className="py-2 px-4 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Changing...' : 'Change Password'}
</button>
</form>
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold">Two-Factor Authentication (MFA)</h2>
<div className="p-4 border rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="font-medium">MFA Status</p>
<p className="text-sm text-muted-foreground">
{mfaEnabled ? 'Enabled' : 'Disabled'}
</p>
</div>
<span
className={`px-3 py-1 rounded-full text-sm ${
mfaEnabled
? 'bg-green-100 text-green-800'
: 'bg-gray-100 text-gray-800'
}`}
>
{mfaEnabled ? 'Active' : 'Inactive'}
</span>
</div>
{!mfaEnabled && !mfaSetup && (
<button
onClick={handleSetupMfa}
className="mt-4 py-2 px-4 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Setup MFA
</button>
)}
{mfaSetup && (
<div className="mt-4 space-y-4">
<div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p className="text-sm text-yellow-800">
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.)
</p>
<p className="mt-2 font-mono text-sm break-all">
Secret: {mfaSetup.secret}
</p>
</div>
<form onSubmit={mfaForm.handleSubmit(handleEnableMfa)} className="space-y-2">
<div className="space-y-2">
<label className="text-sm font-medium">Verification Code</label>
<input
type="text"
{...mfaForm.register('token')}
className="w-full px-3 py-2 border rounded-md"
placeholder="Enter 6-digit code"
maxLength={6}
/>
{mfaForm.formState.errors.token && (
<p className="text-sm text-red-500">{mfaForm.formState.errors.token.message}</p>
)}
</div>
<button
type="submit"
disabled={loading}
className="py-2 px-4 bg-green-600 text-white rounded-md hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Verifying...' : 'Enable MFA'}
</button>
</form>
</div>
)}
{mfaEnabled && (
<form onSubmit={mfaForm.handleSubmit(handleDisableMfa)} className="mt-4 space-y-2">
<div className="space-y-2">
<label className="text-sm font-medium">Verification Code</label>
<input
type="text"
{...mfaForm.register('token')}
className="w-full px-3 py-2 border rounded-md"
placeholder="Enter 6-digit code"
maxLength={6}
/>
{mfaForm.formState.errors.token && (
<p className="text-sm text-red-500">{mfaForm.formState.errors.token.message}</p>
)}
</div>
<button
type="submit"
disabled={loading}
className="py-2 px-4 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loading ? 'Disabling...' : 'Disable MFA'}
</button>
</form>
)}
</div>
</div>
<div className="space-y-4">
<h2 className="text-lg font-semibold">Login Sessions</h2>
<div className="space-y-2">
{sessions.map((session) => (
<div
key={session.id}
className="flex items-center justify-between p-4 border rounded-lg"
>
<div>
<p className="font-medium">{session.deviceName || 'Unknown Device'}</p>
<p className="text-sm text-muted-foreground">
IP: {session.ipAddress || 'Unknown'} |{' '}
Logged in: {new Date(session.createdAt).toLocaleDateString()}
</p>
</div>
<button
onClick={() => handleRevokeSession(session.id)}
className="py-1 px-3 bg-red-100 text-red-700 rounded-md hover:bg-red-200 text-sm"
>
Revoke
</button>
</div>
))}
{sessions.length === 0 && (
<p className="text-center text-muted-foreground py-4">No active sessions</p>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
export default function AboutPage() {
return (
<div className="space-y-6">
<h1 className="text-3xl font-bold tracking-tight">About FischerX</h1>
<p className="text-muted-foreground">
FischerX is a modern full-stack application built with Next.js 14+, TypeScript, Tailwind CSS, and Shadcn UI.
</p>
</div>
)
}

View File

@ -0,0 +1,7 @@
export default function PublicLayout({
children,
}: {
children: React.ReactNode
}) {
return <>{children}</>
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -0,0 +1,59 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96.1%;
--secondary-foreground: 222.2 47.4% 11.2%;
--muted: 210 40% 96.1%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96.1%;
--accent-foreground: 222.2 47.4% 11.2%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 210 40% 98%;
--primary-foreground: 222.2 47.4% 11.2%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -0,0 +1,50 @@
import type { Metadata } from "next"
import { Geist, Geist_Mono } from "next/font/google"
import "./globals.css"
import { QueryProvider } from "@/providers/query-provider"
import { Navbar, Sidebar } from "@/components/layout"
import { ErrorBoundary } from "@/components/error-boundary"
import { MonitoringProvider } from "@/providers/monitoring-provider"
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
})
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
})
export const metadata: Metadata = {
title: "FischerX",
description: "FischerX - A modern full-stack application",
}
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} suppressHydrationWarning>
<body className="min-h-full">
<MonitoringProvider>
<QueryProvider>
<ErrorBoundary>
<div className="relative flex min-h-screen flex-col">
<Navbar />
<div className="flex flex-1">
<Sidebar />
<main className="flex-1 md:ml-64">
<div className="container py-6">{children}</div>
</main>
</div>
</div>
</ErrorBoundary>
</QueryProvider>
</MonitoringProvider>
</body>
</html>
)
}

View File

@ -0,0 +1,17 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import Home from '@/app/page'
describe('Home Page', () => {
it('renders the heading', () => {
render(<Home />)
expect(screen.getByRole('heading', { name: /Welcome to FischerX/i })).toBeInTheDocument()
})
it('renders feature cards', () => {
render(<Home />)
expect(screen.getByText('Next.js 14+')).toBeInTheDocument()
expect(screen.getByText('TypeScript')).toBeInTheDocument()
expect(screen.getByText('Tailwind CSS')).toBeInTheDocument()
})
})

87
apps/web/src/app/page.tsx Normal file
View File

@ -0,0 +1,87 @@
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
export default function Home() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold tracking-tight">Welcome to FischerX</h1>
<p className="text-muted-foreground">
A modern full-stack application built with Next.js 14+
</p>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Next.js 14+</CardTitle>
<CardDescription>App Router with Server Components</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Built with the latest Next.js features including App Router and React Server Components.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>TypeScript</CardTitle>
<CardDescription>Full type safety</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
End-to-end type safety with TypeScript strict mode enabled.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tailwind CSS</CardTitle>
<CardDescription>Utility-first CSS framework</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Rapid UI development with Tailwind CSS and shadcn/ui components.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Zustand</CardTitle>
<CardDescription>State management</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Simple and fast state management with Zustand.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>React Query</CardTitle>
<CardDescription>Data fetching</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Powerful data fetching and caching with TanStack Query.
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Testing</CardTitle>
<CardDescription>Vitest + Playwright</CardDescription>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Comprehensive testing with Vitest for unit tests and Playwright for E2E tests.
</p>
</CardContent>
</Card>
</div>
<div className="flex gap-4">
<Button>Get Started</Button>
<Button variant="outline">Learn More</Button>
</div>
</div>
)
}

View File

@ -0,0 +1,173 @@
'use client';
import { useState, useEffect, ReactNode } from 'react';
import api from '@/lib/api';
interface RequirePermissionProps {
permission: string;
children: ReactNode;
fallback?: ReactNode;
}
export function RequirePermission({ permission, children, fallback }: RequirePermissionProps) {
const [hasPermission, setHasPermission] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkPermission = async () => {
try {
const response = await api.get('/users/me/permissions');
const permissions = response.data.data;
const [resource, action] = permission.split(':');
const hasPerm = permissions.some(
(p: any) => p.resource === resource && p.action === action,
);
setHasPermission(hasPerm);
} catch (err) {
console.error('Failed to check permission:', err);
setHasPermission(false);
} finally {
setLoading(false);
}
};
checkPermission();
}, [permission]);
if (loading) {
return <div className="animate-pulse h-8 bg-gray-200 rounded" />;
}
if (!hasPermission) {
return <>{fallback || null}</>;
}
return <>{children}</>;
}
interface RequireRoleProps {
role: string;
children: ReactNode;
fallback?: ReactNode;
}
export function RequireRole({ role, children, fallback }: RequireRoleProps) {
const [hasRole, setHasRole] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkRole = async () => {
try {
const response = await api.get('/users/me/roles');
const roles = response.data.data;
const hasRole = roles.some((r: any) => r.name === role);
setHasRole(hasRole);
} catch (err) {
console.error('Failed to check role:', err);
setHasRole(false);
} finally {
setLoading(false);
}
};
checkRole();
}, [role]);
if (loading) {
return <div className="animate-pulse h-8 bg-gray-200 rounded" />;
}
if (!hasRole) {
return <>{fallback || null}</>;
}
return <>{children}</>;
}
interface RequireAnyPermissionProps {
permissions: string[];
children: ReactNode;
fallback?: ReactNode;
}
export function RequireAnyPermission({ permissions, children, fallback }: RequireAnyPermissionProps) {
const [hasAnyPermission, setHasAnyPermission] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkPermissions = async () => {
try {
const response = await api.get('/users/me/permissions');
const userPermissions = response.data.data;
const hasAny = permissions.some((permission) => {
const [resource, action] = permission.split(':');
return userPermissions.some(
(p: any) => p.resource === resource && p.action === action,
);
});
setHasAnyPermission(hasAny);
} catch (err) {
console.error('Failed to check permissions:', err);
setHasAnyPermission(false);
} finally {
setLoading(false);
}
};
checkPermissions();
}, [permissions]);
if (loading) {
return <div className="animate-pulse h-8 bg-gray-200 rounded" />;
}
if (!hasAnyPermission) {
return <>{fallback || null}</>;
}
return <>{children}</>;
}
interface RequireAllPermissionsProps {
permissions: string[];
children: ReactNode;
fallback?: ReactNode;
}
export function RequireAllPermissions({ permissions, children, fallback }: RequireAllPermissionsProps) {
const [hasAllPermissions, setHasAllPermissions] = useState<boolean | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const checkPermissions = async () => {
try {
const response = await api.get('/users/me/permissions');
const userPermissions = response.data.data;
const hasAll = permissions.every((permission) => {
const [resource, action] = permission.split(':');
return userPermissions.some(
(p: any) => p.resource === resource && p.action === action,
);
});
setHasAllPermissions(hasAll);
} catch (err) {
console.error('Failed to check permissions:', err);
setHasAllPermissions(false);
} finally {
setLoading(false);
}
};
checkPermissions();
}, [permissions]);
if (loading) {
return <div className="animate-pulse h-8 bg-gray-200 rounded" />;
}
if (!hasAllPermissions) {
return <>{fallback || null}</>;
}
return <>{children}</>;
}

View File

@ -0,0 +1,78 @@
'use client';
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props {
children: ReactNode;
fallback?: ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
this.reportError(error, errorInfo);
}
reportError(error: Error, errorInfo: ErrorInfo) {
if (process.env.NODE_ENV === 'production') {
fetch('/api/errors', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
message: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack,
url: window.location.href,
userAgent: navigator.userAgent,
}),
});
}
}
resetError = () => {
this.setState({ hasError: false, error: undefined });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
}
return (
<div className="flex flex-col items-center justify-center min-h-screen p-4">
<div className="bg-card border rounded-lg p-6 max-w-md w-full text-center">
<h2 className="text-xl font-semibold mb-2">Something went wrong</h2>
<p className="text-muted-foreground mb-4">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<button
onClick={this.resetError}
className="px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90"
>
Try again
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@ -0,0 +1,113 @@
'use client';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useDownloadFile, useDeleteFile } from '@/hooks/use-files';
import type { File } from '@fischerx/types';
interface FilePreviewProps {
file: File;
onDelete?: () => void;
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
const isImage = (mimeType: string): boolean => mimeType.startsWith('image/');
export function FilePreview({ file, onDelete }: FilePreviewProps) {
const downloadFile = useDownloadFile();
const deleteFile = useDeleteFile();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const handleDownload = () => {
downloadFile.mutate(file.id);
};
const handleDelete = () => {
if (showDeleteConfirm) {
deleteFile.mutate(file.id, {
onSuccess: () => {
if (onDelete) onDelete();
},
});
} else {
setShowDeleteConfirm(true);
setTimeout(() => setShowDeleteConfirm(false), 3000);
}
};
const displayUrl = file.cdnUrl || file.url;
return (
<Card className="overflow-hidden">
<CardContent className="p-4">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-20 h-20 bg-gray-100 rounded flex items-center justify-center overflow-hidden">
{isImage(file.mimeType) ? (
<img
src={displayUrl}
alt={file.name}
className="w-full h-full object-cover"
/>
) : (
<div className="text-3xl">📄</div>
)}
</div>
<div className="flex-1 min-w-0">
<h4 className="font-medium text-lg truncate" title={file.name}>
{file.name}
</h4>
<p className="text-sm text-muted-foreground">
{formatFileSize(file.size)} {file.storageType}
</p>
{file.category && (
<p className="text-xs text-muted-foreground mt-1">Category: {file.category}</p>
)}
{file.tags && file.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{file.tags.map((tag) => (
<span
key={tag}
className="px-2 py-0.5 bg-gray-100 text-xs rounded-full"
>
{tag}
</span>
))}
</div>
)}
<div className="flex gap-2 mt-3">
<Button
variant="outline"
size="sm"
onClick={handleDownload}
disabled={downloadFile.isPending}
>
{downloadFile.isPending ? 'Downloading...' : 'Download'}
</Button>
<Button
variant={showDeleteConfirm ? 'destructive' : 'outline'}
size="sm"
onClick={handleDelete}
disabled={deleteFile.isPending}
>
{deleteFile.isPending
? 'Deleting...'
: showDeleteConfirm
? 'Confirm Delete'
: 'Delete'}
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,202 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { useUploadFile, useUploadMultipleFiles } from '@/hooks/use-files';
import type { FileUploadOptions } from '@fischerx/types';
interface FileUploaderProps {
onUploadSuccess?: (files: any[]) => void;
accept?: string;
maxSize?: number;
multiple?: boolean;
category?: string;
className?: string;
}
export function FileUploader({
onUploadSuccess,
accept,
maxSize = 10 * 1024 * 1024,
multiple = false,
category,
className,
}: FileUploaderProps) {
const [isDragging, setIsDragging] = useState(false);
const [uploadProgress, setUploadProgress] = useState(0);
const fileInputRef = useRef<HTMLInputElement>(null);
const uploadFile = useUploadFile();
const uploadMultipleFiles = useUploadMultipleFiles();
const validateFile = (file: File): string | null => {
if (maxSize && file.size > maxSize) {
return `File size must be less than ${(maxSize / 1024 / 1024).toFixed(0)}MB`;
}
if (accept) {
const acceptedTypes = accept.split(',');
const fileType = file.type;
const fileName = file.name;
const isAccepted = acceptedTypes.some(type => {
if (type.startsWith('.')) {
return fileName.toLowerCase().endsWith(type.toLowerCase());
}
if (type.endsWith('/*')) {
return fileType.startsWith(type.replace('/*', '/'));
}
return fileType === type;
});
if (!isAccepted) {
return 'File type not accepted';
}
}
return null;
};
const handleFiles = useCallback(
async (selectedFiles: FileList | File[]) => {
const files = Array.from(selectedFiles);
const errors: string[] = [];
files.forEach((file) => {
const error = validateFile(file);
if (error) {
errors.push(`${file.name}: ${error}`);
}
});
if (errors.length > 0) {
alert(errors.join('\n'));
return;
}
const options: FileUploadOptions = category ? { category } : {};
try {
setUploadProgress(0);
let result;
if (multiple && files.length > 1) {
result = await uploadMultipleFiles.mutateAsync({ files, options });
if (result.success && onUploadSuccess) {
onUploadSuccess(result.data);
}
} else if (files.length === 1) {
result = await uploadFile.mutateAsync({ file: files[0], options });
if (result.success && onUploadSuccess) {
onUploadSuccess([result.data]);
}
}
setUploadProgress(100);
setTimeout(() => setUploadProgress(0), 1000);
} catch (error) {
console.error('Upload failed:', error);
alert('Upload failed. Please try again.');
}
},
[uploadFile, uploadMultipleFiles, category, multiple, onUploadSuccess, maxSize, accept]
);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback(
(e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
if (e.dataTransfer.files.length > 0) {
handleFiles(e.dataTransfer.files);
}
},
[handleFiles]
);
const handleFileSelect = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
handleFiles(e.target.files);
}
e.target.value = '';
},
[handleFiles]
);
const isUploading = uploadFile.isPending || uploadMultipleFiles.isPending;
return (
<Card className={className}>
<CardHeader>
<CardTitle>Upload Files</CardTitle>
</CardHeader>
<CardContent>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => fileInputRef.current?.click()}
className={cn(
'flex flex-col items-center justify-center p-8 border-2 border-dashed rounded-lg cursor-pointer transition-colors',
isDragging
? 'border-primary bg-primary/10'
: 'border-gray-300 hover:border-primary hover:bg-gray-50'
)}
>
<input
ref={fileInputRef}
type="file"
multiple={multiple}
accept={accept}
onChange={handleFileSelect}
className="hidden"
disabled={isUploading}
/>
{isUploading ? (
<div className="text-center">
<div className="text-lg font-medium mb-2">Uploading...</div>
<div className="w-48 h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
) : (
<>
<div className="text-4xl mb-4">📁</div>
<div className="text-center">
<p className="text-lg font-medium mb-1">
{isDragging ? 'Drop files here' : 'Drag and drop files here'}
</p>
<p className="text-sm text-muted-foreground mb-4">or click to browse</p>
<Button variant="default" disabled={isUploading}>
Select Files
</Button>
</div>
</>
)}
</div>
{uploadFile.error && (
<div className="mt-4 p-3 bg-destructive/10 text-destructive rounded-md">
Error uploading file: {uploadFile.error.message}
</div>
)}
{uploadMultipleFiles.error && (
<div className="mt-4 p-3 bg-destructive/10 text-destructive rounded-md">
Error uploading files: {uploadMultipleFiles.error.message}
</div>
)}
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,2 @@
export { Navbar } from './navbar'
export { Sidebar } from './sidebar'

View File

@ -0,0 +1,46 @@
'use client'
import { Button } from '@/components/ui/button'
import { Menu, Sun, Moon } from 'lucide-react'
import { useAppStore } from '@/stores/appStore'
import Link from 'next/link'
export function Navbar() {
const { theme, toggleSidebar, setTheme } = useAppStore()
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-14 items-center">
<div className="mr-4 flex">
<Button
variant="ghost"
size="icon"
onClick={toggleSidebar}
aria-label="Toggle sidebar"
>
<Menu className="h-5 w-5" />
</Button>
<Link className="ml-4 flex items-center space-x-2" href="/">
<span className="font-bold">FischerX</span>
</Link>
</div>
<div className="flex flex-1 items-center justify-between space-x-2 md:justify-end">
<nav className="flex items-center space-x-2">
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
aria-label="Toggle theme"
>
{theme === 'light' ? (
<Moon className="h-5 w-5" />
) : (
<Sun className="h-5 w-5" />
)}
</Button>
</nav>
</div>
</div>
</header>
)
}

View File

@ -0,0 +1,48 @@
'use client'
import { cn } from '@/lib/utils'
import { useAppStore } from '@/stores/appStore'
import { Button } from '@/components/ui/button'
import { Home, Settings, Users, FileText, Folder } from 'lucide-react'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
const sidebarItems = [
{ icon: Home, label: 'Home', href: '/' },
{ icon: Folder, label: 'Files', href: '/files' },
{ icon: FileText, label: 'Documents', href: '/documents' },
{ icon: Users, label: 'Users', href: '/users' },
{ icon: Settings, label: 'Settings', href: '/settings' },
]
export function Sidebar() {
const { sidebarOpen } = useAppStore()
const pathname = usePathname()
return (
<aside
className={cn(
'fixed left-0 top-14 z-40 h-[calc(100vh-3.5rem)] w-64 border-r bg-background transition-transform duration-300 ease-in-out md:translate-x-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full'
)}
>
<div className="flex flex-col gap-2 p-4">
{sidebarItems.map((item) => {
const Icon = item.icon
const isActive = pathname === item.href
return (
<Link key={item.href} href={item.href}>
<Button
variant={isActive ? 'secondary' : 'ghost'}
className="w-full justify-start gap-2"
>
<Icon className="h-4 w-4" />
{item.label}
</Button>
</Link>
)
})}
</div>
</aside>
)
}

View File

@ -0,0 +1,53 @@
'use client';
import { useState } from 'react';
import { PaymentChannel } from '@/lib/payment-api';
import { Button } from '@/components/ui/button';
import { PaymentMethodSelector } from './PaymentMethodSelector';
interface PaymentConfirmProps {
amount: number;
subject: string;
channels: PaymentChannel[];
onConfirm: (channelCode: string) => void;
loading?: boolean;
}
export function PaymentConfirm({
amount,
subject,
channels,
onConfirm,
loading,
}: PaymentConfirmProps) {
const [selectedChannel, setSelectedChannel] = useState<string>();
return (
<div className="max-w-md mx-auto p-6">
<div className="text-center mb-8">
<h2 className="text-2xl font-bold mb-2"></h2>
<p className="text-gray-600">{subject}</p>
<div className="mt-4 text-4xl font-bold text-blue-600">
¥{amount.toFixed(2)}
</div>
</div>
<PaymentMethodSelector
channels={channels}
selectedChannel={selectedChannel}
onSelect={setSelectedChannel}
/>
<div className="mt-8">
<Button
className="w-full"
size="lg"
disabled={!selectedChannel || loading}
onClick={() => selectedChannel && onConfirm(selectedChannel)}
>
{loading ? '处理中...' : '立即支付'}
</Button>
</div>
</div>
);
}

View File

@ -0,0 +1,36 @@
'use client';
interface PaymentFailedProps {
error?: string;
onRetry?: () => void;
onBack?: () => void;
}
export function PaymentFailed({ error, onRetry, onBack }: PaymentFailedProps) {
return (
<div className="max-w-md mx-auto p-6 text-center">
<div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-bold mb-2"></h2>
{error && <p className="text-red-600 mb-4">{error}</p>}
<div className="flex gap-4 justify-center">
{onRetry && (
<button
onClick={onRetry}
className="px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
</button>
)}
{onBack && (
<button
onClick={onBack}
className="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
>
</button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,50 @@
'use client';
import { PaymentChannel } from '@/lib/payment-api';
interface PaymentMethodSelectorProps {
channels: PaymentChannel[];
selectedChannel?: string;
onSelect: (channelCode: string) => void;
}
const channelIcons: Record<string, string> = {
alipay: '💰',
wechat: '💬',
unionpay: '🏦',
};
export function PaymentMethodSelector({
channels,
selectedChannel,
onSelect,
}: PaymentMethodSelectorProps) {
return (
<div className="grid gap-4">
<h3 className="text-lg font-medium"></h3>
<div className="grid gap-3">
{channels.map((channel) => (
<button
key={channel.id}
onClick={() => onSelect(channel.code)}
className={`flex items-center gap-4 p-4 border rounded-lg transition-all ${
selectedChannel === channel.code
? 'border-blue-500 bg-blue-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<span className="text-3xl">
{channelIcons[channel.type] || '💳'}
</span>
<div className="text-left">
<div className="font-medium">{channel.name}</div>
{channel.isSandbox && (
<div className="text-xs text-gray-500"></div>
)}
</div>
</button>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,44 @@
'use client';
import { PaymentOrder } from '@/lib/payment-api';
interface PaymentSuccessProps {
order: PaymentOrder;
paymentUrl?: string;
onContinue?: () => void;
}
export function PaymentSuccess({ order, paymentUrl, onContinue }: PaymentSuccessProps) {
return (
<div className="max-w-md mx-auto p-6 text-center">
<div className="text-6xl mb-4"></div>
<h2 className="text-2xl font-bold mb-2"></h2>
<p className="text-gray-600 mb-4">: {order.orderNo}</p>
<div className="text-3xl font-bold text-green-600 mb-6">
¥{order.amount.toFixed(2)}
</div>
{paymentUrl && (
<div className="mb-6">
<a
href={paymentUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-block px-6 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600"
>
</a>
</div>
)}
{onContinue && (
<button
onClick={onContinue}
className="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
>
</button>
)}
</div>
);
}

View File

@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { Button } from '@/components/ui/button'
describe('Button Component', () => {
it('renders with default variant', () => {
render(<Button>Click me</Button>)
const button = screen.getByRole('button', { name: /click me/i })
expect(button).toBeInTheDocument()
})
it('renders with different variants', () => {
const { rerender } = render(<Button variant="default">Default</Button>)
expect(screen.getByRole('button', { name: /default/i })).toBeInTheDocument()
rerender(<Button variant="destructive">Destructive</Button>)
expect(screen.getByRole('button', { name: /destructive/i })).toBeInTheDocument()
rerender(<Button variant="outline">Outline</Button>)
expect(screen.getByRole('button', { name: /outline/i })).toBeInTheDocument()
})
it('renders with different sizes', () => {
const { rerender } = render(<Button size="default">Default Size</Button>)
expect(screen.getByRole('button', { name: /default size/i })).toBeInTheDocument()
rerender(<Button size="sm">Small</Button>)
expect(screen.getByRole('button', { name: /small/i })).toBeInTheDocument()
rerender(<Button size="lg">Large</Button>)
expect(screen.getByRole('button', { name: /large/i })).toBeInTheDocument()
})
it('is disabled when disabled prop is true', () => {
render(<Button disabled>Disabled</Button>)
expect(screen.getByRole('button', { name: /disabled/i })).toBeDisabled()
})
})

View File

@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/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.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@ -0,0 +1,79 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fileApi } from '@/lib/file-api';
import type { File, FileUploadOptions, FileListParams, ProcessImageOptions } from '@fischerx/types';
export const useFiles = (params?: FileListParams) => {
return useQuery({
queryKey: ['files', params],
queryFn: () => fileApi.listFiles(params),
});
};
export const useFile = (id: string) => {
return useQuery({
queryKey: ['file', id],
queryFn: () => fileApi.getFile(id),
enabled: !!id,
});
};
export const useUploadFile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ file, options }: { file: File | Blob; options?: FileUploadOptions }) =>
fileApi.uploadFile(file, options),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] });
},
});
};
export const useUploadMultipleFiles = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ files, options }: { files: (File | Blob)[]; options?: FileUploadOptions }) =>
fileApi.uploadMultipleFiles(files, options),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] });
},
});
};
export const useUpdateFile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Pick<File, 'name' | 'category' | 'tags'>> }) =>
fileApi.updateFile(id, data),
onSuccess: (_, { id }) => {
queryClient.invalidateQueries({ queryKey: ['files'] });
queryClient.invalidateQueries({ queryKey: ['file', id] });
},
});
};
export const useDeleteFile = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => fileApi.deleteFile(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['files'] });
},
});
};
export const useDownloadFile = () => {
return useMutation({
mutationFn: (id: string) => fileApi.downloadFile(id),
});
};
export const useProcessImage = () => {
return useMutation({
mutationFn: ({ id, options }: { id: string; options: ProcessImageOptions }) =>
fileApi.processImage(id, options),
});
};

View File

@ -0,0 +1,78 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { paymentApi, PaymentChannel, CreatePaymentRequest, CreateRefundRequest } from '@/lib/payment-api';
export const usePaymentChannels = (onlyEnabled?: boolean) => {
return useQuery({
queryKey: ['paymentChannels', onlyEnabled],
queryFn: () => paymentApi.getChannels(onlyEnabled),
});
};
export const usePaymentOrders = (page: number = 1, limit: number = 20) => {
return useQuery({
queryKey: ['paymentOrders', page, limit],
queryFn: () => paymentApi.getOrders(page, limit),
});
};
export const usePaymentOrder = (id?: string) => {
return useQuery({
queryKey: ['paymentOrder', id],
queryFn: () => id ? paymentApi.getOrder(id) : null,
enabled: !!id,
});
};
export const useCreatePayment = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreatePaymentRequest) => paymentApi.createPayment(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paymentOrders'] });
},
});
};
export const useQueryOrderStatus = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => paymentApi.queryOrderStatus(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ['paymentOrder', id] });
queryClient.invalidateQueries({ queryKey: ['paymentOrders'] });
},
});
};
export const useCancelOrder = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => paymentApi.cancelOrder(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: ['paymentOrder', id] });
queryClient.invalidateQueries({ queryKey: ['paymentOrders'] });
},
});
};
export const useCreateRefund = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateRefundRequest) => paymentApi.createRefund(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['paymentOrders'] });
queryClient.invalidateQueries({ queryKey: ['paymentRefunds'] });
},
});
};
export const usePaymentRefunds = (page: number = 1, limit: number = 20) => {
return useQuery({
queryKey: ['paymentRefunds', page, limit],
queryFn: () => paymentApi.getRefunds(page, limit),
});
};

29
apps/web/src/lib/api.ts Normal file
View File

@ -0,0 +1,29 @@
import axios from 'axios';
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api/v1',
headers: {
'Content-Type': 'application/json',
},
});
api.interceptors.request.use((config) => {
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
localStorage.removeItem('token');
window.location.href = '/auth/login';
}
return Promise.reject(error);
}
);
export default api;

View File

@ -0,0 +1,90 @@
import api from './api';
import type { File, FileUploadOptions, FileListParams, ProcessImageOptions } from '@fischerx/types';
export const fileApi = {
uploadFile: async (file: File | Blob, options?: FileUploadOptions): Promise<{ success: boolean; data: File }> => {
const formData = new FormData();
formData.append('file', file);
if (options?.category) formData.append('category', options.category);
if (options?.tags) formData.append('tags', JSON.stringify(options.tags));
if (options?.description) formData.append('description', options.description);
const response = await api.post('/files/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
uploadMultipleFiles: async (
files: (File | Blob)[],
options?: FileUploadOptions
): Promise<{ success: boolean; data: File[]; errors: Array<{ index: number; error: string }> }> => {
const formData = new FormData();
files.forEach((file) => formData.append('files', file));
if (options?.category) formData.append('category', options.category);
if (options?.tags) formData.append('tags', JSON.stringify(options.tags));
if (options?.description) formData.append('description', options.description);
const response = await api.post('/files/upload/multi', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
return response.data;
},
listFiles: async (params?: FileListParams): Promise<{ success: boolean; data: File[]; meta: { page: number; limit: number; total: number } }> => {
const response = await api.get('/files', { params });
return response.data;
},
getFile: async (id: string): Promise<{ success: boolean; data: File }> => {
const response = await api.get(`/files/${id}`);
return response.data;
},
downloadFile: async (id: string): Promise<void> => {
const response = await api.get(`/files/${id}/download`, {
responseType: 'blob',
});
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const contentDisposition = response.headers['content-disposition'];
const filename = contentDisposition?.match(/filename="?([^"]+)"?/)?.[1] || 'download';
a.download = filename;
a.click();
window.URL.revokeObjectURL(url);
},
getFileUrl: async (id: string, expiresIn?: number): Promise<{ success: boolean; data: { url: string } }> => {
const response = await api.get(`/files/${id}/url`, { params: { expiresIn } });
return response.data;
},
updateFile: async (id: string, data: Partial<Pick<File, 'name' | 'category' | 'tags'>>): Promise<{ success: boolean; data: File }> => {
const response = await api.put(`/files/${id}`, data);
return response.data;
},
deleteFile: async (id: string): Promise<{ success: boolean; message: string }> => {
const response = await api.delete(`/files/${id}`);
return response.data;
},
processImage: async (id: string, options: ProcessImageOptions): Promise<void> => {
const response = await api.post(`/files/${id}/process`, options, {
responseType: 'blob',
});
const blob = new Blob([response.data]);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `processed-${id}.jpg`;
a.click();
window.URL.revokeObjectURL(url);
},
};

View File

@ -0,0 +1,102 @@
import { apiClient } from './api';
export interface PaymentChannel {
id: string;
code: string;
name: string;
type: string;
isEnabled: boolean;
isSandbox: boolean;
sortOrder: number;
createdAt: string;
updatedAt: string;
}
export interface PaymentOrder {
id: string;
orderNo: string;
channelOrderNo?: string;
userId: string;
channelId?: string;
amount: number;
currency: string;
subject: string;
body?: string;
status: 'pending' | 'paid' | 'failed' | 'cancelled' | 'refunded';
paidAt?: string;
cancelledAt?: string;
metadata?: Record<string, any>;
channel?: PaymentChannel;
createdAt: string;
updatedAt: string;
}
export interface PaymentRefund {
id: string;
refundNo: string;
channelRefundNo?: string;
orderId: string;
channelId?: string;
userId: string;
amount: number;
reason?: string;
status: 'pending' | 'success' | 'failed';
processedAt?: string;
createdAt: string;
updatedAt: string;
}
export interface CreatePaymentRequest {
channelCode: string;
amount: number;
subject: string;
body?: string;
returnUrl?: string;
metadata?: Record<string, any>;
}
export interface CreatePaymentResponse {
order: PaymentOrder;
paymentData: {
success: boolean;
channelOrderNo?: string;
paymentUrl?: string;
qrCode?: string;
extra?: Record<string, any>;
};
}
export interface CreateRefundRequest {
orderId: string;
amount: number;
reason?: string;
}
export const paymentApi = {
getChannels: (onlyEnabled?: boolean) =>
apiClient.get<PaymentChannel[]>('/payment/channels', { params: { onlyEnabled } }),
createPayment: (data: CreatePaymentRequest) =>
apiClient.post<CreatePaymentResponse>('/payment/orders', data),
getOrders: (page: number = 1, limit: number = 20) =>
apiClient.get(`/payment/orders`, { params: { page, limit } }),
getOrder: (id: string) =>
apiClient.get<PaymentOrder>(`/payment/orders/${id}`),
queryOrderStatus: (id: string) =>
apiClient.post(`/payment/orders/${id}/query`),
cancelOrder: (id: string) =>
apiClient.post(`/payment/orders/${id}/cancel`),
createRefund: (data: CreateRefundRequest) =>
apiClient.post<PaymentRefund>('/payment/refunds', data),
getRefunds: (page: number = 1, limit: number = 20) =>
apiClient.get(`/payment/refunds`, { params: { page, limit } }),
getRefund: (id: string) =>
apiClient.get<PaymentRefund>(`/payment/refunds/${id}`),
};

View File

@ -0,0 +1,17 @@
import { QueryClient, defaultShouldDehydrateQuery } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
gcTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
},
dehydrate: {
shouldDehydrateQuery: (query) =>
defaultShouldDehydrateQuery(query) ||
query.state.status === 'pending',
},
},
})

View File

@ -0,0 +1,38 @@
import { WebTracerProvider } from '@opentelemetry/sdk-trace-web';
import { SimpleSpanProcessor, ConsoleSpanExporter } from '@opentelemetry/sdk-trace-base';
import { ZoneContextManager } from '@opentelemetry/context-zone';
import { FetchInstrumentation } from '@opentelemetry/instrumentation-fetch';
import { XMLHttpRequestInstrumentation } from '@opentelemetry/instrumentation-xml-http-request';
import { registerInstrumentations } from '@opentelemetry/instrumentation';
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http';
import { Resource } from '@opentelemetry/resources';
const isProduction = process.env.NODE_ENV === 'production';
const resource = new Resource({
'service.name': 'fischerx-web',
});
const provider = new WebTracerProvider({ resource });
const traceExporter = isProduction
? new OTLPTraceExporter({
url: process.env.NEXT_PUBLIC_OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4318/v1/traces',
})
: new ConsoleSpanExporter();
provider.addSpanProcessor(new SimpleSpanProcessor(traceExporter));
provider.register({
contextManager: new ZoneContextManager(),
});
registerInstrumentations({
instrumentations: [
new FetchInstrumentation({
ignoreUrls: ['/health', '/metrics'],
}),
new XMLHttpRequestInstrumentation(),
],
});
export const tracer = provider.getTracer('fischerx-web');

View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1,27 @@
import { ReportCallback } from 'web-vitals';
export const reportWebVitals = (onPerfEntry?: ReportCallback) => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export const logVitals = (metric: any) => {
console.log('Web Vital:', metric);
if (process.env.NODE_ENV === 'production') {
fetch('/api/vitals', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(metric),
});
}
};

View File

@ -0,0 +1,40 @@
'use client';
import React, { useEffect, ReactNode } from 'react';
import { logVitals, reportWebVitals } from '@/lib/web-vitals';
interface Props {
children: ReactNode;
}
export function MonitoringProvider({ children }: Props) {
useEffect(() => {
reportWebVitals(logVitals);
}, []);
useEffect(() => {
if (process.env.NODE_ENV === 'production') {
import('@/lib/telemetry').catch(console.error);
}
}, []);
useEffect(() => {
const handleError = (event: ErrorEvent) => {
console.error('Global error:', event.error);
};
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
console.error('Unhandled promise rejection:', event.reason);
};
window.addEventListener('error', handleError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);
return () => {
window.removeEventListener('error', handleError);
window.removeEventListener('unhandledrejection', handleUnhandledRejection);
};
}, []);
return <>{children}</>;
}

View File

@ -0,0 +1,15 @@
'use client'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '@/lib/react-query'
import { useState } from 'react'
export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClientState] = useState(() => queryClient)
return (
<QueryClientProvider client={queryClientState}>
{children}
</QueryClientProvider>
)
}

View File

@ -0,0 +1,15 @@
import { create } from 'zustand'
interface AppState {
sidebarOpen: boolean
theme: 'light' | 'dark'
toggleSidebar: () => void
setTheme: (theme: 'light' | 'dark') => void
}
export const useAppStore = create<AppState>()((set) => ({
sidebarOpen: true,
theme: 'light',
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setTheme: (theme) => set({ theme }),
}))

View File

@ -0,0 +1,30 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface User {
id: string
name: string
email: string
avatar?: string
}
interface UserState {
user: User | null
isAuthenticated: boolean
setUser: (user: User | null) => void
logout: () => void
}
export const useUserStore = create<UserState>()(
persist(
(set) => ({
user: null,
isAuthenticated: false,
setUser: (user) => set({ user, isAuthenticated: !!user }),
logout: () => set({ user: null, isAuthenticated: false }),
}),
{
name: 'user-storage',
}
)
)

View File

@ -0,0 +1 @@
import '@testing-library/jest-dom'

34
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}

18
apps/web/vitest.config.ts Normal file
View File

@ -0,0 +1,18 @@
import { defineConfig } from 'vitest/config'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./src/test/setup.ts'],
include: ['src/**/*.{test,spec}.{ts,tsx}'],
},
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})

90
docker-compose.yml Normal file
View File

@ -0,0 +1,90 @@
version: '3.8'
services:
postgres:
build:
context: ./infra/db
dockerfile: Dockerfile
container_name: fischerx-postgres
environment:
POSTGRES_USER: ${DB_USER:-fischerx}
POSTGRES_PASSWORD: ${DB_PASSWORD:-fischerx}
POSTGRES_DB: ${DB_NAME:-fischerx}
ports:
- "${DB_PORT:-5432}:5432"
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- fischerx-network
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-fischerx}"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
container_name: fischerx-redis
ports:
- "${REDIS_PORT:-6379}:6379"
volumes:
- redis-data:/data
networks:
- fischerx-network
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
api:
build:
context: ./services/api
dockerfile: Dockerfile
container_name: fischerx-api
ports:
- "${API_PORT:-3001}:3001"
environment:
NODE_ENV: ${NODE_ENV:-development}
PORT: ${API_PORT:-3001}
DATABASE_URL: postgresql://${DB_USER:-fischerx}:${DB_PASSWORD:-fischerx}@postgres:5432/${DB_NAME:-fischerx}?schema=public
REDIS_HOST: redis
REDIS_PORT: 6379
JWT_SECRET: ${JWT_SECRET:-your-super-secret-jwt-key-change-in-production}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
networks:
- fischerx-network
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3001/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
web:
build:
context: ./apps/web
dockerfile: Dockerfile
container_name: fischerx-web
ports:
- "${WEB_PORT:-3000}:3000"
environment:
NODE_ENV: ${NODE_ENV:-development}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3001}
depends_on:
api:
condition: service_healthy
networks:
- fischerx-network
volumes:
postgres-data:
redis-data:
networks:
fischerx-network:
driver: bridge

82
docs/README.md Normal file
View File

@ -0,0 +1,82 @@
# FischerX 文档中心
> **文档版本**: v1.0.0
> **最后更新**: 2026-05-25
欢迎来到 FischerX 文档中心!这里包含了项目的所有文档。
## 快速导航
### 架构文档 (architecture/)
- [系统架构](./architecture/system-architecture.md](./architecture/system-architecture.md) - 系统整体架构设计
- [技术选型](./architecture/tech-stack.md) - 技术选型说明
- [架构决策记录 (ADR)](./architecture/adr/) - 重要技术决策历史
### 需求文档 (requirements/)
- [产品需求文档 (PRD)](./requirements/prd.md) - 产品需求说明
- [用户故事](./requirements/product/user-stories.md) - 用户故事列表
- [功能规格](./requirements/features/) - 详细功能规格
### 设计文档 (design/)
- [数据库设计](./design/database/schema.md) - 数据库 Schema 设计
- [API 设计](./design/api/) - API 接口设计
- [UI 设计](./design/ui/) - UI 设计文档
### 开发文档 (development/)
- [快速开始](./development/quick-start.md) - 新手指南
- [开发规范](./development/standards/) - 代码规范
- [最佳实践](./development/best-practices/) - 开发最佳实践
### API 文档 (api/)
- [API 参考](./api/) - 接口文档
- [SDK 文档](./api/sdk/) - SDK 使用说明
- [错误码](./api/error-codes.md) - 错误码说明
### 测试文档 (testing/)
- [测试计划](./testing/test-plan.md) - 测试计划
- [测试用例](./testing/test-cases/) - 测试用例
- [测试报告](./testing/test-reports/) - 测试报告
### 部署文档 (deployment/)
- [环境配置](./deployment/environments.md) - 环境配置说明
- [部署指南](./deployment/deployment-guide.md) - 部署操作指南
- [CI/CD](./deployment/ci-cd.md) - 持续集成/部署
### 运维文档 (operations/)
- [监控告警](./operations/monitoring.md) - 监控配置
- [日志管理](./operations/logging.md) - 日志管理
- [故障处理](./operations/troubleshooting/) - 故障排查指南
- [备份恢复](./operations/backup-restore.md) - 数据备份
### 用户文档 (user/)
- [用户手册](./user/user-manual.md) - 用户使用手册
- [快速入门](./user/getting-started.md) - 用户入门指南
- [FAQ](./user/faq.md) - 常见问题
### 文档模板 (templates/)
- [ADR 模板](./templates/adr-template.md) - 架构决策记录模板
- [API 文档模板](./templates/api-doc-template.md) - API 文档模板
---
## 文档规范
所有文档遵循 [FischerX 文档管理规范](../FischerX%E6%96%87%E6%A1%A3%E7%AE%A1%E8%8C%83.md)。
## 贡献文档
如果你发现文档有问题或需要补充,请:
1. Fork 本仓库
2. 修改文档
3. 提交 Pull Request
4. 等待审核合并
## 获取帮助:
- 提交 Issue
- 联系文档负责人
- 飞书群反馈
---
> **最后更新**: 2026-05-25

0
docs/api/.gitkeep Normal file
View File

71
docs/api/error-codes.md Normal file
View File

@ -0,0 +1,71 @@
# API 错误码说明
> **文档版本**: v1.0.0
> **创建日期**: 2026-05-25
> **最后更新**: 2026-05-25
> **文档作者**: 后端开发
> **文档状态**: 已发布
> **适用范围**: 前端开发、后端开发
## 目录
- [一、错误码规范](#一错误码规范)
- [二、通用错误码](#二通用错误码)
- [三、业务错误码](#三业务错误码)
---
## 一、错误码规范
错误码格式:`{类型}{模块}{序号}`
| 类型 | 说明 |
|------|------|
| 200 | 成功 |
| 400 | 请求错误 |
| 401 | 未授权 |
| 403 | 禁止访问 |
| 404 | 资源不存在 |
| 500 | 服务器错误 |
---
## 二、通用错误码
| 错误码 | HTTP 状态 | 说明 | 解决方案 |
|--------|----------|------|---------|
| 200 | 200 | 请求成功 | |
| 400001 | 400 | 参数错误 | 检查请求参数 |
| 400002 | 400 | 参数缺失 | 补充必填参数 |
| 401001 | 401 | 未登录 | 请先登录 |
| 401002 | 401 | Token 过期 | 重新登录 |
| 403001 | 403 | 无权限访问 | 联系管理员 |
| 404001 | 404 | 资源不存在 | 检查资源 ID |
| 500001 | 500 | 服务器内部错误 | 联系技术支持 |
---
## 三、业务错误码
### 3.1 用户模块 (1)
| 错误码 | HTTP 状态 | 说明 | 解决方案 |
|--------|----------|------|---------|
| 401101 | 400 | 手机号已注册 | 更换手机号 |
| 401102 | 400 | 验证码错误 | 重新获取验证码 |
| 401103 | 400 | 验证码已过期 | 重新获取验证码 |
### 3.2 支付模块 (2)
| 错误码 | HTTP 状态 | 说明 | 解决方案 |
|--------|----------|------|---------|
| 402201 | 400 | 订单不存在 | 检查订单号 |
| 402202 | 400 | 订单已支付 | 请勿重复支付 |
| 402203 | 400 | 余额不足 | 充值后重试 |
---
> **文档维护**: 本文档由后端开发维护,新增错误码时更新
> **反馈渠道**: 如有问题,请联系后端负责人
> **最后更新**: 2026-05-25
> **文档状态**: 已发布

View File

View File

@ -0,0 +1,114 @@
# ADR-0001: 采用 Monorepo 架构
| 项目 | 内容 |
|------|------|
| **状态** | 已接受 |
| **创建日期** | 2026-05-25 |
| **决策者** | 架构师 |
| **最后更新** | 2026-05-25 |
## 上下文
在项目启动阶段,需要决定代码仓库的组织方式。现代软件开发中有两种主流方式:
- **Monorepo单体仓库**: 所有相关代码放在同一个仓库中
- **Polyrepo多仓库**: 每个模块/服务独立一个仓库
## 决策
决定采用 **Monorepo** 架构,使用 Turborepo 作为构建工具。
## 理由
### 为什么选择 Monorepo
| 优势 | 说明 |
|------|------|
| **代码共享** | 前后端可以共享类型定义、工具函数、常量等 |
| **依赖管理** | 统一管理依赖版本,避免版本冲突 |
| **原子提交** | 跨模块的修改可以在同一个提交中完成 |
| **重构方便** | 跨项目重构更容易IDE 支持更好 |
| **统一工具链** | 统一的构建、测试、lint 配置 |
| **团队协作** | 团队成员可以方便地查看和修改所有代码 |
### 为什么选择 Turborepo
| 优势 | 说明 |
|------|------|
| **增量构建** | 只重新构建变更的包,提升构建速度 |
| **任务并行** | 并行执行独立任务,充分利用多核 CPU |
| **远程缓存** | 支持远程构建缓存,团队共享 |
| **简单易用** | 配置简单,开箱即用 |
| **生态完善** | 与 Next.js、NestJS 等框架集成良好 |
## 替代方案考虑
### 方案 1: Polyrepo多仓库
| 优势 | 劣势 |
|------|------|
| 权限控制更精细 | 代码共享困难 |
| 仓库体积小 | 依赖版本管理复杂 |
| 独立部署 | 跨模块修改困难 |
| | 工具链配置重复 |
**不选择理由**: 项目初期模块间耦合度高代码共享需求强烈Polyrepo 会增加开发成本。
### 方案 2: Nx
| 优势 | 劣势 |
|------|------|
| 功能更强大 | 学习曲线陡峭 |
| 插件生态丰富 | 配置复杂 |
| 图形化界面 | 对于小型项目过于重型 |
**不选择理由**: Turborepo 更轻量满足当前需求Nx 对于当前项目过于复杂。
## 后果
### 正面后果
- 开发效率提升,代码共享方便
- 统一的代码规范和工具链
- 跨模块重构更容易
- 依赖版本统一管理
### 负面后果
- 仓库体积会逐渐增大
- 需要配置合理的代码组织方式
- 需要学习 Turborepo 的使用
- CI/CD 需要针对 Monorepo 优化
## 实现计划
1. 使用 `pnpm` 作为包管理器(已配置)
2. 使用 `turborepo` 作为构建工具(已配置)
3. 目录结构遵循:
```
apps/ # 应用
web/ # Web 应用
admin/ # 管理后台
packages/ # 共享包
core/ # 核心业务逻辑
ui/ # UI 组件库
utils/ # 工具函数
types/ # 类型定义
services/ # 后端服务
api/ # API 服务
```
## 相关决策
- ADR-0002: 采用 Next.js 作为前端框架
- ADR-0003: 采用 NestJS 作为后端框架
## 参考
- [Turborepo 官方文档](https://turbo.build/repo)
- [Monorepo vs Polyrepo](https://semaphoreci.com/blog/monorepo-vs-polyrepo)
- [pnpm workspaces](https://pnpm.io/workspaces)
---
> **变更记录**
> 2026-05-25: 初始创建,状态设为已接受

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