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:
commit
af4de6b86a
|
|
@ -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
|
||||
|
|
@ -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=
|
||||
|
|
@ -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"],
|
||||
};
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 }}
|
||||
|
|
@ -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 }}
|
||||
|
|
@ -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 }}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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/
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"semi": true,
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": false,
|
||||
"printWidth": 80,
|
||||
"tabWidth": 2,
|
||||
"useTabs": false,
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "always",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
|
|
@ -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:dev),API可正常访问
|
||||
|
||||
### 共享包验收
|
||||
- [ ] 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响应时间<200ms(P95)
|
||||
- [ ] 系统吞吐量>1000 QPS
|
||||
- [ ] 系统可用性>99.9%
|
||||
|
||||
### 安全性验收
|
||||
- [ ] 无高危安全漏洞
|
||||
- [ ] 数据加密存储
|
||||
- [ ] 访问控制完善
|
||||
- [ ] 安全审计通过
|
||||
|
||||
### 合规性验收
|
||||
- [ ] ICP备案完成
|
||||
- [ ] 数据本地化存储
|
||||
- [ ] 实名认证合规
|
||||
- [ ] 内容审核合规
|
||||
- [ ] 数据安全法合规
|
||||
- [ ] 个人信息保护法合规
|
||||
|
||||
### 文档完整性验收
|
||||
- [ ] 架构设计文档完整
|
||||
- [ ] API文档完整
|
||||
- [ ] 开发指南完整
|
||||
- [ ] 部署指南完整
|
||||
- [ ] 运维文档完整
|
||||
|
||||
### 测试覆盖率验收
|
||||
- [ ] 前端单元测试覆盖率>80%
|
||||
- [ ] 后端单元测试覆盖率>80%
|
||||
- [ ] 集成测试覆盖完整
|
||||
- [ ] E2E测试覆盖完整
|
||||
|
||||
### 可维护性验收
|
||||
- [ ] 代码规范统一
|
||||
- [ ] 模块划分清晰
|
||||
- [ ] 文档完善
|
||||
- [ ] 监控告警完善
|
||||
- [ ] 日志系统完善
|
||||
|
|
@ -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/utils):UI组件、工具函数、类型定义
|
||||
- **业务独立功能**(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
|
||||
|
||||
无删除需求(全新项目)
|
||||
|
|
@ -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]
|
||||
|
|
@ -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/)
|
||||
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
|
|
@ -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,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
|
||||
|
|
@ -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"]
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,6 @@
|
|||
allowBuilds:
|
||||
sharp: set this to true or false
|
||||
unrs-resolver: set this to true or false
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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 |
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <>{children}</>
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
export default function PublicLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return <>{children}</>
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
export { Navbar } from './navbar'
|
||||
export { Sidebar } from './sidebar'
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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 }
|
||||
|
|
@ -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),
|
||||
});
|
||||
};
|
||||
|
|
@ -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),
|
||||
});
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
@ -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}`),
|
||||
};
|
||||
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -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');
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { type ClassValue, clsx } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
|
@ -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),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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}</>;
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }),
|
||||
}))
|
||||
|
|
@ -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',
|
||||
}
|
||||
)
|
||||
)
|
||||
|
|
@ -0,0 +1 @@
|
|||
import '@testing-library/jest-dom'
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
@ -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,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
|
||||
> **文档状态**: 已发布
|
||||
|
|
@ -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
Loading…
Reference in New Issue