diff --git a/.gitignore b/.gitignore index a26fc80..a4c8f3f 100644 --- a/.gitignore +++ b/.gitignore @@ -147,3 +147,4 @@ Thumbs.db tmp/ temp/ nul +/nextjs-frontend \ No newline at end of file diff --git a/crop-x/.env.example b/crop-x/.env.example new file mode 100644 index 0000000..5b24214 --- /dev/null +++ b/crop-x/.env.example @@ -0,0 +1,35 @@ +# 环境配置示例文件 +# 复制此文件为 .env.local 并根据实际情况修改配置 + +# 当前环境: development, test, production +NODE_ENV=development + +# API 服务器地址 (用于 API 代码生成) +API_BASE_URL=http://localhost:8080 + +# React 应用配置 (用于前端运行时) +REACT_APP_API_URL=http://localhost:8080 + +# OpenAPI 文档地址 +REACT_APP_OPENAPI_URL=http://localhost:8080/openapi.json + +# 其他可选配置 +# REACT_APP_API_KEY=your-api-key-here +# REACT_APP_DEBUG=true + +# 不同环境配置示例: +# +# 开发环境: +# NODE_ENV=development +# API_BASE_URL=http://localhost:8080 +# REACT_APP_API_URL=http://localhost:8080 +# +# 测试环境: +# NODE_ENV=test +# API_BASE_URL=http://test-api.example.com +# REACT_APP_API_URL=http://test-api.example.com +# +# 生产环境: +# NODE_ENV=production +# API_BASE_URL=https://api.example.com +# REACT_APP_API_URL=https://api.example.com \ No newline at end of file diff --git a/crop-x/.gitignore b/crop-x/.gitignore new file mode 100644 index 0000000..07f1780 --- /dev/null +++ b/crop-x/.gitignore @@ -0,0 +1,113 @@ +# Dependencies +node_modules/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Production builds +.next/ +out/ +dist/ +build/ + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# API 相关文件 +# 忽略从服务器下载的临时 OpenAPI JSON 文件 +api/v1-from-server.json + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt + +# Storybook build outputs +.out +.storybook-out + +# Temporary folders +tmp/ +temp/ + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Local development +.local + +# TypeScript +*.tsbuildinfo \ No newline at end of file diff --git a/crop-x/api/v1.yaml b/crop-x/api/v1.yaml deleted file mode 100644 index 8dcec37..0000000 --- a/crop-x/api/v1.yaml +++ /dev/null @@ -1,493 +0,0 @@ -openapi: 3.0.3 -info: - title: 智慧农业生产管理系统 API - description: 农业生产管理系统的后端接口文档 - version: 1.0.0 - contact: - name: API Support - email: support@smart-crop.com - -servers: - - url: https://gitea-admin-test-app-app.dev.maimaiag.com/api/v1 - description: 开发/测试/生产环境(统一地址) - -paths: - # 用户管理 - /users: - get: - summary: 获取用户列表 - description: 获取所有用户的分页列表 - tags: - - 用户管理 - parameters: - - name: page - in: query - description: 页码 - required: false - schema: - type: integer - default: 1 - minimum: 1 - - name: limit - in: query - description: 每页数量 - required: false - schema: - type: integer - default: 20 - minimum: 1 - maximum: 100 - - name: search - in: query - description: 搜索关键词 - required: false - schema: - type: string - maxLength: 100 - responses: - '200': - description: 成功获取用户列表 - content: - application/json: - schema: - type: object - properties: - code: - type: integer - example: 200 - message: - type: string - example: "success" - data: - type: object - properties: - users: - type: array - items: - $ref: '#/components/schemas/User' - total: - type: integer - example: 100 - page: - type: integer - example: 1 - limit: - type: integer - example: 20 - - /users/{id}: - get: - summary: 获取用户详情 - description: 根据用户ID获取用户详细信息 - tags: - - 用户管理 - parameters: - - name: id - in: path - required: true - description: 用户ID - schema: - type: integer - minimum: 1 - responses: - '200': - description: 成功获取用户详情 - content: - application/json: - schema: - type: object - properties: - code: - type: integer - example: 200 - message: - type: string - example: "success" - data: - $ref: '#/components/schemas/User' - '404': - description: 用户不存在 - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - - # 农机管理 - /machinery: - get: - summary: 获取农机列表 - description: 获取所有农机的分页列表 - tags: - - 农机管理 - parameters: - - name: page - in: query - schema: - type: integer - default: 1 - - name: limit - in: query - schema: - type: integer - default: 20 - - name: status - in: query - description: 农机状态筛选 - schema: - type: string - enum: [running, idle, maintenance, error, offline] - responses: - '200': - description: 成功获取农机列表 - content: - application/json: - schema: - type: object - properties: - code: - type: integer - example: 200 - message: - type: string - example: "success" - data: - type: object - properties: - machinery: - type: array - items: - $ref: '#/components/schemas/Machinery' - total: - type: integer - example: 50 - - post: - summary: 创建农机 - description: 创建新的农机记录 - tags: - - 农机管理 - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateMachineryRequest' - responses: - '201': - description: 农机创建成功 - content: - application/json: - schema: - type: object - properties: - code: - type: integer - example: 201 - message: - type: string - example: "农机创建成功" - data: - $ref: '#/components/schemas/Machinery' - - /machinery/{id}: - get: - summary: 获取农机详情 - tags: - - 农机管理 - parameters: - - name: id - in: path - required: true - schema: - type: integer - responses: - '200': - description: 成功获取农机详情 - content: - application/json: - schema: - type: object - properties: - code: - type: integer - example: 200 - message: - type: string - example: "success" - data: - $ref: '#/components/schemas/Machinery' - - put: - summary: 更新农机信息 - tags: - - 农机管理 - parameters: - - name: id - in: path - required: true - schema: - type: integer - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateMachineryRequest' - responses: - '200': - description: 农机更新成功 - content: - application/json: - schema: - type: object - properties: - code: - type: integer - example: 200 - message: - type: string - example: "农机更新成功" - data: - $ref: '#/components/schemas/Machinery' - - delete: - summary: 删除农机 - tags: - - 农机管理 - parameters: - - name: id - in: path - required: true - schema: - type: integer - responses: - '204': - description: 农机删除成功 - -components: - schemas: - User: - type: object - properties: - id: - type: integer - description: 用户ID - example: 1 - username: - type: string - description: 用户名 - example: "john_doe" - minLength: 3 - maxLength: 50 - email: - type: string - format: email - description: 邮箱地址 - example: "john@example.com" - full_name: - type: string - description: 全名 - example: "John Doe" - maxLength: 100 - phone: - type: string - description: 手机号码 - example: "13800138000" - pattern: "^1[3-9]\\d{9}$" - role: - type: string - enum: [admin, manager, operator, viewer] - description: 用户角色 - example: "operator" - status: - type: string - enum: [active, inactive, suspended] - description: 用户状态 - example: "active" - created_at: - type: string - format: date-time - description: 创建时间 - example: "2024-01-15T10:30:00Z" - updated_at: - type: string - format: date-time - description: 更新时间 - example: "2024-01-15T10:30:00Z" - - Machinery: - type: object - properties: - id: - type: integer - description: 农机ID - example: 1 - name: - type: string - description: 农机名称 - example: "拖拉机-001" - maxLength: 100 - type: - type: string - enum: [tractor, harvester, planter, sprayer, irrigation] - description: 农机类型 - example: "tractor" - model: - type: string - description: 型号 - example: "John Deere 6M Series" - maxLength: 50 - serial_number: - type: string - description: 序列号 - example: "JD6M123456" - maxLength: 50 - status: - type: string - enum: [running, idle, maintenance, error, offline] - description: 农机状态 - example: "idle" - location: - $ref: '#/components/schemas/Location' - operator: - $ref: '#/components/schemas/User' - purchase_date: - type: string - format: date - description: 购买日期 - example: "2024-01-01" - last_maintenance: - type: string - format: date - description: 上次维护日期 - example: "2024-06-15" - next_maintenance: - type: string - format: date - description: 下次维护日期 - example: "2024-09-15" - created_at: - type: string - format: date-time - description: 创建时间 - updated_at: - type: string - format: date-time - description: 更新时间 - - Location: - type: object - properties: - field_id: - type: integer - description: 地块ID - example: 1 - field_name: - type: string - description: 地块名称 - example: "北区A地块" - coordinates: - $ref: '#/components/schemas/Coordinates' - - Coordinates: - type: object - properties: - latitude: - type: number - format: double - description: 纬度 - example: 39.9042 - minimum: -90 - maximum: 90 - longitude: - type: number - format: double - description: 经度 - example: 116.4074 - minimum: -180 - maximum: 180 - - CreateMachineryRequest: - type: object - required: - - name - - type - - model - properties: - name: - type: string - description: 农机名称 - minLength: 1 - maxLength: 100 - type: - type: string - enum: [tractor, harvester, planter, sprayer, irrigation] - description: 农机类型 - model: - type: string - description: 型号 - minLength: 1 - maxLength: 50 - serial_number: - type: string - description: 序列号 - maxLength: 50 - operator_id: - type: integer - description: 操作员ID - example: 1 - purchase_date: - type: string - format: date - description: 购买日期 - - UpdateMachineryRequest: - type: object - properties: - name: - type: string - description: 农机名称 - maxLength: 100 - status: - type: string - enum: [running, idle, maintenance, error, offline] - description: 农机状态 - operator_id: - type: integer - description: 操作员ID - last_maintenance: - type: string - format: date - description: 上次维护日期 - next_maintenance: - type: string - format: date - description: 下次维护日期 - - Error: - type: object - properties: - code: - type: integer - description: 错误代码 - example: 404 - message: - type: string - description: 错误信息 - example: "资源不存在" - details: - type: object - description: 错误详情 - example: - field: "id" - reason: "用户ID不存在" - - securitySchemes: - BearerAuth: - type: http - scheme: bearer - bearerFormat: JWT - description: JWT 认证令牌 - -security: - - BearerAuth: [] \ No newline at end of file diff --git a/crop-x/openapi-ts.config.ts b/crop-x/openapi-ts.config.ts new file mode 100644 index 0000000..f03d69a --- /dev/null +++ b/crop-x/openapi-ts.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from "@hey-api/openapi-ts"; + +// 获取环境变量配置 +const baseUrl = process.env.API_BASE_URL || 'http://localhost:8080'; + +export default defineConfig({ + client: "@hey-api/client-fetch", + input: `${baseUrl}/openapi.json`, + output: "./src/lib/api", + schemas: { + name: "types.gen.ts", + }, + services: { + name: "sdk.gen.ts", + }, + clientName: "client.gen.ts", +}); \ No newline at end of file diff --git a/crop-x/package-lock.json b/crop-x/package-lock.json index a137889..e7f01fa 100644 --- a/crop-x/package-lock.json +++ b/crop-x/package-lock.json @@ -62,6 +62,8 @@ "zustand": "^5.0.8" }, "devDependencies": { + "@hey-api/client-fetch": "^0.13.1", + "@hey-api/openapi-ts": "^0.86.6", "@tailwindcss/postcss": "^4", "@tailwindcss/vite": "^4.1.14", "@types/node": "^20.10.0", @@ -78,6 +80,7 @@ "husky": "^9.1.6", "install": "^0.13.0", "lint-staged": "^15.2.10", + "node-fetch": "^3.3.2", "npm": "^11.6.2", "openapi-typescript": "^7.10.1", "postcss": "^8.4.47", @@ -842,6 +845,108 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@hey-api/client-fetch": { + "version": "0.13.1", + "resolved": "https://registry.npmmirror.com/@hey-api/client-fetch/-/client-fetch-0.13.1.tgz", + "integrity": "sha512-29jBRYNdxVGlx5oewFgOrkulZckpIpBIRHth3uHFn1PrL2ucMy52FvWOY3U3dVx2go1Z3kUmMi6lr07iOpUqqA==", + "deprecated": "Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts.", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/hey-api" + }, + "peerDependencies": { + "@hey-api/openapi-ts": "< 2" + } + }, + "node_modules/@hey-api/codegen-core": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/@hey-api/codegen-core/-/codegen-core-0.3.1.tgz", + "integrity": "sha512-iLG9uRJdmQf83sCZ8WsDR6RXQep0X+D1t1mxuzhrSS9zVL4NvnjTQD6PNnQNPymJyss/mdPf7f7kbmcCK7DVmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + }, + "peerDependencies": { + "typescript": ">=5.5.3" + } + }, + "node_modules/@hey-api/json-schema-ref-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@hey-api/json-schema-ref-parser/-/json-schema-ref-parser-1.2.1.tgz", + "integrity": "sha512-inPeksRLq+j3ArnuGOzQPQE//YrhezQG0+9Y9yizScBN2qatJ78fIByhEgKdNAbtguDCn4RPxmEhcrePwHxs4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + } + }, + "node_modules/@hey-api/openapi-ts": { + "version": "0.86.6", + "resolved": "https://registry.npmmirror.com/@hey-api/openapi-ts/-/openapi-ts-0.86.6.tgz", + "integrity": "sha512-D+iv0mKMqQT2DbuGgPch4nCMb6oBbItvAnOc3glHUDABOe7xkSwdgNWjzeWBadNZsQ+n38QCgBTely9HOPmKjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hey-api/codegen-core": "^0.3.1", + "@hey-api/json-schema-ref-parser": "1.2.1", + "ansi-colors": "4.1.3", + "c12": "3.3.1", + "color-support": "1.1.3", + "commander": "14.0.1", + "handlebars": "4.7.8", + "open": "10.2.0", + "semver": "7.7.2" + }, + "bin": { + "openapi-ts": "bin/run.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/hey-api" + }, + "peerDependencies": { + "typescript": ">=5.5.3" + } + }, + "node_modules/@hey-api/openapi-ts/node_modules/commander": { + "version": "14.0.1", + "resolved": "https://registry.npmmirror.com/commander/-/commander-14.0.1.tgz", + "integrity": "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/@hey-api/openapi-ts/node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/@hookform/resolvers": { "version": "5.2.2", "resolved": "https://registry.npmmirror.com/@hookform/resolvers/-/resolvers-5.2.2.tgz", @@ -1410,6 +1515,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmmirror.com/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -5018,6 +5130,51 @@ "optional": true, "peer": true }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/c12": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/c12/-/c12-3.3.1.tgz", + "integrity": "sha512-LcWQ01LT9tkoUINHgpIOv3mMs+Abv7oVCrtpMRi1PaapVEpWoMga5WuT7/DqFTu7URP9ftbOmimNw1KNIGh9DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^17.2.3", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.6.1", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^2.0.0", + "pkg-types": "^2.3.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmmirror.com/call-bind/-/call-bind-1.0.8.tgz", @@ -5130,6 +5287,22 @@ "dev": true, "license": "MIT" }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/chownr": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", @@ -5140,6 +5313,16 @@ "node": ">=18" } }, + "node_modules/citty": { + "version": "0.1.6", + "resolved": "https://registry.npmmirror.com/citty/-/citty-0.1.6.tgz", + "integrity": "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmmirror.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -5309,6 +5492,16 @@ "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -5345,6 +5538,23 @@ "dev": true, "license": "MIT" }, + "node_modules/confbox": { + "version": "0.2.2", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.2.tgz", + "integrity": "sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmmirror.com/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5494,6 +5704,16 @@ "dev": true, "license": "BSD-2-Clause" }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.2", "resolved": "https://registry.npmmirror.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz", @@ -5604,6 +5824,36 @@ "dev": true, "license": "MIT" }, + "node_modules/default-browser": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/default-browser/-/default-browser-5.2.1.tgz", + "integrity": "sha512-WY/3TUME0x3KPYdRRxEJJvXRHV4PyPoUsxtZa78lwItwRQRHhd2U9xOscaT/YTf8uCXIAjeJOFBVEh/7FtD8Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/default-browser-id/-/default-browser-id-5.0.0.tgz", + "integrity": "sha512-A6p/pu/6fyBcA1TRz/GqWYPViplrftcW2gZC9q79ngNCKAeR/X3gcEdXQHl4KNXV+3wgIJ1CPkJQ3IHM6lcsyA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmmirror.com/define-data-property/-/define-data-property-1.1.4.tgz", @@ -5622,6 +5872,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/define-properties": { "version": "1.2.1", "resolved": "https://registry.npmmirror.com/define-properties/-/define-properties-1.2.1.tgz", @@ -5640,6 +5903,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/defu": { + "version": "6.1.4", + "resolved": "https://registry.npmmirror.com/defu/-/defu-6.1.4.tgz", + "integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==", + "dev": true, + "license": "MIT" + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -5649,6 +5919,13 @@ "node": ">=0.4.0" } }, + "node_modules/destr": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/destr/-/destr-2.0.5.tgz", + "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "dev": true, + "license": "MIT" + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -5694,6 +5971,19 @@ "csstype": "^3.0.2" } }, + "node_modules/dotenv": { + "version": "17.2.3", + "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-17.2.3.tgz", + "integrity": "sha512-JVUnt+DUIzu87TABbhPmNfVdBDt18BLOWjMUFJMSi/Qqg7NTYtabbvSNJGOJ7afbRuv9D/lngizHtP7QyLQ+9w==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -6621,6 +6911,13 @@ "url": "https://github.com/sindresorhus/execa?sponsor=1" } }, + "node_modules/exsolve": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.7.tgz", + "integrity": "sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -6691,6 +6988,30 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", @@ -6807,6 +7128,19 @@ "node": ">= 6" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/fraction.js": { "version": "4.3.7", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", @@ -6998,6 +7332,24 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/giget": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/giget/-/giget-2.0.0.tgz", + "integrity": "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -7067,6 +7419,28 @@ "dev": true, "license": "MIT" }, + "node_modules/handlebars": { + "version": "4.7.8", + "resolved": "https://registry.npmmirror.com/handlebars/-/handlebars-4.7.8.tgz", + "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/has-bigints/-/has-bigints-1.1.0.tgz", @@ -7437,6 +7811,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -7509,6 +7899,25 @@ "node": ">=0.10.0" } }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmmirror.com/is-map/-/is-map-2.0.3.tgz", @@ -7720,6 +8129,22 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-wsl": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmmirror.com/isarray/-/isarray-2.0.5.tgz", @@ -8506,6 +8931,13 @@ "dev": true, "license": "MIT" }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmmirror.com/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, "node_modules/next": { "version": "15.5.6", "resolved": "https://registry.npmjs.org/next/-/next-15.5.6.tgz", @@ -8605,6 +9037,53 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "resolved": "https://registry.npmmirror.com/node-fetch-native/-/node-fetch-native-1.6.7.tgz", + "integrity": "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==", + "dev": true, + "license": "MIT" + }, "node_modules/node-releases": { "version": "2.0.25", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.25.tgz", @@ -11260,6 +11739,26 @@ "inBundle": true, "license": "ISC" }, + "node_modules/nypm": { + "version": "0.6.2", + "resolved": "https://registry.npmmirror.com/nypm/-/nypm-0.6.2.tgz", + "integrity": "sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==", + "dev": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.2", + "pathe": "^2.0.3", + "pkg-types": "^2.3.0", + "tinyexec": "^1.0.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": "^14.16.0 || >=16.10.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -11382,6 +11881,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/ohash": { + "version": "2.0.11", + "resolved": "https://registry.npmmirror.com/ohash/-/ohash-2.0.11.tgz", + "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "dev": true, + "license": "MIT" + }, "node_modules/onetime": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-6.0.0.tgz", @@ -11398,6 +11904,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/open": { + "version": "10.2.0", + "resolved": "https://registry.npmmirror.com/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "default-browser": "^5.2.1", + "define-lazy-prop": "^3.0.0", + "is-inside-container": "^1.0.0", + "wsl-utils": "^0.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/openapi-fetch": { "version": "0.15.0", "resolved": "https://registry.npmmirror.com/openapi-fetch/-/openapi-fetch-0.15.0.tgz", @@ -11591,6 +12116,20 @@ "dev": true, "license": "MIT" }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-2.0.0.tgz", + "integrity": "sha512-fkEH/OBiKrqqI/yIgjR92lMfs2K8105zt/VT6+7eTjNwisrsh47CeIED9z58zI7DfKdH3uHAn25ziRZn3kgAow==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -11623,6 +12162,18 @@ "node": ">=0.10" } }, + "node_modules/pkg-types": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-2.3.0.tgz", + "integrity": "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==", + "dev": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, "node_modules/pluralize": { "version": "8.0.0", "resolved": "https://registry.npmmirror.com/pluralize/-/pluralize-8.0.0.tgz", @@ -11785,6 +12336,17 @@ ], "license": "MIT" }, + "node_modules/rc9": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/rc9/-/rc9-2.1.2.tgz", + "integrity": "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, "node_modules/react": { "version": "19.2.0", "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", @@ -11959,6 +12521,20 @@ "react-dom": ">=16.6.0" } }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/recharts": { "version": "2.15.4", "resolved": "https://registry.npmmirror.com/recharts/-/recharts-2.15.4.tgz", @@ -12200,6 +12776,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmmirror.com/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -12554,8 +13143,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "license": "BSD-3-Clause", - "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -12937,6 +13524,13 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinyexec": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/tinyexec/-/tinyexec-1.0.1.tgz", + "integrity": "sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -13148,6 +13742,20 @@ "node": ">=14.17" } }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmmirror.com/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/unbox-primitive": { "version": "1.1.0", "resolved": "https://registry.npmmirror.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz", @@ -13450,6 +14058,16 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -13571,6 +14189,13 @@ "node": ">=0.10.0" } }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true, + "license": "MIT" + }, "node_modules/wrap-ansi": { "version": "9.0.2", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", @@ -13602,6 +14227,22 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wsl-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", diff --git a/crop-x/package.json b/crop-x/package.json index 5a298c1..17ec280 100644 --- a/crop-x/package.json +++ b/crop-x/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "next dev --turbopack", + "test:ts": "tsc --noEmit", "build": "next build", "start": "next start", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", @@ -15,6 +16,7 @@ "scripts:setup": "node scripts/setup-dev-tools.js", "scripts:enable": "node scripts/setup-dev-tools.js --enable", "scripts:disable": "node scripts/setup-dev-tools.js --disable", + "api:generate": "node scripts/generate-api.cjs", "deploy": "node scripts/deploy.js" }, "dependencies": { @@ -72,6 +74,8 @@ "zustand": "^5.0.8" }, "devDependencies": { + "@hey-api/client-fetch": "^0.13.1", + "@hey-api/openapi-ts": "^0.86.6", "@tailwindcss/postcss": "^4", "@tailwindcss/vite": "^4.1.14", "@types/node": "^20.10.0", @@ -88,6 +92,7 @@ "husky": "^9.1.6", "install": "^0.13.0", "lint-staged": "^15.2.10", + "node-fetch": "^3.3.2", "npm": "^11.6.2", "openapi-typescript": "^7.10.1", "postcss": "^8.4.47", diff --git a/crop-x/scripts/generate-api.cjs b/crop-x/scripts/generate-api.cjs new file mode 100644 index 0000000..a75892c --- /dev/null +++ b/crop-x/scripts/generate-api.cjs @@ -0,0 +1,169 @@ +/** + * 简化的 API 生成脚本 + * + * 这个脚本现在主要负责: + * 1. 使用 @hey-api/openapi-ts 命令生成客户端代码 + * 2. 环境配置通过 openapi-ts.config.ts 处理 + */ + +const fs = require('fs'); +const path = require('path'); + +// ANSI 颜色代码 +const colors = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + magenta: '\x1b[35m', + cyan: '\x1b[36m', + white: '\x1b[37m' +}; + +// 日志函数 +function log(message, color = 'white') { + console.log(`${colors[color]}${message}${colors.reset}`); +} + +function logSuccess(message) { + log(`✓ ${message}`, 'green'); +} + +function logError(message) { + log(`✗ ${message}`, 'red'); +} + +function logWarning(message) { + log(`⚠ ${message}`, 'yellow'); +} + +function logInfo(message) { + log(`ℹ ${message}`, 'blue'); +} + +// 显示环境配置信息 +logInfo(`当前环境: ${process.env.NODE_ENV || 'development'}`); +logInfo(`API 服务器: ${process.env.API_BASE_URL || 'http://localhost:8080'}`); + +/** + * 使用 openapi-ts 命令生成客户端代码 + */ +function generateWithOpenApiTS() { + return new Promise((resolve, reject) => { + logInfo('使用 @hey-api/openapi-ts 生成客户端代码...'); + + const { exec } = require('child_process'); + const command = 'npx @hey-api/openapi-ts'; + + logInfo(`执行命令: ${command}`); + + const startTime = Date.now(); + + exec(command, { cwd: process.cwd() }, (error, stdout, stderr) => { + const executionTime = Date.now() - startTime; + + if (error) { + logError(`openapi-ts 执行失败 (${executionTime}ms)`); + logError(`错误信息: ${error.message}`); + if (stderr) { + logError(`stderr: ${stderr}`); + } + reject(error); + return; + } + + if (stderr) { + logWarning(`stderr: ${stderr}`); + } + + logSuccess(`openapi-ts 执行成功 (${executionTime}ms)`); + if (stdout) { + logInfo(`输出: ${stdout}`); + } + + resolve(); + }); + }); +} + +/** + * 验证生成的文件 + */ +function validateGeneratedFiles() { + try { + logInfo('验证生成的文件...'); + const outputDir = path.join(process.cwd(), 'src', 'lib', 'api'); + + if (!fs.existsSync(outputDir)) { + throw new Error('输出目录不存在'); + } + + const files = fs.readdirSync(outputDir); + logInfo(`输出目录包含 ${files.length} 个文件:`); + + const requiredFiles = ['types.gen.ts', 'sdk.gen.ts', 'index.ts']; + const generatedFiles = []; + + for (const file of files) { + const filePath = path.join(outputDir, file); + const stats = fs.statSync(filePath); + + // 只显示文件,不显示目录 + if (stats.isFile()) { + const size = (stats.size / 1024).toFixed(2); + logInfo(` 📄 ${file} (${size} KB)`); + generatedFiles.push(file); + } + } + + // 检查必要文件 + const missingFiles = requiredFiles.filter(file => !generatedFiles.includes(file)); + if (missingFiles.length > 0) { + logWarning(`缺少期望的文件: ${missingFiles.join(', ')}`); + } else { + logSuccess('所有期望的文件都已生成'); + } + + return true; + } catch (error) { + logError(`验证生成的文件失败: ${error.message}`); + return false; + } +} + +/** + * 主函数 + */ +async function main() { + const startTime = Date.now(); + + log('='.repeat(60), 'cyan'); + log('API 客户端代码生成脚本', 'cyan'); + log('基于 @hey-api/openapi-ts', 'cyan'); + log('='.repeat(60), 'cyan'); + + try { + // 使用 openapi-ts 生成代码 + await generateWithOpenApiTS(); + + // 验证生成的文件 + if (!validateGeneratedFiles()) { + logWarning('文件验证发现问题,但生成过程已完成'); + } + + const totalTime = Date.now() - startTime; + logSuccess(`API 客户端代码生成完成!总耗时: ${totalTime}ms`); + logSuccess('生成的文件位于 src/lib/api/ 目录'); + + } catch (error) { + const totalTime = Date.now() - startTime; + logError(`脚本执行失败 (${totalTime}ms): ${error.message}`); + process.exit(1); + } +} + +// 执行主函数 +if (require.main === module) { + main(); +} \ No newline at end of file diff --git a/crop-x/src/app/api-example/page.tsx b/crop-x/src/app/api-example/page.tsx index 25bd939..eac452e 100644 --- a/crop-x/src/app/api-example/page.tsx +++ b/crop-x/src/app/api-example/page.tsx @@ -1,14 +1,654 @@ -import ApiExample from '@/components/examples/ApiExample'; +'use client'; + +import { useState } from 'react'; +import { + loginApiV1AuthLoginPost, + registerApiV1AuthRegisterPost, + getCurrentUserApiV1AuthMeGet, + getAllUsersApiV1AuthUsersGet, + logoutApiV1AuthLogoutPost, + rootGet, + healthCheckHealthGet, + type UserLogin, + type UserRegister, + type ApiResponse +} from '@/lib/api'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Badge } from '@/components/ui/badge'; +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Separator } from '@/components/ui/separator'; export default function ApiExamplePage() { + const [loading, setLoading] = useState(false); + const [results, setResults] = useState([]); + const [errors, setErrors] = useState([]); + + // 登录表单状态 + const [loginData, setLoginData] = useState({ + username: '', + password: '' + }); + + // 注册表单状态 + const [registerData, setRegisterData] = useState({ + username: '', + password: '' + }); + + // 添加结果到显示列表 + const addResult = (type: string, input: any, output: any, error?: string) => { + const result = { + id: Date.now(), + type, + input, + output, + error, + timestamp: new Date().toLocaleTimeString() + }; + setResults(prev => [result, ...prev]); + if (error) { + setErrors(prev => [error, ...prev]); + } + }; + + // 清空结果 + const clearResults = () => { + setResults([]); + setErrors([]); + }; + + // 用户登录 + const handleLogin = async () => { + setLoading(true); + try { + addResult('用户登录', '登录请求', '发送中...'); + + const response = await loginApiV1AuthLoginPost({ + body: loginData + }); + + if (response.data) { + addResult('用户登录', loginData, response.data); + } else if (response.error) { + addResult('用户登录', loginData, null, JSON.stringify(response.error)); + } + } catch (error: any) { + addResult('用户登录', loginData, null, error.message); + } finally { + setLoading(false); + } + }; + + // 用户注册 + const handleRegister = async () => { + setLoading(true); + try { + addResult('用户注册', '注册请求', '发送中...'); + + const response = await registerApiV1AuthRegisterPost({ + body: registerData + }); + + if (response.data) { + addResult('用户注册', registerData, response.data); + } else if (response.error) { + addResult('用户注册', registerData, null, JSON.stringify(response.error)); + } + } catch (error: any) { + addResult('用户注册', registerData, null, error.message); + } finally { + setLoading(false); + } + }; + + // 获取当前用户信息 + const handleGetCurrentUser = async () => { + setLoading(true); + try { + addResult('获取当前用户', 'GET /api/v1/auth/me', '发送中...'); + + const response = await getCurrentUserApiV1AuthMeGet({}); + + if (response.data) { + addResult('获取当前用户', '无参数', response.data); + } else if (response.error) { + addResult('获取当前用户', '无参数', null, JSON.stringify(response.error)); + } + } catch (error: any) { + addResult('获取当前用户', '无参数', null, error.message); + } finally { + setLoading(false); + } + }; + + // 获取所有用户 + const handleGetAllUsers = async () => { + setLoading(true); + try { + addResult('获取所有用户', 'GET /api/v1/auth/users', '发送中...'); + + const response = await getAllUsersApiV1AuthUsersGet({}); + + if (response.data) { + addResult('获取所有用户', '无参数', response.data); + } else if (response.error) { + addResult('获取所有用户', '无参数', null, JSON.stringify(response.error)); + } + } catch (error: any) { + addResult('获取所有用户', '无参数', null, error.message); + } finally { + setLoading(false); + } + }; + + // 用户登出 + const handleLogout = async () => { + setLoading(true); + try { + addResult('用户登出', 'POST /api/v1/auth/logout', '发送中...'); + + const response = await logoutApiV1AuthLogoutPost({}); + + if (response.data) { + addResult('用户登出', '无参数', response.data); + } else if (response.error) { + addResult('用户登出', '无参数', null, JSON.stringify(response.error)); + } + } catch (error: any) { + addResult('用户登出', '无参数', null, error.message); + } finally { + setLoading(false); + } + }; + return ( -
- +
+
+
+

OpenAPI 接口测试

+ +
+ + + + 交互式测试 + 接口示例 + + + +
+ {/* 左侧:API 操作面板 */} + + + API 操作 + + 使用 @hey-api/openapi-ts 生成的接口函数 + + + + + + 认证操作 + 用户操作 + + + + {/* 登录表单 */} +
+

用户登录

+ setLoginData(prev => ({ ...prev, username: e.target.value }))} + /> + setLoginData(prev => ({ ...prev, password: e.target.value }))} + /> + +
+ + {/* 注册表单 */} +
+

用户注册

+ setRegisterData(prev => ({ ...prev, username: e.target.value }))} + /> + setRegisterData(prev => ({ ...prev, password: e.target.value }))} + /> + +
+
+ + +
+ + + +
+
+
+
+
+ + {/* 右侧:结果显示面板 */} + + + 请求 & 响应结果 + + 显示 API 调用的输入和输出结果 + + + +
+ {results.length === 0 ? ( +
+

暂无 API 调用记录

+

请在左侧面板中操作 API 接口

+
+ ) : ( + results.map((result) => ( +
+
+

{result.type}

+
+ {result.timestamp} + {result.error ? ( + 错误 + ) : ( + 成功 + )} +
+
+ + {/* 输入数据 */} +
+

输入数据:

+
+                              {JSON.stringify(result.input, null, 2)}
+                            
+
+ + {/* 输出数据 */} +
+

+ {result.error ? '错误信息:' : '响应数据:'} +

+
+                              {result.error || JSON.stringify(result.output, null, 2)}
+                            
+
+
+ )) + )} +
+
+
+
+ + {/* 错误提示 */} + {errors.length > 0 && ( +
+ + + 最近发生了 {errors.length} 个错误,请查看右侧结果面板中的详细信息。 + + +
+ )} +
+ + + + +
+
); } -export const metadata = { - title: 'API 调用示例 - 智慧农业生产管理系统', - description: '测试和展示 OpenAPI 客户端的类型安全 API 调用', -}; \ No newline at end of file +// API示例页面组件 +function ApiExamplesPage() { + const [examplesLoading, setExamplesLoading] = useState>({}); + const [examplesResults, setExamplesResults] = useState>({}); + + // API示例配置 - 基于openapi.json + const apiExamples = [ + { + id: 'login', + method: 'POST', + path: '/api/v1/auth/login', + title: '用户登录', + description: '用户登录接口', + exampleParams: { + username: 'admin', + password: 'admin123' + }, + expectedOutput: { + success: true, + message: '登录成功', + data: { token: 'jwt_token_here' } + } + }, + { + id: 'register', + method: 'POST', + path: '/api/v1/auth/register', + title: '用户注册', + description: '用户注册接口', + exampleParams: { + username: 'newuser', + password: 'newpassword' + }, + expectedOutput: { + success: true, + message: '注册成功', + data: { user_id: 1, username: 'newuser' } + } + }, + { + id: 'me', + method: 'GET', + path: '/api/v1/auth/me', + title: '获取当前用户信息', + description: '获取当前登录用户的信息', + exampleParams: null, + expectedOutput: { + success: true, + message: '获取用户信息成功', + data: { user_id: 1, username: 'admin' } + } + }, + { + id: 'logout', + method: 'POST', + path: '/api/v1/auth/logout', + title: '用户登出', + description: '用户登出接口', + exampleParams: null, + expectedOutput: { + success: true, + message: '登出成功', + data: null + } + }, + { + id: 'users', + method: 'GET', + path: '/api/v1/auth/users', + title: '获取所有用户列表', + description: '获取系统中所有用户的列表 (仅用于演示)', + exampleParams: null, + expectedOutput: { + success: true, + message: '获取用户列表成功', + data: [{ user_id: 1, username: 'admin' }] + } + }, + { + id: 'root', + method: 'GET', + path: '/', + title: '根路径', + description: 'API根路径,用于测试连接', + exampleParams: null, + expectedOutput: {} + }, + { + id: 'health', + method: 'GET', + path: '/health', + title: '健康检查', + description: 'API健康检查接口', + exampleParams: null, + expectedOutput: { status: 'healthy' } + } + ]; + + // 调用示例API + const callExampleApi = async (example: typeof apiExamples[0]) => { + setExamplesLoading(prev => ({ ...prev, [example.id]: true })); + + try { + let response; + + switch (example.id) { + case 'login': + response = await loginApiV1AuthLoginPost({ + body: example.exampleParams + }); + break; + case 'register': + response = await registerApiV1AuthRegisterPost({ + body: example.exampleParams + }); + break; + case 'me': + response = await getCurrentUserApiV1AuthMeGet({}); + break; + case 'logout': + response = await logoutApiV1AuthLogoutPost({}); + break; + case 'users': + response = await getAllUsersApiV1AuthUsersGet({}); + break; + case 'root': + response = await rootGet({}); + break; + case 'health': + response = await healthCheckHealthGet({}); + break; + default: + throw new Error('Unknown API example'); + } + + setExamplesResults(prev => ({ + ...prev, + [example.id]: { + success: !response.error, + data: response.data, + error: response.error, + input: example.exampleParams, + timestamp: new Date().toLocaleTimeString() + } + })); + } catch (error: any) { + setExamplesResults(prev => ({ + ...prev, + [example.id]: { + success: false, + error: error.message, + input: example.exampleParams, + timestamp: new Date().toLocaleTimeString() + } + })); + } finally { + setExamplesLoading(prev => ({ ...prev, [example.id]: false })); + } + }; + + // 批量调用所有示例 + const callAllExamples = async () => { + // 先调用登录接口获取token + await callExampleApi(apiExamples[0]); + + // 然后调用其他接口 + for (const example of apiExamples.slice(1)) { + await new Promise(resolve => setTimeout(resolve, 100)); // 间隔100ms + await callExampleApi(example); + } + }; + + return ( +
+
+
+

接口示例集合

+

+ 基于OpenAPI规范自动生成的所有接口示例,点击按钮即可测试 +

+
+ +
+ +
+ {apiExamples.map((example) => ( + + +
+
+ {example.title} + + {example.method} {example.path} + +
+ {example.method} +
+
+ +

{example.description}

+ + {/* 示例参数 */} +
+

示例参数:

+ {example.exampleParams ? ( +
+                    {JSON.stringify(example.exampleParams, null, 2)}
+                  
+ ) : ( +

无参数

+ )} +
+ + {/* 预期输出 */} +
+

预期输出:

+
+                  {JSON.stringify(example.expectedOutput, null, 2)}
+                
+
+ + {/* 实际结果 */} + {examplesResults[example.id] && ( +
+
+

+ 实际结果 + + ({examplesResults[example.id].timestamp}) + +

+ + {examplesResults[example.id].success ? '成功' : '失败'} + +
+
+                    {examplesResults[example.id].error
+                      ? JSON.stringify(examplesResults[example.id].error, null, 2)
+                      : JSON.stringify(examplesResults[example.id].data, null, 2)
+                    }
+                  
+
+ )} + + +
+
+ ))} +
+ + {/* 统计信息 */} + + + 测试统计 + + +
+
+

+ {Object.values(examplesResults).filter(r => r?.success).length} +

+

成功

+
+
+

+ {Object.values(examplesResults).filter(r => r?.success === false).length} +

+

失败

+
+
+

+ {apiExamples.length} +

+

总计

+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/crop-x/src/components/examples/ApiExample.tsx b/crop-x/src/components/examples/ApiExample.tsx deleted file mode 100644 index f6c0b3d..0000000 --- a/crop-x/src/components/examples/ApiExample.tsx +++ /dev/null @@ -1,231 +0,0 @@ -'use client'; - -import React, { useState, useEffect } from 'react'; -import { api, testConnection, type User, type Machinery } from '@/lib/api/client'; - -export default function ApiExample() { - const [users, setUsers] = useState([]); - const [machinery, setMachinery] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [connectionStatus, setConnectionStatus] = useState<'testing' | 'connected' | 'disconnected'>('testing'); - - // 获取用户列表 - const fetchUsers = async () => { - setLoading(true); - setError(null); - try { - const result = await api.users.getList({ page: 1, limit: 10 }); - if (result?.data?.users) { - setUsers(result.data.users); - } - } catch (err) { - setError(err instanceof Error ? err.message : '获取用户失败'); - } finally { - setLoading(false); - } - }; - - // 获取农机列表 - const fetchMachinery = async () => { - setLoading(true); - setError(null); - try { - const result = await api.machinery.getList({ page: 1, limit: 10 }); - if (result?.data?.machinery) { - setMachinery(result.data.machinery); - } - } catch (err) { - setError(err instanceof Error ? err.message : '获取农机失败'); - } finally { - setLoading(false); - } - }; - - // 创建新农机 - const createMachinery = async () => { - try { - const newMachinery = await api.machinery.create({ - name: '新拖拉机', - type: 'tractor', - model: 'John Deere 6M', - serial_number: 'JD6M123456', - purchase_date: new Date().toISOString().split('T')[0], - }); - - console.log('创建成功:', newMachinery); - // 刷新列表 - fetchMachinery(); - } catch (err) { - setError(err instanceof Error ? err.message : '创建农机失败'); - } - }; - - // 测试 API 连接 - const testApiConnection = async () => { - setConnectionStatus('testing'); - const result = await testConnection(); - - if (result.success) { - setConnectionStatus('connected'); - // 连接成功后获取数据 - fetchUsers(); - fetchMachinery(); - } else { - setConnectionStatus('disconnected'); - setError(`API 连接失败: ${result.error}`); - } - }; - - useEffect(() => { - testApiConnection(); - }, []); - - if (loading) { - return
加载中...
; - } - - if (error) { - return ( -
-
错误: {error}
- -
- ); - } - - // 连接状态显示 - const renderConnectionStatus = () => { - switch (connectionStatus) { - case 'testing': - return ( -
-
-
- 正在测试 API 连接... -
-
- ); - case 'connected': - return ( -
-
-
-
- API 连接成功 -
- -
-
- ); - case 'disconnected': - return ( -
-
-
-
-
- API 连接失败 -
-
{error}
-
- -
-
- ); - } - }; - - return ( -
-

API 调用示例

- - {/* 连接状态 */} - {renderConnectionStatus()} - - {/* 服务器信息 */} -
-

服务器配置

-
-
Base URL: https://gitea-admin-test-app-app.dev.maimaiag.com/api/v1
-
开发/测试/生产: 统一使用此地址
-
-
- -
- {/* 用户列表 */} -
-

用户列表

-
- {users.map((user) => ( -
-
{user.full_name || user.username}
-
- {user.email} • {user.role} • {user.status} -
-
- ))} -
-
- - {/* 农机列表 */} -
-
-

农机列表

- -
-
- {machinery.map((machine) => ( -
-
{machine.name}
-
- {machine.type} • {machine.model} • {machine.status} -
- {machine.operator && ( -
- 操作员: {machine.operator.full_name || machine.operator.username} -
- )} -
- ))} -
-
-
- - {/* 类型安全示例 */} -
-

类型安全优势

-
    -
  • ✅ API 调用参数有完整的类型检查
  • -
  • ✅ 响应数据有自动的类型推断
  • -
  • ✅ 编译时就能发现类型错误
  • -
  • ✅ IDE 支持自动补全和提示
  • -
-
-
- ); -} \ No newline at end of file diff --git a/crop-x/src/lib/api/client.gen.ts b/crop-x/src/lib/api/client.gen.ts new file mode 100644 index 0000000..8087612 --- /dev/null +++ b/crop-x/src/lib/api/client.gen.ts @@ -0,0 +1,18 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { type ClientOptions, type Config, createClient, createConfig } from './client'; +import type { ClientOptions as ClientOptions2 } from './types.gen'; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = (override?: Config) => Config & T>; + +export const client = createClient(createConfig({ + baseUrl: 'http://localhost:8080' +})); diff --git a/crop-x/src/lib/api/client.ts b/crop-x/src/lib/api/client.ts deleted file mode 100644 index 8703785..0000000 --- a/crop-x/src/lib/api/client.ts +++ /dev/null @@ -1,204 +0,0 @@ -import createClient from 'openapi-fetch'; -import type { paths } from './v1.d.ts'; - -// 创建 API 客户端 -const client = createClient({ - baseUrl: 'https://gitea-admin-test-app-app.dev.maimaiag.com/docs', - // 可以添加默认 headers - headers: { - 'Content-Type': 'application/json', - }, -}); - -// 添加认证的客户端 -export const authClient = { - ...client, - // 包装添加 token 的方法 - withAuth: (token: string) => ({ - ...client, - headers: { - 'Content-Type': 'application/json', - 'Authorization': `Bearer ${token}`, - }, - }), -}; - -// 测试连接 -export const testConnection = async () => { - try { - // 尝试获取一个简单的接口来测试连接 - const { data, error, response } = await client.GET('/users', { - params: { - query: { page: 1, limit: 1 } - } - }); - - if (error) { - console.warn('API 连接测试失败:', error); - return { success: false, error }; - } - - console.log('API 连接测试成功:', { status: response?.status, data }); - return { success: true, data }; - } catch (err) { - console.error('API 连接测试出错:', err); - return { success: false, error: err instanceof Error ? err.message : '未知错误' }; - } -}; - -// API 方法封装 -export const api = { - // 用户管理 API - users: { - // 获取用户列表 - getList: async (params?: { - page?: number; - limit?: number; - search?: string; - }) => { - const { data, error, response } = await client.GET('/users', { - params: { - query: params, - }, - }); - - if (error) { - console.error('获取用户列表失败:', error); - throw new Error(`API Error: ${error}`); - } - - return data; - }, - - // 获取用户详情 - getDetail: async (id: number) => { - const { data, error } = await client.GET('/users/{id}', { - params: { - path: { id }, - }, - }); - - if (error) { - console.error('获取用户详情失败:', error); - throw new Error(`API Error: ${error}`); - } - - return data; - }, - }, - - // 农机管理 API - machinery: { - // 获取农机列表 - getList: async (params?: { - page?: number; - limit?: number; - status?: 'running' | 'idle' | 'maintenance' | 'error' | 'offline'; - }) => { - const { data, error } = await client.GET('/machinery', { - params: { - query: params, - }, - }); - - if (error) { - console.error('获取农机列表失败:', error); - throw new Error(`API Error: ${error}`); - } - - return data; - }, - - // 获取农机详情 - getDetail: async (id: number) => { - const { data, error } = await client.GET('/machinery/{id}', { - params: { - path: { id }, - }, - }); - - if (error) { - console.error('获取农机详情失败:', error); - throw new Error(`API Error: ${error}`); - } - - return data; - }, - - // 创建农机 - create: async (machineryData: { - name: string; - type: 'tractor' | 'harvester' | 'planter' | 'sprayer' | 'irrigation'; - model: string; - serial_number?: string; - operator_id?: number; - purchase_date?: string; - }) => { - const { data, error } = await client.POST('/machinery', { - body: machineryData, - }); - - if (error) { - console.error('创建农机失败:', error); - throw new Error(`API Error: ${error}`); - } - - return data; - }, - - // 更新农机 - update: async ( - id: number, - updateData: { - name?: string; - status?: 'running' | 'idle' | 'maintenance' | 'error' | 'offline'; - operator_id?: number; - last_maintenance?: string; - next_maintenance?: string; - } - ) => { - const { data, error } = await client.PUT('/machinery/{id}', { - params: { - path: { id }, - }, - body: updateData, - }); - - if (error) { - console.error('更新农机失败:', error); - throw new Error(`API Error: ${error}`); - } - - return data; - }, - - // 删除农机 - delete: async (id: number) => { - const { error } = await client.DELETE('/machinery/{id}', { - params: { - path: { id }, - }, - }); - - if (error) { - console.error('删除农机失败:', error); - throw new Error(`API Error: ${error}`); - } - - return true; // 删除成功 - }, - }, -}; - -// 类型导出(供组件使用) -export type { - User, - Machinery, - Location, - Coordinates, - CreateMachineryRequest, - UpdateMachineryRequest, - Error as ApiError -} from './v1.d.ts'; - -export default client; \ No newline at end of file diff --git a/crop-x/src/lib/api/client/client.gen.ts b/crop-x/src/lib/api/client/client.gen.ts new file mode 100644 index 0000000..a439d27 --- /dev/null +++ b/crop-x/src/lib/api/client/client.gen.ts @@ -0,0 +1,268 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { createSseClient } from '../core/serverSentEvents.gen'; +import type { HttpMethod } from '../core/types.gen'; +import { getValidRequestBody } from '../core/utils.gen'; +import type { + Client, + Config, + RequestOptions, + ResolvedRequestOptions, +} from './types.gen'; +import { + buildUrl, + createConfig, + createInterceptors, + getParseAs, + mergeConfigs, + mergeHeaders, + setAuthParams, +} from './utils.gen'; + +type ReqInit = Omit & { + body?: any; + headers: ReturnType; +}; + +export const createClient = (config: Config = {}): Client => { + let _config = mergeConfigs(createConfig(), config); + + const getConfig = (): Config => ({ ..._config }); + + const setConfig = (config: Config): Config => { + _config = mergeConfigs(_config, config); + return getConfig(); + }; + + const interceptors = createInterceptors< + Request, + Response, + unknown, + ResolvedRequestOptions + >(); + + const beforeRequest = async (options: RequestOptions) => { + const opts = { + ..._config, + ...options, + fetch: options.fetch ?? _config.fetch ?? globalThis.fetch, + headers: mergeHeaders(_config.headers, options.headers), + serializedBody: undefined, + }; + + if (opts.security) { + await setAuthParams({ + ...opts, + security: opts.security, + }); + } + + if (opts.requestValidator) { + await opts.requestValidator(opts); + } + + if (opts.body !== undefined && opts.bodySerializer) { + opts.serializedBody = opts.bodySerializer(opts.body); + } + + // remove Content-Type header if body is empty to avoid sending invalid requests + if (opts.body === undefined || opts.serializedBody === '') { + opts.headers.delete('Content-Type'); + } + + const url = buildUrl(opts); + + return { opts, url }; + }; + + const request: Client['request'] = async (options) => { + // @ts-expect-error + const { opts, url } = await beforeRequest(options); + const requestInit: ReqInit = { + redirect: 'follow', + ...opts, + body: getValidRequestBody(opts), + }; + + let request = new Request(url, requestInit); + + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = opts.fetch!; + let response = await _fetch(request); + + for (const fn of interceptors.response.fns) { + if (fn) { + response = await fn(response, request, opts); + } + } + + const result = { + request, + response, + }; + + if (response.ok) { + const parseAs = + (opts.parseAs === 'auto' + ? getParseAs(response.headers.get('Content-Type')) + : opts.parseAs) ?? 'json'; + + if ( + response.status === 204 || + response.headers.get('Content-Length') === '0' + ) { + let emptyData: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'text': + emptyData = await response[parseAs](); + break; + case 'formData': + emptyData = new FormData(); + break; + case 'stream': + emptyData = response.body; + break; + case 'json': + default: + emptyData = {}; + break; + } + return opts.responseStyle === 'data' + ? emptyData + : { + data: emptyData, + ...result, + }; + } + + let data: any; + switch (parseAs) { + case 'arrayBuffer': + case 'blob': + case 'formData': + case 'json': + case 'text': + data = await response[parseAs](); + break; + case 'stream': + return opts.responseStyle === 'data' + ? response.body + : { + data: response.body, + ...result, + }; + } + + if (parseAs === 'json') { + if (opts.responseValidator) { + await opts.responseValidator(data); + } + + if (opts.responseTransformer) { + data = await opts.responseTransformer(data); + } + } + + return opts.responseStyle === 'data' + ? data + : { + data, + ...result, + }; + } + + const textError = await response.text(); + let jsonError: unknown; + + try { + jsonError = JSON.parse(textError); + } catch { + // noop + } + + const error = jsonError ?? textError; + let finalError = error; + + for (const fn of interceptors.error.fns) { + if (fn) { + finalError = (await fn(error, response, request, opts)) as string; + } + } + + finalError = finalError || ({} as string); + + if (opts.throwOnError) { + throw finalError; + } + + // TODO: we probably want to return error and improve types + return opts.responseStyle === 'data' + ? undefined + : { + error: finalError, + ...result, + }; + }; + + const makeMethodFn = + (method: Uppercase) => (options: RequestOptions) => + request({ ...options, method }); + + const makeSseFn = + (method: Uppercase) => async (options: RequestOptions) => { + const { opts, url } = await beforeRequest(options); + return createSseClient({ + ...opts, + body: opts.body as BodyInit | null | undefined, + headers: opts.headers as unknown as Record, + method, + onRequest: async (url, init) => { + let request = new Request(url, init); + for (const fn of interceptors.request.fns) { + if (fn) { + request = await fn(request, opts); + } + } + return request; + }, + url, + }); + }; + + return { + buildUrl, + connect: makeMethodFn('CONNECT'), + delete: makeMethodFn('DELETE'), + get: makeMethodFn('GET'), + getConfig, + head: makeMethodFn('HEAD'), + interceptors, + options: makeMethodFn('OPTIONS'), + patch: makeMethodFn('PATCH'), + post: makeMethodFn('POST'), + put: makeMethodFn('PUT'), + request, + setConfig, + sse: { + connect: makeSseFn('CONNECT'), + delete: makeSseFn('DELETE'), + get: makeSseFn('GET'), + head: makeSseFn('HEAD'), + options: makeSseFn('OPTIONS'), + patch: makeSseFn('PATCH'), + post: makeSseFn('POST'), + put: makeSseFn('PUT'), + trace: makeSseFn('TRACE'), + }, + trace: makeMethodFn('TRACE'), + } as Client; +}; diff --git a/crop-x/src/lib/api/client/index.ts b/crop-x/src/lib/api/client/index.ts new file mode 100644 index 0000000..cbf8dfe --- /dev/null +++ b/crop-x/src/lib/api/client/index.ts @@ -0,0 +1,26 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type { Auth } from '../core/auth.gen'; +export type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +export { + formDataBodySerializer, + jsonBodySerializer, + urlSearchParamsBodySerializer, +} from '../core/bodySerializer.gen'; +export { buildClientParams } from '../core/params.gen'; +export { serializeQueryKeyValue } from '../core/queryKeySerializer.gen'; +export { createClient } from './client.gen'; +export type { + Client, + ClientOptions, + Config, + CreateClientConfig, + Options, + OptionsLegacyParser, + RequestOptions, + RequestResult, + ResolvedRequestOptions, + ResponseStyle, + TDataShape, +} from './types.gen'; +export { createConfig, mergeHeaders } from './utils.gen'; diff --git a/crop-x/src/lib/api/client/types.gen.ts b/crop-x/src/lib/api/client/types.gen.ts new file mode 100644 index 0000000..1a005b5 --- /dev/null +++ b/crop-x/src/lib/api/client/types.gen.ts @@ -0,0 +1,268 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth } from '../core/auth.gen'; +import type { + ServerSentEventsOptions, + ServerSentEventsResult, +} from '../core/serverSentEvents.gen'; +import type { + Client as CoreClient, + Config as CoreConfig, +} from '../core/types.gen'; +import type { Middleware } from './utils.gen'; + +export type ResponseStyle = 'data' | 'fields'; + +export interface Config + extends Omit, + CoreConfig { + /** + * Base URL for all requests made by this client. + */ + baseUrl?: T['baseUrl']; + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Please don't use the Fetch client for Next.js applications. The `next` + * options won't have any effect. + * + * Install {@link https://www.npmjs.com/package/@hey-api/client-next `@hey-api/client-next`} instead. + */ + next?: never; + /** + * Return the response data parsed in a specified format. By default, `auto` + * will infer the appropriate method from the `Content-Type` response header. + * You can override this behavior with any of the {@link Body} methods. + * Select `stream` if you don't want to parse response data at all. + * + * @default 'auto' + */ + parseAs?: + | 'arrayBuffer' + | 'auto' + | 'blob' + | 'formData' + | 'json' + | 'stream' + | 'text'; + /** + * Should we return only data or multiple fields (data, error, response, etc.)? + * + * @default 'fields' + */ + responseStyle?: ResponseStyle; + /** + * Throw an error instead of returning it in the response? + * + * @default false + */ + throwOnError?: T['throwOnError']; +} + +export interface RequestOptions< + TData = unknown, + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends Config<{ + responseStyle: TResponseStyle; + throwOnError: ThrowOnError; + }>, + Pick< + ServerSentEventsOptions, + | 'onSseError' + | 'onSseEvent' + | 'sseDefaultRetryDelay' + | 'sseMaxRetryAttempts' + | 'sseMaxRetryDelay' + > { + /** + * Any body that you want to add to your request. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#body} + */ + body?: unknown; + path?: Record; + query?: Record; + /** + * Security mechanism(s) to use for the request. + */ + security?: ReadonlyArray; + url: Url; +} + +export interface ResolvedRequestOptions< + TResponseStyle extends ResponseStyle = 'fields', + ThrowOnError extends boolean = boolean, + Url extends string = string, +> extends RequestOptions { + serializedBody?: string; +} + +export type RequestResult< + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = ThrowOnError extends true + ? Promise< + TResponseStyle extends 'data' + ? TData extends Record + ? TData[keyof TData] + : TData + : { + data: TData extends Record + ? TData[keyof TData] + : TData; + request: Request; + response: Response; + } + > + : Promise< + TResponseStyle extends 'data' + ? + | (TData extends Record + ? TData[keyof TData] + : TData) + | undefined + : ( + | { + data: TData extends Record + ? TData[keyof TData] + : TData; + error: undefined; + } + | { + data: undefined; + error: TError extends Record + ? TError[keyof TError] + : TError; + } + ) & { + request: Request; + response: Response; + } + >; + +export interface ClientOptions { + baseUrl?: string; + responseStyle?: ResponseStyle; + throwOnError?: boolean; +} + +type MethodFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => RequestResult; + +type SseFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'>, +) => Promise>; + +type RequestFn = < + TData = unknown, + TError = unknown, + ThrowOnError extends boolean = false, + TResponseStyle extends ResponseStyle = 'fields', +>( + options: Omit, 'method'> & + Pick< + Required>, + 'method' + >, +) => RequestResult; + +type BuildUrlFn = < + TData extends { + body?: unknown; + path?: Record; + query?: Record; + url: string; + }, +>( + options: Pick & Options, +) => string; + +export type Client = CoreClient< + RequestFn, + Config, + MethodFn, + BuildUrlFn, + SseFn +> & { + interceptors: Middleware; +}; + +/** + * The `createClientConfig()` function will be called on client initialization + * and the returned object will become the client's initial configuration. + * + * You may want to initialize your client this way instead of calling + * `setConfig()`. This is useful for example if you're using Next.js + * to ensure your client always has the correct values. + */ +export type CreateClientConfig = ( + override?: Config, +) => Config & T>; + +export interface TDataShape { + body?: unknown; + headers?: unknown; + path?: unknown; + query?: unknown; + url: string; +} + +type OmitKeys = Pick>; + +export type Options< + TData extends TDataShape = TDataShape, + ThrowOnError extends boolean = boolean, + TResponse = unknown, + TResponseStyle extends ResponseStyle = 'fields', +> = OmitKeys< + RequestOptions, + 'body' | 'path' | 'query' | 'url' +> & + Omit; + +export type OptionsLegacyParser< + TData = unknown, + ThrowOnError extends boolean = boolean, + TResponseStyle extends ResponseStyle = 'fields', +> = TData extends { body?: any } + ? TData extends { headers?: any } + ? OmitKeys< + RequestOptions, + 'body' | 'headers' | 'url' + > & + TData + : OmitKeys< + RequestOptions, + 'body' | 'url' + > & + TData & + Pick, 'headers'> + : TData extends { headers?: any } + ? OmitKeys< + RequestOptions, + 'headers' | 'url' + > & + TData & + Pick, 'body'> + : OmitKeys, 'url'> & + TData; diff --git a/crop-x/src/lib/api/client/utils.gen.ts b/crop-x/src/lib/api/client/utils.gen.ts new file mode 100644 index 0000000..4c48a9e --- /dev/null +++ b/crop-x/src/lib/api/client/utils.gen.ts @@ -0,0 +1,332 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { getAuthToken } from '../core/auth.gen'; +import type { QuerySerializerOptions } from '../core/bodySerializer.gen'; +import { jsonBodySerializer } from '../core/bodySerializer.gen'; +import { + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from '../core/pathSerializer.gen'; +import { getUrl } from '../core/utils.gen'; +import type { Client, ClientOptions, Config, RequestOptions } from './types.gen'; + +export const createQuerySerializer = ({ + parameters = {}, + ...args +}: QuerySerializerOptions = {}) => { + const querySerializer = (queryParams: T) => { + const search: string[] = []; + if (queryParams && typeof queryParams === 'object') { + for (const name in queryParams) { + const value = queryParams[name]; + + if (value === undefined || value === null) { + continue; + } + + const options = parameters[name] || args; + + if (Array.isArray(value)) { + const serializedArray = serializeArrayParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'form', + value, + ...options.array, + }); + if (serializedArray) search.push(serializedArray); + } else if (typeof value === 'object') { + const serializedObject = serializeObjectParam({ + allowReserved: options.allowReserved, + explode: true, + name, + style: 'deepObject', + value: value as Record, + ...options.object, + }); + if (serializedObject) search.push(serializedObject); + } else { + const serializedPrimitive = serializePrimitiveParam({ + allowReserved: options.allowReserved, + name, + value: value as string, + }); + if (serializedPrimitive) search.push(serializedPrimitive); + } + } + } + return search.join('&'); + }; + return querySerializer; +}; + +/** + * Infers parseAs value from provided Content-Type header. + */ +export const getParseAs = ( + contentType: string | null, +): Exclude => { + if (!contentType) { + // If no Content-Type header is provided, the best we can do is return the raw response body, + // which is effectively the same as the 'stream' option. + return 'stream'; + } + + const cleanContent = contentType.split(';')[0]?.trim(); + + if (!cleanContent) { + return; + } + + if ( + cleanContent.startsWith('application/json') || + cleanContent.endsWith('+json') + ) { + return 'json'; + } + + if (cleanContent === 'multipart/form-data') { + return 'formData'; + } + + if ( + ['application/', 'audio/', 'image/', 'video/'].some((type) => + cleanContent.startsWith(type), + ) + ) { + return 'blob'; + } + + if (cleanContent.startsWith('text/')) { + return 'text'; + } + + return; +}; + +const checkForExistence = ( + options: Pick & { + headers: Headers; + }, + name?: string, +): boolean => { + if (!name) { + return false; + } + if ( + options.headers.has(name) || + options.query?.[name] || + options.headers.get('Cookie')?.includes(`${name}=`) + ) { + return true; + } + return false; +}; + +export const setAuthParams = async ({ + security, + ...options +}: Pick, 'security'> & + Pick & { + headers: Headers; + }) => { + for (const auth of security) { + if (checkForExistence(options, auth.name)) { + continue; + } + + const token = await getAuthToken(auth, options.auth); + + if (!token) { + continue; + } + + const name = auth.name ?? 'Authorization'; + + switch (auth.in) { + case 'query': + if (!options.query) { + options.query = {}; + } + options.query[name] = token; + break; + case 'cookie': + options.headers.append('Cookie', `${name}=${token}`); + break; + case 'header': + default: + options.headers.set(name, token); + break; + } + } +}; + +export const buildUrl: Client['buildUrl'] = (options) => + getUrl({ + baseUrl: options.baseUrl as string, + path: options.path, + query: options.query, + querySerializer: + typeof options.querySerializer === 'function' + ? options.querySerializer + : createQuerySerializer(options.querySerializer), + url: options.url, + }); + +export const mergeConfigs = (a: Config, b: Config): Config => { + const config = { ...a, ...b }; + if (config.baseUrl?.endsWith('/')) { + config.baseUrl = config.baseUrl.substring(0, config.baseUrl.length - 1); + } + config.headers = mergeHeaders(a.headers, b.headers); + return config; +}; + +const headersEntries = (headers: Headers): Array<[string, string]> => { + const entries: Array<[string, string]> = []; + headers.forEach((value, key) => { + entries.push([key, value]); + }); + return entries; +}; + +export const mergeHeaders = ( + ...headers: Array['headers'] | undefined> +): Headers => { + const mergedHeaders = new Headers(); + for (const header of headers) { + if (!header) { + continue; + } + + const iterator = + header instanceof Headers + ? headersEntries(header) + : Object.entries(header); + + for (const [key, value] of iterator) { + if (value === null) { + mergedHeaders.delete(key); + } else if (Array.isArray(value)) { + for (const v of value) { + mergedHeaders.append(key, v as string); + } + } else if (value !== undefined) { + // assume object headers are meant to be JSON stringified, i.e. their + // content value in OpenAPI specification is 'application/json' + mergedHeaders.set( + key, + typeof value === 'object' ? JSON.stringify(value) : (value as string), + ); + } + } + } + return mergedHeaders; +}; + +type ErrInterceptor = ( + error: Err, + response: Res, + request: Req, + options: Options, +) => Err | Promise; + +type ReqInterceptor = ( + request: Req, + options: Options, +) => Req | Promise; + +type ResInterceptor = ( + response: Res, + request: Req, + options: Options, +) => Res | Promise; + +class Interceptors { + fns: Array = []; + + clear(): void { + this.fns = []; + } + + eject(id: number | Interceptor): void { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = null; + } + } + + exists(id: number | Interceptor): boolean { + const index = this.getInterceptorIndex(id); + return Boolean(this.fns[index]); + } + + getInterceptorIndex(id: number | Interceptor): number { + if (typeof id === 'number') { + return this.fns[id] ? id : -1; + } + return this.fns.indexOf(id); + } + + update( + id: number | Interceptor, + fn: Interceptor, + ): number | Interceptor | false { + const index = this.getInterceptorIndex(id); + if (this.fns[index]) { + this.fns[index] = fn; + return id; + } + return false; + } + + use(fn: Interceptor): number { + this.fns.push(fn); + return this.fns.length - 1; + } +} + +export interface Middleware { + error: Interceptors>; + request: Interceptors>; + response: Interceptors>; +} + +export const createInterceptors = (): Middleware< + Req, + Res, + Err, + Options +> => ({ + error: new Interceptors>(), + request: new Interceptors>(), + response: new Interceptors>(), +}); + +const defaultQuerySerializer = createQuerySerializer({ + allowReserved: false, + array: { + explode: true, + style: 'form', + }, + object: { + explode: true, + style: 'deepObject', + }, +}); + +const defaultHeaders = { + 'Content-Type': 'application/json', +}; + +export const createConfig = ( + override: Config & T> = {}, +): Config & T> => ({ + ...jsonBodySerializer, + headers: defaultHeaders, + parseAs: 'auto', + querySerializer: defaultQuerySerializer, + ...override, +}); diff --git a/crop-x/src/lib/api/core/auth.gen.ts b/crop-x/src/lib/api/core/auth.gen.ts new file mode 100644 index 0000000..f8a7326 --- /dev/null +++ b/crop-x/src/lib/api/core/auth.gen.ts @@ -0,0 +1,42 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type AuthToken = string | undefined; + +export interface Auth { + /** + * Which part of the request do we use to send the auth? + * + * @default 'header' + */ + in?: 'header' | 'query' | 'cookie'; + /** + * Header or query parameter name. + * + * @default 'Authorization' + */ + name?: string; + scheme?: 'basic' | 'bearer'; + type: 'apiKey' | 'http'; +} + +export const getAuthToken = async ( + auth: Auth, + callback: ((auth: Auth) => Promise | AuthToken) | AuthToken, +): Promise => { + const token = + typeof callback === 'function' ? await callback(auth) : callback; + + if (!token) { + return; + } + + if (auth.scheme === 'bearer') { + return `Bearer ${token}`; + } + + if (auth.scheme === 'basic') { + return `Basic ${btoa(token)}`; + } + + return token; +}; diff --git a/crop-x/src/lib/api/core/bodySerializer.gen.ts b/crop-x/src/lib/api/core/bodySerializer.gen.ts new file mode 100644 index 0000000..552b50f --- /dev/null +++ b/crop-x/src/lib/api/core/bodySerializer.gen.ts @@ -0,0 +1,100 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { + ArrayStyle, + ObjectStyle, + SerializerOptions, +} from './pathSerializer.gen'; + +export type QuerySerializer = (query: Record) => string; + +export type BodySerializer = (body: any) => any; + +type QuerySerializerOptionsObject = { + allowReserved?: boolean; + array?: Partial>; + object?: Partial>; +}; + +export type QuerySerializerOptions = QuerySerializerOptionsObject & { + /** + * Per-parameter serialization overrides. When provided, these settings + * override the global array/object settings for specific parameter names. + */ + parameters?: Record; +}; + +const serializeFormDataPair = ( + data: FormData, + key: string, + value: unknown, +): void => { + if (typeof value === 'string' || value instanceof Blob) { + data.append(key, value); + } else if (value instanceof Date) { + data.append(key, value.toISOString()); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +const serializeUrlSearchParamsPair = ( + data: URLSearchParams, + key: string, + value: unknown, +): void => { + if (typeof value === 'string') { + data.append(key, value); + } else { + data.append(key, JSON.stringify(value)); + } +}; + +export const formDataBodySerializer = { + bodySerializer: | Array>>( + body: T, + ): FormData => { + const data = new FormData(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeFormDataPair(data, key, v)); + } else { + serializeFormDataPair(data, key, value); + } + }); + + return data; + }, +}; + +export const jsonBodySerializer = { + bodySerializer: (body: T): string => + JSON.stringify(body, (_key, value) => + typeof value === 'bigint' ? value.toString() : value, + ), +}; + +export const urlSearchParamsBodySerializer = { + bodySerializer: | Array>>( + body: T, + ): string => { + const data = new URLSearchParams(); + + Object.entries(body).forEach(([key, value]) => { + if (value === undefined || value === null) { + return; + } + if (Array.isArray(value)) { + value.forEach((v) => serializeUrlSearchParamsPair(data, key, v)); + } else { + serializeUrlSearchParamsPair(data, key, value); + } + }); + + return data.toString(); + }, +}; diff --git a/crop-x/src/lib/api/core/params.gen.ts b/crop-x/src/lib/api/core/params.gen.ts new file mode 100644 index 0000000..71c88e8 --- /dev/null +++ b/crop-x/src/lib/api/core/params.gen.ts @@ -0,0 +1,153 @@ +// This file is auto-generated by @hey-api/openapi-ts + +type Slot = 'body' | 'headers' | 'path' | 'query'; + +export type Field = + | { + in: Exclude; + /** + * Field name. This is the name we want the user to see and use. + */ + key: string; + /** + * Field mapped name. This is the name we want to use in the request. + * If omitted, we use the same value as `key`. + */ + map?: string; + } + | { + in: Extract; + /** + * Key isn't required for bodies. + */ + key?: string; + map?: string; + }; + +export interface Fields { + allowExtra?: Partial>; + args?: ReadonlyArray; +} + +export type FieldsConfig = ReadonlyArray; + +const extraPrefixesMap: Record = { + $body_: 'body', + $headers_: 'headers', + $path_: 'path', + $query_: 'query', +}; +const extraPrefixes = Object.entries(extraPrefixesMap); + +type KeyMap = Map< + string, + { + in: Slot; + map?: string; + } +>; + +const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { + if (!map) { + map = new Map(); + } + + for (const config of fields) { + if ('in' in config) { + if (config.key) { + map.set(config.key, { + in: config.in, + map: config.map, + }); + } + } else if (config.args) { + buildKeyMap(config.args, map); + } + } + + return map; +}; + +interface Params { + body: unknown; + headers: Record; + path: Record; + query: Record; +} + +const stripEmptySlots = (params: Params) => { + for (const [slot, value] of Object.entries(params)) { + if (value && typeof value === 'object' && !Object.keys(value).length) { + delete params[slot as Slot]; + } + } +}; + +export const buildClientParams = ( + args: ReadonlyArray, + fields: FieldsConfig, +) => { + const params: Params = { + body: {}, + headers: {}, + path: {}, + query: {}, + }; + + const map = buildKeyMap(fields); + + let config: FieldsConfig[number] | undefined; + + for (const [index, arg] of args.entries()) { + if (fields[index]) { + config = fields[index]; + } + + if (!config) { + continue; + } + + if ('in' in config) { + if (config.key) { + const field = map.get(config.key)!; + const name = field.map || config.key; + (params[field.in] as Record)[name] = arg; + } else { + params.body = arg; + } + } else { + for (const [key, value] of Object.entries(arg ?? {})) { + const field = map.get(key); + + if (field) { + const name = field.map || key; + (params[field.in] as Record)[name] = value; + } else { + const extra = extraPrefixes.find(([prefix]) => + key.startsWith(prefix), + ); + + if (extra) { + const [prefix, slot] = extra; + (params[slot] as Record)[ + key.slice(prefix.length) + ] = value; + } else { + for (const [slot, allowed] of Object.entries( + config.allowExtra ?? {}, + )) { + if (allowed) { + (params[slot as Slot] as Record)[key] = value; + break; + } + } + } + } + } + } + } + + stripEmptySlots(params); + + return params; +}; diff --git a/crop-x/src/lib/api/core/pathSerializer.gen.ts b/crop-x/src/lib/api/core/pathSerializer.gen.ts new file mode 100644 index 0000000..8d99931 --- /dev/null +++ b/crop-x/src/lib/api/core/pathSerializer.gen.ts @@ -0,0 +1,181 @@ +// This file is auto-generated by @hey-api/openapi-ts + +interface SerializeOptions + extends SerializePrimitiveOptions, + SerializerOptions {} + +interface SerializePrimitiveOptions { + allowReserved?: boolean; + name: string; +} + +export interface SerializerOptions { + /** + * @default true + */ + explode: boolean; + style: T; +} + +export type ArrayStyle = 'form' | 'spaceDelimited' | 'pipeDelimited'; +export type ArraySeparatorStyle = ArrayStyle | MatrixStyle; +type MatrixStyle = 'label' | 'matrix' | 'simple'; +export type ObjectStyle = 'form' | 'deepObject'; +type ObjectSeparatorStyle = ObjectStyle | MatrixStyle; + +interface SerializePrimitiveParam extends SerializePrimitiveOptions { + value: string; +} + +export const separatorArrayExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { + switch (style) { + case 'form': + return ','; + case 'pipeDelimited': + return '|'; + case 'spaceDelimited': + return '%20'; + default: + return ','; + } +}; + +export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { + switch (style) { + case 'label': + return '.'; + case 'matrix': + return ';'; + case 'simple': + return ','; + default: + return '&'; + } +}; + +export const serializeArrayParam = ({ + allowReserved, + explode, + name, + style, + value, +}: SerializeOptions & { + value: unknown[]; +}) => { + if (!explode) { + const joinedValues = ( + allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) + ).join(separatorArrayNoExplode(style)); + switch (style) { + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + case 'simple': + return joinedValues; + default: + return `${name}=${joinedValues}`; + } + } + + const separator = separatorArrayExplode(style); + const joinedValues = value + .map((v) => { + if (style === 'label' || style === 'simple') { + return allowReserved ? v : encodeURIComponent(v as string); + } + + return serializePrimitiveParam({ + allowReserved, + name, + value: v as string, + }); + }) + .join(separator); + return style === 'label' || style === 'matrix' + ? separator + joinedValues + : joinedValues; +}; + +export const serializePrimitiveParam = ({ + allowReserved, + name, + value, +}: SerializePrimitiveParam) => { + if (value === undefined || value === null) { + return ''; + } + + if (typeof value === 'object') { + throw new Error( + 'Deeply-nested arrays/objects aren’t supported. Provide your own `querySerializer()` to handle these.', + ); + } + + return `${name}=${allowReserved ? value : encodeURIComponent(value)}`; +}; + +export const serializeObjectParam = ({ + allowReserved, + explode, + name, + style, + value, + valueOnly, +}: SerializeOptions & { + value: Record | Date; + valueOnly?: boolean; +}) => { + if (value instanceof Date) { + return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; + } + + if (style !== 'deepObject' && !explode) { + let values: string[] = []; + Object.entries(value).forEach(([key, v]) => { + values = [ + ...values, + key, + allowReserved ? (v as string) : encodeURIComponent(v as string), + ]; + }); + const joinedValues = values.join(','); + switch (style) { + case 'form': + return `${name}=${joinedValues}`; + case 'label': + return `.${joinedValues}`; + case 'matrix': + return `;${name}=${joinedValues}`; + default: + return joinedValues; + } + } + + const separator = separatorObjectExplode(style); + const joinedValues = Object.entries(value) + .map(([key, v]) => + serializePrimitiveParam({ + allowReserved, + name: style === 'deepObject' ? `${name}[${key}]` : key, + value: v as string, + }), + ) + .join(separator); + return style === 'label' || style === 'matrix' + ? separator + joinedValues + : joinedValues; +}; diff --git a/crop-x/src/lib/api/core/queryKeySerializer.gen.ts b/crop-x/src/lib/api/core/queryKeySerializer.gen.ts new file mode 100644 index 0000000..d3bb683 --- /dev/null +++ b/crop-x/src/lib/api/core/queryKeySerializer.gen.ts @@ -0,0 +1,136 @@ +// This file is auto-generated by @hey-api/openapi-ts + +/** + * JSON-friendly union that mirrors what Pinia Colada can hash. + */ +export type JsonValue = + | null + | string + | number + | boolean + | JsonValue[] + | { [key: string]: JsonValue }; + +/** + * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. + */ +export const queryKeyJsonReplacer = (_key: string, value: unknown) => { + if ( + value === undefined || + typeof value === 'function' || + typeof value === 'symbol' + ) { + return undefined; + } + if (typeof value === 'bigint') { + return value.toString(); + } + if (value instanceof Date) { + return value.toISOString(); + } + return value; +}; + +/** + * Safely stringifies a value and parses it back into a JsonValue. + */ +export const stringifyToJsonValue = (input: unknown): JsonValue | undefined => { + try { + const json = JSON.stringify(input, queryKeyJsonReplacer); + if (json === undefined) { + return undefined; + } + return JSON.parse(json) as JsonValue; + } catch { + return undefined; + } +}; + +/** + * Detects plain objects (including objects with a null prototype). + */ +const isPlainObject = (value: unknown): value is Record => { + if (value === null || typeof value !== 'object') { + return false; + } + const prototype = Object.getPrototypeOf(value as object); + return prototype === Object.prototype || prototype === null; +}; + +/** + * Turns URLSearchParams into a sorted JSON object for deterministic keys. + */ +const serializeSearchParams = (params: URLSearchParams): JsonValue => { + const entries = Array.from(params.entries()).sort(([a], [b]) => + a.localeCompare(b), + ); + const result: Record = {}; + + for (const [key, value] of entries) { + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + continue; + } + + if (Array.isArray(existing)) { + (existing as string[]).push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; +}; + +/** + * Normalizes any accepted value into a JSON-friendly shape for query keys. + */ +export const serializeQueryKeyValue = ( + value: unknown, +): JsonValue | undefined => { + if (value === null) { + return null; + } + + if ( + typeof value === 'string' || + typeof value === 'number' || + typeof value === 'boolean' + ) { + return value; + } + + if ( + value === undefined || + typeof value === 'function' || + typeof value === 'symbol' + ) { + return undefined; + } + + if (typeof value === 'bigint') { + return value.toString(); + } + + if (value instanceof Date) { + return value.toISOString(); + } + + if (Array.isArray(value)) { + return stringifyToJsonValue(value); + } + + if ( + typeof URLSearchParams !== 'undefined' && + value instanceof URLSearchParams + ) { + return serializeSearchParams(value); + } + + if (isPlainObject(value)) { + return stringifyToJsonValue(value); + } + + return undefined; +}; diff --git a/crop-x/src/lib/api/core/serverSentEvents.gen.ts b/crop-x/src/lib/api/core/serverSentEvents.gen.ts new file mode 100644 index 0000000..f8fd78e --- /dev/null +++ b/crop-x/src/lib/api/core/serverSentEvents.gen.ts @@ -0,0 +1,264 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Config } from './types.gen'; + +export type ServerSentEventsOptions = Omit< + RequestInit, + 'method' +> & + Pick & { + /** + * Fetch API implementation. You can use this option to provide a custom + * fetch instance. + * + * @default globalThis.fetch + */ + fetch?: typeof fetch; + /** + * Implementing clients can call request interceptors inside this hook. + */ + onRequest?: (url: string, init: RequestInit) => Promise; + /** + * Callback invoked when a network or parsing error occurs during streaming. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param error The error that occurred. + */ + onSseError?: (error: unknown) => void; + /** + * Callback invoked when an event is streamed from the server. + * + * This option applies only if the endpoint returns a stream of events. + * + * @param event Event streamed from the server. + * @returns Nothing (void). + */ + onSseEvent?: (event: StreamEvent) => void; + serializedBody?: RequestInit['body']; + /** + * Default retry delay in milliseconds. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 3000 + */ + sseDefaultRetryDelay?: number; + /** + * Maximum number of retry attempts before giving up. + */ + sseMaxRetryAttempts?: number; + /** + * Maximum retry delay in milliseconds. + * + * Applies only when exponential backoff is used. + * + * This option applies only if the endpoint returns a stream of events. + * + * @default 30000 + */ + sseMaxRetryDelay?: number; + /** + * Optional sleep function for retry backoff. + * + * Defaults to using `setTimeout`. + */ + sseSleepFn?: (ms: number) => Promise; + url: string; + }; + +export interface StreamEvent { + data: TData; + event?: string; + id?: string; + retry?: number; +} + +export type ServerSentEventsResult< + TData = unknown, + TReturn = void, + TNext = unknown, +> = { + stream: AsyncGenerator< + TData extends Record ? TData[keyof TData] : TData, + TReturn, + TNext + >; +}; + +export const createSseClient = ({ + onRequest, + onSseError, + onSseEvent, + responseTransformer, + responseValidator, + sseDefaultRetryDelay, + sseMaxRetryAttempts, + sseMaxRetryDelay, + sseSleepFn, + url, + ...options +}: ServerSentEventsOptions): ServerSentEventsResult => { + let lastEventId: string | undefined; + + const sleep = + sseSleepFn ?? + ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))); + + const createStream = async function* () { + let retryDelay: number = sseDefaultRetryDelay ?? 3000; + let attempt = 0; + const signal = options.signal ?? new AbortController().signal; + + while (true) { + if (signal.aborted) break; + + attempt++; + + const headers = + options.headers instanceof Headers + ? options.headers + : new Headers(options.headers as Record | undefined); + + if (lastEventId !== undefined) { + headers.set('Last-Event-ID', lastEventId); + } + + try { + const requestInit: RequestInit = { + redirect: 'follow', + ...options, + body: options.serializedBody, + headers, + signal, + }; + let request = new Request(url, requestInit); + if (onRequest) { + request = await onRequest(url, requestInit); + } + // fetch must be assigned here, otherwise it would throw the error: + // TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation + const _fetch = options.fetch ?? globalThis.fetch; + const response = await _fetch(request); + + if (!response.ok) + throw new Error( + `SSE failed: ${response.status} ${response.statusText}`, + ); + + if (!response.body) throw new Error('No body in SSE response'); + + const reader = response.body + .pipeThrough(new TextDecoderStream()) + .getReader(); + + let buffer = ''; + + const abortHandler = () => { + try { + reader.cancel(); + } catch { + // noop + } + }; + + signal.addEventListener('abort', abortHandler); + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + buffer += value; + + const chunks = buffer.split('\n\n'); + buffer = chunks.pop() ?? ''; + + for (const chunk of chunks) { + const lines = chunk.split('\n'); + const dataLines: Array = []; + let eventName: string | undefined; + + for (const line of lines) { + if (line.startsWith('data:')) { + dataLines.push(line.replace(/^data:\s*/, '')); + } else if (line.startsWith('event:')) { + eventName = line.replace(/^event:\s*/, ''); + } else if (line.startsWith('id:')) { + lastEventId = line.replace(/^id:\s*/, ''); + } else if (line.startsWith('retry:')) { + const parsed = Number.parseInt( + line.replace(/^retry:\s*/, ''), + 10, + ); + if (!Number.isNaN(parsed)) { + retryDelay = parsed; + } + } + } + + let data: unknown; + let parsedJson = false; + + if (dataLines.length) { + const rawData = dataLines.join('\n'); + try { + data = JSON.parse(rawData); + parsedJson = true; + } catch { + data = rawData; + } + } + + if (parsedJson) { + if (responseValidator) { + await responseValidator(data); + } + + if (responseTransformer) { + data = await responseTransformer(data); + } + } + + onSseEvent?.({ + data, + event: eventName, + id: lastEventId, + retry: retryDelay, + }); + + if (dataLines.length) { + yield data as any; + } + } + } + } finally { + signal.removeEventListener('abort', abortHandler); + reader.releaseLock(); + } + + break; // exit loop on normal completion + } catch (error) { + // connection failed or aborted; retry after delay + onSseError?.(error); + + if ( + sseMaxRetryAttempts !== undefined && + attempt >= sseMaxRetryAttempts + ) { + break; // stop after firing error + } + + // exponential backoff: double retry each attempt, cap at 30s + const backoff = Math.min( + retryDelay * 2 ** (attempt - 1), + sseMaxRetryDelay ?? 30000, + ); + await sleep(backoff); + } + } + }; + + const stream = createStream(); + + return { stream }; +}; diff --git a/crop-x/src/lib/api/core/types.gen.ts b/crop-x/src/lib/api/core/types.gen.ts new file mode 100644 index 0000000..643c070 --- /dev/null +++ b/crop-x/src/lib/api/core/types.gen.ts @@ -0,0 +1,118 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Auth, AuthToken } from './auth.gen'; +import type { + BodySerializer, + QuerySerializer, + QuerySerializerOptions, +} from './bodySerializer.gen'; + +export type HttpMethod = + | 'connect' + | 'delete' + | 'get' + | 'head' + | 'options' + | 'patch' + | 'post' + | 'put' + | 'trace'; + +export type Client< + RequestFn = never, + Config = unknown, + MethodFn = never, + BuildUrlFn = never, + SseFn = never, +> = { + /** + * Returns the final request URL. + */ + buildUrl: BuildUrlFn; + getConfig: () => Config; + request: RequestFn; + setConfig: (config: Config) => Config; +} & { + [K in HttpMethod]: MethodFn; +} & ([SseFn] extends [never] + ? { sse?: never } + : { sse: { [K in HttpMethod]: SseFn } }); + +export interface Config { + /** + * Auth token or a function returning auth token. The resolved value will be + * added to the request payload as defined by its `security` array. + */ + auth?: ((auth: Auth) => Promise | AuthToken) | AuthToken; + /** + * A function for serializing request body parameter. By default, + * {@link JSON.stringify()} will be used. + */ + bodySerializer?: BodySerializer | null; + /** + * An object containing any HTTP headers that you want to pre-populate your + * `Headers` object with. + * + * {@link https://developer.mozilla.org/docs/Web/API/Headers/Headers#init See more} + */ + headers?: + | RequestInit['headers'] + | Record< + string, + | string + | number + | boolean + | (string | number | boolean)[] + | null + | undefined + | unknown + >; + /** + * The request method. + * + * {@link https://developer.mozilla.org/docs/Web/API/fetch#method See more} + */ + method?: Uppercase; + /** + * A function for serializing request query parameters. By default, arrays + * will be exploded in form style, objects will be exploded in deepObject + * style, and reserved characters are percent-encoded. + * + * This method will have no effect if the native `paramsSerializer()` Axios + * API function is used. + * + * {@link https://swagger.io/docs/specification/serialization/#query View examples} + */ + querySerializer?: QuerySerializer | QuerySerializerOptions; + /** + * A function validating request data. This is useful if you want to ensure + * the request conforms to the desired shape, so it can be safely sent to + * the server. + */ + requestValidator?: (data: unknown) => Promise; + /** + * A function transforming response data before it's returned. This is useful + * for post-processing data, e.g. converting ISO strings into Date objects. + */ + responseTransformer?: (data: unknown) => Promise; + /** + * A function validating response data. This is useful if you want to ensure + * the response conforms to the desired shape, so it can be safely passed to + * the transformers and returned to the user. + */ + responseValidator?: (data: unknown) => Promise; +} + +type IsExactlyNeverOrNeverUndefined = [T] extends [never] + ? true + : [T] extends [never | undefined] + ? [undefined] extends [T] + ? false + : true + : false; + +export type OmitNever> = { + [K in keyof T as IsExactlyNeverOrNeverUndefined extends true + ? never + : K]: T[K]; +}; diff --git a/crop-x/src/lib/api/core/utils.gen.ts b/crop-x/src/lib/api/core/utils.gen.ts new file mode 100644 index 0000000..0b5389d --- /dev/null +++ b/crop-x/src/lib/api/core/utils.gen.ts @@ -0,0 +1,143 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { BodySerializer, QuerySerializer } from './bodySerializer.gen'; +import { + type ArraySeparatorStyle, + serializeArrayParam, + serializeObjectParam, + serializePrimitiveParam, +} from './pathSerializer.gen'; + +export interface PathSerializer { + path: Record; + url: string; +} + +export const PATH_PARAM_RE = /\{[^{}]+\}/g; + +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { + let url = _url; + const matches = _url.match(PATH_PARAM_RE); + if (matches) { + for (const match of matches) { + let explode = false; + let name = match.substring(1, match.length - 1); + let style: ArraySeparatorStyle = 'simple'; + + if (name.endsWith('*')) { + explode = true; + name = name.substring(0, name.length - 1); + } + + if (name.startsWith('.')) { + name = name.substring(1); + style = 'label'; + } else if (name.startsWith(';')) { + name = name.substring(1); + style = 'matrix'; + } + + const value = path[name]; + + if (value === undefined || value === null) { + continue; + } + + if (Array.isArray(value)) { + url = url.replace( + match, + serializeArrayParam({ explode, name, style, value }), + ); + continue; + } + + if (typeof value === 'object') { + url = url.replace( + match, + serializeObjectParam({ + explode, + name, + style, + value: value as Record, + valueOnly: true, + }), + ); + continue; + } + + if (style === 'matrix') { + url = url.replace( + match, + `;${serializePrimitiveParam({ + name, + value: value as string, + })}`, + ); + continue; + } + + const replaceValue = encodeURIComponent( + style === 'label' ? `.${value as string}` : (value as string), + ); + url = url.replace(match, replaceValue); + } + } + return url; +}; + +export const getUrl = ({ + baseUrl, + path, + query, + querySerializer, + url: _url, +}: { + baseUrl?: string; + path?: Record; + query?: Record; + querySerializer: QuerySerializer; + url: string; +}) => { + const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; + let url = (baseUrl ?? '') + pathUrl; + if (path) { + url = defaultPathSerializer({ path, url }); + } + let search = query ? querySerializer(query) : ''; + if (search.startsWith('?')) { + search = search.substring(1); + } + if (search) { + url += `?${search}`; + } + return url; +}; + +export function getValidRequestBody(options: { + body?: unknown; + bodySerializer?: BodySerializer | null; + serializedBody?: unknown; +}) { + const hasBody = options.body !== undefined; + const isSerializedBody = hasBody && options.bodySerializer; + + if (isSerializedBody) { + if ('serializedBody' in options) { + const hasSerializedBody = + options.serializedBody !== undefined && options.serializedBody !== ''; + + return hasSerializedBody ? options.serializedBody : null; + } + + // not all clients implement a serializedBody property (i.e. client-axios) + return options.body !== '' ? options.body : null; + } + + // plain/text body + if (hasBody) { + return options.body; + } + + // no body was provided + return undefined; +} diff --git a/crop-x/src/lib/api/index.ts b/crop-x/src/lib/api/index.ts new file mode 100644 index 0000000..c352c10 --- /dev/null +++ b/crop-x/src/lib/api/index.ts @@ -0,0 +1,4 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type * from './types.gen'; +export * from './sdk.gen'; diff --git a/crop-x/src/lib/api/sdk.gen.ts b/crop-x/src/lib/api/sdk.gen.ts new file mode 100644 index 0000000..f87ef7c --- /dev/null +++ b/crop-x/src/lib/api/sdk.gen.ts @@ -0,0 +1,132 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import type { Client, Options as Options2, TDataShape } from './client'; +import { client } from './client.gen'; +import type { GetAllUsersApiV1AuthUsersGetData, GetAllUsersApiV1AuthUsersGetResponses, GetCurrentUserApiV1AuthMeGetData, GetCurrentUserApiV1AuthMeGetResponses, HealthCheckHealthGetData, HealthCheckHealthGetResponses, LoginApiV1AuthLoginPostData, LoginApiV1AuthLoginPostErrors, LoginApiV1AuthLoginPostResponses, LogoutApiV1AuthLogoutPostData, LogoutApiV1AuthLogoutPostResponses, RegisterApiV1AuthRegisterPostData, RegisterApiV1AuthRegisterPostErrors, RegisterApiV1AuthRegisterPostResponses, RootGetData, RootGetResponses } from './types.gen'; + +export type Options = Options2 & { + /** + * You can provide a client instance returned by `createClient()` instead of + * individual options. This might be also useful if you want to implement a + * custom client. + */ + client?: Client; + /** + * You can pass arbitrary values through the `meta` object. This can be + * used to access values that aren't defined as part of the SDK function. + */ + meta?: Record; +}; + +/** + * 用户登录 + * + * 用户登录接口 + * + * - **username**: 用户名 + * - **password**: 密码 + * + * 返回JWT访问令牌 + */ +export const loginApiV1AuthLoginPost = (options: Options) => { + return (options.client ?? client).post({ + url: '/api/v1/auth/login', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + +/** + * 用户注册 + * + * 用户注册接口 + * + * - **username**: 用户名 (必须唯一) + * - **password**: 密码 + * + * 注意:这是一个演示版本,实际生产环境需要更严格的验证 + */ +export const registerApiV1AuthRegisterPost = (options: Options) => { + return (options.client ?? client).post({ + url: '/api/v1/auth/register', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + +/** + * 获取当前用户信息 + * + * 获取当前登录用户的信息 + */ +export const getCurrentUserApiV1AuthMeGet = (options?: Options) => { + return (options?.client ?? client).get({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/auth/me', + ...options + }); +}; + +/** + * 用户登出 + * + * 用户登出接口 + * + * 注意:由于JWT是无状态的,实际登出需要客户端删除token + * 这里只是验证token并返回成功消息 + */ +export const logoutApiV1AuthLogoutPost = (options?: Options) => { + return (options?.client ?? client).post({ + security: [ + { + scheme: 'bearer', + type: 'http' + } + ], + url: '/api/v1/auth/logout', + ...options + }); +}; + +/** + * 获取所有用户列表 + * + * 获取系统中所有用户的列表 (仅用于演示) + */ +export const getAllUsersApiV1AuthUsersGet = (options?: Options) => { + return (options?.client ?? client).get({ + url: '/api/v1/auth/users', + ...options + }); +}; + +/** + * Root + */ +export const rootGet = (options?: Options) => { + return (options?.client ?? client).get({ + url: '/', + ...options + }); +}; + +/** + * Health Check + */ +export const healthCheckHealthGet = (options?: Options) => { + return (options?.client ?? client).get({ + url: '/health', + ...options + }); +}; diff --git a/crop-x/src/lib/api/types.gen.ts b/crop-x/src/lib/api/types.gen.ts new file mode 100644 index 0000000..ca30c94 --- /dev/null +++ b/crop-x/src/lib/api/types.gen.ts @@ -0,0 +1,207 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: 'http://localhost:8080' | (string & {}); +}; + +/** + * APIResponse + */ +export type ApiResponse = { + /** + * Success + */ + success: boolean; + /** + * Message + */ + message: string; + /** + * Data + */ + data?: { + [key: string]: unknown; + } | null; +}; + +/** + * HTTPValidationError + */ +export type HttpValidationError = { + /** + * Detail + */ + detail?: Array; +}; + +/** + * UserLogin + */ +export type UserLogin = { + /** + * Username + */ + username: string; + /** + * Password + */ + password: string; +}; + +/** + * UserRegister + */ +export type UserRegister = { + /** + * Username + */ + username: string; + /** + * Password + */ + password: string; +}; + +/** + * ValidationError + */ +export type ValidationError = { + /** + * Location + */ + loc: Array; + /** + * Message + */ + msg: string; + /** + * Error Type + */ + type: string; +}; + +export type LoginApiV1AuthLoginPostData = { + body: UserLogin; + path?: never; + query?: never; + url: '/api/v1/auth/login'; +}; + +export type LoginApiV1AuthLoginPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type LoginApiV1AuthLoginPostError = LoginApiV1AuthLoginPostErrors[keyof LoginApiV1AuthLoginPostErrors]; + +export type LoginApiV1AuthLoginPostResponses = { + /** + * Successful Response + */ + 200: ApiResponse; +}; + +export type LoginApiV1AuthLoginPostResponse = LoginApiV1AuthLoginPostResponses[keyof LoginApiV1AuthLoginPostResponses]; + +export type RegisterApiV1AuthRegisterPostData = { + body: UserRegister; + path?: never; + query?: never; + url: '/api/v1/auth/register'; +}; + +export type RegisterApiV1AuthRegisterPostErrors = { + /** + * Validation Error + */ + 422: HttpValidationError; +}; + +export type RegisterApiV1AuthRegisterPostError = RegisterApiV1AuthRegisterPostErrors[keyof RegisterApiV1AuthRegisterPostErrors]; + +export type RegisterApiV1AuthRegisterPostResponses = { + /** + * Successful Response + */ + 200: ApiResponse; +}; + +export type RegisterApiV1AuthRegisterPostResponse = RegisterApiV1AuthRegisterPostResponses[keyof RegisterApiV1AuthRegisterPostResponses]; + +export type GetCurrentUserApiV1AuthMeGetData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/auth/me'; +}; + +export type GetCurrentUserApiV1AuthMeGetResponses = { + /** + * Successful Response + */ + 200: ApiResponse; +}; + +export type GetCurrentUserApiV1AuthMeGetResponse = GetCurrentUserApiV1AuthMeGetResponses[keyof GetCurrentUserApiV1AuthMeGetResponses]; + +export type LogoutApiV1AuthLogoutPostData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/auth/logout'; +}; + +export type LogoutApiV1AuthLogoutPostResponses = { + /** + * Successful Response + */ + 200: ApiResponse; +}; + +export type LogoutApiV1AuthLogoutPostResponse = LogoutApiV1AuthLogoutPostResponses[keyof LogoutApiV1AuthLogoutPostResponses]; + +export type GetAllUsersApiV1AuthUsersGetData = { + body?: never; + path?: never; + query?: never; + url: '/api/v1/auth/users'; +}; + +export type GetAllUsersApiV1AuthUsersGetResponses = { + /** + * Successful Response + */ + 200: ApiResponse; +}; + +export type GetAllUsersApiV1AuthUsersGetResponse = GetAllUsersApiV1AuthUsersGetResponses[keyof GetAllUsersApiV1AuthUsersGetResponses]; + +export type RootGetData = { + body?: never; + path?: never; + query?: never; + url: '/'; +}; + +export type RootGetResponses = { + /** + * Successful Response + */ + 200: unknown; +}; + +export type HealthCheckHealthGetData = { + body?: never; + path?: never; + query?: never; + url: '/health'; +}; + +export type HealthCheckHealthGetResponses = { + /** + * Successful Response + */ + 200: unknown; +}; diff --git a/crop-x/src/lib/api/v1.d.ts b/crop-x/src/lib/api/v1.d.ts deleted file mode 100644 index 5d4fc02..0000000 --- a/crop-x/src/lib/api/v1.d.ts +++ /dev/null @@ -1,526 +0,0 @@ -/** - * This file was auto-generated by openapi-typescript. - * Do not make direct changes to the file. - */ - -export interface paths { - "/users": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * 获取用户列表 - * @description 获取所有用户的分页列表 - */ - get: { - parameters: { - query?: { - /** @description 页码 */ - page?: number; - /** @description 每页数量 */ - limit?: number; - /** @description 搜索关键词 */ - search?: string; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description 成功获取用户列表 */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @example 200 */ - code?: number; - /** @example success */ - message?: string; - data?: { - users?: components["schemas"]["User"][]; - /** @example 100 */ - total?: number; - /** @example 1 */ - page?: number; - /** @example 20 */ - limit?: number; - }; - }; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/users/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * 获取用户详情 - * @description 根据用户ID获取用户详细信息 - */ - get: { - parameters: { - query?: never; - header?: never; - path: { - /** @description 用户ID */ - id: number; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description 成功获取用户详情 */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @example 200 */ - code?: number; - /** @example success */ - message?: string; - data?: components["schemas"]["User"]; - }; - }; - }; - /** @description 用户不存在 */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["Error"]; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/machinery": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** - * 获取农机列表 - * @description 获取所有农机的分页列表 - */ - get: { - parameters: { - query?: { - page?: number; - limit?: number; - /** @description 农机状态筛选 */ - status?: "running" | "idle" | "maintenance" | "error" | "offline"; - }; - header?: never; - path?: never; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description 成功获取农机列表 */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @example 200 */ - code?: number; - /** @example success */ - message?: string; - data?: { - machinery?: components["schemas"]["Machinery"][]; - /** @example 50 */ - total?: number; - }; - }; - }; - }; - }; - }; - put?: never; - /** - * 创建农机 - * @description 创建新的农机记录 - */ - post: { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["CreateMachineryRequest"]; - }; - }; - responses: { - /** @description 农机创建成功 */ - 201: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @example 201 */ - code?: number; - /** @example 农机创建成功 */ - message?: string; - data?: components["schemas"]["Machinery"]; - }; - }; - }; - }; - }; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - "/machinery/{id}": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** 获取农机详情 */ - get: { - parameters: { - query?: never; - header?: never; - path: { - id: number; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description 成功获取农机详情 */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @example 200 */ - code?: number; - /** @example success */ - message?: string; - data?: components["schemas"]["Machinery"]; - }; - }; - }; - }; - }; - /** 更新农机信息 */ - put: { - parameters: { - query?: never; - header?: never; - path: { - id: number; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["UpdateMachineryRequest"]; - }; - }; - responses: { - /** @description 农机更新成功 */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": { - /** @example 200 */ - code?: number; - /** @example 农机更新成功 */ - message?: string; - data?: components["schemas"]["Machinery"]; - }; - }; - }; - }; - }; - post?: never; - /** 删除农机 */ - delete: { - parameters: { - query?: never; - header?: never; - path: { - id: number; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description 农机删除成功 */ - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; -} -export type webhooks = Record; -export interface components { - schemas: { - User: { - /** - * @description 用户ID - * @example 1 - */ - id?: number; - /** - * @description 用户名 - * @example john_doe - */ - username?: string; - /** - * Format: email - * @description 邮箱地址 - * @example john@example.com - */ - email?: string; - /** - * @description 全名 - * @example John Doe - */ - full_name?: string; - /** - * @description 手机号码 - * @example 13800138000 - */ - phone?: string; - /** - * @description 用户角色 - * @example operator - * @enum {string} - */ - role?: "admin" | "manager" | "operator" | "viewer"; - /** - * @description 用户状态 - * @example active - * @enum {string} - */ - status?: "active" | "inactive" | "suspended"; - /** - * Format: date-time - * @description 创建时间 - * @example 2024-01-15T10:30:00Z - */ - created_at?: string; - /** - * Format: date-time - * @description 更新时间 - * @example 2024-01-15T10:30:00Z - */ - updated_at?: string; - }; - Machinery: { - /** - * @description 农机ID - * @example 1 - */ - id?: number; - /** - * @description 农机名称 - * @example 拖拉机-001 - */ - name?: string; - /** - * @description 农机类型 - * @example tractor - * @enum {string} - */ - type?: "tractor" | "harvester" | "planter" | "sprayer" | "irrigation"; - /** - * @description 型号 - * @example John Deere 6M Series - */ - model?: string; - /** - * @description 序列号 - * @example JD6M123456 - */ - serial_number?: string; - /** - * @description 农机状态 - * @example idle - * @enum {string} - */ - status?: "running" | "idle" | "maintenance" | "error" | "offline"; - location?: components["schemas"]["Location"]; - operator?: components["schemas"]["User"]; - /** - * Format: date - * @description 购买日期 - * @example 2024-01-01 - */ - purchase_date?: string; - /** - * Format: date - * @description 上次维护日期 - * @example 2024-06-15 - */ - last_maintenance?: string; - /** - * Format: date - * @description 下次维护日期 - * @example 2024-09-15 - */ - next_maintenance?: string; - /** - * Format: date-time - * @description 创建时间 - */ - created_at?: string; - /** - * Format: date-time - * @description 更新时间 - */ - updated_at?: string; - }; - Location: { - /** - * @description 地块ID - * @example 1 - */ - field_id?: number; - /** - * @description 地块名称 - * @example 北区A地块 - */ - field_name?: string; - coordinates?: components["schemas"]["Coordinates"]; - }; - Coordinates: { - /** - * Format: double - * @description 纬度 - * @example 39.9042 - */ - latitude?: number; - /** - * Format: double - * @description 经度 - * @example 116.4074 - */ - longitude?: number; - }; - CreateMachineryRequest: { - /** @description 农机名称 */ - name: string; - /** - * @description 农机类型 - * @enum {string} - */ - type: "tractor" | "harvester" | "planter" | "sprayer" | "irrigation"; - /** @description 型号 */ - model: string; - /** @description 序列号 */ - serial_number?: string; - /** - * @description 操作员ID - * @example 1 - */ - operator_id?: number; - /** - * Format: date - * @description 购买日期 - */ - purchase_date?: string; - }; - UpdateMachineryRequest: { - /** @description 农机名称 */ - name?: string; - /** - * @description 农机状态 - * @enum {string} - */ - status?: "running" | "idle" | "maintenance" | "error" | "offline"; - /** @description 操作员ID */ - operator_id?: number; - /** - * Format: date - * @description 上次维护日期 - */ - last_maintenance?: string; - /** - * Format: date - * @description 下次维护日期 - */ - next_maintenance?: string; - }; - Error: { - /** - * @description 错误代码 - * @example 404 - */ - code?: number; - /** - * @description 错误信息 - * @example 资源不存在 - */ - message?: string; - /** - * @description 错误详情 - * @example { - * "field": "id", - * "reason": "用户ID不存在" - * } - */ - details?: Record; - }; - }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export type operations = Record; diff --git a/docs/shadcn UI 配色系统完整指南.md b/docs/shadcn UI 配色系统完整指南.md new file mode 100644 index 0000000..409336d --- /dev/null +++ b/docs/shadcn UI 配色系统完整指南.md @@ -0,0 +1,242 @@ +# shadcn UI 配色系统完整指南 + +## 概述 + +shadcn UI 使用基于 CSS 变量的分层配色系统,通过语义化的变量名来管理整个应用的色彩方案。系统分为亮色主题(`:root`)和暗色主题(`.dark`)两套配色。 + +## 🎨 颜色层级分类 + +### 1. 核心系统颜色 (Core System Colors) + +#### 主要交互色 (Primary Colors) +- **`--primary`**: 应用主题色,用于最重要的交互元素 + - 亮色: `hsl(349.5238 100% 87.6471%)` - 粉红色 + - 暗色: `hsl(330 100.0000% 80%)` - 粉红色 +- **`--primary-foreground`**: 主要色上的文字/图标颜色 + - 亮色: `hsl(0 0% 0%)` - 黑色 + - 暗色: `hsl(0 0% 0%)` - 黑色 + +**用途**: 按钮、链接、选中状态、重要高亮等主要交互元素 + +#### 次要交互色 (Secondary Colors) +- **`--secondary`**: 次要按钮和元素背景色 + - 亮色: `hsl(197.4000 71.4286% 72.5490%)` - 蓝色 + - 暗色: `hsl(120 60.0000% 50%)` - 绿色 +- **`--secondary-foreground`**: 次要色上的文字/图标颜色 + - 亮色: `hsl(0 0% 0%)` - 黑色 + - 暗色: `hsl(0 0% 0%)` - 黑色 + +**用途**: 次要按钮、标签、分页器等辅助交互元素 + +#### 强调色 (Accent Colors) +- **`--accent`**: 悬停和强调状态 + - 亮色: `hsl(60 100% 50%)` - 黄色 + - 暗色: `hsl(197.4000 71.4286% 72.5490%)` - 蓝色 +- **`--accent-foreground`**: 强调色上的文字/图标颜色 + - 亮色: `hsl(0 0% 0%)` - 黑色 + - 暗色: `hsl(0 0% 0%)` - 黑色 + +**用途**: 悬停状态、选中背景、高亮区域 + +### 2. 背景和前景色 (Background & Foreground) + +#### 基础背景色 +- **`--background`**: 应用主背景色 + - 亮色: `hsl(200 23.0769% 97.4510%)` - 浅蓝灰 + - 暗色: `hsl(220.0000 14.7541% 11.9608%)` - 深蓝灰 +- **`--foreground`**: 主要文字颜色 + - 亮色: `hsl(0 0% 20%)` - 深灰 + - 暗色: `hsl(0 0% 89.8039%)` - 浅灰 + +#### 卡片背景色 +- **`--card`**: 卡片和弹出层背景 + - 亮色: `hsl(0 0% 100%)` - 纯白 + - 暗色: `hsl(197.1429 6.9307% 19.8039%)` - 深蓝灰 +- **`--card-foreground`**: 卡片上的文字颜色 + - 亮色: `hsl(0 0% 20%)` - 深灰 + - 暗色: `hsl(0 0% 89.8039%)` - 浅灰 + +#### 弹出层背景色 +- **`--popover`**: 弹出层、下拉菜单背景 + - 亮色: `hsl(0 0% 100%)` - 纯白 + - 暗色: `hsl(197.1429 6.9307% 19.8039%)` - 深蓝灰 +- **`--popover-foreground`**: 弹出层文字颜色 + - 亮色: `hsl(0 0% 20%)` - 深灰 + - 暗色: `hsl(0 0% 89.8039%)` - 浅灰 + +### 3. 弱化和辅助色 (Muted & Support) + +#### 弱化元素 +- **`--muted`**: 弱化背景色 + - 亮色: `hsl(50.4000 26.8817% 81.7647%)` - 浅黄灰 + - 暗色: `hsl(0 0% 26.6667%)` - 深灰 +- **`--muted-foreground`**: 弱化文字颜色 + - 亮色: `hsl(0 0% 43.1373%)` - 中灰 + - 暗色: `hsl(0 0% 63.9216%)` - 中浅灰 + +**用途**: 禁用状态、占位符、不重要的信息 + +### 4. 边框和输入色 (Border & Input) + +- **`--border`**: 边框颜色 + - 亮色: `hsl(0 0% 83.1373%)` - 浅灰 + - 暗色: `hsl(0 0% 26.6667%)` - 深灰 +- **`--input`**: 输入框边框色 + - 亮色: `hsl(0 0% 83.1373%)` - 浅灰 + - 暗色: `hsl(0 0% 26.6667%)` - 深灰 + +### 5. 危险和警告色 (Destructive) + +- **`--destructive`**: 危险操作色(删除、警告等) + - 亮色: `hsl(0 84.2365% 60.1961%)` - 红色 + - 暗色: `hsl(0 84.2365% 60.1961%)` - 红色 +- **`--destructive-foreground`**: 危险色上的文字颜色 + - 亮色: `hsl(0 0% 100%)` - 白色 + - 暗色: `hsl(0 0% 100%)` - 白色 + +**用途**: 删除按钮、错误提示、警告信息 + +### 6. 焦点和环色 (Ring) + +- **`--ring`**: 焦点环颜色 + - 亮色: `hsl(349.5238 100% 87.6471%)` - 粉红色 + - 暗色: `hsl(330 100.0000% 80%)` - 粉红色 + +**用途**: 键盘焦点环、表单验证高亮 + +### 7. 图表色 (Chart Colors) + +- **`--chart-1`**: 图表主色 (粉色系) +- **`--chart-2`**: 图表次要色 (蓝色/绿色系) +- **`--chart-3`**: 图表第三色 (黄色/蓝色系) +- **`--chart-4`**: 图表第四色 (黄色系) +- **`--chart-5`**: 图表第五色 (黄绿色系) + +### 8. 侧边栏色 (Sidebar Colors) + +- **`--sidebar`**: 侧边栏背景色 +- **`--sidebar-foreground`**: 侧边栏文字色 +- **`--sidebar-primary`**: 侧边栏主要交互色 +- **`--sidebar-primary-foreground`**: 侧边栏主要文字色 +- **`--sidebar-accent`**: 侧边栏强调色 +- **`--sidebar-accent-foreground`**: 侧边栏强调文字色 +- **`--sidebar-border`**: 侧边栏边框色 +- **`--sidebar-ring`**: 侧边栏焦点环色 + +## 🏗️ 使用优先级和层级 + +### 1. 主要色 (Primary) - 最高优先级 +- 主按钮 +- 重要链接 +- 选中状态 +- 进度指示器 +- 主要数据展示 + +### 2. 次要色 (Secondary) - 中等优先级 +- 次要按钮 +- 标签和徽章 +- 分页组件 +- 辅助交互元素 + +### 3. 强调色 (Accent) - 低优先级 +- 悬停状态 +- 选中背景 +- 高亮区域 +- 过渡效果 + +### 4. 弱化色 (Muted) - 最低优先级 +- 禁用状态 +- 占位符文本 +- 辅助信息 +- 背景装饰 + +## 🎯 实际应用示例 + +### 按钮组件 +```jsx +// 主要按钮 + + +// 次要按钮 + + +// 危险按钮 + +``` + +### 卡片组件 +```jsx +
+

卡片标题

+

辅助信息

+
+``` + +### 输入框组件 +```jsx + +``` + +## 🌓 主题切换原理 + +系统通过 CSS 变量实现主题切换: + +```css +/* 亮色主题 */ +:root { + --background: hsl(200 23.0769% 97.4510%); + --foreground: hsl(0 0% 20%); + /* ... */ +} + +/* 暗色主题 */ +.dark { + --background: hsl(220.0000 14.7541% 11.9608%); + --foreground: hsl(0 0% 89.8039%); + /* ... */ +} +``` + +当 HTML 元素添加 `.dark` 类时,所有使用这些变量的组件都会自动切换颜色。 + +## 🎨 定制建议 + +### 1. 保持语义化 +- 保持变量名的语义含义,不要为了视觉效果而改变变量用途 +- `primary` 始终用于主要交互,`destructive` 始终用于危险操作 + +### 2. 确保对比度 +- 确保 `*-foreground` 颜色与对应的背景色有足够的对比度 +- 在两个主题下都要测试可读性 + +### 3. 渐进增强 +- 先定义好核心颜色,再扩展其他颜色 +- 使用 HSL 色彩空间便于调整饱和度和亮度 + +### 4. 一致性原则 +- 保持相同类型的组件使用相同的颜色变量 +- 避免在组件中硬编码颜色值 + +## 🔧 维护工具 + +### 检查颜色使用 +```bash +# 查找所有使用 primary 变量的地方 +grep -r "bg-primary\|text-primary\|border-primary" src/ +``` + +### 主题切换测试 +1. 在浏览器开发者工具中切换 HTML 元素的 class +2. 检查所有组件的颜色是否正确切换 +3. 验证对比度和可读性 + +--- + +*本文档基于 shadcn UI 标准配色系统编写,适用于 React + Tailwind CSS 项目。* \ No newline at end of file