commit edd1a021573be7384f775dfd0e1c221e845fb7f6 Author: wushumin Date: Mon May 11 15:28:27 2026 +0800 first diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f1417ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +.DS_Store + +# dependencies +node_modules/ +vendor/ + +# runtime +runtime/ +dist/ +unpackage/ + +# env +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE +.idea/ +.vscode/ + +# logs +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..5897f61 --- /dev/null +++ b/README.md @@ -0,0 +1,73 @@ +# 安心验鉴定平台 + +安心验是一个面向奢侈品 / 潮流品鉴定履约场景的平台项目,当前仓库包含: + +- 用户端 H5 / 小程序共用前端 +- 管理后台 +- `webman` 后端 API +- 部署、流程、数据库与产品文档 + +## 目录说明 + +- `server-api` + 后端服务,负责订单、仓库、履约、报告、消息、工单等业务接口 +- `user-app` + uni-app 用户端,H5 与小程序共用代码 +- `admin-web` + Vue 3 管理后台 +- `docs` + 产品、流程、接口、部署与交付文档 + +## 技术栈 + +- 后端:PHP 8.1+ / webman / MySQL / Redis +- 用户端:uni-app / Vue 3 / TypeScript / Pinia +- 后台:Vue 3 / Vite / TypeScript / Element Plus + +## 当前已完成主链路 + +- 用户下单与资料上传 +- 用户提交送检运单 +- 后台标记鉴定中心签收 +- 鉴定补料与单次鉴定 +- 报告出具与验真 +- 用户确认寄回地址 +- 后台登记回寄运单 +- 后台标记用户签收 +- 用户消息中心通知联动 + +## 本地常用命令 + +### 后端 + +```bash +cd server-api +php start.php start -d +php start.php reload -d +php tools/smoke_check.php +php tools/release_audit.php +``` + +### 用户端 + +```bash +cd user-app +npm run dev:h5 +npm run type-check +npm run build:h5 +``` + +### 管理后台 + +```bash +cd admin-web +npm run build +``` + +## 推荐先看文档 + +- [履约状态机](/Users/wushumin/www/biyou/anxinyan/docs/flow/state-machine.md) +- [上线检查清单](/Users/wushumin/www/biyou/anxinyan/docs/deploy/release-checklist.md) +- [履约冒烟检查表](/Users/wushumin/www/biyou/anxinyan/docs/deploy/fulfillment-smoke-checklist.md) +- [当前交付说明](/Users/wushumin/www/biyou/anxinyan/docs/deploy/delivery-notes.md) +- [部署说明](/Users/wushumin/www/biyou/anxinyan/docs/deploy/deploy-plan.md) diff --git a/admin-web/.env.development b/admin-web/.env.development new file mode 100644 index 0000000..c7896a5 --- /dev/null +++ b/admin-web/.env.development @@ -0,0 +1,3 @@ +VITE_API_BASE_URL=http://127.0.0.1:8787 +VITE_APP_ENV=development +VITE_APP_TITLE=安心验管理后台 diff --git a/admin-web/.env.example b/admin-web/.env.example new file mode 100644 index 0000000..c7896a5 --- /dev/null +++ b/admin-web/.env.example @@ -0,0 +1,3 @@ +VITE_API_BASE_URL=http://127.0.0.1:8787 +VITE_APP_ENV=development +VITE_APP_TITLE=安心验管理后台 diff --git a/admin-web/.env.production b/admin-web/.env.production new file mode 100644 index 0000000..0ac6744 --- /dev/null +++ b/admin-web/.env.production @@ -0,0 +1,3 @@ +VITE_API_BASE_URL=https://api.anxinjianyan.com +VITE_APP_ENV=production +VITE_APP_TITLE=安心验管理后台 diff --git a/admin-web/.env.test b/admin-web/.env.test new file mode 100644 index 0000000..da57c2c --- /dev/null +++ b/admin-web/.env.test @@ -0,0 +1,3 @@ +VITE_API_BASE_URL=https://test-api.example.com +VITE_APP_ENV=test +VITE_APP_TITLE=安心验管理后台 diff --git a/admin-web/.gitignore b/admin-web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/admin-web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/admin-web/README.md b/admin-web/README.md new file mode 100644 index 0000000..33895ab --- /dev/null +++ b/admin-web/README.md @@ -0,0 +1,5 @@ +# Vue 3 + TypeScript + Vite + +This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` + + diff --git a/admin-web/package-lock.json b/admin-web/package-lock.json new file mode 100644 index 0000000..d29d20d --- /dev/null +++ b/admin-web/package-lock.json @@ -0,0 +1,2625 @@ +{ + "name": "admin-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "admin-web", + "version": "0.0.0", + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "@types/qrcode": "^1.5.6", + "axios": "^1.15.0", + "echarts": "^6.0.0", + "element-plus": "^2.13.7", + "pinia": "^3.0.4", + "qrcode": "^1.5.4", + "vue": "^3.5.32", + "vue-router": "^5.0.4" + }, + "devDependencies": { + "@types/node": "^24.12.2", + "@vitejs/plugin-vue": "^6.0.5", + "@vue/tsconfig": "^0.9.1", + "typescript": "~6.0.2", + "vite": "^8.0.4", + "vue-tsc": "^3.2.6" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-4.2.0.tgz", + "integrity": "sha512-kzyuwOAQnXJNLS9PSyrk0CWk35nWJW/zl/6KvnTBMFK65gm7U1/Z5BqjxeapjZCIhQcM/DsrEmcbRwDyXyXK4A==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/@element-plus/icons-vue": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz", + "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==", + "license": "MIT", + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmmirror.com/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmmirror.com/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmmirror.com/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@popperjs/core": { + "name": "@sxzz/popperjs-es", + "version": "2.11.8", + "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.8.tgz", + "integrity": "sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.13", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.13.tgz", + "integrity": "sha512-3ngTAv6F/Py35BsYbeeLeecvhMKdsKm4AoOETVhAA+Qc8nrA2I0kF7oa93mE9qnIurngOSpMnQ0x2nQY2FPviA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmmirror.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, + "node_modules/@types/node": { + "version": "24.12.2", + "resolved": "https://registry.npmmirror.com/@types/node/-/node-24.12.2.tgz", + "integrity": "sha512-A1sre26ke7HDIuY/M23nd9gfB+nrmhtYyMINbjI1zHJxYteKR6qSMX56FsmjMcDb3SMcjJg5BiRRgOCC/yBD0g==", + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmmirror.com/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/web-bluetooth": { + "version": "0.0.20", + "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz", + "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.6", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-6.0.6.tgz", + "integrity": "sha512-u9HHgfrq3AjXlysn0eINFnWQOJQLO9WN6VprZ8FXl7A2bYisv3Hui9Ij+7QZ41F/WYWarHjwBbXtD7dKg3uxbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.13" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue-macros/common": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@vue-macros/common/-/common-3.1.2.tgz", + "integrity": "sha512-h9t4ArDdniO9ekYHAD95t9AZcAbb19lEGK+26iAjUODOIJKmObDNBSe4+6ELQAA3vtYiFPPBtHh7+cQCKi3Dng==", + "license": "MIT", + "dependencies": { + "@vue/compiler-sfc": "^3.5.22", + "ast-kit": "^2.1.2", + "local-pkg": "^1.1.2", + "magic-string-ast": "^1.0.2", + "unplugin-utils": "^0.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/vue-macros" + }, + "peerDependencies": { + "vue": "^2.7.0 || ^3.2.25" + }, + "peerDependenciesMeta": { + "vue": { + "optional": true + } + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/language-core": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.2.7.tgz", + "integrity": "sha512-Gn4q/tRxbpVGLEuARQ43p3YELlNAFgRUVCgW9U5Cr+5q4vfD2bWDWpl3ABbJMXUt5xlE1dF8dkigg2aUq7JYYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.1.2", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.4" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.32.tgz", + "integrity": "sha512-1CDVv7tv/IV13V8Nip1k/aaObVbWqRlVCVezTwx3K07p7Vxossp5JU1dcPNhJk3w347gonIUT9jQOGutyJrSVQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/runtime-core": "3.5.32", + "@vue/shared": "3.5.32", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.32.tgz", + "integrity": "sha512-IOjm2+JQwRFS7W28HNuJeXQle9KdZbODFY7hFGVtnnghF51ta20EWAZJHX+zLGtsHhaU6uC9BGPV52KVpYryMQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "vue": "3.5.32" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.9.1", + "resolved": "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.9.1.tgz", + "integrity": "sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": ">= 5.8", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/@vueuse/core": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-12.0.0.tgz", + "integrity": "sha512-C12RukhXiJCbx4MGhjmd/gH52TjJsc3G0E0kQj/kb19H3Nt6n1CA4DRWuTdWWcaFRdlTe0npWDS942mvacvNBw==", + "license": "MIT", + "dependencies": { + "@types/web-bluetooth": "^0.0.20", + "@vueuse/metadata": "12.0.0", + "@vueuse/shared": "12.0.0", + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/metadata": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-12.0.0.tgz", + "integrity": "sha512-Yzimd1D3sjxTDOlF05HekU5aSGdKjxhuhRFHA7gDWLn57PRbBIh+SF5NmjhJ0WRgF3my7T8LBucyxdFJjIfRJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/@vueuse/shared": { + "version": "12.0.0", + "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-12.0.0.tgz", + "integrity": "sha512-3i6qtcq2PIio5i/vVYidkkcgvmTjCqrf26u+Fd4LhnbBmIT6FN8y6q/GJERp8lfcB9zVEfjdV0Br0443qZuJpw==", + "license": "MIT", + "dependencies": { + "vue": "^3.5.13" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ast-kit": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/ast-kit/-/ast-kit-2.2.0.tgz", + "integrity": "sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.5", + "pathe": "^2.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/ast-walker-scope": { + "version": "0.8.3", + "resolved": "https://registry.npmmirror.com/ast-walker-scope/-/ast-walker-scope-0.8.3.tgz", + "integrity": "sha512-cbdCP0PGOBq0ASG+sjnKIoYkWMKhhz+F/h9pRexUdX2Hd38+WOlBkRKlqkGOSm0YQpcFMQBJeK4WspUAkwsEdg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.28.4", + "ast-kit": "^2.1.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/async-validator": { + "version": "4.2.5", + "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz", + "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==", + "license": "MIT" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.15.0", + "resolved": "https://registry.npmmirror.com/axios/-/axios-1.15.0.tgz", + "integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmmirror.com/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmmirror.com/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "license": "MIT" + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/echarts": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/echarts/-/echarts-6.0.0.tgz", + "integrity": "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.0.0" + } + }, + "node_modules/echarts/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/element-plus": { + "version": "2.13.7", + "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.7.tgz", + "integrity": "sha512-XdHATFZOyzVFL1DaHQ90IOJQSg9UnSAV+bhDW+YB5UoZ0Hxs50mwqjqfwXkuwpSag+VXXizVcErBR6Movo5daw==", + "license": "MIT", + "dependencies": { + "@ctrl/tinycolor": "^4.2.0", + "@element-plus/icons-vue": "^2.3.2", + "@floating-ui/dom": "^1.0.1", + "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7", + "@types/lodash": "^4.17.20", + "@types/lodash-es": "^4.17.12", + "@vueuse/core": "12.0.0", + "async-validator": "^4.2.5", + "dayjs": "^1.11.19", + "lodash": "^4.17.23", + "lodash-es": "^4.17.23", + "lodash-unified": "^1.0.3", + "memoize-one": "^6.0.0", + "normalize-wheel-es": "^1.2.0", + "vue-component-type-helpers": "^3.2.4" + }, + "peerDependencies": { + "vue": "^3.3.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmmirror.com/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmmirror.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmmirror.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/lodash-unified": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz", + "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==", + "license": "MIT", + "peerDependencies": { + "@types/lodash-es": "*", + "lodash": "*", + "lodash-es": "*" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magic-string-ast": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/magic-string-ast/-/magic-string-ast-1.0.3.tgz", + "integrity": "sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==", + "license": "MIT", + "dependencies": { + "magic-string": "^0.30.19" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==", + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/mlly/node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/mlly/node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/normalize-wheel-es": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz", + "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==", + "license": "BSD-3-Clause" + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmmirror.com/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "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==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmmirror.com/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmmirror.com/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmmirror.com/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "license": "MIT" + }, + "node_modules/unplugin": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-3.0.0.tgz", + "integrity": "sha512-0Mqk3AT2TZCXWKdcoaufeXNukv2mTrEZExeXlHIOZXdqYoHHr4n51pymnwV8x2BOVxwXbK2HLlI7usrqMpycdg==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/unplugin-utils": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/unplugin-utils/-/unplugin-utils-0.3.1.tgz", + "integrity": "sha512-5lWVjgi6vuHhJ526bI4nlCOmkCIF3nnfXkCMDeMJrtdvxTs6ZFCM8oNufGTsDbKv/tJ/xj8RpvXjRuPBZJuJog==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmmirror.com/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.32.tgz", + "integrity": "sha512-vM4z4Q9tTafVfMAK7IVzmxg34rSzTFMyIe0UUEijUCkn9+23lj0WRfA83dg7eQZIUlgOSGrkViIaCfqSAUXsMw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-sfc": "3.5.32", + "@vue/runtime-dom": "3.5.32", + "@vue/server-renderer": "3.5.32", + "@vue/shared": "3.5.32" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/vue-component-type-helpers/-/vue-component-type-helpers-3.2.7.tgz", + "integrity": "sha512-+gPp5YGmhfsj1IN+xUo7y0fb4clfnOiiUA39y07yW1VzCRjzVgwLbtmdWlghh7mXrPsEaYc7rrIir/HT6C8vYQ==", + "license": "MIT" + }, + "node_modules/vue-router": { + "version": "5.0.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-5.0.4.tgz", + "integrity": "sha512-lCqDLCI2+fKVRl2OzXuzdSWmxXFLQRxQbmHugnRpTMyYiT+hNaycV0faqG5FBHDXoYrZ6MQcX87BvbY8mQ20Bg==", + "license": "MIT", + "dependencies": { + "@babel/generator": "^7.28.6", + "@vue-macros/common": "^3.1.1", + "@vue/devtools-api": "^8.0.6", + "ast-walker-scope": "^0.8.3", + "chokidar": "^5.0.0", + "json5": "^2.2.3", + "local-pkg": "^1.1.2", + "magic-string": "^0.30.21", + "mlly": "^1.8.0", + "muggle-string": "^0.4.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "scule": "^1.3.0", + "tinyglobby": "^0.2.15", + "unplugin": "^3.0.0", + "unplugin-utils": "^0.3.1", + "yaml": "^2.8.2" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@pinia/colada": ">=0.21.2", + "@vue/compiler-sfc": "^3.5.17", + "pinia": "^3.0.4", + "vue": "^3.5.0" + }, + "peerDependenciesMeta": { + "@pinia/colada": { + "optional": true + }, + "@vue/compiler-sfc": { + "optional": true + }, + "pinia": { + "optional": true + } + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-api": { + "version": "8.1.1", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-8.1.1.tgz", + "integrity": "sha512-bsDMJ07b3GN1puVwJb/fyFnj/U2imyswK5UQVLZwVl7O05jDrt6BHxeG5XffmOOdasOj/bOmIjxJvGPxU7pcqw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^8.1.1" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-kit": { + "version": "8.1.1", + "resolved": "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-8.1.1.tgz", + "integrity": "sha512-gVBaBv++i+adg4JpH71k9ppl4soyR7Y2McEqO5YNgv0BI1kMZ7BDX5gnwkZ5COYgiCyhejZG+yGNrBAjj6Coqg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^8.1.1", + "birpc": "^2.6.1", + "hookable": "^5.5.3", + "perfect-debounce": "^2.0.0" + } + }, + "node_modules/vue-router/node_modules/@vue/devtools-shared": { + "version": "8.1.1", + "resolved": "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-8.1.1.tgz", + "integrity": "sha512-+h4ttmJYl/txpxHKaoZcaKpC+pvckgLzIDiSQlaQ7kKthKh8KuwoLW2D8hPJEnqKzXOvu15UHEoGyngAXCz0EQ==", + "license": "MIT" + }, + "node_modules/vue-router/node_modules/perfect-debounce": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-2.1.0.tgz", + "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "license": "MIT" + }, + "node_modules/vue-tsc": { + "version": "3.2.7", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.2.7.tgz", + "integrity": "sha512-zc1tL3HoQni1zGTGrwBVRQb7rGP5SWdu/m4rGB6JcnAC5MT5LFZIxF7Y+EJEnt4hGF23d60rXH7gRjHGb5KQQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.2.7" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmmirror.com/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmmirror.com/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/zrender": { + "version": "6.0.0", + "resolved": "https://registry.npmmirror.com/zrender/-/zrender-6.0.0.tgz", + "integrity": "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + }, + "node_modules/zrender/node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + } + } +} diff --git a/admin-web/package.json b/admin-web/package.json new file mode 100644 index 0000000..2361f07 --- /dev/null +++ b/admin-web/package.json @@ -0,0 +1,30 @@ +{ + "name": "admin-web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@element-plus/icons-vue": "^2.3.2", + "@types/qrcode": "^1.5.6", + "axios": "^1.15.0", + "echarts": "^6.0.0", + "element-plus": "^2.13.7", + "pinia": "^3.0.4", + "qrcode": "^1.5.4", + "vue": "^3.5.32", + "vue-router": "^5.0.4" + }, + "devDependencies": { + "@types/node": "^24.12.2", + "@vitejs/plugin-vue": "^6.0.5", + "@vue/tsconfig": "^0.9.1", + "typescript": "~6.0.2", + "vite": "^8.0.4", + "vue-tsc": "^3.2.6" + } +} diff --git a/admin-web/public/favicon.svg b/admin-web/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/admin-web/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-web/public/icons.svg b/admin-web/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/admin-web/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/admin-web/src/App.vue b/admin-web/src/App.vue new file mode 100644 index 0000000..98240ae --- /dev/null +++ b/admin-web/src/App.vue @@ -0,0 +1,3 @@ + diff --git a/admin-web/src/api/admin.ts b/admin-web/src/api/admin.ts new file mode 100644 index 0000000..fe1a61c --- /dev/null +++ b/admin-web/src/api/admin.ts @@ -0,0 +1,2072 @@ +import request from "./request"; +import type { AdminSessionInfo } from "../utils/auth"; + +export interface DashboardCard { + title: string; + value: number; + desc: string; +} + +export interface AdminLoginResponse { + token: string; + admin_info: AdminSessionInfo; +} + +export interface AdminOrderListItem { + id: number; + order_no: string; + appraisal_no: string; + product_name: string; + category_name: string; + brand_name: string; + service_provider: string; + service_provider_text: string; + source_channel: string; + source_channel_text: string; + source_customer_id: string; + order_status: string; + display_status: string; + estimated_finish_time: string; + pay_amount: number; + created_at: string; +} + +export interface AdminOrderDetail { + order_info: { + id: number; + order_no: string; + appraisal_no: string; + service_provider: string; + service_provider_text: string; + source_channel: string; + source_channel_text: string; + source_customer_id: string; + order_status: string; + display_status: string; + pay_amount: number; + estimated_finish_time: string; + created_at: string; + can_reassign_warehouse: boolean; + can_mark_received: boolean; + can_submit_return_logistics: boolean; + return_logistics_block_reason: string; + can_mark_return_received: boolean; + }; + product_info: { + product_name: string; + category_id: number; + category_name: string; + brand_id: number; + brand_name: string; + color: string; + size_spec: string; + serial_no: string; + }; + extra_info: { + purchase_channel: string; + purchase_price: number; + usage_status: string; + condition_desc: string; + remark: string; + }; + shipping_target: null | { + warehouse_id?: number; + warehouse_name: string; + warehouse_code: string; + receiver_name: string; + receiver_mobile: string; + full_address: string; + service_time: string; + notice: string; + }; + return_address: null | { + user_address_id: number; + consignee: string; + mobile: string; + full_address: string; + }; + timeline: Array<{ + node_text: string; + node_desc: string; + occurred_at: string; + }>; + logistics_info: null | { + express_company: string; + tracking_no: string; + tracking_status: string; + tracking_status_text: string; + latest_desc: string; + latest_time: string; + nodes: Array<{ + node_time: string; + node_desc: string; + node_location: string; + }>; + }; + return_logistics: null | { + express_company: string; + tracking_no: string; + tracking_status: string; + tracking_status_text: string; + latest_desc: string; + latest_time: string; + nodes: Array<{ + node_time: string; + node_desc: string; + node_location: string; + }>; + }; + supplement_task: null | { + reason: string; + deadline: string; + status: string; + items: Array<{ + item_name: string; + guide_text: string; + }>; + }; + report_summary: null | { + report_no: string; + report_title: string; + report_status: string; + publish_time: string; + }; +} + +export interface AdminOrderWarehouseOption { + id: number; + warehouse_name: string; + warehouse_code: string; + service_provider: string; + service_provider_text: string; + full_address: string; + receiver_name: string; + receiver_mobile: string; + service_time: string; + is_default: boolean; + supported_category_names: string[]; +} + +export interface CatalogOverviewCard { + title: string; + value: number; + desc: string; +} + +export interface AdminCategoryItem { + id: number; + name: string; + code: string; + image_url: string; + sort_order: number; + is_enabled: boolean; + need_shipping: boolean; + supported_service_types: string[]; + upload_template_count: number; + upload_template_item_count: number; + upload_template_summary: string; + appraisal_template_count: number; + appraisal_template_point_count: number; + appraisal_template_summary: string; +} + +export interface AdminCategoryPayload { + id?: number; + name: string; + code: string; + image_url: string; + sort_order: number; + is_enabled: boolean; + need_shipping: boolean; + supported_service_types: string[]; +} + +export interface AdminUploadTemplateItem { + id?: number | null; + item_code: string; + item_name: string; + is_required: boolean; + guide_text: string; + sample_image_url: string; + max_upload_count: number; + sort_order: number; + is_enabled: boolean; +} + +export interface AdminCatalogTemplateSampleImageAsset { + file_id: string; + file_url: string; + thumbnail_url: string; + name?: string; +} + +export interface AdminCategoryUploadTemplate { + id: number | null; + category_id: number; + category_name: string; + service_provider: string; + service_provider_text: string; + name: string; + code: string; + is_enabled: boolean; + is_default: boolean; + items: AdminUploadTemplateItem[]; +} + +export interface AdminAppraisalTemplateKeyPoint { + id?: number | null; + point_code: string; + point_name: string; + point_type: "text" | "textarea" | "select" | "boolean"; + options: string[]; + sort_order: number; + is_required: boolean; +} + +export interface AdminCategoryAppraisalTemplate { + id: number | null; + category_id: number; + category_name: string; + service_provider: string; + service_provider_text: string; + name: string; + code: string; + is_enabled: boolean; + is_default: boolean; + result_options: string[]; + condition_options: string[]; + valuation_hint: string; + key_points: AdminAppraisalTemplateKeyPoint[]; +} + +export interface AdminBrandItem { + id: number; + name: string; + en_name: string; + code: string; + sort_order: number; + is_enabled: boolean; + category_names: string; + category_ids?: number[]; + supported_service_types: string[]; +} + +export interface AdminBrandPayload { + id?: number; + name: string; + en_name: string; + code: string; + sort_order: number; + is_enabled: boolean; + category_ids: number[]; + supported_service_types: string[]; +} + +export interface AdminReportListItem { + id: number; + order_id: number; + order_no: string; + appraisal_no: string; + report_no: string; + report_type: string; + report_type_text: string; + report_title: string; + report_status: string; + report_status_text: string; + service_provider: string; + service_provider_text: string; + institution_name: string; + publish_time: string; + product_name: string; + category_name: string; + brand_name: string; +} + +export interface AdminReportDetail { + report_header: { + id: number; + order_id: number; + report_no: string; + report_type: string; + report_type_text: string; + report_title: string; + report_status: string; + report_status_text: string; + service_provider: string; + service_provider_text: string; + institution_name: string; + publish_time: string; + }; + product_info: Record; + result_info: Record; + appraisal_info: Record; + valuation_info: Record; + evidence_attachments: Array<{ + file_id: string; + file_url: string; + thumbnail_url: string; + name?: string; + file_type?: string; + mime_type?: string; + }>; + risk_notice_text: string; + verify_info: { + verify_status: string; + verify_url: string; + verify_qrcode_url: string; + report_page_url: string; + verify_count: number; + }; +} + +export interface AdminPublishReportResponse { + id: number; + report_status: string; + publish_time: string; + verify_url: string; + report_page_url: string; +} + +export interface AdminManualInspectionPayload { + id?: number; + report_header: { + report_no: string; + report_title: string; + report_status: string; + service_provider: string; + institution_name: string; + publish_time: string; + }; + product_info: { + product_name: string; + category_name: string; + brand_name: string; + color: string; + size_spec: string; + serial_no: string; + }; + result_info: { + result_status: string; + result_text: string; + result_desc: string; + }; + appraisal_info: { + appraiser_name: string; + reviewer_name: string; + appraisal_time: string; + }; + valuation_info: { + condition_grade: string; + condition_desc: string; + valuation_min: number | string; + valuation_max: number | string; + valuation_desc: string; + }; + risk_notice_text: string; +} + +export interface AdminAppraisalTaskListItem { + id: number; + order_id: number; + order_no: string; + appraisal_no: string; + external_order_no: string; + service_provider: string; + service_provider_text: string; + task_stage: string; + task_stage_text: string; + status: string; + status_text: string; + assignee_id: number; + product_name: string; + category_name: string; + brand_name: string; + assignee_name: string; + result_text: string; + started_at: string; + submitted_at: string; + sla_deadline: string; + is_overtime: boolean; + display_status: string; + stage_tasks: Array<{ + id: number; + task_stage: string; + task_stage_text: string; + status: string; + status_text: string; + assignee_id: number; + assignee_name: string; + result_text: string; + submitted_at: string; + is_current: boolean; + }>; +} + +export interface AdminAppraisalTaskDetail { + task_info: { + id: number; + order_id: number; + order_no: string; + appraisal_no: string; + external_order_no: string; + service_provider: string; + service_provider_text: string; + task_stage: string; + task_stage_text: string; + status: string; + status_text: string; + assignee_id: number; + assignee_name: string; + started_at: string; + submitted_at: string; + sla_deadline: string; + is_overtime: boolean; + }; + report_summary: null | { + id: number; + report_no: string; + report_status: string; + report_status_text: string; + }; + product_info: { + product_name: string; + category_id: number; + category_name: string; + brand_id: number; + brand_name: string; + color: string; + size_spec: string; + serial_no: string; + }; + extra_info: { + purchase_channel: string; + purchase_price: number; + usage_status: string; + condition_desc: string; + remark: string; + }; + result_info: { + result_text: string; + result_desc: string; + condition_grade: string; + condition_desc?: string; + valuation_min: number; + valuation_max: number; + valuation_desc?: string; + attachments: Array<{ + file_id: string; + file_url: string; + thumbnail_url: string; + name?: string; + file_type?: string; + mime_type?: string; + }>; + external_remark: string; + internal_remark: string; + key_points: Array<{ + point_code: string; + point_name: string; + point_value: string; + point_remark: string; + }>; + }; + prefill_result_info: null | { + result_text: string; + result_desc: string; + condition_grade: string; + condition_desc?: string; + valuation_min: number; + valuation_max: number; + valuation_desc?: string; + attachments: Array<{ + file_id: string; + file_url: string; + thumbnail_url: string; + name?: string; + file_type?: string; + mime_type?: string; + }>; + external_remark: string; + internal_remark: string; + key_points: Array<{ + point_code: string; + point_name: string; + point_value: string; + point_remark: string; + }>; + source_task_id: number; + source_stage: string; + source_stage_text: string; + }; + appraisal_template: null | { + id: number; + name: string; + code: string; + service_provider: string; + service_provider_text: string; + result_options: string[]; + condition_options: string[]; + valuation_hint: string; + key_points: Array<{ + point_code: string; + point_name: string; + point_type: "text" | "textarea" | "select" | "boolean"; + options: string[]; + sort_order: number; + is_required: boolean; + point_value: string; + point_remark: string; + }>; + }; + timeline: Array<{ + node_text: string; + node_desc: string; + occurred_at: string; + }>; + stage_tasks: Array<{ + id: number; + task_stage: string; + task_stage_text: string; + status: string; + status_text: string; + assignee_name: string; + result_text: string; + submitted_at: string; + is_current: boolean; + }>; + materials: Array<{ + item_name: string; + status: string; + source_type: string; + files: Array<{ + file_id: string; + file_url: string; + thumbnail_url: string; + }>; + }>; + supplement_task: null | { + id: number; + reason: string; + deadline: string; + status: string; + items: Array<{ + item_name: string; + guide_text: string; + is_required: boolean; + }>; + }; + material_tag: null | AdminMaterialTagCode; +} + +export interface AdminAppraisalTaskResultPayload { + id: number; + action: "save" | "submit"; + product_info?: { + category_id?: number; + product_name: string; + category_name: string; + brand_name: string; + color: string; + size_spec: string; + serial_no: string; + }; + result_text: string; + result_desc: string; + condition_grade: string; + condition_desc: string; + valuation_min: number; + valuation_max: number; + valuation_desc: string; + attachments: Array<{ + file_id: string; + file_url: string; + thumbnail_url: string; + name?: string; + file_type?: string; + mime_type?: string; + }>; + key_points: Array<{ + point_code: string; + point_name: string; + point_value: string; + point_remark: string; + }>; + external_remark: string; + internal_remark: string; +} + +export interface AdminAppraisalTaskSupplementPayload { + id: number; + reason: string; + deadline: string; + items: Array<{ + item_name: string; + guide_text: string; + is_required: boolean; + }>; +} + +export interface AdminAssignableAppraiserItem { + id: number; + name: string; + mobile: string; + role_names: string[]; + role_codes: string[]; +} + +export interface AdminMessageOverviewCard { + title: string; + value: number; + desc: string; +} + +export interface AdminMessageTemplateItem { + id: number; + template_name: string; + template_code: string; + channel: string; + channel_text: string; + event_code: string; + title: string; + content: string; + is_enabled: boolean; +} + +export interface AdminMessageTemplatePayload { + id?: number; + template_name: string; + template_code: string; + channel: string; + event_code: string; + title: string; + content: string; + is_enabled: boolean; +} + +export interface AdminMessageLogItem { + id: number; + user_id: number; + template_name: string; + biz_type: string; + biz_id: number; + channel: string; + channel_text: string; + status: string; + status_text: string; + fail_reason: string; + sent_at: string; + created_at: string; +} + +export interface AdminTicketOverviewCard { + title: string; + value: number; + desc: string; +} + +export interface AdminTicketItem { + id: number; + ticket_no: string; + ticket_type: string; + ticket_type_text: string; + biz_type: string; + biz_id: number; + order_id: number; + user_id: number; + status: string; + status_text: string; + priority: string; + priority_text: string; + title: string; + created_at: string; + updated_at: string; +} + +export interface AdminTicketDetail { + ticket_info: { + id: number; + ticket_no: string; + ticket_type: string; + ticket_type_text: string; + biz_type: string; + biz_id: number; + order_id: number; + user_id: number; + status: string; + status_text: string; + priority: string; + priority_text: string; + title: string; + content: string; + created_at: string; + updated_at: string; + }; + messages: Array<{ + sender_type: string; + sender_type_text: string; + content: string; + attachments: Array<{ + file_id: string; + file_url: string; + thumbnail_url: string; + name?: string; + }>; + created_at: string; + }>; +} + +export interface AdminTicketPayload { + id: number; + status: string; + priority: string; +} + +export interface AdminUserOverviewCard { + title: string; + value: number; + desc: string; +} + +export interface AdminUserItem { + id: number; + nickname: string; + mobile: string; + status: string; + status_text: string; + password_set: boolean; + default_address: string; + order_count: number; + message_count: number; + ticket_count: number; + created_at: string; + updated_at: string; +} + +export interface AdminUserDetail { + user_info: { + id: number; + nickname: string; + mobile: string; + status: string; + status_text: string; + password_set: boolean; + created_at: string; + updated_at: string; + }; + addresses: Array<{ + consignee: string; + mobile: string; + full_address: string; + is_default: boolean; + }>; + recent_orders: Array<{ + order_no: string; + display_status: string; + pay_amount: number; + created_at: string; + }>; + recent_messages: Array<{ + title: string; + content: string; + is_read: boolean; + created_at: string; + }>; +} + +export interface AdminUserPayload { + id?: number; + nickname: string; + mobile: string; + status: string; + password?: string; +} + +export interface EnterpriseCustomer { + id: number; + customer_code: string; + customer_name: string; + contact_name: string; + contact_mobile: string; + contact_email: string; + settlement_type: string; + settlement_type_text: string; + user_id: number; + webhook_url: string; + webhook_enabled: boolean; + status: string; + status_text: string; + remark: string; + app_count?: number; + order_count?: number; + event_count?: number; + created_at: string; + updated_at: string; +} + +export interface EnterpriseCustomerPayload { + id?: number; + customer_name: string; + contact_name: string; + contact_mobile: string; + contact_email: string; + webhook_url: string; + webhook_enabled: boolean; + status: string; + remark: string; +} + +export interface EnterpriseCustomerApp { + id: number; + customer_id: number; + app_name: string; + app_key: string; + secret_last4: string; + status: string; + status_text: string; + last_used_at: string; + created_at: string; +} + +export interface EnterpriseCustomerOrderRef { + id: number; + customer_id: number; + external_order_no: string; + order_id: number; + order_no: string; + appraisal_no: string; + product_name: string; + order_status: string; + display_status: string; + pay_amount: number; + created_at: string; +} + +export interface EnterpriseOrderEvent { + id: number; + customer_id: number; + order_id: number; + external_order_no: string; + event_code: string; + event_text: string; + status_code: string; + status_text: string; + occurred_at: string; + created_at: string; +} + +export interface EnterpriseWebhookDelivery { + id: number; + event_id: number; + customer_id: number; + webhook_url: string; + app_key: string; + attempt_no: number; + delivery_status: string; + delivery_status_text: string; + http_status: number; + response_body: string; + error_message: string; + is_manual: boolean; + sent_at: string; + created_at: string; +} + +export interface AdminWarehouseOverviewCard { + title: string; + value: number; + desc: string; +} + +export interface AdminWarehouseItem { + id: number; + warehouse_name: string; + warehouse_code: string; + warehouse_type: string; + warehouse_type_text: string; + service_provider: string; + service_provider_text: string; + receiver_name: string; + receiver_mobile: string; + province: string; + city: string; + district: string; + detail_address: string; + full_address: string; + service_time: string; + notice: string; + supported_category_ids: number[]; + supported_category_names: string[]; + service_area_provinces: string[]; + service_area_cities: string[]; + status: string; + status_text: string; + is_default: boolean; + sort_order: number; + remark: string; + created_at: string; + updated_at: string; +} + +export interface AdminWarehousePayload { + id?: number; + warehouse_name: string; + warehouse_code: string; + service_provider: string; + receiver_name: string; + receiver_mobile: string; + province: string; + city: string; + district: string; + detail_address: string; + service_time: string; + notice: string; + supported_category_ids: number[]; + service_area_provinces: string[]; + service_area_cities: string[]; + status: string; + is_default: boolean; + sort_order: number; + remark: string; +} + +export interface AdminMaterialTagCode { + id: number; + batch_id: number; + qr_token: string; + qr_url: string; + verify_code: string; + bind_status: string; + bind_status_text: string; + report_id: number; + report_no: string; + report_status: string; + scan_count: number; + verify_count: number; + bound_at: string; + bound_by_name: string; + created_at: string; +} + +export interface AdminMaterialBatchItem { + id: number; + batch_no: string; + total_count: number; + bound_count: number; + download_count: number; + remark: string; + created_by_name: string; + last_downloaded_at: string; + created_at: string; + matched_codes: AdminMaterialTagCode[]; +} + +export interface AdminMaterialBatchDetail { + batch: { + id: number; + batch_no: string; + total_count: number; + download_count: number; + remark: string; + created_by_name: string; + last_downloaded_at: string; + created_at: string; + }; + codes: AdminMaterialTagCode[]; +} + +export interface AdminAccessOverviewCard { + title: string; + value: number; + desc: string; +} + +export interface AdminManagerItem { + id: number; + name: string; + mobile: string; + email: string; + status: string; + status_text: string; + role_ids: number[]; + role_names: string[]; + last_login_at: string; + created_at: string; +} + +export interface AdminRoleItem { + id: number; + name: string; + code: string; + status: string; + status_text: string; + permission_ids: number[]; + permission_names: string[]; + admin_count: number; + created_at: string; +} + +export interface AdminPermissionItem { + id: number; + name: string; + code: string; + module: string; + action: string; + module_text: string; +} + +export interface AdminManagerPayload { + id?: number; + name: string; + mobile: string; + email: string; + password?: string; + status: string; + role_ids: number[]; +} + +export interface AdminRolePayload { + id?: number; + name: string; + code: string; + status: string; + permission_ids: number[]; +} + +export interface AdminSystemConfigGroupItem { + group_code: string; + group_name: string; + group_desc: string; + items: Array<{ + config_key: string; + title: string; + field_type: string; + placeholder: string; + remark: string; + is_secret: boolean; + value: string; + options?: Array<{ + label: string; + value: string; + }>; + visible_when?: { + config_key: string; + equals: string; + } | null; + }>; +} + +export interface AdminSystemConfigUploadResult { + config_group: string; + config_key: string; + config_value: string; + file_name: string; + original_name: string; +} + +export interface AdminPageVisualsConfig { + order_background_image_url: string; + report_background_image_url: string; +} + +export interface AdminContentHomeConfig { + banners: Array<{ + title: string; + subtitle: string; + description: string; + background_image_url: string; + }>; + page_visuals: AdminPageVisualsConfig; + service_entries: Array<{ + service_provider: string; + title: string; + tag: string; + description: string; + meta: string; + }>; + category_visuals: Array<{ + category_name: string; + category_code: string; + image_url: string; + }>; + quick_entries: Array<{ + code: string; + title: string; + desc: string; + }>; + trust_metrics: Array<{ + value: string; + label: string; + }>; + trust_points: Array<{ + title: string; + desc: string; + }>; + faqs: string[]; +} + +export type AdminContentHomePayload = Omit & { + category_visuals?: AdminContentHomeConfig["category_visuals"]; +}; + +export interface AdminContentImageAsset { + file_id: string; + file_url: string; + thumbnail_url: string; + name?: string; +} + +export interface AdminContentPolicyItem { + code: string; + title: string; + desc: string; + target_url: string; + article_id: number; +} + +export interface AdminContentPolicyConfig { + legal_entries: AdminContentPolicyItem[]; + appraisal_agreements: AdminContentPolicyItem[]; +} + +export interface AdminContentMetaConfig { + help_categories: Array<{ + code: string; + title: string; + desc: string; + }>; + report_risk_defaults: Array<{ + report_type: string; + title: string; + text: string; + }>; + ticket_types: Array<{ + code: string; + title: string; + hint: string; + quick_desc: string; + }>; + ticket_statuses: Array<{ + code: string; + title: string; + desc: string; + }>; + message_events: Array<{ + event_code: string; + title: string; + desc: string; + }>; + message_page_copy: { + title: string; + desc: string; + }; +} + +export interface AdminHelpArticleItem { + id: number; + category: "service" | "report" | "shipping" | "support"; + category_text: string; + title: string; + summary: string; + keywords: string[]; + content_blocks: string[]; + is_recommended: boolean; + is_enabled: boolean; + sort_order: number; + updated_at: string; +} + +export interface AdminHelpArticlePayload { + id?: number; + category: "service" | "report" | "shipping" | "support"; + title: string; + summary: string; + keywords: string[]; + content_blocks: string[]; + is_recommended: boolean; + is_enabled: boolean; + sort_order: number; +} + +export const adminApi = { + login(mobile: string, password: string) { + return request.post("/api/admin/auth/login", { + mobile, + password, + }) as Promise<{ + code: number; + message: string; + data: AdminLoginResponse; + }>; + }, + getAuthMe() { + return request.get("/api/admin/auth/me") as Promise<{ + code: number; + message: string; + data: { + admin_info: AdminSessionInfo; + }; + }>; + }, + logout() { + return request.post("/api/admin/auth/logout") as Promise<{ + code: number; + message: string; + data: Record; + }>; + }, + getDashboard() { + return request.get("/api/admin/dashboard") as Promise<{ + code: number; + message: string; + data: { + cards: DashboardCard[]; + }; + }>; + }, + getOrders(params?: Record) { + return request.get("/api/admin/orders", { params }) as Promise<{ + code: number; + message: string; + data: { + list: AdminOrderListItem[]; + }; + }>; + }, + getOrderDetail(id: number) { + return request.get("/api/admin/order/detail", { + params: { id }, + }) as Promise<{ + code: number; + message: string; + data: AdminOrderDetail; + }>; + }, + receiveOrderLogistics(id: number) { + return request.post("/api/admin/order/logistics/receive", { + id, + }) as Promise<{ + code: number; + message: string; + data: { id: number }; + }>; + }, + saveOrderReturnLogistics(payload: { + id: number; + express_company: string; + tracking_no: string; + }) { + return request.post("/api/admin/order/return-logistics/save", payload) as Promise<{ + code: number; + message: string; + data: { + id: number; + express_company: string; + tracking_no: string; + }; + }>; + }, + receiveOrderReturnLogistics(id: number) { + return request.post("/api/admin/order/return-logistics/receive", { + id, + }) as Promise<{ + code: number; + message: string; + data: { id: number }; + }>; + }, + getOrderWarehouseOptions(id: number) { + return request.get("/api/admin/order/warehouse/options", { + params: { id }, + }) as Promise<{ + code: number; + message: string; + data: { + list: AdminOrderWarehouseOption[]; + }; + }>; + }, + reassignOrderWarehouse(id: number, warehouseId: number) { + return request.post("/api/admin/order/warehouse/reassign", { + id, + warehouse_id: warehouseId, + }) as Promise<{ + code: number; + message: string; + data: { + id: number; + warehouse_id: number; + warehouse_name: string; + }; + }>; + }, + getCatalogOverview() { + return request.get("/api/admin/catalog/overview") as Promise<{ + code: number; + message: string; + data: { + cards: CatalogOverviewCard[]; + }; + }>; + }, + getCategories() { + return request.get("/api/admin/catalog/categories") as Promise<{ + code: number; + message: string; + data: { + list: AdminCategoryItem[]; + }; + }>; + }, + getBrands() { + return request.get("/api/admin/catalog/brands") as Promise<{ + code: number; + message: string; + data: { + list: AdminBrandItem[]; + }; + }>; + }, + getCategoryUploadTemplates(categoryId: number) { + return request.get("/api/admin/catalog/upload-templates", { + params: { category_id: categoryId }, + }) as Promise<{ + code: number; + message: string; + data: { + category: { + id: number; + name: string; + code: string; + }; + list: AdminCategoryUploadTemplate[]; + }; + }>; + }, + getCategoryAppraisalTemplates(categoryId: number) { + return request.get("/api/admin/catalog/appraisal-templates", { + params: { category_id: categoryId }, + }) as Promise<{ + code: number; + message: string; + data: { + category: { + id: number; + name: string; + code: string; + }; + template: AdminCategoryAppraisalTemplate; + list: AdminCategoryAppraisalTemplate[]; + }; + }>; + }, + saveCategory(data: AdminCategoryPayload) { + return request.post("/api/admin/catalog/category/save", data) as Promise<{ + code: number; + message: string; + data: { id: number }; + }>; + }, + saveCategoryUploadTemplates(categoryId: number, templates: AdminCategoryUploadTemplate[]) { + return request.post("/api/admin/catalog/upload-templates/save", { + category_id: categoryId, + templates, + }) as Promise<{ + code: number; + message: string; + data: { + category_id: number; + }; + }>; + }, + saveCategoryAppraisalTemplates(categoryId: number, template: AdminCategoryAppraisalTemplate) { + return request.post("/api/admin/catalog/appraisal-templates/save", { + category_id: categoryId, + template, + }) as Promise<{ + code: number; + message: string; + data: { + category_id: number; + }; + }>; + }, + uploadCatalogTemplateSampleImage(file: File) { + const formData = new FormData(); + formData.append("file", file); + return request.post("/api/admin/catalog/upload-template/sample-image/upload", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }) as Promise<{ + code: number; + message: string; + data: AdminCatalogTemplateSampleImageAsset; + }>; + }, + deleteCatalogTemplateSampleImage(fileUrl: string) { + return request.post("/api/admin/catalog/upload-template/sample-image/delete", { + file_url: fileUrl, + }) as Promise<{ + code: number; + message: string; + data: { file_url: string }; + }>; + }, + saveBrand(data: AdminBrandPayload) { + return request.post("/api/admin/catalog/brand/save", data) as Promise<{ + code: number; + message: string; + data: { id: number }; + }>; + }, + getReports(params?: Record) { + return request.get("/api/admin/reports", { params }) as Promise<{ + code: number; + message: string; + data: { + list: AdminReportListItem[]; + }; + }>; + }, + getReportDetail(id: number) { + return request.get("/api/admin/report/detail", { + params: { id }, + }) as Promise<{ + code: number; + message: string; + data: AdminReportDetail; + }>; + }, + publishReport(id: number) { + return request.post("/api/admin/report/publish", { + id, + }) as Promise<{ + code: number; + message: string; + data: AdminPublishReportResponse; + }>; + }, + saveInspectionReport(data: AdminManualInspectionPayload) { + return request.post("/api/admin/report/inspection/save", data) as Promise<{ + code: number; + message: string; + data: AdminPublishReportResponse; + }>; + }, + getAppraisalTasks(params?: Record) { + return request.get("/api/admin/appraisal-tasks", { params }) as Promise<{ + code: number; + message: string; + data: { + list: AdminAppraisalTaskListItem[]; + }; + }>; + }, + getAppraisalTaskDetail(id: number) { + return request.get("/api/admin/appraisal-task/detail", { + params: { id }, + }) as Promise<{ + code: number; + message: string; + data: AdminAppraisalTaskDetail; + }>; + }, + getAppraisalTaskAssignableAdmins(id: number) { + return request.get("/api/admin/appraisal-task/assignable-admins", { + params: { id }, + }) as Promise<{ + code: number; + message: string; + data: { + list: AdminAssignableAppraiserItem[]; + }; + }>; + }, + assignAppraisalTask(data: { + id: number; + assignee_id: number; + }) { + return request.post("/api/admin/appraisal-task/assign", data) as Promise<{ + code: number; + message: string; + data: { + id: number; + assignee_id: number; + assignee_name: string; + }; + }>; + }, + saveAppraisalTaskResult(data: AdminAppraisalTaskResultPayload) { + return request.post("/api/admin/appraisal-task/save-result", data) as Promise<{ + code: number; + message: string; + data: { id: number }; + }>; + }, + bindAppraisalTaskMaterialTag(data: { id: number; qr_input: string }) { + return request.post("/api/admin/appraisal-task/material-tag/bind", data) as Promise<{ + code: number; + message: string; + data: { + id: number; + material_tag: AdminMaterialTagCode; + }; + }>; + }, + uploadAppraisalEvidenceFile(file: File) { + const formData = new FormData(); + formData.append("file", file); + return request.post("/api/admin/appraisal-task/evidence/upload", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }) as Promise<{ + code: number; + message: string; + data: { + file_id: string; + file_url: string; + thumbnail_url: string; + name?: string; + file_type?: string; + mime_type?: string; + }; + }>; + }, + deleteAppraisalEvidenceFile(fileUrl: string) { + return request.post("/api/admin/appraisal-task/evidence/delete", { + file_url: fileUrl, + }) as Promise<{ + code: number; + message: string; + data: { file_url: string }; + }>; + }, + requestAppraisalTaskSupplement(data: AdminAppraisalTaskSupplementPayload) { + return request.post("/api/admin/appraisal-task/request-supplement", data) as Promise<{ + code: number; + message: string; + data: { id: number; supplement_task_id: number }; + }>; + }, + getMessageOverview() { + return request.get("/api/admin/messages/overview") as Promise<{ + code: number; + message: string; + data: { + cards: AdminMessageOverviewCard[]; + }; + }>; + }, + getMessageTemplates() { + return request.get("/api/admin/messages/templates") as Promise<{ + code: number; + message: string; + data: { + list: AdminMessageTemplateItem[]; + }; + }>; + }, + getMessageLogs() { + return request.get("/api/admin/messages/logs") as Promise<{ + code: number; + message: string; + data: { + list: AdminMessageLogItem[]; + }; + }>; + }, + saveMessageTemplate(data: AdminMessageTemplatePayload) { + return request.post("/api/admin/messages/template/save", data) as Promise<{ + code: number; + message: string; + data: { id: number }; + }>; + }, + getTicketOverview() { + return request.get("/api/admin/tickets/overview") as Promise<{ + code: number; + message: string; + data: { + cards: AdminTicketOverviewCard[]; + }; + }>; + }, + getTickets(params?: Record) { + return request.get("/api/admin/tickets", { params }) as Promise<{ + code: number; + message: string; + data: { + list: AdminTicketItem[]; + }; + }>; + }, + getTicketDetail(id: number) { + return request.get("/api/admin/ticket/detail", { + params: { id }, + }) as Promise<{ + code: number; + message: string; + data: AdminTicketDetail; + }>; + }, + saveTicket(data: AdminTicketPayload) { + return request.post("/api/admin/ticket/save", data) as Promise<{ + code: number; + message: string; + data: { id: number }; + }>; + }, + replyTicket( + ticketId: number, + content: string, + attachments: Array<{ + file_id: string; + file_url: string; + thumbnail_url: string; + name?: string; + }> = [], + ) { + return request.post("/api/admin/ticket/reply", { + ticket_id: ticketId, + content, + attachments, + }) as Promise<{ + code: number; + message: string; + data: { ticket_id: number }; + }>; + }, + uploadTicketFile(file: File) { + const formData = new FormData(); + formData.append("file", file); + return request.post("/api/admin/ticket/file/upload", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }) as Promise<{ + code: number; + message: string; + data: { + file_id: string; + file_url: string; + thumbnail_url: string; + name?: string; + }; + }>; + }, + deleteTicketFile(fileUrl: string) { + return request.post("/api/admin/ticket/file/delete", { + file_url: fileUrl, + }) as Promise<{ + code: number; + message: string; + data: { file_url: string }; + }>; + }, + getUserOverview() { + return request.get("/api/admin/users/overview") as Promise<{ + code: number; + message: string; + data: { + cards: AdminUserOverviewCard[]; + }; + }>; + }, + getUsers(params?: Record) { + return request.get("/api/admin/users", { params }) as Promise<{ + code: number; + message: string; + data: { + list: AdminUserItem[]; + }; + }>; + }, + getUserDetail(id: number) { + return request.get("/api/admin/user/detail", { + params: { id }, + }) as Promise<{ + code: number; + message: string; + data: AdminUserDetail; + }>; + }, + saveUser(data: AdminUserPayload) { + return request.post("/api/admin/user/save", data) as Promise<{ + code: number; + message: string; + data: { id: number }; + }>; + }, + getCustomers(params?: Record) { + return request.get("/api/admin/customers", { params }) as Promise<{ + code: number; + message: string; + data: { + list: EnterpriseCustomer[]; + }; + }>; + }, + getCustomerDetail(id: number) { + return request.get("/api/admin/customer/detail", { + params: { id }, + }) as Promise<{ + code: number; + message: string; + data: { + customer: EnterpriseCustomer; + apps: EnterpriseCustomerApp[]; + }; + }>; + }, + saveCustomer(data: EnterpriseCustomerPayload) { + return request.post("/api/admin/customer/save", data) as Promise<{ + code: number; + message: string; + data: { id: number }; + }>; + }, + createCustomerApp(customerId: number, appName: string) { + return request.post("/api/admin/customer/app/create", { + customer_id: customerId, + app_name: appName, + }) as Promise<{ + code: number; + message: string; + data: { + app: EnterpriseCustomerApp; + app_secret: string; + }; + }>; + }, + updateCustomerAppStatus(id: number, status: string) { + return request.post("/api/admin/customer/app/status", { + id, + status, + }) as Promise<{ + code: number; + message: string; + data: { id: number; status: string }; + }>; + }, + resetCustomerAppSecret(id: number) { + return request.post("/api/admin/customer/app/reset-secret", { + id, + }) as Promise<{ + code: number; + message: string; + data: { + app: EnterpriseCustomerApp; + app_secret: string; + }; + }>; + }, + getCustomerOrders(customerId: number) { + return request.get("/api/admin/customer/orders", { + params: { customer_id: customerId }, + }) as Promise<{ + code: number; + message: string; + data: { + list: EnterpriseCustomerOrderRef[]; + }; + }>; + }, + getCustomerEvents(customerId: number) { + return request.get("/api/admin/customer/events", { + params: { customer_id: customerId }, + }) as Promise<{ + code: number; + message: string; + data: { + list: EnterpriseOrderEvent[]; + }; + }>; + }, + getCustomerDeliveries(params: { customer_id?: number; event_id?: number }) { + return request.get("/api/admin/customer/deliveries", { params }) as Promise<{ + code: number; + message: string; + data: { + list: EnterpriseWebhookDelivery[]; + }; + }>; + }, + resendCustomerEvent(eventId: number) { + return request.post("/api/admin/customer/event/resend", { + event_id: eventId, + }) as Promise<{ + code: number; + message: string; + data: { + delivery: EnterpriseWebhookDelivery; + sent: boolean; + }; + }>; + }, + getWarehouseOverview() { + return request.get("/api/admin/warehouses/overview") as Promise<{ + code: number; + message: string; + data: { + cards: AdminWarehouseOverviewCard[]; + }; + }>; + }, + getWarehouses() { + return request.get("/api/admin/warehouses") as Promise<{ + code: number; + message: string; + data: { + list: AdminWarehouseItem[]; + category_options: Array<{ + id: number; + name: string; + }>; + }; + }>; + }, + saveWarehouse(data: AdminWarehousePayload) { + return request.post("/api/admin/warehouse/save", data) as Promise<{ + code: number; + message: string; + data: { id: number }; + }>; + }, + getMaterialBatches(params?: Record) { + return request.get("/api/admin/material/batches", { params }) as Promise<{ + code: number; + message: string; + data: { + list: AdminMaterialBatchItem[]; + }; + }>; + }, + getMaterialBatchDetail(id: number, keyword = "") { + return request.get("/api/admin/material/batch/detail", { + params: { id, keyword }, + }) as Promise<{ + code: number; + message: string; + data: AdminMaterialBatchDetail; + }>; + }, + createMaterialBatch(data: { count: number; remark: string }) { + return request.post("/api/admin/material/batch/create", data) as Promise<{ + code: number; + message: string; + data: { + id: number; + batch_no: string; + total_count: number; + remark: string; + }; + }>; + }, + downloadMaterialBatch(id: number) { + return request.get("/api/admin/material/batch/download", { + params: { id }, + responseType: "blob", + }) as Promise; + }, + getAccessOverview() { + return request.get("/api/admin/access/overview") as Promise<{ + code: number; + message: string; + data: { + cards: AdminAccessOverviewCard[]; + }; + }>; + }, + getAdmins() { + return request.get("/api/admin/access/admins") as Promise<{ + code: number; + message: string; + data: { + list: AdminManagerItem[]; + }; + }>; + }, + getRoles() { + return request.get("/api/admin/access/roles") as Promise<{ + code: number; + message: string; + data: { + list: AdminRoleItem[]; + }; + }>; + }, + getPermissions() { + return request.get("/api/admin/access/permissions") as Promise<{ + code: number; + message: string; + data: { + list: AdminPermissionItem[]; + }; + }>; + }, + saveAdmin(data: AdminManagerPayload) { + return request.post("/api/admin/access/admin/save", data) as Promise<{ + code: number; + message: string; + data: { id: number }; + }>; + }, + saveRole(data: AdminRolePayload) { + return request.post("/api/admin/access/role/save", data) as Promise<{ + code: number; + message: string; + data: { id: number }; + }>; + }, + getSystemConfigs() { + return request.get("/api/admin/system-configs") as Promise<{ + code: number; + message: string; + data: { + groups: AdminSystemConfigGroupItem[]; + }; + }>; + }, + saveSystemConfigs(items: Array<{ + config_group: string; + config_key: string; + config_value: string; + }>) { + return request.post("/api/admin/system-configs/save", { + items, + }) as Promise<{ + code: number; + message: string; + data: Record; + }>; + }, + uploadSystemConfigFile(configGroup: string, configKey: string, file: File) { + const formData = new FormData(); + formData.append("config_group", configGroup); + formData.append("config_key", configKey); + formData.append("file", file); + + return request.post("/api/admin/system-configs/upload-file", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }) as Promise<{ + code: number; + message: string; + data: AdminSystemConfigUploadResult; + }>; + }, + getContentHome() { + return request.get("/api/admin/content/home") as Promise<{ + code: number; + message: string; + data: { + home_config: AdminContentHomeConfig; + }; + }>; + }, + getContentBootstrap() { + return request.get("/api/admin/content/bootstrap") as Promise<{ + code: number; + message: string; + data: { + home_config: AdminContentHomeConfig; + policy_config: AdminContentPolicyConfig; + meta_config: AdminContentMetaConfig; + help_articles: AdminHelpArticleItem[]; + }; + }>; + }, + getContentPolicy() { + return request.get("/api/admin/content/policy") as Promise<{ + code: number; + message: string; + data: { + policy_config: AdminContentPolicyConfig; + }; + }>; + }, + getContentMeta() { + return request.get("/api/admin/content/meta") as Promise<{ + code: number; + message: string; + data: { + meta_config: AdminContentMetaConfig; + }; + }>; + }, + saveContentHome(homeConfig: AdminContentHomePayload) { + return request.post("/api/admin/content/home/save", { + home_config: homeConfig, + }) as Promise<{ + code: number; + message: string; + data: { + home_config: AdminContentHomeConfig; + }; + }>; + }, + uploadContentImage(file: File) { + const formData = new FormData(); + formData.append("file", file); + return request.post("/api/admin/content/image/upload", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }) as Promise<{ + code: number; + message: string; + data: AdminContentImageAsset; + }>; + }, + saveContentPolicy(policyConfig: AdminContentPolicyConfig) { + return request.post("/api/admin/content/policy/save", { + policy_config: policyConfig, + }) as Promise<{ + code: number; + message: string; + data: { + policy_config: AdminContentPolicyConfig; + }; + }>; + }, + saveContentMeta(metaConfig: AdminContentMetaConfig) { + return request.post("/api/admin/content/meta/save", { + meta_config: metaConfig, + }) as Promise<{ + code: number; + message: string; + data: { + meta_config: AdminContentMetaConfig; + }; + }>; + }, + getHelpArticles() { + return request.get("/api/admin/content/help/articles") as Promise<{ + code: number; + message: string; + data: { + list: AdminHelpArticleItem[]; + }; + }>; + }, + saveHelpArticle(payload: AdminHelpArticlePayload) { + return request.post("/api/admin/content/help/article/save", payload) as Promise<{ + code: number; + message: string; + data: { + id: number; + }; + }>; + }, + deleteHelpArticle(id: number) { + return request.post("/api/admin/content/help/article/delete", { + id, + }) as Promise<{ + code: number; + message: string; + data: Record; + }>; + }, +}; diff --git a/admin-web/src/api/request.ts b/admin-web/src/api/request.ts new file mode 100644 index 0000000..de0ed1c --- /dev/null +++ b/admin-web/src/api/request.ts @@ -0,0 +1,63 @@ +import axios from "axios"; +import { clearAdminSession, getAdminToken } from "../utils/auth"; +import { resolveApiBaseUrl } from "../utils/env"; +import { goToAdminLogin } from "../utils/navigation"; + +interface ApiPayload { + code?: number; + message?: string; + data?: unknown; +} + +function redirectToLoginOnUnauthorized() { + clearAdminSession(); + goToAdminLogin(); +} + +const request = axios.create({ + baseURL: resolveApiBaseUrl(), + timeout: 20000, +}); + +request.interceptors.request.use((config) => { + const token = getAdminToken(); + if (token) { + config.headers = config.headers || {}; + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +request.interceptors.response.use( + (response) => { + if (response.config.responseType === "blob" || response.config.responseType === "arraybuffer") { + return response.data as any; + } + + const payload = response.data as ApiPayload; + if (payload?.code === 0) { + return payload as any; + } + + if (payload?.code === 401) { + redirectToLoginOnUnauthorized(); + } + + const error = new Error(payload?.message || "请求失败") as Error & { + payload?: ApiPayload; + status?: number; + }; + error.payload = payload; + error.status = response.status; + return Promise.reject(error); + }, + (error) => { + const status = error?.response?.status; + if (status === 401) { + redirectToLoginOnUnauthorized(); + } + return Promise.reject(error); + }, +); + +export default request; diff --git a/admin-web/src/assets/hero.png b/admin-web/src/assets/hero.png new file mode 100644 index 0000000..cc51a3d Binary files /dev/null and b/admin-web/src/assets/hero.png differ diff --git a/admin-web/src/assets/vite.svg b/admin-web/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/admin-web/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/admin-web/src/assets/vue.svg b/admin-web/src/assets/vue.svg new file mode 100644 index 0000000..770e9d3 --- /dev/null +++ b/admin-web/src/assets/vue.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/admin-web/src/components/HelloWorld.vue b/admin-web/src/components/HelloWorld.vue new file mode 100644 index 0000000..5917e16 --- /dev/null +++ b/admin-web/src/components/HelloWorld.vue @@ -0,0 +1,93 @@ + + + diff --git a/admin-web/src/components/OrderStatusTag.vue b/admin-web/src/components/OrderStatusTag.vue new file mode 100644 index 0000000..ce381bc --- /dev/null +++ b/admin-web/src/components/OrderStatusTag.vue @@ -0,0 +1,44 @@ + + + diff --git a/admin-web/src/layouts/AdminLayout.vue b/admin-web/src/layouts/AdminLayout.vue new file mode 100644 index 0000000..3c0ddb8 --- /dev/null +++ b/admin-web/src/layouts/AdminLayout.vue @@ -0,0 +1,93 @@ + + + diff --git a/admin-web/src/main.ts b/admin-web/src/main.ts new file mode 100644 index 0000000..1a8342d --- /dev/null +++ b/admin-web/src/main.ts @@ -0,0 +1,15 @@ +import { createApp } from "vue"; +import { createPinia } from "pinia"; +import ElementPlus from "element-plus"; +import "element-plus/dist/index.css"; +import "./style.css"; +import App from "./App.vue"; +import router from "./router"; +import { setAppRouter } from "./utils/navigation"; + +const app = createApp(App); +app.use(createPinia()); +app.use(router); +app.use(ElementPlus); +setAppRouter(router); +app.mount("#app"); diff --git a/admin-web/src/pages/access/index.vue b/admin-web/src/pages/access/index.vue new file mode 100644 index 0000000..0831e40 --- /dev/null +++ b/admin-web/src/pages/access/index.vue @@ -0,0 +1,274 @@ + + + diff --git a/admin-web/src/pages/appraisal-tasks/index.vue b/admin-web/src/pages/appraisal-tasks/index.vue new file mode 100644 index 0000000..cb26405 --- /dev/null +++ b/admin-web/src/pages/appraisal-tasks/index.vue @@ -0,0 +1,2355 @@ + + + + + diff --git a/admin-web/src/pages/catalog/index.vue b/admin-web/src/pages/catalog/index.vue new file mode 100644 index 0000000..df7903b --- /dev/null +++ b/admin-web/src/pages/catalog/index.vue @@ -0,0 +1,1103 @@ + + + + + diff --git a/admin-web/src/pages/content/articles.vue b/admin-web/src/pages/content/articles.vue new file mode 100644 index 0000000..6544dac --- /dev/null +++ b/admin-web/src/pages/content/articles.vue @@ -0,0 +1,190 @@ + + + diff --git a/admin-web/src/pages/content/home.vue b/admin-web/src/pages/content/home.vue new file mode 100644 index 0000000..790394b --- /dev/null +++ b/admin-web/src/pages/content/home.vue @@ -0,0 +1,413 @@ + + + + + diff --git a/admin-web/src/pages/content/index.vue b/admin-web/src/pages/content/index.vue new file mode 100644 index 0000000..1f9e8df --- /dev/null +++ b/admin-web/src/pages/content/index.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/admin-web/src/pages/content/meta.vue b/admin-web/src/pages/content/meta.vue new file mode 100644 index 0000000..e3203e8 --- /dev/null +++ b/admin-web/src/pages/content/meta.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/admin-web/src/pages/content/policy.vue b/admin-web/src/pages/content/policy.vue new file mode 100644 index 0000000..35229d8 --- /dev/null +++ b/admin-web/src/pages/content/policy.vue @@ -0,0 +1,172 @@ + + + + + diff --git a/admin-web/src/pages/content/shared.ts b/admin-web/src/pages/content/shared.ts new file mode 100644 index 0000000..e987f96 --- /dev/null +++ b/admin-web/src/pages/content/shared.ts @@ -0,0 +1,157 @@ +import type { AdminContentHomeConfig, AdminContentMetaConfig, AdminContentPolicyConfig, AdminContentPolicyItem, AdminHelpArticleItem, AdminHelpArticlePayload } from "../../api/admin"; + +export type HomeSectionKey = "service_entries" | "quick_entries" | "trust_metrics" | "trust_points" | "faqs"; +export type PolicySectionKey = "legal_entries" | "appraisal_agreements"; +export type MetaSectionKey = "help_categories" | "report_risk_defaults" | "ticket_types" | "ticket_statuses" | "message_events"; +export type ContentTabKey = "home" | "policy" | "meta" | "articles"; + +export type ArticleFormState = { + id?: number; + category: AdminHelpArticlePayload["category"]; + title: string; + summary: string; + keywordsText: string; + contentBlocksText: string; + is_recommended: boolean; + is_enabled: boolean; + sort_order: number; +}; + +export const contentTabs: Array<{ key: ContentTabKey; label: string; desc: string; routeName: string }> = [ + { key: "home", label: "首页与主页面", desc: "Banner、主页面背景、服务入口和信任信息。", routeName: "content-home" }, + { key: "policy", label: "协议与说明", desc: "设置页说明入口和下单确认协议。", routeName: "content-policy" }, + { key: "meta", label: "分类与文案", desc: "帮助分类、消息事件、工单文案和风险提示。", routeName: "content-meta" }, + { key: "articles", label: "帮助文章", desc: "帮助中心文章正文、推荐状态和排序。", routeName: "content-articles" }, +]; + +export const serviceProviderOptions = [ + { label: "实物鉴定", value: "anxinyan" }, + { label: "中检鉴定", value: "zhongjian" }, +]; + +export const quickCodeOptions = [ + { label: "发起鉴定", value: "start" }, + { label: "我的订单", value: "orders" }, + { label: "我的报告", value: "reports" }, + { label: "消息中心", value: "messages" }, +]; + +export const articleCategoryOptions = [ + { label: "服务流程", value: "service" }, + { label: "报告验真", value: "report" }, + { label: "寄送物流", value: "shipping" }, + { label: "售后支持", value: "support" }, +]; + +export function createHomeConfig(): AdminContentHomeConfig { + return { + banners: [{ title: "", subtitle: "", description: "", background_image_url: "" }], + page_visuals: { + order_background_image_url: "", + report_background_image_url: "", + }, + service_entries: [], + category_visuals: [], + quick_entries: [], + trust_metrics: [], + trust_points: [], + faqs: [], + }; +} + +export function normalizeHomeConfig(config?: Partial): AdminContentHomeConfig { + const banners = config?.banners?.length ? config.banners : [{ title: "", subtitle: "", description: "", background_image_url: "" }]; + const pageVisuals: Partial = config?.page_visuals || {}; + + return { + banners: banners.map((item) => ({ + title: item.title || "", + subtitle: item.subtitle || "", + description: item.description || "", + background_image_url: item.background_image_url || "", + })), + page_visuals: { + order_background_image_url: pageVisuals.order_background_image_url || "", + report_background_image_url: pageVisuals.report_background_image_url || "", + }, + service_entries: config?.service_entries || [], + category_visuals: (config?.category_visuals || []).map((item) => ({ + category_name: item.category_name || "", + category_code: item.category_code || "", + image_url: item.image_url || "", + })), + quick_entries: config?.quick_entries || [], + trust_metrics: config?.trust_metrics || [], + trust_points: config?.trust_points || [], + faqs: config?.faqs || [], + }; +} + +export function createPolicyItem(): AdminContentPolicyItem { + return { + code: "", + title: "", + desc: "", + target_url: "", + article_id: 0, + }; +} + +export function parseHelpArticleId(targetUrl?: string) { + if (!targetUrl) { + return 0; + } + + const matched = targetUrl.match(/\/pages\/help\/detail\?id=(\d+)/); + return matched ? Number(matched[1] || 0) : 0; +} + +export function normalizePolicyItems(items?: Partial[]) { + return (items || []).map((item) => ({ + code: item.code || "", + title: item.title || "", + desc: item.desc || "", + target_url: item.target_url || "", + article_id: Number(item.article_id || parseHelpArticleId(item.target_url) || 0), + })); +} + +export function normalizePolicyConfig(config?: Partial): AdminContentPolicyConfig { + return { + legal_entries: normalizePolicyItems(config?.legal_entries), + appraisal_agreements: normalizePolicyItems(config?.appraisal_agreements), + }; +} + +export function normalizeMetaConfig(config?: Partial): AdminContentMetaConfig { + return { + help_categories: config?.help_categories || [], + report_risk_defaults: config?.report_risk_defaults || [], + ticket_types: config?.ticket_types || [], + ticket_statuses: config?.ticket_statuses || [], + message_events: config?.message_events || [], + message_page_copy: config?.message_page_copy || { + title: "", + desc: "", + }, + }; +} + +export function resetArticleForm(target: ArticleFormState, row?: AdminHelpArticleItem) { + target.id = row?.id; + target.category = row?.category || "service"; + target.title = row?.title || ""; + target.summary = row?.summary || ""; + target.keywordsText = row?.keywords?.join("\n") || ""; + target.contentBlocksText = row?.content_blocks?.join("\n") || ""; + target.is_recommended = row?.is_recommended || false; + target.is_enabled = row ? row.is_enabled : true; + target.sort_order = row?.sort_order || 0; +} + +export function parseLines(value: string) { + return value + .split("\n") + .map((item) => item.trim()) + .filter(Boolean); +} diff --git a/admin-web/src/pages/customers/index.vue b/admin-web/src/pages/customers/index.vue new file mode 100644 index 0000000..070c0b6 --- /dev/null +++ b/admin-web/src/pages/customers/index.vue @@ -0,0 +1,512 @@ + + + + + diff --git a/admin-web/src/pages/dashboard/index.vue b/admin-web/src/pages/dashboard/index.vue new file mode 100644 index 0000000..220c612 --- /dev/null +++ b/admin-web/src/pages/dashboard/index.vue @@ -0,0 +1,31 @@ + + + diff --git a/admin-web/src/pages/login/index.vue b/admin-web/src/pages/login/index.vue new file mode 100644 index 0000000..64ce6d7 --- /dev/null +++ b/admin-web/src/pages/login/index.vue @@ -0,0 +1,57 @@ + + + diff --git a/admin-web/src/pages/materials/index.vue b/admin-web/src/pages/materials/index.vue new file mode 100644 index 0000000..8d3b9ce --- /dev/null +++ b/admin-web/src/pages/materials/index.vue @@ -0,0 +1,354 @@ + + + + + diff --git a/admin-web/src/pages/messages/index.vue b/admin-web/src/pages/messages/index.vue new file mode 100644 index 0000000..461c939 --- /dev/null +++ b/admin-web/src/pages/messages/index.vue @@ -0,0 +1,196 @@ + + + diff --git a/admin-web/src/pages/orders/index.vue b/admin-web/src/pages/orders/index.vue new file mode 100644 index 0000000..63d70a7 --- /dev/null +++ b/admin-web/src/pages/orders/index.vue @@ -0,0 +1,814 @@ + + + + + diff --git a/admin-web/src/pages/reports/index.vue b/admin-web/src/pages/reports/index.vue new file mode 100644 index 0000000..ffb1bf4 --- /dev/null +++ b/admin-web/src/pages/reports/index.vue @@ -0,0 +1,847 @@ + + + + + diff --git a/admin-web/src/pages/system-config/index.vue b/admin-web/src/pages/system-config/index.vue new file mode 100644 index 0000000..01072cb --- /dev/null +++ b/admin-web/src/pages/system-config/index.vue @@ -0,0 +1,294 @@ + + + + + diff --git a/admin-web/src/pages/tickets/index.vue b/admin-web/src/pages/tickets/index.vue new file mode 100644 index 0000000..8f670cd --- /dev/null +++ b/admin-web/src/pages/tickets/index.vue @@ -0,0 +1,316 @@ + + + diff --git a/admin-web/src/pages/users/index.vue b/admin-web/src/pages/users/index.vue new file mode 100644 index 0000000..328c819 --- /dev/null +++ b/admin-web/src/pages/users/index.vue @@ -0,0 +1,232 @@ + + + diff --git a/admin-web/src/pages/warehouses/index.vue b/admin-web/src/pages/warehouses/index.vue new file mode 100644 index 0000000..b48c71e --- /dev/null +++ b/admin-web/src/pages/warehouses/index.vue @@ -0,0 +1,306 @@ + + + diff --git a/admin-web/src/router/index.ts b/admin-web/src/router/index.ts new file mode 100644 index 0000000..59ab897 --- /dev/null +++ b/admin-web/src/router/index.ts @@ -0,0 +1,258 @@ +import { createRouter, createWebHashHistory } from "vue-router"; +import { adminApi } from "../api/admin"; +import { clearAdminSession, getAdminInfo, getAdminToken, hasPermission, setAdminInfo } from "../utils/auth"; + +const adminChildren = [ + { + path: "dashboard", + name: "dashboard", + component: () => import("../pages/dashboard/index.vue"), + meta: { + title: "工作台", + desc: "查看当前订单、报告与处理进度概览。", + permission: "dashboard.view", + }, + }, + { + path: "orders", + name: "orders", + component: () => import("../pages/orders/index.vue"), + meta: { + title: "订单中心", + desc: "管理订单流转、查看补图任务与处理详情。", + permission: "orders.manage", + }, + }, + { + path: "appraisal-tasks", + name: "appraisal-tasks", + component: () => import("../pages/appraisal-tasks/index.vue"), + meta: { + title: "鉴定作业台", + desc: "查看鉴定任务、资料详情与当前作业结论。", + permission: "appraisal_tasks.manage", + }, + }, + { + path: "catalog", + name: "catalog", + component: () => import("../pages/catalog/index.vue"), + meta: { + title: "商品资料中心", + desc: "查看品类、品牌、上传模板与鉴定模板等基础配置数据。", + permission: "catalog.manage", + }, + }, + { + path: "reports", + name: "reports", + component: () => import("../pages/reports/index.vue"), + meta: { + title: "报告中心", + desc: "查看已生成报告、报告状态与验真信息。", + permission: "reports.manage", + }, + }, + { + path: "messages", + name: "messages", + component: () => import("../pages/messages/index.vue"), + meta: { + title: "消息中心", + desc: "查看消息模板、触发规则与发送记录概览。", + permission: "messages.manage", + }, + }, + { + path: "tickets", + name: "tickets", + component: () => import("../pages/tickets/index.vue"), + meta: { + title: "客服与售后", + desc: "查看工单、用户留言与售后处理记录。", + permission: "tickets.manage", + }, + }, + { + path: "users", + name: "users", + component: () => import("../pages/users/index.vue"), + meta: { + title: "用户管理", + desc: "查看用户资料、地址、消息和工单等用户侧资产概览。", + permission: "users.manage", + }, + }, + { + path: "customers", + name: "customers", + component: () => import("../pages/customers/index.vue"), + meta: { + title: "客户管理", + desc: "维护大客户资料、开放接口应用 Key、订单映射和 Webhook 推送记录。", + permission: "customers.manage", + }, + }, + { + path: "warehouses", + name: "warehouses", + component: () => import("../pages/warehouses/index.vue"), + meta: { + title: "仓库中心", + desc: "维护收货仓库、检测中心地址信息,并为后续多仓库扩展预留能力。", + permission: "warehouses.manage", + }, + }, + { + path: "materials", + name: "materials", + component: () => import("../pages/materials/index.vue"), + meta: { + title: "物料管理", + desc: "批量生成吊牌二维码,管理批次下载、条码搜索与报告绑定状态。", + permission: "materials.manage", + }, + }, + { + path: "access", + name: "access", + component: () => import("../pages/access/index.vue"), + meta: { + title: "权限中心", + desc: "管理管理员账号、角色配置与权限点分配。", + permission: "access.manage", + }, + }, + { + path: "content", + name: "content", + component: () => import("../pages/content/index.vue"), + redirect: { name: "content-home" }, + meta: { + title: "内容中心", + desc: "维护首页展示内容与帮助中心文章等用户端内容配置。", + permission: "system.manage", + }, + children: [ + { + path: "home", + name: "content-home", + component: () => import("../pages/content/home.vue"), + meta: { + title: "内容中心", + desc: "首页内容维护:Banner、服务入口、快捷入口和信任信息。", + permission: "system.manage", + menuIndex: "content", + contentTab: "home", + }, + }, + { + path: "policy", + name: "content-policy", + component: () => import("../pages/content/policy.vue"), + meta: { + title: "内容中心", + desc: "协议与说明维护:设置页说明入口和下单确认协议。", + permission: "system.manage", + menuIndex: "content", + contentTab: "policy", + }, + }, + { + path: "meta", + name: "content-meta", + component: () => import("../pages/content/meta.vue"), + meta: { + title: "内容中心", + desc: "分类与文案维护:帮助分类、消息事件、工单文案和风险提示。", + permission: "system.manage", + menuIndex: "content", + contentTab: "meta", + }, + }, + { + path: "articles", + name: "content-articles", + component: () => import("../pages/content/articles.vue"), + meta: { + title: "内容中心", + desc: "帮助文章维护:文章正文、推荐状态和排序。", + permission: "system.manage", + menuIndex: "content", + contentTab: "articles", + }, + }, + ], + }, + { + path: "system-config", + name: "system-config", + component: () => import("../pages/system-config/index.vue"), + meta: { + title: "系统配置", + desc: "配置小程序、H5、支付与商户平台等上线核心参数。", + permission: "system.manage", + }, + }, +]; + +const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { + path: "/login", + name: "login", + component: () => import("../pages/login/index.vue"), + meta: { + public: true, + title: "登录", + }, + }, + { + path: "/", + component: () => import("../layouts/AdminLayout.vue"), + redirect: "/dashboard", + children: adminChildren, + }, + ], +}); + +function firstAccessibleRoute() { + const target = adminChildren.find((route) => hasPermission(route.meta.permission as string)); + return target?.name || "dashboard"; +} + +router.beforeEach(async (to) => { + if (to.meta.public) { + if (getAdminToken()) { + return { name: firstAccessibleRoute() }; + } + return true; + } + + const token = getAdminToken(); + if (!token) { + clearAdminSession(); + return { name: "login" }; + } + + if (!getAdminInfo()) { + try { + const response = await adminApi.getAuthMe(); + setAdminInfo(response.data.admin_info); + } catch (error) { + console.error(error); + clearAdminSession(); + return { name: "login" }; + } + } + + const permission = to.meta.permission as string | undefined; + if (permission && !hasPermission(permission)) { + return { name: firstAccessibleRoute() }; + } + + return true; +}); + +export default router; diff --git a/admin-web/src/style.css b/admin-web/src/style.css new file mode 100644 index 0000000..6963d5a --- /dev/null +++ b/admin-web/src/style.css @@ -0,0 +1,446 @@ +:root { + font-family: "PingFang SC", "Microsoft YaHei", sans-serif; + color: #1f2430; + background: #f5f6f8; + line-height: 1.5; + font-weight: 400; + color-scheme: light; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + + --admin-brand-black: #171717; + --admin-brand-gold: #c8a45d; + --admin-page-bg: #f5f6f8; + --admin-card-bg: #ffffff; + --admin-border: #e9e2d2; + --admin-text-main: #1f2430; + --admin-text-subtle: #6d7483; + --admin-success: #2f6b4f; + --admin-warning: #b67a2d; + --admin-danger: #9f3b32; + --admin-progress: #486885; + --admin-neutral: #667085; +} + +* { + box-sizing: border-box; +} + +html, +body, +#app { + margin: 0; + min-height: 100%; + background: var(--admin-page-bg); +} + +body { + min-width: 1200px; + color: var(--admin-text-main); +} + +.login-page { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + background: + radial-gradient(circle at top right, rgba(200, 164, 93, 0.18), transparent 22%), + linear-gradient(160deg, #111111 0%, #171717 48%, #272117 100%); +} + +.login-card { + width: 100%; + max-width: 460px; + padding: 32px 30px 28px; + border-radius: 28px; + background: linear-gradient(180deg, #ffffff 0%, #fbf7ef 100%); + border: 1px solid rgba(200, 164, 93, 0.24); + box-shadow: 0 28px 80px rgba(0, 0, 0, 0.22); +} + +.login-card__eyebrow { + display: inline-flex; + align-items: center; + min-height: 32px; + padding: 0 14px; + border-radius: 999px; + background: rgba(200, 164, 93, 0.14); + color: #7a5a21; + font-size: 12px; + font-weight: 600; +} + +.login-card__title { + margin-top: 18px; + font-size: 30px; + font-weight: 800; + line-height: 1.1; +} + +.login-card__desc { + margin-top: 10px; + color: var(--admin-text-subtle); + font-size: 14px; + line-height: 1.6; +} + +.login-card__hint { + margin-top: 8px; + color: var(--admin-text-subtle); + font-size: 12px; +} + +.login-card__action { + width: 100%; + margin-top: 18px; +} + +a { + color: inherit; + text-decoration: none; +} + +.admin-layout { + min-height: 100vh; + background: linear-gradient(180deg, #f8f8f8 0%, #f2f3f5 100%); +} + +.admin-aside { + position: relative; + border-right: 1px solid rgba(255, 255, 255, 0.06); + background: + radial-gradient(circle at top right, rgba(200, 164, 93, 0.14), transparent 24%), + linear-gradient(180deg, #111111 0%, #171717 50%, #1f1b14 100%); +} + +.admin-brand { + padding: 28px 24px 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +.admin-brand__name { + color: #fff; + font-size: 24px; + font-weight: 700; +} + +.admin-brand__desc { + margin-top: 8px; + color: rgba(255, 255, 255, 0.6); + font-size: 13px; +} + +.admin-aside .el-menu { + border-right: none; + background: transparent; + --el-menu-bg-color: transparent; + --el-menu-text-color: rgba(255, 255, 255, 0.72); + --el-menu-hover-text-color: #ffffff; + --el-menu-active-color: #ffffff; + --el-menu-hover-bg-color: rgba(200, 164, 93, 0.18); +} + +.admin-aside .el-menu-item, +.admin-aside .el-sub-menu__title { + color: rgba(255, 255, 255, 0.72); + height: 52px; + transition: + color 0.2s ease, + background-color 0.2s ease, + box-shadow 0.2s ease; +} + +.admin-aside .el-menu-item .el-icon, +.admin-aside .el-sub-menu__title .el-icon { + color: inherit; + transition: color 0.2s ease; +} + +.admin-aside .el-menu-item:hover, +.admin-aside .el-menu-item:focus, +.admin-aside .el-sub-menu__title:hover, +.admin-aside .el-sub-menu__title:focus { + color: #fff; + background: + linear-gradient(90deg, rgba(200, 164, 93, 0.26) 0%, rgba(200, 164, 93, 0.1) 92%); + box-shadow: inset 0 0 0 1px rgba(200, 164, 93, 0.1); +} + +.admin-aside .el-menu-item.is-active { + color: #fff; + background: + linear-gradient(90deg, rgba(200, 164, 93, 0.24) 0%, rgba(200, 164, 93, 0.12) 92%); + box-shadow: + inset 0 0 0 1px rgba(200, 164, 93, 0.12), + 0 12px 28px rgba(0, 0, 0, 0.14); +} + +.admin-main { + padding: 24px; +} + +.admin-topbar { + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + padding: 22px 24px; + border: 1px solid var(--admin-border); + border-radius: 24px; + background: linear-gradient(135deg, #ffffff 0%, #fbf7ef 100%); + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.04); +} + +.admin-topbar__title { + font-size: 26px; + font-weight: 700; +} + +.admin-topbar__desc { + margin-top: 6px; + color: var(--admin-text-subtle); + font-size: 14px; +} + +.admin-topbar__meta { + display: flex; + gap: 12px; + flex-wrap: wrap; +} + +.admin-chip { + display: inline-flex; + align-items: center; + min-height: 34px; + padding: 0 14px; + border-radius: 999px; + background: rgba(200, 164, 93, 0.12); + color: #7a5a21; + font-size: 12px; + font-weight: 600; +} + +.admin-content { + margin-top: 20px; +} + +.panel-card { + border: 1px solid var(--admin-border); + border-radius: 22px; + background: var(--admin-card-bg); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04); +} + +.panel-card + .panel-card { + margin-top: 18px; +} + +.metric-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 18px; +} + +.metric-card { + padding: 22px; + border: 1px solid var(--admin-border); + border-radius: 20px; + background: linear-gradient(180deg, #ffffff 0%, #fbf8f1 100%); +} + +.metric-card__label { + color: var(--admin-text-subtle); + font-size: 13px; +} + +.metric-card__value { + margin-top: 12px; + font-size: 34px; + font-weight: 700; + line-height: 1; +} + +.metric-card__desc { + margin-top: 12px; + color: var(--admin-text-subtle); + font-size: 13px; +} + +.filters-row { + display: flex; + gap: 12px; + align-items: center; + flex-wrap: wrap; +} + +.orders-table .el-table { + --el-table-border-color: #f0eadf; + --el-table-header-bg-color: #fbf8f2; + --el-table-row-hover-bg-color: #fcfaf5; + border-radius: 16px; +} + +.status-tag { + --status-tag-color: var(--admin-neutral); + --status-tag-bg: rgba(102, 112, 133, 0.1); + --status-tag-border: rgba(102, 112, 133, 0.16); + --status-tag-glow: rgba(102, 112, 133, 0.14); + display: inline-flex; + align-items: center; + gap: 8px; + min-height: 30px; + padding: 0 12px 0 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 700; + line-height: 1; + letter-spacing: 0.01em; + white-space: nowrap; + color: var(--status-tag-color); + border: 1px solid var(--status-tag-border); + background: var(--status-tag-bg); + box-shadow: + inset 0 1px 0 rgba(255, 255, 255, 0.72), + 0 1px 2px rgba(17, 24, 39, 0.04); + vertical-align: middle; +} + +.status-tag::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; + box-shadow: 0 0 0 4px var(--status-tag-glow); +} + +.status-tag--success { + --status-tag-color: var(--admin-success); + --status-tag-bg: linear-gradient(180deg, rgba(47, 107, 79, 0.12) 0%, rgba(47, 107, 79, 0.08) 100%); + --status-tag-border: rgba(47, 107, 79, 0.16); + --status-tag-glow: rgba(47, 107, 79, 0.16); +} + +.status-tag--warning { + --status-tag-color: var(--admin-warning); + --status-tag-bg: linear-gradient(180deg, rgba(182, 122, 45, 0.14) 0%, rgba(182, 122, 45, 0.09) 100%); + --status-tag-border: rgba(182, 122, 45, 0.18); + --status-tag-glow: rgba(182, 122, 45, 0.18); +} + +.status-tag--danger { + --status-tag-color: var(--admin-danger); + --status-tag-bg: linear-gradient(180deg, rgba(159, 59, 50, 0.12) 0%, rgba(159, 59, 50, 0.08) 100%); + --status-tag-border: rgba(159, 59, 50, 0.16); + --status-tag-glow: rgba(159, 59, 50, 0.16); +} + +.status-tag--progress { + --status-tag-color: var(--admin-progress); + --status-tag-bg: linear-gradient(180deg, rgba(72, 104, 133, 0.14) 0%, rgba(72, 104, 133, 0.09) 100%); + --status-tag-border: rgba(72, 104, 133, 0.16); + --status-tag-glow: rgba(72, 104, 133, 0.16); +} + +.status-tag--neutral { + --status-tag-color: var(--admin-neutral); + --status-tag-bg: linear-gradient(180deg, rgba(102, 112, 133, 0.12) 0%, rgba(102, 112, 133, 0.08) 100%); + --status-tag-border: rgba(102, 112, 133, 0.14); + --status-tag-glow: rgba(102, 112, 133, 0.14); +} + +.detail-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.detail-card { + padding: 18px 18px 16px; + border: 1px solid var(--admin-border); + border-radius: 18px; + background: #fffdfa; +} + +.detail-card__title { + font-size: 15px; + font-weight: 700; +} + +.detail-card__desc { + margin-top: 12px; +} + +.detail-card__desc + .detail-card__desc { + margin-top: 10px; +} + +.detail-label { + color: var(--admin-text-subtle); + font-size: 13px; +} + +.detail-value { + margin-top: 4px; + color: var(--admin-text-main); + font-size: 14px; + font-weight: 600; +} + +.timeline-list { + display: flex; + flex-direction: column; + gap: 14px; +} + +.timeline-node { + padding: 14px 16px; + border-radius: 16px; + background: #fcfaf5; + border: 1px solid #efe8d9; +} + +.timeline-node__title { + font-size: 14px; + font-weight: 700; +} + +.timeline-node__time { + margin-top: 6px; + color: var(--admin-text-subtle); + font-size: 12px; +} + +.timeline-node__desc { + margin-top: 8px; + color: var(--admin-text-main); + font-size: 13px; +} + +.admin-upload-list { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 12px; +} + +.admin-upload-thumb { + width: 84px; + height: 84px; + border-radius: 14px; + overflow: hidden; + border: 1px solid #eadfc8; + background: #f6f3ec; + cursor: pointer; +} + +.admin-upload-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} diff --git a/admin-web/src/utils/auth.ts b/admin-web/src/utils/auth.ts new file mode 100644 index 0000000..7be6ebf --- /dev/null +++ b/admin-web/src/utils/auth.ts @@ -0,0 +1,54 @@ +const TOKEN_KEY = "anxinyan_admin_token"; +const ADMIN_INFO_KEY = "anxinyan_admin_info"; + +export interface AdminSessionInfo { + id: number; + name: string; + mobile: string; + email: string; + status: string; + role_names: string[]; + permission_codes: string[]; +} + +export function getAdminToken() { + return localStorage.getItem(TOKEN_KEY) || ""; +} + +export function setAdminToken(token: string) { + localStorage.setItem(TOKEN_KEY, token); +} + +export function clearAdminToken() { + localStorage.removeItem(TOKEN_KEY); +} + +export function getAdminInfo(): AdminSessionInfo | null { + const raw = localStorage.getItem(ADMIN_INFO_KEY); + if (!raw) return null; + try { + return JSON.parse(raw) as AdminSessionInfo; + } catch { + return null; + } +} + +export function setAdminInfo(info: AdminSessionInfo) { + localStorage.setItem(ADMIN_INFO_KEY, JSON.stringify(info)); +} + +export function clearAdminInfo() { + localStorage.removeItem(ADMIN_INFO_KEY); +} + +export function clearAdminSession() { + clearAdminToken(); + clearAdminInfo(); +} + +export function hasPermission(code?: string) { + if (!code) return true; + const info = getAdminInfo(); + if (!info) return false; + return info.permission_codes.includes(code); +} diff --git a/admin-web/src/utils/env.ts b/admin-web/src/utils/env.ts new file mode 100644 index 0000000..c141145 --- /dev/null +++ b/admin-web/src/utils/env.ts @@ -0,0 +1,24 @@ +const LOCAL_API_BASE_URL = "http://127.0.0.1:8787"; + +function isLocalLikeHostname(hostname: string) { + return ( + hostname === "localhost" || + hostname === "127.0.0.1" || + hostname === "0.0.0.0" || + /^10\./.test(hostname) || + /^192\.168\./.test(hostname) || + /^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname) + ); +} + +export function resolveApiBaseUrl() { + if (import.meta.env.DEV) { + return LOCAL_API_BASE_URL; + } + + if (typeof window !== "undefined" && isLocalLikeHostname(window.location.hostname)) { + return LOCAL_API_BASE_URL; + } + + return import.meta.env.VITE_API_BASE_URL || LOCAL_API_BASE_URL; +} diff --git a/admin-web/src/utils/navigation.ts b/admin-web/src/utils/navigation.ts new file mode 100644 index 0000000..f492448 --- /dev/null +++ b/admin-web/src/utils/navigation.ts @@ -0,0 +1,33 @@ +import type { Router } from "vue-router"; + +let appRouter: Router | null = null; + +export function setAppRouter(router: Router) { + appRouter = router; +} + +export function goToAdminLogin() { + if (appRouter) { + if (appRouter.currentRoute.value.name !== "login") { + appRouter.replace({ name: "login" }); + } + return; + } + + if (window.location.hash !== "#/login") { + window.history.replaceState({}, "", "/#/login"); + window.dispatchEvent(new PopStateEvent("popstate")); + } +} + +export function goToAdminHome() { + if (appRouter) { + appRouter.replace({ name: "dashboard" }); + return; + } + + if (window.location.hash !== "#/dashboard") { + window.history.replaceState({}, "", "/#/dashboard"); + window.dispatchEvent(new PopStateEvent("popstate")); + } +} diff --git a/admin-web/tsconfig.app.json b/admin-web/tsconfig.app.json new file mode 100644 index 0000000..5c750c5 --- /dev/null +++ b/admin-web/tsconfig.app.json @@ -0,0 +1,14 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/admin-web/tsconfig.json b/admin-web/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/admin-web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/admin-web/tsconfig.node.json b/admin-web/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/admin-web/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/admin-web/vite.config.ts b/admin-web/vite.config.ts new file mode 100644 index 0000000..1c3a067 --- /dev/null +++ b/admin-web/vite.config.ts @@ -0,0 +1,44 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [vue()], + server: { + host: '0.0.0.0', + port: 5174, + strictPort: true, + }, + preview: { + host: '0.0.0.0', + port: 4174, + strictPort: true, + }, + build: { + rollupOptions: { + output: { + manualChunks(id) { + if (!id.includes('node_modules')) { + return + } + + if (id.includes('element-plus') || id.includes('@element-plus')) { + return 'vendor-element-plus' + } + + if (id.includes('echarts')) { + return 'vendor-echarts' + } + + if (id.includes('axios')) { + return 'vendor-axios' + } + + if (id.includes('vue-router') || id.includes('pinia') || id.includes('/vue/')) { + return 'vendor-vue' + } + }, + }, + }, + }, +}) diff --git a/design-prototypes/anxinyan-user-ui-overview-v1.html b/design-prototypes/anxinyan-user-ui-overview-v1.html new file mode 100644 index 0000000..2269055 --- /dev/null +++ b/design-prototypes/anxinyan-user-ui-overview-v1.html @@ -0,0 +1,1401 @@ + + + + + + 安心验 · 用户端 UI 探索 V1 + + + + + + + + + +
+ + + + diff --git a/design-prototypes/anxinyan-user-ui-overview-v1.png b/design-prototypes/anxinyan-user-ui-overview-v1.png new file mode 100644 index 0000000..1846195 Binary files /dev/null and b/design-prototypes/anxinyan-user-ui-overview-v1.png differ diff --git a/design-prototypes/anxinyan-user-ui-style-variants-v2-v3.html b/design-prototypes/anxinyan-user-ui-style-variants-v2-v3.html new file mode 100644 index 0000000..a5b695a --- /dev/null +++ b/design-prototypes/anxinyan-user-ui-style-variants-v2-v3.html @@ -0,0 +1,1000 @@ + + + + + + 安心验 · 其他风格方向参考 + + + + + + + + + +
+ + + + diff --git a/design-prototypes/anxinyan-user-ui-style-variants-v2-v3.png b/design-prototypes/anxinyan-user-ui-style-variants-v2-v3.png new file mode 100644 index 0000000..87d272d Binary files /dev/null and b/design-prototypes/anxinyan-user-ui-style-variants-v2-v3.png differ diff --git a/docs/api/api-list.md b/docs/api/api-list.md new file mode 100644 index 0000000..ce62573 --- /dev/null +++ b/docs/api/api-list.md @@ -0,0 +1,5 @@ +# API List + +## 第三方开放接口 + +- [第三方订单对接文档](./third-party-openapi.md):客户推送订单、订单查询、签名鉴权、Webhook 回调说明。 diff --git a/docs/api/third-party-openapi.md b/docs/api/third-party-openapi.md new file mode 100644 index 0000000..aa0aa49 --- /dev/null +++ b/docs/api/third-party-openapi.md @@ -0,0 +1,385 @@ +# 第三方订单对接文档 + +版本:v1 +更新日期:2026-05-08 + +## 1. 对接说明 + +本文档用于第三方系统对接安心验开放接口。第三方推送订单时,只需要提供第三方自己的订单号 `external_order_no`,不需要提前传物品信息。具体物品信息会在鉴定师鉴定时由平台侧补充完善。 + +接口域名以实际环境为准,本文统一使用: + +```text +https://{api-domain} +``` + +## 2. 凭证与安全 + +平台会为每个对接方分配: + +| 参数 | 说明 | +| --- | --- | +| `app_key` | 调用方身份标识 | +| `app_secret` | 签名密钥,请妥善保管,不要传给前端或泄露到日志中 | + +所有开放接口都需要签名。请求必须使用 HTTPS,并携带以下请求头: + +| Header | 必填 | 说明 | +| --- | --- | --- | +| `Content-Type` | 是 | 固定为 `application/json` | +| `X-AXY-App-Key` | 是 | 平台分配的 `app_key` | +| `X-AXY-Timestamp` | 是 | Unix 秒级时间戳,有效期 300 秒 | +| `X-AXY-Nonce` | 是 | 随机字符串,同一个 `app_key` 下不可重复使用 | +| `X-AXY-Signature` | 是 | 请求签名 | + +### 2.1 签名算法 + +签名使用 HMAC-SHA256,小写十六进制输出。 + +```text +body_hash = sha256(raw_body) +base = UPPERCASE_HTTP_METHOD + path_with_query + timestamp + nonce + body_hash +signature = hex_hmac_sha256(base, app_secret) +``` + +字段说明: + +| 字段 | 说明 | +| --- | --- | +| `raw_body` | 原始请求体字符串。GET 请求没有请求体时为空字符串 | +| `path_with_query` | 请求路径加查询字符串,例如 `/api/open/v1/orders?external_order_no=T202605080001` | +| `timestamp` | 与 `X-AXY-Timestamp` 完全一致 | +| `nonce` | 与 `X-AXY-Nonce` 完全一致 | + +注意事项: + +- `path_with_query` 必须与实际请求完全一致,包括查询参数顺序和 URL 编码。 +- POST 请求签名时使用实际发送的 JSON 字符串,不要签名一个格式化版本、发送另一个压缩版本。 +- GET 请求的 `body_hash` 为 `sha256("")`。 +- `nonce` 会做防重放校验,重试请求需要重新生成 `nonce` 和签名。 + +### 2.2 Node.js 签名示例 + +```js +import crypto from 'node:crypto'; + +function sign({ method, pathWithQuery, body = '', timestamp, nonce, appSecret }) { + const bodyHash = crypto.createHash('sha256').update(body).digest('hex'); + const base = method.toUpperCase() + pathWithQuery + timestamp + nonce + bodyHash; + return crypto.createHmac('sha256', appSecret).update(base).digest('hex'); +} +``` + +### 2.3 PHP 签名示例 + +```php +function sign_request(string $method, string $pathWithQuery, string $body, string $timestamp, string $nonce, string $appSecret): string +{ + $bodyHash = hash('sha256', $body); + $base = strtoupper($method) . $pathWithQuery . $timestamp . $nonce . $bodyHash; + return hash_hmac('sha256', $base, $appSecret); +} +``` + +## 3. 通用响应格式 + +成功响应: + +```json +{ + "code": 0, + "message": "ok", + "data": {} +} +``` + +失败响应: + +```json +{ + "code": 422, + "message": "external_order_no 不能为空", + "data": {} +} +``` + +常见错误码: + +| code | 说明 | +| --- | --- | +| `401` | 鉴权失败、签名错误、时间戳过期、`nonce` 重复 | +| `404` | 订单不存在 | +| `409` | 幂等冲突,例如同一个 `external_order_no` 请求内容不一致 | +| `422` | 请求参数不合法 | +| `500` | 服务端处理失败 | + +## 4. 创建订单 + +```text +POST /api/open/v1/orders +``` + +第三方创建订单时只需要传 `external_order_no`。平台会创建一笔待收货订单,后续物品信息由鉴定师在鉴定工作台补充。 + +### 4.1 请求参数 + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `external_order_no` | string | 是 | 第三方订单号。同一对接客户下必须唯一 | +| `service_provider` | string | 否 | 服务方,可选 `anxinyan`、`zhongjian`,默认 `anxinyan` | +| `product_info` | object | 否 | 物品信息,当前可不传 | +| `materials` | array | 否 | 鉴定资料图片 URL 列表,当前可不传 | +| `return_address` | object | 否 | 退回地址,当前可不传;如传任一地址字段,则必填完整地址 | +| `inbound_logistics` | object | 否 | 寄入物流信息,当前可不传 | +| `express_company` | string | 否 | 寄入快递公司,可替代 `inbound_logistics.express_company` | +| `tracking_no` | string | 否 | 寄入运单号,可替代 `inbound_logistics.tracking_no` | +| `extra_info` | object | 否 | 扩展信息,当前可不传 | + +### 4.2 最小请求示例 + +```json +{ + "external_order_no": "THIRD202605080001" +} +``` + +### 4.3 带可选字段请求示例 + +```json +{ + "external_order_no": "THIRD202605080002", + "service_provider": "anxinyan", + "inbound_logistics": { + "express_company": "顺丰速运", + "tracking_no": "SF1234567890" + }, + "return_address": { + "consignee": "张三", + "mobile": "13800138000", + "province": "浙江省", + "city": "杭州市", + "district": "西湖区", + "detail_address": "文三路 1 号" + } +} +``` + +### 4.4 cURL 示例 + +```bash +curl -X POST 'https://{api-domain}/api/open/v1/orders' \ + -H 'Content-Type: application/json' \ + -H 'X-AXY-App-Key: your_app_key' \ + -H 'X-AXY-Timestamp: 1778227200' \ + -H 'X-AXY-Nonce: 7b7b2a2f9c9e4d1f' \ + -H 'X-AXY-Signature: calculated_signature' \ + -d '{"external_order_no":"THIRD202605080001"}' +``` + +### 4.5 成功响应示例 + +```json +{ + "code": 0, + "message": "订单已创建", + "data": { + "idempotent": false, + "order": { + "customer_id": "CUST001", + "customer_code": "CUST001", + "external_order_no": "THIRD202605080001", + "order_id": 123, + "order_no": "AXY20260508120000123", + "appraisal_no": "AXY-APP-20260508-1001", + "order_status": "pending_shipping", + "display_status": "待寄送商品", + "payment_status": "paid", + "pay_amount": 99, + "estimated_finish_time": "2026-05-09 12:00:00", + "created_at": "2026-05-08 12:00:00", + "timeline": [ + { + "node_code": "created", + "node_text": "下单成功", + "node_desc": "大客户订单已推送并创建成功", + "occurred_at": "2026-05-08 12:00:00" + }, + { + "node_code": "pending_shipping", + "node_text": "待寄送商品", + "node_desc": "请将商品寄送至鉴定中心", + "occurred_at": "2026-05-08 12:00:00" + } + ], + "inbound_logistics": null, + "return_logistics": null, + "report_summary": null + } + } +} +``` + +### 4.6 幂等规则 + +同一个对接客户下,`external_order_no` 作为幂等键: + +- 第一次请求会创建订单。 +- 后续使用相同 `external_order_no` 且请求内容一致时,不会重复创建订单,会返回已有订单,`data.idempotent` 为 `true`。 +- 后续使用相同 `external_order_no` 但请求内容不一致时,返回 `409`。 + +建议第三方重试创建订单时保持请求 JSON 内容一致,仅重新生成 `timestamp`、`nonce` 和 `signature`。 + +## 5. 查询订单 + +支持按第三方订单号或平台订单号查询订单进度。 + +### 5.1 按第三方订单号查询 + +```text +GET /api/open/v1/orders/{external_order_no} +``` + +示例: + +```bash +curl -X GET 'https://{api-domain}/api/open/v1/orders/THIRD202605080001' \ + -H 'Content-Type: application/json' \ + -H 'X-AXY-App-Key: your_app_key' \ + -H 'X-AXY-Timestamp: 1778227200' \ + -H 'X-AXY-Nonce: f0f74a6baf764d8f' \ + -H 'X-AXY-Signature: calculated_signature' +``` + +### 5.2 通过查询参数查询 + +```text +GET /api/open/v1/orders?external_order_no=THIRD202605080001 +GET /api/open/v1/orders?order_no=AXY20260508120000123 +``` + +### 5.3 响应示例 + +```json +{ + "code": 0, + "message": "ok", + "data": { + "order": { + "customer_id": "CUST001", + "customer_code": "CUST001", + "external_order_no": "THIRD202605080001", + "order_id": 123, + "order_no": "AXY20260508120000123", + "appraisal_no": "AXY-APP-20260508-1001", + "order_status": "report_published", + "display_status": "报告已发布", + "payment_status": "paid", + "pay_amount": 99, + "estimated_finish_time": "2026-05-09 12:00:00", + "created_at": "2026-05-08 12:00:00", + "timeline": [], + "inbound_logistics": { + "express_company": "顺丰速运", + "tracking_no": "SF1234567890", + "tracking_status": "submitted", + "latest_desc": "客户已提交寄送运单:顺丰速运 SF1234567890,等待鉴定中心签收。", + "latest_time": "2026-05-08 12:00:00" + }, + "return_logistics": null, + "report_summary": { + "report_no": "R202605080001", + "report_title": "鉴定报告", + "report_status": "published", + "publish_time": "2026-05-08 18:00:00", + "verify_url": "https://{h5-domain}/verify?id=xxx", + "report_page_url": "https://{h5-domain}/report/xxx", + "verify_status": "valid" + } + } + } +} +``` + +## 6. 订单状态 + +常见订单状态如下,最终以接口返回的 `order_status` 和 `display_status` 为准。 + +| order_status | display_status | 说明 | +| --- | --- | --- | +| `pending_shipping` | 待寄送商品 | 订单已创建,等待物品到仓或人工确认收货 | +| `received` | 鉴定中心已收货 | 物品已到仓 | +| `appraising` | 物品鉴定中 | 鉴定师正在鉴定 | +| `generating_report` | 物品鉴定完成 | 鉴定完成,报告生成中 | +| `report_published` | 报告已发布 | 报告已发布,可查看报告摘要 | +| `return_shipped` | 物品已寄回 | 物品已退回寄出 | +| `completed` | 已完成 | 订单完成 | +| `pending_supplement` | 需要补充资料 | 需要补充资料 | + +## 7. Webhook 事件回调 + +如需接收订单状态变化通知,第三方需向平台提供可公网访问的 `webhook_url`,并由平台开启回调。 + +平台会以 POST JSON 方式推送事件: + +| Header | 说明 | +| --- | --- | +| `Content-Type` | `application/json` | +| `X-AXY-App-Key` | 平台分配给该客户的 `app_key` | + +当前 webhook 仅携带 `X-AXY-App-Key`,暂未实现回调签名。如第三方需要回调验签,可与平台另行约定后升级。 + +回调超时时间: + +| 项目 | 值 | +| --- | --- | +| 连接超时 | 3 秒 | +| 总超时 | 6 秒 | +| 成功判定 | HTTP 状态码为 2xx 且无网络错误 | + +### 7.1 回调报文 + +```json +{ + "event_code": "order_created", + "event_text": "订单创建", + "customer_id": "CUST001", + "customer_code": "CUST001", + "external_order_no": "THIRD202605080001", + "order_no": "AXY20260508120000123", + "appraisal_no": "AXY-APP-20260508-1001", + "status_code": "pending_shipping", + "status_text": "待寄送商品", + "occurred_at": "2026-05-08 12:00:00", + "data": {}, + "event_id": 1 +} +``` + +### 7.2 事件类型 + +| event_code | event_text | status_code | status_text | +| --- | --- | --- | --- | +| `order_created` | 订单创建 | `pending_shipping` | 待寄送商品 | +| `inbound_received` | 快递已到仓 | `received` | 鉴定中心已收货 | +| `appraising` | 物品鉴定中 | `appraising` | 物品鉴定中 | +| `appraisal_finished` | 物品鉴定完成 | `generating_report` | 物品鉴定完成 | +| `report_published` | 报告已发布 | `report_published` | 报告已发布 | +| `return_shipped` | 物品已寄回 | `return_shipped` | 物品已寄回 | +| `completed` | 订单已完成 | `completed` | 已完成 | +| `supplement_required` | 需要补充资料 | `pending_supplement` | 需要补充资料 | + +### 7.3 回调接收建议 + +第三方接收 webhook 时建议: + +- 使用 `event_id` 做事件幂等,避免重复处理。 +- 收到事件后返回 HTTP 2xx。 +- 如需强一致的最新状态,可以收到 webhook 后再调用订单查询接口确认。 + +## 8. 对接流程建议 + +1. 平台分配 `app_key` 和 `app_secret`。 +2. 第三方完成签名调试。 +3. 第三方调用创建订单接口,只传 `external_order_no` 即可。 +4. 第三方可通过查询接口主动查询订单状态。 +5. 如启用 webhook,平台在订单状态变化时主动通知第三方。 diff --git a/docs/database/er-design.md b/docs/database/er-design.md new file mode 100644 index 0000000..6d07c0f --- /dev/null +++ b/docs/database/er-design.md @@ -0,0 +1,3 @@ +# ER Design + +Pending fill-in based on the confirmed table structure. diff --git a/docs/deploy/anxinyan-api.service.example b/docs/deploy/anxinyan-api.service.example new file mode 100644 index 0000000..584e5db --- /dev/null +++ b/docs/deploy/anxinyan-api.service.example @@ -0,0 +1,17 @@ +[Unit] +Description=Anxinyan webman API service +After=network.target + +[Service] +Type=forking +WorkingDirectory=/www/wwwroot/anxinyan-api +ExecStart=/usr/bin/php /www/wwwroot/anxinyan-api/start.php start -d +ExecReload=/usr/bin/php /www/wwwroot/anxinyan-api/start.php reload -d +ExecStop=/usr/bin/php /www/wwwroot/anxinyan-api/start.php stop +Restart=always +RestartSec=3 +User=www +Group=www + +[Install] +WantedBy=multi-user.target diff --git a/docs/deploy/api.anxinjianyan.com.nginx.conf.example b/docs/deploy/api.anxinjianyan.com.nginx.conf.example new file mode 100644 index 0000000..49176cd --- /dev/null +++ b/docs/deploy/api.anxinjianyan.com.nginx.conf.example @@ -0,0 +1,21 @@ +server { + listen 80; + server_name api.anxinjianyan.com; + + client_max_body_size 50m; + + location / { + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_read_timeout 120s; + proxy_send_timeout 120s; + proxy_pass http://127.0.0.1:8787; + } +} + +# 如已配置 HTTPS,可在 443 server 中复用同样的 location 代理逻辑。 diff --git a/docs/deploy/backend-api-online-deploy.md b/docs/deploy/backend-api-online-deploy.md new file mode 100644 index 0000000..cbd036d --- /dev/null +++ b/docs/deploy/backend-api-online-deploy.md @@ -0,0 +1,157 @@ +# 安心验后端 API 线上部署说明 + +## 1. 当前部署目标 + +- API 域名:`api.anxinjianyan.com` +- 项目目录建议:`/www/wwwroot/anxinyan-api` +- 服务监听端口:`8787` + +当前后端基于 `webman`,推荐部署方式: + +1. 代码上传到服务器 +2. 使用 `php start.php start -d` 启动常驻进程 +3. 使用 `Nginx` 反向代理到 `127.0.0.1:8787` +4. 用 `systemd` 管理进程自启动 + +## 2. 本次已准备好的发布包 + +发布包路径: + +- [/Users/wushumin/www/biyou/anxinyan/releases/anxinyan-server-api-20260422.zip](/Users/wushumin/www/biyou/anxinyan/releases/anxinyan-server-api-20260422.zip) + +建议上传后解压到: + +```bash +mkdir -p /www/wwwroot/anxinyan-api +unzip anxinyan-server-api-20260422.zip -d /www/wwwroot/anxinyan-api +``` + +## 3. 服务器要求 + +- PHP 8.1+ +- 扩展: + - `pdo` + - `pdo_mysql` + - `pcntl` + - 建议 `opcache` +- MySQL 8+ +- Redis 6/7 +- `Nginx` +- `systemd` + +## 4. 部署步骤 + +### 4.1 上传并解压 + +```bash +cd /www/wwwroot +mkdir -p anxinyan-api +tar -xzf /path/to/anxinyan-server-api-20260422.tar.gz -C anxinyan-api +cd anxinyan-api +``` + +### 4.2 检查目录权限 + +```bash +mkdir -p runtime/logs storage/payment-certs +chmod -R 775 runtime storage +``` + +### 4.3 确认环境变量 + +发布包中已包含当前 `.env`,但上线前仍需人工确认: + +- `APP_ENV=production` +- `APP_DEBUG=false` +- 数据库连接是否为正式库 +- Redis 连接是否为正式实例 + +## 5. 启动命令 + +### 首次启动 + +```bash +cd /www/wwwroot/anxinyan-api +php start.php start -d +``` + +### 重载 + +```bash +cd /www/wwwroot/anxinyan-api +php start.php reload -d +``` + +### 停止 + +```bash +cd /www/wwwroot/anxinyan-api +php start.php stop +``` + +## 6. Nginx 配置 + +示例文件: + +- [api.anxinjianyan.com.nginx.conf.example](/Users/wushumin/www/biyou/anxinyan/docs/deploy/api.anxinjianyan.com.nginx.conf.example) + +核心逻辑: + +- 对外监听 `80/443` +- 反向代理到 `127.0.0.1:8787` +- 保留真实 IP、协议头和 Host + +## 7. systemd 配置 + +示例文件: + +- [anxinyan-api.service.example](/Users/wushumin/www/biyou/anxinyan/docs/deploy/anxinyan-api.service.example) + +放置路径建议: + +```bash +/etc/systemd/system/anxinyan-api.service +``` + +启用命令: + +```bash +systemctl daemon-reload +systemctl enable anxinyan-api +systemctl start anxinyan-api +systemctl status anxinyan-api +``` + +## 8. 上线后立即检查 + +### 接口联通 + +```bash +curl http://127.0.0.1:8787/ +curl https://api.anxinjianyan.com/ +``` + +### 冒烟验证 + +```bash +cd /www/wwwroot/anxinyan-api +php tools/smoke_check.php +php tools/release_audit.php +``` + +## 9. 当前仍需人工确认的阻塞项 + +- 小程序正式配置: + - `mini_program.app_id` + - `mini_program.app_secret` + - `mini_program.original_id` + +## 10. 特别说明 + +- 当前数据库中的支付证书路径仍是本机绝对路径,若线上要用微信支付,建议在服务器后台重新上传: + - `apiclient_key.pem` + - `apiclient_cert.pem` +- 后台系统配置中的支付回调地址已调整为: + - `https://api.anxinjianyan.com` +- H5 页面根地址已调整为: + - `https://m.anxinjianyan.com` diff --git a/docs/deploy/delivery-notes.md b/docs/deploy/delivery-notes.md new file mode 100644 index 0000000..0c7c16c --- /dev/null +++ b/docs/deploy/delivery-notes.md @@ -0,0 +1,138 @@ +# 安心验当前交付说明 + +## 1. 当前交付范围 + +本阶段已覆盖以下端与能力: + +- 用户端 H5 +- 用户端小程序共用代码 +- 管理后台 +- 履约主流程 +- 报告验真与下载 +- 用户消息通知 + +本阶段不纳入: + +- 上门鉴定复杂预约 +- 积分商城 +- 内容社区 +- 直播 / 短视频 +- 裂变分销 +- 官网 +- 商家后台 + +## 2. 已完成模块 + +### 用户端 + +- 登录 + - 手机号 + 密码 + - 手机号 + 验证码 +- 发起鉴定 + - 选择服务方式 + - 选择商品信息 + - 补充购买信息 + - 上传鉴定资料 + - 订单确认 +- 订单中心 + - 列表页 + - 详情页 + - 寄送页 + - 补料页 +- 报告中心 + - 报告列表 + - 报告详情 + - 报告验真 +- 消息中心 +- 工单中心 +- 地址管理 +- 我的 / 设置 + +### 后台 + +- 管理员登录 +- 订单中心 +- 鉴定作业台 +- 商品资料中心 +- 报告中心 +- 消息中心 +- 客服与售后 +- 用户管理 +- 仓库中心 +- 权限中心 +- 系统配置 + +## 3. 已完成履约链路 + +### 送检链路 + +- 下单确认时选择寄回地址 +- 订单创建后锁定送检仓库 +- 用户可在寄送前切换检测中心 +- 用户提交寄送运单 +- 后台标记鉴定中心签收 + +### 鉴定链路 + +- 鉴定 +- 补料发起 +- 用户补料 +- 报告生成 +- 报告发布 + +### 寄回链路 + +- 用户确认寄回地址 +- 后台登记回寄运单 +- 用户端查看回寄物流 +- 后台标记用户签收 +- 消息中心同步回寄与签收通知 + +## 4. 历史兼容处理 + +当前代码已兼容以下历史数据问题: + +- 老订单没有 `order_return_addresses` 快照时 + - 用户端订单详情会自动回退展示默认地址 + - 后台登记回寄运单时会自动用默认地址补写快照 +- 历史 `verify_qrcode_url` 不是图片链接时 + - 用户端报告详情会直接本地生成二维码 SVG +- 历史 `display_status` 与当前真实履约状态不一致时 + - 用户端和后台订单列表会优先按真实物流状态推导展示 + +## 5. 当前仍建议人工重点确认 + +- 老订单回寄状态是否符合实际业务预期 +- 订单列表中 `待寄回 / 物品已寄回 / 已完成` 的显示是否满足运营口径 +- 管理后台权限是否满足正式分工 +- 支付、短信、小程序正式配置是否完整 + +## 6. 当前已知上线前必须处理项 + +根据 `php tools/release_audit.php` 当前结果,当前仍剩以下未完成项: + +- 小程序正式配置仍为空: + - `mini_program.app_id` + - `mini_program.app_secret` + - `mini_program.original_id` + +已完成但需要运维知晓的变更: + +- 后端 `.env` 已切换为生产开关: + - `APP_ENV=production` + - `APP_DEBUG=false` +- 前端生产 API 域名已替换为正式域名 +- 测试管理员已停用 +- 默认超级管理员密码已旋转,不再使用初始密码 + +## 7. 建议的最终上线动作 + +1. 跑 `tools/release_audit.php` +2. 在后台补齐小程序正式配置 +3. 执行 `cd user-app && npm run sync:mp-config` +4. 再次运行 `tools/release_audit.php` +5. 修正审计结果中的剩余 `FAIL` +6. 跑 `tools/smoke_check.php` +7. 按 [fulfillment-smoke-checklist.md](/Users/wushumin/www/biyou/anxinyan/docs/deploy/fulfillment-smoke-checklist.md) 执行人工验收 +8. 清理测试数据 +9. 构建正式包并发布 diff --git a/docs/deploy/deploy-plan.md b/docs/deploy/deploy-plan.md new file mode 100644 index 0000000..c3b7a7f --- /dev/null +++ b/docs/deploy/deploy-plan.md @@ -0,0 +1,130 @@ +# 安心验部署说明 + +## 1. 项目结构 + +- `server-api` + 技术栈:PHP 8 + webman + MySQL + Redis +- `user-app` + 技术栈:uni-app + Vue 3 + TypeScript + 产物: + - H5 + - 微信小程序 +- `admin-web` + 技术栈:Vue 3 + Vite + TypeScript + Element Plus + +## 2. 当前已确认域名 + +- 用户端 H5:`m.anxinjianyan.com` +- 后端 API:`api.anxinjianyan.com` +- 管理后台:`admin.anxinjianyan.com` + +说明: + +- H5 页面根地址会用于生成报告页、验真页、扫码跳转链接 +- API 域名会用于 H5、后台、小程序请求 + +## 3. 本地常用命令 + +### 后端 + +```bash +cd /Users/wushumin/www/biyou/anxinyan/server-api +php start.php start -d +php start.php reload -d +php tools/smoke_check.php +php tools/release_audit.php +``` + +### 用户端 H5 + +```bash +cd /Users/wushumin/www/biyou/anxinyan/user-app +npm run dev:h5 +npm run type-check +npm run build:h5 +``` + +### 管理后台 + +```bash +cd /Users/wushumin/www/biyou/anxinyan/admin-web +npm run build +``` + +### 小程序配置同步 + +```bash +cd /Users/wushumin/www/biyou/anxinyan/user-app +npm run sync:mp-config +npm run build:mp-weixin +``` + +## 4. 部署顺序建议 + +1. 导入数据库结构与种子数据 +2. 执行 schema 升级脚本 +3. 配置后端 `.env` +4. 在后台完成系统配置 +5. 构建并部署 `admin-web` +6. 构建并部署 `user-app` H5 +7. 同步小程序 AppID 并构建小程序包 +8. 跑 `smoke_check.php` +9. 执行人工履约链路验收 + +## 5. 当前必须执行的 schema 升级 + +已存在脚本: + +- `php tools/schema_upgrade_warehouses.php` +- `php tools/schema_upgrade_order_shipping_targets.php` +- `php tools/schema_upgrade_order_return_flow.php` +- `php tools/schema_upgrade_manual_reports.php` +- `php tools/schema_upgrade_user_login_sms.php` + +建议在正式环境按上述顺序执行一次。 + +## 6. 后台必须配置的分组 + +- 小程序配置 +- H5 配置 +- 短信配置 +- 微信支付 / 商户平台配置 + +其中: + +- H5 根地址必须指向正式域名 +- 小程序 AppID 必须同步到 `manifest.json` +- 支付证书和商户密钥必须在后台上传或保存 + +## 7. 部署后必须验证的主链路 + +- 用户下单 +- 用户提交寄送运单 +- 后台标记鉴定中心签收 +- 后台发起补料 +- 用户补料 +- 后台发布报告 +- 用户确认寄回地址 +- 后台登记回寄运单 +- 后台标记用户签收 +- 用户报告验真 + +## 8. 当前状态 + +当前代码库已经具备: + +- 用户端主流程 +- 后台订单履约主流程 +- 多仓库 / 改派仓库 +- 补料任务 +- 报告发布与验真 +- 寄回地址确认 +- 回寄运单登记 +- 用户签收闭环 + +剩余工作更偏向: + +- 正式环境配置 +- 测试数据清理 +- 人工验收 +- 上线前口径确认 diff --git a/docs/deploy/fulfillment-smoke-checklist.md b/docs/deploy/fulfillment-smoke-checklist.md new file mode 100644 index 0000000..32275ae --- /dev/null +++ b/docs/deploy/fulfillment-smoke-checklist.md @@ -0,0 +1,113 @@ +# 安心验履约链路冒烟检查表 + +## 目标 + +确认用户端、后台、消息中心围绕以下主链路已经闭环: + +1. 用户下单 +2. 用户寄送商品并提交运单 +3. 鉴定中心签收 +4. 鉴定中 / 补料 +5. 报告出具 +6. 用户确认寄回地址 +7. 后台登记回寄运单 +8. 用户签收回寄商品 + +## 自动检查 + +先执行: + +```bash +cd /Users/wushumin/www/biyou/anxinyan/server-api +php tools/smoke_check.php +``` + +预期: + +- 输出 `SMOKE_OK` +- `app` 与 `admin` 关键接口全部通过 +- 报告详情接口包含 `verify_qrcode_url` + +## 人工检查 + +### 1. 新建订单 + +- 用户端发起一笔新订单 +- 在确认订单页必须可以选择“寄回地址” +- 未选择寄回地址时,不能提交订单 +- 订单创建成功后,订单详情应显示: + - 收货仓库 + - 寄回地址 + - 下单资料 + +### 2. 用户寄送 + +- 订单状态应为 `待寄送` 或 `已提交运单` +- 用户寄送页应只展示“寄往鉴定中心”物流 +- 提交运单后: + - 订单详情提示改为“等待鉴定中心签收” + - 订单列表状态应显示 `已提交运单` + +### 3. 鉴定中心签收 + +- 后台订单详情点击“标记鉴定中心签收” +- 订单状态应变为 `鉴定中心已收货` +- 用户端订单详情同步显示已签收 + +### 4. 补料 + +- 后台发起补料后: + - 用户端订单状态应显示 `等待您补充资料` + - 鉴定作业台任务状态应显示 `待用户补料` + - 不得出现 `已退回` 这类容易误解成回寄商品的文案 + +### 5. 报告出具 + +- 后台发布报告后: + - 用户端报告中心出现报告 + - 报告详情页显示验真二维码 + - 订单状态应显示 `待寄回` + - 消息中心收到“报告已出具” + +### 6. 用户确认寄回地址 + +- 订单详情页 `寄回给您` 区块应显示地址 +- 老订单若没有寄回快照,应自动回退显示用户默认地址 +- 用户可在回寄前修改寄回地址 + +### 7. 后台登记回寄运单 + +- 后台订单详情点击“登记回寄运单” +- 若订单没有寄回地址快照,但用户有默认地址,应能自动补写后继续登记 +- 登记成功后: + - 订单状态应显示 `物品已寄回` + - 用户端订单详情显示回寄物流 + - 消息中心收到“鉴定物品已寄回” + +### 8. 标记用户签收 + +- 后台订单详情点击“标记用户签收” +- 完成后: + - 订单状态应显示 `已完成` + - 回寄物流状态应为 `用户已签收` + - 消息中心收到“回寄商品已签收” + - 用户端订单详情应提示本次订单已完成 + +## 当前已实现的关键点 + +- 下单确认时选择寄回地址 +- 订单详情展示寄回地址与回寄物流 +- 后台登记回寄运单 +- 后台标记用户签收 +- 回寄消息通知联动 +- 用户端与后台订单状态口径统一 +- 报告详情二维码显示 + +## 仍建议重点人工确认 + +- 历史老订单在没有寄回地址快照时的展示是否符合预期 +- `completed` 状态下,列表页是否准确区分: + - 物品已寄回 + - 已完成 +- 用户消息中心的回寄通知点击跳转是否总是进入正确订单 +- 报告页二维码在 H5 与小程序环境下都能正常扫描 diff --git a/docs/deploy/release-checklist.md b/docs/deploy/release-checklist.md new file mode 100644 index 0000000..a2115da --- /dev/null +++ b/docs/deploy/release-checklist.md @@ -0,0 +1,67 @@ +# 安心验上线检查清单 + +## 1. 环境变量 +- 替换 [server-api/.env.example](/Users/wushumin/www/biyou/anxinyan/server-api/.env.example) 中的数据库、Redis 等占位值 +- 确认 `APP_ENV=production` +- 确认 `APP_DEBUG=false` +- 确认 [admin-web/.env.production](/Users/wushumin/www/biyou/anxinyan/admin-web/.env.production) 与 [user-app/.env.production](/Users/wushumin/www/biyou/anxinyan/user-app/.env.production) 指向正式 API 域名,而不是 `localhost / 127.0.0.1 / example.com` + +## 2. 后台系统配置 +- 在后台 `系统配置` 中填写并保存: + - 小程序 `AppID / AppSecret / 原始ID` + - H5 `AppID / AppSecret / OAuth 回调地址 / H5 页面根地址` + - 短信 `阿里云 AccessKey ID / AccessKey Secret / 短信签名 / 登录模板 Code / Region ID` + - 支付 `MchID / APIv3 Key / 商户证书序列号 / 商户私钥 / 平台证书序列号 / 支付回调地址` +- 严禁保留演示值: + - `wx1234567890test` + - `h5_app_demo` + - `1900000109` + - `demo_api_v3_key_1234567890` + +## 3. 管理后台安全 +- 修改默认超级管理员密码: + - `13800138000 / Admin@123456` +- 删除或停用测试管理员: + - `13800138001 / Test@123456` +- 按实际运营需要分配角色与权限 + +## 4. 业务数据清理 +- 清理测试工单、测试订单、测试物流、测试消息 +- 清理 `server-api/public/uploads/` 下测试图片和 PDF +- 确认用户昵称、地址等演示数据已替换或清空 + +## 5. 构建与回归 +- 后端执行: + - `php tools/smoke_check.php` +- 前端执行: + - `cd user-app && npm run type-check` + - `cd user-app && npm run build:h5` + - `cd admin-web && npm run build` +- 核验关键链路: + - 用户端下单 -> 提交运单 -> 补资料 -> 报告 -> 验真 + - 用户工单 -> 客服回复 -> 消息提醒 + - 后台登录 -> 权限控制 -> 系统配置保存 + +## 6. 微信相关 +- 在后台 `系统配置` 保存正式小程序 `AppID` 后,执行: + - `cd user-app && npm run sync:mp-config` +- 再执行: + - `cd user-app && npm run build:mp-weixin` +- 构建前确认 [user-app/src/manifest.json](/Users/wushumin/www/biyou/anxinyan/user-app/src/manifest.json) 中 `mp-weixin.appid` 已同步为正式值 +- 确认后台 `H5 页面根地址` 指向正式 H5 域名,例如 `https://m.example.com`,用于生成扫码查看报告和验真页链接 +- H5 授权域名、支付域名、回调域名已在微信平台完成配置 +- 微信支付商户平台证书与 APIv3 Key 已完成正式部署 + +## 7. 短信登录 +- 在后台 `系统配置 -> 短信配置` 中填写阿里云短信参数 +- 确认短信签名与登录模板已在阿里云短信服务中审核通过 +- 确认登录模板包含 `code` 变量 +- 正式环境下验证: + - 非微信浏览器 H5 可通过 `手机号 + 验证码` 登录 + - 已设置密码的账号可通过 `手机号 + 密码` 登录 + +## 8. 发布前建议 +- 先跑一遍 `tools/release_audit.php` +- 如需打包小程序,先跑一遍 `npm run sync:mp-config` +- 审核巡检输出中的 `FAIL / WARN` +- 完成替换后再做最终上线 diff --git a/docs/flow/state-machine.md b/docs/flow/state-machine.md new file mode 100644 index 0000000..d7d55e0 --- /dev/null +++ b/docs/flow/state-machine.md @@ -0,0 +1,126 @@ +# 安心验履约状态机 + +## 1. 订单主状态 + +### 用户提交前 + +- `pending_payment` + 说明:订单待支付,当前项目里基本不作为主流履约状态使用。 +- `pending_submission` + 说明:待补充下单资料,尚未进入正式送检流转。 + +### 用户寄送阶段 + +- `pending_shipping` + 说明:订单已创建,等待用户寄送商品到鉴定中心。 + 典型展示: + - 未填运单:`待寄送商品` + - 已填运单:`已提交运单` +- `received` + 说明:鉴定中心已签收商品,等待进入鉴定处理。 + +### 鉴定阶段 + +- `in_first_review` + 说明:鉴定处理中。 +- `pending_supplement` + 说明:鉴定师发起补料,等待用户补交资料。 +- `generating_report` + 说明:已完成鉴定,正在生成报告。 + +### 报告与寄回阶段 + +- `report_published` + 说明:报告已发布,等待平台安排寄回商品。 + 典型展示:`待寄回` +- `completed` + 说明:订单已完成。 + 注意:`completed` 下根据回寄物流再细分展示: + - 已登记回寄运单但用户未签收:`物品已寄回` + - 用户已签收回寄商品:`已完成` + +## 2. 鉴定任务状态 + +### 任务阶段 + +- `first_review` + 说明:鉴定任务 + +### 任务状态值 + +- `pending` + 对外文案:`待处理` +- `processing` + 对外文案:`处理中` +- `returned` + 对外文案:`待用户补料` + 注意:这是任务被打回补料,不是货品寄回用户。 +- `submitted` + 对外文案:`已提交` +- `completed` + 对外文案:`已完成` + +## 3. 物流状态 + +### 物流类型 + +- `send_to_center` + 说明:用户寄送到鉴定中心 +- `return_to_user` + 说明:平台回寄给用户 + +### 物流节点状态 + +#### 用户寄送物流 + +- `submitted` + 文案:`已提交运单` +- `in_transit` + 文案:`运输中` +- `received` + 文案:`已签收` + +#### 回寄物流 + +- `submitted` + 文案:`已登记回寄运单` +- `in_transit` + 文案:`回寄途中` +- `received` + 文案:`用户已签收` + +## 4. 关键状态迁移 + +### 下单到鉴定 + +1. 用户创建订单 +2. 订单进入 `pending_shipping` +3. 用户提交寄送运单 +4. 后台标记鉴定中心签收 +5. 订单进入 `received` +6. 鉴定任务进入 `processing` + +### 补料分支 + +1. 鉴定师发起补料 +2. 当前任务状态改为 `returned` +3. 订单状态改为 `pending_supplement` +4. 用户补料完成后,订单重新进入 `in_first_review` + +### 出报告到寄回 + +1. 后台发布报告 +2. 订单进入 `report_published` +3. 用户确认寄回地址 +4. 后台登记回寄运单 +5. 订单进入 `completed` +6. 若回寄物流未签收,对外显示 `物品已寄回` +7. 后台标记用户签收后,对外显示 `已完成` + +## 5. 当前关键口径 + +- “补料”只能表示资料补充,不得使用“退回”对外表达。 +- “待寄回”表示报告已出具但平台尚未登记回寄运单。 +- “物品已寄回”表示平台已登记回寄运单,但用户尚未签收。 +- “已完成”只用于回寄商品已签收,或无需回寄的最终完成态。 +- 订单报告未发布前,不允许登记回寄运单或安排物品寄回。 diff --git a/docs/prd/mvp-prd.md b/docs/prd/mvp-prd.md new file mode 100644 index 0000000..d720c4b --- /dev/null +++ b/docs/prd/mvp-prd.md @@ -0,0 +1,3 @@ +# MVP PRD + +Pending fill-in based on the confirmed product scope. diff --git a/docs/static-data-audit-20260423.md b/docs/static-data-audit-20260423.md new file mode 100644 index 0000000..e63c172 --- /dev/null +++ b/docs/static-data-audit-20260423.md @@ -0,0 +1,226 @@ +# 安心验静态数据与展示映射审计 + +审计时间:2026-04-23 + +## 一、已确认的真实问题 + +### 1. 后台订单详情页存在“接口有值但页面没展示完整”的问题 + +- 文件:`admin-web/src/pages/orders/index.vue` +- 现象: + - 接口已返回 `product_info.color` + - 接口已返回 `extra_info.condition_desc` / `remark` + - 页面原先只展示了 `size_spec` 和 `usage_status` + - 导致后台看到的内容与接口实际内容不一致,且卡片出现大面积空白 +- 本轮已修复: + - 商品信息区补充 `颜色 / 规格` + - 补充信息区补充 `补充说明` + +### 2. 鉴定作业台也存在同类字段遗漏 + +- 文件:`admin-web/src/pages/appraisal-tasks/index.vue` +- 现象: + - 页面已有 `product_info.color` + - 但工作区与上下文区未展示,导致检测侧看到的信息不完整 +- 本轮已修复: + - 商品与送检区补充 `颜色 / 规格` + - 工作台左侧上下文区补充 `颜色 / 规格` + +### 3. 用户端多个页面在接口失败时会静默回退到 mock 数据 + +- 风险级别:高 +- 问题本质: + - 页面请求失败后,没有给用户明确错误态 + - 而是继续显示 `src/mocks/app.ts` 中的示例数据 + - 会造成“接口已经失败,但页面仍像有真实数据”的错觉 +- 这类问题比普通静态文案更危险,因为会直接影响业务判断 + +## 二、前端仍在使用 mock / fallback 的页面 + +### 1. 用户端高风险页面 + +以下页面在请求失败时,会直接沿用 mock 或 fallback 数据: + +- `user-app/src/pages/home/index.vue` +- `user-app/src/pages/address/index.vue` +- `user-app/src/pages/message/index.vue` +- `user-app/src/pages/mine/index.vue` +- `user-app/src/pages/order/detail.vue` +- `user-app/src/pages/order/shipping.vue` +- `user-app/src/pages/report/detail.vue` +- `user-app/src/pages/help/index.vue` +- `user-app/src/pages/help/detail.vue` +- `user-app/src/pages/settings/index.vue` +- `user-app/src/pages/support/index.vue` +- `user-app/src/pages/support/detail.vue` +- `user-app/src/pages/support/create.vue` +- `user-app/src/pages/verify/result.vue` + +统一来源: + +- `user-app/src/mocks/app.ts` + +### 2. 风险说明 + +- 订单、报告、消息、地址、工单、验真都属于真实业务数据 +- 这些页面不适合在接口失败时展示示例数据 +- 正确策略应该是: + - 显示错误态 + - 提供重试按钮 + - 保留空态,但不能伪造业务内容 + +## 三、后端接口本身仍是静态拼装的数据 + +### 1. 首页接口 + +- 文件:`server-api/app/controller/app/HomeController.php` +- 当前静态内容: + - `banners` + - `service_entries` + - `quick_entries` + - `trust_metrics` + - `trust_points` + - `faqs` +- 当前只有 `category_entries` 来自数据库 + +补充说明: + +- `user-app/src/pages/home/index.vue` 原先没有消费 `banners` +- 即使接口已经返回首页头图文案,前端页面也仍然使用写死标题 +- 本轮已修复为直接读取接口 `banners[0]` + +### 2. 帮助中心接口 + +- 文件:`server-api/app/controller/app/HelpCenterController.php` +- 当前问题: + - 帮助分类与文章内容全部写在控制器里 + - 搜索和分类筛选只是对本地数组做过滤 +- 结论: + - 当前帮助中心已经“可用”,但本质仍是写死内容,不是后台可运营内容 + +### 3. 设置页协议入口 + +- 文件:`server-api/app/controller/app/SettingsController.php` +- 当前静态内容: + - `legal_entries` + - 跳转的帮助中心关键词 + +### 4. 下单预览协议文案 + +- 文件:`server-api/app/controller/app/AppraisalController.php` +- 当前静态内容: + - `preview()` 中的 `agreements` + - `service_agreement` + - `privacy_policy` + - `appraisal_notice` + +## 四、前端仍然硬编码的业务选项 + +这些不一定是 bug,但如果要达到“后台可维护、线上可运营”的标准,后续建议逐步改成可配置或接口下发。 + +### 1. 用户端 + +- `user-app/src/pages/support/create.vue` + - 工单类型 `typeOptions` +- `user-app/src/pages/support/index.vue` + - 常见问题快捷入口 `quickTypes` +- `user-app/src/pages/appraisal/product.vue` + - 颜色建议 `colorSuggestions` + - 规格建议 `sizeSuggestions` +- `user-app/src/pages/appraisal/extra.vue` + - 购买渠道 + - 使用情况 + - 附件情况 + +### 2. 管理后台 + +- `admin-web/src/pages/orders/index.vue` + - 服务筛选项 + - 状态筛选项 +- `admin-web/src/pages/appraisal-tasks/index.vue` + - 阶段筛选项 + - 状态筛选项 + - 结果选项 +- `admin-web/src/pages/reports/index.vue` + - 服务筛选项 + - 报告状态项 + - 结果项 +- `admin-web/src/pages/tickets/index.vue` + - 工单类型与状态筛选 + +说明: + +- 这类枚举型选项短期内可以先保留 +- 但工单类型、结果模板、服务文案如果未来需要运营调整,建议迁移为后台字典表或系统配置 + +## 五、已发现的“假入口 / 假交互” + +- `user-app/src/pages/appraisal/upload.vue` + - `查看示例` 按钮无实际功能 + - `查看拍摄示例` 入口无实际功能 +- `user-app/src/pages/home/index.vue` + - 未识别的快捷入口会落到“该功能正在完善中” +- `user-app/src/pages/mine/index.vue` + - 未识别入口也会落到“该功能正在完善中” + +## 六、推荐开发顺序 + +### P0:先清掉会误导真实业务判断的 fallback + +优先处理以下页面: + +- 订单详情 +- 报告详情 +- 消息中心 +- 地址管理 +- 工单列表 / 工单详情 +- 设置页 +- 验真页 + +目标: + +- 失败时显示明确错误状态,不再显示 mock 业务数据 + +### P1:把接口里已返回但页面没展示完整的字段补齐 + +优先处理以下模块: + +- 后台订单详情 +- 鉴定作业台 +- 用户端订单详情 +- 报告详情页的商品摘要 / 估值摘要一致性 + +目标: + +- 页面展示与接口字段保持一致 +- 同一份订单数据在用户端、后台、报告端口径一致 + +### P2:把后端静态内容迁移为可维护内容 + +优先处理: + +- 首页内容 +- 帮助中心 +- 设置页协议入口 +- 下单预览协议区 + +推荐做法: + +- 新建内容配置表 / 帮助中心表 / 协议配置表 +- 后台增加内容维护入口 +- 用户端改为读取接口配置 + +### P3:把前端硬编码枚举逐步改为字典化 + +优先处理: + +- 工单类型 +- 鉴定结果模板 +- 用户端下单辅助枚举 + +## 七、建议的下一步落地动作 + +1. 先移除用户端业务页面对 `user-app/src/mocks/app.ts` 的依赖,改为错误态 + 重试。 +2. 统一订单 / 报告 / 鉴定任务三端字段口径,补齐 `color`、`condition_desc`、`remark`、`accessories` 等实际字段。 +3. 设计内容配置模型,把首页、帮助中心、协议说明迁到后台管理。 +4. 最后再做字典化,把工单类型、辅助选项、结果模板迁到接口配置。 diff --git a/logo.jpeg b/logo.jpeg new file mode 100644 index 0000000..ccedfad Binary files /dev/null and b/logo.jpeg differ diff --git a/releases/anxinyan-admin-web-admin.anxinjianyan.com-20260424.zip b/releases/anxinyan-admin-web-admin.anxinjianyan.com-20260424.zip new file mode 100644 index 0000000..37e9532 Binary files /dev/null and b/releases/anxinyan-admin-web-admin.anxinjianyan.com-20260424.zip differ diff --git a/releases/anxinyan-admin-web-admin.anxinjianyan.com-20260504.zip b/releases/anxinyan-admin-web-admin.anxinjianyan.com-20260504.zip new file mode 100644 index 0000000..dacc021 Binary files /dev/null and b/releases/anxinyan-admin-web-admin.anxinjianyan.com-20260504.zip differ diff --git a/releases/anxinyan-admin-web-admin.anxinjianyan.com-20260505.zip b/releases/anxinyan-admin-web-admin.anxinjianyan.com-20260505.zip new file mode 100644 index 0000000..f5aeea5 Binary files /dev/null and b/releases/anxinyan-admin-web-admin.anxinjianyan.com-20260505.zip differ diff --git a/releases/anxinyan-admin-web-admin.anxinjianyan.com-20260509.zip b/releases/anxinyan-admin-web-admin.anxinjianyan.com-20260509.zip new file mode 100644 index 0000000..53658a4 Binary files /dev/null and b/releases/anxinyan-admin-web-admin.anxinjianyan.com-20260509.zip differ diff --git a/releases/anxinyan-docs-20260509.zip b/releases/anxinyan-docs-20260509.zip new file mode 100644 index 0000000..669a9f2 Binary files /dev/null and b/releases/anxinyan-docs-20260509.zip differ diff --git a/releases/anxinyan-server-api-20260424.zip b/releases/anxinyan-server-api-20260424.zip new file mode 100644 index 0000000..2332fa1 Binary files /dev/null and b/releases/anxinyan-server-api-20260424.zip differ diff --git a/releases/anxinyan-server-api-20260504.zip b/releases/anxinyan-server-api-20260504.zip new file mode 100644 index 0000000..4a0f840 Binary files /dev/null and b/releases/anxinyan-server-api-20260504.zip differ diff --git a/releases/anxinyan-server-api-20260505.zip b/releases/anxinyan-server-api-20260505.zip new file mode 100644 index 0000000..70f4bb9 Binary files /dev/null and b/releases/anxinyan-server-api-20260505.zip differ diff --git a/releases/anxinyan-server-api-20260509.zip b/releases/anxinyan-server-api-20260509.zip new file mode 100644 index 0000000..f6da724 Binary files /dev/null and b/releases/anxinyan-server-api-20260509.zip differ diff --git a/releases/anxinyan-user-h5-m.anxinjianyan.com-20260424.zip b/releases/anxinyan-user-h5-m.anxinjianyan.com-20260424.zip new file mode 100644 index 0000000..e12f4be Binary files /dev/null and b/releases/anxinyan-user-h5-m.anxinjianyan.com-20260424.zip differ diff --git a/releases/anxinyan-user-h5-m.anxinjianyan.com-20260504.zip b/releases/anxinyan-user-h5-m.anxinjianyan.com-20260504.zip new file mode 100644 index 0000000..c3708f9 Binary files /dev/null and b/releases/anxinyan-user-h5-m.anxinjianyan.com-20260504.zip differ diff --git a/releases/anxinyan-user-h5-m.anxinjianyan.com-20260505.zip b/releases/anxinyan-user-h5-m.anxinjianyan.com-20260505.zip new file mode 100644 index 0000000..3af7856 Binary files /dev/null and b/releases/anxinyan-user-h5-m.anxinjianyan.com-20260505.zip differ diff --git a/releases/anxinyan-user-h5-m.anxinjianyan.com-20260509.zip b/releases/anxinyan-user-h5-m.anxinjianyan.com-20260509.zip new file mode 100644 index 0000000..b3b08b8 Binary files /dev/null and b/releases/anxinyan-user-h5-m.anxinjianyan.com-20260509.zip differ diff --git a/server-api/.env.example b/server-api/.env.example new file mode 100644 index 0000000..185e4b0 --- /dev/null +++ b/server-api/.env.example @@ -0,0 +1,17 @@ +APP_ENV=production +APP_DEBUG=false +PUBLIC_FILE_BASE_URL= + +DB_HOST=127.0.0.1 +DB_PORT=3306 +DB_DATABASE=anxinyan +DB_USERNAME=root +DB_PASSWORD=change_me +DB_CHARSET=utf8mb4 +DB_PREFIX= + +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD= +REDIS_DB=0 +REDIS_PREFIX=anxinyan: diff --git a/server-api/.gitignore b/server-api/.gitignore new file mode 100644 index 0000000..516299c --- /dev/null +++ b/server-api/.gitignore @@ -0,0 +1,8 @@ +/runtime +/.idea +/.vscode +/vendor +*.log +.env +/tests/tmp +/tests/.phpunit.result.cache diff --git a/server-api/Dockerfile b/server-api/Dockerfile new file mode 100644 index 0000000..94ce154 --- /dev/null +++ b/server-api/Dockerfile @@ -0,0 +1,18 @@ +FROM php:8.3.22-cli-alpine + +RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" + +RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ + && apk update --no-cache \ + && docker-php-source extract + +# install extensions +RUN docker-php-ext-install pdo pdo_mysql -j$(nproc) pcntl + +# enable opcache and pcntl +RUN docker-php-ext-enable opcache pcntl +RUN docker-php-source delete \ + rm -rf /var/cache/apk/* + +RUN mkdir -p /app +WORKDIR /app \ No newline at end of file diff --git a/server-api/LICENSE b/server-api/LICENSE new file mode 100644 index 0000000..2c66292 --- /dev/null +++ b/server-api/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 walkor and contributors (see https://github.com/walkor/webman/contributors) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/server-api/README.md b/server-api/README.md new file mode 100644 index 0000000..4031784 --- /dev/null +++ b/server-api/README.md @@ -0,0 +1,70 @@ +
+

webman

+ +基于workerman开发的超高性能PHP框架 + + +

学习

+ + + +
+ +

赞助商

+ +

特别赞助

+ + + + +

铂金赞助

+ + + + +
+ + +
+ +

请作者喝咖啡

+ + + +
+如果您觉得webman对您有所帮助,欢迎捐赠。 + + +
+ + +
+

LICENSE

+The webman is open-sourced software licensed under the MIT. +
+ +
+ + diff --git a/server-api/app/bootstrap/Dotenv.php b/server-api/app/bootstrap/Dotenv.php new file mode 100644 index 0000000..8b465c7 --- /dev/null +++ b/server-api/app/bootstrap/Dotenv.php @@ -0,0 +1,22 @@ +safeLoad(); + self::$loaded = true; + } +} diff --git a/server-api/app/controller/IndexController.php b/server-api/app/controller/IndexController.php new file mode 100644 index 0000000..396ea80 --- /dev/null +++ b/server-api/app/controller/IndexController.php @@ -0,0 +1,42 @@ + + * { + padding: 0; + margin: 0; + } + iframe { + border: none; + overflow: scroll; + } + + +EOF; + } + + public function view(Request $request) + { + return view('index/view', ['name' => 'webman']); + } + + public function json(Request $request) + { + return json(['code' => 0, 'msg' => 'ok']); + } + +} diff --git a/server-api/app/controller/admin/AccessController.php b/server-api/app/controller/admin/AccessController.php new file mode 100644 index 0000000..17ea4f7 --- /dev/null +++ b/server-api/app/controller/admin/AccessController.php @@ -0,0 +1,296 @@ +accessService()->bootstrapDefaults(); + + return api_success([ + 'cards' => [ + [ + 'title' => '管理员数量', + 'value' => (int)Db::name('admin_users')->count(), + 'desc' => '当前后台管理员账号总数', + ], + [ + 'title' => '启用角色', + 'value' => (int)Db::name('admin_roles')->where('status', 'enabled')->count(), + 'desc' => '当前启用中的角色数量', + ], + [ + 'title' => '权限点', + 'value' => (int)Db::name('admin_permissions')->count(), + 'desc' => '后台模块当前可分配的权限点数量', + ], + [ + 'title' => '角色授权', + 'value' => (int)Db::name('admin_role_permissions')->count(), + 'desc' => '角色与权限的关联配置总数', + ], + ], + ]); + } + + public function admins(Request $request) + { + $this->accessService()->bootstrapDefaults(); + + $rows = Db::name('admin_users') + ->order('id', 'desc') + ->select() + ->toArray(); + + $list = array_map(function (array $item) { + $roleIds = Db::name('admin_role_relations')->where('admin_user_id', $item['id'])->column('role_id'); + $roles = $roleIds + ? Db::name('admin_roles')->whereIn('id', $roleIds)->column('name') + : []; + + return [ + 'id' => (int)$item['id'], + 'name' => $item['name'], + 'mobile' => $item['mobile'], + 'email' => $item['email'], + 'status' => $item['status'], + 'status_text' => $this->accessService()->statusText($item['status']), + 'role_ids' => array_map('intval', $roleIds), + 'role_names' => array_values($roles), + 'last_login_at' => $item['last_login_at'], + 'created_at' => $item['created_at'], + ]; + }, $rows); + + return api_success(['list' => $list]); + } + + public function roles(Request $request) + { + $this->accessService()->bootstrapDefaults(); + + $rows = Db::name('admin_roles') + ->order('id', 'asc') + ->select() + ->toArray(); + + $list = array_map(function (array $item) { + $permissionIds = Db::name('admin_role_permissions')->where('role_id', $item['id'])->column('permission_id'); + $permissions = $permissionIds + ? Db::name('admin_permissions')->whereIn('id', $permissionIds)->column('name') + : []; + + return [ + 'id' => (int)$item['id'], + 'name' => $item['name'], + 'code' => $item['code'], + 'status' => $item['status'], + 'status_text' => $this->accessService()->statusText($item['status']), + 'permission_ids' => array_map('intval', $permissionIds), + 'permission_names' => array_values($permissions), + 'admin_count' => (int)Db::name('admin_role_relations')->where('role_id', $item['id'])->count(), + 'created_at' => $item['created_at'], + ]; + }, $rows); + + return api_success(['list' => $list]); + } + + public function permissions(Request $request) + { + $this->accessService()->bootstrapDefaults(); + + $rows = Db::name('admin_permissions') + ->order('module', 'asc') + ->order('id', 'asc') + ->select() + ->toArray(); + + return api_success([ + 'list' => array_map(fn (array $item) => [ + 'id' => (int)$item['id'], + 'name' => $item['name'], + 'code' => $item['code'], + 'module' => $item['module'], + 'action' => $item['action'], + 'module_text' => $this->accessService()->moduleText($item['module']), + ], $rows), + ]); + } + + public function saveAdmin(Request $request) + { + $this->accessService()->bootstrapDefaults(); + + $id = (int)$request->input('id', 0); + $name = trim((string)$request->input('name', '')); + $mobile = trim((string)$request->input('mobile', '')); + $email = trim((string)$request->input('email', '')); + $password = trim((string)$request->input('password', '')); + $status = trim((string)$request->input('status', 'enabled')); + $roleIds = $this->normalizeIds((array)$request->input('role_ids', [])); + + if ($name === '' || $mobile === '') { + return api_error('管理员姓名和手机号不能为空', 422); + } + + $now = date('Y-m-d H:i:s'); + + Db::startTrans(); + try { + if ($id > 0) { + $admin = Db::name('admin_users')->where('id', $id)->find(); + if (!$admin) { + Db::rollback(); + return api_error('管理员不存在', 404); + } + + $exists = Db::name('admin_users') + ->where('mobile', $mobile) + ->where('id', '<>', $id) + ->find(); + if ($exists) { + Db::rollback(); + return api_error('管理员手机号已存在', 422); + } + + Db::name('admin_users')->where('id', $id)->update([ + 'name' => $name, + 'mobile' => $mobile, + 'email' => $email, + 'password' => $password !== '' ? password_hash($password, PASSWORD_BCRYPT) : $admin['password'], + 'status' => $status !== '' ? $status : 'enabled', + 'updated_at' => $now, + ]); + $adminId = $id; + } else { + $exists = Db::name('admin_users')->where('mobile', $mobile)->find(); + if ($exists) { + Db::rollback(); + return api_error('管理员手机号已存在', 422); + } + + $adminId = (int)Db::name('admin_users')->insertGetId([ + 'name' => $name, + 'mobile' => $mobile, + 'email' => $email, + 'password' => password_hash($password !== '' ? $password : 'Admin@123456', PASSWORD_BCRYPT), + 'status' => $status !== '' ? $status : 'enabled', + 'last_login_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + Db::name('admin_role_relations')->where('admin_user_id', $adminId)->delete(); + foreach ($roleIds as $roleId) { + Db::name('admin_role_relations')->insert([ + 'admin_user_id' => $adminId, + 'role_id' => $roleId, + 'created_at' => $now, + ]); + } + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('管理员保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success(['id' => $adminId], '管理员已保存'); + } + + public function saveRole(Request $request) + { + $this->accessService()->bootstrapDefaults(); + + $id = (int)$request->input('id', 0); + $name = trim((string)$request->input('name', '')); + $code = trim((string)$request->input('code', '')); + $status = trim((string)$request->input('status', 'enabled')); + $permissionIds = $this->normalizeIds((array)$request->input('permission_ids', [])); + + if ($name === '' || $code === '') { + return api_error('角色名称和编码不能为空', 422); + } + + $now = date('Y-m-d H:i:s'); + + Db::startTrans(); + try { + if ($id > 0) { + $role = Db::name('admin_roles')->where('id', $id)->find(); + if (!$role) { + Db::rollback(); + return api_error('角色不存在', 404); + } + + $exists = Db::name('admin_roles') + ->where('code', $code) + ->where('id', '<>', $id) + ->find(); + if ($exists) { + Db::rollback(); + return api_error('角色编码已存在', 422); + } + + Db::name('admin_roles')->where('id', $id)->update([ + 'name' => $name, + 'code' => $code, + 'status' => $status !== '' ? $status : 'enabled', + 'updated_at' => $now, + ]); + $roleId = $id; + } else { + $exists = Db::name('admin_roles')->where('code', $code)->find(); + if ($exists) { + Db::rollback(); + return api_error('角色编码已存在', 422); + } + + $roleId = (int)Db::name('admin_roles')->insertGetId([ + 'name' => $name, + 'code' => $code, + 'status' => $status !== '' ? $status : 'enabled', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + Db::name('admin_role_permissions')->where('role_id', $roleId)->delete(); + foreach ($permissionIds as $permissionId) { + Db::name('admin_role_permissions')->insert([ + 'role_id' => $roleId, + 'permission_id' => $permissionId, + 'created_at' => $now, + ]); + } + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('角色保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success(['id' => $roleId], '角色已保存'); + } + + private function normalizeIds(array $values): array + { + return array_values(array_unique(array_filter(array_map('intval', $values), fn (int $item) => $item > 0))); + } + + private function accessService(): AdminAccessService + { + return new AdminAccessService(); + } +} diff --git a/server-api/app/controller/admin/AppraisalTasksController.php b/server-api/app/controller/admin/AppraisalTasksController.php new file mode 100644 index 0000000..852cf0b --- /dev/null +++ b/server-api/app/controller/admin/AppraisalTasksController.php @@ -0,0 +1,1614 @@ +input('keyword', '')); + $taskStage = trim((string)$request->input('task_stage', '')); + $status = trim((string)$request->input('status', '')); + $serviceProvider = trim((string)$request->input('service_provider', '')); + + $query = $this->buildTaskBaseQuery() + ->whereRaw($this->workbenchVisibleOrderStatusSql()); + + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->whereRaw( + '(o.order_no LIKE :keyword_order OR o.appraisal_no LIKE :keyword_appraisal OR ecr.external_order_no LIKE :keyword_external OR p.product_name LIKE :keyword_product)', + [ + 'keyword_order' => "%{$keyword}%", + 'keyword_appraisal' => "%{$keyword}%", + 'keyword_external' => "%{$keyword}%", + 'keyword_product' => "%{$keyword}%", + ] + ); + }); + } + + if ($taskStage !== '') { + $query->where('t.task_stage', $taskStage); + } + if ($status !== '') { + $query->where('t.status', $status); + } + if ($serviceProvider !== '') { + $query->where('o.service_provider', $serviceProvider); + } + + $matchedRows = $query->select()->toArray(); + if (!$matchedRows) { + return api_success(['list' => []]); + } + + $orderIds = array_values(array_unique(array_map(fn (array $item) => (int)$item['order_id'], $matchedRows))); + $reportMap = $this->buildAppraisalReportMap($orderIds); + + $allRows = $this->buildTaskBaseQuery() + ->whereRaw($this->workbenchVisibleOrderStatusSql()) + ->whereIn('t.order_id', $orderIds) + ->order('t.order_id', 'desc') + ->order('t.id', 'desc') + ->select() + ->toArray(); + + $list = $this->buildGroupedTaskList($allRows, $reportMap); + + return api_success(['list' => $list]); + } + + public function detail(Request $request) + { + $id = (int)$request->input('id', 0); + if (!$id) { + return api_error('任务 ID 不能为空', 422); + } + + $task = Db::name('appraisal_tasks') + ->alias('t') + ->leftJoin('orders o', 'o.id = t.order_id') + ->leftJoin('order_products p', 'p.order_id = t.order_id') + ->leftJoin('order_extras e', 'e.order_id = t.order_id') + ->leftJoin('appraisal_task_results r', 'r.task_id = t.id') + ->leftJoin('enterprise_customer_order_refs ecr', 'ecr.order_id = t.order_id') + ->field([ + 't.id', + 't.order_id', + 't.task_stage', + 't.status', + 't.assignee_id', + 't.assignee_name', + 't.started_at', + 't.submitted_at', + 't.sla_deadline', + 't.is_overtime', + 'o.order_no', + 'o.appraisal_no', + 'ecr.external_order_no', + 'o.service_provider', + 'o.order_status', + 'o.display_status', + 'p.product_name', + 'p.category_id', + 'p.category_name', + 'p.brand_id', + 'p.brand_name', + 'p.color', + 'p.size_spec', + 'p.serial_no', + 'e.purchase_channel', + 'e.purchase_price', + 'e.usage_status', + 'e.condition_desc as extra_condition_desc', + 'e.remark', + 'r.id as result_id', + 'r.result_text', + 'r.result_desc', + 'r.condition_grade', + 'r.condition_desc as result_condition_desc', + 'r.valuation_min', + 'r.valuation_max', + 'r.valuation_desc as result_valuation_desc', + 'r.attachments_json as result_attachments_json', + 'r.external_remark', + 'r.internal_remark', + ]) + ->where('t.id', $id) + ->find(); + + if (!$task) { + return api_error('任务不存在', 404); + } + + $report = $this->findLatestAppraisalReport((int)$task['order_id']); + $materialTag = $report ? (new MaterialTagService())->findBoundTagForReport((int)$report['id']) : null; + $effectiveStatus = $this->effectiveTaskStatus($task, $report); + if ($effectiveStatus !== $task['status']) { + Db::name('appraisal_tasks')->where('id', (int)$task['id'])->update([ + 'status' => $effectiveStatus, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + $task['status'] = $effectiveStatus; + } + + $timeline = Db::name('order_timelines') + ->where('order_id', $task['order_id']) + ->order('occurred_at', 'asc') + ->select() + ->toArray(); + + $materials = Db::name('order_upload_items') + ->where('order_id', $task['order_id']) + ->select() + ->toArray(); + + $materials = array_values(array_filter(array_map(function (array $item) use ($request) { + $files = Db::name('order_upload_files') + ->where('order_upload_item_id', $item['id']) + ->order('id', 'asc') + ->select() + ->toArray(); + + if (!$files) { + return null; + } + + return [ + 'item_name' => $item['item_name'], + 'status' => $item['status'], + 'source_type' => $item['source_type'], + 'files' => array_map(fn (array $file) => [ + 'file_id' => $file['file_id'], + 'file_url' => $this->assetUrlService()->normalizeUrl((string)$file['file_url'], $request), + 'thumbnail_url' => $this->assetUrlService()->normalizeUrl((string)$file['thumbnail_url'], $request), + ], $files), + ]; + }, $materials))); + + $supplementTask = Db::name('order_supplement_tasks') + ->where('order_id', $task['order_id']) + ->where('status', 'pending') + ->order('id', 'desc') + ->find(); + + $supplementItems = []; + if ($supplementTask) { + $supplementItems = Db::name('order_supplement_task_items') + ->where('task_id', $supplementTask['id']) + ->order('id', 'asc') + ->select() + ->toArray(); + } + + $stageReportMap = $this->buildAppraisalReportMap([(int)$task['order_id']]); + + $stageTaskRows = $this->buildTaskBaseQuery() + ->where('t.order_id', (int)$task['order_id']) + ->order('t.id', 'asc') + ->select() + ->toArray(); + + $stageTasks = array_map(function (array $item) use ($id, $stageReportMap) { + $row = $this->normalizeTaskListRow($item, $stageReportMap[(int)$item['order_id']] ?? null); + $row['is_current'] = (int)$row['id'] === $id; + return $row; + }, $stageTaskRows); + + usort($stageTasks, fn (array $a, array $b) => $this->stagePriority($a['task_stage']) <=> $this->stagePriority($b['task_stage'])); + + $currentResultInfo = $this->formatResultInfo($task, $request); + $prefillResultInfo = null; + + if ($task['task_stage'] === 'final_review') { + $prefillTask = Db::name('appraisal_tasks') + ->alias('t') + ->leftJoin('appraisal_task_results r', 'r.task_id = t.id') + ->field([ + 't.id', + 't.task_stage', + 'r.id as result_id', + 'r.result_text', + 'r.result_desc', + 'r.condition_grade', + 'r.condition_desc', + 'r.valuation_min', + 'r.valuation_max', + 'r.valuation_desc', + 'r.attachments_json', + 'r.external_remark', + 'r.internal_remark', + ]) + ->where('t.order_id', (int)$task['order_id']) + ->where('t.task_stage', 'first_review') + ->order('t.id', 'desc') + ->find(); + + if ($prefillTask) { + $prefillPayload = $this->formatResultInfo($prefillTask, $request); + if ($this->hasResultData($prefillPayload)) { + $prefillResultInfo = array_merge($prefillPayload, [ + 'source_task_id' => (int)$prefillTask['id'], + 'source_stage' => 'first_review', + 'source_stage_text' => '鉴定', + ]); + } + } + } + + $appraisalTemplate = $this->resolveAppraisalTemplate( + (int)($task['category_id'] ?? 0), + (string)($task['service_provider'] ?? 'anxinyan'), + $currentResultInfo['key_points'] ?? [] + ); + + return api_success([ + 'task_info' => [ + 'id' => (int)$task['id'], + 'order_id' => (int)$task['order_id'], + 'order_no' => $task['order_no'], + 'appraisal_no' => $task['appraisal_no'], + 'external_order_no' => $task['external_order_no'] ?: '', + 'service_provider' => $task['service_provider'], + 'service_provider_text' => $task['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定', + 'task_stage' => $task['task_stage'], + 'task_stage_text' => '鉴定', + 'status' => $task['status'], + 'status_text' => $this->taskStatusText($task['status']), + 'assignee_id' => (int)($task['assignee_id'] ?? 0), + 'assignee_name' => $task['assignee_name'] ?: '未分配', + 'started_at' => $task['started_at'], + 'submitted_at' => $task['submitted_at'], + 'sla_deadline' => $task['sla_deadline'], + 'is_overtime' => (bool)$task['is_overtime'], + ], + 'report_summary' => $report ? [ + 'id' => (int)$report['id'], + 'report_no' => $report['report_no'], + 'report_status' => $report['report_status'], + 'report_status_text' => $this->reportStatusText($report['report_status']), + ] : null, + 'material_tag' => $materialTag, + 'product_info' => [ + 'product_name' => $task['product_name'] ?: '', + 'category_id' => (int)($task['category_id'] ?? 0), + 'category_name' => $task['category_name'] ?: '', + 'brand_id' => (int)($task['brand_id'] ?? 0), + 'brand_name' => $task['brand_name'] ?: '', + 'color' => $task['color'] ?: '', + 'size_spec' => $task['size_spec'] ?: '', + 'serial_no' => $task['serial_no'] ?: '', + ], + 'extra_info' => [ + 'purchase_channel' => $task['purchase_channel'], + 'purchase_price' => (float)$task['purchase_price'], + 'usage_status' => $task['usage_status'], + 'condition_desc' => $task['extra_condition_desc'], + 'remark' => $task['remark'], + ], + 'result_info' => $currentResultInfo, + 'prefill_result_info' => $prefillResultInfo, + 'appraisal_template' => $appraisalTemplate, + 'timeline' => array_map(fn (array $item) => [ + 'node_text' => $item['node_text'], + 'node_desc' => $item['node_desc'], + 'occurred_at' => $item['occurred_at'], + ], $timeline), + 'stage_tasks' => $stageTasks, + 'materials' => $materials, + 'supplement_task' => $supplementTask ? [ + 'id' => (int)$supplementTask['id'], + 'reason' => $supplementTask['reason'], + 'deadline' => $supplementTask['deadline'], + 'status' => $supplementTask['status'], + 'items' => array_map(fn (array $item) => [ + 'item_name' => $item['item_name'], + 'guide_text' => $item['guide_text'], + 'is_required' => (bool)$item['is_required'], + ], $supplementItems), + ] : null, + ]); + } + + public function bindMaterialTag(Request $request) + { + $id = (int)$request->input('id', 0); + $qrInput = trim((string)$request->input('qr_input', '')); + if ($id <= 0 || $qrInput === '') { + return api_error('任务 ID 和吊牌二维码不能为空', 422); + } + + $task = Db::name('appraisal_tasks')->where('id', $id)->find(); + if (!$task) { + return api_error('任务不存在', 404); + } + + $operatorGuard = $this->guardTaskOperator($request, $task); + if ($operatorGuard['error']) { + return $operatorGuard['error']; + } + + try { + $tag = (new MaterialTagService())->bindTagToReportByTask($id, $qrInput, $request); + } catch (\InvalidArgumentException $e) { + return api_error($e->getMessage(), 422); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), $e->getCode() ?: 404); + } catch (\Throwable $e) { + return api_error('吊牌绑定失败', 500, ['detail' => $e->getMessage()]); + } + + return api_success([ + 'id' => $id, + 'material_tag' => $tag, + ], '吊牌已绑定'); + } + + public function assignableAdmins(Request $request) + { + $id = (int)$request->input('id', 0); + if (!$id) { + return api_error('任务 ID 不能为空', 422); + } + + $task = Db::name('appraisal_tasks')->where('id', $id)->find(); + if (!$task) { + return api_error('任务不存在', 404); + } + + return api_success([ + 'list' => $this->findAssignableAdmins((string)$task['task_stage']), + ]); + } + + public function assign(Request $request) + { + $id = (int)$request->input('id', 0); + $assigneeId = (int)$request->input('assignee_id', 0); + if (!$id || !$assigneeId) { + return api_error('任务 ID 和处理人不能为空', 422); + } + + $task = Db::name('appraisal_tasks')->where('id', $id)->find(); + if (!$task) { + return api_error('任务不存在', 404); + } + if (in_array((string)($task['status'] ?? ''), ['submitted', 'completed'], true)) { + return api_error('当前任务已流转完成,不能再修改处理人', 422); + } + + $candidate = $this->findAssignableAdminById($assigneeId, (string)$task['task_stage']); + if (!$candidate) { + return api_error('所选管理员角色不适用于当前任务阶段', 422); + } + + Db::name('appraisal_tasks')->where('id', $id)->update([ + 'assignee_id' => (int)$candidate['id'], + 'assignee_name' => (string)$candidate['name'], + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + return api_success([ + 'id' => $id, + 'assignee_id' => (int)$candidate['id'], + 'assignee_name' => (string)$candidate['name'], + ], '处理人已分配'); + } + + public function saveResult(Request $request) + { + $id = (int)$request->input('id', 0); + $action = trim((string)$request->input('action', 'save')); + + if (!$id) { + return api_error('任务 ID 不能为空', 422); + } + + $task = Db::name('appraisal_tasks')->where('id', $id)->find(); + if (!$task) { + return api_error('任务不存在', 404); + } + + $order = Db::name('orders')->where('id', (int)$task['order_id'])->find() ?: []; + $task['order_status'] = $order['order_status'] ?? ''; + $report = $this->findLatestAppraisalReport((int)$task['order_id']); + $effectiveStatus = $this->effectiveTaskStatus($task, $report); + if ($effectiveStatus !== $task['status']) { + Db::name('appraisal_tasks')->where('id', $id)->update([ + 'status' => $effectiveStatus, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + $task['status'] = $effectiveStatus; + } + + if (in_array($effectiveStatus, ['submitted', 'completed'], true)) { + return api_error('当前任务已流转完成,不能再编辑或提交', 422); + } + + $operatorGuard = $this->guardTaskOperator($request, $task); + if ($operatorGuard['error']) { + return $operatorGuard['error']; + } + + $resultText = trim((string)$request->input('result_text', '')); + if ($action !== 'save' && $resultText === '') { + return api_error('鉴定结论不能为空', 422); + } + $productInput = $request->input('product_info', null); + $productPayload = is_array($productInput) ? $this->normalizeProductInput($productInput) : null; + $attachments = $this->evidenceService()->normalize($request->input('attachments', []), $request, true); + $keyPoints = $this->normalizeKeyPointInput($request->input('key_points', [])); + $payload = [ + 'task_id' => $id, + 'order_id' => (int)$task['order_id'], + 'result_status' => $this->mapResultStatus($resultText), + 'result_text' => $resultText, + 'result_desc' => trim((string)$request->input('result_desc', '')), + 'condition_grade' => trim((string)$request->input('condition_grade', '')), + 'condition_desc' => trim((string)$request->input('condition_desc', '')), + 'valuation_min' => (float)$request->input('valuation_min', 0), + 'valuation_max' => (float)$request->input('valuation_max', 0), + 'valuation_desc' => trim((string)$request->input('valuation_desc', '')), + 'attachments_json' => $attachments ? json_encode($attachments, JSON_UNESCAPED_UNICODE) : null, + 'external_remark' => trim((string)$request->input('external_remark', '')), + 'internal_remark' => trim((string)$request->input('internal_remark', '')), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + if ($action !== 'save' && ($task['order_status'] ?? '') === 'pending_shipping') { + return api_error('订单尚未确认到仓,不能提交鉴定结论', 422); + } + + $now = date('Y-m-d H:i:s'); + + Db::startTrans(); + try { + if ($operatorGuard['task_update']) { + Db::name('appraisal_tasks')->where('id', $id)->update(array_merge($operatorGuard['task_update'], [ + 'updated_at' => $now, + ])); + $task = array_merge($task, $operatorGuard['task_update']); + } + + if ($productPayload !== null) { + $this->saveOrderProductSnapshot((int)$task['order_id'], $productPayload, $now); + } + + $resultId = Db::name('appraisal_task_results')->where('task_id', $id)->value('id'); + if ($resultId) { + Db::name('appraisal_task_results')->where('id', $resultId)->update($payload); + $savedResultId = (int)$resultId; + } else { + $payload['created_at'] = $now; + $savedResultId = (int)Db::name('appraisal_task_results')->insertGetId($payload); + } + + $this->saveTaskKeyPoints($savedResultId, $keyPoints, $now); + + if ($action === 'save') { + $taskUpdate = [ + 'status' => 'processing', + 'updated_at' => $now, + ]; + if (!$task['started_at']) { + $taskUpdate['started_at'] = $now; + } + Db::name('appraisal_tasks')->where('id', $id)->update($taskUpdate); + Db::commit(); + return api_success(['id' => $id], '结论已保存'); + } + + if ($resultText === '') { + Db::rollback(); + return api_error('提交结论前请选择鉴定结论', 422); + } + + if (!$this->hasSubmittableProductInfo((int)$task['order_id'], $productPayload)) { + Db::rollback(); + return api_error('提交结论前请先完善物品信息', 422); + } + + Db::name('appraisal_tasks')->where('id', $id)->update([ + 'status' => 'completed', + 'started_at' => $task['started_at'] ?: $now, + 'submitted_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('orders')->where('id', $task['order_id'])->update([ + 'order_status' => 'generating_report', + 'display_status' => '正在生成报告', + 'updated_at' => $now, + ]); + + Db::name('order_timelines')->insert([ + 'order_id' => $task['order_id'], + 'node_code' => 'generating_report', + 'node_text' => '正在生成报告', + 'node_desc' => '鉴定已完成,系统正在生成正式报告草稿', + 'operator_type' => 'admin', + 'operator_id' => 1, + 'occurred_at' => $now, + 'created_at' => $now, + ]); + + $this->createOrUpdateReportDraft((int)$task['order_id'], $task, $payload, $now); + + Db::commit(); + (new EnterpriseWebhookService())->recordOrderEvent((int)$task['order_id'], 'appraisal_finished', [ + 'task_id' => $id, + 'task_stage' => $task['task_stage'], + 'finished_at' => $now, + ]); + return api_success(['id' => $id], '鉴定已完成,报告草稿已生成'); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('结论保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + } + + public function requestSupplement(Request $request) + { + $id = (int)$request->input('id', 0); + $reason = trim((string)$request->input('reason', '')); + $deadline = trim((string)$request->input('deadline', '')); + $items = $request->input('items', []); + + if (!$id) { + return api_error('任务 ID 不能为空', 422); + } + + if ($reason === '') { + return api_error('补资料原因不能为空', 422); + } + + $task = Db::name('appraisal_tasks')->where('id', $id)->find(); + if (!$task) { + return api_error('任务不存在', 404); + } + + $order = Db::name('orders')->where('id', (int)$task['order_id'])->find() ?: []; + $task['order_status'] = $order['order_status'] ?? ''; + $report = $this->findLatestAppraisalReport((int)$task['order_id']); + $effectiveStatus = $this->effectiveTaskStatus($task, $report); + if ($effectiveStatus !== $task['status']) { + Db::name('appraisal_tasks')->where('id', $id)->update([ + 'status' => $effectiveStatus, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + $task['status'] = $effectiveStatus; + } + + if (in_array($effectiveStatus, ['submitted', 'completed'], true)) { + return api_error('当前任务已流转完成,不能再发起补资料', 422); + } + + $operatorGuard = $this->guardTaskOperator($request, $task); + if ($operatorGuard['error']) { + return $operatorGuard['error']; + } + + if (!is_array($items) || !$items) { + return api_error('请至少填写一项补资料要求', 422); + } + + $normalizedItems = []; + foreach ($items as $index => $item) { + if (!is_array($item)) { + continue; + } + + $itemName = trim((string)($item['item_name'] ?? '')); + $guideText = trim((string)($item['guide_text'] ?? '')); + $isRequired = array_key_exists('is_required', $item) ? (bool)$item['is_required'] : true; + + if ($itemName === '') { + continue; + } + + $normalizedItems[] = [ + 'item_code' => 'supplement_' . ($index + 1), + 'item_name' => $itemName, + 'guide_text' => $guideText, + 'is_required' => $isRequired ? 1 : 0, + ]; + } + + if (!$normalizedItems) { + return api_error('请至少填写一项有效的补资料要求', 422); + } + + $now = date('Y-m-d H:i:s'); + + Db::startTrans(); + try { + if ($operatorGuard['task_update']) { + Db::name('appraisal_tasks')->where('id', $id)->update(array_merge($operatorGuard['task_update'], [ + 'updated_at' => $now, + ])); + $task = array_merge($task, $operatorGuard['task_update']); + } + + Db::name('order_supplement_tasks') + ->where('order_id', $task['order_id']) + ->where('status', 'pending') + ->update([ + 'status' => 'closed', + 'updated_at' => $now, + ]); + + $supplementTaskId = (int)Db::name('order_supplement_tasks')->insertGetId([ + 'order_id' => $task['order_id'], + 'reason' => $reason, + 'deadline' => $deadline !== '' ? $deadline : null, + 'status' => 'pending', + 'created_by' => 1, + 'submitted_at' => null, + 'approved_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + foreach ($normalizedItems as $item) { + Db::name('order_supplement_task_items')->insert([ + 'task_id' => $supplementTaskId, + 'item_code' => $item['item_code'], + 'item_name' => $item['item_name'], + 'guide_text' => $item['guide_text'], + 'sample_image_url' => '', + 'is_required' => $item['is_required'], + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('order_upload_items')->insert([ + 'order_id' => $task['order_id'], + 'template_id' => null, + 'item_code' => $item['item_code'], + 'item_name' => $item['item_name'], + 'is_required' => $item['is_required'], + 'source_type' => 'supplement', + 'status' => 'pending', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + Db::name('appraisal_tasks')->where('id', $id)->update([ + 'status' => 'returned', + 'started_at' => $task['started_at'] ?: $now, + 'updated_at' => $now, + ]); + + Db::name('orders')->where('id', $task['order_id'])->update([ + 'order_status' => 'pending_supplement', + 'display_status' => '等待您补充资料', + 'updated_at' => $now, + ]); + + Db::name('order_timelines')->insert([ + 'order_id' => $task['order_id'], + 'node_code' => 'supplement', + 'node_text' => '待补资料', + 'node_desc' => $reason, + 'operator_type' => 'admin', + 'operator_id' => 1, + 'occurred_at' => $now, + 'created_at' => $now, + ]); + + $order = Db::name('orders')->where('id', $task['order_id'])->find(); + + (new MessageDispatcher())->sendInboxEvent('supplement_required', [ + 'user_id' => (int)($order['user_id'] ?? 0), + 'biz_type' => 'supplement', + 'biz_id' => $supplementTaskId, + 'reason' => $reason, + 'deadline' => $deadline, + 'fallback_title' => '请补充鉴定资料', + 'fallback_content' => '鉴定师需要您补充资料后继续处理,请尽快进入订单详情查看。', + ]); + + Db::commit(); + + (new EnterpriseWebhookService())->recordOrderEvent((int)$task['order_id'], 'supplement_required', [ + 'task_id' => $id, + 'supplement_task_id' => $supplementTaskId, + 'reason' => $reason, + 'deadline' => $deadline, + 'items' => $normalizedItems, + ]); + + return api_success([ + 'id' => $id, + 'supplement_task_id' => $supplementTaskId, + ], '已发起补资料要求'); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('发起补资料失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + } + + public function uploadEvidenceFile(Request $request) + { + try { + $asset = $this->evidenceService()->upload($request); + return api_success($asset); + } catch (\Throwable $e) { + return api_error($e->getMessage(), 422); + } + } + + public function deleteEvidenceFile(Request $request) + { + $fileUrl = trim((string)$request->input('file_url', '')); + if ($fileUrl === '') { + return api_error('文件地址不能为空', 422); + } + + $this->evidenceService()->delete($fileUrl); + + return api_success([ + 'file_url' => $fileUrl, + ], '删除成功'); + } + + private function buildTaskBaseQuery() + { + return Db::name('appraisal_tasks') + ->alias('t') + ->leftJoin('orders o', 'o.id = t.order_id') + ->leftJoin('order_products p', 'p.order_id = t.order_id') + ->leftJoin('appraisal_task_results r', 'r.task_id = t.id') + ->leftJoin('enterprise_customer_order_refs ecr', 'ecr.order_id = t.order_id') + ->field([ + 't.id', + 't.order_id', + 't.task_stage', + 't.status', + 't.assignee_id', + 't.assignee_name', + 't.started_at', + 't.submitted_at', + 't.sla_deadline', + 't.is_overtime', + 'o.order_no', + 'o.appraisal_no', + 'ecr.external_order_no', + 'o.service_provider', + 'o.order_status', + 'o.display_status', + 'p.product_name', + 'p.category_id', + 'p.category_name', + 'p.brand_id', + 'p.brand_name', + 'r.result_text', + ]); + } + + private function normalizeTaskListRow(array $item, ?array $report = null): array + { + $effectiveStatus = $this->effectiveTaskStatus($item, $report); + return [ + 'id' => (int)$item['id'], + 'order_id' => (int)$item['order_id'], + 'order_no' => $item['order_no'], + 'appraisal_no' => $item['appraisal_no'], + 'external_order_no' => $item['external_order_no'] ?: '', + 'service_provider' => $item['service_provider'], + 'service_provider_text' => $item['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定', + 'task_stage' => $item['task_stage'], + 'task_stage_text' => '鉴定', + 'status' => $effectiveStatus, + 'status_text' => $this->taskStatusText($effectiveStatus), + 'assignee_id' => (int)($item['assignee_id'] ?? 0), + 'product_name' => $item['product_name'] ?: '待完善物品信息', + 'category_id' => (int)($item['category_id'] ?? 0), + 'category_name' => $item['category_name'] ?: '', + 'brand_id' => (int)($item['brand_id'] ?? 0), + 'brand_name' => $item['brand_name'] ?: '', + 'assignee_name' => $item['assignee_name'] ?: '未分配', + 'result_text' => $item['result_text'] ?: '', + 'started_at' => $item['started_at'], + 'submitted_at' => $item['submitted_at'], + 'sla_deadline' => $item['sla_deadline'], + 'is_overtime' => (bool)$item['is_overtime'], + 'display_status' => $item['display_status'], + ]; + } + + private function formatResultInfo(array $task, ?Request $request = null): array + { + $resultId = 0; + if (!empty($task['result_id'])) { + $resultId = (int)$task['result_id']; + } elseif (!empty($task['id'])) { + $resultId = (int)Db::name('appraisal_task_results')->where('task_id', (int)$task['id'])->value('id'); + } + + return [ + 'result_text' => $task['result_text'] ?: '', + 'result_desc' => $task['result_desc'] ?: '', + 'condition_grade' => $task['condition_grade'] ?: '', + 'condition_desc' => ($task['result_condition_desc'] ?? $task['condition_desc'] ?? '') ?: '', + 'valuation_min' => (float)($task['valuation_min'] ?? 0), + 'valuation_max' => (float)($task['valuation_max'] ?? 0), + 'valuation_desc' => ($task['result_valuation_desc'] ?? $task['valuation_desc'] ?? '') ?: '', + 'attachments' => $this->evidenceService()->normalize($task['result_attachments_json'] ?? $task['attachments_json'] ?? null, $request), + 'external_remark' => $task['external_remark'] ?: '', + 'internal_remark' => $task['internal_remark'] ?: '', + 'key_points' => $resultId > 0 ? $this->loadTaskKeyPoints($resultId) : [], + ]; + } + + private function hasResultData(array $resultInfo): bool + { + return $resultInfo['result_text'] !== '' + || $resultInfo['result_desc'] !== '' + || $resultInfo['condition_grade'] !== '' + || $resultInfo['condition_desc'] !== '' + || (float)$resultInfo['valuation_min'] > 0 + || (float)$resultInfo['valuation_max'] > 0 + || $resultInfo['valuation_desc'] !== '' + || !empty($resultInfo['attachments']) + || $this->hasKeyPointData($resultInfo['key_points'] ?? []) + || $resultInfo['external_remark'] !== '' + || $resultInfo['internal_remark'] !== ''; + } + + private function hasKeyPointData(array $keyPoints): bool + { + foreach ($keyPoints as $point) { + if (!is_array($point)) { + continue; + } + if (trim((string)($point['point_value'] ?? '')) !== '' || trim((string)($point['point_remark'] ?? '')) !== '') { + return true; + } + } + + return false; + } + + private function buildGroupedTaskList(array $rows, array $reportMap = []): array + { + $grouped = []; + foreach ($rows as $item) { + $row = $this->normalizeTaskListRow($item, $reportMap[(int)$item['order_id']] ?? null); + $grouped[$row['order_id']][] = $row; + } + + $list = []; + foreach ($grouped as $tasks) { + usort($tasks, fn (array $a, array $b) => $this->stagePriority($a['task_stage']) <=> $this->stagePriority($b['task_stage'])); + $currentTask = $this->selectCurrentTask($tasks); + $latestResultText = $this->latestResultText($tasks); + $latestSubmittedAt = $this->latestSubmittedAt($tasks); + + $list[] = array_merge($currentTask, [ + 'result_text' => $latestResultText, + 'submitted_at' => $latestSubmittedAt, + 'stage_tasks' => array_map(function (array $task) use ($currentTask) { + $task['is_current'] = $task['id'] === $currentTask['id']; + return $task; + }, $tasks), + ]); + } + + usort($list, fn (array $a, array $b) => $b['id'] <=> $a['id']); + return $list; + } + + private function selectCurrentTask(array $tasks): array + { + $sorted = $tasks; + usort($sorted, function (array $a, array $b) { + $stageCompare = $this->stagePriority($b['task_stage']) <=> $this->stagePriority($a['task_stage']); + if ($stageCompare !== 0) { + return $stageCompare; + } + + $statusCompare = $this->statusPriority($b['status']) <=> $this->statusPriority($a['status']); + if ($statusCompare !== 0) { + return $statusCompare; + } + + return $b['id'] <=> $a['id']; + }); + + return $sorted[0]; + } + + private function latestResultText(array $tasks): string + { + $sorted = $tasks; + usort($sorted, function (array $a, array $b) { + $stageCompare = $this->stagePriority($b['task_stage']) <=> $this->stagePriority($a['task_stage']); + if ($stageCompare !== 0) { + return $stageCompare; + } + + return $b['id'] <=> $a['id']; + }); + + foreach ($sorted as $task) { + if ($task['result_text'] !== '') { + return $task['result_text']; + } + } + + return ''; + } + + private function latestSubmittedAt(array $tasks): ?string + { + $submitted = array_values(array_filter(array_map(fn (array $task) => $task['submitted_at'], $tasks))); + if (!$submitted) { + return null; + } + + rsort($submitted); + return $submitted[0]; + } + + private function stagePriority(string $stage): int + { + return match ($stage) { + 'first_review' => 1, + 'final_review' => 2, + default => 0, + }; + } + + private function statusPriority(string $status): int + { + return match ($status) { + 'processing' => 5, + 'pending' => 4, + 'returned' => 3, + 'submitted' => 2, + 'completed' => 1, + default => 0, + }; + } + + private function buildAppraisalReportMap(array $orderIds): array + { + if (!$orderIds) { + return []; + } + + $rows = Db::name('reports') + ->whereIn('order_id', $orderIds) + ->where('report_type', 'appraisal') + ->order('id', 'desc') + ->select() + ->toArray(); + + $map = []; + foreach ($rows as $row) { + $orderId = (int)$row['order_id']; + if (!isset($map[$orderId])) { + $map[$orderId] = $row; + } + } + + return $map; + } + + private function workbenchVisibleOrderStatuses(): array + { + return [ + 'received', + 'in_first_review', + 'pending_supplement', + 'in_final_review', + 'generating_report', + 'report_published', + 'completed', + ]; + } + + private function workbenchVisibleOrderStatusSql(): string + { + $quoted = array_map(fn (string $status) => "'" . addslashes($status) . "'", $this->workbenchVisibleOrderStatuses()); + return '(o.order_status IN (' . implode(', ', $quoted) . ") OR (o.order_status = 'pending_shipping' AND o.source_channel = 'enterprise_push'))"; + } + + private function findLatestAppraisalReport(int $orderId): ?array + { + return Db::name('reports') + ->where('order_id', $orderId) + ->where('report_type', 'appraisal') + ->order('id', 'desc') + ->find() ?: null; + } + + private function effectiveTaskStatus(array $task, ?array $report = null): string + { + $status = (string)($task['status'] ?? ''); + $stage = (string)($task['task_stage'] ?? ''); + $submittedAt = (string)($task['submitted_at'] ?? ''); + $orderStatus = (string)($task['order_status'] ?? ''); + + if ( + $submittedAt !== '' + && ( + $status === 'completed' + || $report + || in_array($orderStatus, ['generating_report', 'report_published', 'completed'], true) + ) + ) { + return 'completed'; + } + + if ($submittedAt !== '' && $status !== 'completed') { + return 'submitted'; + } + + return $status; + } + + private function taskStatusText(string $status): string + { + return match ($status) { + 'pending' => '待处理', + 'processing' => '处理中', + 'submitted' => '已提交', + 'returned' => '待用户补料', + 'completed' => '已完成', + default => $status, + }; + } + + private function reportStatusText(string $status): string + { + return match ($status) { + 'draft' => '草稿中', + 'pending_publish' => '待发布', + 'published' => '已发布', + 'updated' => '已更新', + 'invalid' => '已作废', + default => $status, + }; + } + + private function mapResultStatus(string $resultText): string + { + return match ($resultText) { + '正品' => 'authentic', + '不符合正品特征' => 'non_authentic', + '存疑' => 'suspicious', + '暂无法明确判断' => 'inconclusive', + default => '', + }; + } + + private function decodeJsonArray(mixed $value): array + { + if (is_array($value)) { + return array_values($value); + } + if (is_string($value) && $value !== '') { + $decoded = json_decode($value, true); + return is_array($decoded) ? array_values($decoded) : []; + } + return []; + } + + private function decodeJsonObject(mixed $value): array + { + if (is_array($value)) { + return $value; + } + if (is_string($value) && $value !== '') { + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; + } + return []; + } + + private function serviceProviderText(string $serviceProvider): string + { + return $serviceProvider === 'zhongjian' ? '中检鉴定' : '实物鉴定'; + } + + private function normalizeProductInput(mixed $input): array + { + $product = is_array($input) ? $input : []; + $categoryId = !empty($product['category_id']) ? (int)$product['category_id'] : null; + $brandId = !empty($product['brand_id']) ? (int)$product['brand_id'] : null; + + $categoryName = trim((string)($product['category_name'] ?? '')); + if ($categoryName === '' && $categoryId) { + $categoryName = $this->lookupName('catalog_categories', 'name', $categoryId); + } + + $brandName = trim((string)($product['brand_name'] ?? '')); + if ($brandName === '' && $brandId) { + $brandName = $this->lookupName('catalog_brands', 'name', $brandId); + } + + $productName = trim((string)($product['product_name'] ?? '')); + if ($productName === '') { + $productName = trim($categoryName . ' ' . $brandName); + } + + return [ + 'category_id' => $categoryId, + 'category_name' => $categoryName, + 'brand_id' => $brandId, + 'brand_name' => $brandName, + 'color' => trim((string)($product['color'] ?? '')), + 'size_spec' => trim((string)($product['size_spec'] ?? '')), + 'serial_no' => trim((string)($product['serial_no'] ?? '')), + 'product_name' => $productName, + ]; + } + + private function saveOrderProductSnapshot(int $orderId, array $product, string $now): void + { + $exists = Db::name('order_products')->where('order_id', $orderId)->find(); + $payload = array_merge($product, [ + 'updated_at' => $now, + ]); + if ($exists) { + foreach (['category_id', 'brand_id'] as $key) { + if (empty($payload[$key]) && !empty($exists[$key])) { + $payload[$key] = (int)$exists[$key]; + } + } + + foreach (['category_name', 'brand_name', 'color', 'size_spec', 'serial_no', 'product_name'] as $key) { + if (($payload[$key] ?? '') === '' && ($exists[$key] ?? '') !== '') { + $payload[$key] = $exists[$key]; + } + } + + if (($payload['product_name'] ?? '') === '') { + $payload['product_name'] = trim(($payload['category_name'] ?? '') . ' ' . ($payload['brand_name'] ?? '')); + } + + Db::name('order_products')->where('order_id', $orderId)->update($payload); + return; + } + + if (($payload['product_name'] ?? '') === '') { + $payload['product_name'] = trim(($payload['category_name'] ?? '') . ' ' . ($payload['brand_name'] ?? '')); + } + + Db::name('order_products')->insert(array_merge($payload, [ + 'order_id' => $orderId, + 'product_cover' => '', + 'created_at' => $now, + ])); + } + + private function hasSubmittableProductInfo(int $orderId, ?array $incomingProduct = null): bool + { + $product = $incomingProduct; + if ($product === null) { + $product = Db::name('order_products')->where('order_id', $orderId)->find() ?: []; + } + + $name = trim((string)($product['product_name'] ?? '')); + $categoryName = trim((string)($product['category_name'] ?? '')); + $brandName = trim((string)($product['brand_name'] ?? '')); + + return $name !== '' || $categoryName !== '' || $brandName !== ''; + } + + private function resolveAppraisalTemplate(int $categoryId, string $serviceProvider, array $savedKeyPoints = []): ?array + { + if ($categoryId <= 0) { + return null; + } + + $template = Db::name('appraisal_templates') + ->where('scope_type', 'category') + ->where('scope_id', $categoryId) + ->where('is_enabled', 1) + ->order('is_default', 'desc') + ->order('id', 'desc') + ->find(); + + $category = null; + if (!$template) { + $category = Db::name('catalog_categories')->field(['id', 'name'])->where('id', $categoryId)->find(); + if (!$category) { + return null; + } + } + + $savedMap = []; + foreach ($savedKeyPoints as $point) { + if (!is_array($point)) { + continue; + } + $code = (string)($point['point_code'] ?? ''); + if ($code === '') { + continue; + } + $savedMap[$code] = $point; + } + + $pointRows = $template + ? Db::name('appraisal_template_key_points') + ->where('template_id', (int)$template['id']) + ->order('sort_order', 'asc') + ->order('id', 'asc') + ->select() + ->toArray() + : []; + + return [ + 'id' => $template ? (int)$template['id'] : 0, + 'name' => (string)($template['name'] ?? sprintf('%s鉴定模板', (string)$category['name'])), + 'code' => (string)($template['code'] ?? sprintf('appraisal_category_%d', $categoryId)), + 'service_provider' => 'category', + 'service_provider_text' => '通用品类模板', + 'result_options' => [], + 'condition_options' => [], + 'valuation_hint' => '', + 'key_points' => array_map(function (array $point) use ($savedMap) { + $pointCode = (string)$point['point_code']; + $saved = $savedMap[$pointCode] ?? []; + return [ + 'point_code' => $pointCode, + 'point_name' => (string)$point['point_name'], + 'point_type' => (string)$point['point_type'], + 'options' => $this->decodeJsonArray($point['options_json'] ?? null), + 'sort_order' => (int)$point['sort_order'], + 'is_required' => (bool)$point['is_required'], + 'point_value' => (string)($saved['point_value'] ?? ''), + 'point_remark' => (string)($saved['point_remark'] ?? ''), + ]; + }, $pointRows), + ]; + } + + private function normalizeKeyPointInput(mixed $input): array + { + if (!is_array($input)) { + return []; + } + + $list = []; + foreach ($input as $item) { + if (!is_array($item)) { + continue; + } + + $pointCode = trim((string)($item['point_code'] ?? '')); + $pointName = trim((string)($item['point_name'] ?? '')); + if ($pointCode === '' || $pointName === '') { + continue; + } + + $list[] = [ + 'point_code' => $pointCode, + 'point_name' => $pointName, + 'point_value' => trim((string)($item['point_value'] ?? '')), + 'point_remark' => trim((string)($item['point_remark'] ?? '')), + ]; + } + + return $list; + } + + private function saveTaskKeyPoints(int $taskResultId, array $keyPoints, string $now): void + { + Db::name('appraisal_task_key_points')->where('task_result_id', $taskResultId)->delete(); + if (!$keyPoints) { + return; + } + + $rows = array_map(fn (array $point) => [ + 'task_result_id' => $taskResultId, + 'point_code' => $point['point_code'], + 'point_name' => $point['point_name'], + 'point_value' => $point['point_value'], + 'point_remark' => $point['point_remark'], + 'created_at' => $now, + 'updated_at' => $now, + ], $keyPoints); + + Db::name('appraisal_task_key_points')->insertAll($rows); + } + + private function loadTaskKeyPoints(int $taskResultId): array + { + if ($taskResultId <= 0) { + return []; + } + + $rows = Db::name('appraisal_task_key_points') + ->where('task_result_id', $taskResultId) + ->order('id', 'asc') + ->select() + ->toArray(); + + return array_map(fn (array $item) => [ + 'point_code' => (string)$item['point_code'], + 'point_name' => (string)$item['point_name'], + 'point_value' => (string)$item['point_value'], + 'point_remark' => (string)$item['point_remark'], + ], $rows); + } + + private function loadLatestOrderKeyPoints(int $orderId): array + { + $result = Db::name('appraisal_task_results') + ->alias('r') + ->leftJoin('appraisal_tasks t', 't.id = r.task_id') + ->field([ + 'r.id', + 't.task_stage', + 't.id as task_id', + ]) + ->where('r.order_id', $orderId) + ->orderRaw("CASE WHEN t.task_stage = 'final_review' THEN 2 WHEN t.task_stage = 'first_review' THEN 1 ELSE 0 END DESC") + ->order('r.id', 'desc') + ->find(); + + if (!$result) { + return []; + } + + return $this->loadTaskKeyPoints((int)$result['id']); + } + + private function lookupName(string $table, string $field, ?int $id): string + { + if (!$id) { + return ''; + } + + return (string)Db::name($table)->where('id', $id)->value($field); + } + + private function createOrUpdateReportDraft(int $orderId, array $task, array $resultPayload, string $now): void + { + $report = Db::name('reports')->where('order_id', $orderId)->order('id', 'desc')->find(); + $order = Db::name('orders')->where('id', $orderId)->find(); + $product = Db::name('order_products')->where('order_id', $orderId)->find(); + $extra = Db::name('order_extras')->where('order_id', $orderId)->find(); + $stageTasks = Db::name('appraisal_tasks') + ->where('order_id', $orderId) + ->select() + ->toArray(); + + $firstReviewTask = null; + $finalReviewTask = null; + foreach ($stageTasks as $stageTask) { + if (($stageTask['task_stage'] ?? '') === 'first_review') { + $firstReviewTask = $stageTask; + } + if (($stageTask['task_stage'] ?? '') === 'final_review') { + $finalReviewTask = $stageTask; + } + } + + $appraisalSnapshot = $this->buildAppraisalSnapshot( + $task['service_provider'], + $now, + $firstReviewTask, + $finalReviewTask + ); + + $reportData = [ + 'order_id' => $orderId, + 'appraisal_no' => $order['appraisal_no'] ?? '', + 'report_type' => 'appraisal', + 'service_provider' => $task['service_provider'], + 'institution_name' => $task['service_provider'] === 'zhongjian' ? '中检合作机构' : '安心验', + 'report_title' => $task['service_provider'] === 'zhongjian' ? '中检鉴定报告' : '安心验鉴定报告', + 'report_status' => 'pending_publish', + 'publish_time' => null, + 'updated_at' => $now, + ]; + + if ($report) { + Db::name('reports')->where('id', $report['id'])->update($reportData); + $reportId = (int)$report['id']; + } else { + $reportData['report_no'] = 'AXY-R-' . date('Ymd') . '-' . mt_rand(1000, 9999); + $reportData['report_version'] = 1; + $reportData['created_at'] = $now; + $reportId = (int)Db::name('reports')->insertGetId($reportData); + } + + $contentPayload = [ + 'report_id' => $reportId, + 'product_snapshot_json' => json_encode([ + 'product_name' => $product['product_name'] ?? '', + 'category_name' => $product['category_name'] ?? '', + 'brand_name' => $product['brand_name'] ?? '', + 'color' => $product['color'] ?? '', + 'size_spec' => $product['size_spec'] ?? '', + ], JSON_UNESCAPED_UNICODE), + 'result_snapshot_json' => json_encode([ + 'result_status' => $resultPayload['result_status'], + 'result_text' => $resultPayload['result_text'], + 'result_desc' => $resultPayload['result_desc'], + 'key_points' => $this->loadLatestOrderKeyPoints($orderId), + ], JSON_UNESCAPED_UNICODE), + 'appraisal_snapshot_json' => json_encode($appraisalSnapshot, JSON_UNESCAPED_UNICODE), + 'valuation_snapshot_json' => json_encode([ + 'condition_grade' => $resultPayload['condition_grade'], + 'condition_desc' => $resultPayload['condition_desc'] ?: ($extra['condition_desc'] ?? ''), + 'valuation_min' => $resultPayload['valuation_min'], + 'valuation_max' => $resultPayload['valuation_max'], + 'valuation_desc' => $resultPayload['valuation_desc'], + ], JSON_UNESCAPED_UNICODE), + 'evidence_attachments_json' => $resultPayload['attachments_json'] ?? null, + 'risk_notice_text' => (new ContentService())->getReportRiskNotice('appraisal'), + 'updated_at' => $now, + ]; + + $content = Db::name('report_contents')->where('report_id', $reportId)->find(); + if ($content) { + Db::name('report_contents')->where('report_id', $reportId)->update($contentPayload); + } else { + $contentPayload['created_at'] = $now; + Db::name('report_contents')->insert($contentPayload); + } + } + + private function buildAppraisalSnapshot(string $serviceProvider, string $fallbackTime, ?array $firstReviewTask, ?array $finalReviewTask): array + { + $institutionName = $serviceProvider === 'zhongjian' ? '中检合作机构' : '安心验'; + $appraiserName = $this->normalizeAssigneeName($firstReviewTask['assignee_name'] ?? '') + ?: $this->normalizeAssigneeName($finalReviewTask['assignee_name'] ?? ''); + $reviewerName = $appraiserName; + $appraisalTime = $firstReviewTask['submitted_at'] + ?? $firstReviewTask['started_at'] + ?? $finalReviewTask['submitted_at'] + ?? $finalReviewTask['started_at'] + ?? $fallbackTime; + + return [ + 'service_provider' => $serviceProvider, + 'institution_name' => $institutionName, + 'appraiser_name' => $appraiserName, + 'reviewer_name' => $reviewerName, + 'appraisal_time' => $appraisalTime, + ]; + } + + private function normalizeAssigneeName(?string $value): string + { + $name = trim((string)$value); + if ($name === '' || $name === '未分配') { + return ''; + } + + return $name; + } + + private function guardTaskOperator(Request $request, array $task): array + { + $adminId = (int)$request->header('x-admin-id', 0); + $adminName = trim((string)$request->header('x-admin-name', '')); + if ($adminId <= 0 || $adminName === '') { + return ['error' => api_error('当前登录管理员信息异常', 401), 'task_update' => []]; + } + + $roleCodes = $this->adminRoleCodes($adminId); + if (!$this->canOperateTaskStage((string)$task['task_stage'], $roleCodes)) { + return ['error' => api_error('当前账号未配置适用于该任务阶段的鉴定角色', 403), 'task_update' => []]; + } + + $isSuperAdmin = in_array('super_admin', $roleCodes, true); + $assigneeId = (int)($task['assignee_id'] ?? 0); + $assigneeName = trim((string)($task['assignee_name'] ?? '')); + + if ($assigneeId > 0 && $assigneeId !== $adminId && !$isSuperAdmin) { + return ['error' => api_error("当前任务已分配给 {$assigneeName},请勿越权处理", 422), 'task_update' => []]; + } + + if ($assigneeId <= 0 || $assigneeName === '' || $assigneeName === '未分配') { + return [ + 'error' => null, + 'task_update' => [ + 'assignee_id' => $adminId, + 'assignee_name' => $adminName, + ], + ]; + } + + return ['error' => null, 'task_update' => []]; + } + + private function assignableRoleCodesForStage(string $taskStage): array + { + return ['appraiser', 'super_admin']; + } + + private function canOperateTaskStage(string $taskStage, array $roleCodes): bool + { + return (bool)array_intersect($roleCodes, $this->assignableRoleCodesForStage($taskStage)); + } + + private function adminRoleCodes(int $adminId): array + { + $roleIds = Db::name('admin_role_relations')->where('admin_user_id', $adminId)->column('role_id'); + if (!$roleIds) { + return []; + } + + return array_values(Db::name('admin_roles')->whereIn('id', $roleIds)->column('code')); + } + + private function findAssignableAdmins(string $taskStage): array + { + $expectedRoleCodes = $this->assignableRoleCodesForStage($taskStage); + $admins = Db::name('admin_users') + ->where('status', 'enabled') + ->order('id', 'asc') + ->select() + ->toArray(); + + $list = []; + foreach ($admins as $admin) { + $roleCodes = $this->adminRoleCodes((int)$admin['id']); + if (!$this->canOperateTaskStage($taskStage, $roleCodes)) { + continue; + } + + $roleIds = Db::name('admin_role_relations')->where('admin_user_id', (int)$admin['id'])->column('role_id'); + $roleNames = $roleIds ? Db::name('admin_roles')->whereIn('id', $roleIds)->column('name') : []; + + $list[] = [ + 'id' => (int)$admin['id'], + 'name' => $admin['name'], + 'mobile' => $admin['mobile'], + 'role_names' => array_values($roleNames), + 'role_codes' => $roleCodes, + ]; + } + + return $list; + } + + private function findAssignableAdminById(int $adminId, string $taskStage): ?array + { + $admin = Db::name('admin_users')->where('id', $adminId)->where('status', 'enabled')->find(); + if (!$admin) { + return null; + } + + $roleCodes = $this->adminRoleCodes($adminId); + if (!$this->canOperateTaskStage($taskStage, $roleCodes)) { + return null; + } + + $admin['role_codes'] = $roleCodes; + return $admin; + } + + private function evidenceService(): AppraisalEvidenceService + { + return new AppraisalEvidenceService(); + } + + private function assetUrlService(): PublicAssetUrlService + { + return new PublicAssetUrlService(); + } +} diff --git a/server-api/app/controller/admin/AuthController.php b/server-api/app/controller/admin/AuthController.php new file mode 100644 index 0000000..40ad876 --- /dev/null +++ b/server-api/app/controller/admin/AuthController.php @@ -0,0 +1,44 @@ +input('mobile', '')); + $password = trim((string)$request->input('password', '')); + + if ($mobile === '' || $password === '') { + return api_error('手机号和密码不能为空', 422); + } + + try { + $payload = (new AdminAuthService())->login($mobile, $password, $request); + return api_success($payload, '登录成功'); + } catch (\Throwable $e) { + return api_error($e->getMessage(), 401); + } + } + + public function me(Request $request) + { + $admin = (new AdminAuthService())->current($request); + if (!$admin) { + return api_error('未登录或登录已过期', 401); + } + + return api_success([ + 'admin_info' => $admin, + ]); + } + + public function logout(Request $request) + { + (new AdminAuthService())->logout($request); + return api_success([], '已退出登录'); + } +} diff --git a/server-api/app/controller/admin/CatalogController.php b/server-api/app/controller/admin/CatalogController.php new file mode 100644 index 0000000..c28e94b --- /dev/null +++ b/server-api/app/controller/admin/CatalogController.php @@ -0,0 +1,889 @@ + [ + [ + 'title' => '启用品类', + 'value' => (int)Db::name('catalog_categories')->where('is_enabled', 1)->count(), + 'desc' => '当前前台可用的鉴定品类数量', + ], + [ + 'title' => '启用品牌', + 'value' => (int)Db::name('catalog_brands')->where('is_enabled', 1)->count(), + 'desc' => '已配置并启用的品牌数量', + ], + ], + ]); + } + + public function categories(Request $request) + { + $rows = Db::name('catalog_categories') + ->field([ + 'id', + 'name', + 'code', + 'sort_order', + 'is_enabled', + 'need_shipping', + 'supported_service_types', + ]) + ->order('sort_order', 'asc') + ->select() + ->toArray(); + $categoryVisuals = $this->categoryVisualMap($request); + + $templateSummaryMap = []; + $appraisalTemplateSummaryMap = []; + if ($rows) { + $categoryIds = array_map(fn (array $item) => (int)$item['id'], $rows); + $templateRows = Db::name('upload_templates') + ->field(['id', 'scope_id']) + ->where('scope_type', 'category') + ->whereIn('scope_id', $categoryIds) + ->where('is_enabled', 1) + ->select() + ->toArray(); + + $templateIds = array_map(fn (array $item) => (int)$item['id'], $templateRows); + $itemCountMap = []; + if ($templateIds) { + $itemRows = Db::name('upload_template_items') + ->fieldRaw('template_id, COUNT(*) AS item_count') + ->whereIn('template_id', $templateIds) + ->where('is_enabled', 1) + ->group('template_id') + ->select() + ->toArray(); + + foreach ($itemRows as $item) { + $itemCountMap[(int)$item['template_id']] = (int)$item['item_count']; + } + } + + foreach ($templateRows as $item) { + $categoryId = (int)($item['scope_id'] ?? 0); + if ($categoryId <= 0) { + continue; + } + if (!isset($templateSummaryMap[$categoryId])) { + $templateSummaryMap[$categoryId] = [ + 'template_count' => 0, + 'item_count' => 0, + ]; + } + $templateSummaryMap[$categoryId]['template_count'] += 1; + $templateSummaryMap[$categoryId]['item_count'] += $itemCountMap[(int)$item['id']] ?? 0; + } + + $appraisalTemplateRows = Db::name('appraisal_templates') + ->field(['id', 'scope_id', 'is_default']) + ->where('scope_type', 'category') + ->whereIn('scope_id', $categoryIds) + ->where('is_enabled', 1) + ->order('is_default', 'desc') + ->order('id', 'desc') + ->select() + ->toArray(); + + $appraisalTemplateIds = array_map(fn (array $item) => (int)$item['id'], $appraisalTemplateRows); + $pointCountMap = []; + if ($appraisalTemplateIds) { + $pointRows = Db::name('appraisal_template_key_points') + ->fieldRaw('template_id, COUNT(*) AS point_count') + ->whereIn('template_id', $appraisalTemplateIds) + ->group('template_id') + ->select() + ->toArray(); + + foreach ($pointRows as $item) { + $pointCountMap[(int)$item['template_id']] = (int)$item['point_count']; + } + } + + foreach ($appraisalTemplateRows as $item) { + $categoryId = (int)($item['scope_id'] ?? 0); + if ($categoryId <= 0) { + continue; + } + if (isset($appraisalTemplateSummaryMap[$categoryId])) { + continue; + } + if (!isset($appraisalTemplateSummaryMap[$categoryId])) { + $appraisalTemplateSummaryMap[$categoryId] = [ + 'template_count' => 0, + 'point_count' => 0, + ]; + } + $appraisalTemplateSummaryMap[$categoryId]['template_count'] = 1; + $appraisalTemplateSummaryMap[$categoryId]['point_count'] += $pointCountMap[(int)$item['id']] ?? 0; + } + } + + $list = array_map(function (array $item) use ($templateSummaryMap, $appraisalTemplateSummaryMap, $categoryVisuals) { + $summary = $templateSummaryMap[(int)$item['id']] ?? ['template_count' => 0, 'item_count' => 0]; + $appraisalSummary = $appraisalTemplateSummaryMap[(int)$item['id']] ?? ['template_count' => 0, 'point_count' => 0]; + $codeKey = $this->categoryMatchKey((string)$item['code']); + $nameKey = $this->categoryMatchKey((string)$item['name']); + return [ + 'id' => (int)$item['id'], + 'name' => $item['name'], + 'code' => $item['code'], + 'image_url' => $categoryVisuals['code:' . $codeKey] ?? $categoryVisuals['name:' . $nameKey] ?? '', + 'sort_order' => (int)$item['sort_order'], + 'is_enabled' => (bool)$item['is_enabled'], + 'need_shipping' => (bool)$item['need_shipping'], + 'supported_service_types' => $this->decodeJsonArray($item['supported_service_types'] ?? null), + 'upload_template_count' => (int)$summary['template_count'], + 'upload_template_item_count' => (int)$summary['item_count'], + 'upload_template_summary' => (int)$summary['template_count'] > 0 + ? sprintf('%d 套模板 / %d 项采集项', (int)$summary['template_count'], (int)$summary['item_count']) + : '未配置模板', + 'appraisal_template_count' => 1, + 'appraisal_template_point_count' => (int)$appraisalSummary['point_count'], + 'appraisal_template_summary' => sprintf('%d 个自定义鉴定项', (int)$appraisalSummary['point_count']), + ]; + }, $rows); + + return api_success(['list' => $list]); + } + + public function uploadTemplates(Request $request) + { + $categoryId = (int)$request->input('category_id', 0); + if ($categoryId <= 0) { + return api_error('品类 ID 不能为空', 422); + } + + $category = Db::name('catalog_categories')->where('id', $categoryId)->find(); + if (!$category) { + return api_error('品类不存在', 404); + } + + $serviceProviders = $this->decodeJsonArray($category['supported_service_types'] ?? null); + if (!$serviceProviders) { + $serviceProviders = ['anxinyan']; + } + + $existingRows = Db::name('upload_templates') + ->where('scope_type', 'category') + ->where('scope_id', $categoryId) + ->whereIn('service_provider', $serviceProviders) + ->order('id', 'desc') + ->select() + ->toArray(); + + $existingMap = []; + foreach ($existingRows as $row) { + $provider = (string)($row['service_provider'] ?? ''); + if ($provider === '' || isset($existingMap[$provider])) { + continue; + } + $existingMap[$provider] = $row; + } + + $list = array_map(function (string $serviceProvider) use ($categoryId, $category, $existingMap, $request) { + $existing = $existingMap[$serviceProvider] ?? null; + $items = []; + if ($existing) { + $itemRows = Db::name('upload_template_items') + ->where('template_id', (int)$existing['id']) + ->order('sort_order', 'asc') + ->order('id', 'asc') + ->select() + ->toArray(); + + $items = array_map(fn (array $item) => [ + 'id' => (int)$item['id'], + 'item_code' => (string)$item['item_code'], + 'item_name' => (string)$item['item_name'], + 'is_required' => (bool)$item['is_required'], + 'guide_text' => (string)$item['guide_text'], + 'sample_image_url' => $this->templateSampleImageService()->normalizeUrl((string)$item['sample_image_url'], $request), + 'max_upload_count' => (int)$item['max_upload_count'], + 'sort_order' => (int)$item['sort_order'], + 'is_enabled' => (bool)$item['is_enabled'], + ], $itemRows); + } + + return [ + 'id' => $existing ? (int)$existing['id'] : null, + 'category_id' => $categoryId, + 'category_name' => (string)$category['name'], + 'service_provider' => $serviceProvider, + 'service_provider_text' => $this->serviceProviderText($serviceProvider), + 'name' => $existing['name'] ?? sprintf('%s-%s上传模板', (string)$category['name'], $this->serviceProviderText($serviceProvider)), + 'code' => $existing['code'] ?? sprintf('upload_category_%d_%s', $categoryId, $serviceProvider), + 'is_enabled' => $existing ? (bool)$existing['is_enabled'] : true, + 'is_default' => $existing ? (bool)$existing['is_default'] : ($serviceProvider === 'anxinyan'), + 'items' => $items, + ]; + }, $serviceProviders); + + return api_success([ + 'category' => [ + 'id' => $categoryId, + 'name' => (string)$category['name'], + 'code' => (string)$category['code'], + ], + 'list' => $list, + ]); + } + + public function saveUploadTemplates(Request $request) + { + $categoryId = (int)$request->input('category_id', 0); + $templates = $request->input('templates', []); + if ($categoryId <= 0) { + return api_error('品类 ID 不能为空', 422); + } + if (!is_array($templates)) { + return api_error('模板数据格式不正确', 422); + } + + $category = Db::name('catalog_categories')->where('id', $categoryId)->find(); + if (!$category) { + return api_error('品类不存在', 404); + } + + $serviceProviders = $this->decodeJsonArray($category['supported_service_types'] ?? null); + if (!$serviceProviders) { + $serviceProviders = ['anxinyan']; + } + $allowedProviders = array_fill_keys($serviceProviders, true); + + $now = date('Y-m-d H:i:s'); + $defaultTemplateId = 0; + $orphanSampleImageUrls = []; + + Db::startTrans(); + try { + foreach ($templates as $template) { + if (!is_array($template)) { + continue; + } + + $serviceProvider = trim((string)($template['service_provider'] ?? '')); + if ($serviceProvider === '' || !isset($allowedProviders[$serviceProvider])) { + continue; + } + + $templateId = (int)($template['id'] ?? 0); + $exists = null; + if ($templateId > 0) { + $exists = Db::name('upload_templates')->where('id', $templateId)->find(); + } + if (!$exists) { + $exists = Db::name('upload_templates') + ->where('scope_type', 'category') + ->where('scope_id', $categoryId) + ->where('service_provider', $serviceProvider) + ->order('id', 'desc') + ->find(); + } + + $payload = [ + 'name' => trim((string)($template['name'] ?? '')) ?: sprintf('%s-%s上传模板', (string)$category['name'], $this->serviceProviderText($serviceProvider)), + 'code' => trim((string)($template['code'] ?? '')) ?: sprintf('upload_category_%d_%s', $categoryId, $serviceProvider), + 'scope_type' => 'category', + 'scope_id' => $categoryId, + 'service_provider' => $serviceProvider, + 'is_default' => !empty($template['is_default']) ? 1 : 0, + 'is_enabled' => array_key_exists('is_enabled', $template) ? (!empty($template['is_enabled']) ? 1 : 0) : 1, + 'updated_at' => $now, + ]; + + if ($exists) { + Db::name('upload_templates')->where('id', (int)$exists['id'])->update($payload); + $savedTemplateId = (int)$exists['id']; + } else { + $payload['created_at'] = $now; + $savedTemplateId = (int)Db::name('upload_templates')->insertGetId($payload); + } + + if ($serviceProvider === 'anxinyan') { + $defaultTemplateId = $savedTemplateId; + } + + $existingItemRows = Db::name('upload_template_items') + ->where('template_id', $savedTemplateId) + ->select() + ->toArray(); + $existingSampleUrls = array_values(array_filter(array_map( + fn (array $item) => $this->templateSampleImageService()->storagePath((string)($item['sample_image_url'] ?? '')), + $existingItemRows + ))); + + Db::name('upload_template_items')->where('template_id', $savedTemplateId)->delete(); + $items = is_array($template['items'] ?? null) ? $template['items'] : []; + $insertRows = []; + $nextSampleUrls = []; + foreach ($items as $index => $item) { + if (!is_array($item)) { + continue; + } + $itemCode = trim((string)($item['item_code'] ?? '')); + $itemName = trim((string)($item['item_name'] ?? '')); + if ($itemCode === '' || $itemName === '') { + continue; + } + + $insertRows[] = [ + 'template_id' => $savedTemplateId, + 'item_code' => $itemCode, + 'item_name' => $itemName, + 'is_required' => !empty($item['is_required']) ? 1 : 0, + 'guide_text' => trim((string)($item['guide_text'] ?? '')), + 'sample_image_url' => $this->templateSampleImageService()->storagePath((string)($item['sample_image_url'] ?? '')), + 'max_upload_count' => max(1, (int)($item['max_upload_count'] ?? 1)), + 'sort_order' => (int)($item['sort_order'] ?? (($index + 1) * 10)), + 'is_enabled' => array_key_exists('is_enabled', $item) ? (!empty($item['is_enabled']) ? 1 : 0) : 1, + 'created_at' => $now, + 'updated_at' => $now, + ]; + $sampleUrl = $this->templateSampleImageService()->storagePath((string)($item['sample_image_url'] ?? '')); + if ($sampleUrl !== '') { + $nextSampleUrls[] = $sampleUrl; + } + } + + if ($insertRows) { + Db::name('upload_template_items')->insertAll($insertRows); + } + + $removedSampleUrls = array_values(array_diff($existingSampleUrls, $nextSampleUrls)); + if ($removedSampleUrls) { + $orphanSampleImageUrls = array_values(array_unique(array_merge($orphanSampleImageUrls, $removedSampleUrls))); + } + } + + Db::name('catalog_categories')->where('id', $categoryId)->update([ + 'default_upload_template_id' => $defaultTemplateId > 0 ? $defaultTemplateId : null, + 'updated_at' => $now, + ]); + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('上传模板保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + foreach ($orphanSampleImageUrls as $fileUrl) { + $this->templateSampleImageService()->delete($fileUrl); + } + + return api_success([ + 'category_id' => $categoryId, + ], '上传模板已保存'); + } + + public function appraisalTemplates(Request $request) + { + $categoryId = (int)$request->input('category_id', 0); + if ($categoryId <= 0) { + return api_error('品类 ID 不能为空', 422); + } + + $category = Db::name('catalog_categories')->where('id', $categoryId)->find(); + if (!$category) { + return api_error('品类不存在', 404); + } + + $template = Db::name('appraisal_templates') + ->where('scope_type', 'category') + ->where('scope_id', $categoryId) + ->where('is_enabled', 1) + ->order('is_default', 'desc') + ->order('id', 'desc') + ->find(); + + $points = []; + if ($template) { + $pointRows = Db::name('appraisal_template_key_points') + ->where('template_id', (int)$template['id']) + ->order('sort_order', 'asc') + ->order('id', 'asc') + ->select() + ->toArray(); + + $points = array_map(fn (array $item) => [ + 'id' => (int)$item['id'], + 'point_code' => (string)$item['point_code'], + 'point_name' => (string)$item['point_name'], + 'point_type' => (string)$item['point_type'], + 'options' => $this->decodeJsonArray($item['options_json'] ?? null), + 'sort_order' => (int)$item['sort_order'], + 'is_required' => (bool)$item['is_required'], + ], $pointRows); + } + + $payload = [ + 'id' => $template ? (int)$template['id'] : null, + 'category_id' => $categoryId, + 'category_name' => (string)$category['name'], + 'service_provider' => 'category', + 'service_provider_text' => '通用品类模板', + 'name' => $template['name'] ?? sprintf('%s鉴定模板', (string)$category['name']), + 'code' => $template['code'] ?? sprintf('appraisal_category_%d', $categoryId), + 'is_enabled' => true, + 'is_default' => true, + 'result_options' => [], + 'condition_options' => [], + 'valuation_hint' => '', + 'key_points' => $points, + ]; + + return api_success([ + 'category' => [ + 'id' => $categoryId, + 'name' => (string)$category['name'], + 'code' => (string)$category['code'], + ], + 'template' => $payload, + 'list' => [$payload], + ]); + } + + public function saveAppraisalTemplates(Request $request) + { + $categoryId = (int)$request->input('category_id', 0); + $template = $request->input('template', null); + $templates = $request->input('templates', []); + if ($categoryId <= 0) { + return api_error('品类 ID 不能为空', 422); + } + if (!is_array($template)) { + $template = is_array($templates) ? ($templates[0] ?? []) : []; + } + if (!is_array($template)) { + return api_error('模板数据格式不正确', 422); + } + + $category = Db::name('catalog_categories')->where('id', $categoryId)->find(); + if (!$category) { + return api_error('品类不存在', 404); + } + + $now = date('Y-m-d H:i:s'); + + Db::startTrans(); + try { + $exists = Db::name('appraisal_templates') + ->where('scope_type', 'category') + ->where('scope_id', $categoryId) + ->order('is_default', 'desc') + ->order('id', 'desc') + ->find(); + + $payload = [ + 'name' => sprintf('%s鉴定模板', (string)$category['name']), + 'code' => sprintf('appraisal_category_%d', $categoryId), + 'scope_type' => 'category', + 'scope_id' => $categoryId, + 'service_provider' => 'category', + 'is_default' => 1, + 'is_enabled' => 1, + 'result_options_json' => json_encode([], JSON_UNESCAPED_UNICODE), + 'condition_rule_json' => json_encode([], JSON_UNESCAPED_UNICODE), + 'valuation_rule_json' => json_encode([], JSON_UNESCAPED_UNICODE), + 'updated_at' => $now, + ]; + + if ($exists) { + Db::name('appraisal_templates')->where('id', (int)$exists['id'])->update($payload); + $savedTemplateId = (int)$exists['id']; + } else { + $payload['created_at'] = $now; + $savedTemplateId = (int)Db::name('appraisal_templates')->insertGetId($payload); + } + + $otherTemplateIds = Db::name('appraisal_templates') + ->where('scope_type', 'category') + ->where('scope_id', $categoryId) + ->where('id', '<>', $savedTemplateId) + ->column('id'); + if ($otherTemplateIds) { + Db::name('appraisal_templates')->whereIn('id', $otherTemplateIds)->update([ + 'is_enabled' => 0, + 'is_default' => 0, + 'updated_at' => $now, + ]); + } + + Db::name('appraisal_template_key_points')->where('template_id', $savedTemplateId)->delete(); + $points = is_array($template['key_points'] ?? null) ? $template['key_points'] : []; + $insertRows = []; + foreach ($points as $index => $point) { + if (!is_array($point)) { + continue; + } + $pointName = trim((string)($point['point_name'] ?? '')); + if ($pointName === '') { + continue; + } + $pointCode = $this->normalizeCode((string)($point['point_code'] ?? '')) ?: sprintf('point_%d', $index + 1); + + $pointType = trim((string)($point['point_type'] ?? 'text')); + if (!in_array($pointType, ['text', 'textarea', 'select', 'boolean'], true)) { + $pointType = 'text'; + } + + $insertRows[] = [ + 'template_id' => $savedTemplateId, + 'point_code' => $pointCode, + 'point_name' => $pointName, + 'point_type' => $pointType, + 'options_json' => json_encode($this->normalizeArray($point['options'] ?? []), JSON_UNESCAPED_UNICODE), + 'sort_order' => (int)($point['sort_order'] ?? (($index + 1) * 10)), + 'is_required' => !empty($point['is_required']) ? 1 : 0, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + if ($insertRows) { + Db::name('appraisal_template_key_points')->insertAll($insertRows); + } + + Db::name('catalog_categories')->where('id', $categoryId)->update([ + 'default_appraisal_template_id' => $savedTemplateId, + 'updated_at' => $now, + ]); + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('鉴定模板保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success([ + 'category_id' => $categoryId, + ], '鉴定模板已保存'); + } + + public function uploadTemplateSampleImage(Request $request) + { + try { + $asset = $this->templateSampleImageService()->upload($request); + return api_success($asset, '示例图上传成功'); + } catch (\Throwable $e) { + return api_error($e->getMessage(), 422); + } + } + + public function deleteUploadTemplateSampleImage(Request $request) + { + $fileUrl = trim((string)$request->input('file_url', '')); + if ($fileUrl === '') { + return api_error('文件地址不能为空', 422); + } + + $this->templateSampleImageService()->delete($fileUrl); + + return api_success([ + 'file_url' => $fileUrl, + ], '示例图已删除'); + } + + public function saveCategory(Request $request) + { + $id = (int)$request->input('id', 0); + $name = trim((string)$request->input('name', '')); + $code = trim((string)$request->input('code', '')); + $imageUrl = trim((string)$request->input('image_url', '')); + + if ($name === '' || $code === '') { + return api_error('品类名称和编码不能为空', 422); + } + + $previous = $id > 0 + ? Db::name('catalog_categories')->field(['name', 'code'])->where('id', $id)->find() + : null; + + $payload = [ + 'name' => $name, + 'code' => $code, + 'sort_order' => (int)$request->input('sort_order', 0), + 'is_enabled' => $request->input('is_enabled', true) ? 1 : 0, + 'need_shipping' => $request->input('need_shipping', true) ? 1 : 0, + 'supported_service_types' => json_encode($this->normalizeArray($request->input('supported_service_types', [])), JSON_UNESCAPED_UNICODE), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + if ($id > 0) { + Db::name('catalog_categories')->where('id', $id)->update($payload); + $this->saveCategoryVisual($name, $code, $imageUrl, is_array($previous) ? $previous : null); + return api_success(['id' => $id], '更新成功'); + } + + $payload['created_at'] = date('Y-m-d H:i:s'); + $newId = Db::name('catalog_categories')->insertGetId($payload); + $this->saveCategoryVisual($name, $code, $imageUrl); + return api_success(['id' => (int)$newId], '创建成功'); + } + + public function brands(Request $request) + { + $rows = Db::name('catalog_brands') + ->alias('b') + ->leftJoin('catalog_brand_categories cbc', 'cbc.brand_id = b.id') + ->leftJoin('catalog_categories c', 'c.id = cbc.category_id') + ->field([ + 'b.id', + 'b.name', + 'b.en_name', + 'b.code', + 'b.sort_order', + 'b.is_enabled', + 'b.supported_service_types', + 'GROUP_CONCAT(DISTINCT cbc.category_id) AS category_ids', + 'GROUP_CONCAT(DISTINCT c.name) AS category_names', + ]) + ->group('b.id') + ->order('b.sort_order', 'asc') + ->select() + ->toArray(); + + $list = array_map(function (array $item) { + return [ + 'id' => (int)$item['id'], + 'name' => $item['name'], + 'en_name' => $item['en_name'], + 'code' => $item['code'], + 'sort_order' => (int)$item['sort_order'], + 'is_enabled' => (bool)$item['is_enabled'], + 'category_ids' => $this->decodeIntList($item['category_ids'] ?? ''), + 'category_names' => $item['category_names'] ?: '', + 'supported_service_types' => $this->decodeJsonArray($item['supported_service_types'] ?? null), + ]; + }, $rows); + + return api_success(['list' => $list]); + } + + public function saveBrand(Request $request) + { + $id = (int)$request->input('id', 0); + $name = trim((string)$request->input('name', '')); + $enName = trim((string)$request->input('en_name', '')); + $code = trim((string)$request->input('code', '')); + $categoryIds = $this->normalizeIntArray($request->input('category_ids', [])); + + if ($name === '' || $code === '') { + return api_error('品牌名称和编码不能为空', 422); + } + + $payload = [ + 'name' => $name, + 'en_name' => $enName, + 'code' => $code, + 'sort_order' => (int)$request->input('sort_order', 0), + 'is_enabled' => $request->input('is_enabled', true) ? 1 : 0, + 'supported_service_types' => json_encode($this->normalizeArray($request->input('supported_service_types', [])), JSON_UNESCAPED_UNICODE), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + Db::startTrans(); + try { + if ($id > 0) { + Db::name('catalog_brands')->where('id', $id)->update($payload); + Db::name('catalog_brand_categories')->where('brand_id', $id)->delete(); + foreach ($categoryIds as $categoryId) { + Db::name('catalog_brand_categories')->insert([ + 'brand_id' => $id, + 'category_id' => $categoryId, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + Db::commit(); + return api_success(['id' => $id], '更新成功'); + } + + $payload['logo'] = ''; + $payload['created_at'] = date('Y-m-d H:i:s'); + $newId = Db::name('catalog_brands')->insertGetId($payload); + foreach ($categoryIds as $categoryId) { + Db::name('catalog_brand_categories')->insert([ + 'brand_id' => $newId, + 'category_id' => $categoryId, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } + Db::commit(); + return api_success(['id' => (int)$newId], '创建成功'); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('品牌保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + } + + private function decodeJsonArray(mixed $value): array + { + if (is_array($value)) { + return array_values($value); + } + if (is_string($value) && $value !== '') { + $decoded = json_decode($value, true); + return is_array($decoded) ? array_values($decoded) : []; + } + return []; + } + + private function decodeJsonObject(mixed $value): array + { + if (is_array($value)) { + return $value; + } + if (is_string($value) && $value !== '') { + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; + } + return []; + } + + private function normalizeArray(mixed $value): array + { + if (is_array($value)) { + return array_values(array_filter(array_map(static fn ($item) => trim((string)$item), $value), static fn ($item) => $item !== '')); + } + if (is_string($value) && $value !== '') { + return array_values(array_filter(array_map('trim', explode(',', $value)), static fn ($item) => $item !== '')); + } + return []; + } + + private function normalizeCode(string $value): string + { + $normalized = strtolower(trim($value)); + $normalized = (string)preg_replace('/[^a-z0-9_]+/', '_', $normalized); + $normalized = trim($normalized, '_'); + return $normalized; + } + + private function normalizeIntArray(mixed $value): array + { + if (!is_array($value)) { + return []; + } + return array_values(array_filter(array_map(static fn ($item) => (int)$item, $value), static fn ($item) => $item > 0)); + } + + private function decodeIntList(string $value): array + { + if ($value === '') { + return []; + } + return array_values(array_filter(array_map(static fn ($item) => (int)$item, explode(',', $value)), static fn ($item) => $item > 0)); + } + + private function serviceProviderText(string $serviceProvider): string + { + return $serviceProvider === 'zhongjian' ? '中检鉴定' : '实物鉴定'; + } + + private function categoryVisualMap(Request $request): array + { + $items = (new ContentService())->getHomeConfig()['category_visuals'] ?? []; + if (!is_array($items)) { + return []; + } + + $map = []; + $storage = new FileStorageService(); + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $imageUrl = trim((string)($item['image_url'] ?? '')); + if ($imageUrl === '') { + continue; + } + $imageUrl = $storage->normalizeUrl($imageUrl, $request); + + $categoryCode = $this->categoryMatchKey((string)($item['category_code'] ?? '')); + if ($categoryCode !== '') { + $map['code:' . $categoryCode] = $imageUrl; + } + + $categoryName = $this->categoryMatchKey((string)($item['category_name'] ?? '')); + if ($categoryName !== '') { + $map['name:' . $categoryName] = $imageUrl; + } + } + + return $map; + } + + private function saveCategoryVisual(string $categoryName, string $categoryCode, string $imageUrl, ?array $previous = null): void + { + $contentService = new ContentService(); + $homeConfig = $contentService->getHomeConfig(); + $items = is_array($homeConfig['category_visuals'] ?? null) ? $homeConfig['category_visuals'] : []; + + $removeKeys = [ + 'code:' . $this->categoryMatchKey($categoryCode), + 'name:' . $this->categoryMatchKey($categoryName), + ]; + if ($previous) { + $removeKeys[] = 'code:' . $this->categoryMatchKey((string)($previous['code'] ?? '')); + $removeKeys[] = 'name:' . $this->categoryMatchKey((string)($previous['name'] ?? '')); + } + $removeKeys = array_values(array_filter(array_unique($removeKeys), static fn ($key) => !str_ends_with($key, ':'))); + + $nextItems = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + $itemKeys = [ + 'code:' . $this->categoryMatchKey((string)($item['category_code'] ?? '')), + 'name:' . $this->categoryMatchKey((string)($item['category_name'] ?? '')), + ]; + if (array_intersect($removeKeys, $itemKeys)) { + continue; + } + $nextItems[] = $item; + } + + $nextItems[] = [ + 'category_name' => $categoryName, + 'category_code' => $categoryCode, + 'image_url' => $imageUrl, + ]; + + $homeConfig['category_visuals'] = $nextItems; + $contentService->saveHomeConfig($homeConfig); + } + + private function categoryMatchKey(string $value): string + { + $value = trim($value); + $normalized = preg_replace('/[\s\p{Cf}]+/u', '', $value); + + return strtolower($normalized ?? $value); + } + + private function templateSampleImageService(): CatalogTemplateSampleImageService + { + return new CatalogTemplateSampleImageService(); + } +} diff --git a/server-api/app/controller/admin/ContentsController.php b/server-api/app/controller/admin/ContentsController.php new file mode 100644 index 0000000..5812fcd --- /dev/null +++ b/server-api/app/controller/admin/ContentsController.php @@ -0,0 +1,155 @@ +service(); + + return api_success([ + 'home_config' => $service->getHomeConfig(), + 'policy_config' => $service->getPolicyConfig(), + 'meta_config' => $service->getMetaConfig(), + 'help_articles' => $service->getHelpArticles(false), + ]); + } + + public function home(Request $request) + { + return api_success([ + 'home_config' => $this->service()->getHomeConfig(), + ]); + } + + public function saveHome(Request $request) + { + try { + $this->service()->saveHomeConfig((array)$request->input('home_config', [])); + return api_success([ + 'home_config' => $this->service()->getHomeConfig(), + ], '首页内容已保存'); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), 422); + } catch (\Throwable $e) { + return api_error('首页内容保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + } + + public function uploadImage(Request $request) + { + try { + return api_success((new ContentImageService())->upload($request), '图片已上传'); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), 422); + } catch (\Throwable $e) { + return api_error('图片上传失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + } + + public function helpArticles(Request $request) + { + return api_success([ + 'list' => $this->service()->getHelpArticles(false), + ]); + } + + public function policy(Request $request) + { + return api_success([ + 'policy_config' => $this->service()->getPolicyConfig(), + ]); + } + + public function savePolicy(Request $request) + { + try { + $this->service()->savePolicyConfig((array)$request->input('policy_config', [])); + return api_success([ + 'policy_config' => $this->service()->getPolicyConfig(), + ], '协议与说明已保存'); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), 422); + } catch (\Throwable $e) { + return api_error('协议与说明保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + } + + public function meta(Request $request) + { + return api_success([ + 'meta_config' => $this->service()->getMetaConfig(), + ]); + } + + public function saveMeta(Request $request) + { + try { + $this->service()->saveMetaConfig((array)$request->input('meta_config', [])); + return api_success([ + 'meta_config' => $this->service()->getMetaConfig(), + ], '帮助分类与报告提示已保存'); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), 422); + } catch (\Throwable $e) { + return api_error('帮助分类与报告提示保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + } + + public function saveHelpArticle(Request $request) + { + try { + $id = $this->service()->saveHelpArticle([ + 'id' => (int)$request->input('id', 0), + 'category' => trim((string)$request->input('category', 'service')), + 'title' => trim((string)$request->input('title', '')), + 'summary' => trim((string)$request->input('summary', '')), + 'keywords' => (array)$request->input('keywords', []), + 'content_blocks' => (array)$request->input('content_blocks', []), + 'is_recommended' => (bool)$request->input('is_recommended', false), + 'is_enabled' => (bool)$request->input('is_enabled', true), + 'sort_order' => (int)$request->input('sort_order', 0), + ]); + + return api_success(['id' => $id], '帮助文章已保存'); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), 422); + } catch (\Throwable $e) { + return api_error('帮助文章保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + } + + public function deleteHelpArticle(Request $request) + { + try { + $this->service()->deleteHelpArticle((int)$request->input('id', 0)); + return api_success([], '帮助文章已删除'); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), 422); + } catch (\Throwable $e) { + return api_error('帮助文章删除失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + } + + private function service(): ContentService + { + return new ContentService(); + } +} diff --git a/server-api/app/controller/admin/CustomersController.php b/server-api/app/controller/admin/CustomersController.php new file mode 100644 index 0000000..e367af3 --- /dev/null +++ b/server-api/app/controller/admin/CustomersController.php @@ -0,0 +1,393 @@ +input('keyword', '')); + $status = trim((string)$request->input('status', '')); + + $query = Db::name('enterprise_customers')->order('id', 'desc'); + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->whereRaw( + '(customer_code LIKE :keyword_code OR customer_name LIKE :keyword_name OR contact_name LIKE :keyword_contact OR contact_mobile LIKE :keyword_mobile)', + [ + 'keyword_code' => "%{$keyword}%", + 'keyword_name' => "%{$keyword}%", + 'keyword_contact' => "%{$keyword}%", + 'keyword_mobile' => "%{$keyword}%", + ] + ); + }); + } + if (in_array($status, ['enabled', 'disabled'], true)) { + $query->where('status', $status); + } + + $rows = $query->select()->toArray(); + $customerIds = array_map(static fn(array $item) => (int)$item['id'], $rows); + $appCountMap = $this->countMap('enterprise_customer_apps', $customerIds); + $orderCountMap = $this->countMap('enterprise_customer_order_refs', $customerIds); + $eventCountMap = $this->countMap('enterprise_order_events', $customerIds); + + return api_success([ + 'list' => array_map(function (array $item) use ($appCountMap, $orderCountMap, $eventCountMap) { + $customer = $this->customerService()->formatCustomer($item); + $id = (int)$customer['id']; + $customer['app_count'] = (int)($appCountMap[$id] ?? 0); + $customer['order_count'] = (int)($orderCountMap[$id] ?? 0); + $customer['event_count'] = (int)($eventCountMap[$id] ?? 0); + return $customer; + }, $rows), + ]); + } + + public function detail(Request $request) + { + $id = (int)$request->input('id', 0); + if ($id <= 0) { + return api_error('客户 ID 不能为空', 422); + } + + $customer = Db::name('enterprise_customers')->where('id', $id)->find(); + if (!$customer) { + return api_error('客户不存在', 404); + } + + $apps = Db::name('enterprise_customer_apps') + ->where('customer_id', $id) + ->order('id', 'desc') + ->select() + ->toArray(); + + return api_success([ + 'customer' => $this->customerService()->formatCustomer($customer), + 'apps' => array_map(fn(array $item) => $this->customerService()->formatApp($item), $apps), + ]); + } + + public function save(Request $request) + { + $id = (int)$request->input('id', 0); + $customerName = trim((string)$request->input('customer_name', '')); + if ($customerName === '') { + return api_error('客户名称不能为空', 422); + } + + $status = trim((string)$request->input('status', 'enabled')); + if (!in_array($status, ['enabled', 'disabled'], true)) { + return api_error('客户状态不正确', 422); + } + + $webhookUrl = trim((string)$request->input('webhook_url', '')); + if ($webhookUrl !== '' && !preg_match('/^https?:\/\//i', $webhookUrl)) { + return api_error('Webhook URL 必须以 http 或 https 开头', 422); + } + + $now = date('Y-m-d H:i:s'); + $payload = [ + 'customer_name' => $customerName, + 'contact_name' => trim((string)$request->input('contact_name', '')), + 'contact_mobile' => trim((string)$request->input('contact_mobile', '')), + 'contact_email' => trim((string)$request->input('contact_email', '')), + 'settlement_type' => 'monthly', + 'webhook_url' => $webhookUrl, + 'webhook_enabled' => $request->input('webhook_enabled', false) ? 1 : 0, + 'status' => $status, + 'remark' => trim((string)$request->input('remark', '')), + 'updated_at' => $now, + ]; + + Db::startTrans(); + try { + if ($id > 0) { + $customer = Db::name('enterprise_customers')->where('id', $id)->find(); + if (!$customer) { + Db::rollback(); + return api_error('客户不存在', 404); + } + Db::name('enterprise_customers')->where('id', $id)->update($payload); + } else { + $payload['customer_code'] = $this->customerService()->generateCustomerCode(); + $payload['created_at'] = $now; + $id = (int)Db::name('enterprise_customers')->insertGetId($payload); + } + + $customer = Db::name('enterprise_customers')->where('id', $id)->find(); + if ($customer) { + $this->customerService()->ensureVirtualUser($customer); + } + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('客户保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success([ + 'id' => $id, + ], $request->input('id', 0) ? '客户已更新' : '客户已创建'); + } + + public function createApp(Request $request) + { + $customerId = (int)$request->input('customer_id', 0); + $appName = trim((string)$request->input('app_name', '默认应用')); + if ($customerId <= 0) { + return api_error('客户 ID 不能为空', 422); + } + if ($appName === '') { + $appName = '默认应用'; + } + + $customer = Db::name('enterprise_customers')->where('id', $customerId)->find(); + if (!$customer) { + return api_error('客户不存在', 404); + } + + $secret = $this->customerService()->generateAppSecret(); + $now = date('Y-m-d H:i:s'); + $appId = (int)Db::name('enterprise_customer_apps')->insertGetId([ + 'customer_id' => $customerId, + 'app_name' => $appName, + 'app_key' => $this->customerService()->generateAppKey(), + 'app_secret_cipher' => $this->customerService()->encryptSecret($secret), + 'secret_last4' => substr($secret, -4), + 'status' => 'enabled', + 'last_used_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $app = Db::name('enterprise_customer_apps')->where('id', $appId)->find(); + return api_success([ + 'app' => $this->customerService()->formatApp($app), + 'app_secret' => $secret, + ], '应用 Key 已创建,请立即复制保存 Secret'); + } + + public function updateAppStatus(Request $request) + { + $id = (int)$request->input('id', 0); + $status = trim((string)$request->input('status', '')); + if ($id <= 0 || !in_array($status, ['enabled', 'disabled'], true)) { + return api_error('应用 ID 或状态不正确', 422); + } + + $app = Db::name('enterprise_customer_apps')->where('id', $id)->find(); + if (!$app) { + return api_error('应用不存在', 404); + } + + Db::name('enterprise_customer_apps')->where('id', $id)->update([ + 'status' => $status, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + return api_success([ + 'id' => $id, + 'status' => $status, + ], $status === 'enabled' ? '应用已启用' : '应用已停用'); + } + + public function resetAppSecret(Request $request) + { + $id = (int)$request->input('id', 0); + if ($id <= 0) { + return api_error('应用 ID 不能为空', 422); + } + + $app = Db::name('enterprise_customer_apps')->where('id', $id)->find(); + if (!$app) { + return api_error('应用不存在', 404); + } + + $secret = $this->customerService()->generateAppSecret(); + Db::name('enterprise_customer_apps')->where('id', $id)->update([ + 'app_secret_cipher' => $this->customerService()->encryptSecret($secret), + 'secret_last4' => substr($secret, -4), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + $fresh = Db::name('enterprise_customer_apps')->where('id', $id)->find(); + return api_success([ + 'app' => $this->customerService()->formatApp($fresh), + 'app_secret' => $secret, + ], '应用 Secret 已重置,请立即复制保存'); + } + + public function orders(Request $request) + { + $customerId = (int)$request->input('customer_id', 0); + if ($customerId <= 0) { + return api_error('客户 ID 不能为空', 422); + } + + $rows = Db::name('enterprise_customer_order_refs') + ->alias('r') + ->leftJoin('orders o', 'o.id = r.order_id') + ->leftJoin('order_products p', 'p.order_id = r.order_id') + ->field([ + 'r.id', + 'r.customer_id', + 'r.external_order_no', + 'r.order_id', + 'r.order_no', + 'r.appraisal_no', + 'r.created_at', + 'o.order_status', + 'o.display_status', + 'o.pay_amount', + 'p.product_name', + ]) + ->where('r.customer_id', $customerId) + ->order('r.id', 'desc') + ->select() + ->toArray(); + + return api_success([ + 'list' => array_map(static fn(array $item) => [ + 'id' => (int)$item['id'], + 'customer_id' => (int)$item['customer_id'], + 'external_order_no' => (string)$item['external_order_no'], + 'order_id' => (int)$item['order_id'], + 'order_no' => (string)$item['order_no'], + 'appraisal_no' => (string)$item['appraisal_no'], + 'product_name' => (string)($item['product_name'] ?: '待完善物品信息'), + 'order_status' => (string)($item['order_status'] ?? ''), + 'display_status' => (string)($item['display_status'] ?? ''), + 'pay_amount' => (float)($item['pay_amount'] ?? 0), + 'created_at' => (string)$item['created_at'], + ], $rows), + ]); + } + + public function events(Request $request) + { + $customerId = (int)$request->input('customer_id', 0); + if ($customerId <= 0) { + return api_error('客户 ID 不能为空', 422); + } + + $rows = Db::name('enterprise_order_events') + ->where('customer_id', $customerId) + ->order('id', 'desc') + ->limit(200) + ->select() + ->toArray(); + + return api_success([ + 'list' => array_map(fn(array $item) => $this->webhookService()->formatEvent($item), $rows), + ]); + } + + public function deliveries(Request $request) + { + $customerId = (int)$request->input('customer_id', 0); + $eventId = (int)$request->input('event_id', 0); + if ($customerId <= 0 && $eventId <= 0) { + return api_error('客户 ID 或事件 ID 不能为空', 422); + } + + $query = Db::name('enterprise_webhook_deliveries')->order('id', 'desc')->limit(200); + if ($customerId > 0) { + $query->where('customer_id', $customerId); + } + if ($eventId > 0) { + $query->where('event_id', $eventId); + } + + $rows = $query->select()->toArray(); + + return api_success([ + 'list' => array_map(fn(array $item) => $this->webhookService()->formatDelivery($item), $rows), + ]); + } + + public function resendEvent(Request $request) + { + $eventId = (int)$request->input('event_id', 0); + if ($eventId <= 0) { + return api_error('事件 ID 不能为空', 422); + } + + try { + $result = $this->webhookService()->deliverEvent($eventId, true); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), 422); + } catch (\Throwable $e) { + return api_error('事件补发失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success([ + 'delivery' => $this->webhookService()->formatDelivery($result['delivery']), + 'sent' => (bool)$result['sent'], + ], $result['sent'] ? '事件已补发成功' : '事件补发未成功,请查看推送记录'); + } + + public function orderProgress(Request $request) + { + $customerId = (int)$request->input('customer_id', 0); + $externalOrderNo = trim((string)$request->input('external_order_no', '')); + if ($customerId <= 0 || $externalOrderNo === '') { + return api_error('客户 ID 和外部订单号不能为空', 422); + } + + $customer = Db::name('enterprise_customers')->where('id', $customerId)->find(); + if (!$customer) { + return api_error('客户不存在', 404); + } + + try { + $order = (new EnterpriseOrderService())->findOrder($customer, $externalOrderNo, ''); + } catch (\Throwable $e) { + return api_error($e->getMessage(), 404); + } + + return api_success([ + 'order' => $order, + ]); + } + + private function countMap(string $table, array $customerIds): array + { + if (!$customerIds) { + return []; + } + + $rows = Db::name($table) + ->field('customer_id, COUNT(*) AS total') + ->whereIn('customer_id', $customerIds) + ->group('customer_id') + ->select() + ->toArray(); + + $map = []; + foreach ($rows as $row) { + $map[(int)$row['customer_id']] = (int)$row['total']; + } + return $map; + } + + private function customerService(): EnterpriseCustomerService + { + return new EnterpriseCustomerService(); + } + + private function webhookService(): EnterpriseWebhookService + { + return new EnterpriseWebhookService(); + } +} diff --git a/server-api/app/controller/admin/DashboardController.php b/server-api/app/controller/admin/DashboardController.php new file mode 100644 index 0000000..d037f3d --- /dev/null +++ b/server-api/app/controller/admin/DashboardController.php @@ -0,0 +1,66 @@ +count(); + $pendingCount = (int)Db::name('orders')->whereIn('order_status', [ + 'pending_payment', + 'pending_submission', + 'pending_shipping', + 'pending_supplement', + ])->count(); + $processingCount = (int)Db::name('orders')->whereIn('order_status', [ + 'received', + 'pending_assignment', + 'in_first_review', + 'in_final_review', + 'generating_report', + ])->count(); + $pendingReturnCount = (int)Db::name('orders')->where('order_status', 'report_published')->count(); + $returningCount = (int)Db::name('orders') + ->alias('o') + ->join('order_logistics l', 'l.order_id = o.id') + ->where('o.order_status', 'completed') + ->where('l.logistics_type', 'return_to_user') + ->where('l.tracking_no', '<>', '') + ->where('l.tracking_status', '<>', 'received') + ->count(); + + return api_success([ + 'cards' => [ + [ + 'title' => '订单总量', + 'value' => $totalOrders, + 'desc' => '当前数据库内订单总数', + ], + [ + 'title' => '待处理订单', + 'value' => $pendingCount, + 'desc' => '待支付、待补资料、待寄送等订单', + ], + [ + 'title' => '处理中订单', + 'value' => $processingCount, + 'desc' => '已进入鉴定流程的订单', + ], + [ + 'title' => '待寄回订单', + 'value' => $pendingReturnCount, + 'desc' => '报告已出具,等待平台安排回寄', + ], + [ + 'title' => '回寄途中', + 'value' => $returningCount, + 'desc' => '已登记回寄运单,等待用户签收', + ], + ], + ]); + } +} diff --git a/server-api/app/controller/admin/MaterialsController.php b/server-api/app/controller/admin/MaterialsController.php new file mode 100644 index 0000000..2790e98 --- /dev/null +++ b/server-api/app/controller/admin/MaterialsController.php @@ -0,0 +1,80 @@ + $this->service()->listBatches([ + 'keyword' => $request->input('keyword', ''), + 'qr_url' => $request->input('qr_url', ''), + 'verify_code' => $request->input('verify_code', ''), + 'date_start' => $request->input('date_start', ''), + 'date_end' => $request->input('date_end', ''), + ]), + ]); + } + + public function detail(Request $request) + { + $id = (int)$request->input('id', 0); + if ($id <= 0) { + return api_error('物料批次 ID 不能为空', 422); + } + + try { + return api_success($this->service()->detail($id, trim((string)$request->input('keyword', '')))); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), $e->getCode() ?: 404); + } + } + + public function create(Request $request) + { + $count = (int)$request->input('count', 0); + $remark = trim((string)$request->input('remark', '')); + $adminId = (int)$request->header('x-admin-id', 0); + $adminName = trim((string)$request->header('x-admin-name', '')); + + try { + return api_success($this->service()->createBatch($count, $remark, $adminId, $adminName), '物料批次已生成'); + } catch (\InvalidArgumentException $e) { + return api_error($e->getMessage(), 422); + } catch (\Throwable $e) { + return api_error('物料批次生成失败', 500, ['detail' => $e->getMessage()]); + } + } + + public function download(Request $request) + { + $id = (int)$request->input('id', 0); + if ($id <= 0) { + return api_error('物料批次 ID 不能为空', 422); + } + + try { + $file = $this->service()->downloadBatch($id, $request); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), $e->getCode() ?: 500); + } catch (\Throwable $e) { + return api_error('物料批次下载失败', 500, ['detail' => $e->getMessage()]); + } + + $filename = rawurlencode($file['filename']); + return response($file['content'], 200, [ + 'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'Content-Disposition' => "attachment; filename=\"{$file['filename']}\"; filename*=UTF-8''{$filename}", + 'Cache-Control' => 'no-store, no-cache, must-revalidate', + ]); + } + + private function service(): MaterialTagService + { + return new MaterialTagService(); + } +} diff --git a/server-api/app/controller/admin/MessagesController.php b/server-api/app/controller/admin/MessagesController.php new file mode 100644 index 0000000..16ccfcf --- /dev/null +++ b/server-api/app/controller/admin/MessagesController.php @@ -0,0 +1,165 @@ + [ + [ + 'title' => '启用模板', + 'value' => (int)Db::name('message_templates')->where('is_enabled', 1)->count(), + 'desc' => '当前已启用的消息模板数量', + ], + [ + 'title' => '触发规则', + 'value' => (int)Db::name('message_rules')->where('is_enabled', 1)->count(), + 'desc' => '当前启用的消息触发规则数量', + ], + [ + 'title' => '发送日志', + 'value' => (int)Db::name('message_logs')->count(), + 'desc' => '消息发送记录总数', + ], + [ + 'title' => '站内消息', + 'value' => (int)Db::name('user_messages')->count(), + 'desc' => '当前已生成的站内消息数量', + ], + ], + ]); + } + + public function templates(Request $request) + { + $rows = Db::name('message_templates') + ->field([ + 'id', + 'template_name', + 'template_code', + 'channel', + 'event_code', + 'title', + 'content', + 'is_enabled', + ]) + ->order('id', 'asc') + ->select() + ->toArray(); + + $list = array_map(function (array $item) { + return [ + 'id' => (int)$item['id'], + 'template_name' => $item['template_name'], + 'template_code' => $item['template_code'], + 'channel' => $item['channel'], + 'channel_text' => $this->channelText($item['channel']), + 'event_code' => $item['event_code'], + 'title' => $item['title'], + 'content' => $item['content'], + 'is_enabled' => (bool)$item['is_enabled'], + ]; + }, $rows); + + return api_success(['list' => $list]); + } + + public function saveTemplate(Request $request) + { + $id = (int)$request->input('id', 0); + $templateName = trim((string)$request->input('template_name', '')); + $templateCode = trim((string)$request->input('template_code', '')); + $channel = trim((string)$request->input('channel', '')); + $eventCode = trim((string)$request->input('event_code', '')); + + if ($templateName === '' || $templateCode === '' || $channel === '' || $eventCode === '') { + return api_error('模板名称、模板编码、发送渠道和触发事件不能为空', 422); + } + + $payload = [ + 'template_name' => $templateName, + 'template_code' => $templateCode, + 'channel' => $channel, + 'event_code' => $eventCode, + 'title' => trim((string)$request->input('title', '')), + 'content' => trim((string)$request->input('content', '')), + 'is_enabled' => $request->input('is_enabled', true) ? 1 : 0, + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + if ($id > 0) { + Db::name('message_templates')->where('id', $id)->update($payload); + return api_success(['id' => $id], '更新成功'); + } + + $payload['created_at'] = date('Y-m-d H:i:s'); + $newId = Db::name('message_templates')->insertGetId($payload); + return api_success(['id' => (int)$newId], '创建成功'); + } + + public function logs(Request $request) + { + $rows = Db::name('message_logs') + ->field([ + 'id', + 'user_id', + 'template_id', + 'biz_type', + 'biz_id', + 'channel', + 'status', + 'fail_reason', + 'sent_at', + 'created_at', + ]) + ->order('id', 'desc') + ->select() + ->toArray(); + + $templates = Db::name('message_templates')->column('template_name', 'id'); + + $list = array_map(function (array $item) use ($templates) { + return [ + 'id' => (int)$item['id'], + 'user_id' => (int)($item['user_id'] ?? 0), + 'template_name' => $templates[$item['template_id']] ?? '未知模板', + 'biz_type' => $item['biz_type'], + 'biz_id' => (int)($item['biz_id'] ?? 0), + 'channel' => $item['channel'], + 'channel_text' => $this->channelText($item['channel']), + 'status' => $item['status'], + 'status_text' => $this->logStatusText($item['status']), + 'fail_reason' => $item['fail_reason'] ?: '', + 'sent_at' => $item['sent_at'], + 'created_at' => $item['created_at'], + ]; + }, $rows); + + return api_success(['list' => $list]); + } + + private function channelText(string $channel): string + { + return match ($channel) { + 'inbox' => '站内消息', + 'sms' => '短信', + 'wechat_subscribe' => '微信订阅消息', + default => $channel, + }; + } + + private function logStatusText(string $status): string + { + return match ($status) { + 'pending' => '待发送', + 'sent' => '已发送', + 'failed' => '发送失败', + default => $status, + }; + } +} diff --git a/server-api/app/controller/admin/OrdersController.php b/server-api/app/controller/admin/OrdersController.php new file mode 100644 index 0000000..f607f79 --- /dev/null +++ b/server-api/app/controller/admin/OrdersController.php @@ -0,0 +1,952 @@ +input('keyword', '')); + $status = trim((string)$request->input('status', '')); + $serviceProvider = trim((string)$request->input('service_provider', '')); + $sourceChannel = $this->normalizeOrderSourceChannel((string)$request->input('source_channel', '')); + + $query = Db::name('orders') + ->alias('o') + ->leftJoin('order_products p', 'p.order_id = o.id') + ->field([ + 'o.id', + 'o.order_no', + 'o.appraisal_no', + 'o.service_provider', + 'o.order_status', + 'o.display_status', + 'o.estimated_finish_time', + 'o.source_channel', + 'o.source_customer_id', + 'o.pay_amount', + 'o.created_at', + 'p.product_name', + 'p.category_name', + 'p.brand_name', + ]) + ->order('o.id', 'desc'); + + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->whereRaw( + '(o.order_no LIKE :keyword_order OR o.appraisal_no LIKE :keyword_appraisal OR p.product_name LIKE :keyword_product)', + [ + 'keyword_order' => "%{$keyword}%", + 'keyword_appraisal' => "%{$keyword}%", + 'keyword_product' => "%{$keyword}%", + ] + ); + }); + } + + $specialStatusFilters = ['returning', 'completed_signed']; + if ($status !== '' && !in_array($status, $specialStatusFilters, true)) { + $query->where('o.order_status', $status); + } + + if ($serviceProvider !== '') { + $query->where('o.service_provider', $serviceProvider); + } + + if ($sourceChannel !== '') { + $query->where('o.source_channel', $sourceChannel); + } + + $rows = $query->select()->toArray(); + + $returnTrackingMap = []; + if ($rows) { + $returnRows = Db::name('order_logistics') + ->whereIn('order_id', array_column($rows, 'id')) + ->where('logistics_type', 'return_to_user') + ->order('id', 'desc') + ->select() + ->toArray(); + foreach ($returnRows as $row) { + $orderId = (int)($row['order_id'] ?? 0); + if ($orderId > 0 && !isset($returnTrackingMap[$orderId])) { + $returnTrackingMap[$orderId] = [ + 'tracking_no' => (string)($row['tracking_no'] ?? ''), + 'tracking_status' => (string)($row['tracking_status'] ?? ''), + ]; + } + } + } + + $list = array_map(function (array $item) use ($returnTrackingMap) { + return [ + 'id' => (int)$item['id'], + 'order_no' => $item['order_no'], + 'appraisal_no' => $item['appraisal_no'], + 'product_name' => $item['product_name'] ?: '待完善物品信息', + 'category_name' => $item['category_name'] ?: '', + 'brand_name' => $item['brand_name'] ?: '', + 'service_provider' => $item['service_provider'], + 'service_provider_text' => $item['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定', + 'source_channel' => $this->normalizeOrderSourceChannel((string)($item['source_channel'] ?? '')), + 'source_channel_text' => $this->sourceChannelText((string)($item['source_channel'] ?? '')), + 'source_customer_id' => (string)($item['source_customer_id'] ?? ''), + 'order_status' => $item['order_status'], + 'display_status' => $this->displayStatus( + (string)$item['order_status'], + (string)$item['display_status'], + $returnTrackingMap[(int)$item['id']]['tracking_no'] ?? '', + $returnTrackingMap[(int)$item['id']]['tracking_status'] ?? '', + ), + 'estimated_finish_time' => $item['estimated_finish_time'], + 'pay_amount' => (float)$item['pay_amount'], + 'created_at' => $item['created_at'], + ]; + }, $rows); + + if ($status === 'returning') { + $list = array_values(array_filter($list, function (array $item) { + return $item['order_status'] === 'completed' && $item['display_status'] === '物品已寄回'; + })); + } + + if ($status === 'completed_signed') { + $list = array_values(array_filter($list, function (array $item) { + return $item['order_status'] === 'completed' && $item['display_status'] === '已完成'; + })); + } + + return api_success([ + 'list' => $list, + ]); + } + + public function detail(Request $request) + { + $id = (int)$request->input('id', 0); + if (!$id) { + return api_error('订单 ID 不能为空', 422); + } + + $order = Db::name('orders')->where('id', $id)->find(); + if (!$order) { + return api_error('订单不存在', 404); + } + + $product = Db::name('order_products')->where('order_id', $id)->find(); + $extra = Db::name('order_extras')->where('order_id', $id)->find(); + $sendLogistics = Db::name('order_logistics') + ->where('order_id', $id) + ->where('logistics_type', 'send_to_center') + ->order('id', 'desc') + ->find(); + $returnLogistics = Db::name('order_logistics') + ->where('order_id', $id) + ->where('logistics_type', 'return_to_user') + ->order('id', 'desc') + ->find(); + $timeline = Db::name('order_timelines') + ->where('order_id', $id) + ->order('occurred_at', 'asc') + ->select() + ->toArray(); + + $timeline = array_map(fn (array $item) => [ + 'node_text' => $item['node_text'], + 'node_desc' => $item['node_desc'], + 'occurred_at' => $item['occurred_at'], + ], $timeline); + + $supplement = Db::name('order_supplement_tasks')->where('order_id', $id)->order('id', 'desc')->find(); + $supplementItems = []; + if ($supplement) { + $supplementItems = Db::name('order_supplement_task_items') + ->where('task_id', $supplement['id']) + ->select() + ->toArray(); + + $supplementItems = array_map(fn (array $item) => [ + 'item_name' => $item['item_name'], + 'guide_text' => $item['guide_text'], + ], $supplementItems); + } + + $report = Db::name('reports')->where('order_id', $id)->order('id', 'desc')->find(); + $hasPublishedOrderReport = $report && ($report['report_status'] ?? '') === 'published'; + $canAttemptReturnLogistics = in_array($order['order_status'], ['report_published', 'completed'], true) + && (($returnLogistics['tracking_status'] ?? '') !== 'received'); + $shippingTarget = Db::name('order_shipping_targets')->where('order_id', $id)->find(); + $returnAddress = Db::name('order_return_addresses')->where('order_id', $id)->find(); + if (!$returnAddress) { + $returnAddress = Db::name('user_addresses') + ->where('user_id', (int)$order['user_id']) + ->where('is_default', 1) + ->order('id', 'desc') + ->find() + ?: Db::name('user_addresses') + ->where('user_id', (int)$order['user_id']) + ->order('id', 'desc') + ->find(); + if ($returnAddress) { + $returnAddress = [ + 'user_address_id' => (int)$returnAddress['id'], + 'consignee' => $returnAddress['consignee'], + 'mobile' => $returnAddress['mobile'], + 'province' => $returnAddress['province'], + 'city' => $returnAddress['city'], + 'district' => $returnAddress['district'], + 'detail_address' => $returnAddress['detail_address'], + ]; + } + } + $logisticsNodes = []; + if ($sendLogistics) { + $logisticsNodes = Db::name('order_logistics_nodes') + ->where('logistics_id', $sendLogistics['id']) + ->order('node_time', 'desc') + ->select() + ->toArray(); + } + $returnLogisticsNodes = []; + if ($returnLogistics) { + $returnLogisticsNodes = Db::name('order_logistics_nodes') + ->where('logistics_id', $returnLogistics['id']) + ->order('node_time', 'desc') + ->select() + ->toArray(); + } + + return api_success([ + 'order_info' => [ + 'id' => (int)$order['id'], + 'order_no' => $order['order_no'], + 'appraisal_no' => $order['appraisal_no'], + 'service_provider' => $order['service_provider'], + 'service_provider_text' => $order['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定', + 'source_channel' => $this->normalizeOrderSourceChannel((string)($order['source_channel'] ?? '')), + 'source_channel_text' => $this->sourceChannelText((string)($order['source_channel'] ?? '')), + 'source_customer_id' => (string)($order['source_customer_id'] ?? ''), + 'order_status' => $order['order_status'], + 'display_status' => $this->displayStatus( + (string)$order['order_status'], + (string)$order['display_status'], + $returnLogistics['tracking_no'] ?? '', + $returnLogistics['tracking_status'] ?? '', + ), + 'pay_amount' => (float)$order['pay_amount'], + 'estimated_finish_time' => $order['estimated_finish_time'], + 'created_at' => $order['created_at'], + 'can_reassign_warehouse' => $order['order_status'] === 'pending_shipping' && empty($sendLogistics['tracking_no']), + 'can_mark_received' => $order['order_status'] === 'pending_shipping' + && (!empty($sendLogistics['tracking_no']) || ($order['source_channel'] ?? '') === 'enterprise_push'), + 'can_submit_return_logistics' => $hasPublishedOrderReport && $canAttemptReturnLogistics, + 'return_logistics_block_reason' => (!$hasPublishedOrderReport && $canAttemptReturnLogistics) + ? '订单报告未发布前,物品不允许寄回' + : '', + 'can_mark_return_received' => $order['order_status'] === 'completed' && !empty($returnLogistics['tracking_no']) && ($returnLogistics['tracking_status'] ?? '') !== 'received', + ], + 'product_info' => [ + 'product_name' => $product['product_name'] ?? '', + 'category_id' => (int)($product['category_id'] ?? 0), + 'category_name' => $product['category_name'] ?? '', + 'brand_id' => (int)($product['brand_id'] ?? 0), + 'brand_name' => $product['brand_name'] ?? '', + 'color' => $product['color'] ?? '', + 'size_spec' => $product['size_spec'] ?? '', + 'serial_no' => $product['serial_no'] ?? '', + ], + 'extra_info' => [ + 'purchase_channel' => $extra['purchase_channel'] ?? '', + 'purchase_price' => (float)($extra['purchase_price'] ?? 0), + 'usage_status' => $extra['usage_status'] ?? '', + 'condition_desc' => $extra['condition_desc'] ?? '', + 'remark' => $extra['remark'] ?? '', + ], + 'shipping_target' => $shippingTarget ? [ + 'warehouse_id' => (int)($shippingTarget['warehouse_id'] ?? 0), + 'warehouse_name' => $shippingTarget['warehouse_name'], + 'warehouse_code' => $shippingTarget['warehouse_code'], + 'receiver_name' => $shippingTarget['receiver_name'], + 'receiver_mobile' => $shippingTarget['receiver_mobile'], + 'full_address' => trim(sprintf( + '%s%s%s%s', + $shippingTarget['province'] ?? '', + $shippingTarget['city'] ?? '', + $shippingTarget['district'] ?? '', + $shippingTarget['detail_address'] ?? '' + )), + 'service_time' => $shippingTarget['service_time'], + 'notice' => $shippingTarget['notice'], + ] : null, + 'return_address' => $returnAddress ? [ + 'user_address_id' => (int)($returnAddress['user_address_id'] ?? 0), + 'consignee' => $returnAddress['consignee'], + 'mobile' => $returnAddress['mobile'], + 'full_address' => trim(sprintf( + '%s%s%s%s', + $returnAddress['province'] ?? '', + $returnAddress['city'] ?? '', + $returnAddress['district'] ?? '', + $returnAddress['detail_address'] ?? '' + )), + ] : null, + 'timeline' => $timeline, + 'logistics_info' => $sendLogistics ? [ + 'express_company' => $sendLogistics['express_company'], + 'tracking_no' => $sendLogistics['tracking_no'], + 'tracking_status' => $sendLogistics['tracking_status'], + 'tracking_status_text' => $this->trackingStatusText($sendLogistics['tracking_status'], 'send_to_center'), + 'latest_desc' => $this->formatAdminLogisticsDesc( + 'send_to_center', + $sendLogistics['tracking_status'], + $sendLogistics['express_company'], + $sendLogistics['tracking_no'], + $sendLogistics['latest_desc'] + ), + 'latest_time' => $sendLogistics['latest_time'], + 'nodes' => array_map(fn (array $item) => [ + 'node_time' => $item['node_time'], + 'node_desc' => $this->formatAdminLogisticsDesc( + 'send_to_center', + $sendLogistics['tracking_status'], + $sendLogistics['express_company'], + $sendLogistics['tracking_no'], + $item['node_desc'] + ), + 'node_location' => $item['node_location'], + ], $logisticsNodes), + ] : null, + 'return_logistics' => $returnLogistics ? [ + 'express_company' => $returnLogistics['express_company'], + 'tracking_no' => $returnLogistics['tracking_no'], + 'tracking_status' => $returnLogistics['tracking_status'], + 'tracking_status_text' => $this->trackingStatusText($returnLogistics['tracking_status'], 'return_to_user'), + 'latest_desc' => $this->formatAdminLogisticsDesc( + 'return_to_user', + $returnLogistics['tracking_status'], + $returnLogistics['express_company'], + $returnLogistics['tracking_no'], + $returnLogistics['latest_desc'] + ), + 'latest_time' => $returnLogistics['latest_time'], + 'nodes' => array_map(fn (array $item) => [ + 'node_time' => $item['node_time'], + 'node_desc' => $this->formatAdminLogisticsDesc( + 'return_to_user', + $returnLogistics['tracking_status'], + $returnLogistics['express_company'], + $returnLogistics['tracking_no'], + $item['node_desc'] + ), + 'node_location' => $item['node_location'], + ], $returnLogisticsNodes), + ] : null, + 'supplement_task' => $supplement ? [ + 'reason' => $supplement['reason'], + 'deadline' => $supplement['deadline'], + 'status' => $supplement['status'], + 'items' => $supplementItems, + ] : null, + 'report_summary' => $report ? [ + 'report_no' => $report['report_no'], + 'report_title' => $report['report_title'], + 'report_status' => $report['report_status'], + 'publish_time' => $report['publish_time'], + ] : null, + ]); + } + + public function warehouseOptions(Request $request) + { + $id = (int)$request->input('id', 0); + if ($id <= 0) { + return api_error('订单 ID 不能为空', 422); + } + + $order = Db::name('orders')->where('id', $id)->find(); + if (!$order) { + return api_error('订单不存在', 404); + } + + $product = Db::name('order_products')->where('order_id', $id)->find(); + $options = (new WarehouseService())->optionsForOrder( + (string)($order['service_provider'] ?? 'anxinyan'), + !empty($product['category_id']) ? (int)$product['category_id'] : null + ); + + return api_success([ + 'list' => $options, + ]); + } + + public function reassignWarehouse(Request $request) + { + $id = (int)$request->input('id', 0); + $warehouseId = (int)$request->input('warehouse_id', 0); + if ($id <= 0 || $warehouseId <= 0) { + return api_error('订单 ID 和仓库 ID 不能为空', 422); + } + + $order = Db::name('orders')->where('id', $id)->find(); + if (!$order) { + return api_error('订单不存在', 404); + } + + $logistics = Db::name('order_logistics') + ->where('order_id', $id) + ->where('logistics_type', 'send_to_center') + ->order('id', 'desc') + ->find(); + if ($order['order_status'] !== 'pending_shipping' || !empty($logistics['tracking_no'])) { + return api_error('当前订单已进入寄送流程,暂不支持改派仓库', 422); + } + + $warehouse = Db::name('shipping_warehouses') + ->where('id', $warehouseId) + ->where('status', 'enabled') + ->find(); + if (!$warehouse) { + return api_error('目标仓库不存在或已停用', 404); + } + + $product = Db::name('order_products')->where('order_id', $id)->find(); + $categoryId = !empty($product['category_id']) ? (int)$product['category_id'] : null; + $allowedWarehouses = (new WarehouseService())->optionsForOrder((string)$order['service_provider'], $categoryId); + $allowedIds = array_column($allowedWarehouses, 'id'); + if (!in_array($warehouseId, $allowedIds, true)) { + return api_error('目标仓库不适用于当前订单服务类型或品类', 422); + } + + $currentTarget = Db::name('order_shipping_targets')->where('order_id', $id)->find(); + if ($currentTarget && (int)($currentTarget['warehouse_id'] ?? 0) === $warehouseId) { + return api_error('当前订单已绑定该仓库,无需重复改派', 422); + } + + $snapshot = [ + 'warehouse_id' => (int)$warehouse['id'], + 'warehouse_name' => $warehouse['warehouse_name'], + 'warehouse_code' => $warehouse['warehouse_code'], + 'receiver_name' => $warehouse['receiver_name'], + 'receiver_mobile' => $warehouse['receiver_mobile'], + 'province' => $warehouse['province'], + 'city' => $warehouse['city'], + 'district' => $warehouse['district'], + 'detail_address' => $warehouse['detail_address'], + 'service_time' => $warehouse['service_time'], + 'notice' => $warehouse['notice'], + ]; + + $now = date('Y-m-d H:i:s'); + Db::startTrans(); + try { + (new WarehouseService())->bindOrderTarget($id, (string)$order['service_provider'], $categoryId); + Db::name('order_shipping_targets')->where('order_id', $id)->update([ + 'warehouse_id' => $snapshot['warehouse_id'], + 'warehouse_name' => $snapshot['warehouse_name'], + 'warehouse_code' => $snapshot['warehouse_code'], + 'service_provider' => $order['service_provider'], + 'receiver_name' => $snapshot['receiver_name'], + 'receiver_mobile' => $snapshot['receiver_mobile'], + 'province' => $snapshot['province'], + 'city' => $snapshot['city'], + 'district' => $snapshot['district'], + 'detail_address' => $snapshot['detail_address'], + 'service_time' => $snapshot['service_time'], + 'notice' => $snapshot['notice'], + 'updated_at' => $now, + ]); + + Db::name('order_timelines')->insert([ + 'order_id' => $id, + 'node_code' => 'warehouse_reassigned', + 'node_text' => '仓库已改派', + 'node_desc' => sprintf('订单收货仓库已改派至 %s', $snapshot['warehouse_name']), + 'operator_type' => 'admin', + 'operator_id' => 1, + 'occurred_at' => $now, + 'created_at' => $now, + ]); + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('仓库改派失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success([ + 'id' => $id, + 'warehouse_id' => $snapshot['warehouse_id'], + 'warehouse_name' => $snapshot['warehouse_name'], + ], '仓库已改派'); + } + + public function receiveLogistics(Request $request) + { + $id = (int)$request->input('id', 0); + if ($id <= 0) { + return api_error('订单 ID 不能为空', 422); + } + + $order = Db::name('orders')->where('id', $id)->find(); + if (!$order) { + return api_error('订单不存在', 404); + } + + $logistics = Db::name('order_logistics') + ->where('order_id', $id) + ->where('logistics_type', 'send_to_center') + ->order('id', 'desc') + ->find(); + $allowEnterpriseManualReceive = ($order['source_channel'] ?? '') === 'enterprise_push'; + if ((!$logistics || $logistics['tracking_no'] === '') && !$allowEnterpriseManualReceive) { + return api_error('当前订单还没有有效运单信息', 422); + } + + if ($order['order_status'] !== 'pending_shipping') { + return api_error('当前订单状态不支持标记签收', 422); + } + + $now = date('Y-m-d H:i:s'); + $latestDesc = '鉴定中心已签收包裹,等待鉴定师开始处理。'; + + Db::startTrans(); + try { + if ($logistics) { + Db::name('order_logistics')->where('id', $logistics['id'])->update([ + 'tracking_status' => 'received', + 'latest_desc' => $latestDesc, + 'latest_time' => $now, + 'updated_at' => $now, + ]); + $logisticsId = (int)$logistics['id']; + } else { + $latestDesc = '大客户推送订单已确认到仓,等待鉴定师开始处理。'; + $logisticsId = (int)Db::name('order_logistics')->insertGetId([ + 'order_id' => $id, + 'logistics_type' => 'send_to_center', + 'express_company' => '', + 'tracking_no' => '', + 'tracking_status' => 'received', + 'latest_desc' => $latestDesc, + 'latest_time' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + Db::name('order_logistics_nodes')->insert([ + 'logistics_id' => $logisticsId, + 'node_time' => $now, + 'node_desc' => $latestDesc, + 'node_location' => '鉴定中心', + 'created_at' => $now, + ]); + + Db::name('orders')->where('id', $id)->update([ + 'order_status' => 'in_first_review', + 'display_status' => '鉴定中', + 'updated_at' => $now, + ]); + + $taskUpdate = [ + 'status' => 'processing', + 'updated_at' => $now, + ]; + $task = Db::name('appraisal_tasks') + ->where('order_id', $id) + ->where('task_stage', 'first_review') + ->order('id', 'asc') + ->find(); + if ($task && empty($task['started_at'])) { + $taskUpdate['started_at'] = $now; + } + if ($task) { + Db::name('appraisal_tasks')->where('id', (int)$task['id'])->update($taskUpdate); + } + + Db::name('order_timelines')->insert([ + 'order_id' => $id, + 'node_code' => 'first_review', + 'node_text' => '鉴定中', + 'node_desc' => $logistics + ? '包裹已由鉴定中心签收,订单已进入鉴定流程' + : '大客户推送订单已确认到仓,订单已进入鉴定流程', + 'operator_type' => 'admin', + 'operator_id' => 1, + 'occurred_at' => $now, + 'created_at' => $now, + ]); + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('标记签收失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + (new EnterpriseWebhookService())->recordOrderEvent($id, 'inbound_received', [ + 'express_company' => (string)($logistics['express_company'] ?? ''), + 'tracking_no' => (string)($logistics['tracking_no'] ?? ''), + 'received_at' => $now, + ]); + + return api_success(['id' => $id], '已标记鉴定中心签收'); + } + + public function saveReturnLogistics(Request $request) + { + $id = (int)$request->input('id', 0); + $expressCompany = trim((string)$request->input('express_company', '')); + $trackingNo = trim((string)$request->input('tracking_no', '')); + + if ($id <= 0 || $expressCompany === '' || $trackingNo === '') { + return api_error('订单、快递公司和运单号不能为空', 422); + } + + $order = Db::name('orders')->where('id', $id)->find(); + if (!$order) { + return api_error('订单不存在', 404); + } + if (!in_array($order['order_status'], ['report_published', 'completed'], true)) { + return api_error('当前订单状态不支持登记回寄运单', 422); + } + + $report = Db::name('reports')->where('order_id', $id)->order('id', 'desc')->find(); + if (!$report || ($report['report_status'] ?? '') !== 'published') { + return api_error('订单报告未发布前,物品不允许寄回', 422); + } + + $returnAddress = Db::name('order_return_addresses')->where('order_id', $id)->find(); + if (!$returnAddress) { + $fallbackAddress = Db::name('user_addresses') + ->where('user_id', (int)$order['user_id']) + ->where('is_default', 1) + ->order('id', 'desc') + ->find() + ?: Db::name('user_addresses') + ->where('user_id', (int)$order['user_id']) + ->order('id', 'desc') + ->find(); + + if (!$fallbackAddress) { + return api_error('当前订单尚未确认寄回地址,且用户账户下没有可用地址', 422); + } + + $returnAddress = [ + 'user_address_id' => (int)$fallbackAddress['id'], + 'consignee' => $fallbackAddress['consignee'], + 'mobile' => $fallbackAddress['mobile'], + 'province' => $fallbackAddress['province'], + 'city' => $fallbackAddress['city'], + 'district' => $fallbackAddress['district'], + 'detail_address' => $fallbackAddress['detail_address'], + ]; + } + + $existing = Db::name('order_logistics') + ->where('order_id', $id) + ->where('logistics_type', 'return_to_user') + ->order('id', 'desc') + ->find(); + + $now = date('Y-m-d H:i:s'); + $latestDesc = sprintf('平台已通过 %s 回寄商品,运单号 %s。', $expressCompany, $trackingNo); + + Db::startTrans(); + try { + $existingReturnAddress = Db::name('order_return_addresses')->where('order_id', $id)->find(); + if (!$existingReturnAddress) { + Db::name('order_return_addresses')->insert([ + 'order_id' => $id, + 'user_address_id' => $returnAddress['user_address_id'] ?? null, + 'consignee' => $returnAddress['consignee'] ?? '', + 'mobile' => $returnAddress['mobile'] ?? '', + 'province' => $returnAddress['province'] ?? '', + 'city' => $returnAddress['city'] ?? '', + 'district' => $returnAddress['district'] ?? '', + 'detail_address' => $returnAddress['detail_address'] ?? '', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + if ($existing) { + Db::name('order_logistics')->where('id', $existing['id'])->update([ + 'logistics_type' => 'return_to_user', + 'express_company' => $expressCompany, + 'tracking_no' => $trackingNo, + 'tracking_status' => 'in_transit', + 'latest_desc' => $latestDesc, + 'latest_time' => $now, + 'updated_at' => $now, + ]); + $logisticsId = (int)$existing['id']; + $nodeText = '已更新回寄运单'; + $nodeDesc = sprintf('平台更新回寄运单:%s %s', $expressCompany, $trackingNo); + } else { + $logisticsId = (int)Db::name('order_logistics')->insertGetId([ + 'order_id' => $id, + 'logistics_type' => 'return_to_user', + 'express_company' => $expressCompany, + 'tracking_no' => $trackingNo, + 'tracking_status' => 'in_transit', + 'latest_desc' => $latestDesc, + 'latest_time' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $nodeText = '已寄回用户'; + $nodeDesc = sprintf('平台已通过 %s 回寄商品,运单号 %s', $expressCompany, $trackingNo); + } + + Db::name('order_logistics_nodes')->insert([ + 'logistics_id' => $logisticsId, + 'node_time' => $now, + 'node_desc' => $latestDesc, + 'node_location' => $returnAddress['city'] ?? '用户地址', + 'created_at' => $now, + ]); + + Db::name('orders')->where('id', $id)->update([ + 'order_status' => 'completed', + 'display_status' => '物品已寄回', + 'updated_at' => $now, + ]); + + Db::name('order_timelines')->insert([ + 'order_id' => $id, + 'node_code' => 'return_shipped', + 'node_text' => $nodeText, + 'node_desc' => $nodeDesc, + 'operator_type' => 'admin', + 'operator_id' => 1, + 'occurred_at' => $now, + 'created_at' => $now, + ]); + + (new MessageDispatcher())->sendInboxEvent('return_shipped', [ + 'user_id' => (int)($order['user_id'] ?? 0), + 'biz_type' => 'return_shipped', + 'biz_id' => $id, + 'express_company' => $expressCompany, + 'tracking_no' => $trackingNo, + 'fallback_title' => '鉴定物品已寄回', + 'fallback_content' => sprintf('平台已通过%s回寄鉴定物品,运单号 %s,可前往订单详情查看物流进度。', $expressCompany, $trackingNo), + ]); + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('回寄运单登记失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + (new EnterpriseWebhookService())->recordOrderEvent($id, 'return_shipped', [ + 'express_company' => $expressCompany, + 'tracking_no' => $trackingNo, + 'shipped_at' => $now, + ]); + + return api_success([ + 'id' => $id, + 'express_company' => $expressCompany, + 'tracking_no' => $trackingNo, + ], '回寄运单已登记'); + } + + public function receiveReturnLogistics(Request $request) + { + $id = (int)$request->input('id', 0); + if ($id <= 0) { + return api_error('订单 ID 不能为空', 422); + } + + $order = Db::name('orders')->where('id', $id)->find(); + if (!$order) { + return api_error('订单不存在', 404); + } + + $logistics = Db::name('order_logistics') + ->where('order_id', $id) + ->where('logistics_type', 'return_to_user') + ->order('id', 'desc') + ->find(); + if (!$logistics || $logistics['tracking_no'] === '') { + return api_error('当前订单还没有有效回寄运单', 422); + } + if (($logistics['tracking_status'] ?? '') === 'received') { + return api_error('当前订单已标记用户签收,无需重复操作', 422); + } + + $now = date('Y-m-d H:i:s'); + $latestDesc = '用户已签收回寄商品,本次订单已完成。'; + + Db::startTrans(); + try { + Db::name('order_logistics')->where('id', $logistics['id'])->update([ + 'tracking_status' => 'received', + 'latest_desc' => $latestDesc, + 'latest_time' => $now, + 'updated_at' => $now, + ]); + + Db::name('order_logistics_nodes')->insert([ + 'logistics_id' => $logistics['id'], + 'node_time' => $now, + 'node_desc' => $latestDesc, + 'node_location' => '用户地址', + 'created_at' => $now, + ]); + + Db::name('orders')->where('id', $id)->update([ + 'order_status' => 'completed', + 'display_status' => '已完成', + 'updated_at' => $now, + ]); + + Db::name('order_timelines')->insert([ + 'order_id' => $id, + 'node_code' => 'return_received', + 'node_text' => '用户已签收', + 'node_desc' => '回寄商品已由用户签收,本次订单已完成。', + 'operator_type' => 'admin', + 'operator_id' => 1, + 'occurred_at' => $now, + 'created_at' => $now, + ]); + + (new MessageDispatcher())->sendInboxEvent('return_received', [ + 'user_id' => (int)($order['user_id'] ?? 0), + 'biz_type' => 'return_received', + 'biz_id' => $id, + 'fallback_title' => '回寄商品已签收', + 'fallback_content' => '系统已确认您签收回寄商品,本次鉴定订单已完成。', + ]); + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('标记用户签收失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + (new EnterpriseWebhookService())->recordOrderEvent($id, 'completed', [ + 'express_company' => (string)($logistics['express_company'] ?? ''), + 'tracking_no' => (string)($logistics['tracking_no'] ?? ''), + 'completed_at' => $now, + ]); + + return api_success(['id' => $id], '已标记用户签收'); + } + + private function trackingStatusText(string $status, string $logisticsType = 'send_to_center'): string + { + if ($logisticsType === 'return_to_user') { + return match ($status) { + 'submitted' => '已登记回寄运单', + 'in_transit' => '回寄途中', + 'received' => '用户已签收', + default => $status === '' ? '待回寄' : $status, + }; + } + + return match ($status) { + 'submitted' => '用户已提交运单', + 'in_transit' => '用户已寄出,运输中', + 'received' => '鉴定中心已签收', + default => $status === '' ? '待提交' : $status, + }; + } + + private function displayStatus(string $orderStatus, string $displayStatus, string $returnTrackingNo = '', string $returnTrackingStatus = ''): string + { + if ($orderStatus === 'report_published') { + return '待寄回'; + } + + if ($orderStatus === 'completed') { + if ($returnTrackingStatus === 'received') { + return '已完成'; + } + if ($returnTrackingNo !== '') { + return '物品已寄回'; + } + } + + return $displayStatus; + } + + private function normalizeOrderSourceChannel(string $sourceChannel): string + { + $sourceChannel = trim($sourceChannel); + $aliases = [ + 'wechat_mini_program' => 'mini_program', + 'weixin_mini_program' => 'mini_program', + 'mp_weixin' => 'mini_program', + 'miniapp' => 'mini_program', + 'user_app' => 'mini_program', + 'web_h5' => 'h5', + 'enterprise' => 'enterprise_push', + 'enterprise_order' => 'enterprise_push', + 'customer_push' => 'enterprise_push', + 'large_customer_push' => 'enterprise_push', + ]; + $sourceChannel = $aliases[$sourceChannel] ?? $sourceChannel; + + return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push'], true) ? $sourceChannel : ''; + } + + private function sourceChannelText(string $sourceChannel): string + { + return match ($this->normalizeOrderSourceChannel($sourceChannel)) { + 'mini_program' => '小程序', + 'h5' => 'H5', + 'enterprise_push' => '大客户推送订单', + default => '未知渠道', + }; + } + + private function formatAdminLogisticsDesc(string $logisticsType, string $status, string $expressCompany, string $trackingNo, string $fallback): string + { + $expressCompany = trim($expressCompany); + $trackingNo = trim($trackingNo); + + if ($logisticsType === 'return_to_user') { + if (in_array($status, ['submitted', 'in_transit'], true) && $expressCompany !== '' && $trackingNo !== '') { + return sprintf('平台已登记回寄运单:%s %s,商品正在回寄途中。', $expressCompany, $trackingNo); + } + + if ($status === 'received') { + return '用户已签收回寄商品,订单已完成。'; + } + + return $fallback; + } + + if ($status === 'submitted' && $expressCompany !== '' && $trackingNo !== '') { + return sprintf('用户已提交寄送运单:%s %s,等待鉴定中心签收。', $expressCompany, $trackingNo); + } + + if ($status === 'in_transit' && $expressCompany !== '' && $trackingNo !== '') { + return sprintf('用户已寄出商品:%s %s,当前运输中。', $expressCompany, $trackingNo); + } + + if ($status === 'received') { + return '鉴定中心已签收包裹,等待鉴定师开始处理。'; + } + + return $fallback; + } +} diff --git a/server-api/app/controller/admin/ReportsController.php b/server-api/app/controller/admin/ReportsController.php new file mode 100644 index 0000000..981736c --- /dev/null +++ b/server-api/app/controller/admin/ReportsController.php @@ -0,0 +1,705 @@ +input('keyword', '')); + $status = trim((string)$request->input('status', '')); + $serviceProvider = trim((string)$request->input('service_provider', '')); + + $query = Db::name('reports') + ->alias('r') + ->leftJoin('orders o', 'o.id = r.order_id') + ->leftJoin('order_products p', 'p.order_id = r.order_id') + ->field([ + 'r.id', + 'r.report_no', + 'r.order_id', + 'r.appraisal_no', + 'r.report_type', + 'r.report_title', + 'r.report_status', + 'r.service_provider', + 'r.institution_name', + 'r.publish_time', + 'o.order_no', + 'p.product_name', + 'p.category_name', + 'p.brand_name', + ]) + ->order('r.id', 'desc'); + + if ($status !== '') { + $query->where('r.report_status', $status); + } + + if ($serviceProvider !== '') { + $query->where('r.service_provider', $serviceProvider); + } + + $rows = $query->select()->toArray(); + $contentMap = $this->loadReportContentMap(array_map(fn(array $item) => (int)$item['id'], $rows)); + + $list = []; + foreach ($rows as $item) { + $productSnapshot = $contentMap[(int)$item['id']]['product_snapshot'] ?? []; + $mapped = [ + 'id' => (int)$item['id'], + 'order_id' => (int)($item['order_id'] ?? 0), + 'order_no' => $item['order_no'] ?? '', + 'appraisal_no' => $item['appraisal_no'] ?? '', + 'report_no' => $item['report_no'], + 'report_type' => $item['report_type'] ?: 'appraisal', + 'report_type_text' => $this->reportTypeText($item['report_type'] ?: 'appraisal'), + 'report_title' => $item['report_title'], + 'report_status' => $item['report_status'], + 'report_status_text' => $this->reportStatusText($item['report_status']), + 'service_provider' => $item['service_provider'], + 'service_provider_text' => $this->serviceProviderText($item['service_provider']), + 'institution_name' => $item['institution_name'] ?: $this->defaultInstitutionName($item['service_provider']), + 'publish_time' => $item['publish_time'], + 'product_name' => $item['product_name'] ?: (string)($productSnapshot['product_name'] ?? ''), + 'category_name' => $item['category_name'] ?: (string)($productSnapshot['category_name'] ?? ''), + 'brand_name' => $item['brand_name'] ?: (string)($productSnapshot['brand_name'] ?? ''), + ]; + + if ($keyword !== '' && !$this->matchKeyword($mapped, $keyword)) { + continue; + } + + $list[] = $mapped; + } + + return api_success(['list' => $list]); + } + + public function detail(Request $request) + { + $id = (int)$request->input('id', 0); + if (!$id) { + return api_error('报告 ID 不能为空', 422); + } + + $report = Db::name('reports')->where('id', $id)->find(); + if (!$report) { + return api_error('报告不存在', 404); + } + + $content = Db::name('report_contents')->where('report_id', $id)->find(); + $productSnapshot = $this->decodeJsonField($content['product_snapshot_json'] ?? null); + $resultSnapshot = $this->decodeJsonField($content['result_snapshot_json'] ?? null); + $appraisalSnapshot = $this->decodeJsonField($content['appraisal_snapshot_json'] ?? null); + $valuationSnapshot = $this->decodeJsonField($content['valuation_snapshot_json'] ?? null); + $appraisalSnapshot = $this->enrichAppraisalSnapshot($report, $appraisalSnapshot); + $evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request); + + $verify = Db::name('report_verifies')->where('report_id', $id)->find() ?: []; + if (($report['report_status'] ?? '') === 'published') { + $verify = $this->createOrUpdateVerifyRecord($report, date('Y-m-d H:i:s')); + } + + $reportPageUrl = $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $report['report_no']]); + $verifyUrl = $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $report['report_no']]); + if (!$verify) { + $verify = []; + } + $verify['report_page_url'] = $verify['report_page_url'] ?? $reportPageUrl; + $verify['verify_qrcode_url'] = $verify['verify_qrcode_url'] ?? $reportPageUrl; + $verify['verify_url'] = $verify['verify_url'] ?? $verifyUrl; + $defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($report['report_type'] ?? 'appraisal')); + + return api_success([ + 'report_header' => [ + 'id' => (int)$report['id'], + 'order_id' => (int)($report['order_id'] ?? 0), + 'report_no' => $report['report_no'], + 'report_type' => $report['report_type'] ?: 'appraisal', + 'report_type_text' => $this->reportTypeText($report['report_type'] ?: 'appraisal'), + 'report_title' => $report['report_title'], + 'report_status' => $report['report_status'], + 'report_status_text' => $this->reportStatusText($report['report_status']), + 'service_provider' => $report['service_provider'], + 'service_provider_text' => $this->serviceProviderText($report['service_provider']), + 'institution_name' => $report['institution_name'] ?: $this->defaultInstitutionName($report['service_provider']), + 'publish_time' => $report['publish_time'], + ], + 'product_info' => $productSnapshot, + 'result_info' => $resultSnapshot, + 'appraisal_info' => $appraisalSnapshot, + 'valuation_info' => $valuationSnapshot, + 'evidence_attachments' => $evidenceAttachments, + 'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice, + 'verify_info' => [ + 'verify_status' => $verify['verify_status'] ?? (($report['report_status'] ?? '') === 'published' ? 'valid' : 'pending'), + 'verify_url' => $verify['verify_url'] ?? $verifyUrl, + 'verify_qrcode_url' => $verify['verify_qrcode_url'] ?? $reportPageUrl, + 'report_page_url' => $verify['report_page_url'] ?? $reportPageUrl, + 'verify_count' => (int)($verify['verify_count'] ?? 0), + ], + ]); + } + + public function saveInspection(Request $request) + { + $id = (int)$request->input('id', 0); + $header = $request->input('report_header', []); + $productInfo = $request->input('product_info', []); + $resultInfo = $request->input('result_info', []); + $appraisalInfo = $request->input('appraisal_info', []); + $valuationInfo = $request->input('valuation_info', []); + $riskNoticeText = trim((string)$request->input('risk_notice_text', '')); + + if (!is_array($header) || !is_array($productInfo) || !is_array($resultInfo) || !is_array($appraisalInfo) || !is_array($valuationInfo)) { + return api_error('检查单参数格式错误', 422); + } + + $serviceProvider = trim((string)($header['service_provider'] ?? 'anxinyan')); + if (!in_array($serviceProvider, ['anxinyan', 'zhongjian'], true)) { + return api_error('服务类型不正确', 422); + } + + $reportStatus = trim((string)($header['report_status'] ?? 'pending_publish')); + if (!in_array($reportStatus, ['draft', 'pending_publish', 'published'], true)) { + return api_error('报告状态不正确', 422); + } + + $productName = trim((string)($productInfo['product_name'] ?? '')); + $resultText = trim((string)($resultInfo['result_text'] ?? '')); + if ($productName === '') { + return api_error('商品名称不能为空', 422); + } + if ($resultText === '') { + return api_error('鉴定结论不能为空', 422); + } + + $now = date('Y-m-d H:i:s'); + + Db::startTrans(); + try { + $existing = null; + if ($id > 0) { + $existing = Db::name('reports')->where('id', $id)->find(); + if (!$existing || (($existing['report_type'] ?? 'appraisal') !== 'inspection')) { + Db::rollback(); + return api_error('检查单不存在', 404); + } + if (($existing['report_status'] ?? '') === 'published') { + Db::rollback(); + return api_error('已发布的检查单不支持直接编辑,请复制后重新补录', 422); + } + } + + $reportNo = trim((string)($header['report_no'] ?? ($existing['report_no'] ?? ''))); + if ($reportNo === '') { + $reportNo = $this->generateUniqueReportNo('inspection'); + } + + $conflict = Db::name('reports') + ->where('report_no', $reportNo) + ->when($id > 0, fn($query) => $query->where('id', '<>', $id)) + ->find(); + if ($conflict) { + Db::rollback(); + return api_error('检查单编号已存在,请更换后重试', 422); + } + + $reportTitle = trim((string)($header['report_title'] ?? '')); + if ($reportTitle === '') { + $reportTitle = $this->defaultReportTitle($serviceProvider, 'inspection'); + } + + $institutionName = trim((string)($header['institution_name'] ?? '')); + if ($institutionName === '') { + $institutionName = $this->defaultInstitutionName($serviceProvider); + } + + $publishTime = $reportStatus === 'published' + ? trim((string)($header['publish_time'] ?? ($existing['publish_time'] ?? $now))) + : null; + + $reportPayload = [ + 'report_no' => $reportNo, + 'order_id' => 0, + 'appraisal_no' => $existing['appraisal_no'] ?? $this->generateUniqueAppraisalNo('inspection'), + 'report_type' => 'inspection', + 'service_provider' => $serviceProvider, + 'institution_name' => $institutionName, + 'report_title' => $reportTitle, + 'report_status' => $reportStatus, + 'report_version' => $existing ? ((int)$existing['report_version'] + 1) : 1, + 'publish_time' => $publishTime ?: null, + 'invalid_reason' => '', + 'updated_at' => $now, + ]; + + if ($existing) { + Db::name('reports')->where('id', $id)->update($reportPayload); + $reportId = $id; + } else { + $reportPayload['created_at'] = $now; + $reportId = (int)Db::name('reports')->insertGetId($reportPayload); + } + + $normalizedProductInfo = [ + 'product_name' => $productName, + 'category_name' => trim((string)($productInfo['category_name'] ?? '')), + 'brand_name' => trim((string)($productInfo['brand_name'] ?? '')), + 'color' => trim((string)($productInfo['color'] ?? '')), + 'size_spec' => trim((string)($productInfo['size_spec'] ?? '')), + 'serial_no' => trim((string)($productInfo['serial_no'] ?? '')), + ]; + + $normalizedResultInfo = [ + 'result_status' => trim((string)($resultInfo['result_status'] ?? 'authentic')), + 'result_text' => $resultText, + 'result_desc' => trim((string)($resultInfo['result_desc'] ?? '')), + ]; + + $normalizedAppraisalInfo = [ + 'service_provider' => $serviceProvider, + 'institution_name' => $institutionName, + 'appraiser_name' => trim((string)($appraisalInfo['appraiser_name'] ?? '')), + 'reviewer_name' => trim((string)($appraisalInfo['reviewer_name'] ?? '')), + 'appraisal_time' => trim((string)($appraisalInfo['appraisal_time'] ?? ($publishTime ?: $now))), + ]; + + $normalizedValuationInfo = [ + 'condition_grade' => trim((string)($valuationInfo['condition_grade'] ?? '')), + 'condition_desc' => trim((string)($valuationInfo['condition_desc'] ?? '')), + 'valuation_min' => (float)($valuationInfo['valuation_min'] ?? 0), + 'valuation_max' => (float)($valuationInfo['valuation_max'] ?? 0), + 'valuation_desc' => trim((string)($valuationInfo['valuation_desc'] ?? '')), + ]; + + $contentPayload = [ + 'report_id' => $reportId, + 'product_snapshot_json' => json_encode($normalizedProductInfo, JSON_UNESCAPED_UNICODE), + 'result_snapshot_json' => json_encode($normalizedResultInfo, JSON_UNESCAPED_UNICODE), + 'appraisal_snapshot_json' => json_encode($normalizedAppraisalInfo, JSON_UNESCAPED_UNICODE), + 'valuation_snapshot_json' => json_encode($normalizedValuationInfo, JSON_UNESCAPED_UNICODE), + 'risk_notice_text' => $riskNoticeText !== '' ? $riskNoticeText : (new ContentService())->getReportRiskNotice('inspection'), + 'updated_at' => $now, + ]; + + $content = Db::name('report_contents')->where('report_id', $reportId)->find(); + if ($content) { + Db::name('report_contents')->where('report_id', $reportId)->update($contentPayload); + } else { + $contentPayload['created_at'] = $now; + Db::name('report_contents')->insert($contentPayload); + } + + $reportRecord = Db::name('reports')->where('id', $reportId)->find(); + $verifyInfo = [ + 'verify_url' => '', + 'report_page_url' => '', + ]; + + if ($reportStatus === 'published' && $reportRecord) { + $verifyInfo = $this->createOrUpdateVerifyRecord($reportRecord, $now); + } else { + Db::name('report_verifies')->where('report_id', $reportId)->delete(); + } + + Db::commit(); + + return api_success([ + 'id' => $reportId, + 'report_status' => $reportStatus, + 'publish_time' => $publishTime ?: '', + 'verify_url' => $verifyInfo['verify_url'] ?? '', + 'report_page_url' => $verifyInfo['report_page_url'] ?? '', + ], $existing ? '检查单已更新' : '检查单已补录'); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('检查单保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + } + + public function publish(Request $request) + { + $id = (int)$request->input('id', 0); + if (!$id) { + return api_error('报告 ID 不能为空', 422); + } + + $now = date('Y-m-d H:i:s'); + + Db::startTrans(); + try { + $report = Db::name('reports')->where('id', $id)->find(); + if (!$report) { + Db::rollback(); + return api_error('报告不存在', 404); + } + + if (!in_array($report['report_status'], ['draft', 'pending_publish', 'updated', 'published'], true)) { + Db::rollback(); + return api_error('当前报告状态不支持发布', 422); + } + + $effectivePublishTime = $report['publish_time'] ?: $now; + if ($report['report_status'] !== 'published') { + Db::name('reports')->where('id', $id)->update([ + 'report_status' => 'published', + 'publish_time' => $effectivePublishTime, + 'updated_at' => $now, + ]); + $report['report_status'] = 'published'; + $report['publish_time'] = $effectivePublishTime; + } + + if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) { + $this->refreshAppraisalSnapshot((int)$report['id'], (int)$report['order_id'], $report['service_provider'], $now); + } + + $verify = $this->createOrUpdateVerifyRecord($report, $now); + + if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) { + Db::name('orders')->where('id', $report['order_id'])->update([ + 'order_status' => 'report_published', + 'display_status' => '报告已出具', + 'updated_at' => $now, + ]); + + $order = Db::name('orders')->where('id', $report['order_id'])->find(); + $product = Db::name('order_products')->where('order_id', $report['order_id'])->find(); + + $timelineExists = Db::name('order_timelines') + ->where('order_id', $report['order_id']) + ->where('node_code', 'report_published') + ->where('node_text', '报告已出具') + ->find(); + + if (!$timelineExists) { + Db::name('order_timelines')->insert([ + 'order_id' => $report['order_id'], + 'node_code' => 'report_published', + 'node_text' => '报告已出具', + 'node_desc' => '正式报告已发布,用户可查看报告并进行验真。', + 'operator_type' => 'admin', + 'operator_id' => 1, + 'occurred_at' => $now, + 'created_at' => $now, + ]); + } + + (new MessageDispatcher())->sendInboxEvent('report_published', [ + 'user_id' => (int)($order['user_id'] ?? 0), + 'biz_type' => 'report', + 'biz_id' => (int)$report['id'], + 'report_no' => $report['report_no'], + 'report_title' => $report['report_title'], + 'product_name' => $product['product_name'] ?? '', + 'publish_time' => $report['publish_time'] ?: $now, + 'verify_url' => $verify['verify_url'], + 'fallback_title' => '报告已出具', + 'fallback_content' => '您的正式报告已生成,可前往报告中心查看并完成验真。', + ]); + } + + Db::commit(); + + if (($report['report_type'] ?? 'appraisal') === 'appraisal' && (int)($report['order_id'] ?? 0) > 0) { + (new EnterpriseWebhookService())->recordOrderEvent((int)$report['order_id'], 'report_published', [ + 'report_id' => $id, + 'report_no' => (string)$report['report_no'], + 'report_title' => (string)$report['report_title'], + 'publish_time' => $effectivePublishTime, + 'verify_url' => (string)($verify['verify_url'] ?? ''), + 'report_page_url' => (string)($verify['report_page_url'] ?? ''), + ]); + } + + return api_success([ + 'id' => $id, + 'report_status' => 'published', + 'publish_time' => $effectivePublishTime, + 'verify_url' => $verify['verify_url'], + 'report_page_url' => $verify['report_page_url'], + ], '报告已发布'); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('报告发布失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + } + + private function reportStatusText(string $status): string + { + return match ($status) { + 'draft' => '草稿中', + 'pending_publish' => '待发布', + 'published' => '已发布', + 'updated' => '已更新', + 'invalid' => '已作废', + default => $status, + }; + } + + private function reportTypeText(string $reportType): string + { + return match ($reportType) { + 'inspection' => '补录检查单', + default => '订单报告', + }; + } + + private function serviceProviderText(string $serviceProvider): string + { + return $serviceProvider === 'zhongjian' ? '中检鉴定' : '实物鉴定'; + } + + private function decodeJsonField(mixed $value): array + { + if (is_array($value)) { + return $value; + } + if (is_string($value) && $value !== '') { + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : []; + } + return []; + } + + private function loadReportContentMap(array $reportIds): array + { + if (!$reportIds) { + return []; + } + + $rows = Db::name('report_contents')->whereIn('report_id', $reportIds)->select()->toArray(); + $map = []; + foreach ($rows as $row) { + $map[(int)$row['report_id']] = [ + 'product_snapshot' => $this->decodeJsonField($row['product_snapshot_json'] ?? null), + 'result_snapshot' => $this->decodeJsonField($row['result_snapshot_json'] ?? null), + ]; + } + return $map; + } + + private function matchKeyword(array $item, string $keyword): bool + { + $needle = mb_strtolower($keyword); + foreach (['report_no', 'report_title', 'product_name', 'brand_name', 'institution_name', 'order_no', 'appraisal_no'] as $field) { + if (str_contains(mb_strtolower((string)($item[$field] ?? '')), $needle)) { + return true; + } + } + return false; + } + + private function createOrUpdateVerifyRecord(array $report, string $now): array + { + $reportNo = (string)$report['report_no']; + $verifyToken = 'verify_' . strtolower((string)preg_replace('/[^a-zA-Z0-9]/', '', $reportNo)); + $verifyUrl = $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $reportNo]); + $reportPageUrl = $this->buildPublicPageUrl('/pages/report/detail', ['report_no' => $reportNo]); + + $payload = [ + 'report_id' => (int)$report['id'], + 'report_no' => $reportNo, + 'verify_token' => $verifyToken, + 'verify_qrcode_url' => $reportPageUrl, + 'verify_url' => $verifyUrl, + 'verify_status' => 'valid', + 'updated_at' => $now, + ]; + + $verify = Db::name('report_verifies')->where('report_id', $report['id'])->find(); + if ($verify) { + Db::name('report_verifies')->where('id', $verify['id'])->update($payload); + } else { + $payload['last_verified_at'] = null; + $payload['verify_count'] = 0; + $payload['created_at'] = $now; + Db::name('report_verifies')->insert($payload); + } + + $fresh = Db::name('report_verifies')->where('report_id', $report['id'])->find() ?: $payload; + $fresh['report_page_url'] = $reportPageUrl; + return $fresh; + } + + private function buildPublicPageUrl(string $pagePath, array $query = []): string + { + $baseUrl = $this->normalizeH5BaseUrl($this->getSystemConfigValue('h5', 'page_base_url')); + $page = ltrim($pagePath, '/'); + $queryString = http_build_query($query); + $hashPath = '/#/' . $page; + if ($queryString !== '') { + $hashPath .= '?' . $queryString; + } + + if ($baseUrl === '') { + return $hashPath; + } + + return $baseUrl . $hashPath; + } + + private function normalizeH5BaseUrl(string $value): string + { + $baseUrl = trim($value); + if ($baseUrl === '') { + return ''; + } + + $hashPos = strpos($baseUrl, '#'); + if ($hashPos !== false) { + $baseUrl = substr($baseUrl, 0, $hashPos); + } + + if (!preg_match('/^https?:\/\//i', $baseUrl)) { + $baseUrl = 'https://' . ltrim($baseUrl, '/'); + } + + return rtrim($baseUrl, '/'); + } + + private function enrichAppraisalSnapshot(array $report, array $snapshot): array + { + if (($report['report_type'] ?? 'appraisal') !== 'appraisal' || (int)($report['order_id'] ?? 0) <= 0) { + return $snapshot; + } + + $tasks = Db::name('appraisal_tasks') + ->where('order_id', (int)$report['order_id']) + ->order('id', 'asc') + ->select() + ->toArray(); + + $firstReviewTask = null; + $finalReviewTask = null; + foreach ($tasks as $task) { + if (($task['task_stage'] ?? '') === 'first_review') { + $firstReviewTask = $task; + } + if (($task['task_stage'] ?? '') === 'final_review') { + $finalReviewTask = $task; + } + } + + $institutionName = $snapshot['institution_name'] ?? ($report['institution_name'] ?: $this->defaultInstitutionName($report['service_provider'])); + $appraiserName = $this->normalizeAssigneeName($snapshot['appraiser_name'] ?? '') + ?: $this->normalizeAssigneeName($firstReviewTask['assignee_name'] ?? '') + ?: $this->normalizeAssigneeName($finalReviewTask['assignee_name'] ?? ''); + $reviewerName = $appraiserName; + $appraisalTime = $snapshot['appraisal_time'] + ?? ($firstReviewTask['submitted_at'] + ?? $firstReviewTask['started_at'] + ?? $finalReviewTask['submitted_at'] + ?? $finalReviewTask['started_at'] + ?? ''); + + $snapshot['service_provider'] = $snapshot['service_provider'] ?? $report['service_provider']; + $snapshot['institution_name'] = $institutionName; + $snapshot['appraiser_name'] = $appraiserName; + $snapshot['reviewer_name'] = $reviewerName; + $snapshot['appraisal_time'] = $appraisalTime; + + return $snapshot; + } + + private function refreshAppraisalSnapshot(int $reportId, int $orderId, string $serviceProvider, string $now): void + { + $content = Db::name('report_contents')->where('report_id', $reportId)->find(); + if (!$content) { + return; + } + + $snapshot = $this->enrichAppraisalSnapshot( + [ + 'report_type' => 'appraisal', + 'order_id' => $orderId, + 'service_provider' => $serviceProvider, + 'institution_name' => '', + ], + $this->decodeJsonField($content['appraisal_snapshot_json'] ?? null), + ); + + Db::name('report_contents')->where('report_id', $reportId)->update([ + 'appraisal_snapshot_json' => json_encode($snapshot, JSON_UNESCAPED_UNICODE), + 'updated_at' => $now, + ]); + } + + private function normalizeAssigneeName(?string $value): string + { + $name = trim((string)$value); + if ($name === '' || $name === '未分配') { + return ''; + } + + return $name; + } + + private function evidenceService(): AppraisalEvidenceService + { + return new AppraisalEvidenceService(); + } + + private function getSystemConfigValue(string $groupCode, string $configKey): string + { + $row = Db::name('system_configs') + ->where('config_group', $groupCode) + ->where('config_key', $configKey) + ->find(); + + return trim((string)($row['config_value'] ?? '')); + } + + private function generateUniqueReportNo(string $reportType): string + { + $prefix = $reportType === 'inspection' ? 'AXY-CHK' : 'AXY-R'; + for ($i = 0; $i < 20; $i++) { + $candidate = sprintf('%s-%s-%04d', $prefix, date('Ymd'), random_int(0, 9999)); + if (!Db::name('reports')->where('report_no', $candidate)->find()) { + return $candidate; + } + } + + return sprintf('%s-%s-%s', $prefix, date('YmdHis'), random_int(1000, 9999)); + } + + private function generateUniqueAppraisalNo(string $reportType): string + { + $prefix = $reportType === 'inspection' ? 'AXY-CHECK' : 'AXY-APP'; + for ($i = 0; $i < 20; $i++) { + $candidate = sprintf('%s-%s-%04d', $prefix, date('Ymd'), random_int(0, 9999)); + if (!Db::name('reports')->where('appraisal_no', $candidate)->find()) { + return $candidate; + } + } + + return sprintf('%s-%s-%s', $prefix, date('YmdHis'), random_int(1000, 9999)); + } + + private function defaultReportTitle(string $serviceProvider, string $reportType): string + { + if ($reportType === 'inspection') { + return $serviceProvider === 'zhongjian' ? '中检检查单' : '安心验检查单'; + } + + return $serviceProvider === 'zhongjian' ? '中检鉴定报告' : '安心验鉴定报告'; + } + + private function defaultInstitutionName(string $serviceProvider): string + { + return $serviceProvider === 'zhongjian' ? '中检合作机构' : '安心验'; + } +} diff --git a/server-api/app/controller/admin/SystemConfigsController.php b/server-api/app/controller/admin/SystemConfigsController.php new file mode 100644 index 0000000..66d4b60 --- /dev/null +++ b/server-api/app/controller/admin/SystemConfigsController.php @@ -0,0 +1,480 @@ +bootstrapDefaults(); + + $configs = Db::name('system_configs') + ->whereIn('config_group', array_keys($this->definitions())) + ->order('config_group', 'asc') + ->order('config_key', 'asc') + ->select() + ->toArray(); + + $configMap = []; + foreach ($configs as $item) { + $configMap[$item['config_group'] . '.' . $item['config_key']] = $item['config_value'] ?? ''; + } + + $groups = []; + foreach ($this->definitions() as $groupCode => $group) { + $groups[] = [ + 'group_code' => $groupCode, + 'group_name' => $group['group_name'], + 'group_desc' => $group['group_desc'], + 'items' => array_map(function (array $item) use ($groupCode, $configMap) { + return [ + 'config_key' => $item['config_key'], + 'title' => $item['title'], + 'field_type' => $item['field_type'], + 'placeholder' => $item['placeholder'], + 'remark' => $item['remark'], + 'is_secret' => (bool)$item['is_secret'], + 'options' => $item['options'] ?? [], + 'visible_when' => $item['visible_when'] ?? null, + 'value' => $configMap[$groupCode . '.' . $item['config_key']] ?? '', + ]; + }, $group['items']), + ]; + } + + return api_success(['groups' => $groups]); + } + + public function save(Request $request) + { + $items = $request->input('items', []); + if (!is_array($items) || !$items) { + return api_error('配置项不能为空', 422); + } + + $definitions = $this->definitions(); + $allowedMap = []; + foreach ($definitions as $groupCode => $group) { + foreach ($group['items'] as $item) { + $allowedMap[$groupCode . '.' . $item['config_key']] = true; + } + } + + $configValueMap = []; + foreach ($definitions as $groupCode => $group) { + foreach ($group['items'] as $item) { + $configValueMap[$groupCode . '.' . $item['config_key']] = (string)Db::name('system_configs') + ->where('config_group', $groupCode) + ->where('config_key', $item['config_key']) + ->value('config_value'); + } + } + + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $groupCode = trim((string)($item['config_group'] ?? '')); + $configKey = trim((string)($item['config_key'] ?? '')); + $mapKey = $groupCode . '.' . $configKey; + if ($groupCode === '' || $configKey === '' || !isset($allowedMap[$mapKey])) { + continue; + } + + $configValueMap[$mapKey] = (string)($item['config_value'] ?? ''); + } + + try { + $this->validateConfigValues($configValueMap); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), 422); + } + + $now = date('Y-m-d H:i:s'); + + Db::startTrans(); + try { + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $groupCode = trim((string)($item['config_group'] ?? '')); + $configKey = trim((string)($item['config_key'] ?? '')); + $configValue = (string)($item['config_value'] ?? ''); + $mapKey = $groupCode . '.' . $configKey; + + if ($groupCode === '' || $configKey === '' || !isset($allowedMap[$mapKey])) { + continue; + } + + $exists = Db::name('system_configs') + ->where('config_group', $groupCode) + ->where('config_key', $configKey) + ->find(); + + $payload = [ + 'config_group' => $groupCode, + 'config_key' => $configKey, + 'config_value' => $configValue, + 'remark' => '后台系统配置', + 'updated_at' => $now, + ]; + + if ($exists) { + Db::name('system_configs')->where('id', $exists['id'])->update($payload); + } else { + $payload['created_at'] = $now; + Db::name('system_configs')->insert($payload); + } + } + + Db::commit(); + (new FileStorageConfigService())->clearCache(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('系统配置保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success([], '系统配置已保存'); + } + + public function uploadFile(Request $request) + { + $groupCode = trim((string)$request->input('config_group', '')); + $configKey = trim((string)$request->input('config_key', '')); + if ($groupCode === '' || $configKey === '') { + return api_error('配置分组和配置项不能为空', 422); + } + + $allowed = $this->uploadableConfigMap(); + $mapKey = $groupCode . '.' . $configKey; + if (!isset($allowed[$mapKey])) { + return api_error('当前配置项不支持文件上传', 422); + } + + $file = $request->file('file'); + if (!$file || !$file->isValid()) { + return api_error('上传文件无效', 422); + } + + $originalName = (string)$file->getUploadName(); + $extension = strtolower((string)$file->getUploadExtension()); + if ($extension !== 'pem') { + return api_error('仅支持上传 .pem 文件', 422); + } + + $content = file_get_contents($file->getRealPath()); + if (!is_string($content) || !str_contains($content, '-----BEGIN')) { + return api_error('PEM 文件内容格式不正确', 422); + } + + $storageDir = base_path() . '/storage/payment-certs'; + if (!is_dir($storageDir)) { + mkdir($storageDir, 0775, true); + } + + $targetFilename = $allowed[$mapKey]['filename']; + $targetPath = $storageDir . '/' . $targetFilename; + file_put_contents($targetPath, $content); + @chmod($targetPath, 0600); + + $now = date('Y-m-d H:i:s'); + $exists = Db::name('system_configs') + ->where('config_group', $groupCode) + ->where('config_key', $configKey) + ->find(); + + $payload = [ + 'config_group' => $groupCode, + 'config_key' => $configKey, + 'config_value' => $targetPath, + 'remark' => '后台系统配置', + 'updated_at' => $now, + ]; + + if ($exists) { + Db::name('system_configs')->where('id', $exists['id'])->update($payload); + } else { + $payload['created_at'] = $now; + Db::name('system_configs')->insert($payload); + } + + return api_success([ + 'config_group' => $groupCode, + 'config_key' => $configKey, + 'config_value' => $targetPath, + 'file_name' => $targetFilename, + 'original_name' => $originalName, + ], '文件已上传'); + } + + private function bootstrapDefaults(): void + { + $now = date('Y-m-d H:i:s'); + foreach ($this->definitions() as $groupCode => $group) { + foreach ($group['items'] as $item) { + $exists = Db::name('system_configs') + ->where('config_group', $groupCode) + ->where('config_key', $item['config_key']) + ->find(); + if ($exists) { + continue; + } + + Db::name('system_configs')->insert([ + 'config_group' => $groupCode, + 'config_key' => $item['config_key'], + 'config_value' => (string)($item['default_value'] ?? ''), + 'remark' => '后台系统配置', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + } + + private function definitions(): array + { + return [ + 'file_storage' => [ + 'group_name' => '文件存储', + 'group_desc' => '配置业务文件存储方式。支持本地磁盘或阿里云 OSS,切换为 OSS 后需填写对应 Bucket 与密钥资料。', + 'items' => [ + [ + 'config_key' => 'driver', + 'title' => '存储驱动', + 'field_type' => 'select', + 'placeholder' => '请选择文件存储方式', + 'remark' => '本地模式写入服务器 public/uploads;OSS 模式写入阿里云对象存储。', + 'is_secret' => false, + 'default_value' => 'local', + 'options' => [ + ['label' => '本地存储', 'value' => 'local'], + ['label' => '阿里云 OSS', 'value' => 'oss'], + ['label' => '七牛云 Kodo', 'value' => 'qiniu'], + ], + ], + [ + 'config_key' => 'public_base_url', + 'title' => '公开访问域名', + 'field_type' => 'text', + 'placeholder' => '例如 https://api.anxinjianyan.com 或 https://static.example.com', + 'remark' => '用于生成文件公网访问地址;本地可填 API 域名,OSS 可填自定义 CDN/回源域名,不填则按驱动自动推导。', + 'is_secret' => false, + ], + [ + 'config_key' => 'oss_endpoint', + 'title' => 'OSS Endpoint', + 'field_type' => 'text', + 'placeholder' => '例如 oss-cn-shenzhen.aliyuncs.com', + 'remark' => '填写 Bucket 所在地域的公网 Endpoint。', + 'is_secret' => false, + 'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'], + ], + [ + 'config_key' => 'oss_bucket', + 'title' => 'OSS Bucket', + 'field_type' => 'text', + 'placeholder' => '请输入 Bucket 名称', + 'remark' => '将作为所有业务文件的目标 Bucket。', + 'is_secret' => false, + 'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'], + ], + [ + 'config_key' => 'oss_access_key_id', + 'title' => 'OSS AccessKey ID', + 'field_type' => 'text', + 'placeholder' => '请输入 OSS AccessKey ID', + 'remark' => '用于 OSS 文件上传、删除和存在性校验。', + 'is_secret' => false, + 'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'], + ], + [ + 'config_key' => 'oss_access_key_secret', + 'title' => 'OSS AccessKey Secret', + 'field_type' => 'password', + 'placeholder' => '请输入 OSS AccessKey Secret', + 'remark' => '请妥善保管,仅后台可见。', + 'is_secret' => true, + 'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'], + ], + [ + 'config_key' => 'oss_bucket_domain', + 'title' => 'OSS 绑定域名', + 'field_type' => 'text', + 'placeholder' => '例如 https://static.anxinjianyan.com', + 'remark' => '如 Bucket 已绑定自定义域名,可填写;不填则默认使用 https://bucket.endpoint。', + 'is_secret' => false, + 'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'], + ], + [ + 'config_key' => 'oss_path_prefix', + 'title' => 'OSS 路径前缀', + 'field_type' => 'text', + 'placeholder' => '例如 anxinyan-prod', + 'remark' => '可选。填写后 OSS 对象会统一写入此前缀目录下。', + 'is_secret' => false, + 'visible_when' => ['config_key' => 'driver', 'equals' => 'oss'], + ], + [ + 'config_key' => 'qiniu_bucket', + 'title' => '七牛 Bucket', + 'field_type' => 'text', + 'placeholder' => '请输入七牛 Kodo Bucket 名称', + 'remark' => '将作为七牛云对象存储的目标 Bucket。', + 'is_secret' => false, + 'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'], + ], + [ + 'config_key' => 'qiniu_access_key', + 'title' => '七牛 AccessKey', + 'field_type' => 'text', + 'placeholder' => '请输入七牛 AccessKey', + 'remark' => '用于七牛文件上传、删除和存在性校验。', + 'is_secret' => false, + 'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'], + ], + [ + 'config_key' => 'qiniu_secret_key', + 'title' => '七牛 SecretKey', + 'field_type' => 'password', + 'placeholder' => '请输入七牛 SecretKey', + 'remark' => '请妥善保管,仅后台可见。', + 'is_secret' => true, + 'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'], + ], + [ + 'config_key' => 'qiniu_bucket_domain', + 'title' => '七牛公网访问域名', + 'field_type' => 'text', + 'placeholder' => '例如 https://static.example.com 或 https://xxx.clouddn.com', + 'remark' => '用于生成七牛文件公网访问地址。建议填写已绑定并可公开访问的域名。', + 'is_secret' => false, + 'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'], + ], + [ + 'config_key' => 'qiniu_path_prefix', + 'title' => '七牛路径前缀', + 'field_type' => 'text', + 'placeholder' => '例如 anxinyan-prod', + 'remark' => '可选。填写后七牛对象会统一写入此前缀目录下。', + 'is_secret' => false, + 'visible_when' => ['config_key' => 'driver', 'equals' => 'qiniu'], + ], + ], + ], + 'mini_program' => [ + 'group_name' => '小程序配置', + 'group_desc' => '配置微信小程序 AppID、密钥及消息通知相关参数。', + 'items' => [ + ['config_key' => 'app_id', 'title' => '小程序 AppID', 'field_type' => 'text', 'placeholder' => '请输入小程序 AppID', 'remark' => '用于小程序登录、消息与支付能力接入', 'is_secret' => false], + ['config_key' => 'app_secret', 'title' => '小程序 AppSecret', 'field_type' => 'password', 'placeholder' => '请输入小程序 AppSecret', 'remark' => '请妥善保管,仅后台可见', 'is_secret' => true], + ['config_key' => 'original_id', 'title' => '原始 ID', 'field_type' => 'text', 'placeholder' => '请输入原始 ID', 'remark' => '用于公众号/小程序主体识别', 'is_secret' => false], + ], + ], + 'h5' => [ + 'group_name' => 'H5 配置', + 'group_desc' => '配置 H5 接入、开放平台、回调地址以及公开页面域名。', + 'items' => [ + ['config_key' => 'app_id', 'title' => 'H5 AppID', 'field_type' => 'text', 'placeholder' => '请输入 H5 AppID', 'remark' => '用于 H5 登录与开放平台接入', 'is_secret' => false], + ['config_key' => 'app_secret', 'title' => 'H5 AppSecret', 'field_type' => 'password', 'placeholder' => '请输入 H5 AppSecret', 'remark' => '请妥善保管,仅后台可见', 'is_secret' => true], + ['config_key' => 'oauth_redirect_url', 'title' => '授权回调地址', 'field_type' => 'text', 'placeholder' => '请输入 H5 授权回调地址', 'remark' => '用于 H5 登录或支付回调', 'is_secret' => false], + ['config_key' => 'page_base_url', 'title' => 'H5 页面根地址', 'field_type' => 'text', 'placeholder' => '例如 https://m.anxinyan.com', 'remark' => '用于生成扫码查看报告和验真页的完整 H5 链接', 'is_secret' => false], + ], + ], + 'payment' => [ + 'group_name' => '支付与商户平台', + 'group_desc' => '配置微信支付商户号、API 密钥、证书序列号等上线必要参数。', + 'items' => [ + ['config_key' => 'mch_id', 'title' => '商户号 MchID', 'field_type' => 'text', 'placeholder' => '请输入商户号', 'remark' => '微信支付商户平台分配的商户号', 'is_secret' => false], + ['config_key' => 'api_v3_key', 'title' => 'APIv3 Key', 'field_type' => 'password', 'placeholder' => '请输入 APIv3 Key', 'remark' => '用于微信支付接口验签与解密', 'is_secret' => true], + ['config_key' => 'merchant_serial_no', 'title' => '商户证书序列号', 'field_type' => 'text', 'placeholder' => '请输入商户证书序列号', 'remark' => '与商户 API 证书匹配', 'is_secret' => false], + ['config_key' => 'apiclient_key_path', 'title' => 'apiclient_key.pem', 'field_type' => 'file', 'placeholder' => '请上传 apiclient_key.pem', 'remark' => '上传微信支付商户私钥文件,系统将保存到后端非公开目录', 'is_secret' => true], + ['config_key' => 'apiclient_cert_path', 'title' => 'apiclient_cert.pem', 'field_type' => 'file', 'placeholder' => '请上传 apiclient_cert.pem', 'remark' => '上传微信支付商户证书文件,系统将保存到后端非公开目录', 'is_secret' => false], + ['config_key' => 'merchant_private_key', 'title' => '商户私钥', 'field_type' => 'textarea', 'placeholder' => '请输入商户私钥内容', 'remark' => '用于支付签名,请妥善保管', 'is_secret' => true], + ['config_key' => 'platform_certificate_serial', 'title' => '平台证书序列号', 'field_type' => 'text', 'placeholder' => '请输入微信支付平台证书序列号', 'remark' => '用于平台证书校验', 'is_secret' => false], + ['config_key' => 'notify_url', 'title' => '支付回调地址', 'field_type' => 'text', 'placeholder' => '请输入支付回调通知地址', 'remark' => '支付成功后用于回调业务系统', 'is_secret' => false], + ], + ], + 'sms' => [ + 'group_name' => '短信配置', + 'group_desc' => '配置阿里云短信服务 AccessKey、签名和登录验证码模板,用于手机号验证码登录。', + 'items' => [ + ['config_key' => 'access_key_id', 'title' => 'AccessKey ID', 'field_type' => 'text', 'placeholder' => '请输入阿里云 AccessKey ID', 'remark' => '用于调用阿里云短信 SendSms 接口', 'is_secret' => false], + ['config_key' => 'access_key_secret', 'title' => 'AccessKey Secret', 'field_type' => 'password', 'placeholder' => '请输入阿里云 AccessKey Secret', 'remark' => '请妥善保管,仅后台可见', 'is_secret' => true], + ['config_key' => 'sign_name', 'title' => '短信签名', 'field_type' => 'text', 'placeholder' => '请输入短信签名', 'remark' => '需与阿里云短信服务已审核通过的签名一致', 'is_secret' => false], + ['config_key' => 'login_template_code', 'title' => '登录模板 Code', 'field_type' => 'text', 'placeholder' => '例如 SMS_123456789', 'remark' => '模板中需包含 code 变量', 'is_secret' => false], + ['config_key' => 'region_id', 'title' => 'Region ID', 'field_type' => 'text', 'placeholder' => '默认 cn-hangzhou', 'remark' => '通常填写 cn-hangzhou', 'is_secret' => false], + ['config_key' => 'endpoint', 'title' => '短信 Endpoint', 'field_type' => 'text', 'placeholder' => '默认可留空', 'remark' => '如不填写则按 SDK 默认规则解析', 'is_secret' => false], + ], + ], + ]; + } + + private function uploadableConfigMap(): array + { + return [ + 'payment.apiclient_key_path' => [ + 'filename' => 'apiclient_key.pem', + ], + 'payment.apiclient_cert_path' => [ + 'filename' => 'apiclient_cert.pem', + ], + ]; + } + + private function validateConfigValues(array $configValueMap): void + { + $driver = (new FileStorageConfigService())->normalizeDriver((string)($configValueMap['file_storage.driver'] ?? 'local')); + if ($driver === 'local') { + return; + } + + if ($driver === 'oss') { + $required = [ + 'file_storage.oss_endpoint' => 'OSS Endpoint', + 'file_storage.oss_bucket' => 'OSS Bucket', + 'file_storage.oss_access_key_id' => 'OSS AccessKey ID', + 'file_storage.oss_access_key_secret' => 'OSS AccessKey Secret', + ]; + + foreach ($required as $key => $label) { + if (trim((string)($configValueMap[$key] ?? '')) === '') { + throw new \RuntimeException(sprintf('当前已切换为 OSS 存储,请先填写 %s', $label)); + } + } + + return; + } + + if ($driver !== 'qiniu') { + return; + } + + $required = [ + 'file_storage.qiniu_bucket' => '七牛 Bucket', + 'file_storage.qiniu_access_key' => '七牛 AccessKey', + 'file_storage.qiniu_secret_key' => '七牛 SecretKey', + ]; + + foreach ($required as $key => $label) { + if (trim((string)($configValueMap[$key] ?? '')) === '') { + throw new \RuntimeException(sprintf('当前已切换为七牛云存储,请先填写 %s', $label)); + } + } + + $publicBaseUrl = trim((string)($configValueMap['file_storage.public_base_url'] ?? '')); + $bucketDomain = trim((string)($configValueMap['file_storage.qiniu_bucket_domain'] ?? '')); + if ($publicBaseUrl === '' && $bucketDomain === '') { + throw new \RuntimeException('当前已切换为七牛云存储,请至少填写公开访问域名或七牛公网访问域名'); + } + } +} diff --git a/server-api/app/controller/admin/TicketsController.php b/server-api/app/controller/admin/TicketsController.php new file mode 100644 index 0000000..60c3f2b --- /dev/null +++ b/server-api/app/controller/admin/TicketsController.php @@ -0,0 +1,344 @@ + [ + [ + 'title' => '工单总量', + 'value' => (int)Db::name('tickets')->count(), + 'desc' => '当前数据库内工单总数', + ], + [ + 'title' => '待处理工单', + 'value' => (int)Db::name('tickets')->whereIn('status', ['pending', 'processing'])->count(), + 'desc' => '待处理与处理中工单数量', + ], + [ + 'title' => '已解决工单', + 'value' => (int)Db::name('tickets')->where('status', 'resolved')->count(), + 'desc' => '当前已解决的工单数量', + ], + [ + 'title' => '工单留言', + 'value' => (int)Db::name('ticket_messages')->count(), + 'desc' => '当前工单消息记录总数', + ], + ], + ]); + } + + public function index(Request $request) + { + $keyword = trim((string)$request->input('keyword', '')); + $status = trim((string)$request->input('status', '')); + $type = trim((string)$request->input('ticket_type', '')); + + $query = Db::name('tickets') + ->field([ + 'id', + 'ticket_no', + 'ticket_type', + 'biz_type', + 'biz_id', + 'order_id', + 'user_id', + 'status', + 'priority', + 'assignee_id', + 'title', + 'created_at', + 'updated_at', + ]) + ->order('id', 'desc'); + + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->whereLike('ticket_no', "%{$keyword}%") + ->whereOrLike('title', "%{$keyword}%"); + }); + } + if ($status !== '') { + $query->where('status', $status); + } + if ($type !== '') { + $query->where('ticket_type', $type); + } + + $rows = $query->select()->toArray(); + + $list = array_map(function (array $item) { + return [ + 'id' => (int)$item['id'], + 'ticket_no' => $item['ticket_no'], + 'ticket_type' => $item['ticket_type'], + 'ticket_type_text' => $this->ticketTypeText($item['ticket_type']), + 'biz_type' => $item['biz_type'], + 'biz_id' => (int)($item['biz_id'] ?? 0), + 'order_id' => (int)($item['order_id'] ?? 0), + 'user_id' => (int)($item['user_id'] ?? 0), + 'status' => $item['status'], + 'status_text' => $this->statusText($item['status']), + 'priority' => $item['priority'], + 'priority_text' => $this->priorityText($item['priority']), + 'title' => $item['title'] ?: '未命名工单', + 'created_at' => $item['created_at'], + 'updated_at' => $item['updated_at'], + ]; + }, $rows); + + return api_success(['list' => $list]); + } + + public function detail(Request $request) + { + $id = (int)$request->input('id', 0); + if (!$id) { + return api_error('工单 ID 不能为空', 422); + } + + $ticket = Db::name('tickets')->where('id', $id)->find(); + if (!$ticket) { + return api_error('工单不存在', 404); + } + + $messages = Db::name('ticket_messages') + ->where('ticket_id', $id) + ->order('id', 'asc') + ->select() + ->toArray(); + + return api_success([ + 'ticket_info' => [ + 'id' => (int)$ticket['id'], + 'ticket_no' => $ticket['ticket_no'], + 'ticket_type' => $ticket['ticket_type'], + 'ticket_type_text' => $this->ticketTypeText($ticket['ticket_type']), + 'biz_type' => $ticket['biz_type'], + 'biz_id' => (int)($ticket['biz_id'] ?? 0), + 'order_id' => (int)($ticket['order_id'] ?? 0), + 'user_id' => (int)($ticket['user_id'] ?? 0), + 'status' => $ticket['status'], + 'status_text' => $this->statusText($ticket['status']), + 'priority' => $ticket['priority'], + 'priority_text' => $this->priorityText($ticket['priority']), + 'title' => $ticket['title'], + 'content' => $ticket['content'], + 'created_at' => $ticket['created_at'], + 'updated_at' => $ticket['updated_at'], + ], + 'messages' => array_map(function (array $item) { + return [ + 'sender_type' => $item['sender_type'], + 'sender_type_text' => $item['sender_type'] === 'customer_service' ? '客服' : ($item['sender_type'] === 'system' ? '系统' : '用户'), + 'content' => $item['content'] ?: '', + 'attachments' => $this->attachmentService()->normalize($item['attachments_json'] ?? null, $request), + 'created_at' => $item['created_at'], + ]; + }, $messages), + ]); + } + + public function save(Request $request) + { + $id = (int)$request->input('id', 0); + if (!$id) { + return api_error('工单 ID 不能为空', 422); + } + + $ticket = Db::name('tickets')->where('id', $id)->find(); + if (!$ticket) { + return api_error('工单不存在', 404); + } + + $status = trim((string)$request->input('status', $ticket['status'])); + $priority = trim((string)$request->input('priority', $ticket['priority'])); + $now = date('Y-m-d H:i:s'); + + Db::startTrans(); + try { + Db::name('tickets')->where('id', $id)->update([ + 'status' => $status, + 'priority' => $priority, + 'updated_at' => $now, + ]); + + if ($status !== $ticket['status']) { + $this->notifyStatusChanged($ticket, $status, $now); + } + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('工单更新失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success(['id' => $id], '工单已更新'); + } + + public function reply(Request $request) + { + $ticketId = (int)$request->input('ticket_id', 0); + $content = trim((string)$request->input('content', '')); + $attachments = $this->attachmentService()->normalize($request->input('attachments', []), $request, true); + + if (!$ticketId) { + return api_error('工单 ID 不能为空', 422); + } + if ($content === '' && !$attachments) { + return api_error('回复内容和附件至少填写一项', 422); + } + + $ticket = Db::name('tickets')->where('id', $ticketId)->find(); + if (!$ticket) { + return api_error('工单不存在', 404); + } + + $now = date('Y-m-d H:i:s'); + Db::startTrans(); + try { + $messageId = (int)Db::name('ticket_messages')->insertGetId([ + 'ticket_id' => $ticketId, + 'sender_type' => 'customer_service', + 'sender_id' => 1, + 'content' => $content, + 'attachments_json' => $attachments ? json_encode($attachments, JSON_UNESCAPED_UNICODE) : null, + 'created_at' => $now, + ]); + Db::name('tickets')->where('id', $ticketId)->update([ + 'status' => 'processing', + 'updated_at' => $now, + ]); + + (new MessageDispatcher())->sendInboxEvent('ticket_reply', [ + 'user_id' => (int)($ticket['user_id'] ?? 0), + 'biz_type' => 'ticket_message', + 'biz_id' => $messageId, + 'ticket_id' => $ticketId, + 'ticket_no' => $ticket['ticket_no'], + 'ticket_title' => $ticket['title'] ?: '客服工单', + 'reply_content' => $content, + 'fallback_title' => '工单有新回复', + 'fallback_content' => sprintf('客服已回复您的工单「%s」,点击查看详情。', $ticket['title'] ?: '客服工单'), + ]); + + Db::commit(); + return api_success(['ticket_id' => $ticketId], '回复成功'); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('回复失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + } + + public function uploadFile(Request $request) + { + try { + $asset = $this->attachmentService()->upload($request); + return api_success($asset); + } catch (\Throwable $e) { + return api_error($e->getMessage(), 422); + } + } + + public function deleteFile(Request $request) + { + $fileUrl = trim((string)$request->input('file_url', '')); + if ($fileUrl === '') { + return api_error('文件地址不能为空', 422); + } + + $this->attachmentService()->delete($fileUrl); + + return api_success([ + 'file_url' => $fileUrl, + ], '删除成功'); + } + + private function statusText(string $status): string + { + return (new ContentService())->ticketStatusText($status); + } + + private function priorityText(string $priority): string + { + return match ($priority) { + 'high' => '高优先级', + 'normal' => '普通', + 'low' => '低优先级', + default => $priority, + }; + } + + private function ticketTypeText(string $type): string + { + return (new ContentService())->ticketTypeText($type); + } + + private function notifyStatusChanged(array $ticket, string $status, string $now): void + { + $eventConfig = match ($status) { + 'waiting_user' => [ + 'event_code' => 'ticket_waiting_user', + 'title' => '工单等待您补充反馈', + 'content' => sprintf('客服正在跟进工单「%s」,当前需要您补充反馈信息。', $ticket['title'] ?: '客服工单'), + 'system_message' => '工单状态已更新为待用户反馈,请等待用户补充信息。', + ], + 'resolved' => [ + 'event_code' => 'ticket_resolved', + 'title' => '工单已解决', + 'content' => sprintf('您的工单「%s」已处理完成,如仍有疑问可继续留言。', $ticket['title'] ?: '客服工单'), + 'system_message' => '工单状态已更新为已解决。', + ], + 'closed' => [ + 'event_code' => 'ticket_closed', + 'title' => '工单已关闭', + 'content' => sprintf('您的工单「%s」已关闭,如需继续处理可重新发起工单。', $ticket['title'] ?: '客服工单'), + 'system_message' => '工单状态已更新为已关闭。', + ], + default => null, + }; + + if (!$eventConfig) { + return; + } + + $messageId = (int)Db::name('ticket_messages')->insertGetId([ + 'ticket_id' => (int)$ticket['id'], + 'sender_type' => 'system', + 'sender_id' => null, + 'content' => $eventConfig['system_message'], + 'attachments_json' => null, + 'created_at' => $now, + ]); + + (new MessageDispatcher())->sendInboxEvent($eventConfig['event_code'], [ + 'user_id' => (int)($ticket['user_id'] ?? 0), + 'biz_type' => 'ticket_message', + 'biz_id' => $messageId, + 'ticket_id' => (int)$ticket['id'], + 'ticket_no' => $ticket['ticket_no'] ?? '', + 'ticket_title' => $ticket['title'] ?: '客服工单', + 'fallback_title' => $eventConfig['title'], + 'fallback_content' => $eventConfig['content'], + ]); + } + + private function attachmentService(): TicketAttachmentService + { + return new TicketAttachmentService(); + } +} diff --git a/server-api/app/controller/admin/UsersController.php b/server-api/app/controller/admin/UsersController.php new file mode 100644 index 0000000..fb0f6d3 --- /dev/null +++ b/server-api/app/controller/admin/UsersController.php @@ -0,0 +1,244 @@ +ensurePasswordColumn(); + return api_success([ + 'cards' => [ + [ + 'title' => '用户总量', + 'value' => (int)Db::name('users')->count(), + 'desc' => '当前数据库中的用户数量', + ], + [ + 'title' => '正常用户', + 'value' => (int)Db::name('users')->where('status', 'enabled')->count(), + 'desc' => '当前可正常使用系统的用户数量', + ], + [ + 'title' => '地址数量', + 'value' => (int)Db::name('user_addresses')->count(), + 'desc' => '用户维护的寄送与收货地址总数', + ], + [ + 'title' => '消息总量', + 'value' => (int)Db::name('user_messages')->count(), + 'desc' => '已发送给用户的站内消息数量', + ], + ], + ]); + } + + public function index(Request $request) + { + $this->ensurePasswordColumn(); + $keyword = trim((string)$request->input('keyword', '')); + $status = trim((string)$request->input('status', '')); + + $query = Db::name('users') + ->alias('u') + ->leftJoin('user_addresses a', 'a.user_id = u.id AND a.is_default = 1') + ->field([ + 'u.id', + 'u.nickname', + 'u.mobile', + 'u.password', + 'u.status', + 'u.created_at', + 'u.updated_at', + 'a.province', + 'a.city', + 'a.district', + 'a.detail_address', + ]) + ->order('u.id', 'desc'); + + if ($keyword !== '') { + $query->where(function ($builder) use ($keyword) { + $builder->whereLike('u.nickname', "%{$keyword}%") + ->whereOrLike('u.mobile', "%{$keyword}%"); + }); + } + + if ($status !== '') { + $query->where('u.status', $status); + } + + $rows = $query->select()->toArray(); + + $list = array_map(function (array $item) { + $userId = (int)$item['id']; + + return [ + 'id' => $userId, + 'nickname' => $item['nickname'] ?: '未命名用户', + 'mobile' => $item['mobile'] ?: '', + 'status' => $item['status'], + 'status_text' => $this->userStatusText($item['status']), + 'password_set' => ((string)($item['password'] ?? '')) !== '', + 'default_address' => trim(sprintf( + '%s%s%s%s', + $item['province'] ?? '', + $item['city'] ?? '', + $item['district'] ?? '', + $item['detail_address'] ?? '' + )), + 'order_count' => (int)Db::name('orders')->where('user_id', $userId)->count(), + 'message_count' => (int)Db::name('user_messages')->where('user_id', $userId)->count(), + 'ticket_count' => (int)Db::name('tickets')->where('user_id', $userId)->count(), + 'created_at' => $item['created_at'], + 'updated_at' => $item['updated_at'], + ]; + }, $rows); + + return api_success(['list' => $list]); + } + + public function detail(Request $request) + { + $this->ensurePasswordColumn(); + $id = (int)$request->input('id', 0); + if ($id <= 0) { + return api_error('用户 ID 不能为空', 422); + } + + $user = Db::name('users')->where('id', $id)->find(); + if (!$user) { + return api_error('用户不存在', 404); + } + + $addresses = Db::name('user_addresses') + ->where('user_id', $id) + ->order('is_default', 'desc') + ->order('id', 'desc') + ->select() + ->toArray(); + + $recentOrders = Db::name('orders') + ->where('user_id', $id) + ->order('id', 'desc') + ->limit(5) + ->select() + ->toArray(); + + $recentMessages = Db::name('user_messages') + ->where('user_id', $id) + ->order('id', 'desc') + ->limit(5) + ->select() + ->toArray(); + + return api_success([ + 'user_info' => [ + 'id' => (int)$user['id'], + 'nickname' => $user['nickname'] ?: '未命名用户', + 'mobile' => $user['mobile'] ?: '', + 'status' => $user['status'], + 'status_text' => $this->userStatusText($user['status']), + 'password_set' => ((string)($user['password'] ?? '')) !== '', + 'created_at' => $user['created_at'], + 'updated_at' => $user['updated_at'], + ], + 'addresses' => array_map(fn (array $item) => [ + 'consignee' => $item['consignee'], + 'mobile' => $item['mobile'], + 'full_address' => trim(sprintf('%s%s%s%s', $item['province'], $item['city'], $item['district'], $item['detail_address'])), + 'is_default' => (bool)$item['is_default'], + ], $addresses), + 'recent_orders' => array_map(fn (array $item) => [ + 'order_no' => $item['order_no'], + 'display_status' => $item['display_status'], + 'pay_amount' => (float)$item['pay_amount'], + 'created_at' => $item['created_at'], + ], $recentOrders), + 'recent_messages' => array_map(fn (array $item) => [ + 'title' => $item['title'], + 'content' => $item['content'], + 'is_read' => (bool)$item['is_read'], + 'created_at' => $item['created_at'], + ], $recentMessages), + ]); + } + + public function save(Request $request) + { + $this->ensurePasswordColumn(); + $id = (int)$request->input('id', 0); + $nickname = trim((string)$request->input('nickname', '')); + $mobile = trim((string)$request->input('mobile', '')); + $status = trim((string)$request->input('status', 'enabled')); + $password = trim((string)$request->input('password', '')); + + if ($nickname === '' || $mobile === '') { + return api_error('昵称和手机号不能为空', 422); + } + + $now = date('Y-m-d H:i:s'); + $payload = [ + 'nickname' => $nickname, + 'mobile' => $mobile, + 'status' => $status !== '' ? $status : 'enabled', + 'updated_at' => $now, + ]; + if ($password !== '') { + $payload['password'] = password_hash($password, PASSWORD_BCRYPT); + } + + if ($id > 0) { + $user = Db::name('users')->where('id', $id)->find(); + if (!$user) { + return api_error('用户不存在', 404); + } + + $exists = Db::name('users') + ->where('mobile', $mobile) + ->where('id', '<>', $id) + ->find(); + if ($exists) { + return api_error('手机号已存在', 422); + } + + Db::name('users')->where('id', $id)->update($payload); + return api_success(['id' => $id], '用户已更新'); + } + + $exists = Db::name('users')->where('mobile', $mobile)->find(); + if ($exists) { + return api_error('手机号已存在', 422); + } + + $payload['avatar'] = ''; + $payload['password'] = $payload['password'] ?? ''; + $payload['last_login_at'] = null; + $payload['created_at'] = $now; + $newId = (int)Db::name('users')->insertGetId($payload); + + return api_success(['id' => $newId], '用户已创建'); + } + + private function userStatusText(string $status): string + { + return match ($status) { + 'enabled' => '正常', + 'disabled' => '已停用', + default => $status, + }; + } + + private function ensurePasswordColumn(): void + { + $column = Db::query("SHOW COLUMNS FROM users LIKE 'password'"); + if ($column) { + return; + } + + Db::execute("ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL DEFAULT '' AFTER mobile"); + } +} diff --git a/server-api/app/controller/admin/WarehousesController.php b/server-api/app/controller/admin/WarehousesController.php new file mode 100644 index 0000000..65bca2b --- /dev/null +++ b/server-api/app/controller/admin/WarehousesController.php @@ -0,0 +1,69 @@ + $this->service()->overviewCards(), + ]); + } + + public function index(Request $request) + { + return api_success([ + 'list' => $this->service()->list(), + 'category_options' => array_map(static fn(array $item) => [ + 'id' => (int)$item['id'], + 'name' => $item['name'], + ], \support\think\Db::name('catalog_categories')->where('is_enabled', 1)->order('sort_order', 'asc')->select()->toArray()), + ]); + } + + public function save(Request $request) + { + $id = (int)$request->input('id', 0); + try { + $warehouseId = $this->service()->save([ + 'warehouse_name' => $request->input('warehouse_name', ''), + 'warehouse_code' => $request->input('warehouse_code', ''), + 'service_provider' => $request->input('service_provider', 'anxinyan'), + 'receiver_name' => $request->input('receiver_name', ''), + 'receiver_mobile' => $request->input('receiver_mobile', ''), + 'province' => $request->input('province', ''), + 'city' => $request->input('city', ''), + 'district' => $request->input('district', ''), + 'detail_address' => $request->input('detail_address', ''), + 'service_time' => $request->input('service_time', ''), + 'notice' => $request->input('notice', ''), + 'supported_category_ids' => $request->input('supported_category_ids', []), + 'service_area_provinces' => $request->input('service_area_provinces', []), + 'service_area_cities' => $request->input('service_area_cities', []), + 'status' => $request->input('status', 'enabled'), + 'is_default' => $request->input('is_default', false), + 'sort_order' => $request->input('sort_order', 0), + 'remark' => $request->input('remark', ''), + ], $id); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), 422); + } catch (\Throwable $e) { + return api_error('仓库保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success([ + 'id' => $warehouseId, + ], $id > 0 ? '仓库已更新' : '仓库已创建'); + } + + private function service(): WarehouseService + { + return new WarehouseService(); + } +} diff --git a/server-api/app/controller/app/AddressesController.php b/server-api/app/controller/app/AddressesController.php new file mode 100644 index 0000000..38a2c5b --- /dev/null +++ b/server-api/app/controller/app/AddressesController.php @@ -0,0 +1,219 @@ +where('user_id', $userId) + ->order('is_default', 'desc') + ->order('id', 'desc') + ->select() + ->toArray(); + + return api_success([ + 'list' => array_map(fn (array $item) => $this->formatAddress($item), $rows), + ]); + } + + public function detail(Request $request) + { + $id = (int)$request->input('id', 0); + if ($id <= 0) { + return api_error('地址 ID 不能为空', 422); + } + + $address = Db::name('user_addresses')->where('id', $id)->where('user_id', app_user_id($request))->find(); + if (!$address) { + return api_error('地址不存在', 404); + } + + return api_success($this->formatAddress($address)); + } + + public function save(Request $request) + { + $userId = app_user_id($request); + $id = (int)$request->input('id', 0); + $consignee = trim((string)$request->input('consignee', '')); + $mobile = trim((string)$request->input('mobile', '')); + $province = trim((string)$request->input('province', '')); + $city = trim((string)$request->input('city', '')); + $district = trim((string)$request->input('district', '')); + $detailAddress = trim((string)$request->input('detail_address', '')); + $isDefault = (bool)$request->input('is_default', false); + + if ($consignee === '' || $mobile === '' || $province === '' || $city === '' || $district === '' || $detailAddress === '') { + return api_error('请完整填写地址信息', 422); + } + + $now = date('Y-m-d H:i:s'); + + Db::startTrans(); + try { + $existing = null; + if ($id > 0) { + $existing = Db::name('user_addresses')->where('id', $id)->where('user_id', $userId)->find(); + if (!$existing) { + Db::rollback(); + return api_error('地址不存在', 404); + } + } + + $addressCount = (int)Db::name('user_addresses')->where('user_id', $userId)->count(); + $shouldSetDefault = $isDefault || $addressCount === 0 || ($existing && (bool)$existing['is_default']); + + if ($shouldSetDefault) { + Db::name('user_addresses')->where('user_id', $userId)->update([ + 'is_default' => 0, + 'updated_at' => $now, + ]); + } + + $payload = [ + 'user_id' => $userId, + 'consignee' => $consignee, + 'mobile' => $mobile, + 'province' => $province, + 'city' => $city, + 'district' => $district, + 'detail_address' => $detailAddress, + 'is_default' => $shouldSetDefault ? 1 : 0, + 'updated_at' => $now, + ]; + + if ($existing) { + Db::name('user_addresses')->where('id', $id)->update($payload); + $addressId = $id; + } else { + $payload['created_at'] = $now; + $addressId = (int)Db::name('user_addresses')->insertGetId($payload); + } + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('地址保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + $address = Db::name('user_addresses')->where('id', $addressId)->where('user_id', $userId)->find(); + + return api_success([ + 'id' => $addressId, + 'address' => $this->formatAddress($address ?: []), + ], '地址已保存'); + } + + public function setDefault(Request $request) + { + $id = (int)$request->input('id', 0); + if ($id <= 0) { + return api_error('地址 ID 不能为空', 422); + } + + $address = Db::name('user_addresses')->where('id', $id)->where('user_id', app_user_id($request))->find(); + if (!$address) { + return api_error('地址不存在', 404); + } + + $now = date('Y-m-d H:i:s'); + + Db::startTrans(); + try { + Db::name('user_addresses')->where('user_id', app_user_id($request))->update([ + 'is_default' => 0, + 'updated_at' => $now, + ]); + + Db::name('user_addresses')->where('id', $id)->where('user_id', app_user_id($request))->update([ + 'is_default' => 1, + 'updated_at' => $now, + ]); + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('默认地址设置失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success([ + 'id' => $id, + ], '已设为默认地址'); + } + + public function delete(Request $request) + { + $id = (int)$request->input('id', 0); + if ($id <= 0) { + return api_error('地址 ID 不能为空', 422); + } + + $address = Db::name('user_addresses')->where('id', $id)->where('user_id', app_user_id($request))->find(); + if (!$address) { + return api_error('地址不存在', 404); + } + + Db::startTrans(); + try { + Db::name('user_addresses')->where('id', $id)->where('user_id', app_user_id($request))->delete(); + + if ((bool)$address['is_default']) { + $next = Db::name('user_addresses') + ->where('user_id', app_user_id($request)) + ->order('id', 'desc') + ->find(); + + if ($next) { + Db::name('user_addresses')->where('id', $next['id'])->update([ + 'is_default' => 1, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + } + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('地址删除失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success([ + 'id' => $id, + ], '地址已删除'); + } + + private function formatAddress(array $item): array + { + return [ + 'id' => (int)($item['id'] ?? 0), + 'consignee' => $item['consignee'] ?? '', + 'mobile' => $item['mobile'] ?? '', + 'province' => $item['province'] ?? '', + 'city' => $item['city'] ?? '', + 'district' => $item['district'] ?? '', + 'detail_address' => $item['detail_address'] ?? '', + 'full_address' => trim(sprintf( + '%s%s%s%s', + $item['province'] ?? '', + $item['city'] ?? '', + $item['district'] ?? '', + $item['detail_address'] ?? '' + )), + 'is_default' => (bool)($item['is_default'] ?? false), + 'created_at' => $item['created_at'] ?? '', + 'updated_at' => $item['updated_at'] ?? '', + ]; + } +} diff --git a/server-api/app/controller/app/AppraisalController.php b/server-api/app/controller/app/AppraisalController.php new file mode 100644 index 0000000..8831353 --- /dev/null +++ b/server-api/app/controller/app/AppraisalController.php @@ -0,0 +1,663 @@ +post('draft_id', 0); + $itemCode = trim((string)$request->post('item_code', '')); + $itemName = trim((string)$request->post('item_name', '')); + + if (!$draftId || $itemCode === '') { + return api_error('草稿 ID 和资料项编码不能为空', 422); + } + + $draft = Db::name('appraisal_drafts')->where('id', $draftId)->where('user_id', $userId)->find(); + if (!$draft) { + return api_error('草稿不存在', 404); + } + + $file = $request->file('file'); + if (!$file || !$file->isValid()) { + return api_error('上传文件无效', 422); + } + + $extension = strtolower($file->getUploadExtension() ?: 'jpg'); + $filename = sprintf('%s_%s.%s', $itemCode, uniqid(), $extension); + $relativeDir = 'uploads/appraisal/' . date('Ymd'); + $relativePath = $relativeDir . '/' . $filename; + $this->storage()->putUploadedFile($file, $relativePath); + + $fileUrl = $this->storage()->publicUrl($request, $relativePath); + + return api_success([ + 'file_id' => md5($relativePath), + 'item_code' => $itemCode, + 'item_name' => $itemName, + 'file_url' => $fileUrl, + 'thumbnail_url' => $fileUrl, + 'name' => $file->getUploadName(), + ]); + } + + public function createDraft(Request $request) + { + $userId = app_user_id($request); + $serviceProvider = (string)$request->input('service_provider', 'anxinyan'); + $serviceMode = (string)$request->input('service_mode', 'physical'); + + $draftId = Db::name('appraisal_drafts')->insertGetId([ + 'user_id' => $userId, + 'service_mode' => $serviceMode, + 'service_provider' => $serviceProvider, + 'current_step' => 1, + 'status' => 'draft', + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + return api_success([ + 'draft_id' => (int)$draftId, + 'service_provider' => $serviceProvider, + 'service_mode' => $serviceMode, + ]); + } + + public function deleteFile(Request $request) + { + $fileUrl = trim((string)$request->post('file_url', '')); + if ($fileUrl === '') { + return api_error('文件地址不能为空', 422); + } + + $relativePath = $this->storage()->storagePath($fileUrl); + if (!str_starts_with($relativePath, 'uploads/appraisal/')) { + return api_error('不允许删除该文件', 403); + } + + $this->storage()->delete($relativePath); + + return api_success([ + 'file_url' => $fileUrl, + ], '删除成功'); + } + + public function draftDetail(Request $request) + { + $draftId = (int)$request->input('draft_id', 0); + $draft = Db::name('appraisal_drafts')->where('id', $draftId)->where('user_id', app_user_id($request))->find(); + if (!$draft) { + return api_error('草稿不存在', 404); + } + + $product = Db::name('appraisal_draft_products')->where('draft_id', $draftId)->find(); + $extra = Db::name('appraisal_draft_extras')->where('draft_id', $draftId)->find(); + + return api_success([ + 'draft_id' => (int)$draft['id'], + 'service_provider' => $draft['service_provider'], + 'service_mode' => $draft['service_mode'], + 'current_step' => (int)$draft['current_step'], + 'product_info' => $product ?: new \stdClass(), + 'extra_info' => $extra ?: new \stdClass(), + 'upload_info' => [ + 'items' => $this->draftUploadItems($draftId, $request), + ], + ]); + } + + public function saveDraft(Request $request) + { + $draftId = (int)$request->input('draft_id', 0); + $draft = Db::name('appraisal_drafts')->where('id', $draftId)->where('user_id', app_user_id($request))->find(); + if (!$draft) { + return api_error('草稿不存在', 404); + } + + $currentStep = (int)$request->input('current_step', $draft['current_step']); + $productInfo = (array)$request->input('product_info', []); + $extraInfo = (array)$request->input('extra_info', []); + $uploadInfo = (array)$request->input('upload_info', []); + + Db::name('appraisal_drafts') + ->where('id', $draftId) + ->update([ + 'service_provider' => $request->input('service_provider', $draft['service_provider']), + 'current_step' => $currentStep, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + if ($productInfo) { + $payload = [ + 'draft_id' => $draftId, + 'category_id' => $productInfo['category_id'] ?? null, + 'brand_id' => $productInfo['brand_id'] ?? null, + 'color' => $productInfo['color'] ?? '', + 'size_spec' => $productInfo['size_spec'] ?? '', + 'serial_no' => $productInfo['serial_no'] ?? '', + 'updated_at' => date('Y-m-d H:i:s'), + ]; + $exists = Db::name('appraisal_draft_products')->where('draft_id', $draftId)->find(); + if ($exists) { + Db::name('appraisal_draft_products')->where('draft_id', $draftId)->update($payload); + } else { + $payload['created_at'] = date('Y-m-d H:i:s'); + Db::name('appraisal_draft_products')->insert($payload); + } + } + + if ($extraInfo) { + $purchaseDate = $extraInfo['purchase_date'] ?? null; + if ($purchaseDate === '') { + $purchaseDate = null; + } + $payload = [ + 'draft_id' => $draftId, + 'purchase_channel' => $extraInfo['purchase_channel'] ?? '', + 'purchase_price' => $extraInfo['purchase_price'] ?? 0, + 'purchase_date' => $purchaseDate, + 'usage_status' => $extraInfo['usage_status'] ?? '', + 'condition_desc' => $extraInfo['condition_desc'] ?? '', + 'has_accessories' => !empty($extraInfo['accessories']) ? 1 : 0, + 'accessories_json' => json_encode($extraInfo['accessories'] ?? [], JSON_UNESCAPED_UNICODE), + 'remark' => $extraInfo['remark'] ?? '', + 'updated_at' => date('Y-m-d H:i:s'), + ]; + $exists = Db::name('appraisal_draft_extras')->where('draft_id', $draftId)->find(); + if ($exists) { + Db::name('appraisal_draft_extras')->where('draft_id', $draftId)->update($payload); + } else { + $payload['created_at'] = date('Y-m-d H:i:s'); + Db::name('appraisal_draft_extras')->insert($payload); + } + } + + if ($uploadInfo) { + $draftUploadIds = Db::name('appraisal_draft_uploads')->where('draft_id', $draftId)->column('id'); + if ($draftUploadIds) { + Db::name('appraisal_draft_upload_files')->whereIn('draft_upload_id', $draftUploadIds)->delete(); + } + Db::name('appraisal_draft_uploads')->where('draft_id', $draftId)->delete(); + foreach (($uploadInfo['items'] ?? []) as $item) { + $draftUploadId = Db::name('appraisal_draft_uploads')->insertGetId([ + 'draft_id' => $draftId, + 'template_id' => $uploadInfo['template_id'] ?? null, + 'item_code' => $item['item_code'] ?? '', + 'item_name' => $item['item_name'] ?? '', + 'is_required' => !empty($item['is_required']) ? 1 : 0, + 'quality_status' => $item['quality_status'] ?? 'pending', + 'quality_message' => $item['quality_message'] ?? '', + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + foreach (($item['files'] ?? []) as $index => $file) { + Db::name('appraisal_draft_upload_files')->insert([ + 'draft_upload_id' => $draftUploadId, + 'file_id' => $file['file_id'] ?? '', + 'file_url' => $this->assetUrlService()->storagePath((string)($file['file_url'] ?? '')), + 'thumbnail_url' => $this->assetUrlService()->storagePath((string)($file['thumbnail_url'] ?? ($file['file_url'] ?? ''))), + 'sort_order' => $index, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + } + } + + return api_success(['draft_id' => $draftId, 'current_step' => $currentStep]); + } + + public function uploadTemplate(Request $request) + { + $categoryId = (int)$request->input('category_id', 1); + $serviceProvider = (string)$request->input('service_provider', 'anxinyan'); + + $template = Db::name('upload_templates') + ->where('scope_type', 'category') + ->where('scope_id', $categoryId) + ->where('service_provider', $serviceProvider) + ->where('is_enabled', 1) + ->find(); + + if (!$template) { + $template = Db::name('upload_templates') + ->where('scope_type', 'category') + ->where('scope_id', $categoryId) + ->where('service_provider', 'anxinyan') + ->where('is_enabled', 1) + ->find(); + } + + if (!$template) { + return api_success([ + 'template_id' => 0, + 'required_items' => [], + 'optional_items' => [], + ]); + } + + $items = Db::name('upload_template_items') + ->where('template_id', $template['id']) + ->where('is_enabled', 1) + ->order('sort_order', 'asc') + ->select() + ->toArray(); + + $requiredItems = []; + $optionalItems = []; + foreach ($items as $item) { + $payload = [ + 'item_code' => $item['item_code'], + 'item_name' => $item['item_name'], + 'guide_text' => $item['guide_text'], + 'sample_image_url' => $this->assetUrlService()->normalizeUrl((string)$item['sample_image_url'], $request), + 'is_required' => (bool)$item['is_required'], + 'quality_status' => $item['is_required'] ? 'pending' : 'optional', + 'quality_message' => '', + ]; + if ($item['is_required']) { + $requiredItems[] = $payload; + } else { + $optionalItems[] = $payload; + } + } + + return api_success([ + 'template_id' => (int)$template['id'], + 'required_items' => $requiredItems, + 'optional_items' => $optionalItems, + ]); + } + + public function preview(Request $request) + { + $draftId = (int)$request->input('draft_id', 0); + + $draft = Db::name('appraisal_drafts')->where('id', $draftId)->where('user_id', app_user_id($request))->find(); + $product = Db::name('appraisal_draft_products')->where('draft_id', $draftId)->find(); + $extra = Db::name('appraisal_draft_extras')->where('draft_id', $draftId)->find(); + + if (!$draft) { + return api_error('预览数据不存在', 404); + } + + $serviceConfig = $this->serviceConfig((string)$draft['service_provider']); + $policyConfig = (new ContentService())->getPolicyConfig(); + + return api_success([ + 'service_summary' => [ + 'service_provider' => $draft['service_provider'], + 'service_provider_text' => $draft['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定', + ], + 'product_summary' => [ + 'product_name' => $this->resolveProductName($product), + 'category_name' => $this->lookupName('catalog_categories', 'name', $product['category_id'] ?? null), + 'brand_name' => $this->lookupName('catalog_brands', 'name', $product['brand_id'] ?? null), + 'price' => $extra['purchase_price'] ?? 0, + ], + 'upload_summary' => [ + 'uploaded_count' => $this->countUploadedDraftItems($draftId), + ], + 'fee_detail' => [ + 'service_fee' => (float)$serviceConfig['price'], + 'discount_fee' => 0, + 'pay_amount' => (float)$serviceConfig['price'], + ], + 'agreements' => $policyConfig['appraisal_agreements'], + ]); + } + + public function submit(Request $request) + { + $userId = app_user_id($request); + $draftId = (int)$request->input('draft_id', 0); + $returnAddressId = (int)$request->input('return_address_id', 0); + $sourceChannel = $this->normalizeOrderSourceChannel((string)$request->input('source_channel', 'mini_program')); + $sourceCustomerId = trim((string)$request->input('source_customer_id', '')); + + if ($sourceChannel === 'enterprise_push' && $sourceCustomerId === '') { + return api_error('大客户推送订单必须提供客户 ID', 422); + } + if ($sourceChannel !== 'enterprise_push') { + $sourceCustomerId = ''; + } + + $draft = Db::name('appraisal_drafts')->where('id', $draftId)->where('user_id', $userId)->find(); + $product = Db::name('appraisal_draft_products')->where('draft_id', $draftId)->find(); + $extra = Db::name('appraisal_draft_extras')->where('draft_id', $draftId)->find(); + + if (!$draft || !$product) { + return api_error('提交数据不完整', 422); + } + + $serviceConfig = $this->serviceConfig((string)$draft['service_provider']); + $now = date('Y-m-d H:i:s'); + $orderNo = 'AXY' . date('YmdHis') . mt_rand(100, 999); + $appraisalNo = 'AXY-APP-' . date('Ymd') . '-' . mt_rand(1000, 9999); + $estimated = date('Y-m-d H:i:s', strtotime(sprintf('+%d hours', (int)$serviceConfig['sla_hours']))); + $productName = $this->resolveProductName($product); + $warehouseService = new WarehouseService(); + $defaultAddress = Db::name('user_addresses') + ->where('user_id', $userId) + ->where('is_default', 1) + ->find(); + $returnAddress = null; + if ($returnAddressId > 0) { + $returnAddress = Db::name('user_addresses') + ->where('id', $returnAddressId) + ->where('user_id', $userId) + ->find(); + if (!$returnAddress) { + return api_error('寄回地址不存在,请重新选择', 422); + } + } + if (!$returnAddress) { + $returnAddress = $defaultAddress ?: Db::name('user_addresses') + ->where('user_id', $userId) + ->order('id', 'desc') + ->find(); + } + if (!$returnAddress) { + return api_error('请先添加并确认寄回地址', 422); + } + + Db::startTrans(); + try { + $orderId = Db::name('orders')->insertGetId([ + 'order_no' => $orderNo, + 'appraisal_no' => $appraisalNo, + 'user_id' => $userId, + 'service_mode' => $draft['service_mode'], + 'service_provider' => $draft['service_provider'], + 'payment_status' => 'paid', + 'order_status' => 'pending_shipping', + 'display_status' => '待寄送商品', + 'estimated_finish_time' => $estimated, + 'source_channel' => $sourceChannel, + 'source_customer_id' => $sourceCustomerId, + 'pay_amount' => $serviceConfig['price'], + 'paid_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('order_products')->insert([ + 'order_id' => $orderId, + 'category_id' => $product['category_id'] ?? null, + 'category_name' => $this->lookupName('catalog_categories', 'name', $product['category_id'] ?? null), + 'brand_id' => $product['brand_id'] ?? null, + 'brand_name' => $this->lookupName('catalog_brands', 'name', $product['brand_id'] ?? null), + 'color' => $product['color'] ?? '', + 'size_spec' => $product['size_spec'] ?? '', + 'serial_no' => $product['serial_no'] ?? '', + 'product_name' => $productName, + 'product_cover' => '', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('order_extras')->insert([ + 'order_id' => $orderId, + 'purchase_channel' => $extra['purchase_channel'] ?? '', + 'purchase_price' => $extra['purchase_price'] ?? 0, + 'purchase_date' => $extra['purchase_date'] ?? null, + 'usage_status' => $extra['usage_status'] ?? '', + 'condition_desc' => $extra['condition_desc'] ?? '', + 'has_accessories' => $extra['has_accessories'] ?? 0, + 'accessories_json' => $extra['accessories_json'] ?? json_encode([], JSON_UNESCAPED_UNICODE), + 'remark' => $extra['remark'] ?? '', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + if ($returnAddress) { + Db::name('order_return_addresses')->insert([ + 'order_id' => $orderId, + 'user_address_id' => (int)$returnAddress['id'], + 'consignee' => $returnAddress['consignee'] ?? '', + 'mobile' => $returnAddress['mobile'] ?? '', + 'province' => $returnAddress['province'] ?? '', + 'city' => $returnAddress['city'] ?? '', + 'district' => $returnAddress['district'] ?? '', + 'detail_address' => $returnAddress['detail_address'] ?? '', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + $shippingTarget = $warehouseService->bindOrderTarget( + $orderId, + (string)$draft['service_provider'], + !empty($product['category_id']) ? (int)$product['category_id'] : null, + $defaultAddress ?: null + ); + + Db::name('order_timelines')->insertAll([ + [ + 'order_id' => $orderId, + 'node_code' => 'created', + 'node_text' => '下单成功', + 'node_desc' => '订单已生成并完成支付', + 'operator_type' => 'system', + 'operator_id' => null, + 'occurred_at' => $now, + 'created_at' => $now, + ], + [ + 'order_id' => $orderId, + 'node_code' => 'pending_shipping', + 'node_text' => '待寄送商品', + 'node_desc' => sprintf( + '请尽快将商品寄送至%s,以免影响处理时效', + $shippingTarget['warehouse_name'] ?: '鉴定中心' + ), + 'operator_type' => 'system', + 'operator_id' => null, + 'occurred_at' => $now, + 'created_at' => $now, + ], + ]); + + $draftUploads = Db::name('appraisal_draft_uploads')->where('draft_id', $draftId)->select()->toArray(); + foreach ($draftUploads as $draftUpload) { + $draftFiles = Db::name('appraisal_draft_upload_files')->where('draft_upload_id', $draftUpload['id'])->select()->toArray(); + if (!$draftFiles) { + continue; + } + + $orderUploadId = Db::name('order_upload_items')->insertGetId([ + 'order_id' => $orderId, + 'template_id' => $draftUpload['template_id'], + 'item_code' => $draftUpload['item_code'], + 'item_name' => $draftUpload['item_name'], + 'is_required' => $draftUpload['is_required'], + 'source_type' => 'initial', + 'status' => $draftUpload['quality_status'], + 'created_at' => $now, + 'updated_at' => $now, + ]); + + foreach ($draftFiles as $draftFile) { + Db::name('order_upload_files')->insert([ + 'order_upload_item_id' => $orderUploadId, + 'file_id' => $draftFile['file_id'], + 'file_url' => $draftFile['file_url'], + 'thumbnail_url' => $draftFile['thumbnail_url'], + 'quality_status' => $draftUpload['quality_status'], + 'quality_message' => $draftUpload['quality_message'], + 'uploaded_by_user_id' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + Db::name('appraisal_tasks')->insert([ + 'order_id' => $orderId, + 'task_stage' => 'first_review', + 'service_provider' => $draft['service_provider'], + 'status' => 'pending', + 'assignee_id' => null, + 'assignee_name' => '未分配', + 'started_at' => null, + 'submitted_at' => null, + 'sla_deadline' => $estimated, + 'is_overtime' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('appraisal_drafts')->where('id', $draftId)->update([ + 'status' => 'submitted', + 'updated_at' => $now, + ]); + + (new MessageDispatcher())->sendInboxEvent('order_created', [ + 'user_id' => $userId, + 'biz_type' => 'order', + 'biz_id' => (int)$orderId, + 'order_no' => $orderNo, + 'appraisal_no' => $appraisalNo, + 'product_name' => $productName, + 'pay_amount' => (string)$serviceConfig['price'], + 'fallback_title' => '订单提交成功', + 'fallback_content' => '您的鉴定订单已提交成功,可前往订单中心查看进度。', + ]); + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('提交失败,请稍后重试', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success([ + 'order_id' => (int)$orderId, + 'order_no' => $orderNo, + 'appraisal_no' => $appraisalNo, + 'pay_amount' => (float)$serviceConfig['price'], + 'next_status' => 'pending_shipping', + ]); + } + + private function lookupName(string $table, string $field, mixed $id): string + { + if (empty($id)) { + return ''; + } + return (string)Db::name($table)->where('id', $id)->value($field); + } + + private function resolveProductName(?array $product): string + { + if (!$product) { + return ''; + } + $categoryName = $this->lookupName('catalog_categories', 'name', $product['category_id'] ?? null); + $brandName = $this->lookupName('catalog_brands', 'name', $product['brand_id'] ?? null); + $fallbackName = trim($categoryName . ' ' . $brandName); + if ($fallbackName !== '') { + return $fallbackName; + } + return ''; + } + + private function serviceConfig(string $serviceProvider): array + { + $configs = [ + 'anxinyan' => ['price' => 99.00, 'sla_hours' => 48], + 'zhongjian' => ['price' => 199.00, 'sla_hours' => 72], + ]; + if (isset($configs[$serviceProvider])) { + return $configs[$serviceProvider]; + } + return $configs['anxinyan']; + } + + private function draftUploadItems(int $draftId, Request $request): array + { + $uploads = Db::name('appraisal_draft_uploads')->where('draft_id', $draftId)->select()->toArray(); + if (!$uploads) { + return []; + } + + return array_map(function (array $item) use ($request) { + $files = Db::name('appraisal_draft_upload_files') + ->where('draft_upload_id', $item['id']) + ->order('sort_order', 'asc') + ->select() + ->toArray(); + + return [ + 'item_code' => $item['item_code'], + 'item_name' => $item['item_name'], + 'is_required' => (bool)$item['is_required'], + 'quality_status' => $item['quality_status'], + 'quality_message' => $item['quality_message'], + 'files' => array_map(fn (array $file) => [ + 'file_id' => $file['file_id'], + 'file_url' => $this->assetUrlService()->normalizeUrl((string)$file['file_url'], $request), + 'thumbnail_url' => $this->assetUrlService()->normalizeUrl((string)$file['thumbnail_url'], $request), + ], $files), + ]; + }, $uploads); + } + + private function countUploadedDraftItems(int $draftId): int + { + $uploadIds = Db::name('appraisal_draft_upload_files') + ->alias('f') + ->join('appraisal_draft_uploads u', 'u.id = f.draft_upload_id') + ->where('u.draft_id', $draftId) + ->group('u.id') + ->column('u.id'); + + return count($uploadIds); + } + + private function assetUrlService(): PublicAssetUrlService + { + return new PublicAssetUrlService(); + } + + private function storage(): FileStorageService + { + return new FileStorageService(); + } + + private function normalizeOrderSourceChannel(string $sourceChannel): string + { + $sourceChannel = trim($sourceChannel); + $aliases = [ + 'wechat_mini_program' => 'mini_program', + 'weixin_mini_program' => 'mini_program', + 'mp_weixin' => 'mini_program', + 'miniapp' => 'mini_program', + 'user_app' => 'mini_program', + 'web_h5' => 'h5', + 'enterprise' => 'enterprise_push', + 'enterprise_order' => 'enterprise_push', + 'customer_push' => 'enterprise_push', + 'large_customer_push' => 'enterprise_push', + ]; + $sourceChannel = $aliases[$sourceChannel] ?? $sourceChannel; + + return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push'], true) + ? $sourceChannel + : 'mini_program'; + } +} diff --git a/server-api/app/controller/app/AuthController.php b/server-api/app/controller/app/AuthController.php new file mode 100644 index 0000000..4259c02 --- /dev/null +++ b/server-api/app/controller/app/AuthController.php @@ -0,0 +1,112 @@ +input('mobile', '')); + if ($mobile === '') { + return api_error('手机号不能为空', 422); + } + + try { + $payload = (new AppAuthService())->sendLoginCode($mobile, $request); + return api_success($payload, '验证码已发送'); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), 422); + } catch (\Throwable $e) { + return api_error('验证码发送失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + } + + public function loginByCode(Request $request) + { + $mobile = trim((string)$request->input('mobile', '')); + $code = trim((string)$request->input('code', '')); + if ($mobile === '' || $code === '') { + return api_error('手机号和验证码不能为空', 422); + } + + try { + $payload = (new AppAuthService())->loginByCode($mobile, $code, $request); + return api_success($payload, '登录成功'); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), 401); + } catch (\Throwable $e) { + return api_error('登录失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + } + + public function loginByPassword(Request $request) + { + $mobile = trim((string)$request->input('mobile', '')); + $password = trim((string)$request->input('password', '')); + if ($mobile === '' || $password === '') { + return api_error('手机号和密码不能为空', 422); + } + + try { + $payload = (new AppAuthService())->loginByPassword($mobile, $password, $request); + return api_success($payload, '登录成功'); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), 401); + } catch (\Throwable $e) { + return api_error('登录失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + } + + public function me(Request $request) + { + $userInfo = (new AppAuthService())->current($request); + if (!$userInfo) { + return api_error('未登录或登录已过期', 401); + } + + return api_success([ + 'user_info' => $userInfo, + ]); + } + + public function savePassword(Request $request) + { + $userId = app_user_id($request); + $currentPassword = trim((string)$request->input('current_password', '')); + $newPassword = trim((string)$request->input('new_password', '')); + $confirmPassword = trim((string)$request->input('confirm_password', '')); + + if ($newPassword === '' || $confirmPassword === '') { + return api_error('新密码和确认密码不能为空', 422); + } + if ($newPassword !== $confirmPassword) { + return api_error('两次输入的新密码不一致', 422); + } + + try { + $payload = (new AppAuthService())->savePassword($userId, $currentPassword, $newPassword); + return api_success($payload, !empty($payload['had_password']) ? '登录密码已更新' : '登录密码已设置'); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), 422); + } catch (\Throwable $e) { + return api_error('密码保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + } + + public function logout(Request $request) + { + (new AppAuthService())->logout($request); + return api_success([], '已退出登录'); + } +} diff --git a/server-api/app/controller/app/CatalogController.php b/server-api/app/controller/app/CatalogController.php new file mode 100644 index 0000000..922ae09 --- /dev/null +++ b/server-api/app/controller/app/CatalogController.php @@ -0,0 +1,42 @@ +field(['id AS category_id', 'name AS category_name', 'code AS category_code']) + ->where('is_enabled', 1) + ->order('sort_order', 'asc') + ->select() + ->toArray(); + + return api_success(['list' => $list]); + } + + public function brands(Request $request) + { + $categoryId = (int)$request->input('category_id', 0); + + $query = Db::name('catalog_brands') + ->alias('b') + ->field(['b.id AS brand_id', 'b.name AS brand_name', 'b.en_name AS brand_en_name']) + ->where('b.is_enabled', 1) + ->order('b.sort_order', 'asc'); + + if ($categoryId > 0) { + $query->join('catalog_brand_categories cbc', 'cbc.brand_id = b.id') + ->where('cbc.category_id', $categoryId); + } + + return api_success([ + 'list' => $query->select()->toArray(), + ]); + } + +} diff --git a/server-api/app/controller/app/HelpCenterController.php b/server-api/app/controller/app/HelpCenterController.php new file mode 100644 index 0000000..dd6fa93 --- /dev/null +++ b/server-api/app/controller/app/HelpCenterController.php @@ -0,0 +1,101 @@ +input('q', '')); + $category = trim((string)$request->input('category', '')); + + $articles = $this->articles(); + $allArticles = $this->articles(); + $categoryConfig = (new ContentService())->getHelpCategories(); + + if ($keyword !== '') { + $articles = array_values(array_filter($articles, function (array $item) use ($keyword) { + $haystack = implode(' ', array_merge( + [$item['title'], $item['summary'], $item['category_text']], + $item['keywords'], + $item['content_blocks'] + )); + + return str_contains($haystack, $keyword); + })); + } + + if ($category !== '' && $category !== 'all') { + $articles = array_values(array_filter($articles, fn (array $item) => $item['category'] === $category)); + } + + $categoryCounts = []; + foreach ($allArticles as $item) { + $categoryCounts[$item['category']] = ($categoryCounts[$item['category']] ?? 0) + 1; + } + + $categories = array_map(function (array $item) use ($allArticles, $categoryCounts) { + $code = (string)($item['code'] ?? ''); + return [ + 'code' => $code, + 'title' => (string)($item['title'] ?? $code), + 'desc' => (string)($item['desc'] ?? ''), + 'count' => $code === 'all' ? count($allArticles) : (int)($categoryCounts[$code] ?? 0), + ]; + }, $categoryConfig); + + return api_success([ + 'categories' => $categories, + 'articles' => array_map(fn (array $item) => $this->articleSummary($item), $articles), + ]); + } + + public function detail(Request $request) + { + $id = (int)$request->input('id', 0); + if ($id <= 0) { + return api_error('文章 ID 不能为空', 422); + } + + $article = null; + foreach ($this->articles() as $item) { + if ((int)$item['id'] === $id) { + $article = $item; + break; + } + } + + if (!$article) { + return api_error('帮助文章不存在', 404); + } + + $related = array_values(array_filter($this->articles(), fn (array $item) => $item['category'] === $article['category'] && $item['id'] !== $article['id'])); + + return api_success([ + 'article' => $article, + 'related_articles' => array_map(fn (array $item) => $this->articleSummary($item), array_slice($related, 0, 3)), + ]); + } + + private function articleSummary(array $item): array + { + return [ + 'id' => (int)$item['id'], + 'title' => $item['title'], + 'category' => $item['category'], + 'category_text' => $item['category_text'], + 'summary' => $item['summary'], + 'keywords' => $item['keywords'], + 'updated_at' => $item['updated_at'], + 'is_recommended' => (bool)$item['is_recommended'], + ]; + } + + private function articles(): array + { + return (new ContentService())->getHelpArticles(true); + } +} diff --git a/server-api/app/controller/app/HomeController.php b/server-api/app/controller/app/HomeController.php new file mode 100644 index 0000000..c4b8bb6 --- /dev/null +++ b/server-api/app/controller/app/HomeController.php @@ -0,0 +1,94 @@ +getHomeConfig(); + $categoryVisuals = $this->categoryVisualMap($content['category_visuals'] ?? [], $request); + + $categories = CatalogCategory::where('is_enabled', 1) + ->order('sort_order', 'asc') + ->field(['id', 'name', 'code']) + ->select() + ->map(function ($item) use ($categoryVisuals) { + $code = (string)$item->code; + $name = (string)$item->name; + $codeKey = $this->categoryMatchKey($code); + $nameKey = $this->categoryMatchKey($name); + + return [ + 'category_id' => (int)$item->id, + 'category_name' => $name, + 'category_code' => $code, + 'image_url' => $categoryVisuals['code:' . $codeKey] ?? $categoryVisuals['name:' . $nameKey] ?? '', + ]; + }) + ->toArray(); + + return api_success([ + 'banners' => $content['banners'], + 'page_visuals' => $content['page_visuals'], + 'service_entries' => $content['service_entries'], + 'category_entries' => $categories, + 'quick_entries' => $content['quick_entries'], + 'trust_metrics' => $content['trust_metrics'], + 'trust_points' => $content['trust_points'], + 'faqs' => $content['faqs'], + ]); + } + + public function pageVisuals(Request $request) + { + $content = (new ContentService())->getHomeConfig(); + + return api_success($content['page_visuals'] ?? [ + 'order_background_image_url' => '', + 'report_background_image_url' => '', + ]); + } + + private function categoryVisualMap(array $items, Request $request): array + { + $map = []; + $storage = new FileStorageService(); + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $imageUrl = trim((string)($item['image_url'] ?? '')); + if ($imageUrl === '') { + continue; + } + $imageUrl = $storage->normalizeUrl($imageUrl, $request); + + $categoryCode = $this->categoryMatchKey((string)($item['category_code'] ?? '')); + if ($categoryCode !== '') { + $map['code:' . $categoryCode] = $imageUrl; + } + + $categoryName = $this->categoryMatchKey((string)($item['category_name'] ?? '')); + if ($categoryName !== '') { + $map['name:' . $categoryName] = $imageUrl; + } + } + + return $map; + } + + private function categoryMatchKey(string $value): string + { + $value = trim($value); + $normalized = preg_replace('/[\s\p{Cf}]+/u', '', $value); + + return strtolower($normalized ?? $value); + } +} diff --git a/server-api/app/controller/app/MaterialTagsController.php b/server-api/app/controller/app/MaterialTagsController.php new file mode 100644 index 0000000..80b3f40 --- /dev/null +++ b/server-api/app/controller/app/MaterialTagsController.php @@ -0,0 +1,49 @@ +input('token', '')); + if ($token === '') { + return api_error('吊牌标识不能为空', 422); + } + + try { + return api_success($this->service()->showPublicTag($token, $request)); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), $e->getCode() ?: 404, [ + 'tag_status' => 'not_found', + ]); + } + } + + public function verify(Request $request) + { + $token = trim((string)$request->input('token', '')); + $reportNo = trim((string)$request->input('report_no', '')); + $verifyCode = trim((string)$request->input('verify_code', '')); + + if ($token === '' || $reportNo === '' || $verifyCode === '') { + return api_error('吊牌标识、报告编号和验真编码不能为空', 422); + } + + try { + return api_success($this->service()->verifyPublicTag($token, $reportNo, $verifyCode, $request)); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), $e->getCode() ?: 404, [ + 'verify_passed' => false, + ]); + } + } + + private function service(): MaterialTagService + { + return new MaterialTagService(); + } +} diff --git a/server-api/app/controller/app/MessagesController.php b/server-api/app/controller/app/MessagesController.php new file mode 100644 index 0000000..b7c66ab --- /dev/null +++ b/server-api/app/controller/app/MessagesController.php @@ -0,0 +1,277 @@ +where('user_id', $userId) + ->order('id', 'desc') + ->select() + ->toArray(); + + $latest = $rows[0] ?? null; + $summary = $this->buildSummary($rows); + + return api_success([ + 'total_count' => $summary['total_count'], + 'unread_count' => $summary['unread_count'], + 'category_counts' => $summary['category_counts'], + 'latest_title' => $latest['title'] ?? '', + 'latest_time' => $latest['created_at'] ?? '', + ]); + } + + public function meta(Request $request) + { + $content = new ContentService(); + + return api_success([ + 'message_page_copy' => $content->getMessagePageCopy(), + ]); + } + + public function index(Request $request) + { + $userId = app_user_id($request); + $category = $this->normalizeCategory((string)$request->input('category', 'all')); + $unreadOnly = (bool)$request->input('unread_only', false); + + $rows = Db::name('user_messages') + ->where('user_id', $userId) + ->order('id', 'desc') + ->select() + ->toArray(); + + $filteredRows = array_values(array_filter($rows, function (array $item) use ($category, $unreadOnly) { + if ($unreadOnly && (bool)$item['is_read']) { + return false; + } + + if ($category !== 'all' && $this->messageCategory($item['biz_type'] ?? '') !== $category) { + return false; + } + + return true; + })); + + $list = array_map(function (array $item) { + [$targetUrl, $targetLabel] = $this->resolveMessageTarget($item); + $messageCategory = $this->messageCategory($item['biz_type']); + + return [ + 'id' => (int)$item['id'], + 'title' => $item['title'], + 'content' => $item['content'] ?: '', + 'biz_type' => $item['biz_type'], + 'biz_type_text' => $this->bizTypeText($item['biz_type']), + 'category' => $messageCategory, + 'category_text' => $this->categoryText($messageCategory), + 'biz_id' => (int)($item['biz_id'] ?? 0), + 'is_read' => (bool)$item['is_read'], + 'created_at' => $item['created_at'], + 'target_url' => $targetUrl, + 'target_label' => $targetLabel, + ]; + }, $filteredRows); + + $summary = $this->buildSummary($rows); + + return api_success([ + 'list' => $list, + 'summary' => array_merge($summary, [ + 'current_count' => count($list), + 'current_category' => $category, + 'unread_only' => $unreadOnly, + ]), + ]); + } + + public function read(Request $request) + { + $id = (int)$request->input('id', 0); + if ($id <= 0) { + return api_error('消息 ID 不能为空', 422); + } + + $message = Db::name('user_messages')->where('id', $id)->where('user_id', app_user_id($request))->find(); + if (!$message) { + return api_error('消息不存在', 404); + } + + if (!(bool)$message['is_read']) { + $now = date('Y-m-d H:i:s'); + Db::name('user_messages')->where('id', $id)->update([ + 'is_read' => 1, + 'read_at' => $now, + 'updated_at' => $now, + ]); + } + + return api_success([ + 'id' => $id, + 'is_read' => true, + ], '已标记为已读'); + } + + public function readAll(Request $request) + { + $userId = app_user_id($request); + $now = date('Y-m-d H:i:s'); + + $affected = Db::name('user_messages') + ->where('user_id', $userId) + ->where('is_read', 0) + ->update([ + 'is_read' => 1, + 'read_at' => $now, + 'updated_at' => $now, + ]); + + return api_success([ + 'affected' => (int)$affected, + ], '已全部标记为已读'); + } + + private function resolveMessageTarget(array $message): array + { + $bizType = $message['biz_type'] ?? ''; + $bizId = (int)($message['biz_id'] ?? 0); + + if ($bizType === 'report' && $bizId > 0) { + $report = Db::name('reports')->where('id', $bizId)->find(); + if ($report) { + return ["/pages/report/detail?report_no=" . rawurlencode((string)$report['report_no']), '查看报告']; + } + } + + if ($bizType === 'order' && $bizId > 0) { + $order = Db::name('orders')->where('id', $bizId)->find(); + if ($order) { + return ["/pages/order/detail?id={$bizId}", '查看订单']; + } + } + + if ($bizType === 'return_shipped' && $bizId > 0) { + $order = Db::name('orders')->where('id', $bizId)->find(); + if ($order) { + return ["/pages/order/detail?id={$bizId}", '查看物流']; + } + } + + if ($bizType === 'return_received' && $bizId > 0) { + $order = Db::name('orders')->where('id', $bizId)->find(); + if ($order) { + return ["/pages/order/detail?id={$bizId}", '查看订单']; + } + } + + if ($bizType === 'supplement' && $bizId > 0) { + $supplementTask = Db::name('order_supplement_tasks')->where('id', $bizId)->find(); + if ($supplementTask) { + if (($supplementTask['status'] ?? '') === 'pending') { + return ["/pages/order/supplement?order_id={$supplementTask['order_id']}", '去补资料']; + } + return ["/pages/order/detail?id={$supplementTask['order_id']}", '查看进度']; + } + } + + if ($bizType === 'ticket_message' && $bizId > 0) { + $ticketMessage = Db::name('ticket_messages')->where('id', $bizId)->find(); + if ($ticketMessage) { + return ["/pages/support/detail?id={$ticketMessage['ticket_id']}", '查看工单']; + } + } + + if (in_array($bizType, ['ticket_waiting_user', 'ticket_resolved', 'ticket_closed'], true) && $bizId > 0) { + $ticket = Db::name('tickets')->where('id', $bizId)->find(); + if ($ticket) { + return ["/pages/support/detail?id={$ticket['id']}", '查看工单']; + } + } + + return ['', '查看详情']; + } + + private function bizTypeText(string $bizType): string + { + return match ($bizType) { + 'report' => '报告通知', + 'order' => '订单通知', + 'return_shipped' => '回寄通知', + 'return_received' => '签收通知', + 'supplement' => '补资料通知', + 'ticket_message' => '工单通知', + 'ticket_waiting_user' => '工单通知', + 'ticket_resolved' => '工单通知', + 'ticket_closed' => '工单通知', + default => '系统通知', + }; + } + + private function buildSummary(array $rows): array + { + $categoryCounts = [ + 'all' => count($rows), + 'order' => 0, + 'report' => 0, + 'supplement' => 0, + 'ticket' => 0, + ]; + + $unreadCount = 0; + foreach ($rows as $item) { + $category = $this->messageCategory($item['biz_type'] ?? ''); + if ($category !== 'all' && isset($categoryCounts[$category])) { + $categoryCounts[$category]++; + } + if (!(bool)($item['is_read'] ?? false)) { + $unreadCount++; + } + } + + return [ + 'total_count' => count($rows), + 'unread_count' => $unreadCount, + 'category_counts' => $categoryCounts, + ]; + } + + private function normalizeCategory(string $category): string + { + return in_array($category, ['all', 'order', 'report', 'supplement', 'ticket'], true) + ? $category + : 'all'; + } + + private function messageCategory(string $bizType): string + { + return match ($bizType) { + 'order' => 'order', + 'return_shipped' => 'order', + 'return_received' => 'order', + 'report' => 'report', + 'supplement' => 'supplement', + 'ticket_message', 'ticket_waiting_user', 'ticket_resolved', 'ticket_closed' => 'ticket', + default => 'all', + }; + } + + private function categoryText(string $category): string + { + return match ($category) { + 'order' => '订单', + 'report' => '报告', + 'supplement' => '补资料', + 'ticket' => '工单', + default => '全部', + }; + } +} diff --git a/server-api/app/controller/app/MineController.php b/server-api/app/controller/app/MineController.php new file mode 100644 index 0000000..0649991 --- /dev/null +++ b/server-api/app/controller/app/MineController.php @@ -0,0 +1,97 @@ +where('id', $userId)->find(); + if (!$user) { + return api_error('用户不存在', 404); + } + + $ordersCount = (int)Db::name('orders') + ->where('user_id', $userId) + ->count(); + + $reportRows = Db::name('reports') + ->alias('r') + ->join('orders o', 'o.id = r.order_id') + ->leftJoin('report_contents c', 'c.report_id = r.id') + ->field([ + 'r.id', + 'r.report_no', + 'c.result_snapshot_json', + 'c.valuation_snapshot_json', + ]) + ->where('o.user_id', $userId) + ->where('r.report_status', 'published') + ->select() + ->toArray(); + + $reportCount = count($reportRows); + $authenticCount = 0; + $totalValuation = 0.0; + + foreach ($reportRows as $row) { + $result = $this->decodeJsonField($row['result_snapshot_json'] ?? null); + $valuation = $this->decodeJsonField($row['valuation_snapshot_json'] ?? null); + + $resultStatus = (string)($result['result_status'] ?? ''); + $resultText = (string)($result['result_text'] ?? ''); + if ($resultStatus === 'authentic' || str_contains($resultText, '正')) { + $authenticCount++; + } + + $min = (float)($valuation['valuation_min'] ?? 0); + $max = (float)($valuation['valuation_max'] ?? 0); + if ($min > 0 && $max > 0) { + $totalValuation += ($min + $max) / 2; + } else { + $totalValuation += max($min, $max); + } + } + + $unreadCount = (int)Db::name('user_messages') + ->where('user_id', $userId) + ->where('is_read', 0) + ->count(); + + return api_success([ + 'profile_info' => [ + 'user_id' => (int)$user['id'], + 'nickname' => $user['nickname'] ?: '安心验用户', + 'mobile' => $user['mobile'] ?: '', + 'avatar' => $user['avatar'] ?: '', + 'status' => $user['status'] ?: 'enabled', + 'status_text' => ($user['status'] ?? 'enabled') === 'enabled' ? '账号正常' : '账号异常', + 'password_set' => ((string)($user['password'] ?? '')) !== '', + ], + 'asset_summary' => [ + 'total_valuation' => round($totalValuation, 2), + 'item_count' => $ordersCount, + 'report_count' => $reportCount, + 'authentic_rate' => $reportCount > 0 ? (int)round($authenticCount / $reportCount * 100) : 0, + 'unread_count' => $unreadCount, + ], + ]); + } + + private function decodeJsonField(mixed $value): array + { + if (is_array($value)) { + return $value; + } + + if (is_string($value) && $value !== '') { + return json_decode($value, true) ?: []; + } + + return []; + } +} diff --git a/server-api/app/controller/app/OrdersController.php b/server-api/app/controller/app/OrdersController.php new file mode 100644 index 0000000..341ac3d --- /dev/null +++ b/server-api/app/controller/app/OrdersController.php @@ -0,0 +1,546 @@ +alias('o') + ->leftJoin('order_products p', 'p.order_id = o.id') + ->leftJoin('order_logistics l', 'l.order_id = o.id AND l.logistics_type = "send_to_center"') + ->field([ + 'o.id', + 'o.order_no', + 'o.appraisal_no', + 'o.service_provider', + 'o.order_status', + 'o.display_status', + 'o.estimated_finish_time', + 'p.product_name', + 'p.product_cover', + 'l.tracking_no', + ]) + ->where('o.user_id', $userId) + ->order('o.id', 'desc') + ->select() + ->toArray(); + + $returnTrackingMap = []; + if ($orders) { + $returnRows = Db::name('order_logistics') + ->whereIn('order_id', array_column($orders, 'id')) + ->where('logistics_type', 'return_to_user') + ->order('id', 'desc') + ->select() + ->toArray(); + foreach ($returnRows as $row) { + $orderId = (int)($row['order_id'] ?? 0); + if ($orderId > 0 && !isset($returnTrackingMap[$orderId])) { + $returnTrackingMap[$orderId] = [ + 'tracking_no' => (string)($row['tracking_no'] ?? ''), + 'tracking_status' => (string)($row['tracking_status'] ?? ''), + ]; + } + } + } + + $list = array_map(function (array $item) use ($returnTrackingMap) { + return [ + 'order_id' => (int)$item['id'], + 'order_no' => $item['order_no'], + 'appraisal_no' => $item['appraisal_no'], + 'order_status' => $item['order_status'], + 'product_name' => $item['product_name'] ?: '待补充商品名称', + 'product_cover' => $item['product_cover'] ?: '', + 'service_provider' => $item['service_provider'], + 'display_status' => $this->displayStatus( + $item['order_status'], + $item['display_status'], + $item['tracking_no'] ?? '', + $returnTrackingMap[(int)$item['id']]['tracking_no'] ?? '', + $returnTrackingMap[(int)$item['id']]['tracking_status'] ?? '', + ), + 'status_desc' => $this->statusDescription( + $item['order_status'], + $item['tracking_no'] ?? '', + $returnTrackingMap[(int)$item['id']]['tracking_no'] ?? '', + $returnTrackingMap[(int)$item['id']]['tracking_status'] ?? '', + ), + 'estimated_finish_time' => $item['estimated_finish_time'], + 'primary_action' => $this->primaryAction( + $item['order_status'], + $returnTrackingMap[(int)$item['id']]['tracking_no'] ?? '', + $returnTrackingMap[(int)$item['id']]['tracking_status'] ?? '', + ), + ]; + }, $orders); + + return api_success(['list' => $list]); + } + + public function detail(Request $request) + { + $id = (int)$request->input('id', 1); + $userId = app_user_id($request); + + $order = Order::where('id', $id)->where('user_id', $userId)->find(); + if (!$order) { + return api_error('订单不存在', 404); + } + + $product = OrderProduct::where('order_id', $id)->find(); + $extra = Db::name('order_extras')->where('order_id', $id)->find(); + $sendLogistics = Db::name('order_logistics') + ->where('order_id', $id) + ->where('logistics_type', 'send_to_center') + ->order('id', 'desc') + ->find(); + $returnLogistics = Db::name('order_logistics') + ->where('order_id', $id) + ->where('logistics_type', 'return_to_user') + ->order('id', 'desc') + ->find(); + $timeline = OrderTimeline::where('order_id', $id) + ->order('occurred_at', 'asc') + ->select() + ->map(fn ($item) => [ + 'node_code' => $item->node_code, + 'node_text' => $item->node_text, + 'node_desc' => $item->node_desc, + 'occurred_at' => $item->occurred_at, + ]) + ->toArray(); + + $supplement = OrderSupplementTask::where('order_id', $id) + ->where('status', 'pending') + ->order('id', 'desc') + ->find(); + + $supplementItems = []; + if ($supplement) { + $supplementItems = OrderSupplementTaskItem::where('task_id', $supplement->id) + ->select() + ->map(fn ($item) => [ + 'item_code' => $item->item_code, + 'item_name' => $item->item_name, + 'guide_text' => $item->guide_text, + ]) + ->toArray(); + } + + $materials = Db::name('order_upload_items') + ->where('order_id', $id) + ->order('id', 'asc') + ->select() + ->toArray(); + + $materials = array_values(array_filter(array_map(function (array $item) use ($request) { + $files = Db::name('order_upload_files') + ->where('order_upload_item_id', $item['id']) + ->order('id', 'asc') + ->select() + ->toArray(); + + if (!$files) { + return null; + } + + return [ + 'upload_item_id' => (int)$item['id'], + 'item_code' => $item['item_code'], + 'item_name' => $item['item_name'], + 'is_required' => (bool)$item['is_required'], + 'source_type' => $item['source_type'], + 'source_type_text' => $this->materialSourceTypeText($item['source_type']), + 'status' => $item['status'], + 'status_text' => $this->materialStatusText($item['status']), + 'file_count' => count($files), + 'files' => array_map(fn (array $file) => [ + 'file_id' => $file['file_id'], + 'file_url' => $this->assetUrlService()->normalizeUrl((string)$file['file_url'], $request), + 'thumbnail_url' => $this->assetUrlService()->normalizeUrl((string)$file['thumbnail_url'], $request), + 'quality_status' => $file['quality_status'], + 'quality_message' => $file['quality_message'], + ], $files), + ]; + }, $materials))); + + $returnAddress = Db::name('order_return_addresses')->where('order_id', $id)->find(); + if (!$returnAddress) { + $returnAddress = Db::name('user_addresses') + ->where('user_id', (int)$order->user_id) + ->where('is_default', 1) + ->order('id', 'desc') + ->find() + ?: Db::name('user_addresses') + ->where('user_id', (int)$order->user_id) + ->order('id', 'desc') + ->find(); + if ($returnAddress) { + $returnAddress = [ + 'user_address_id' => (int)$returnAddress['id'], + 'consignee' => $returnAddress['consignee'], + 'mobile' => $returnAddress['mobile'], + 'province' => $returnAddress['province'], + 'city' => $returnAddress['city'], + 'district' => $returnAddress['district'], + 'detail_address' => $returnAddress['detail_address'], + ]; + } + } + $returnNodes = []; + if ($returnLogistics) { + $returnNodes = Db::name('order_logistics_nodes') + ->where('logistics_id', $returnLogistics['id']) + ->order('node_time', 'desc') + ->select() + ->toArray(); + } + + return api_success([ + 'order_info' => [ + 'order_id' => (int)$order->id, + 'order_no' => $order->order_no, + 'appraisal_no' => $order->appraisal_no, + 'service_provider' => $order->service_provider, + 'source_channel' => $this->normalizeOrderSourceChannel((string)($order->source_channel ?? '')), + 'source_channel_text' => $this->sourceChannelText((string)($order->source_channel ?? '')), + 'source_customer_id' => (string)($order->source_customer_id ?? ''), + 'order_status' => $order->order_status, + 'display_status' => $this->displayStatus( + $order->order_status, + $order->display_status, + $sendLogistics['tracking_no'] ?? '', + $returnLogistics['tracking_no'] ?? '', + $returnLogistics['tracking_status'] ?? '', + ), + 'status_desc' => $this->statusDescription( + $order->order_status, + $sendLogistics['tracking_no'] ?? '', + $returnLogistics['tracking_no'] ?? '', + $returnLogistics['tracking_status'] ?? '', + ), + 'estimated_finish_time' => $order->estimated_finish_time, + 'can_edit_return_address' => empty($returnLogistics['tracking_no']), + ], + 'product_info' => [ + 'product_name' => $product?->product_name ?: '', + 'category_name' => $product?->category_name ?: '', + 'brand_name' => $product?->brand_name ?: '', + 'color' => $product?->color ?: '', + 'size_spec' => $product?->size_spec ?: '', + 'serial_no' => $product?->serial_no ?: '', + ], + 'extra_info' => [ + 'purchase_channel' => $extra['purchase_channel'] ?? '', + 'purchase_price' => (float)($extra['purchase_price'] ?? 0), + 'purchase_date' => $extra['purchase_date'] ?? '', + 'usage_status' => $extra['usage_status'] ?? '', + 'usage_status_text' => $this->usageStatusText($extra['usage_status'] ?? ''), + 'condition_desc' => $extra['condition_desc'] ?? '', + 'has_accessories' => (bool)($extra['has_accessories'] ?? false), + 'accessories' => $this->decodeJsonArray($extra['accessories_json'] ?? null), + 'remark' => $extra['remark'] ?? '', + ], + 'materials' => $materials, + 'return_address' => $returnAddress ? $this->formatReturnAddress($returnAddress) : null, + 'return_logistics' => $returnLogistics ? [ + 'express_company' => $returnLogistics['express_company'], + 'tracking_no' => $returnLogistics['tracking_no'], + 'tracking_status' => $returnLogistics['tracking_status'], + 'tracking_status_text' => $this->trackingStatusText((string)$returnLogistics['tracking_status'], 'return_to_user'), + 'latest_desc' => $returnLogistics['latest_desc'], + 'latest_time' => $returnLogistics['latest_time'], + 'nodes' => array_map(fn (array $item) => [ + 'node_time' => $item['node_time'], + 'node_desc' => $item['node_desc'], + 'node_location' => $item['node_location'], + ], $returnNodes), + ] : null, + 'timeline' => $timeline, + 'supplement_task' => $supplement ? [ + 'task_id' => (int)$supplement->id, + 'reason' => $supplement->reason, + 'deadline' => $supplement->deadline, + 'items' => $supplementItems, + ] : null, + 'available_actions' => [ + 'primary_action' => $this->primaryAction($order->order_status), + 'secondary_action' => '联系客服', + ], + ]); + } + + public function saveReturnAddress(Request $request) + { + $orderId = (int)$request->input('order_id', 0); + $addressId = (int)$request->input('address_id', 0); + $userId = app_user_id($request); + + if ($orderId <= 0 || $addressId <= 0) { + return api_error('订单和地址参数不能为空', 422); + } + + $order = Db::name('orders')->where('id', $orderId)->where('user_id', $userId)->find(); + if (!$order) { + return api_error('订单不存在', 404); + } + + $returnLogistics = Db::name('order_logistics') + ->where('order_id', $orderId) + ->where('logistics_type', 'return_to_user') + ->order('id', 'desc') + ->find(); + if (!empty($returnLogistics['tracking_no'])) { + return api_error('回寄运单已生成,当前不可再修改寄回地址', 422); + } + + $address = Db::name('user_addresses')->where('id', $addressId)->where('user_id', $userId)->find(); + if (!$address) { + return api_error('地址不存在', 404); + } + + $now = date('Y-m-d H:i:s'); + $snapshot = [ + 'user_address_id' => (int)$address['id'], + 'consignee' => $address['consignee'], + 'mobile' => $address['mobile'], + 'province' => $address['province'], + 'city' => $address['city'], + 'district' => $address['district'], + 'detail_address' => $address['detail_address'], + ]; + + Db::startTrans(); + try { + $existing = Db::name('order_return_addresses')->where('order_id', $orderId)->find(); + if ($existing) { + Db::name('order_return_addresses')->where('order_id', $orderId)->update(array_merge($snapshot, [ + 'updated_at' => $now, + ])); + $nodeText = '已更新寄回地址'; + } else { + Db::name('order_return_addresses')->insert(array_merge($snapshot, [ + 'order_id' => $orderId, + 'created_at' => $now, + 'updated_at' => $now, + ])); + $nodeText = '已确认寄回地址'; + } + + Db::name('order_timelines')->insert([ + 'order_id' => $orderId, + 'node_code' => 'return_address_selected', + 'node_text' => $nodeText, + 'node_desc' => sprintf('用户已确认寄回地址:%s%s%s%s', $address['province'], $address['city'], $address['district'], $address['detail_address']), + 'operator_type' => 'user', + 'operator_id' => $userId, + 'occurred_at' => $now, + 'created_at' => $now, + ]); + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('寄回地址保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success([ + 'order_id' => $orderId, + 'return_address' => $this->formatReturnAddress($snapshot), + ], '寄回地址已更新'); + } + + private function primaryAction(string $status, string $returnTrackingNo = '', string $returnTrackingStatus = ''): string + { + return match ($status) { + 'pending_payment' => '去支付', + 'pending_submission' => '去上传', + 'pending_shipping' => '查看寄送', + 'pending_supplement' => '去补资料', + 'report_published' => '查看报告', + 'completed' => ($returnTrackingNo !== '' && $returnTrackingStatus !== 'received') ? '查看物流' : '查看报告', + default => '查看进度', + }; + } + + private function statusDescription(string $status, string $trackingNo = '', string $returnTrackingNo = '', string $returnTrackingStatus = ''): string + { + return match ($status) { + 'pending_payment' => '请完成支付后继续本次鉴定服务', + 'pending_submission' => '请补充必要资料后继续进入鉴定流程', + 'pending_shipping' => $trackingNo !== '' ? '运单已提交,等待鉴定中心签收' : '请尽快将商品寄送至鉴定中心', + 'received' => '商品已由鉴定中心签收,等待鉴定师开始处理', + 'in_first_review' => '鉴定师正在处理,后续节点会持续同步', + 'in_final_review' => '鉴定师正在处理,预计 24 小时内出具报告', + 'pending_supplement' => '鉴定师需要您补充资料后继续处理', + 'report_published' => '正式报告已生成,待平台安排回寄商品', + 'completed' => $returnTrackingStatus === 'received' + ? '回寄商品已签收,本次订单已完成' + : ($returnTrackingNo !== '' ? '鉴定物品已寄回,请留意签收与物流信息' : '正式报告已生成,可立即查看并验真'), + default => '当前无需操作,请耐心等待', + }; + } + + private function displayStatus( + string $status, + string $displayStatus, + string $trackingNo = '', + string $returnTrackingNo = '', + string $returnTrackingStatus = '', + ): string + { + if ($status === 'pending_shipping' && $trackingNo !== '') { + return '已提交运单'; + } + + if ($status === 'report_published') { + return '待寄回'; + } + + if ($status === 'completed') { + if ($returnTrackingStatus === 'received') { + return '已完成'; + } + if ($returnTrackingNo !== '') { + return '物品已寄回'; + } + } + + return $displayStatus; + } + + private function usageStatusText(string $status): string + { + return match ($status) { + 'new' => '全新未使用', + 'light_use' => '轻微使用痕迹', + 'used' => '长期使用', + default => $status, + }; + } + + private function materialStatusText(string $status): string + { + return match ($status) { + 'uploaded' => '已上传', + 'optional' => '选填未上传', + 'pending' => '待上传', + default => $status, + }; + } + + private function materialSourceTypeText(string $sourceType): string + { + return match ($sourceType) { + 'supplement' => '补充资料', + default => '下单资料', + }; + } + + private function normalizeOrderSourceChannel(string $sourceChannel): string + { + $sourceChannel = trim($sourceChannel); + $aliases = [ + 'wechat_mini_program' => 'mini_program', + 'weixin_mini_program' => 'mini_program', + 'mp_weixin' => 'mini_program', + 'miniapp' => 'mini_program', + 'user_app' => 'mini_program', + 'web_h5' => 'h5', + 'enterprise' => 'enterprise_push', + 'enterprise_order' => 'enterprise_push', + 'customer_push' => 'enterprise_push', + 'large_customer_push' => 'enterprise_push', + ]; + $sourceChannel = $aliases[$sourceChannel] ?? $sourceChannel; + + return in_array($sourceChannel, ['mini_program', 'h5', 'enterprise_push'], true) ? $sourceChannel : ''; + } + + private function sourceChannelText(string $sourceChannel): string + { + return match ($this->normalizeOrderSourceChannel($sourceChannel)) { + 'mini_program' => '小程序', + 'h5' => 'H5', + 'enterprise_push' => '大客户推送订单', + default => '未知渠道', + }; + } + + private function decodeJsonArray(mixed $value): array + { + if (is_array($value)) { + return array_values(array_filter($value, fn ($item) => is_string($item) && $item !== '')); + } + + if (!is_string($value) || $value === '') { + return []; + } + + $decoded = json_decode($value, true); + if (!is_array($decoded)) { + return []; + } + + return array_values(array_filter($decoded, fn ($item) => is_string($item) && $item !== '')); + } + + private function formatReturnAddress(array $item): array + { + return [ + 'user_address_id' => (int)($item['user_address_id'] ?? 0), + 'consignee' => $item['consignee'] ?? '', + 'mobile' => $item['mobile'] ?? '', + 'province' => $item['province'] ?? '', + 'city' => $item['city'] ?? '', + 'district' => $item['district'] ?? '', + 'detail_address' => $item['detail_address'] ?? '', + 'full_address' => trim(sprintf( + '%s%s%s%s', + $item['province'] ?? '', + $item['city'] ?? '', + $item['district'] ?? '', + $item['detail_address'] ?? '' + )), + ]; + } + + private function trackingStatusText(string $status, string $logisticsType = 'send_to_center'): string + { + if ($logisticsType === 'return_to_user') { + return match ($status) { + 'submitted' => '已登记回寄运单', + 'in_transit' => '回寄途中', + 'received' => '用户已签收', + default => $status === '' ? '待回寄' : $status, + }; + } + + return match ($status) { + 'submitted' => '已提交运单', + 'in_transit' => '运输中', + 'received' => '已签收', + default => $status === '' ? '待提交' : $status, + }; + } + + private function assetUrlService(): PublicAssetUrlService + { + return new PublicAssetUrlService(); + } +} diff --git a/server-api/app/controller/app/ReportsController.php b/server-api/app/controller/app/ReportsController.php new file mode 100644 index 0000000..69182e8 --- /dev/null +++ b/server-api/app/controller/app/ReportsController.php @@ -0,0 +1,296 @@ +alias('o') + ->leftJoin('reports r', 'r.order_id = o.id AND r.report_status = "published"') + ->leftJoin('order_products p', 'p.order_id = o.id') + ->field([ + 'o.id AS order_id', + 'o.order_status', + 'o.display_status', + 'o.service_provider', + 'p.product_name', + 'p.product_cover', + 'r.id AS report_id', + 'r.report_no', + 'r.institution_name', + 'r.publish_time', + ]) + ->where('o.user_id', $userId) + ->whereIn('o.order_status', ['in_first_review', 'in_final_review', 'generating_report', 'report_published', 'completed']) + ->order('o.id', 'desc') + ->select() + ->toArray(); + + $list = array_map(function (array $item) { + $published = !empty($item['report_id']); + return [ + 'report_id' => $published ? (int)$item['report_id'] : null, + 'order_id' => (int)$item['order_id'], + 'report_no' => $item['report_no'] ?: '', + 'product_name' => $item['product_name'] ?: '', + 'product_cover' => $item['product_cover'] ?: '', + 'service_provider' => $item['service_provider'], + 'status' => $published ? '已出报告' : '待出报告', + 'result_text' => $published ? '正品' : '待出报告', + 'institution_name' => $item['institution_name'] ?: '安心验', + 'publish_time' => $item['publish_time'], + ]; + }, $rows); + + return api_success(['list' => $list]); + } + + public function detail(Request $request) + { + $id = (int)$request->input('id', 0); + $reportNo = trim((string)$request->input('report_no', '')); + if (!$id && $reportNo === '') { + return api_error('报告标识不能为空', 422); + } + + $report = null; + if ($reportNo !== '') { + $report = Report::where('report_status', 'published') + ->where('report_no', $reportNo) + ->find(); + } elseif ($id > 0) { + $userInfo = app_user($request) ?: (new AppAuthService())->current($request); + if (!$userInfo) { + return api_error('未登录或登录已过期', 401); + } + + $report = Db::name('reports') + ->alias('r') + ->join('orders o', 'o.id = r.order_id') + ->where('r.id', $id) + ->where('r.report_status', 'published') + ->where('o.user_id', (int)$userInfo['id']) + ->field('r.*') + ->find(); + } + + if (!$report) { + return api_error('报告不存在', 404); + } + + $reportData = is_array($report) ? $report : $report->toArray(); + $content = Db::name('report_contents')->where('report_id', $reportData['id'])->find(); + $verify = Db::name('report_verifies')->where('report_id', $reportData['id'])->find(); + $verify = $this->normalizeVerifyInfo($reportData, $verify ?: []); + $pdfUrl = $this->ensurePdfFile($request, $reportData, $content ?: [], $verify ?: []); + $evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request); + $defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($reportData['report_type'] ?? 'appraisal')); + $payload = [ + 'product_snapshot' => $this->decodeJsonField($content['product_snapshot_json'] ?? null), + 'result_snapshot' => $this->decodeJsonField($content['result_snapshot_json'] ?? null), + 'appraisal_snapshot' => $this->decodeJsonField($content['appraisal_snapshot_json'] ?? null), + 'valuation_snapshot' => $this->decodeJsonField($content['valuation_snapshot_json'] ?? null), + 'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : $defaultRiskNotice, + ]; + + return api_success([ + 'report_header' => [ + 'report_id' => (int)$reportData['id'], + 'report_no' => $reportData['report_no'], + 'report_type' => $reportData['report_type'] ?? 'appraisal', + 'report_title' => $reportData['report_title'], + 'report_status' => $reportData['report_status'], + 'service_provider' => $reportData['service_provider'], + 'institution_name' => $reportData['institution_name'], + 'publish_time' => $reportData['publish_time'], + ], + 'result_info' => $payload['result_snapshot'], + 'product_info' => $payload['product_snapshot'], + 'appraisal_info' => $payload['appraisal_snapshot'], + 'valuation_info' => $payload['valuation_snapshot'], + 'evidence_attachments' => $evidenceAttachments, + 'risk_notice_text' => $payload['risk_notice_text'], + 'verify_info' => [ + 'report_no' => $reportData['report_no'], + 'verify_status' => $verify['verify_status'] ?? 'valid', + 'verify_url' => $verify['verify_url'] ?? '', + 'verify_qrcode_url' => $verify['verify_qrcode_url'] ?? '', + ], + 'file_info' => [ + 'pdf_url' => $pdfUrl, + ], + ]); + } + + private function decodeJsonField(mixed $value): array + { + if (is_array($value)) { + return $value; + } + if (is_string($value) && $value !== '') { + return json_decode($value, true) ?: []; + } + return []; + } + + private function normalizeVerifyInfo(array $report, array $verify): array + { + $reportNo = (string)($report['report_no'] ?? ''); + $verifyPageUrl = $this->buildPublicPageUrl('/pages/verify/result', ['report_no' => $reportNo]); + + $verify['report_no'] = $verify['report_no'] ?? $reportNo; + $verify['verify_status'] = $verify['verify_status'] ?? 'valid'; + + $rawVerifyUrl = trim((string)($verify['verify_url'] ?? '')); + if ($rawVerifyUrl === '' || str_starts_with($rawVerifyUrl, '/api/app/verify')) { + $verify['verify_url'] = $verifyPageUrl; + } + + $rawQrValue = trim((string)($verify['verify_qrcode_url'] ?? '')); + if ($rawQrValue === '' || str_starts_with($rawQrValue, '/api/app/verify') || str_contains($rawQrValue, '/pages/report/detail')) { + $verify['verify_qrcode_url'] = $verify['verify_url']; + } + + return $verify; + } + + private function ensurePdfFile(Request $request, array $report, array $content, array $verify): string + { + $existingFile = Db::name('report_files') + ->where('report_id', (int)$report['id']) + ->where('file_type', 'pdf') + ->find(); + + if ($existingFile && !empty($existingFile['file_url'])) { + $relativeUrl = ltrim((string)$existingFile['file_url'], '/'); + if ($this->storage()->exists($relativeUrl)) { + return $this->storage()->publicUrl($request, $relativeUrl); + } + } + + $productInfo = $this->decodeJsonField($content['product_snapshot_json'] ?? null); + $resultInfo = $this->decodeJsonField($content['result_snapshot_json'] ?? null); + $appraisalInfo = $this->decodeJsonField($content['appraisal_snapshot_json'] ?? null); + $valuationInfo = $this->decodeJsonField($content['valuation_snapshot_json'] ?? null); + + $publishTime = (string)($report['publish_time'] ?: date('Y-m-d H:i:s')); + $defaultRiskNotice = (new ContentService())->getReportRiskNotice((string)($report['report_type'] ?? 'appraisal')); + $relativeDir = 'uploads/reports/' . date('Ymd', strtotime($publishTime)); + $filename = $report['report_no'] . '.pdf'; + $relativePath = $relativeDir . '/' . $filename; + $generator = new ReportPdfGenerator(); + $pdfBinary = $generator->generate([ + 'report_title' => $report['report_title'] ?? '鉴定报告', + 'service_provider_text' => ($report['service_provider'] ?? 'anxinyan') === 'zhongjian' ? '中检鉴定' : '实物鉴定', + 'institution_name' => $report['institution_name'] ?? '安心验', + 'report_no' => $report['report_no'] ?? '', + 'publish_time' => $publishTime, + 'result_text' => $resultInfo['result_text'] ?? '-', + 'result_desc' => $resultInfo['result_desc'] ?? '-', + 'product_name' => $productInfo['product_name'] ?? '-', + 'category_brand' => trim(($productInfo['category_name'] ?? '-') . ' / ' . ($productInfo['brand_name'] ?? '-')), + 'spec_info' => trim(($productInfo['color'] ?? '-') . ' / ' . ($productInfo['size_spec'] ?? '-')), + 'appraisers' => trim((string)($appraisalInfo['appraiser_name'] ?? '-')), + 'condition_grade' => $valuationInfo['condition_grade'] ?? '-', + 'valuation_range' => sprintf( + '¥%s - ¥%s', + $valuationInfo['valuation_min'] ?? 0, + $valuationInfo['valuation_max'] ?? 0 + ), + 'verify_info' => sprintf( + '%s / %s', + $verify['report_no'] ?? ($report['report_no'] ?? '-'), + ($verify['verify_status'] ?? 'valid') === 'valid' ? '有效' : ($verify['verify_status'] ?? '-') + ), + 'risk_notice_text' => ($content['risk_notice_text'] ?? '') !== '' ? $content['risk_notice_text'] : ($defaultRiskNotice !== '' ? $defaultRiskNotice : '-'), + ]); + + $this->storage()->putContents($relativePath, $pdfBinary); + + $now = date('Y-m-d H:i:s'); + $filePayload = [ + 'report_id' => (int)$report['id'], + 'file_type' => 'pdf', + 'file_url' => '/' . $relativePath, + 'file_status' => 'ready', + 'updated_at' => $now, + ]; + + if ($existingFile) { + Db::name('report_files')->where('id', $existingFile['id'])->update($filePayload); + } else { + $filePayload['created_at'] = $now; + Db::name('report_files')->insert($filePayload); + } + + return $this->storage()->publicUrl($request, $relativePath); + } + + private function buildPublicPageUrl(string $pagePath, array $query = []): string + { + $baseUrl = $this->normalizeH5BaseUrl($this->getSystemConfigValue('h5', 'page_base_url')); + $page = ltrim($pagePath, '/'); + $queryString = http_build_query($query); + $hashPath = '/#/' . $page; + if ($queryString !== '') { + $hashPath .= '?' . $queryString; + } + + if ($baseUrl === '') { + return $hashPath; + } + + return $baseUrl . $hashPath; + } + + private function normalizeH5BaseUrl(string $value): string + { + $baseUrl = trim($value); + if ($baseUrl === '') { + return ''; + } + + $hashPos = strpos($baseUrl, '#'); + if ($hashPos !== false) { + $baseUrl = substr($baseUrl, 0, $hashPos); + } + + if (!preg_match('/^https?:\/\//i', $baseUrl)) { + $baseUrl = 'https://' . ltrim($baseUrl, '/'); + } + + return rtrim($baseUrl, '/'); + } + + private function getSystemConfigValue(string $groupCode, string $configKey): string + { + $row = Db::name('system_configs') + ->where('config_group', $groupCode) + ->where('config_key', $configKey) + ->find(); + + return trim((string)($row['config_value'] ?? '')); + } + + private function evidenceService(): AppraisalEvidenceService + { + return new AppraisalEvidenceService(); + } + + private function storage(): FileStorageService + { + return new FileStorageService(); + } +} diff --git a/server-api/app/controller/app/SettingsController.php b/server-api/app/controller/app/SettingsController.php new file mode 100644 index 0000000..5047cb6 --- /dev/null +++ b/server-api/app/controller/app/SettingsController.php @@ -0,0 +1,130 @@ +where('id', $userId)->find(); + if (!$user) { + return api_error('用户不存在', 404); + } + + return api_success($this->buildPayload($user, $userId)); + } + + public function save(Request $request) + { + $userId = app_user_id($request); + $user = Db::name('users')->where('id', $userId)->find(); + if (!$user) { + return api_error('用户不存在', 404); + } + + $nickname = trim((string)$request->input('nickname', $user['nickname'])); + if ($nickname === '') { + return api_error('昵称不能为空', 422); + } + + $preferenceInput = (array)$request->input('preferences', []); + $preferences = $this->normalizePreferences($preferenceInput); + $now = date('Y-m-d H:i:s'); + + Db::startTrans(); + try { + Db::name('users')->where('id', $userId)->update([ + 'nickname' => $nickname, + 'updated_at' => $now, + ]); + + $config = Db::name('system_configs') + ->where('config_group', 'user_settings') + ->where('config_key', $this->preferenceConfigKey($userId)) + ->find(); + + $payload = [ + 'config_group' => 'user_settings', + 'config_key' => $this->preferenceConfigKey($userId), + 'config_value' => json_encode($preferences, JSON_UNESCAPED_UNICODE), + 'remark' => '用户端设置偏好', + 'updated_at' => $now, + ]; + + if ($config) { + Db::name('system_configs')->where('id', $config['id'])->update($payload); + } else { + $payload['created_at'] = $now; + Db::name('system_configs')->insert($payload); + } + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('设置保存失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + $latestUser = Db::name('users')->where('id', $userId)->find(); + + return api_success($this->buildPayload($latestUser ?: $user, $userId), '设置已保存'); + } + + private function buildPayload(array $user, int $userId): array + { + $content = (new ContentService())->getPolicyConfig(); + + return [ + 'profile_info' => [ + 'user_id' => (int)$user['id'], + 'nickname' => $user['nickname'] ?: '安心验用户', + 'mobile' => $user['mobile'] ?: '', + 'avatar' => $user['avatar'] ?: '', + 'status' => $user['status'] ?: 'enabled', + 'status_text' => ($user['status'] ?? 'enabled') === 'enabled' ? '账号正常' : '账号异常', + 'password_set' => ((string)($user['password'] ?? '')) !== '', + ], + 'preferences' => $this->loadPreferences($userId), + 'legal_entries' => $content['legal_entries'], + ]; + } + + private function loadPreferences(int $userId): array + { + $configValue = Db::name('system_configs') + ->where('config_group', 'user_settings') + ->where('config_key', $this->preferenceConfigKey($userId)) + ->value('config_value'); + + $decoded = []; + if (is_string($configValue) && $configValue !== '') { + $decoded = json_decode($configValue, true); + $decoded = is_array($decoded) ? $decoded : []; + } + + return $this->normalizePreferences($decoded); + } + + private function normalizePreferences(array $input): array + { + return [ + 'notify_order' => array_key_exists('notify_order', $input) ? (bool)$input['notify_order'] : true, + 'notify_report' => array_key_exists('notify_report', $input) ? (bool)$input['notify_report'] : true, + 'notify_supplement' => array_key_exists('notify_supplement', $input) ? (bool)$input['notify_supplement'] : true, + 'notify_ticket' => array_key_exists('notify_ticket', $input) ? (bool)$input['notify_ticket'] : true, + 'marketing_notify' => array_key_exists('marketing_notify', $input) ? (bool)$input['marketing_notify'] : false, + 'privacy_mode' => array_key_exists('privacy_mode', $input) ? (bool)$input['privacy_mode'] : false, + ]; + } + + private function preferenceConfigKey(int $userId): string + { + return 'user_' . $userId; + } +} diff --git a/server-api/app/controller/app/ShippingController.php b/server-api/app/controller/app/ShippingController.php new file mode 100644 index 0000000..c1c3607 --- /dev/null +++ b/server-api/app/controller/app/ShippingController.php @@ -0,0 +1,277 @@ +input('order_id', 0); + $userId = app_user_id($request); + if ($orderId <= 0) { + return api_error('订单 ID 不能为空', 422); + } + + $order = Db::name('orders')->where('id', $orderId)->where('user_id', $userId)->find(); + if (!$order) { + return api_error('订单不存在', 404); + } + + $product = Db::name('order_products')->where('order_id', $orderId)->find(); + $logistics = Db::name('order_logistics') + ->where('order_id', $orderId) + ->where('logistics_type', 'send_to_center') + ->order('id', 'desc') + ->find(); + $nodes = []; + if ($logistics) { + $nodes = Db::name('order_logistics_nodes') + ->where('logistics_id', $logistics['id']) + ->order('node_time', 'desc') + ->select() + ->toArray(); + } + + $warehouseService = new WarehouseService(); + $categoryId = (int)($product['category_id'] ?? 0); + $defaultAddress = Db::name('user_addresses') + ->where('user_id', $userId) + ->where('is_default', 1) + ->find(); + $center = $warehouseService->getOrderTarget($orderId) + ?: $warehouseService->resolveForShipping((string)($order['service_provider'] ?? 'anxinyan'), $categoryId, $defaultAddress ?: null); + $trackingSubmitted = !empty($logistics['tracking_no']); + $warehouseOptions = $warehouseService->optionsForOrder((string)($order['service_provider'] ?? 'anxinyan'), $categoryId, $defaultAddress ?: null); + + return api_success([ + 'order_info' => [ + 'order_id' => (int)$order['id'], + 'order_no' => $order['order_no'], + 'appraisal_no' => $order['appraisal_no'], + 'service_provider' => $order['service_provider'], + 'display_status' => $trackingSubmitted ? '已提交运单' : ($order['display_status'] ?: '待寄送商品'), + 'estimated_finish_time' => $order['estimated_finish_time'], + 'product_name' => $product['product_name'] ?? '', + ], + 'shipping_address' => $center, + 'shipping_options' => [ + 'current_warehouse_id' => (int)($center['warehouse_id'] ?? 0), + 'can_select_warehouse' => in_array($order['order_status'], ['pending_shipping'], true) && !$trackingSubmitted, + 'list' => $warehouseOptions, + ], + 'shipping_notice' => [ + 'tips' => [ + '请在包裹内附上订单号或鉴定单号,便于鉴定中心快速匹配。', + '贵重商品建议使用顺丰、京东等可追踪快递,并保留寄件凭证。', + '寄出后请尽快填写快递公司和运单号,我们会同步更新处理进度。', + ], + 'express_recommendations' => ['顺丰速运', '京东快递', 'EMS', '中通快递'], + ], + 'logistics_info' => [ + 'express_company' => $logistics['express_company'] ?? '', + 'tracking_no' => $logistics['tracking_no'] ?? '', + 'tracking_status' => $logistics['tracking_status'] ?? '', + 'tracking_status_text' => $this->trackingStatusText((string)($logistics['tracking_status'] ?? '')), + 'latest_desc' => $logistics['latest_desc'] ?? '', + 'latest_time' => $logistics['latest_time'] ?? '', + 'is_submitted' => $trackingSubmitted, + ], + 'logistics_nodes' => array_map(fn (array $item) => [ + 'node_time' => $item['node_time'], + 'node_desc' => $item['node_desc'], + 'node_location' => $item['node_location'], + ], $nodes), + 'can_submit_tracking' => in_array($order['order_status'], ['pending_shipping'], true), + ]); + } + + public function save(Request $request) + { + $orderId = (int)$request->input('order_id', 0); + $userId = app_user_id($request); + $expressCompany = trim((string)$request->input('express_company', '')); + $trackingNo = trim((string)$request->input('tracking_no', '')); + $warehouseId = (int)$request->input('warehouse_id', 0); + + if ($orderId <= 0) { + return api_error('订单 ID 不能为空', 422); + } + if ($expressCompany === '' || $trackingNo === '') { + return api_error('快递公司和运单号不能为空', 422); + } + + $order = Db::name('orders')->where('id', $orderId)->where('user_id', $userId)->find(); + if (!$order) { + return api_error('订单不存在', 404); + } + if (!in_array($order['order_status'], ['pending_shipping'], true)) { + return api_error('当前订单状态不支持提交运单', 422); + } + + $product = Db::name('order_products')->where('order_id', $orderId)->find(); + $warehouseService = new WarehouseService(); + $categoryId = !empty($product['category_id']) ? (int)$product['category_id'] : null; + $currentTarget = $warehouseService->getOrderTarget($orderId) + ?: $warehouseService->resolveForShipping((string)($order['service_provider'] ?? 'anxinyan'), $categoryId); + $selectedWarehouse = $currentTarget; + + if ($warehouseId > 0) { + $defaultAddress = Db::name('user_addresses') + ->where('user_id', $userId) + ->where('is_default', 1) + ->find(); + $candidateWarehouses = $warehouseService->optionsForOrder((string)$order['service_provider'], $categoryId, $defaultAddress ?: null); + $matched = null; + foreach ($candidateWarehouses as $item) { + if ((int)$item['id'] === $warehouseId) { + $matched = $item; + break; + } + } + + if (!$matched) { + return api_error('所选仓库不可用,请重新选择', 422); + } + + $selectedWarehouse = [ + 'warehouse_id' => (int)$matched['id'], + 'warehouse_name' => $matched['warehouse_name'], + 'warehouse_code' => $matched['warehouse_code'], + 'receiver_name' => $matched['receiver_name'], + 'receiver_mobile' => $matched['receiver_mobile'], + 'province' => $matched['province'], + 'city' => $matched['city'], + 'district' => $matched['district'], + 'detail_address' => $matched['detail_address'], + 'service_time' => $matched['service_time'], + 'notice' => $matched['notice'], + ]; + } + + $now = date('Y-m-d H:i:s'); + $latestDesc = sprintf('您已提交 %s 运单号 %s,等待鉴定中心签收。', $expressCompany, $trackingNo); + $existing = Db::name('order_logistics') + ->where('order_id', $orderId) + ->where('logistics_type', 'send_to_center') + ->order('id', 'desc') + ->find(); + $warehouseChanged = (int)($selectedWarehouse['warehouse_id'] ?? 0) > 0 + && (int)($selectedWarehouse['warehouse_id'] ?? 0) !== (int)($currentTarget['warehouse_id'] ?? 0); + + Db::startTrans(); + try { + if ((int)($selectedWarehouse['warehouse_id'] ?? 0) > 0) { + $warehouseService->bindOrderTarget($orderId, (string)$order['service_provider'], $categoryId); + Db::name('order_shipping_targets')->where('order_id', $orderId)->update([ + 'warehouse_id' => $selectedWarehouse['warehouse_id'], + 'warehouse_name' => $selectedWarehouse['warehouse_name'], + 'warehouse_code' => $selectedWarehouse['warehouse_code'], + 'service_provider' => $order['service_provider'], + 'receiver_name' => $selectedWarehouse['receiver_name'], + 'receiver_mobile' => $selectedWarehouse['receiver_mobile'], + 'province' => $selectedWarehouse['province'], + 'city' => $selectedWarehouse['city'], + 'district' => $selectedWarehouse['district'], + 'detail_address' => $selectedWarehouse['detail_address'], + 'service_time' => $selectedWarehouse['service_time'], + 'notice' => $selectedWarehouse['notice'], + 'updated_at' => $now, + ]); + } + + if ($existing) { + Db::name('order_logistics')->where('id', $existing['id'])->update([ + 'logistics_type' => 'send_to_center', + 'express_company' => $expressCompany, + 'tracking_no' => $trackingNo, + 'tracking_status' => 'submitted', + 'latest_desc' => $latestDesc, + 'latest_time' => $now, + 'updated_at' => $now, + ]); + $logisticsId = (int)$existing['id']; + $nodeText = '已更新运单'; + $nodeDesc = sprintf('用户更新了寄送运单:%s %s', $expressCompany, $trackingNo); + } else { + $logisticsId = (int)Db::name('order_logistics')->insertGetId([ + 'order_id' => $orderId, + 'logistics_type' => 'send_to_center', + 'express_company' => $expressCompany, + 'tracking_no' => $trackingNo, + 'tracking_status' => 'submitted', + 'latest_desc' => $latestDesc, + 'latest_time' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + $nodeText = '已提交运单'; + $nodeDesc = sprintf('用户已提交寄送运单:%s %s', $expressCompany, $trackingNo); + } + + Db::name('order_logistics_nodes')->insert([ + 'logistics_id' => $logisticsId, + 'node_time' => $now, + 'node_desc' => $latestDesc, + 'node_location' => $selectedWarehouse['warehouse_name'] ?? '用户端', + 'created_at' => $now, + ]); + + Db::name('orders')->where('id', $orderId)->update([ + 'display_status' => '已提交运单', + 'updated_at' => $now, + ]); + + Db::name('order_timelines')->insert([ + 'order_id' => $orderId, + 'node_code' => 'tracking_submitted', + 'node_text' => $nodeText, + 'node_desc' => $nodeDesc, + 'operator_type' => 'user', + 'operator_id' => $userId, + 'occurred_at' => $now, + 'created_at' => $now, + ]); + + if ($warehouseChanged) { + Db::name('order_timelines')->insert([ + 'order_id' => $orderId, + 'node_code' => 'warehouse_selected', + 'node_text' => '已选择寄送仓库', + 'node_desc' => sprintf('用户已选择寄送仓库:%s', $selectedWarehouse['warehouse_name']), + 'operator_type' => 'user', + 'operator_id' => $userId, + 'occurred_at' => $now, + 'created_at' => $now, + ]); + } + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('运单提交失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success([ + 'order_id' => $orderId, + 'express_company' => $expressCompany, + 'tracking_no' => $trackingNo, + 'warehouse_id' => (int)($selectedWarehouse['warehouse_id'] ?? 0), + ], '运单已提交'); + } + + private function trackingStatusText(string $status): string + { + return match ($status) { + 'submitted' => '已提交运单', + 'in_transit' => '运输中', + 'received' => '已签收', + default => $status === '' ? '待提交' : $status, + }; + } +} diff --git a/server-api/app/controller/app/SupplementController.php b/server-api/app/controller/app/SupplementController.php new file mode 100644 index 0000000..c3f3462 --- /dev/null +++ b/server-api/app/controller/app/SupplementController.php @@ -0,0 +1,359 @@ +input('order_id', 0); + $userId = app_user_id($request); + if ($orderId <= 0) { + return api_error('订单 ID 不能为空', 422); + } + + $order = Db::name('orders')->where('id', $orderId)->where('user_id', $userId)->find(); + if (!$order) { + return api_error('订单不存在', 404); + } + + $supplementTask = Db::name('order_supplement_tasks') + ->where('order_id', $orderId) + ->where('status', 'pending') + ->order('id', 'desc') + ->find(); + + if (!$supplementTask) { + return api_error('当前订单暂无待补资料任务', 404); + } + + $taskItems = Db::name('order_supplement_task_items') + ->where('task_id', $supplementTask['id']) + ->order('id', 'asc') + ->select() + ->toArray(); + + $uploadItems = Db::name('order_upload_items') + ->where('order_id', $orderId) + ->where('source_type', 'supplement') + ->where('created_at', '>=', $supplementTask['created_at']) + ->order('id', 'desc') + ->select() + ->toArray(); + + $uploadItemsByName = []; + foreach ($uploadItems as $item) { + if (!isset($uploadItemsByName[$item['item_name']])) { + $uploadItemsByName[$item['item_name']] = $item; + } + } + + $list = array_map(function (array $item) use ($uploadItemsByName, $request) { + $uploadItem = $uploadItemsByName[$item['item_name']] ?? null; + $files = []; + + if ($uploadItem) { + $files = Db::name('order_upload_files') + ->where('order_upload_item_id', $uploadItem['id']) + ->order('id', 'asc') + ->select() + ->toArray(); + } + + return [ + 'upload_item_id' => (int)($uploadItem['id'] ?? 0), + 'item_code' => $uploadItem['item_code'] ?? $item['item_code'], + 'item_name' => $item['item_name'], + 'guide_text' => $item['guide_text'], + 'is_required' => (bool)$item['is_required'], + 'status' => $uploadItem['status'] ?? 'pending', + 'files' => array_map(fn (array $file) => [ + 'id' => (int)$file['id'], + 'file_id' => $file['file_id'], + 'file_url' => $this->assetUrlService()->normalizeUrl((string)$file['file_url'], $request), + 'thumbnail_url' => $this->assetUrlService()->normalizeUrl((string)$file['thumbnail_url'], $request), + ], $files), + ]; + }, $taskItems); + + return api_success([ + 'order_id' => (int)$order['id'], + 'order_no' => $order['order_no'], + 'appraisal_no' => $order['appraisal_no'], + 'reason' => $supplementTask['reason'], + 'deadline' => $supplementTask['deadline'], + 'items' => $list, + ]); + } + + public function uploadFile(Request $request) + { + $userId = app_user_id($request); + $uploadItemId = (int)$request->post('upload_item_id', 0); + if ($uploadItemId <= 0) { + return api_error('资料项 ID 不能为空', 422); + } + + $uploadItem = $this->findUserUploadItem($uploadItemId, $userId); + if (!$uploadItem) { + return api_error('补资料项不存在', 404); + } + + $supplementTask = $this->findPendingSupplementTask((int)$uploadItem['order_id']); + if (!$supplementTask) { + return api_error('当前订单暂无待补资料任务', 422); + } + + $file = $request->file('file'); + if (!$file || !$file->isValid()) { + return api_error('上传文件无效', 422); + } + + $extension = strtolower($file->getUploadExtension() ?: 'jpg'); + $filename = sprintf('%s_%s.%s', $uploadItem['item_code'], uniqid(), $extension); + $relativeDir = 'uploads/supplement/' . date('Ymd'); + $relativePath = $relativeDir . '/' . $filename; + $this->storage()->putUploadedFile($file, $relativePath); + + $fileUrl = $this->storage()->publicUrl($request, $relativePath); + $now = date('Y-m-d H:i:s'); + + Db::name('order_upload_files')->insert([ + 'order_upload_item_id' => $uploadItemId, + 'file_id' => md5($relativePath), + 'file_url' => '/' . ltrim($relativePath, '/'), + 'thumbnail_url' => '/' . ltrim($relativePath, '/'), + 'quality_status' => 'uploaded', + 'quality_message' => '', + 'uploaded_by_user_id' => $userId, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('order_upload_items')->where('id', $uploadItemId)->update([ + 'status' => 'uploaded', + 'updated_at' => $now, + ]); + + return api_success([ + 'id' => $uploadItemId, + 'file_id' => md5($relativePath), + 'file_url' => $fileUrl, + 'thumbnail_url' => $fileUrl, + 'name' => $file->getUploadName(), + ]); + } + + public function deleteFile(Request $request) + { + $fileId = trim((string)$request->input('file_id', '')); + if ($fileId === '') { + return api_error('文件 ID 不能为空', 422); + } + + $file = Db::name('order_upload_files')->where('file_id', $fileId)->find(); + if (!$file) { + return api_error('文件不存在', 404); + } + + $uploadItem = $this->findUserUploadItem((int)$file['order_upload_item_id'], app_user_id($request)); + if (!$uploadItem) { + return api_error('无权删除该文件', 403); + } + + $relativePath = $this->storage()->storagePath((string)$file['file_url']); + if (str_starts_with($relativePath, 'uploads/supplement/')) { + $this->storage()->delete($relativePath); + } + + Db::name('order_upload_files')->where('id', $file['id'])->delete(); + + $remainingCount = (int)Db::name('order_upload_files') + ->where('order_upload_item_id', $uploadItem['id']) + ->count(); + + Db::name('order_upload_items')->where('id', $uploadItem['id'])->update([ + 'status' => $remainingCount > 0 ? 'uploaded' : 'pending', + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + return api_success([ + 'file_id' => $fileId, + ], '删除成功'); + } + + public function submit(Request $request) + { + $orderId = (int)$request->input('order_id', 0); + $userId = app_user_id($request); + if ($orderId <= 0) { + return api_error('订单 ID 不能为空', 422); + } + + $order = Db::name('orders')->where('id', $orderId)->where('user_id', $userId)->find(); + if (!$order) { + return api_error('订单不存在', 404); + } + + $supplementTask = $this->findPendingSupplementTask($orderId); + if (!$supplementTask) { + return api_error('当前订单暂无待补资料任务', 422); + } + + $taskItems = Db::name('order_supplement_task_items') + ->where('task_id', $supplementTask['id']) + ->order('id', 'asc') + ->select() + ->toArray(); + + $uploadItems = Db::name('order_upload_items') + ->where('order_id', $orderId) + ->where('source_type', 'supplement') + ->where('created_at', '>=', $supplementTask['created_at']) + ->order('id', 'desc') + ->select() + ->toArray(); + + $uploadItemsByName = []; + foreach ($uploadItems as $item) { + if (!isset($uploadItemsByName[$item['item_name']])) { + $uploadItemsByName[$item['item_name']] = $item; + } + } + + foreach ($taskItems as $item) { + if (!(bool)$item['is_required']) { + continue; + } + + $uploadItem = $uploadItemsByName[$item['item_name']] ?? null; + if (!$uploadItem) { + return api_error("请先上传「{$item['item_name']}」", 422); + } + + $fileCount = (int)Db::name('order_upload_files') + ->where('order_upload_item_id', $uploadItem['id']) + ->count(); + + if ($fileCount <= 0) { + return api_error("请先上传「{$item['item_name']}」", 422); + } + } + + $now = date('Y-m-d H:i:s'); + + Db::startTrans(); + try { + $resumeTask = Db::name('appraisal_tasks') + ->where('order_id', $orderId) + ->where('status', 'returned') + ->orderRaw("FIELD(task_stage, 'final_review', 'first_review')") + ->order('id', 'desc') + ->find(); + + if (!$resumeTask) { + $resumeTask = Db::name('appraisal_tasks') + ->where('order_id', $orderId) + ->where('task_stage', 'first_review') + ->order('id', 'asc') + ->find(); + } + + $nextOrderStatus = 'in_first_review'; + $nextDisplayStatus = '鉴定中'; + $timelineDesc = '您已完成补充资料上传,订单重新进入鉴定流程'; + + Db::name('order_supplement_tasks')->where('id', $supplementTask['id'])->update([ + 'status' => 'submitted', + 'submitted_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('orders')->where('id', $orderId)->update([ + 'order_status' => $nextOrderStatus, + 'display_status' => $nextDisplayStatus, + 'updated_at' => $now, + ]); + + if ($resumeTask) { + $resumeTaskUpdate = [ + 'status' => 'processing', + 'updated_at' => $now, + ]; + if (empty($resumeTask['started_at'])) { + $resumeTaskUpdate['started_at'] = $now; + } + + Db::name('appraisal_tasks') + ->where('id', $resumeTask['id']) + ->update($resumeTaskUpdate); + } + + Db::name('order_timelines')->insert([ + 'order_id' => $orderId, + 'node_code' => 'supplement_submitted', + 'node_text' => '补资料已提交', + 'node_desc' => $timelineDesc, + 'operator_type' => 'user', + 'operator_id' => $userId, + 'occurred_at' => $now, + 'created_at' => $now, + ]); + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('提交补资料失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success([ + 'order_id' => $orderId, + 'next_status' => $nextOrderStatus, + ], '补资料已提交'); + } + + private function findUserUploadItem(int $uploadItemId, int $userId): ?array + { + $uploadItem = Db::name('order_upload_items')->where('id', $uploadItemId)->find(); + if (!$uploadItem) { + return null; + } + + $order = Db::name('orders')->where('id', $uploadItem['order_id'])->where('user_id', $userId)->find(); + if (!$order || $uploadItem['source_type'] !== 'supplement') { + return null; + } + + return $uploadItem; + } + + private function findPendingSupplementTask(int $orderId): ?array + { + $task = Db::name('order_supplement_tasks') + ->where('order_id', $orderId) + ->where('status', 'pending') + ->order('id', 'desc') + ->find(); + + return $task ?: null; + } + + private function assetUrlService(): PublicAssetUrlService + { + return new PublicAssetUrlService(); + } + + private function storage(): FileStorageService + { + return new FileStorageService(); + } +} diff --git a/server-api/app/controller/app/TicketsController.php b/server-api/app/controller/app/TicketsController.php new file mode 100644 index 0000000..ee24666 --- /dev/null +++ b/server-api/app/controller/app/TicketsController.php @@ -0,0 +1,357 @@ +where('user_id', $userId)->column('id'); + $ticketTypes = (new ContentService())->getTicketTypes(); + + return api_success([ + 'cards' => [ + [ + 'title' => '全部工单', + 'value' => (int)Db::name('tickets')->where('user_id', $userId)->count(), + 'desc' => '您当前已提交的全部客服工单', + ], + [ + 'title' => '待处理', + 'value' => (int)Db::name('tickets')->where('user_id', $userId)->whereIn('status', ['pending', 'processing', 'waiting_user'])->count(), + 'desc' => '客服待处理或正在跟进中的工单', + ], + [ + 'title' => '已解决', + 'value' => (int)Db::name('tickets')->where('user_id', $userId)->where('status', 'resolved')->count(), + 'desc' => '已处理完成的工单数量', + ], + [ + 'title' => '工单留言', + 'value' => $ticketIds ? (int)Db::name('ticket_messages')->whereIn('ticket_id', $ticketIds)->count() : 0, + 'desc' => '您与客服之间的全部沟通记录', + ], + ], + 'ticket_types' => $ticketTypes, + ]); + } + + public function meta(Request $request) + { + $content = new ContentService(); + return api_success([ + 'ticket_types' => $content->getTicketTypes(), + 'ticket_statuses' => $content->getTicketStatuses(), + ]); + } + + public function index(Request $request) + { + $userId = app_user_id($request); + $status = trim((string)$request->input('status', '')); + $type = trim((string)$request->input('ticket_type', '')); + + $query = Db::name('tickets') + ->where('user_id', $userId) + ->order('id', 'desc'); + + if ($status !== '') { + $query->where('status', $status); + } + if ($type !== '') { + $query->where('ticket_type', $type); + } + + $rows = $query->select()->toArray(); + + $list = array_map(function (array $item) { + $lastMessage = Db::name('ticket_messages') + ->where('ticket_id', $item['id']) + ->order('id', 'desc') + ->find(); + + $lastAttachments = $this->attachmentService()->normalize($lastMessage['attachments_json'] ?? null, $request); + $latestMessage = $lastMessage['content'] ?? ($item['content'] ?? ''); + if ($latestMessage === '' && $lastAttachments) { + $latestMessage = sprintf('[附件 %d 张]', count($lastAttachments)); + } + + return [ + 'id' => (int)$item['id'], + 'ticket_no' => $item['ticket_no'], + 'ticket_type' => $item['ticket_type'], + 'ticket_type_text' => $this->ticketTypeText($item['ticket_type']), + 'status' => $item['status'], + 'status_text' => $this->statusText($item['status']), + 'priority' => $item['priority'], + 'priority_text' => $this->priorityText($item['priority']), + 'title' => $item['title'] ?: '未命名工单', + 'order_id' => (int)($item['order_id'] ?? 0), + 'latest_message' => $latestMessage, + 'updated_at' => $item['updated_at'], + 'created_at' => $item['created_at'], + ]; + }, $rows); + + return api_success(['list' => $list]); + } + + public function detail(Request $request) + { + $id = (int)$request->input('id', 0); + if ($id <= 0) { + return api_error('工单 ID 不能为空', 422); + } + + $ticket = Db::name('tickets')->where('id', $id)->where('user_id', app_user_id($request))->find(); + if (!$ticket) { + return api_error('工单不存在', 404); + } + + $messages = Db::name('ticket_messages') + ->where('ticket_id', $id) + ->order('id', 'asc') + ->select() + ->toArray(); + + $order = null; + if (!empty($ticket['order_id'])) { + $order = Db::name('orders') + ->field(['id', 'order_no', 'display_status']) + ->where('id', $ticket['order_id']) + ->where('user_id', app_user_id($request)) + ->find(); + } + + return api_success([ + 'ticket_info' => [ + 'id' => (int)$ticket['id'], + 'ticket_no' => $ticket['ticket_no'], + 'ticket_type' => $ticket['ticket_type'], + 'ticket_type_text' => $this->ticketTypeText($ticket['ticket_type']), + 'status' => $ticket['status'], + 'status_text' => $this->statusText($ticket['status']), + 'priority' => $ticket['priority'], + 'priority_text' => $this->priorityText($ticket['priority']), + 'title' => $ticket['title'], + 'content' => $ticket['content'] ?: '', + 'order_id' => (int)($ticket['order_id'] ?? 0), + 'created_at' => $ticket['created_at'], + 'updated_at' => $ticket['updated_at'], + ], + 'order_info' => $order ? [ + 'order_id' => (int)$order['id'], + 'order_no' => $order['order_no'], + 'display_status' => $order['display_status'], + ] : null, + 'messages' => array_map(function (array $item) { + return [ + 'sender_type' => $item['sender_type'], + 'sender_type_text' => match ($item['sender_type']) { + 'customer_service' => '客服', + 'system' => '系统', + default => '您', + }, + 'content' => $item['content'] ?: '', + 'attachments' => $this->attachmentService()->normalize($item['attachments_json'] ?? null, $request), + 'created_at' => $item['created_at'], + ]; + }, $messages), + ]); + } + + public function create(Request $request) + { + $userId = app_user_id($request); + $ticketType = trim((string)$request->input('ticket_type', 'order_issue')); + $title = trim((string)$request->input('title', '')); + $content = trim((string)$request->input('content', '')); + $attachments = $this->attachmentService()->normalize($request->input('attachments', []), $request, true); + $orderId = (int)$request->input('order_id', 0); + $reportId = (int)$request->input('report_id', 0); + + if ($title === '') { + return api_error('工单标题不能为空', 422); + } + if ($content === '' && !$attachments) { + return api_error('问题描述和附件至少填写一项', 422); + } + + $bizType = 'support'; + $bizId = null; + + if ($orderId > 0) { + $order = Db::name('orders')->where('id', $orderId)->where('user_id', $userId)->find(); + if (!$order) { + return api_error('关联订单不存在', 404); + } + $bizType = 'order'; + $bizId = $orderId; + } elseif ($reportId > 0) { + $report = Db::name('reports')->where('id', $reportId)->find(); + if (!$report) { + return api_error('关联报告不存在', 404); + } + $bizType = 'report'; + $bizId = $reportId; + } + + $now = date('Y-m-d H:i:s'); + $ticketNo = 'TK' . date('YmdHis') . mt_rand(100, 999); + + Db::startTrans(); + try { + $ticketId = (int)Db::name('tickets')->insertGetId([ + 'ticket_no' => $ticketNo, + 'ticket_type' => $ticketType, + 'biz_type' => $bizType, + 'biz_id' => $bizId, + 'order_id' => $orderId > 0 ? $orderId : null, + 'user_id' => $userId, + 'status' => 'pending', + 'priority' => 'normal', + 'assignee_id' => null, + 'title' => $title, + 'content' => $content, + 'closed_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('ticket_messages')->insertAll([ + [ + 'ticket_id' => $ticketId, + 'sender_type' => 'user', + 'sender_id' => $userId, + 'content' => $content, + 'attachments_json' => $attachments ? json_encode($attachments, JSON_UNESCAPED_UNICODE) : null, + 'created_at' => $now, + ], + [ + 'ticket_id' => $ticketId, + 'sender_type' => 'system', + 'sender_id' => null, + 'content' => '工单已创建,客服会尽快与您联系。', + 'attachments_json' => null, + 'created_at' => $now, + ], + ]); + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('工单创建失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success([ + 'ticket_id' => $ticketId, + 'ticket_no' => $ticketNo, + ], '工单已提交'); + } + + public function reply(Request $request) + { + $ticketId = (int)$request->input('ticket_id', 0); + $content = trim((string)$request->input('content', '')); + $attachments = $this->attachmentService()->normalize($request->input('attachments', []), $request, true); + + if ($ticketId <= 0) { + return api_error('工单 ID 不能为空', 422); + } + if ($content === '' && !$attachments) { + return api_error('回复内容和附件至少填写一项', 422); + } + + $ticket = Db::name('tickets')->where('id', $ticketId)->where('user_id', app_user_id($request))->find(); + if (!$ticket) { + return api_error('工单不存在', 404); + } + + $now = date('Y-m-d H:i:s'); + + Db::startTrans(); + try { + Db::name('ticket_messages')->insert([ + 'ticket_id' => $ticketId, + 'sender_type' => 'user', + 'sender_id' => app_user_id($request), + 'content' => $content, + 'attachments_json' => $attachments ? json_encode($attachments, JSON_UNESCAPED_UNICODE) : null, + 'created_at' => $now, + ]); + + Db::name('tickets')->where('id', $ticketId)->update([ + 'status' => 'processing', + 'updated_at' => $now, + ]); + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + return api_error('发送失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success([ + 'ticket_id' => $ticketId, + ], '已发送'); + } + + public function uploadFile(Request $request) + { + try { + $asset = $this->attachmentService()->upload($request); + return api_success($asset); + } catch (\Throwable $e) { + return api_error($e->getMessage(), 422); + } + } + + public function deleteFile(Request $request) + { + $fileUrl = trim((string)$request->input('file_url', '')); + if ($fileUrl === '') { + return api_error('文件地址不能为空', 422); + } + + $this->attachmentService()->delete($fileUrl); + + return api_success([ + 'file_url' => $fileUrl, + ], '删除成功'); + } + + private function statusText(string $status): string + { + return (new ContentService())->ticketStatusText($status); + } + + private function priorityText(string $priority): string + { + return match ($priority) { + 'high' => '高优先级', + 'normal' => '普通', + 'low' => '低优先级', + default => $priority, + }; + } + + private function ticketTypeText(string $type): string + { + return (new ContentService())->ticketTypeText($type); + } + + private function attachmentService(): TicketAttachmentService + { + return new TicketAttachmentService(); + } +} diff --git a/server-api/app/controller/app/VerifyController.php b/server-api/app/controller/app/VerifyController.php new file mode 100644 index 0000000..64e6f12 --- /dev/null +++ b/server-api/app/controller/app/VerifyController.php @@ -0,0 +1,84 @@ +input('report_no', '')); + if ($reportNo === '') { + return api_error('报告编号不能为空', 422); + } + + $verify = Db::name('report_verifies')->where('report_no', $reportNo)->find(); + $report = Db::name('reports') + ->where('report_no', $reportNo) + ->where('report_status', 'published') + ->find(); + if (!$verify || !$report) { + return api_error('报告不存在', 404, [ + 'verify_status' => 'not_found', + ]); + } + + $now = date('Y-m-d H:i:s'); + Db::name('report_verifies')->where('id', $verify['id'])->update([ + 'verify_count' => (int)$verify['verify_count'] + 1, + 'last_verified_at' => $now, + 'updated_at' => $now, + ]); + Db::name('report_verify_logs')->insert([ + 'report_verify_id' => (int)$verify['id'], + 'verify_type' => 'h5', + 'ip' => (string)$request->getRealIp(), + 'user_agent' => (string)$request->header('user-agent', ''), + 'verified_at' => $now, + 'created_at' => $now, + ]); + + $content = Db::name('report_contents')->where('report_id', $report['id'])->find(); + $productSnapshot = $this->decodeJsonField($content['product_snapshot_json'] ?? null); + $resultSnapshot = $this->decodeJsonField($content['result_snapshot_json'] ?? null); + $evidenceAttachments = $this->evidenceService()->normalize($content['evidence_attachments_json'] ?? null, $request); + + return api_success([ + 'verify_status' => $verify['verify_status'], + 'verify_message' => match ($verify['verify_status']) { + 'valid' => '该报告真实有效,可作为对应鉴定结果参考。', + 'updated' => '该报告已更新,请以最新版本为准。', + 'invalid' => '该报告已失效,请勿继续作为有效凭证使用。', + default => '当前验真状态异常,请稍后重试。', + }, + 'report_summary' => [ + 'report_no' => $report['report_no'], + 'report_title' => $report['report_title'], + 'institution_name' => $report['institution_name'], + 'publish_time' => $report['publish_time'], + ], + 'product_summary' => $productSnapshot, + 'result_summary' => $resultSnapshot, + 'evidence_attachments' => $evidenceAttachments, + ]); + } + + private function decodeJsonField(mixed $value): array + { + if (is_array($value)) { + return $value; + } + if (is_string($value) && $value !== '') { + return json_decode($value, true) ?: []; + } + return []; + } + + private function evidenceService(): AppraisalEvidenceService + { + return new AppraisalEvidenceService(); + } +} diff --git a/server-api/app/controller/open/OrdersController.php b/server-api/app/controller/open/OrdersController.php new file mode 100644 index 0000000..54a3640 --- /dev/null +++ b/server-api/app/controller/open/OrdersController.php @@ -0,0 +1,69 @@ +authenticate($request); + } catch (\Throwable $e) { + return api_error($e->getMessage(), 401); + } + + $payload = json_decode($request->rawBody(), true); + if (!is_array($payload)) { + return api_error('请求体必须是合法 JSON 对象', 422); + } + + try { + $result = (new EnterpriseOrderService())->createOrder($auth['customer'], $payload, $request); + } catch (\InvalidArgumentException $e) { + return api_error($e->getMessage(), 422); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), 409); + } catch (\Throwable $e) { + return api_error('订单创建失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success($result, !empty($result['idempotent']) ? '订单已存在' : '订单已创建'); + } + + public function detail(Request $request) + { + try { + $auth = (new EnterpriseOpenApiAuthService())->authenticate($request); + } catch (\Throwable $e) { + return api_error($e->getMessage(), 401); + } + + $externalOrderNo = trim((string)($request->route?->param('external_order_no', '') ?? '')); + if ($externalOrderNo === '') { + $externalOrderNo = trim((string)$request->input('external_order_no', '')); + } + $orderNo = trim((string)$request->input('order_no', '')); + + try { + $order = (new EnterpriseOrderService())->findOrder($auth['customer'], $externalOrderNo, $orderNo); + } catch (\InvalidArgumentException $e) { + return api_error($e->getMessage(), 422); + } catch (\RuntimeException $e) { + return api_error($e->getMessage(), 404); + } catch (\Throwable $e) { + return api_error('订单查询失败', 500, [ + 'detail' => $e->getMessage(), + ]); + } + + return api_success([ + 'order' => $order, + ]); + } +} diff --git a/server-api/app/functions.php b/server-api/app/functions.php new file mode 100644 index 0000000..361d271 --- /dev/null +++ b/server-api/app/functions.php @@ -0,0 +1,44 @@ + $code, + 'message' => $message, + 'data' => $data, + ]); + } +} + +if (!function_exists('api_error')) { + function api_error(string $message = 'error', int $code = 1, array $data = []): Response + { + return json([ + 'code' => $code, + 'message' => $message, + 'data' => $data, + ]); + } +} + +if (!function_exists('app_user')) { + function app_user(Request $request): ?array + { + $user = $request->appUser ?? null; + return is_array($user) ? $user : null; + } +} + +if (!function_exists('app_user_id')) { + function app_user_id(Request $request): int + { + return (int)(app_user($request)['id'] ?? 0); + } +} diff --git a/server-api/app/middleware/AdminAuthMiddleware.php b/server-api/app/middleware/AdminAuthMiddleware.php new file mode 100644 index 0000000..02bea6a --- /dev/null +++ b/server-api/app/middleware/AdminAuthMiddleware.php @@ -0,0 +1,69 @@ +path(); + if (!str_starts_with($path, '/api/admin')) { + return $handler($request); + } + + if (in_array($path, ['/api/admin/ping', '/api/admin/auth/login'], true)) { + return $handler($request); + } + + $authService = new AdminAuthService(); + $adminInfo = $authService->current($request); + if (!$adminInfo) { + return api_error('未登录或登录已过期', 401); + } + + $permissionCode = $this->permissionCode($path); + if ($permissionCode !== '' && !$authService->hasPermission($adminInfo, $permissionCode)) { + return api_error('无权访问该后台功能', 403); + } + + $request->setHeader('x-admin-id', (string)$adminInfo['id']); + $request->setHeader('x-admin-name', (string)$adminInfo['name']); + + return $handler($request); + } + + private function permissionCode(string $path): string + { + return match (true) { + str_starts_with($path, '/api/admin/dashboard') => 'dashboard.view', + str_starts_with($path, '/api/admin/orders'), + str_starts_with($path, '/api/admin/order/') => 'orders.manage', + str_starts_with($path, '/api/admin/appraisal-tasks'), + str_starts_with($path, '/api/admin/appraisal-task/') => 'appraisal_tasks.manage', + str_starts_with($path, '/api/admin/catalog/') => 'catalog.manage', + str_starts_with($path, '/api/admin/reports'), + str_starts_with($path, '/api/admin/report/') => 'reports.manage', + str_starts_with($path, '/api/admin/messages') => 'messages.manage', + str_starts_with($path, '/api/admin/tickets'), + str_starts_with($path, '/api/admin/ticket/') => 'tickets.manage', + str_starts_with($path, '/api/admin/users'), + str_starts_with($path, '/api/admin/user/') => 'users.manage', + str_starts_with($path, '/api/admin/customers'), + str_starts_with($path, '/api/admin/customer/') => 'customers.manage', + str_starts_with($path, '/api/admin/warehouses'), + str_starts_with($path, '/api/admin/warehouse/') => 'warehouses.manage', + str_starts_with($path, '/api/admin/material/') => 'materials.manage', + str_starts_with($path, '/api/admin/access/') => 'access.manage', + str_starts_with($path, '/api/admin/content/') => 'system.manage', + str_starts_with($path, '/api/admin/system-configs') => 'system.manage', + str_starts_with($path, '/api/admin/auth/me'), + str_starts_with($path, '/api/admin/auth/logout') => '', + default => '', + }; + } +} diff --git a/server-api/app/middleware/AppAuthMiddleware.php b/server-api/app/middleware/AppAuthMiddleware.php new file mode 100644 index 0000000..3f99bbd --- /dev/null +++ b/server-api/app/middleware/AppAuthMiddleware.php @@ -0,0 +1,57 @@ +path(); + if (!str_starts_with($path, '/api/app')) { + return $handler($request); + } + + if ($request->method() === 'OPTIONS') { + return $handler($request); + } + + $authService = new AppAuthService(); + $userInfo = $authService->current($request); + if ($userInfo) { + $request->appUser = $userInfo; + } + + if ($this->isPublicPath($path)) { + return $handler($request); + } + + if (!$userInfo) { + return api_error('未登录或登录已过期', 401); + } + + return $handler($request); + } + + private function isPublicPath(string $path): bool + { + return in_array($path, [ + '/api/app/home/index', + '/api/app/content/page-visuals', + '/api/app/catalog/brands', + '/api/app/help-center', + '/api/app/help-article/detail', + '/api/app/report/detail', + '/api/app/verify', + '/api/app/material-tag', + '/api/app/material-tag/verify', + '/api/app/auth/send-code', + '/api/app/auth/login/code', + '/api/app/auth/login/password', + ], true); + } +} diff --git a/server-api/app/middleware/CorsMiddleware.php b/server-api/app/middleware/CorsMiddleware.php new file mode 100644 index 0000000..e431ac9 --- /dev/null +++ b/server-api/app/middleware/CorsMiddleware.php @@ -0,0 +1,28 @@ + $request->header('origin', '*'), + 'Access-Control-Allow-Methods' => 'GET, POST, PUT, PATCH, DELETE, OPTIONS', + 'Access-Control-Allow-Headers' => 'Content-Type, Authorization, X-Requested-With, X-AXY-App-Key, X-AXY-Timestamp, X-AXY-Nonce, X-AXY-Signature', + 'Access-Control-Allow-Credentials' => 'true', + ]; + + if ($request->method() === 'OPTIONS') { + return response('', 204, $headers); + } + + /** @var Response $response */ + $response = $handler($request); + return $response->withHeaders($headers); + } +} diff --git a/server-api/app/middleware/StaticFile.php b/server-api/app/middleware/StaticFile.php new file mode 100644 index 0000000..fa8dbf7 --- /dev/null +++ b/server-api/app/middleware/StaticFile.php @@ -0,0 +1,42 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +namespace app\middleware; + +use Webman\MiddlewareInterface; +use Webman\Http\Response; +use Webman\Http\Request; + +/** + * Class StaticFile + * @package app\middleware + */ +class StaticFile implements MiddlewareInterface +{ + public function process(Request $request, callable $handler): Response + { + // Access to files beginning with. Is prohibited + if (strpos($request->path(), '/.') !== false) { + return response('

403 forbidden

', 403); + } + /** @var Response $response */ + $response = $handler($request); + // Add cross domain HTTP header + /*$response->withHeaders([ + 'Access-Control-Allow-Origin' => '*', + 'Access-Control-Allow-Credentials' => 'true', + ]);*/ + return $response; + } +} diff --git a/server-api/app/model/BaseModel.php b/server-api/app/model/BaseModel.php new file mode 100644 index 0000000..d0a2bf9 --- /dev/null +++ b/server-api/app/model/BaseModel.php @@ -0,0 +1,12 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +namespace app\process; + +use FilesystemIterator; +use RecursiveDirectoryIterator; +use RecursiveIteratorIterator; +use SplFileInfo; +use Workerman\Timer; +use Workerman\Worker; + +/** + * Class FileMonitor + * @package process + */ +class Monitor +{ + /** + * @var array + */ + protected array $paths = []; + + /** + * @var array + */ + protected array $extensions = []; + + /** + * @var array + */ + protected array $loadedFiles = []; + + /** + * @var int + */ + protected int $ppid = 0; + + /** + * Pause monitor + * @return void + */ + public static function pause(): void + { + file_put_contents(static::lockFile(), time()); + } + + /** + * Resume monitor + * @return void + */ + public static function resume(): void + { + clearstatcache(); + if (is_file(static::lockFile())) { + unlink(static::lockFile()); + } + } + + /** + * Whether monitor is paused + * @return bool + */ + public static function isPaused(): bool + { + clearstatcache(); + return file_exists(static::lockFile()); + } + + /** + * Lock file + * @return string + */ + protected static function lockFile(): string + { + return runtime_path('monitor.lock'); + } + + /** + * FileMonitor constructor. + * @param $monitorDir + * @param $monitorExtensions + * @param array $options + */ + public function __construct($monitorDir, $monitorExtensions, array $options = []) + { + $this->ppid = function_exists('posix_getppid') ? posix_getppid() : 0; + static::resume(); + $this->paths = (array)$monitorDir; + $this->extensions = $monitorExtensions; + foreach (get_included_files() as $index => $file) { + $this->loadedFiles[$file] = $index; + if (strpos($file, 'webman-framework/src/support/App.php')) { + break; + } + } + if (!Worker::getAllWorkers()) { + return; + } + $disableFunctions = explode(',', ini_get('disable_functions')); + if (in_array('exec', $disableFunctions, true)) { + echo "\nMonitor file change turned off because exec() has been disabled by disable_functions setting in " . PHP_CONFIG_FILE_PATH . "/php.ini\n"; + } else { + if ($options['enable_file_monitor'] ?? true) { + Timer::add(1, function () { + $this->checkAllFilesChange(); + }); + } + } + + $memoryLimit = $this->getMemoryLimit($options['memory_limit'] ?? null); + if ($memoryLimit && ($options['enable_memory_monitor'] ?? true)) { + Timer::add(60, [$this, 'checkMemory'], [$memoryLimit]); + } + } + + /** + * @param $monitorDir + * @return bool + */ + public function checkFilesChange($monitorDir): bool + { + static $lastMtime, $tooManyFilesCheck; + if (!$lastMtime) { + $lastMtime = time(); + } + clearstatcache(); + if (!is_dir($monitorDir)) { + if (!is_file($monitorDir)) { + return false; + } + $iterator = [new SplFileInfo($monitorDir)]; + } else { + // recursive traversal directory + $dirIterator = new RecursiveDirectoryIterator($monitorDir, FilesystemIterator::SKIP_DOTS | FilesystemIterator::FOLLOW_SYMLINKS); + $iterator = new RecursiveIteratorIterator($dirIterator); + } + $count = 0; + foreach ($iterator as $file) { + $count ++; + /** @var SplFileInfo $file */ + if (is_dir($file->getRealPath())) { + continue; + } + // check mtime + if (in_array($file->getExtension(), $this->extensions, true) && $lastMtime < $file->getMTime()) { + $lastMtime = $file->getMTime(); + if (DIRECTORY_SEPARATOR === '/' && isset($this->loadedFiles[$file->getRealPath()])) { + echo "$file updated but cannot be reloaded because only auto-loaded files support reload.\n"; + continue; + } + $var = 0; + exec('"'.PHP_BINARY . '" -l ' . $file, $out, $var); + if ($var) { + continue; + } + // send SIGUSR1 signal to master process for reload + if (DIRECTORY_SEPARATOR === '/') { + if ($masterPid = $this->getMasterPid()) { + echo $file . " updated and reload\n"; + posix_kill($masterPid, SIGUSR1); + } else { + echo "Master process has gone away and can not reload\n"; + } + return true; + } + echo $file . " updated and reload\n"; + return true; + } + } + if (!$tooManyFilesCheck && $count > 1000) { + echo "Monitor: There are too many files ($count files) in $monitorDir which makes file monitoring very slow\n"; + $tooManyFilesCheck = 1; + } + return false; + } + + /** + * @return int + */ + public function getMasterPid(): int + { + if ($this->ppid === 0) { + return 0; + } + if (function_exists('posix_kill') && !posix_kill($this->ppid, 0)) { + echo "Master process has gone away\n"; + return $this->ppid = 0; + } + if (PHP_OS_FAMILY !== 'Linux') { + return $this->ppid; + } + $cmdline = "/proc/$this->ppid/cmdline"; + if (!is_readable($cmdline) || !($content = file_get_contents($cmdline)) || (!str_contains($content, 'WorkerMan') && !str_contains($content, 'php'))) { + // Process not exist + $this->ppid = 0; + } + return $this->ppid; + } + + /** + * @return bool + */ + public function checkAllFilesChange(): bool + { + if (static::isPaused()) { + return false; + } + foreach ($this->paths as $path) { + if ($this->checkFilesChange($path)) { + return true; + } + } + return false; + } + + /** + * @param $memoryLimit + * @return void + */ + public function checkMemory($memoryLimit): void + { + if (static::isPaused() || $memoryLimit <= 0) { + return; + } + $masterPid = $this->getMasterPid(); + if ($masterPid <= 0) { + echo "Master process has gone away\n"; + return; + } + + $childrenFile = "/proc/$masterPid/task/$masterPid/children"; + if (!is_file($childrenFile) || !($children = file_get_contents($childrenFile))) { + return; + } + foreach (explode(' ', $children) as $pid) { + $pid = (int)$pid; + $statusFile = "/proc/$pid/status"; + if (!is_file($statusFile) || !($status = file_get_contents($statusFile))) { + continue; + } + $mem = 0; + if (preg_match('/VmRSS\s*?:\s*?(\d+?)\s*?kB/', $status, $match)) { + $mem = $match[1]; + } + $mem = (int)($mem / 1024); + if ($mem >= $memoryLimit) { + posix_kill($pid, SIGINT); + } + } + } + + /** + * Get memory limit + * @param $memoryLimit + * @return int + */ + protected function getMemoryLimit($memoryLimit): int + { + if ($memoryLimit === 0) { + return 0; + } + $usePhpIni = false; + if (!$memoryLimit) { + $memoryLimit = ini_get('memory_limit'); + $usePhpIni = true; + } + + if ($memoryLimit == -1) { + return 0; + } + $unit = strtolower($memoryLimit[strlen($memoryLimit) - 1]); + $memoryLimit = (int)$memoryLimit; + if ($unit === 'g') { + $memoryLimit = 1024 * $memoryLimit; + } else if ($unit === 'k') { + $memoryLimit = ($memoryLimit / 1024); + } else if ($unit === 'm') { + $memoryLimit = (int)($memoryLimit); + } else if ($unit === 't') { + $memoryLimit = (1024 * 1024 * $memoryLimit); + } else { + $memoryLimit = ($memoryLimit / (1024 * 1024)); + } + if ($memoryLimit < 50) { + $memoryLimit = 50; + } + if ($usePhpIni) { + $memoryLimit = (0.8 * $memoryLimit); + } + return (int)$memoryLimit; + } + +} diff --git a/server-api/app/support/AdminAccessService.php b/server-api/app/support/AdminAccessService.php new file mode 100644 index 0000000..fb146a1 --- /dev/null +++ b/server-api/app/support/AdminAccessService.php @@ -0,0 +1,253 @@ +syncPermissions(); + $superAdminRoleId = $this->ensureSuperAdminRole(); + $this->ensureDefaultOperationRoles(); + $this->ensureDefaultAdmin($superAdminRoleId); + } + + public function permissionDefinitions(): array + { + return [ + ['name' => '查看工作台', 'code' => 'dashboard.view', 'module' => 'dashboard', 'action' => 'view'], + ['name' => '管理订单', 'code' => 'orders.manage', 'module' => 'orders', 'action' => 'manage'], + ['name' => '管理鉴定任务', 'code' => 'appraisal_tasks.manage', 'module' => 'appraisal_tasks', 'action' => 'manage'], + ['name' => '管理商品资料', 'code' => 'catalog.manage', 'module' => 'catalog', 'action' => 'manage'], + ['name' => '管理报告', 'code' => 'reports.manage', 'module' => 'reports', 'action' => 'manage'], + ['name' => '管理消息', 'code' => 'messages.manage', 'module' => 'messages', 'action' => 'manage'], + ['name' => '管理工单', 'code' => 'tickets.manage', 'module' => 'tickets', 'action' => 'manage'], + ['name' => '管理用户', 'code' => 'users.manage', 'module' => 'users', 'action' => 'manage'], + ['name' => '管理客户', 'code' => 'customers.manage', 'module' => 'customers', 'action' => 'manage'], + ['name' => '管理仓库', 'code' => 'warehouses.manage', 'module' => 'warehouses', 'action' => 'manage'], + ['name' => '管理物料', 'code' => 'materials.manage', 'module' => 'materials', 'action' => 'manage'], + ['name' => '管理权限', 'code' => 'access.manage', 'module' => 'access', 'action' => 'manage'], + ['name' => '管理系统配置', 'code' => 'system.manage', 'module' => 'system_config', 'action' => 'manage'], + ]; + } + + public function moduleText(string $module): string + { + return match ($module) { + 'dashboard' => '工作台', + 'orders' => '订单中心', + 'appraisal_tasks' => '鉴定作业台', + 'catalog' => '商品资料中心', + 'reports' => '报告中心', + 'messages' => '消息中心', + 'tickets' => '客服与售后', + 'users' => '用户管理', + 'customers' => '客户管理', + 'warehouses' => '仓库中心', + 'materials' => '物料管理', + 'access' => '权限中心', + 'system_config' => '系统配置', + default => $module, + }; + } + + public function statusText(string $status): string + { + return match ($status) { + 'enabled' => '启用中', + 'disabled' => '已停用', + default => $status, + }; + } + + private function syncPermissions(): void + { + $now = date('Y-m-d H:i:s'); + foreach ($this->permissionDefinitions() as $item) { + $exists = Db::name('admin_permissions')->where('code', $item['code'])->find(); + $payload = [ + 'name' => $item['name'], + 'code' => $item['code'], + 'module' => $item['module'], + 'action' => $item['action'], + 'updated_at' => $now, + ]; + if ($exists) { + Db::name('admin_permissions')->where('id', $exists['id'])->update($payload); + } else { + try { + $payload['created_at'] = $now; + Db::name('admin_permissions')->insert($payload); + } catch (\Throwable $e) { + // Ignore duplicate insert caused by concurrent bootstrap. + } + } + } + } + + private function ensureSuperAdminRole(): int + { + $now = date('Y-m-d H:i:s'); + $role = Db::name('admin_roles')->where('code', 'super_admin')->find(); + + if ($role) { + Db::name('admin_roles')->where('id', $role['id'])->update([ + 'name' => '超级管理员', + 'status' => 'enabled', + 'updated_at' => $now, + ]); + $roleId = (int)$role['id']; + } else { + $roleId = (int)Db::name('admin_roles')->insertGetId([ + 'name' => '超级管理员', + 'code' => 'super_admin', + 'status' => 'enabled', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + $permissionIds = Db::name('admin_permissions')->column('id'); + foreach ($permissionIds as $permissionId) { + $exists = Db::name('admin_role_permissions') + ->where('role_id', $roleId) + ->where('permission_id', $permissionId) + ->find(); + if (!$exists) { + try { + Db::name('admin_role_permissions')->insert([ + 'role_id' => $roleId, + 'permission_id' => $permissionId, + 'created_at' => $now, + ]); + } catch (\Throwable $e) { + // Ignore duplicate insert caused by concurrent bootstrap. + } + } + } + + return $roleId; + } + + private function ensureDefaultOperationRoles(): void + { + $this->ensureRoleWithPermissions('appraiser', '鉴定师', [ + 'dashboard.view', + 'appraisal_tasks.manage', + 'reports.manage', + ]); + + $this->ensureRoleWithPermissions('reviewer', '报告管理员', [ + 'dashboard.view', + 'appraisal_tasks.manage', + 'reports.manage', + ]); + + $this->ensureRoleWithPermissions('material_manager', '物料管理员', [ + 'dashboard.view', + 'materials.manage', + ]); + } + + private function ensureRoleWithPermissions(string $code, string $name, array $permissionCodes): int + { + $now = date('Y-m-d H:i:s'); + $role = Db::name('admin_roles')->where('code', $code)->find(); + + if ($role) { + Db::name('admin_roles')->where('id', $role['id'])->update([ + 'name' => $name, + 'status' => 'enabled', + 'updated_at' => $now, + ]); + $roleId = (int)$role['id']; + } else { + $roleId = (int)Db::name('admin_roles')->insertGetId([ + 'name' => $name, + 'code' => $code, + 'status' => 'enabled', + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + $permissionIds = Db::name('admin_permissions') + ->whereIn('code', $permissionCodes) + ->column('id'); + + $permissionIds = array_map('intval', $permissionIds); + $existingPermissionIds = array_map( + 'intval', + Db::name('admin_role_permissions')->where('role_id', $roleId)->column('permission_id') + ); + + $obsoletePermissionIds = array_values(array_diff($existingPermissionIds, $permissionIds)); + if ($obsoletePermissionIds) { + Db::name('admin_role_permissions') + ->where('role_id', $roleId) + ->whereIn('permission_id', $obsoletePermissionIds) + ->delete(); + } + + $missingPermissionIds = array_values(array_diff($permissionIds, $existingPermissionIds)); + foreach ($missingPermissionIds as $permissionId) { + try { + Db::name('admin_role_permissions')->insert([ + 'role_id' => $roleId, + 'permission_id' => (int)$permissionId, + 'created_at' => $now, + ]); + } catch (\Throwable $e) { + // Ignore duplicate insert caused by concurrent bootstrap. + } + } + + return $roleId; + } + + private function ensureDefaultAdmin(int $superAdminRoleId): void + { + $now = date('Y-m-d H:i:s'); + $admin = Db::name('admin_users')->order('id', 'asc')->find(); + $defaultPasswordHash = password_hash('Admin@123456', PASSWORD_BCRYPT); + + if ($admin) { + if (($admin['password'] ?? '') === '' || ($admin['password'] ?? '') === 'not-used') { + Db::name('admin_users')->where('id', $admin['id'])->update([ + 'password' => $defaultPasswordHash, + 'updated_at' => $now, + ]); + } + $adminId = (int)$admin['id']; + } else { + $adminId = (int)Db::name('admin_users')->insertGetId([ + 'name' => '系统管理员', + 'mobile' => '13800138000', + 'email' => 'admin@anxinyan.local', + 'password' => $defaultPasswordHash, + 'status' => 'enabled', + 'last_login_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + $relation = Db::name('admin_role_relations') + ->where('admin_user_id', $adminId) + ->where('role_id', $superAdminRoleId) + ->find(); + if (!$relation) { + try { + Db::name('admin_role_relations')->insert([ + 'admin_user_id' => $adminId, + 'role_id' => $superAdminRoleId, + 'created_at' => $now, + ]); + } catch (\Throwable $e) { + // Ignore duplicate insert caused by concurrent bootstrap. + } + } + } +} diff --git a/server-api/app/support/AdminAuthService.php b/server-api/app/support/AdminAuthService.php new file mode 100644 index 0000000..d144e3c --- /dev/null +++ b/server-api/app/support/AdminAuthService.php @@ -0,0 +1,160 @@ +ensureTokenTable(); + (new AdminAccessService())->bootstrapDefaults(); + } + + public function login(string $mobile, string $password, Request $request): array + { + $admin = Db::name('admin_users')->where('mobile', $mobile)->find(); + if (!$admin || ($admin['status'] ?? 'enabled') !== 'enabled') { + throw new \RuntimeException('账号不存在或已停用'); + } + + if (!password_verify($password, (string)$admin['password'])) { + throw new \RuntimeException('手机号或密码错误'); + } + + $token = bin2hex(random_bytes(24)); + $tokenHash = hash('sha256', $token); + $now = date('Y-m-d H:i:s'); + $expireTime = date('Y-m-d H:i:s', time() + 7 * 24 * 3600); + + // Allow concurrent logins across devices/browsers. Only clean up this user's expired tokens. + Db::name('admin_api_tokens') + ->where('admin_user_id', $admin['id']) + ->where('expire_time', '<', $now) + ->delete(); + Db::name('admin_api_tokens')->insert([ + 'admin_user_id' => (int)$admin['id'], + 'token_hash' => $tokenHash, + 'expire_time' => $expireTime, + 'last_active_at' => $now, + 'last_ip' => $request->getRealIp(), + 'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('admin_users')->where('id', $admin['id'])->update([ + 'last_login_at' => $now, + 'updated_at' => $now, + ]); + + return [ + 'token' => $token, + 'admin_info' => $this->adminInfo((int)$admin['id']), + ]; + } + + public function logout(Request $request): void + { + $token = $this->extractToken($request); + if ($token === '') { + return; + } + + Db::name('admin_api_tokens')->where('token_hash', hash('sha256', $token))->delete(); + } + + public function current(Request $request): ?array + { + $token = $this->extractToken($request); + if ($token === '') { + return null; + } + + $record = Db::name('admin_api_tokens') + ->where('token_hash', hash('sha256', $token)) + ->find(); + if (!$record) { + return null; + } + + if (!empty($record['expire_time']) && strtotime((string)$record['expire_time']) < time()) { + Db::name('admin_api_tokens')->where('id', $record['id'])->delete(); + return null; + } + + $admin = Db::name('admin_users')->where('id', $record['admin_user_id'])->find(); + if (!$admin || ($admin['status'] ?? 'enabled') !== 'enabled') { + return null; + } + + Db::name('admin_api_tokens')->where('id', $record['id'])->update([ + 'last_active_at' => date('Y-m-d H:i:s'), + 'last_ip' => $request->getRealIp(), + 'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + return $this->adminInfo((int)$admin['id']); + } + + public function hasPermission(array $adminInfo, string $permissionCode): bool + { + if ($permissionCode === '') { + return true; + } + + return in_array($permissionCode, $adminInfo['permission_codes'], true); + } + + private function adminInfo(int $adminUserId): array + { + $admin = Db::name('admin_users')->where('id', $adminUserId)->find(); + $roleIds = Db::name('admin_role_relations')->where('admin_user_id', $adminUserId)->column('role_id'); + $roles = $roleIds ? Db::name('admin_roles')->whereIn('id', $roleIds)->select()->toArray() : []; + $permissionIds = $roleIds ? Db::name('admin_role_permissions')->whereIn('role_id', $roleIds)->column('permission_id') : []; + $permissions = $permissionIds ? Db::name('admin_permissions')->whereIn('id', $permissionIds)->select()->toArray() : []; + + return [ + 'id' => (int)($admin['id'] ?? 0), + 'name' => $admin['name'] ?? '', + 'mobile' => $admin['mobile'] ?? '', + 'email' => $admin['email'] ?? '', + 'status' => $admin['status'] ?? 'enabled', + 'role_names' => array_values(array_map(fn (array $item) => $item['name'], $roles)), + 'permission_codes' => array_values(array_unique(array_map(fn (array $item) => $item['code'], $permissions))), + ]; + } + + private function extractToken(Request $request): string + { + $authorization = trim((string)$request->header('authorization', '')); + if (preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)) { + return trim($matches[1]); + } + return ''; + } + + private function ensureTokenTable(): void + { + Db::execute(<<<'SQL' +CREATE TABLE IF NOT EXISTS admin_api_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + admin_user_id BIGINT UNSIGNED NOT NULL, + token_hash VARCHAR(64) NOT NULL, + expire_time DATETIME NOT NULL, + last_active_at DATETIME NULL DEFAULT NULL, + last_ip VARCHAR(64) NOT NULL DEFAULT '', + user_agent VARCHAR(500) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_admin_api_tokens_token_hash (token_hash), + KEY idx_admin_api_tokens_admin_user_id (admin_user_id), + KEY idx_admin_api_tokens_expire_time (expire_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台登录Token'; +SQL); + } +} diff --git a/server-api/app/support/AppAuthService.php b/server-api/app/support/AppAuthService.php new file mode 100644 index 0000000..3e234c8 --- /dev/null +++ b/server-api/app/support/AppAuthService.php @@ -0,0 +1,441 @@ +ensurePasswordColumn(); + $this->ensureTokenTable(); + $this->ensureSmsCodeTable(); + } + + public function sendLoginCode(string $mobile, Request $request): array + { + $mobile = $this->normalizeMobile($mobile); + $user = Db::name('users')->where('mobile', $mobile)->find(); + if ($user && ($user['status'] ?? 'enabled') !== 'enabled') { + throw new \RuntimeException('账号已停用,无法发送验证码'); + } + + $scene = 'login'; + $now = time(); + $latest = Db::name('sms_code_logs') + ->where('mobile', $mobile) + ->where('scene', $scene) + ->order('id', 'desc') + ->find(); + + if ($latest) { + $retryAt = strtotime((string)$latest['created_at']) + 60; + if ($retryAt > $now) { + throw new \RuntimeException(sprintf('请 %d 秒后再试', max(1, $retryAt - $now))); + } + } + + $todayStart = date('Y-m-d 00:00:00'); + $todayCount = (int)Db::name('sms_code_logs') + ->where('mobile', $mobile) + ->where('scene', $scene) + ->where('created_at', '>=', $todayStart) + ->count(); + if ($todayCount >= 20) { + throw new \RuntimeException('今日验证码发送次数已达上限,请明天再试'); + } + + $code = (string)random_int(100000, 999999); + $nowText = date('Y-m-d H:i:s', $now); + $expireTime = date('Y-m-d H:i:s', $now + 300); + + $sendResult = null; + $sendStatus = 'failed'; + $failedReason = ''; + try { + $sendResult = (new AppSmsService())->sendLoginCode($mobile, $code); + $sendStatus = ($sendResult['provider'] ?? '') === 'debug' ? 'mock' : 'success'; + } catch (\Throwable $e) { + $failedReason = $this->truncateText($e->getMessage(), 250); + } + + Db::name('sms_code_logs')->insert([ + 'mobile' => $mobile, + 'scene' => $scene, + 'code_hash' => $this->codeHash($mobile, $scene, $code), + 'send_status' => $sendStatus, + 'provider' => $sendResult['provider'] ?? 'aliyun_sms', + 'template_code' => $this->systemConfig('sms', 'login_template_code'), + 'request_id' => $sendResult['request_id'] ?? '', + 'biz_id' => $sendResult['biz_id'] ?? '', + 'failed_reason' => $failedReason, + 'expire_time' => $expireTime, + 'used_at' => null, + 'send_ip' => $request->getRealIp(), + 'created_at' => $nowText, + 'updated_at' => $nowText, + ]); + + if ($sendStatus === 'failed') { + throw new \RuntimeException($failedReason ?: '验证码发送失败'); + } + + $payload = [ + 'mobile' => $mobile, + 'scene' => $scene, + 'expire_seconds' => 300, + 'retry_after_seconds' => 60, + ]; + if (($sendResult['debug_code'] ?? null) !== null) { + $payload['debug_code'] = $sendResult['debug_code']; + } + + return $payload; + } + + public function loginByCode(string $mobile, string $code, Request $request): array + { + $mobile = $this->normalizeMobile($mobile); + $code = trim($code); + if (!preg_match('/^\d{6}$/', $code)) { + throw new \RuntimeException('验证码格式不正确'); + } + + $record = Db::name('sms_code_logs') + ->where('mobile', $mobile) + ->where('scene', 'login') + ->whereIn('send_status', ['success', 'mock']) + ->whereNull('used_at') + ->order('id', 'desc') + ->find(); + if (!$record) { + throw new \RuntimeException('验证码不存在或已失效'); + } + + if (strtotime((string)$record['expire_time']) < time()) { + throw new \RuntimeException('验证码已过期,请重新获取'); + } + + if (!hash_equals((string)$record['code_hash'], $this->codeHash($mobile, 'login', $code))) { + throw new \RuntimeException('验证码错误'); + } + + $now = date('Y-m-d H:i:s'); + Db::name('sms_code_logs')->where('id', $record['id'])->update([ + 'used_at' => $now, + 'updated_at' => $now, + ]); + + $user = Db::name('users')->where('mobile', $mobile)->find(); + if ($user && ($user['status'] ?? 'enabled') !== 'enabled') { + throw new \RuntimeException('账号已停用'); + } + + if (!$user) { + $userId = (int)Db::name('users')->insertGetId([ + 'nickname' => '安心验用户' . substr($mobile, -4), + 'avatar' => '', + 'mobile' => $mobile, + 'password' => '', + 'status' => 'enabled', + 'last_login_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } else { + $userId = (int)$user['id']; + } + + $this->syncMobileAuth($userId, $mobile, $now); + return $this->issueToken($userId, $request, 'sms_code'); + } + + public function loginByPassword(string $mobile, string $password, Request $request): array + { + $mobile = $this->normalizeMobile($mobile); + $password = trim($password); + if ($password === '') { + throw new \RuntimeException('密码不能为空'); + } + + $user = Db::name('users')->where('mobile', $mobile)->find(); + if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') { + throw new \RuntimeException('账号不存在或已停用'); + } + + $passwordHash = (string)($user['password'] ?? ''); + if ($passwordHash === '') { + throw new \RuntimeException('当前账号尚未设置登录密码,请使用验证码登录'); + } + + if (!password_verify($password, $passwordHash)) { + throw new \RuntimeException('手机号或密码错误'); + } + + $this->syncMobileAuth((int)$user['id'], $mobile, date('Y-m-d H:i:s')); + return $this->issueToken((int)$user['id'], $request, 'password'); + } + + public function logout(Request $request): void + { + $token = $this->extractToken($request); + if ($token === '') { + return; + } + + Db::name('user_api_tokens')->where('token_hash', hash('sha256', $token))->delete(); + } + + public function current(Request $request): ?array + { + $token = $this->extractToken($request); + if ($token === '') { + return null; + } + + $record = Db::name('user_api_tokens') + ->where('token_hash', hash('sha256', $token)) + ->find(); + if (!$record) { + return null; + } + + if (!empty($record['expire_time']) && strtotime((string)$record['expire_time']) < time()) { + Db::name('user_api_tokens')->where('id', $record['id'])->delete(); + return null; + } + + $user = Db::name('users')->where('id', $record['user_id'])->find(); + if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') { + return null; + } + + Db::name('user_api_tokens')->where('id', $record['id'])->update([ + 'last_active_at' => date('Y-m-d H:i:s'), + 'last_ip' => $request->getRealIp(), + 'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + return $this->userInfo((int)$user['id']); + } + + public function savePassword(int $userId, string $currentPassword, string $newPassword): array + { + $user = Db::name('users')->where('id', $userId)->find(); + if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') { + throw new \RuntimeException('账号不存在或已停用'); + } + + $currentHash = (string)($user['password'] ?? ''); + $hadPassword = $currentHash !== ''; + if ($currentHash !== '') { + if ($currentPassword === '') { + throw new \RuntimeException('请输入当前密码'); + } + if (!password_verify($currentPassword, $currentHash)) { + throw new \RuntimeException('当前密码错误'); + } + } + + $this->validateNewPassword($newPassword); + + Db::name('users')->where('id', $userId)->update([ + 'password' => password_hash($newPassword, PASSWORD_BCRYPT), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + return [ + 'user_id' => $userId, + 'password_set' => true, + 'had_password' => $hadPassword, + ]; + } + + private function issueToken(int $userId, Request $request, string $authType): array + { + $user = Db::name('users')->where('id', $userId)->find(); + if (!$user || ($user['status'] ?? 'enabled') !== 'enabled') { + throw new \RuntimeException('账号不存在或已停用'); + } + + $token = bin2hex(random_bytes(24)); + $tokenHash = hash('sha256', $token); + $now = date('Y-m-d H:i:s'); + $expireTime = date('Y-m-d H:i:s', time() + 30 * 24 * 3600); + + Db::name('user_api_tokens')->where('user_id', $userId)->delete(); + Db::name('user_api_tokens')->insert([ + 'user_id' => $userId, + 'token_hash' => $tokenHash, + 'auth_type' => $authType, + 'expire_time' => $expireTime, + 'last_active_at' => $now, + 'last_ip' => $request->getRealIp(), + 'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('users')->where('id', $userId)->update([ + 'last_login_at' => $now, + 'updated_at' => $now, + ]); + + return [ + 'token' => $token, + 'user_info' => $this->userInfo($userId), + ]; + } + + private function userInfo(int $userId): array + { + $user = Db::name('users')->where('id', $userId)->find(); + return [ + 'id' => (int)($user['id'] ?? 0), + 'nickname' => $user['nickname'] ?: '安心验用户', + 'mobile' => $user['mobile'] ?? '', + 'avatar' => $user['avatar'] ?? '', + 'status' => $user['status'] ?? 'enabled', + 'password_set' => ((string)($user['password'] ?? '')) !== '', + ]; + } + + private function syncMobileAuth(int $userId, string $mobile, string $now): void + { + $existing = Db::name('user_auths') + ->where('auth_type', 'mobile') + ->where('auth_key', $mobile) + ->find(); + + $payload = [ + 'user_id' => $userId, + 'auth_type' => 'mobile', + 'auth_key' => $mobile, + 'auth_open_id' => '', + 'auth_union_id' => '', + 'auth_extra' => json_encode(['mobile' => $mobile], JSON_UNESCAPED_UNICODE), + 'updated_at' => $now, + ]; + + if ($existing) { + Db::name('user_auths')->where('id', $existing['id'])->update($payload); + return; + } + + $payload['created_at'] = $now; + Db::name('user_auths')->insert($payload); + } + + private function normalizeMobile(string $mobile): string + { + $mobile = preg_replace('/\D+/', '', $mobile) ?: ''; + if (!preg_match('/^1\d{10}$/', $mobile)) { + throw new \RuntimeException('请输入正确的手机号'); + } + return $mobile; + } + + private function validateNewPassword(string $password): void + { + if (mb_strlen($password) < 8) { + throw new \RuntimeException('密码长度不能少于 8 位'); + } + if (!preg_match('/[A-Za-z]/', $password) || !preg_match('/\d/', $password)) { + throw new \RuntimeException('密码需同时包含字母和数字'); + } + } + + private function codeHash(string $mobile, string $scene, string $code): string + { + return hash('sha256', implode('|', [$mobile, $scene, $code])); + } + + private function systemConfig(string $groupCode, string $configKey): string + { + $row = Db::name('system_configs') + ->where('config_group', $groupCode) + ->where('config_key', $configKey) + ->find(); + + return trim((string)($row['config_value'] ?? '')); + } + + private function extractToken(Request $request): string + { + $authorization = trim((string)$request->header('authorization', '')); + if (preg_match('/^Bearer\s+(.+)$/i', $authorization, $matches)) { + return trim($matches[1]); + } + return ''; + } + + private function ensurePasswordColumn(): void + { + $column = Db::query("SHOW COLUMNS FROM users LIKE 'password'"); + if ($column) { + return; + } + + Db::execute("ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL DEFAULT '' AFTER mobile"); + } + + private function ensureTokenTable(): void + { + Db::execute(<<<'SQL' +CREATE TABLE IF NOT EXISTS user_api_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + token_hash VARCHAR(64) NOT NULL, + auth_type VARCHAR(32) NOT NULL DEFAULT 'password', + expire_time DATETIME NOT NULL, + last_active_at DATETIME NULL DEFAULT NULL, + last_ip VARCHAR(64) NOT NULL DEFAULT '', + user_agent VARCHAR(500) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_user_api_tokens_token_hash (token_hash), + KEY idx_user_api_tokens_user_id (user_id), + KEY idx_user_api_tokens_expire_time (expire_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户登录Token'; +SQL); + } + + private function ensureSmsCodeTable(): void + { + Db::execute(<<<'SQL' +CREATE TABLE IF NOT EXISTS sms_code_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + mobile VARCHAR(32) NOT NULL, + scene VARCHAR(32) NOT NULL DEFAULT 'login', + code_hash VARCHAR(64) NOT NULL, + send_status VARCHAR(32) NOT NULL DEFAULT 'success', + provider VARCHAR(32) NOT NULL DEFAULT 'aliyun_sms', + template_code VARCHAR(64) NOT NULL DEFAULT '', + request_id VARCHAR(128) NOT NULL DEFAULT '', + biz_id VARCHAR(128) NOT NULL DEFAULT '', + failed_reason VARCHAR(255) NOT NULL DEFAULT '', + expire_time DATETIME NOT NULL, + used_at DATETIME NULL DEFAULT NULL, + send_ip VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_sms_code_logs_mobile_scene (mobile, scene), + KEY idx_sms_code_logs_expire_time (expire_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='短信验证码发送记录'; +SQL); + } + + private function truncateText(string $value, int $maxLength): string + { + if (mb_strlen($value) <= $maxLength) { + return $value; + } + + return mb_substr($value, 0, $maxLength); + } +} diff --git a/server-api/app/support/AppSmsService.php b/server-api/app/support/AppSmsService.php new file mode 100644 index 0000000..1f25a81 --- /dev/null +++ b/server-api/app/support/AppSmsService.php @@ -0,0 +1,125 @@ +bootstrapCaBundle(); + $config = $this->config(); + if (!$this->isConfigured($config)) { + if ($this->isDebugMode()) { + return [ + 'provider' => 'debug', + 'request_id' => 'debug', + 'biz_id' => 'debug', + 'raw_body' => [ + 'Code' => 'OK', + 'Message' => 'DEBUG_SMS_BYPASS', + ], + 'debug_code' => $code, + ]; + } + + throw new \RuntimeException('短信配置未完成,请先在后台系统配置中填写阿里云短信参数'); + } + + $client = new Dysmsapi(new Config([ + 'accessKeyId' => $config['access_key_id'], + 'accessKeySecret' => $config['access_key_secret'], + 'regionId' => $config['region_id'] ?: 'cn-hangzhou', + 'endpoint' => $config['endpoint'] ?: null, + ])); + + $response = $client->sendSms(new SendSmsRequest([ + 'phoneNumbers' => $mobile, + 'signName' => $config['sign_name'], + 'templateCode' => $config['login_template_code'], + 'templateParam' => json_encode(['code' => $code], JSON_UNESCAPED_UNICODE), + ])); + + $body = $response->body ? $response->body->toMap() : []; + $responseCode = (string)($body['Code'] ?? ''); + if ($responseCode !== 'OK') { + throw new \RuntimeException((string)($body['Message'] ?? '短信发送失败')); + } + + return [ + 'provider' => 'aliyun_sms', + 'request_id' => (string)($body['RequestId'] ?? ''), + 'biz_id' => (string)($body['BizId'] ?? ''), + 'raw_body' => $body, + 'debug_code' => null, + ]; + } + + private function config(): array + { + $rows = Db::name('system_configs') + ->where('config_group', 'sms') + ->select() + ->toArray(); + + $map = []; + foreach ($rows as $row) { + $map[$row['config_key']] = trim((string)($row['config_value'] ?? '')); + } + + return [ + 'access_key_id' => $map['access_key_id'] ?? '', + 'access_key_secret' => $map['access_key_secret'] ?? '', + 'sign_name' => $map['sign_name'] ?? '', + 'login_template_code' => $map['login_template_code'] ?? '', + 'region_id' => $map['region_id'] ?? 'cn-hangzhou', + 'endpoint' => $map['endpoint'] ?? '', + ]; + } + + private function isConfigured(array $config): bool + { + return $config['access_key_id'] !== '' + && $config['access_key_secret'] !== '' + && $config['sign_name'] !== '' + && $config['login_template_code'] !== ''; + } + + private function isDebugMode(): bool + { + return in_array(strtolower((string)($_ENV['APP_DEBUG'] ?? 'false')), ['1', 'true'], true); + } + + private function bootstrapCaBundle(): void + { + if (ini_get('curl.cainfo') || ini_get('openssl.cafile')) { + return; + } + + foreach ($this->candidateCaFiles() as $path) { + if (!is_file($path)) { + continue; + } + + ini_set('curl.cainfo', $path); + ini_set('openssl.cafile', $path); + putenv('CURL_CA_BUNDLE=' . $path); + putenv('SSL_CERT_FILE=' . $path); + return; + } + } + + private function candidateCaFiles(): array + { + return [ + '/etc/ssl/cert.pem', + '/private/etc/ssl/cert.pem', + '/opt/homebrew/etc/openssl@3/cert.pem', + '/usr/local/etc/openssl@3/cert.pem', + ]; + } +} diff --git a/server-api/app/support/AppraisalEvidenceService.php b/server-api/app/support/AppraisalEvidenceService.php new file mode 100644 index 0000000..a28a35a --- /dev/null +++ b/server-api/app/support/AppraisalEvidenceService.php @@ -0,0 +1,141 @@ +file($inputName); + if (!$file || !$file->isValid()) { + throw new \RuntimeException('上传文件无效'); + } + + $extension = strtolower($file->getUploadExtension() ?: ''); + $fileType = $this->detectFileType($extension); + if ($fileType === 'file') { + throw new \RuntimeException('仅支持上传图片、视频或 PDF 文件'); + } + + $filename = sprintf('evidence_%s.%s', uniqid(), $extension ?: 'dat'); + $relativeDir = 'uploads/appraisal-evidence/' . date('Ymd'); + $relativePath = $relativeDir . '/' . $filename; + $this->storage()->putUploadedFile($file, $relativePath); + + $fileUrl = $this->storage()->publicUrl($request, $relativePath); + + return [ + 'file_id' => md5($relativePath), + 'file_url' => $fileUrl, + 'thumbnail_url' => $fileType === 'image' ? $fileUrl : '', + 'name' => $file->getUploadName(), + 'file_type' => $fileType, + 'mime_type' => $this->mimeType($fileType, $extension), + ]; + } + + public function delete(string $fileUrl): void + { + $relativePath = $this->storage()->storagePath($fileUrl); + if (!str_starts_with($relativePath, 'uploads/appraisal-evidence/')) { + return; + } + + $this->storage()->delete($relativePath); + } + + public function normalize(mixed $attachments, ?Request $request = null, bool $forStorage = false): array + { + if (is_string($attachments) && $attachments !== '') { + $decoded = json_decode($attachments, true); + $attachments = is_array($decoded) ? $decoded : []; + } + + if (!is_array($attachments)) { + return []; + } + + $list = []; + foreach ($attachments as $item) { + if (!is_array($item)) { + continue; + } + + $fileUrl = trim((string)($item['file_url'] ?? '')); + if ($fileUrl === '') { + continue; + } + + $name = trim((string)($item['name'] ?? '')); + $mimeType = trim((string)($item['mime_type'] ?? '')); + $fileType = trim((string)($item['file_type'] ?? '')); + + if ($fileType === '') { + $path = parse_url('/' . $this->storage()->storagePath($fileUrl), PHP_URL_PATH) ?: ''; + $extension = strtolower(pathinfo($path, PATHINFO_EXTENSION)); + $fileType = $this->detectFileType($extension); + if ($mimeType === '') { + $mimeType = $this->mimeType($fileType, $extension); + } + } + + $storedFileUrl = $this->storage()->storagePath($fileUrl); + $storedThumbnailUrl = $this->storage()->storagePath(trim((string)($item['thumbnail_url'] ?? ($fileType === 'image' ? $fileUrl : '')))); + + $list[] = [ + 'file_id' => trim((string)($item['file_id'] ?? md5($storedFileUrl))), + 'file_url' => $forStorage + ? '/' . $storedFileUrl + : ($request ? $this->storage()->normalizeUrl($fileUrl, $request) : $fileUrl), + 'thumbnail_url' => $forStorage + ? ($storedThumbnailUrl !== '' ? '/' . $storedThumbnailUrl : '') + : ($request + ? $this->storage()->normalizeUrl(trim((string)($item['thumbnail_url'] ?? ($fileType === 'image' ? $fileUrl : ''))), $request) + : trim((string)($item['thumbnail_url'] ?? ($fileType === 'image' ? $fileUrl : '')))), + 'name' => $name, + 'file_type' => $fileType ?: 'file', + 'mime_type' => $mimeType, + ]; + } + + return $list; + } + + public function detectFileType(string $extension): string + { + if (in_array($extension, self::IMAGE_EXTENSIONS, true)) { + return 'image'; + } + if (in_array($extension, self::VIDEO_EXTENSIONS, true)) { + return 'video'; + } + if (in_array($extension, self::PDF_EXTENSIONS, true)) { + return 'pdf'; + } + return 'file'; + } + + private function mimeType(string $fileType, string $extension): string + { + return match ($fileType) { + 'image' => 'image/' . ($extension === 'jpg' ? 'jpeg' : ($extension ?: 'jpeg')), + 'video' => 'video/' . ($extension ?: 'mp4'), + 'pdf' => 'application/pdf', + default => 'application/octet-stream', + }; + } + + private function storage(): FileStorageService + { + return new FileStorageService(); + } +} diff --git a/server-api/app/support/CatalogTemplateSampleImageService.php b/server-api/app/support/CatalogTemplateSampleImageService.php new file mode 100644 index 0000000..7d825b6 --- /dev/null +++ b/server-api/app/support/CatalogTemplateSampleImageService.php @@ -0,0 +1,64 @@ +file($inputName); + if (!$file || !$file->isValid()) { + throw new \RuntimeException('上传文件无效'); + } + + $extension = strtolower($file->getUploadExtension() ?: 'jpg'); + if (!in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) { + throw new \RuntimeException('仅支持上传 jpg、jpeg、png、webp 图片'); + } + + $filename = sprintf('upload_template_sample_%s.%s', uniqid(), $extension); + $relativeDir = 'uploads/upload-template-samples/' . date('Ymd'); + $relativePath = $relativeDir . '/' . $filename; + $this->storage()->putUploadedFile($file, $relativePath); + + $fileUrl = $this->storage()->publicUrl($request, $relativePath); + + return [ + 'file_id' => md5($relativePath), + 'file_url' => $fileUrl, + 'thumbnail_url' => $fileUrl, + 'name' => $file->getUploadName(), + ]; + } + + public function delete(string $fileUrl): void + { + $relativePath = $this->storage()->storagePath($fileUrl); + if (!str_starts_with($relativePath, 'uploads/upload-template-samples/')) { + return; + } + + $this->storage()->delete($relativePath); + } + + public function normalizeUrl(string $fileUrl, Request $request): string + { + return $this->storage()->normalizeUrl($fileUrl, $request); + } + + public function storagePath(string $fileUrl): string + { + return $this->storage()->storagePath($fileUrl); + } + + private function storage(): FileStorageService + { + return new FileStorageService(); + } +} diff --git a/server-api/app/support/ContentImageService.php b/server-api/app/support/ContentImageService.php new file mode 100644 index 0000000..84bd68a --- /dev/null +++ b/server-api/app/support/ContentImageService.php @@ -0,0 +1,43 @@ +file($inputName); + if (!$file || !$file->isValid()) { + throw new \RuntimeException('上传文件无效'); + } + + $extension = strtolower($file->getUploadExtension() ?: 'jpg'); + if (!in_array($extension, ['jpg', 'jpeg', 'png', 'webp'], true)) { + throw new \RuntimeException('仅支持上传 jpg、jpeg、png、webp 图片'); + } + + $filename = sprintf('content_image_%s.%s', uniqid(), $extension); + $relativeDir = 'uploads/content-images/' . date('Ymd'); + $relativePath = $relativeDir . '/' . $filename; + $this->storage()->putUploadedFile($file, $relativePath); + + $fileUrl = $this->storage()->publicUrl($request, $relativePath); + + return [ + 'file_id' => md5($relativePath), + 'file_url' => $fileUrl, + 'thumbnail_url' => $fileUrl, + 'name' => $file->getUploadName(), + ]; + } + + private function storage(): FileStorageService + { + return new FileStorageService(); + } +} diff --git a/server-api/app/support/ContentService.php b/server-api/app/support/ContentService.php new file mode 100644 index 0000000..0d273ad --- /dev/null +++ b/server-api/app/support/ContentService.php @@ -0,0 +1,1109 @@ +ensureConfigDefaults(); + $this->ensureHelpArticlesTable(); + $this->seedHelpArticles(); + $this->ensurePolicyHelpArticles(); + self::$bootstrapped = true; + } + + public function getHomeConfig(): array + { + $this->bootstrapDefaults(); + + $defaults = $this->defaultHomeConfig(); + $configMap = Db::name('system_configs') + ->where('config_group', self::HOME_GROUP) + ->column('config_value', 'config_key'); + + return [ + 'banners' => $this->decodeJsonConfig($configMap['banners_json'] ?? '', $defaults['banners']), + 'page_visuals' => $this->normalizeObjectConfig( + $this->decodeJsonObjectConfig($configMap['page_visuals_json'] ?? '', $defaults['page_visuals']), + ['order_background_image_url', 'report_background_image_url'], + $defaults['page_visuals'] + ), + 'service_entries' => $this->decodeJsonConfig($configMap['service_entries_json'] ?? '', $defaults['service_entries']), + 'category_visuals' => $this->decodeJsonConfig($configMap['category_visuals_json'] ?? '', $defaults['category_visuals']), + 'quick_entries' => $this->decodeJsonConfig($configMap['quick_entries_json'] ?? '', $defaults['quick_entries']), + 'trust_metrics' => $this->decodeJsonConfig($configMap['trust_metrics_json'] ?? '', $defaults['trust_metrics']), + 'trust_points' => $this->decodeJsonConfig($configMap['trust_points_json'] ?? '', $defaults['trust_points']), + 'faqs' => $this->decodeJsonConfig($configMap['faqs_json'] ?? '', $defaults['faqs']), + ]; + } + + public function getPolicyConfig(): array + { + $this->bootstrapDefaults(); + + $defaults = $this->defaultPolicyConfig(); + $configMap = Db::name('system_configs') + ->where('config_group', self::POLICY_GROUP) + ->column('config_value', 'config_key'); + + return [ + 'legal_entries' => $this->hydratePolicyItems( + $this->decodeJsonConfig($configMap['legal_entries_json'] ?? '', $defaults['legal_entries']) + ), + 'appraisal_agreements' => $this->hydratePolicyItems( + $this->decodeJsonConfig($configMap['appraisal_agreements_json'] ?? '', $defaults['appraisal_agreements']) + ), + ]; + } + + public function getMetaConfig(): array + { + $this->bootstrapDefaults(); + + $defaults = $this->defaultMetaConfig(); + $configMap = Db::name('system_configs') + ->where('config_group', self::META_GROUP) + ->column('config_value', 'config_key'); + + return [ + 'help_categories' => $this->decodeJsonConfig($configMap['help_categories_json'] ?? '', $defaults['help_categories']), + 'report_risk_defaults' => $this->decodeJsonConfig($configMap['report_risk_defaults_json'] ?? '', $defaults['report_risk_defaults']), + 'ticket_types' => $this->decodeJsonConfig($configMap['ticket_types_json'] ?? '', $defaults['ticket_types']), + 'ticket_statuses' => $this->decodeJsonConfig($configMap['ticket_statuses_json'] ?? '', $defaults['ticket_statuses']), + 'message_events' => $this->decodeJsonConfig($configMap['message_events_json'] ?? '', $defaults['message_events']), + 'message_page_copy' => $this->decodeJsonObjectConfig($configMap['message_page_copy_json'] ?? '', $defaults['message_page_copy']), + ]; + } + + public function saveHomeConfig(array $payload): void + { + $this->bootstrapDefaults(); + + $defaults = $this->defaultHomeConfig(); + $existing = $this->getHomeConfig(); + $categoryVisuals = array_key_exists('category_visuals', $payload) + ? $payload['category_visuals'] + : $existing['category_visuals']; + $normalized = [ + 'banners_json' => json_encode($this->normalizeArrayItems($payload['banners'] ?? [], ['title', 'subtitle', 'description', 'background_image_url'], $defaults['banners']), JSON_UNESCAPED_UNICODE), + 'page_visuals_json' => json_encode($this->normalizeObjectConfig($payload['page_visuals'] ?? [], ['order_background_image_url', 'report_background_image_url'], $defaults['page_visuals']), JSON_UNESCAPED_UNICODE), + 'service_entries_json' => json_encode($this->normalizeArrayItems($payload['service_entries'] ?? [], ['service_provider', 'title', 'tag', 'description', 'meta'], $defaults['service_entries']), JSON_UNESCAPED_UNICODE), + 'category_visuals_json' => json_encode($this->normalizeArrayItems($categoryVisuals, ['category_name', 'category_code', 'image_url'], $defaults['category_visuals']), JSON_UNESCAPED_UNICODE), + 'quick_entries_json' => json_encode($this->normalizeArrayItems($payload['quick_entries'] ?? [], ['code', 'title', 'desc'], $defaults['quick_entries']), JSON_UNESCAPED_UNICODE), + 'trust_metrics_json' => json_encode($this->normalizeArrayItems($payload['trust_metrics'] ?? [], ['value', 'label'], $defaults['trust_metrics']), JSON_UNESCAPED_UNICODE), + 'trust_points_json' => json_encode($this->normalizeArrayItems($payload['trust_points'] ?? [], ['title', 'desc'], $defaults['trust_points']), JSON_UNESCAPED_UNICODE), + 'faqs_json' => json_encode($this->normalizeStringList($payload['faqs'] ?? [], $defaults['faqs']), JSON_UNESCAPED_UNICODE), + ]; + + $now = date('Y-m-d H:i:s'); + foreach ($normalized as $configKey => $configValue) { + $this->upsertSystemConfig(self::HOME_GROUP, $configKey, $configValue, $now); + } + } + + public function savePolicyConfig(array $payload): void + { + $this->bootstrapDefaults(); + + $defaults = $this->defaultPolicyConfig(); + $normalized = [ + 'legal_entries_json' => json_encode($this->normalizePolicyItems($payload['legal_entries'] ?? [], $defaults['legal_entries']), JSON_UNESCAPED_UNICODE), + 'appraisal_agreements_json' => json_encode($this->normalizePolicyItems($payload['appraisal_agreements'] ?? [], $defaults['appraisal_agreements']), JSON_UNESCAPED_UNICODE), + ]; + + $now = date('Y-m-d H:i:s'); + foreach ($normalized as $configKey => $configValue) { + $this->upsertSystemConfig(self::POLICY_GROUP, $configKey, $configValue, $now); + } + } + + public function saveMetaConfig(array $payload): void + { + $this->bootstrapDefaults(); + + $defaults = $this->defaultMetaConfig(); + $normalized = [ + 'help_categories_json' => json_encode($this->normalizeArrayItems($payload['help_categories'] ?? [], ['code', 'title', 'desc'], $defaults['help_categories']), JSON_UNESCAPED_UNICODE), + 'report_risk_defaults_json' => json_encode($this->normalizeArrayItems($payload['report_risk_defaults'] ?? [], ['report_type', 'title', 'text'], $defaults['report_risk_defaults']), JSON_UNESCAPED_UNICODE), + 'ticket_types_json' => json_encode($this->normalizeArrayItems($payload['ticket_types'] ?? [], ['code', 'title', 'hint', 'quick_desc'], $defaults['ticket_types']), JSON_UNESCAPED_UNICODE), + 'ticket_statuses_json' => json_encode($this->normalizeArrayItems($payload['ticket_statuses'] ?? [], ['code', 'title', 'desc'], $defaults['ticket_statuses']), JSON_UNESCAPED_UNICODE), + 'message_events_json' => json_encode($this->normalizeArrayItems($payload['message_events'] ?? [], ['event_code', 'title', 'desc'], $defaults['message_events']), JSON_UNESCAPED_UNICODE), + 'message_page_copy_json' => json_encode($this->normalizeObjectConfig($payload['message_page_copy'] ?? [], ['title', 'desc'], $defaults['message_page_copy']), JSON_UNESCAPED_UNICODE), + ]; + + $now = date('Y-m-d H:i:s'); + foreach ($normalized as $configKey => $configValue) { + $this->upsertSystemConfig(self::META_GROUP, $configKey, $configValue, $now); + } + } + + public function getHelpArticles(bool $enabledOnly = false): array + { + $this->bootstrapDefaults(); + + $query = Db::name(self::HELP_TABLE)->order('sort_order', 'asc')->order('id', 'asc'); + if ($enabledOnly) { + $query->where('is_enabled', 1); + } + + return array_map(function (array $item) { + return [ + 'id' => (int)$item['id'], + 'category' => (string)$item['category'], + 'category_text' => $this->categoryText((string)$item['category']), + 'title' => (string)$item['title'], + 'summary' => (string)$item['summary'], + 'keywords' => $this->decodeJsonConfig($item['keywords_json'] ?? '', []), + 'updated_at' => (string)$item['updated_at'], + 'is_recommended' => (bool)$item['is_recommended'], + 'is_enabled' => (bool)$item['is_enabled'], + 'sort_order' => (int)$item['sort_order'], + 'content_blocks' => $this->decodeJsonConfig($item['content_blocks_json'] ?? '', []), + ]; + }, $query->select()->toArray()); + } + + public function getHelpCategories(): array + { + $meta = $this->getMetaConfig(); + return $meta['help_categories']; + } + + public function getTicketTypes(): array + { + $meta = $this->getMetaConfig(); + return $meta['ticket_types']; + } + + public function getTicketStatuses(): array + { + $meta = $this->getMetaConfig(); + return $meta['ticket_statuses']; + } + + public function getMessageEvents(): array + { + $meta = $this->getMetaConfig(); + return $meta['message_events']; + } + + public function getMessagePageCopy(): array + { + $meta = $this->getMetaConfig(); + return $meta['message_page_copy']; + } + + public function getHelpArticle(int $id): ?array + { + $items = $this->getHelpArticles(true); + foreach ($items as $item) { + if ((int)$item['id'] === $id) { + return $item; + } + } + + return null; + } + + public function saveHelpArticle(array $payload): int + { + $this->bootstrapDefaults(); + + $id = (int)($payload['id'] ?? 0); + $category = trim((string)($payload['category'] ?? 'service')); + $title = trim((string)($payload['title'] ?? '')); + $summary = trim((string)($payload['summary'] ?? '')); + $keywords = $this->normalizeStringList($payload['keywords'] ?? [], []); + $contentBlocks = $this->normalizeStringList($payload['content_blocks'] ?? [], []); + $isRecommended = !empty($payload['is_recommended']) ? 1 : 0; + $isEnabled = array_key_exists('is_enabled', $payload) ? (!empty($payload['is_enabled']) ? 1 : 0) : 1; + $sortOrder = (int)($payload['sort_order'] ?? 0); + + if ($title === '' || $summary === '') { + throw new \RuntimeException('文章标题和摘要不能为空'); + } + if (!$contentBlocks) { + throw new \RuntimeException('请至少填写一段文章正文'); + } + if (!in_array($category, ['service', 'report', 'shipping', 'support'], true)) { + throw new \RuntimeException('文章分类不合法'); + } + + $now = date('Y-m-d H:i:s'); + $data = [ + 'category' => $category, + 'title' => $title, + 'summary' => $summary, + 'keywords_json' => json_encode($keywords, JSON_UNESCAPED_UNICODE), + 'content_blocks_json' => json_encode($contentBlocks, JSON_UNESCAPED_UNICODE), + 'is_recommended' => $isRecommended, + 'is_enabled' => $isEnabled, + 'sort_order' => $sortOrder, + 'updated_at' => $now, + ]; + + if ($id > 0) { + $exists = Db::name(self::HELP_TABLE)->where('id', $id)->find(); + if (!$exists) { + throw new \RuntimeException('帮助文章不存在'); + } + Db::name(self::HELP_TABLE)->where('id', $id)->update($data); + return $id; + } + + $data['created_at'] = $now; + return (int)Db::name(self::HELP_TABLE)->insertGetId($data); + } + + public function deleteHelpArticle(int $id): void + { + $this->bootstrapDefaults(); + if ($id <= 0) { + throw new \RuntimeException('文章 ID 不能为空'); + } + + $exists = Db::name(self::HELP_TABLE)->where('id', $id)->find(); + if (!$exists) { + throw new \RuntimeException('帮助文章不存在'); + } + + $reference = $this->findPolicyReferenceByArticleId($id); + if ($reference !== null) { + throw new \RuntimeException(sprintf('该文章已被“%s”引用,请先在内容中心的协议与说明中解绑', $reference)); + } + + Db::name(self::HELP_TABLE)->where('id', $id)->delete(); + } + + public function categoryText(string $category): string + { + foreach ($this->getHelpCategories() as $item) { + if (($item['code'] ?? '') === $category) { + return (string)($item['title'] ?? $category); + } + } + + return $category; + } + + public function getReportRiskNotice(string $reportType): string + { + $meta = $this->getMetaConfig(); + foreach ($meta['report_risk_defaults'] as $item) { + if (($item['report_type'] ?? '') === $reportType) { + return (string)($item['text'] ?? ''); + } + } + + return ''; + } + + public function ticketTypeText(string $type): string + { + foreach ($this->getTicketTypes() as $item) { + if (($item['code'] ?? '') === $type) { + return (string)($item['title'] ?? $type); + } + } + + return $type; + } + + public function ticketStatusText(string $status): string + { + foreach ($this->getTicketStatuses() as $item) { + if (($item['code'] ?? '') === $status) { + return (string)($item['title'] ?? $status); + } + } + + return $status; + } + + private function ensureConfigDefaults(): void + { + $homeDefaults = $this->defaultHomeConfig(); + $defaults = [ + self::HOME_GROUP => [ + 'banners_json' => json_encode($homeDefaults['banners'], JSON_UNESCAPED_UNICODE), + 'page_visuals_json' => json_encode($homeDefaults['page_visuals'], JSON_UNESCAPED_UNICODE), + 'service_entries_json' => json_encode($homeDefaults['service_entries'], JSON_UNESCAPED_UNICODE), + 'category_visuals_json' => json_encode($homeDefaults['category_visuals'], JSON_UNESCAPED_UNICODE), + 'quick_entries_json' => json_encode($homeDefaults['quick_entries'], JSON_UNESCAPED_UNICODE), + 'trust_metrics_json' => json_encode($homeDefaults['trust_metrics'], JSON_UNESCAPED_UNICODE), + 'trust_points_json' => json_encode($homeDefaults['trust_points'], JSON_UNESCAPED_UNICODE), + 'faqs_json' => json_encode($homeDefaults['faqs'], JSON_UNESCAPED_UNICODE), + ], + self::POLICY_GROUP => [ + 'legal_entries_json' => json_encode($this->defaultPolicyConfig()['legal_entries'], JSON_UNESCAPED_UNICODE), + 'appraisal_agreements_json' => json_encode($this->defaultPolicyConfig()['appraisal_agreements'], JSON_UNESCAPED_UNICODE), + ], + self::META_GROUP => [ + 'help_categories_json' => json_encode($this->defaultMetaConfig()['help_categories'], JSON_UNESCAPED_UNICODE), + 'report_risk_defaults_json' => json_encode($this->defaultMetaConfig()['report_risk_defaults'], JSON_UNESCAPED_UNICODE), + 'ticket_types_json' => json_encode($this->defaultMetaConfig()['ticket_types'], JSON_UNESCAPED_UNICODE), + 'ticket_statuses_json' => json_encode($this->defaultMetaConfig()['ticket_statuses'], JSON_UNESCAPED_UNICODE), + 'message_events_json' => json_encode($this->defaultMetaConfig()['message_events'], JSON_UNESCAPED_UNICODE), + 'message_page_copy_json' => json_encode($this->defaultMetaConfig()['message_page_copy'], JSON_UNESCAPED_UNICODE), + ], + ]; + + $groupCodes = array_keys($defaults); + $existingRows = Db::name('system_configs') + ->field(['config_group', 'config_key']) + ->whereIn('config_group', $groupCodes) + ->select() + ->toArray(); + + $existingMap = []; + foreach ($existingRows as $item) { + $existingMap[($item['config_group'] ?? '') . '.' . ($item['config_key'] ?? '')] = true; + } + + $now = date('Y-m-d H:i:s'); + $insertRows = []; + foreach ($defaults as $groupCode => $items) { + foreach ($items as $configKey => $configValue) { + $mapKey = $groupCode . '.' . $configKey; + if (isset($existingMap[$mapKey])) { + continue; + } + + $insertRows[] = [ + 'config_group' => $groupCode, + 'config_key' => $configKey, + 'config_value' => $configValue, + 'remark' => '内容配置', + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + } + + if ($insertRows) { + Db::name('system_configs')->insertAll($insertRows); + } + } + + private function ensureHelpArticlesTable(): void + { + $exists = Db::query(sprintf("SHOW TABLES LIKE '%s'", self::HELP_TABLE)); + if ($exists) { + return; + } + + Db::execute(sprintf( + "CREATE TABLE %s ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + category VARCHAR(32) NOT NULL DEFAULT 'service', + title VARCHAR(255) NOT NULL DEFAULT '', + summary VARCHAR(500) NOT NULL DEFAULT '', + keywords_json LONGTEXT NULL, + content_blocks_json LONGTEXT NULL, + is_recommended TINYINT(1) NOT NULL DEFAULT 0, + is_enabled TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_help_articles_category (category), + KEY idx_help_articles_enabled (is_enabled), + KEY idx_help_articles_sort (sort_order) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='帮助中心文章'", + self::HELP_TABLE + )); + } + + private function ensurePolicyHelpArticles(): void + { + $defaults = $this->defaultPolicyConfig(); + $configMap = Db::name('system_configs') + ->where('config_group', self::POLICY_GROUP) + ->column('config_value', 'config_key'); + + $policy = [ + 'legal_entries' => $this->decodeJsonConfig($configMap['legal_entries_json'] ?? '', $defaults['legal_entries']), + 'appraisal_agreements' => $this->decodeJsonConfig($configMap['appraisal_agreements_json'] ?? '', $defaults['appraisal_agreements']), + ]; + + $changed = false; + foreach (['legal_entries', 'appraisal_agreements'] as $section) { + $items = []; + foreach (($policy[$section] ?? []) as $item) { + if (!is_array($item)) { + continue; + } + + $normalizedItem = $item; + $articleId = (int)($normalizedItem['article_id'] ?? 0); + if ($articleId <= 0) { + $articleId = $this->extractHelpArticleIdFromTargetUrl((string)($normalizedItem['target_url'] ?? '')); + } + + if ($articleId > 0) { + $article = Db::name(self::HELP_TABLE)->field(['id'])->where('id', $articleId)->find(); + if (!$article) { + $articleId = 0; + } + } + + if ($articleId <= 0) { + $articleId = $this->resolveOrCreatePolicyArticleId((string)($normalizedItem['code'] ?? '')); + } + + if ($articleId > 0) { + $nextTargetUrl = $this->buildHelpArticleTargetUrl($articleId); + if ((int)($normalizedItem['article_id'] ?? 0) !== $articleId || (string)($normalizedItem['target_url'] ?? '') !== $nextTargetUrl) { + $changed = true; + } + $normalizedItem['article_id'] = $articleId; + $normalizedItem['target_url'] = $nextTargetUrl; + } + + $items[] = $normalizedItem; + } + + $policy[$section] = $items; + } + + if (!$changed) { + return; + } + + $now = date('Y-m-d H:i:s'); + $this->upsertSystemConfig(self::POLICY_GROUP, 'legal_entries_json', json_encode($policy['legal_entries'], JSON_UNESCAPED_UNICODE), $now); + $this->upsertSystemConfig(self::POLICY_GROUP, 'appraisal_agreements_json', json_encode($policy['appraisal_agreements'], JSON_UNESCAPED_UNICODE), $now); + } + + private function seedHelpArticles(): void + { + $count = (int)Db::name(self::HELP_TABLE)->count(); + if ($count > 0) { + return; + } + + $now = date('Y-m-d H:i:s'); + foreach ($this->defaultHelpArticles() as $index => $item) { + Db::name(self::HELP_TABLE)->insert([ + 'category' => $item['category'], + 'title' => $item['title'], + 'summary' => $item['summary'], + 'keywords_json' => json_encode($item['keywords'], JSON_UNESCAPED_UNICODE), + 'content_blocks_json' => json_encode($item['content_blocks'], JSON_UNESCAPED_UNICODE), + 'is_recommended' => !empty($item['is_recommended']) ? 1 : 0, + 'is_enabled' => 1, + 'sort_order' => $item['sort_order'] ?? ($index + 1) * 10, + 'created_at' => $item['updated_at'] ?? $now, + 'updated_at' => $item['updated_at'] ?? $now, + ]); + } + } + + private function upsertSystemConfig(string $groupCode, string $configKey, string $configValue, string $now): void + { + $exists = Db::name('system_configs') + ->where('config_group', $groupCode) + ->where('config_key', $configKey) + ->find(); + + $payload = [ + 'config_group' => $groupCode, + 'config_key' => $configKey, + 'config_value' => $configValue, + 'remark' => '内容配置', + 'updated_at' => $now, + ]; + + if ($exists) { + Db::name('system_configs')->where('id', $exists['id'])->update($payload); + return; + } + + $payload['created_at'] = $now; + Db::name('system_configs')->insert($payload); + } + + private function decodeJsonConfig(string $value, array $default): array + { + if ($value === '') { + return $default; + } + + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : $default; + } + + private function decodeJsonObjectConfig(string $value, array $default): array + { + if ($value === '') { + return $default; + } + + $decoded = json_decode($value, true); + return is_array($decoded) ? $decoded : $default; + } + + private function normalizeArrayItems(mixed $value, array $keys, array $default): array + { + if (!is_array($value)) { + return $default; + } + + $normalized = []; + foreach ($value as $item) { + if (!is_array($item)) { + continue; + } + + $row = []; + foreach ($keys as $key) { + $row[$key] = trim((string)($item[$key] ?? '')); + } + + if (implode('', $row) === '') { + continue; + } + + $normalized[] = $row; + } + + return $normalized ?: $default; + } + + private function normalizePolicyItems(mixed $value, array $default): array + { + if (!is_array($value)) { + return $this->hydratePolicyItems($default); + } + + $normalized = []; + foreach ($value as $item) { + if (!is_array($item)) { + continue; + } + + $code = trim((string)($item['code'] ?? '')); + $title = trim((string)($item['title'] ?? '')); + $desc = trim((string)($item['desc'] ?? '')); + $targetUrl = trim((string)($item['target_url'] ?? '')); + $articleId = (int)($item['article_id'] ?? 0); + + if ($articleId <= 0) { + $articleId = $this->extractHelpArticleIdFromTargetUrl($targetUrl); + } + + if ($articleId > 0) { + $article = Db::name(self::HELP_TABLE)->field(['id'])->where('id', $articleId)->find(); + if (!$article) { + throw new \RuntimeException(sprintf('绑定文章 #%d 不存在,请重新选择', $articleId)); + } + $targetUrl = $this->buildHelpArticleTargetUrl($articleId); + } + + if ($code === '' && $title === '' && $desc === '' && $targetUrl === '' && $articleId <= 0) { + continue; + } + + $normalized[] = [ + 'code' => $code, + 'title' => $title, + 'desc' => $desc, + 'target_url' => $targetUrl, + 'article_id' => $articleId, + ]; + } + + return $normalized ?: $this->hydratePolicyItems($default); + } + + private function hydratePolicyItems(array $items): array + { + $normalized = []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + $articleId = (int)($item['article_id'] ?? 0); + $targetUrl = trim((string)($item['target_url'] ?? '')); + + if ($articleId <= 0) { + $articleId = $this->extractHelpArticleIdFromTargetUrl($targetUrl); + } + + if ($articleId > 0) { + $targetUrl = $this->buildHelpArticleTargetUrl($articleId); + } + + $normalized[] = [ + 'code' => trim((string)($item['code'] ?? '')), + 'title' => trim((string)($item['title'] ?? '')), + 'desc' => trim((string)($item['desc'] ?? '')), + 'target_url' => $targetUrl, + 'article_id' => $articleId, + ]; + } + + return $normalized; + } + + private function extractHelpArticleIdFromTargetUrl(string $targetUrl): int + { + if ($targetUrl === '') { + return 0; + } + + if (!preg_match('/\/pages\/help\/detail\?id=(\d+)/', $targetUrl, $matches)) { + return 0; + } + + return (int)($matches[1] ?? 0); + } + + private function buildHelpArticleTargetUrl(int $articleId): string + { + return sprintf('/pages/help/detail?id=%d', $articleId); + } + + private function findPolicyReferenceByArticleId(int $articleId): ?string + { + $policy = $this->getPolicyConfig(); + foreach (['legal_entries' => '设置页说明入口', 'appraisal_agreements' => '下单确认协议'] as $section => $sectionName) { + foreach (($policy[$section] ?? []) as $item) { + $itemArticleId = (int)($item['article_id'] ?? 0); + if ($itemArticleId <= 0) { + $itemArticleId = $this->extractHelpArticleIdFromTargetUrl((string)($item['target_url'] ?? '')); + } + + if ($itemArticleId !== $articleId) { + continue; + } + + $title = trim((string)($item['title'] ?? '')); + return $title !== '' ? sprintf('%s / %s', $sectionName, $title) : $sectionName; + } + } + + return null; + } + + private function normalizeObjectConfig(mixed $value, array $keys, array $default): array + { + if (!is_array($value)) { + return $default; + } + + $normalized = []; + foreach ($keys as $key) { + $normalized[$key] = trim((string)($value[$key] ?? '')); + } + + return implode('', $normalized) === '' ? $default : $normalized; + } + + private function normalizeStringList(mixed $value, array $default): array + { + if (!is_array($value)) { + return $default; + } + + $normalized = array_values(array_filter(array_map( + fn ($item) => trim((string)$item), + $value + ), fn ($item) => $item !== '')); + + return $normalized ?: $default; + } + + private function defaultHomeConfig(): array + { + return [ + 'banners' => [ + [ + 'title' => '安心验', + 'subtitle' => '独立第三方鉴定服务平台', + 'description' => '专业鉴定高价值商品,报告可验真,流程可追踪。', + 'background_image_url' => '', + ], + ], + 'page_visuals' => [ + 'order_background_image_url' => '', + 'report_background_image_url' => '', + ], + 'service_entries' => [ + [ + 'service_provider' => 'anxinyan', + 'title' => '实物鉴定', + 'tag' => '标准服务', + 'description' => '由安心验提供标准实物鉴定服务,适合正式结果交付场景。', + 'meta' => '预计 48 小时内出结果 | 报告可验真', + ], + [ + 'service_provider' => 'zhongjian', + 'title' => '中检鉴定', + 'tag' => '更高规格机构', + 'description' => '由更高规格机构提供实物鉴定服务,适合更高要求场景。', + 'meta' => '流程一致 | 出具机构不同 | 价格与时效有差异', + ], + ], + 'category_visuals' => [ + ['category_name' => '奢侈品箱包', 'category_code' => 'luxury_bag', 'image_url' => ''], + ['category_name' => '潮流鞋类', 'category_code' => 'sneaker', 'image_url' => ''], + ['category_name' => '首饰配饰', 'category_code' => 'jewelry', 'image_url' => ''], + ['category_name' => '高端美妆', 'category_code' => 'beauty', 'image_url' => ''], + ['category_name' => '腕表', 'category_code' => 'watch', 'image_url' => ''], + ['category_name' => '服饰', 'category_code' => 'clothing', 'image_url' => ''], + ['category_name' => '3C 数码', 'category_code' => 'digital', 'image_url' => ''], + ['category_name' => '古董文玩', 'category_code' => 'antique', 'image_url' => ''], + ], + 'quick_entries' => [ + ['code' => 'start', 'title' => '发起鉴定', 'desc' => '进入送检流程'], + ['code' => 'orders', 'title' => '我的订单', 'desc' => '查看当前进度'], + ['code' => 'reports', 'title' => '我的报告', 'desc' => '查看结果凭证'], + ['code' => 'messages', 'title' => '消息中心', 'desc' => '查看服务提醒与结果通知'], + ], + 'trust_metrics' => [ + ['value' => '1280+', 'label' => '累计鉴定申请'], + ['value' => '48h', 'label' => '标准结果时效'], + ['value' => '100%', 'label' => '正式报告可验真'], + ], + 'trust_points' => [ + ['title' => '独立第三方', 'desc' => '保持中立判断,不参与买卖立场。'], + ['title' => '报告可验真', 'desc' => '每份正式报告均支持编号与状态验证。'], + ['title' => '流程可追踪', 'desc' => '从下单到出报告,关键节点一目了然。'], + ['title' => '标准化作业', 'desc' => '按模板采集资料,单次鉴定后出具报告。'], + ], + 'faqs' => [ + '实物鉴定和中检鉴定有什么区别?', + '一般多久可以出结果?', + '报告如何验证真伪?', + ], + ]; + } + + private function defaultPolicyConfig(): array + { + return [ + 'legal_entries' => [ + [ + 'code' => 'privacy_policy', + 'title' => '隐私说明', + 'desc' => '了解平台如何处理您的订单与联系方式信息', + 'target_url' => '/pages/help/index?q=%E9%9A%90%E7%A7%81', + 'article_id' => 0, + ], + [ + 'code' => 'service_notice', + 'title' => '服务与通知说明', + 'desc' => '了解消息提醒、工单回复与服务相关通知逻辑', + 'target_url' => '/pages/help/index?q=%E6%9C%8D%E5%8A%A1', + 'article_id' => 0, + ], + ], + 'appraisal_agreements' => [ + [ + 'code' => 'service_agreement', + 'title' => '服务协议', + 'desc' => '下单前请确认服务边界、报告用途与责任说明。', + 'target_url' => '/pages/help/index?q=%E6%9C%8D%E5%8A%A1', + 'article_id' => 0, + ], + [ + 'code' => 'appraisal_notice', + 'title' => '鉴定须知', + 'desc' => '了解资料要求、流程节点与补资料处理规则。', + 'target_url' => '/pages/help/index?q=%E9%89%B4%E5%AE%9A', + 'article_id' => 0, + ], + [ + 'code' => 'privacy_policy', + 'title' => '隐私政策', + 'desc' => '了解平台如何处理联系方式、地址和订单信息。', + 'target_url' => '/pages/help/index?q=%E9%9A%90%E7%A7%81', + 'article_id' => 0, + ], + ], + ]; + } + + private function defaultMetaConfig(): array + { + return [ + 'help_categories' => [ + ['code' => 'all', 'title' => '全部', 'desc' => '查看全部帮助文章'], + ['code' => 'service', 'title' => '服务流程', 'desc' => '了解下单、寄送、鉴定流程'], + ['code' => 'report', 'title' => '报告验真', 'desc' => '了解报告查看、下载与验真'], + ['code' => 'shipping', 'title' => '寄送物流', 'desc' => '了解寄送、运单和签收说明'], + ['code' => 'support', 'title' => '售后支持', 'desc' => '了解补资料、工单和客服协助'], + ], + 'report_risk_defaults' => [ + [ + 'report_type' => 'appraisal', + 'title' => '正式鉴定报告', + 'text' => '本报告基于送检商品及当前提交资料出具。若商品状态或所附资料发生变化,报告结论可能不再适用。', + ], + [ + 'report_type' => 'inspection', + 'title' => '后台补录检查单', + 'text' => '本检查单为后台补录结果,请结合扫码查看的正式页面与现场实物信息综合判断。', + ], + ], + 'ticket_types' => [ + [ + 'code' => 'pre_consultation', + 'title' => '鉴定前咨询', + 'hint' => '适合流程、服务说明类问题', + 'quick_desc' => '下单前流程、服务说明咨询', + ], + [ + 'code' => 'order_issue', + 'title' => '订单问题', + 'hint' => '适合订单状态、支付、进度问题', + 'quick_desc' => '进度、状态、支付相关', + ], + [ + 'code' => 'upload_issue', + 'title' => '上传问题', + 'hint' => '适合拍摄上传、补图协助问题', + 'quick_desc' => '拍摄、上传、补图协助', + ], + [ + 'code' => 'report_issue', + 'title' => '报告问题', + 'hint' => '适合报告结论、验真、估值问题', + 'quick_desc' => '结论、估值、验真咨询', + ], + [ + 'code' => 'after_sales', + 'title' => '售后问题', + 'hint' => '适合服务反馈与后续处理', + 'quick_desc' => '服务反馈与后续处理', + ], + [ + 'code' => 'recheck', + 'title' => '结果咨询', + 'hint' => '适合咨询报告结论或补充说明', + 'quick_desc' => '报告结论与说明咨询', + ], + ], + 'ticket_statuses' => [ + ['code' => 'pending', 'title' => '待处理', 'desc' => '工单已提交,客服尚未正式开始处理。'], + ['code' => 'processing', 'title' => '处理中', 'desc' => '客服正在跟进问题,您可继续补充说明或截图。'], + ['code' => 'waiting_user', 'title' => '待您反馈', 'desc' => '客服需要您补充更多信息后才能继续处理。'], + ['code' => 'resolved', 'title' => '已解决', 'desc' => '当前问题已处理完成,如仍有疑问可继续留言。'], + ['code' => 'closed', 'title' => '已关闭', 'desc' => '工单已关闭,如需继续处理可重新发起工单。'], + ], + 'message_events' => [ + ['event_code' => 'order_created', 'title' => '下单成功', 'desc' => '用户成功创建鉴定订单后触发。'], + ['event_code' => 'supplement_required', 'title' => '待补资料', 'desc' => '鉴定师发起补资料要求后触发。'], + ['event_code' => 'report_published', 'title' => '报告已出具', 'desc' => '正式报告发布成功后触发。'], + ['event_code' => 'return_shipped', 'title' => '物品已寄回', 'desc' => '平台登记回寄物流后触发。'], + ['event_code' => 'return_received', 'title' => '回寄商品已签收', 'desc' => '用户签收回寄物品后触发。'], + ['event_code' => 'ticket_reply', 'title' => '工单有新回复', 'desc' => '客服回复用户工单后触发。'], + ['event_code' => 'ticket_waiting_user', 'title' => '工单待用户反馈', 'desc' => '后台将工单状态改为待用户反馈时触发。'], + ['event_code' => 'ticket_resolved', 'title' => '工单已解决', 'desc' => '后台将工单状态改为已解决时触发。'], + ['event_code' => 'ticket_closed', 'title' => '工单已关闭', 'desc' => '后台将工单状态改为已关闭时触发。'], + ], + 'message_page_copy' => [ + 'title' => '服务提醒与处理进度', + 'desc' => '这里会统一展示订单流转、补资料、报告出具和工单回复等关键通知,方便您集中查看。', + ], + ]; + } + + private function defaultHelpArticles(): array + { + return [ + [ + 'category' => 'service', + 'title' => '实物鉴定和中检鉴定有什么区别?', + 'summary' => '两种服务的核心流程一致,差异主要体现在出具机构、时效与价格上。', + 'keywords' => ['实物鉴定', '中检鉴定', '服务区别'], + 'updated_at' => '2026-04-21 09:00:00', + 'is_recommended' => true, + 'sort_order' => 10, + 'content_blocks' => [ + '实物鉴定和中检鉴定都会经过下单、填写信息、上传资料、寄送商品、鉴定和查看报告这几个核心步骤。', + '两者最大的区别在于出具机构不同。实物鉴定由安心验提供标准实物鉴定服务;中检鉴定由更高规格合作机构提供服务,适合对机构资质有更高要求的场景。', + '中检鉴定通常价格更高、时效也会略长一些。下单前建议先根据您的使用场景、预算和时效要求选择合适服务。', + ], + ], + [ + 'category' => 'service', + 'title' => '一般多久可以出结果?', + 'summary' => '标准版通常 48 小时左右,具体取决于服务类型、资料完整度和物流节点。', + 'keywords' => ['时效', '出结果', '多久'], + 'updated_at' => '2026-04-21 09:00:00', + 'is_recommended' => true, + 'sort_order' => 20, + 'content_blocks' => [ + '安心验标准版通常在 48 小时左右完成处理,中检鉴定因机构流程要求更高,时效会相对更长。', + '如果您上传的资料不完整、需要补图,或者商品物流尚未签收,整体时效会顺延。', + '建议您在订单详情和消息中心关注关键节点,一旦有补资料要求或报告出具通知,系统会第一时间提醒。', + ], + ], + [ + 'category' => 'report', + 'title' => '报告如何验证真伪?', + 'summary' => '正式报告出具后,可通过报告详情页或验真页输入编号进行验证。', + 'keywords' => ['报告', '验真', '验证真伪'], + 'updated_at' => '2026-04-21 09:00:00', + 'is_recommended' => true, + 'sort_order' => 30, + 'content_blocks' => [ + '正式报告发布后,您可以在报告中心进入报告详情,再点击“去验真”进入验真页面。', + '验真页会展示报告编号、机构、商品摘要和结论摘要。请以验真页显示的结果为准。', + '如果您对报告内容或验真结果仍有疑问,可以直接通过客服工单联系人工支持。', + ], + ], + [ + 'category' => 'shipping', + 'title' => '商品寄出后还需要做什么?', + 'summary' => '寄出商品后,请尽快回到“查看寄送”页填写快递公司和运单号。', + 'keywords' => ['寄送', '运单', '物流'], + 'updated_at' => '2026-04-21 09:00:00', + 'is_recommended' => false, + 'sort_order' => 40, + 'content_blocks' => [ + '寄出商品后,请保留寄件凭证,并尽快在订单详情或寄送页填写快递公司和运单号。', + '提交运单后,订单会显示“已提交运单”,后续签收和处理节点也会继续同步。', + '贵重商品建议选择可追踪快递,并在包裹内附上订单号或鉴定单号。', + ], + ], + [ + 'category' => 'support', + 'title' => '收到补资料提醒后该怎么处理?', + 'summary' => '收到补资料提醒后,请进入订单详情或补资料页,按要求重新上传指定资料。', + 'keywords' => ['补资料', '补图', '上传'], + 'updated_at' => '2026-04-21 09:00:00', + 'is_recommended' => false, + 'sort_order' => 50, + 'content_blocks' => [ + '如果鉴定师认为现有资料还不足以完成判断,系统会推送补资料通知到消息中心。', + '您可以直接点击消息进入补资料页,按要求上传缺失资料,再提交补资料。', + '提交完成后,订单会重新进入鉴定流程。如仍有疑问,也可以通过客服工单寻求协助。', + ], + ], + [ + 'category' => 'support', + 'title' => '如何联系客服并查看处理进度?', + 'summary' => '您可以从订单详情、验真页、“我的”页等入口发起工单,并在工单详情查看客服回复。', + 'keywords' => ['客服', '工单', '处理进度'], + 'updated_at' => '2026-04-21 09:00:00', + 'is_recommended' => false, + 'sort_order' => 60, + 'content_blocks' => [ + '目前用户端已支持发起工单、继续留言、查看客服回复和附件。', + '客服回复后,消息中心会收到提醒,点击即可进入对应工单详情。', + '如果工单状态变成“待您反馈”或“已解决”,系统也会同步推送状态通知。', + ], + ], + ]; + } + + private function defaultPolicyHelpArticles(): array + { + return [ + 'privacy_policy' => [ + 'category' => 'service', + 'title' => '隐私政策', + 'summary' => '说明平台如何处理联系方式、地址、订单等个人信息,以及相关使用边界。', + 'keywords' => ['隐私政策', '个人信息', '联系方式', '地址', '订单信息'], + 'is_recommended' => false, + 'sort_order' => 70, + 'content_blocks' => [ + '平台会在您下单、填写寄回地址、提交运单和联系客服等场景中收集必要的信息,仅用于完成鉴定服务、订单履约、结果通知和售后支持。', + '联系方式、地址、订单编号、商品资料和服务记录会用于鉴定流程推进、物流寄回、消息提醒和问题追踪,不会用于与本次服务无关的处理场景。', + '如需了解或更正相关信息,可通过设置页、地址管理、订单详情或客服工单入口进行处理,我们会按平台规则提供协助。', + ], + ], + 'service_notice' => [ + 'category' => 'service', + 'title' => '服务与通知说明', + 'summary' => '说明消息提醒、工单回复、补资料和结果通知的触发方式与查看入口。', + 'keywords' => ['服务通知', '消息提醒', '工单回复', '补资料', '结果通知'], + 'is_recommended' => false, + 'sort_order' => 80, + 'content_blocks' => [ + '订单创建、补资料、报告出具、物品寄回和工单回复等关键节点都会通过消息中心统一提醒,方便您集中查看当前处理进度。', + '若鉴定师需要补充资料,系统会推送待补资料通知,您可直接进入订单详情或补资料页继续上传。', + '若对通知内容有疑问,可通过客服工单继续追问,客服回复后也会再次触发站内提醒。', + ], + ], + 'service_agreement' => [ + 'category' => 'service', + 'title' => '服务协议', + 'summary' => '说明鉴定服务边界、报告用途、结果交付方式与相关责任说明。', + 'keywords' => ['服务协议', '鉴定服务', '报告用途', '责任说明'], + 'is_recommended' => false, + 'sort_order' => 90, + 'content_blocks' => [ + '平台提供的是独立第三方鉴定服务,服务结果基于送检商品、提交资料及实际履约节点综合判断,并以正式页面或报告展示内容为准。', + '不同服务类型在出具机构、价格、时效和交付形式上可能存在差异,下单前请结合自身需求确认所选服务方案。', + '若因资料缺失、物流未签收、商品状态变化或其他需补充核验的情况影响处理进度,平台会通过补资料或消息通知继续提示您后续操作。', + ], + ], + 'appraisal_notice' => [ + 'category' => 'service', + 'title' => '鉴定须知', + 'summary' => '说明资料要求、流程节点、补资料处理规则与常见注意事项。', + 'keywords' => ['鉴定须知', '资料要求', '流程节点', '补资料'], + 'is_recommended' => false, + 'sort_order' => 100, + 'content_blocks' => [ + '请尽量按模板上传清晰、完整的商品资料,并在寄送实物后及时填写物流信息,这会直接影响鉴定效率与处理时效。', + '若当前资料不足以支持判断,鉴定师会发起补资料要求,订单会暂停在待补资料节点,待您补齐后再继续流转。', + '正式报告仅对当前送检商品及本次服务资料负责,如商品状态、附件情况或所附证明材料发生变化,相关结论可能需要重新核验。', + ], + ], + ]; + } + + private function resolveOrCreatePolicyArticleId(string $code): int + { + $code = trim($code); + if ($code === '') { + return 0; + } + + $definitions = $this->defaultPolicyHelpArticles(); + $definition = $definitions[$code] ?? null; + if (!$definition) { + return 0; + } + + $existing = Db::name(self::HELP_TABLE) + ->where('title', $definition['title']) + ->find(); + if ($existing) { + return (int)$existing['id']; + } + + $now = date('Y-m-d H:i:s'); + return (int)Db::name(self::HELP_TABLE)->insertGetId([ + 'category' => $definition['category'], + 'title' => $definition['title'], + 'summary' => $definition['summary'], + 'keywords_json' => json_encode($definition['keywords'], JSON_UNESCAPED_UNICODE), + 'content_blocks_json' => json_encode($definition['content_blocks'], JSON_UNESCAPED_UNICODE), + 'is_recommended' => !empty($definition['is_recommended']) ? 1 : 0, + 'is_enabled' => 1, + 'sort_order' => (int)$definition['sort_order'], + 'created_at' => $now, + 'updated_at' => $now, + ]); + } +} diff --git a/server-api/app/support/EnterpriseCustomerService.php b/server-api/app/support/EnterpriseCustomerService.php new file mode 100644 index 0000000..dcf8465 --- /dev/null +++ b/server-api/app/support/EnterpriseCustomerService.php @@ -0,0 +1,128 @@ +where('customer_code', $code)->find()); + + return $code; + } + + public function generateAppKey(): string + { + do { + $key = 'ak_' . bin2hex(random_bytes(12)); + } while (Db::name('enterprise_customer_apps')->where('app_key', $key)->find()); + + return $key; + } + + public function generateAppSecret(): string + { + return 'sk_' . bin2hex(random_bytes(24)); + } + + public function encryptSecret(string $secret): string + { + $key = $this->secretKey(); + $iv = random_bytes(16); + $cipher = openssl_encrypt($secret, 'AES-256-CBC', $key, OPENSSL_RAW_DATA, $iv); + if ($cipher === false) { + throw new \RuntimeException('应用密钥加密失败'); + } + + return base64_encode($iv . $cipher); + } + + public function decryptSecret(string $cipherText): string + { + $raw = base64_decode($cipherText, true); + if ($raw === false || strlen($raw) <= 16) { + return ''; + } + + $iv = substr($raw, 0, 16); + $cipher = substr($raw, 16); + $secret = openssl_decrypt($cipher, 'AES-256-CBC', $this->secretKey(), OPENSSL_RAW_DATA, $iv); + + return is_string($secret) ? $secret : ''; + } + + public function ensureVirtualUser(array $customer): int + { + $existingUserId = (int)($customer['user_id'] ?? 0); + if ($existingUserId > 0 && Db::name('users')->where('id', $existingUserId)->find()) { + return $existingUserId; + } + + $now = date('Y-m-d H:i:s'); + $mobile = 'ENT' . substr(hash('sha256', (string)($customer['customer_code'] ?? $customer['id'] ?? '')), 0, 20) . '@V'; + $userId = (int)Db::name('users')->insertGetId([ + 'nickname' => (string)($customer['customer_name'] ?? '大客户'), + 'avatar' => '', + 'mobile' => $mobile, + 'password' => '', + 'status' => 'enabled', + 'last_login_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('enterprise_customers')->where('id', $customer['id'])->update([ + 'user_id' => $userId, + 'updated_at' => $now, + ]); + + return $userId; + } + + public function formatCustomer(array $item): array + { + return [ + 'id' => (int)$item['id'], + 'customer_code' => (string)$item['customer_code'], + 'customer_name' => (string)$item['customer_name'], + 'contact_name' => (string)($item['contact_name'] ?? ''), + 'contact_mobile' => (string)($item['contact_mobile'] ?? ''), + 'contact_email' => (string)($item['contact_email'] ?? ''), + 'settlement_type' => (string)($item['settlement_type'] ?? 'monthly'), + 'settlement_type_text' => '月结', + 'user_id' => (int)($item['user_id'] ?? 0), + 'webhook_url' => (string)($item['webhook_url'] ?? ''), + 'webhook_enabled' => (bool)($item['webhook_enabled'] ?? false), + 'status' => (string)($item['status'] ?? 'enabled'), + 'status_text' => ($item['status'] ?? 'enabled') === 'enabled' ? '启用中' : '已停用', + 'remark' => (string)($item['remark'] ?? ''), + 'created_at' => (string)($item['created_at'] ?? ''), + 'updated_at' => (string)($item['updated_at'] ?? ''), + ]; + } + + public function formatApp(array $item): array + { + return [ + 'id' => (int)$item['id'], + 'customer_id' => (int)$item['customer_id'], + 'app_name' => (string)($item['app_name'] ?? ''), + 'app_key' => (string)$item['app_key'], + 'secret_last4' => (string)($item['secret_last4'] ?? ''), + 'status' => (string)($item['status'] ?? 'enabled'), + 'status_text' => ($item['status'] ?? 'enabled') === 'enabled' ? '启用中' : '已停用', + 'last_used_at' => (string)($item['last_used_at'] ?? ''), + 'created_at' => (string)($item['created_at'] ?? ''), + ]; + } + + private function secretKey(): string + { + $seed = $_ENV['APP_KEY'] ?? $_ENV['JWT_SECRET'] ?? 'anxinyan-enterprise-secret-key'; + return hash('sha256', (string)$seed, true); + } +} diff --git a/server-api/app/support/EnterpriseOpenApiAuthService.php b/server-api/app/support/EnterpriseOpenApiAuthService.php new file mode 100644 index 0000000..56127bd --- /dev/null +++ b/server-api/app/support/EnterpriseOpenApiAuthService.php @@ -0,0 +1,78 @@ +header('x-axy-app-key', '')); + $timestamp = trim((string)$request->header('x-axy-timestamp', '')); + $nonce = trim((string)$request->header('x-axy-nonce', '')); + $signature = trim((string)$request->header('x-axy-signature', '')); + + if ($appKey === '' || $timestamp === '' || $nonce === '' || $signature === '') { + throw new \RuntimeException('开放接口鉴权头不完整'); + } + + if (!ctype_digit($timestamp) || abs(time() - (int)$timestamp) > 300) { + throw new \RuntimeException('请求时间戳已过期'); + } + + $app = Db::name('enterprise_customer_apps')->where('app_key', $appKey)->find(); + if (!$app || ($app['status'] ?? 'enabled') !== 'enabled') { + throw new \RuntimeException('应用 Key 不存在或已停用'); + } + + $customer = Db::name('enterprise_customers')->where('id', $app['customer_id'])->find(); + if (!$customer || ($customer['status'] ?? 'enabled') !== 'enabled') { + throw new \RuntimeException('客户不存在或已停用'); + } + + $this->storeNonce($appKey, $nonce, (int)$timestamp); + + $secret = (new EnterpriseCustomerService())->decryptSecret((string)($app['app_secret_cipher'] ?? '')); + if ($secret === '') { + throw new \RuntimeException('应用密钥不可用'); + } + + $rawBody = $request->rawBody(); + $pathWithQuery = $request->uri(); + $base = strtoupper($request->method()) . $pathWithQuery . $timestamp . $nonce . hash('sha256', $rawBody); + $expected = hash_hmac('sha256', $base, $secret); + + if (!hash_equals($expected, strtolower($signature))) { + throw new \RuntimeException('请求签名无效'); + } + + Db::name('enterprise_customer_apps')->where('id', $app['id'])->update([ + 'last_used_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + return [ + 'app' => $app, + 'customer' => $customer, + ]; + } + + private function storeNonce(string $appKey, string $nonce, int $timestamp): void + { + $expireBefore = date('Y-m-d H:i:s', time() - 600); + Db::name('enterprise_api_nonces')->where('created_at', '<', $expireBefore)->delete(); + + try { + Db::name('enterprise_api_nonces')->insert([ + 'app_key' => $appKey, + 'nonce' => $nonce, + 'request_timestamp' => $timestamp, + 'created_at' => date('Y-m-d H:i:s'), + ]); + } catch (\Throwable $e) { + throw new \RuntimeException('请求 nonce 已使用'); + } + } +} diff --git a/server-api/app/support/EnterpriseOrderService.php b/server-api/app/support/EnterpriseOrderService.php new file mode 100644 index 0000000..d0af325 --- /dev/null +++ b/server-api/app/support/EnterpriseOrderService.php @@ -0,0 +1,449 @@ +normalizePayloadForHash($payload), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES)); + $existingRef = Db::name('enterprise_customer_order_refs') + ->where('customer_id', (int)$customer['id']) + ->where('external_order_no', $externalOrderNo) + ->find(); + if ($existingRef) { + if (($existingRef['payload_hash'] ?? '') !== $payloadHash) { + throw new \RuntimeException('external_order_no 已存在,但请求内容不一致'); + } + return [ + 'idempotent' => true, + 'order' => $this->buildOrderProgress((int)$customer['id'], $existingRef, (string)$customer['customer_code']), + ]; + } + + $serviceProvider = trim((string)($payload['service_provider'] ?? 'anxinyan')); + if (!in_array($serviceProvider, ['anxinyan', 'zhongjian'], true)) { + throw new \InvalidArgumentException('service_provider 无效'); + } + + $serviceConfig = $this->serviceConfig($serviceProvider); + + $product = $this->normalizeProduct((array)($payload['product_info'] ?? [])); + $returnAddress = $this->normalizeReturnAddress((array)($payload['return_address'] ?? [])); + $materials = $this->normalizeMaterials((array)($payload['materials'] ?? [])); + + $now = date('Y-m-d H:i:s'); + $orderNo = 'AXY' . date('YmdHis') . mt_rand(100, 999); + $appraisalNo = 'AXY-APP-' . date('Ymd') . '-' . mt_rand(1000, 9999); + $estimated = date('Y-m-d H:i:s', strtotime(sprintf('+%d hours', (int)$serviceConfig['sla_hours']))); + $userId = (new EnterpriseCustomerService())->ensureVirtualUser($customer); + $productName = $this->resolveProductName($product); + + Db::startTrans(); + try { + $orderId = (int)Db::name('orders')->insertGetId([ + 'order_no' => $orderNo, + 'appraisal_no' => $appraisalNo, + 'user_id' => $userId, + 'service_mode' => 'physical', + 'service_provider' => $serviceProvider, + 'payment_status' => 'paid', + 'order_status' => 'pending_shipping', + 'display_status' => '待寄送商品', + 'estimated_finish_time' => $estimated, + 'source_channel' => 'enterprise_push', + 'source_customer_id' => $customer['customer_code'], + 'pay_amount' => $serviceConfig['price'], + 'paid_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('order_products')->insert(array_merge($product, [ + 'order_id' => $orderId, + 'product_name' => $productName, + 'product_cover' => $materials[0]['file_url'] ?? '', + 'created_at' => $now, + 'updated_at' => $now, + ])); + + $extra = (array)($payload['extra_info'] ?? []); + Db::name('order_extras')->insert([ + 'order_id' => $orderId, + 'purchase_channel' => trim((string)($extra['purchase_channel'] ?? '')), + 'purchase_price' => (float)($extra['purchase_price'] ?? 0), + 'purchase_date' => $extra['purchase_date'] ?? null, + 'usage_status' => trim((string)($extra['usage_status'] ?? '')), + 'condition_desc' => trim((string)($extra['condition_desc'] ?? '')), + 'has_accessories' => !empty($extra['has_accessories']) ? 1 : 0, + 'accessories_json' => json_encode(array_values((array)($extra['accessories'] ?? [])), JSON_UNESCAPED_UNICODE), + 'remark' => trim((string)($extra['remark'] ?? '')), + 'created_at' => $now, + 'updated_at' => $now, + ]); + + if ($returnAddress) { + Db::name('order_return_addresses')->insert(array_merge($returnAddress, [ + 'order_id' => $orderId, + 'user_address_id' => null, + 'created_at' => $now, + 'updated_at' => $now, + ])); + } + + $shippingTarget = (new WarehouseService())->bindOrderTarget( + $orderId, + $serviceProvider, + !empty($product['category_id']) ? (int)$product['category_id'] : null, + null + ); + + $this->insertMaterials($orderId, $materials, $now); + $this->insertTimelines($orderId, $now, $shippingTarget); + $this->insertTask($orderId, $serviceProvider, $estimated, $now); + $this->insertInboundLogistics($orderId, $this->normalizeInboundLogistics($payload), $now); + + Db::name('enterprise_customer_order_refs')->insert([ + 'customer_id' => (int)$customer['id'], + 'external_order_no' => $externalOrderNo, + 'order_id' => $orderId, + 'order_no' => $orderNo, + 'appraisal_no' => $appraisalNo, + 'payload_hash' => $payloadHash, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + throw $e; + } + + (new EnterpriseWebhookService())->recordOrderEvent($orderId, 'order_created', [ + 'product_name' => $productName, + 'pay_amount' => (float)$serviceConfig['price'], + ]); + + $ref = Db::name('enterprise_customer_order_refs')->where('order_id', $orderId)->find(); + return [ + 'idempotent' => false, + 'order' => $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']), + ]; + } + + public function findOrder(array $customer, string $externalOrderNo = '', string $orderNo = ''): array + { + $query = Db::name('enterprise_customer_order_refs')->where('customer_id', (int)$customer['id']); + if ($externalOrderNo !== '') { + $query->where('external_order_no', $externalOrderNo); + } elseif ($orderNo !== '') { + $query->where('order_no', $orderNo); + } else { + throw new \InvalidArgumentException('external_order_no 或 order_no 不能为空'); + } + + $ref = $query->find(); + if (!$ref) { + throw new \RuntimeException('订单不存在'); + } + + return $this->buildOrderProgress((int)$customer['id'], $ref, (string)$customer['customer_code']); + } + + public function buildOrderProgress(int $customerId, array $ref, string $customerCode = ''): array + { + $order = Db::name('orders')->where('id', (int)$ref['order_id'])->find(); + if (!$order) { + throw new \RuntimeException('订单不存在'); + } + + $timeline = Db::name('order_timelines')->where('order_id', (int)$order['id'])->order('occurred_at', 'asc')->select()->toArray(); + $sendLogistics = Db::name('order_logistics')->where('order_id', (int)$order['id'])->where('logistics_type', 'send_to_center')->order('id', 'desc')->find(); + $returnLogistics = Db::name('order_logistics')->where('order_id', (int)$order['id'])->where('logistics_type', 'return_to_user')->order('id', 'desc')->find(); + $report = Db::name('reports')->where('order_id', (int)$order['id'])->order('id', 'desc')->find(); + $verify = $report ? (Db::name('report_verifies')->where('report_id', (int)$report['id'])->find() ?: null) : null; + + return [ + 'customer_id' => $customerCode !== '' ? $customerCode : (string)$customerId, + 'customer_code' => $customerCode !== '' ? $customerCode : (string)$customerId, + 'external_order_no' => (string)$ref['external_order_no'], + 'order_id' => (int)$order['id'], + 'order_no' => (string)$order['order_no'], + 'appraisal_no' => (string)$order['appraisal_no'], + 'order_status' => (string)$order['order_status'], + 'display_status' => (string)$order['display_status'], + 'payment_status' => (string)$order['payment_status'], + 'pay_amount' => (float)$order['pay_amount'], + 'estimated_finish_time' => (string)($order['estimated_finish_time'] ?? ''), + 'created_at' => (string)$order['created_at'], + 'timeline' => array_map(fn(array $item) => [ + 'node_code' => (string)$item['node_code'], + 'node_text' => (string)$item['node_text'], + 'node_desc' => (string)$item['node_desc'], + 'occurred_at' => (string)$item['occurred_at'], + ], $timeline), + 'inbound_logistics' => $this->formatLogistics($sendLogistics), + 'return_logistics' => $this->formatLogistics($returnLogistics), + 'report_summary' => $report ? [ + 'report_no' => (string)$report['report_no'], + 'report_title' => (string)$report['report_title'], + 'report_status' => (string)$report['report_status'], + 'publish_time' => (string)($report['publish_time'] ?? ''), + 'verify_url' => (string)($verify['verify_url'] ?? ''), + 'report_page_url' => (string)($verify['verify_qrcode_url'] ?? ''), + 'verify_status' => (string)($verify['verify_status'] ?? ''), + ] : null, + ]; + } + + private function normalizePayloadForHash(array $payload): array + { + ksort($payload); + foreach ($payload as &$value) { + if (is_array($value)) { + $value = $this->normalizePayloadForHash($value); + } + } + unset($value); + return $payload; + } + + private function normalizeProduct(array $product): array + { + $productName = trim((string)($product['product_name'] ?? '')); + + return [ + 'category_id' => !empty($product['category_id']) ? (int)$product['category_id'] : null, + 'category_name' => trim((string)($product['category_name'] ?? '')), + 'brand_id' => !empty($product['brand_id']) ? (int)$product['brand_id'] : null, + 'brand_name' => trim((string)($product['brand_name'] ?? '')), + 'color' => trim((string)($product['color'] ?? '')), + 'size_spec' => trim((string)($product['size_spec'] ?? '')), + 'serial_no' => trim((string)($product['serial_no'] ?? '')), + 'product_name' => $productName, + ]; + } + + private function normalizeReturnAddress(array $address): ?array + { + $requiredKeys = ['consignee', 'mobile', 'province', 'city', 'district', 'detail_address']; + $hasAnyValue = false; + foreach ($requiredKeys as $key) { + if (trim((string)($address[$key] ?? '')) !== '') { + $hasAnyValue = true; + break; + } + } + if (!$hasAnyValue) { + return null; + } + + foreach ($requiredKeys as $key) { + if (trim((string)($address[$key] ?? '')) === '') { + throw new \InvalidArgumentException("return_address.{$key} 不能为空"); + } + } + + return [ + 'consignee' => trim((string)$address['consignee']), + 'mobile' => trim((string)$address['mobile']), + 'province' => trim((string)$address['province']), + 'city' => trim((string)$address['city']), + 'district' => trim((string)$address['district']), + 'detail_address' => trim((string)$address['detail_address']), + ]; + } + + private function normalizeMaterials(array $materials): array + { + $list = []; + foreach ($materials as $index => $item) { + if (is_string($item)) { + $url = trim($item); + $itemCode = 'material_' . ($index + 1); + $itemName = '鉴定资料'; + } elseif (is_array($item)) { + $url = trim((string)($item['file_url'] ?? $item['url'] ?? '')); + $itemCode = trim((string)($item['item_code'] ?? 'material_' . ($index + 1))); + $itemName = trim((string)($item['item_name'] ?? '鉴定资料')); + } else { + $url = ''; + $itemCode = 'material_' . ($index + 1); + $itemName = '鉴定资料'; + } + if ($url === '' || !preg_match('/^https?:\/\//i', $url)) { + throw new \InvalidArgumentException('materials 只支持 http/https 图片 URL'); + } + $list[] = [ + 'item_code' => $itemCode, + 'item_name' => $itemName, + 'file_url' => $url, + 'thumbnail_url' => is_array($item) ? trim((string)($item['thumbnail_url'] ?? $url)) : $url, + 'is_required' => is_array($item) && !empty($item['is_required']) ? 1 : 0, + ]; + } + + return $list; + } + + private function normalizeInboundLogistics(array $payload): array + { + $logistics = (array)($payload['inbound_logistics'] ?? []); + if (!empty($payload['express_company']) || !empty($payload['tracking_no'])) { + $logistics = array_merge($logistics, [ + 'express_company' => $payload['express_company'] ?? ($logistics['express_company'] ?? ''), + 'tracking_no' => $payload['tracking_no'] ?? ($logistics['tracking_no'] ?? ''), + ]); + } + + return $logistics; + } + + private function resolveProductName(array $product): string + { + $productName = trim((string)($product['product_name'] ?? '')); + if ($productName !== '') { + return $productName; + } + return trim(($product['brand_name'] ?? '') . ' ' . ($product['category_name'] ?? '')); + } + + private function serviceConfig(string $serviceProvider): array + { + $configs = [ + 'anxinyan' => ['price' => 99.00, 'sla_hours' => 48], + 'zhongjian' => ['price' => 199.00, 'sla_hours' => 72], + ]; + if (isset($configs[$serviceProvider])) { + return $configs[$serviceProvider]; + } + return $configs['anxinyan']; + } + + private function insertMaterials(int $orderId, array $materials, string $now): void + { + foreach ($materials as $item) { + $uploadItemId = (int)Db::name('order_upload_items')->insertGetId([ + 'order_id' => $orderId, + 'template_id' => null, + 'item_code' => $item['item_code'], + 'item_name' => $item['item_name'], + 'is_required' => $item['is_required'], + 'source_type' => 'initial', + 'status' => 'uploaded', + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('order_upload_files')->insert([ + 'order_upload_item_id' => $uploadItemId, + 'file_id' => md5($item['file_url']), + 'file_url' => $item['file_url'], + 'thumbnail_url' => $item['thumbnail_url'], + 'quality_status' => 'uploaded', + 'quality_message' => '', + 'uploaded_by_user_id' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + } + + private function insertTimelines(int $orderId, string $now, array $shippingTarget): void + { + Db::name('order_timelines')->insertAll([ + [ + 'order_id' => $orderId, + 'node_code' => 'created', + 'node_text' => '下单成功', + 'node_desc' => '大客户订单已推送并创建成功', + 'operator_type' => 'system', + 'operator_id' => null, + 'occurred_at' => $now, + 'created_at' => $now, + ], + [ + 'order_id' => $orderId, + 'node_code' => 'pending_shipping', + 'node_text' => '待寄送商品', + 'node_desc' => sprintf('请将商品寄送至%s', $shippingTarget['warehouse_name'] ?: '鉴定中心'), + 'operator_type' => 'system', + 'operator_id' => null, + 'occurred_at' => $now, + 'created_at' => $now, + ], + ]); + } + + private function insertTask(int $orderId, string $serviceProvider, string $estimated, string $now): void + { + Db::name('appraisal_tasks')->insert([ + 'order_id' => $orderId, + 'task_stage' => 'first_review', + 'service_provider' => $serviceProvider, + 'status' => 'pending', + 'assignee_id' => null, + 'assignee_name' => '未分配', + 'started_at' => null, + 'submitted_at' => null, + 'sla_deadline' => $estimated, + 'is_overtime' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]); + } + + private function insertInboundLogistics(int $orderId, array $logistics, string $now): void + { + $expressCompany = trim((string)($logistics['express_company'] ?? '')); + $trackingNo = trim((string)($logistics['tracking_no'] ?? '')); + if ($expressCompany === '' || $trackingNo === '') { + return; + } + + $latestDesc = sprintf('客户已提交寄送运单:%s %s,等待鉴定中心签收。', $expressCompany, $trackingNo); + $logisticsId = (int)Db::name('order_logistics')->insertGetId([ + 'order_id' => $orderId, + 'logistics_type' => 'send_to_center', + 'express_company' => $expressCompany, + 'tracking_no' => $trackingNo, + 'tracking_status' => 'submitted', + 'latest_desc' => $latestDesc, + 'latest_time' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('order_logistics_nodes')->insert([ + 'logistics_id' => $logisticsId, + 'node_time' => $now, + 'node_desc' => $latestDesc, + 'node_location' => '', + 'created_at' => $now, + ]); + } + + private function formatLogistics(?array $logistics): ?array + { + if (!$logistics) { + return null; + } + + return [ + 'express_company' => (string)$logistics['express_company'], + 'tracking_no' => (string)$logistics['tracking_no'], + 'tracking_status' => (string)$logistics['tracking_status'], + 'latest_desc' => (string)$logistics['latest_desc'], + 'latest_time' => (string)($logistics['latest_time'] ?? ''), + ]; + } +} diff --git a/server-api/app/support/EnterpriseWebhookService.php b/server-api/app/support/EnterpriseWebhookService.php new file mode 100644 index 0000000..43f80a1 --- /dev/null +++ b/server-api/app/support/EnterpriseWebhookService.php @@ -0,0 +1,228 @@ +where('order_id', $orderId)->find(); + if (!$ref) { + return null; + } + + $customer = Db::name('enterprise_customers')->where('id', (int)$ref['customer_id'])->find(); + $order = Db::name('orders')->where('id', $orderId)->find(); + if (!$customer || !$order) { + return null; + } + + $eventMeta = $this->eventMeta($eventCode); + $payload = [ + 'event_code' => $eventCode, + 'event_text' => $eventMeta['event_text'], + 'customer_id' => (string)$customer['customer_code'], + 'customer_code' => (string)$customer['customer_code'], + 'external_order_no' => (string)$ref['external_order_no'], + 'order_no' => (string)$order['order_no'], + 'appraisal_no' => (string)$order['appraisal_no'], + 'status_code' => $eventMeta['status_code'], + 'status_text' => $eventMeta['status_text'], + 'occurred_at' => date('Y-m-d H:i:s'), + 'data' => $data, + ]; + + $eventId = (int)Db::name('enterprise_order_events')->insertGetId([ + 'customer_id' => (int)$customer['id'], + 'order_id' => $orderId, + 'external_order_no' => (string)$ref['external_order_no'], + 'event_code' => $eventCode, + 'event_text' => $eventMeta['event_text'], + 'status_code' => $eventMeta['status_code'], + 'status_text' => $eventMeta['status_text'], + 'payload_json' => json_encode($payload, JSON_UNESCAPED_UNICODE), + 'occurred_at' => $payload['occurred_at'], + 'created_at' => date('Y-m-d H:i:s'), + ]); + + $payload['event_id'] = $eventId; + + if ($sendNow) { + $this->deliverEvent($eventId, false); + } + + return $payload; + } + + public function deliverEvent(int $eventId, bool $manual = false): array + { + $event = Db::name('enterprise_order_events')->where('id', $eventId)->find(); + if (!$event) { + throw new \RuntimeException('事件不存在'); + } + + $customer = Db::name('enterprise_customers')->where('id', (int)$event['customer_id'])->find(); + if (!$customer) { + throw new \RuntimeException('客户不存在'); + } + + $url = trim((string)($customer['webhook_url'] ?? '')); + $appKey = (string)Db::name('enterprise_customer_apps') + ->where('customer_id', (int)$customer['id']) + ->where('status', 'enabled') + ->order('id', 'asc') + ->value('app_key'); + $attemptNo = (int)Db::name('enterprise_webhook_deliveries')->where('event_id', $eventId)->count() + 1; + + if (!(bool)($customer['webhook_enabled'] ?? false) || $url === '' || $appKey === '') { + $delivery = $this->saveDelivery($event, $url, $appKey, $attemptNo, 'skipped', 0, '', 'Webhook未启用或配置不完整', $manual); + return ['delivery' => $delivery, 'sent' => false]; + } + + $payload = json_decode((string)$event['payload_json'], true); + if (!is_array($payload)) { + $payload = []; + } + $payload['event_id'] = $eventId; + + $result = $this->postJson($url, $payload, $appKey); + $status = ($result['http_status'] >= 200 && $result['http_status'] < 300 && $result['error_message'] === '') ? 'success' : 'failed'; + + $delivery = $this->saveDelivery( + $event, + $url, + $appKey, + $attemptNo, + $status, + $result['http_status'], + $result['response_body'], + $result['error_message'], + $manual + ); + + return ['delivery' => $delivery, 'sent' => $status === 'success']; + } + + public function formatEvent(array $item): array + { + return [ + 'id' => (int)$item['id'], + 'customer_id' => (int)$item['customer_id'], + 'order_id' => (int)$item['order_id'], + 'external_order_no' => (string)$item['external_order_no'], + 'event_code' => (string)$item['event_code'], + 'event_text' => (string)$item['event_text'], + 'status_code' => (string)$item['status_code'], + 'status_text' => (string)$item['status_text'], + 'occurred_at' => (string)$item['occurred_at'], + 'created_at' => (string)$item['created_at'], + ]; + } + + public function formatDelivery(array $item): array + { + return [ + 'id' => (int)$item['id'], + 'event_id' => (int)$item['event_id'], + 'customer_id' => (int)$item['customer_id'], + 'webhook_url' => (string)$item['webhook_url'], + 'app_key' => (string)$item['app_key'], + 'attempt_no' => (int)$item['attempt_no'], + 'delivery_status' => (string)$item['delivery_status'], + 'delivery_status_text' => $this->deliveryStatusText((string)$item['delivery_status']), + 'http_status' => (int)$item['http_status'], + 'response_body' => (string)($item['response_body'] ?? ''), + 'error_message' => (string)($item['error_message'] ?? ''), + 'is_manual' => (bool)($item['is_manual'] ?? false), + 'sent_at' => (string)($item['sent_at'] ?? ''), + 'created_at' => (string)($item['created_at'] ?? ''), + ]; + } + + private function eventMeta(string $eventCode): array + { + return match ($eventCode) { + 'order_created' => ['event_text' => '订单创建', 'status_code' => 'pending_shipping', 'status_text' => '待寄送商品'], + 'inbound_received' => ['event_text' => '快递已到仓', 'status_code' => 'received', 'status_text' => '鉴定中心已收货'], + 'appraising' => ['event_text' => '物品鉴定中', 'status_code' => 'appraising', 'status_text' => '物品鉴定中'], + 'appraisal_finished' => ['event_text' => '物品鉴定完成', 'status_code' => 'generating_report', 'status_text' => '物品鉴定完成'], + 'report_published' => ['event_text' => '报告已发布', 'status_code' => 'report_published', 'status_text' => '报告已发布'], + 'return_shipped' => ['event_text' => '物品已寄回', 'status_code' => 'return_shipped', 'status_text' => '物品已寄回'], + 'completed' => ['event_text' => '订单已完成', 'status_code' => 'completed', 'status_text' => '已完成'], + 'supplement_required' => ['event_text' => '需要补充资料', 'status_code' => 'pending_supplement', 'status_text' => '需要补充资料'], + default => ['event_text' => $eventCode, 'status_code' => $eventCode, 'status_text' => $eventCode], + }; + } + + private function postJson(string $url, array $payload, string $appKey): array + { + $body = json_encode($payload, JSON_UNESCAPED_UNICODE); + $ch = curl_init($url); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $body, + CURLOPT_TIMEOUT => 6, + CURLOPT_CONNECTTIMEOUT => 3, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'X-AXY-App-Key: ' . $appKey, + ], + ]); + + $response = curl_exec($ch); + $errno = curl_errno($ch); + $error = curl_error($ch); + $httpStatus = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + return [ + 'http_status' => $httpStatus, + 'response_body' => is_string($response) ? substr($response, 0, 2000) : '', + 'error_message' => $errno ? $error : '', + ]; + } + + private function saveDelivery( + array $event, + string $url, + string $appKey, + int $attemptNo, + string $status, + int $httpStatus, + string $responseBody, + string $errorMessage, + bool $manual + ): array { + $now = date('Y-m-d H:i:s'); + $id = (int)Db::name('enterprise_webhook_deliveries')->insertGetId([ + 'event_id' => (int)$event['id'], + 'customer_id' => (int)$event['customer_id'], + 'webhook_url' => $url, + 'app_key' => $appKey, + 'attempt_no' => $attemptNo, + 'delivery_status' => $status, + 'http_status' => $httpStatus, + 'response_body' => $responseBody, + 'error_message' => $errorMessage, + 'is_manual' => $manual ? 1 : 0, + 'sent_at' => $status === 'skipped' ? null : $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return Db::name('enterprise_webhook_deliveries')->where('id', $id)->find() ?: []; + } + + private function deliveryStatusText(string $status): string + { + return match ($status) { + 'success' => '推送成功', + 'failed' => '推送失败', + 'skipped' => '已跳过', + default => $status, + }; + } +} diff --git a/server-api/app/support/FileStorageConfigService.php b/server-api/app/support/FileStorageConfigService.php new file mode 100644 index 0000000..7f82f4b --- /dev/null +++ b/server-api/app/support/FileStorageConfigService.php @@ -0,0 +1,243 @@ +where('config_group', self::GROUP) + ->column('config_value', 'config_key'); + + return [ + 'driver' => $this->normalizeDriver((string)($rows['driver'] ?? 'local')), + 'public_base_url' => trim((string)($rows['public_base_url'] ?? '')), + 'oss_endpoint' => trim((string)($rows['oss_endpoint'] ?? '')), + 'oss_bucket' => trim((string)($rows['oss_bucket'] ?? '')), + 'oss_access_key_id' => trim((string)($rows['oss_access_key_id'] ?? '')), + 'oss_access_key_secret' => trim((string)($rows['oss_access_key_secret'] ?? '')), + 'oss_bucket_domain' => trim((string)($rows['oss_bucket_domain'] ?? '')), + 'oss_path_prefix' => trim((string)($rows['oss_path_prefix'] ?? '')), + 'qiniu_bucket' => trim((string)($rows['qiniu_bucket'] ?? '')), + 'qiniu_access_key' => trim((string)($rows['qiniu_access_key'] ?? '')), + 'qiniu_secret_key' => trim((string)($rows['qiniu_secret_key'] ?? '')), + 'qiniu_bucket_domain' => trim((string)($rows['qiniu_bucket_domain'] ?? '')), + 'qiniu_path_prefix' => trim((string)($rows['qiniu_path_prefix'] ?? '')), + ]; + } + + public function clearCache(): void + { + // noop; kept for call-site compatibility. + } + + public function driver(): string + { + return $this->getConfig()['driver']; + } + + public function isOss(): bool + { + return $this->driver() === 'oss'; + } + + public function isQiniu(): bool + { + return $this->driver() === 'qiniu'; + } + + public function assertReady(): void + { + if ($this->isOss()) { + $config = $this->getConfig(); + $requiredKeys = [ + 'oss_endpoint' => 'OSS Endpoint', + 'oss_bucket' => 'OSS Bucket', + 'oss_access_key_id' => 'OSS AccessKey ID', + 'oss_access_key_secret' => 'OSS AccessKey Secret', + ]; + + foreach ($requiredKeys as $key => $label) { + if (trim((string)($config[$key] ?? '')) === '') { + throw new \RuntimeException(sprintf('%s 未配置,当前无法使用 OSS 存储', $label)); + } + } + return; + } + + if ($this->isQiniu()) { + $config = $this->getConfig(); + $requiredKeys = [ + 'qiniu_bucket' => '七牛 Bucket', + 'qiniu_access_key' => '七牛 AccessKey', + 'qiniu_secret_key' => '七牛 SecretKey', + ]; + + foreach ($requiredKeys as $key => $label) { + if (trim((string)($config[$key] ?? '')) === '') { + throw new \RuntimeException(sprintf('%s 未配置,当前无法使用七牛云存储', $label)); + } + } + } + } + + public function publicBaseUrl(): string + { + $config = $this->getConfig(); + + if ($config['public_base_url'] !== '') { + return $this->normalizeBaseUrl($config['public_base_url']); + } + + if ($this->isOss() && $config['oss_bucket_domain'] !== '') { + return $this->normalizeBaseUrl($config['oss_bucket_domain']); + } + + if ($this->isOss() && $config['oss_endpoint'] !== '' && $config['oss_bucket'] !== '') { + return $this->normalizeBaseUrl(sprintf( + 'https://%s.%s', + $config['oss_bucket'], + $this->normalizeEndpointHost($config['oss_endpoint']) + )); + } + + if ($this->isQiniu() && $config['qiniu_bucket_domain'] !== '') { + return $this->normalizeBaseUrl($config['qiniu_bucket_domain']); + } + + $notifyUrl = Db::name('system_configs') + ->where('config_group', 'payment') + ->where('config_key', 'notify_url') + ->value('config_value'); + if (is_string($notifyUrl) && trim($notifyUrl) !== '') { + return $this->extractOrigin($notifyUrl); + } + + return ''; + } + + public function bucket(): string + { + return $this->getConfig()['oss_bucket']; + } + + public function qiniuBucket(): string + { + return $this->getConfig()['qiniu_bucket']; + } + + public function endpoint(): string + { + return $this->normalizeEndpointHost($this->getConfig()['oss_endpoint']); + } + + public function accessKeyId(): string + { + return $this->getConfig()['oss_access_key_id']; + } + + public function qiniuAccessKey(): string + { + return $this->getConfig()['qiniu_access_key']; + } + + public function accessKeySecret(): string + { + return $this->getConfig()['oss_access_key_secret']; + } + + public function qiniuSecretKey(): string + { + return $this->getConfig()['qiniu_secret_key']; + } + + public function objectKey(string $relativePath): string + { + $relativePath = ltrim($relativePath, '/'); + $config = $this->getConfig(); + $prefix = trim((string)($this->isQiniu() ? ($config['qiniu_path_prefix'] ?? '') : ($config['oss_path_prefix'] ?? '')), '/'); + + if ($prefix === '') { + return $relativePath; + } + + return $prefix . '/' . $relativePath; + } + + public function removePathPrefix(string $objectKey): string + { + $objectKey = ltrim($objectKey, '/'); + $config = $this->getConfig(); + $prefix = trim((string)($this->isQiniu() ? ($config['qiniu_path_prefix'] ?? '') : ($config['oss_path_prefix'] ?? '')), '/'); + + if ($prefix === '') { + return $objectKey; + } + + if (str_starts_with($objectKey, $prefix . '/')) { + return substr($objectKey, strlen($prefix) + 1); + } + + return $objectKey; + } + + public function normalizeDriver(string $value): string + { + return in_array($value, ['oss', 'qiniu'], true) ? $value : 'local'; + } + + public function normalizeBaseUrl(string $baseUrl): string + { + $baseUrl = trim($baseUrl); + if ($baseUrl === '') { + return ''; + } + + if (!preg_match('/^https?:\/\//i', $baseUrl)) { + $baseUrl = 'https://' . ltrim($baseUrl, '/'); + } + + return rtrim($baseUrl, '/'); + } + + private function extractOrigin(string $url): string + { + $parts = parse_url(trim($url)); + $scheme = (string)($parts['scheme'] ?? ''); + $host = (string)($parts['host'] ?? ''); + $port = (string)($parts['port'] ?? ''); + + if ($host === '') { + return $this->normalizeBaseUrl($url); + } + + $origin = ($scheme !== '' ? $scheme : 'https') . '://' . $host; + if ($port !== '' && !(($scheme === 'http' && $port === '80') || ($scheme === 'https' && $port === '443'))) { + $origin .= ':' . $port; + } + + return rtrim($origin, '/'); + } + + public function normalizeEndpointHost(string $endpoint): string + { + $endpoint = trim($endpoint); + if ($endpoint === '') { + return ''; + } + + if (preg_match('/^https?:\/\//i', $endpoint)) { + $host = parse_url($endpoint, PHP_URL_HOST); + if (is_string($host) && $host !== '') { + return $host; + } + } + + return preg_replace('#^https?://#i', '', rtrim($endpoint, '/')) ?: ''; + } +} diff --git a/server-api/app/support/FileStorageService.php b/server-api/app/support/FileStorageService.php new file mode 100644 index 0000000..c4df27d --- /dev/null +++ b/server-api/app/support/FileStorageService.php @@ -0,0 +1,363 @@ +assetUrlService()->buildUrl($request, $this->storagePath($value)); + } + + public function normalizeUrl(string $value, Request $request): string + { + return $this->assetUrlService()->normalizeUrl($value, $request); + } + + public function storagePath(string $value): string + { + return ltrim($this->assetUrlService()->storagePath($value), '/'); + } + + public function putUploadedFile(mixed $file, string $relativePath): void + { + $relativePath = $this->storagePath($relativePath); + + if ($this->configService()->isOss()) { + $this->configService()->assertReady(); + $realPath = $file->getRealPath(); + if (!is_string($realPath) || $realPath === '' || !is_file($realPath)) { + throw new \RuntimeException('上传文件无效'); + } + + $this->ossClient()->uploadFile( + $this->configService()->bucket(), + $this->configService()->objectKey($relativePath), + $realPath + ); + return; + } + + if ($this->configService()->isQiniu()) { + $this->configService()->assertReady(); + $realPath = $file->getRealPath(); + if (!is_string($realPath) || $realPath === '' || !is_file($realPath)) { + throw new \RuntimeException('上传文件无效'); + } + + $key = $this->configService()->objectKey($relativePath); + $this->qiniuUploadFile($realPath, $key); + return; + } + + $target = public_path() . '/' . $relativePath; + if (!is_dir(dirname($target))) { + mkdir(dirname($target), 0775, true); + } + + $file->move($target); + } + + public function putContents(string $relativePath, string $content): void + { + $relativePath = $this->storagePath($relativePath); + + if ($this->configService()->isOss()) { + $this->configService()->assertReady(); + $tmpFile = tempnam(sys_get_temp_dir(), 'anxinyan_oss_'); + if ($tmpFile === false) { + throw new \RuntimeException('无法创建临时文件'); + } + + file_put_contents($tmpFile, $content); + + try { + $this->ossClient()->uploadFile( + $this->configService()->bucket(), + $this->configService()->objectKey($relativePath), + $tmpFile + ); + } finally { + if (file_exists($tmpFile)) { + @unlink($tmpFile); + } + } + return; + } + + if ($this->configService()->isQiniu()) { + $this->configService()->assertReady(); + $tmpFile = tempnam(sys_get_temp_dir(), 'anxinyan_qiniu_'); + if ($tmpFile === false) { + throw new \RuntimeException('无法创建临时文件'); + } + + file_put_contents($tmpFile, $content); + + try { + $key = $this->configService()->objectKey($relativePath); + $this->qiniuUploadFile($tmpFile, $key); + } finally { + if (file_exists($tmpFile)) { + @unlink($tmpFile); + } + } + return; + } + + $target = public_path() . '/' . $relativePath; + if (!is_dir(dirname($target))) { + mkdir(dirname($target), 0775, true); + } + + file_put_contents($target, $content); + } + + public function exists(string $value): bool + { + $relativePath = $this->storagePath($value); + if ($relativePath === '') { + return false; + } + + if ($this->configService()->isOss()) { + $this->configService()->assertReady(); + return $this->ossClient()->doesObjectExist( + $this->configService()->bucket(), + $this->configService()->objectKey($relativePath) + ); + } + + if ($this->configService()->isQiniu()) { + $this->configService()->assertReady(); + [$ret, $err] = $this->qiniuBucketManager()->stat( + $this->configService()->qiniuBucket(), + $this->configService()->objectKey($relativePath) + ); + + if ($err !== null && $this->shouldRetryQiniuWithoutHttps($err)) { + [$ret, $err] = $this->qiniuBucketManager(false)->stat( + $this->configService()->qiniuBucket(), + $this->configService()->objectKey($relativePath) + ); + } + + if ($err === null) { + return true; + } + + if ((int)$err->code() === 612) { + return false; + } + + throw new \RuntimeException('七牛云文件检查失败:' . $err->message()); + } + + return is_file(public_path() . '/' . $relativePath); + } + + public function delete(string $value): void + { + $relativePath = $this->storagePath($value); + if ($relativePath === '') { + return; + } + + if ($this->configService()->isOss()) { + $this->configService()->assertReady(); + $this->ossClient()->deleteObject( + $this->configService()->bucket(), + $this->configService()->objectKey($relativePath) + ); + return; + } + + if ($this->configService()->isQiniu()) { + $this->configService()->assertReady(); + [$ret, $err] = $this->qiniuBucketManager()->delete( + $this->configService()->qiniuBucket(), + $this->configService()->objectKey($relativePath) + ); + + if ($err !== null && $this->shouldRetryQiniuWithoutHttps($err)) { + [$ret, $err] = $this->qiniuBucketManager(false)->delete( + $this->configService()->qiniuBucket(), + $this->configService()->objectKey($relativePath) + ); + } + + if ($err !== null && (int)$err->code() !== 612) { + throw new \RuntimeException('七牛云删除失败:' . $err->message()); + } + return; + } + + $fullPath = public_path() . '/' . $relativePath; + if (file_exists($fullPath) && is_file($fullPath)) { + @unlink($fullPath); + } + } + + private function ossClient(): OssClient + { + if ($this->ossClient instanceof OssClient) { + return $this->ossClient; + } + + $this->configService()->assertReady(); + $this->ensureCaBundleConfigured(); + + return $this->ossClient = new OssClient( + $this->configService()->accessKeyId(), + $this->configService()->accessKeySecret(), + $this->configService()->endpoint() + ); + } + + private function qiniuAuth(): QiniuAuth + { + if ($this->qiniuAuth instanceof QiniuAuth) { + return $this->qiniuAuth; + } + + $this->configService()->assertReady(); + $this->ensureCaBundleConfigured(); + + return $this->qiniuAuth = new QiniuAuth( + $this->configService()->qiniuAccessKey(), + $this->configService()->qiniuSecretKey() + ); + } + + private function qiniuConfig(bool $useHttps = true): QiniuConfig + { + $config = new QiniuConfig(); + $config->useHTTPS = $useHttps; + $config->useCdnDomains = false; + + return $config; + } + + private function qiniuUploadManager(bool $useHttps = true): QiniuUploadManager + { + return new QiniuUploadManager($this->qiniuConfig($useHttps)); + } + + private function qiniuBucketManager(bool $useHttps = true): QiniuBucketManager + { + return new QiniuBucketManager( + $this->qiniuAuth(), + $this->qiniuConfig($useHttps) + ); + } + + private function qiniuUploadFile(string $filePath, string $key): void + { + $token = $this->qiniuAuth()->uploadToken($this->configService()->qiniuBucket(), $key); + try { + [$ret, $err] = $this->qiniuUploadManager()->putFile($token, $key, $filePath); + } catch (\Throwable $e) { + $err = $e; + } + + if ($err !== null && $this->shouldRetryQiniuWithoutHttps($err)) { + try { + [$ret, $err] = $this->qiniuUploadManager(false)->putFile($token, $key, $filePath); + } catch (\Throwable $e) { + $err = $e; + } + } + + if ($err !== null) { + throw new \RuntimeException('七牛云上传失败:' . $this->qiniuErrorMessage($err)); + } + } + + private function shouldRetryQiniuWithoutHttps(mixed $err): bool + { + return stripos($this->qiniuErrorMessage($err), 'SSL certificate problem') !== false; + } + + private function qiniuErrorMessage(mixed $err): string + { + if ($err instanceof \Throwable) { + return $err->getMessage(); + } + + if (is_object($err) && method_exists($err, 'message')) { + return (string)$err->message(); + } + + if (is_string($err) && $err !== '') { + return $err; + } + + return '未知错误'; + } + + private function assetUrlService(): PublicAssetUrlService + { + return new PublicAssetUrlService(); + } + + private function configService(): FileStorageConfigService + { + return new FileStorageConfigService(); + } + + private function ensureCaBundleConfigured(): void + { + if (self::$caBundleInitialized) { + return; + } + + $currentCurlCa = (string)ini_get('curl.cainfo'); + $currentOpensslCa = (string)ini_get('openssl.cafile'); + if (($currentCurlCa !== '' && is_file($currentCurlCa)) || ($currentOpensslCa !== '' && is_file($currentOpensslCa))) { + self::$caBundleInitialized = true; + return; + } + + $candidates = [ + '/etc/ssl/cert.pem', + '/private/etc/ssl/cert.pem', + '/etc/ssl/certs/ca-certificates.crt', + '/etc/pki/tls/certs/ca-bundle.crt', + '/opt/homebrew/etc/openssl@3/cert.pem', + '/usr/local/etc/openssl@3/cert.pem', + ]; + + foreach ($candidates as $path) { + if (!is_file($path)) { + continue; + } + + @ini_set('curl.cainfo', $path); + @ini_set('openssl.cafile', $path); + self::$caBundleInitialized = true; + return; + } + } +} diff --git a/server-api/app/support/MaterialTagService.php b/server-api/app/support/MaterialTagService.php new file mode 100644 index 0000000..ae641ae --- /dev/null +++ b/server-api/app/support/MaterialTagService.php @@ -0,0 +1,704 @@ + self::MAX_BATCH_COUNT) { + throw new \InvalidArgumentException('单批生成数量需在 1-10000 之间'); + } + + $h5BaseUrl = $this->normalizeH5BaseUrl($this->getSystemConfigValue('h5', 'page_base_url')); + if ($h5BaseUrl === '') { + throw new \InvalidArgumentException('请先在系统配置中填写 H5 页面根地址'); + } + + $now = date('Y-m-d H:i:s'); + $batchNo = $this->generateUniqueBatchNo(); + + Db::startTrans(); + try { + $batchId = (int)Db::name('material_batches')->insertGetId([ + 'batch_no' => $batchNo, + 'total_count' => $count, + 'remark' => mb_substr($remark, 0, 500), + 'download_count' => 0, + 'created_by' => $adminId, + 'created_by_name' => $adminName, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + $rows = []; + $pendingTokens = []; + for ($i = 0; $i < $count; $i++) { + $token = $this->generateUniqueToken($pendingTokens); + $pendingTokens[$token] = true; + $rows[] = [ + 'batch_id' => $batchId, + 'qr_token' => $token, + 'qr_url' => $this->buildMaterialTagUrl($token, $h5BaseUrl), + 'verify_code' => $this->generateVerifyCode(), + 'bind_status' => 'unbound', + 'scan_count' => 0, + 'verify_count' => 0, + 'created_at' => $now, + 'updated_at' => $now, + ]; + } + + foreach (array_chunk($rows, 500) as $chunk) { + Db::name('material_tag_codes')->insertAll($chunk); + } + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + throw $e; + } + + return [ + 'id' => $batchId, + 'batch_no' => $batchNo, + 'total_count' => $count, + 'remark' => $remark, + ]; + } + + public function listBatches(array $filters): array + { + $keyword = trim((string)($filters['keyword'] ?? '')); + $qrUrl = trim((string)($filters['qr_url'] ?? '')); + $verifyCode = trim((string)($filters['verify_code'] ?? '')); + $dateStart = trim((string)($filters['date_start'] ?? '')); + $dateEnd = trim((string)($filters['date_end'] ?? '')); + + $query = Db::name('material_batches')->alias('b')->field('b.*')->order('b.id', 'desc'); + + $matchedCodeRows = []; + $codeSearchValue = $qrUrl !== '' ? $qrUrl : ($verifyCode !== '' ? $verifyCode : $keyword); + if ($codeSearchValue !== '') { + $searchToken = $this->extractToken($codeSearchValue) ?: $codeSearchValue; + $codeQuery = Db::name('material_tag_codes') + ->where(function ($builder) use ($codeSearchValue, $qrUrl, $verifyCode, $searchToken) { + if ($qrUrl !== '') { + $builder->whereRaw('(qr_url LIKE :qr_url OR qr_token = :qr_token)', [ + 'qr_url' => "%{$qrUrl}%", + 'qr_token' => $searchToken, + ]); + return; + } + if ($verifyCode !== '') { + $builder->where('verify_code', $verifyCode); + return; + } + $builder->whereRaw('(qr_url LIKE :keyword_qr_url OR qr_token = :keyword_qr_token OR verify_code = :keyword_verify_code)', [ + 'keyword_qr_url' => "%{$codeSearchValue}%", + 'keyword_qr_token' => $searchToken, + 'keyword_verify_code' => $codeSearchValue, + ]); + }); + $matchedCodeRows = $codeQuery->order('id', 'asc')->select()->toArray(); + if (!$matchedCodeRows) { + return []; + } + $query->whereIn('b.id', array_values(array_unique(array_map(fn (array $item) => (int)$item['batch_id'], $matchedCodeRows)))); + } + + if ($dateStart !== '') { + $query->where('b.created_at', '>=', $dateStart . (strlen($dateStart) === 10 ? ' 00:00:00' : '')); + } + if ($dateEnd !== '') { + $query->where('b.created_at', '<=', $dateEnd . (strlen($dateEnd) === 10 ? ' 23:59:59' : '')); + } + + $rows = $query->select()->toArray(); + if (!$rows) { + return []; + } + + $batchIds = array_map(fn (array $item) => (int)$item['id'], $rows); + $boundCounts = Db::name('material_tag_codes') + ->whereIn('batch_id', $batchIds) + ->where('bind_status', 'bound') + ->group('batch_id') + ->column('COUNT(*) AS c', 'batch_id'); + + $matchedByBatch = []; + if ($matchedCodeRows) { + $reportMap = $this->loadReportMap(array_values(array_filter(array_map(fn (array $item) => (int)($item['report_id'] ?? 0), $matchedCodeRows)))); + foreach ($matchedCodeRows as $codeRow) { + $batchId = (int)$codeRow['batch_id']; + $matchedByBatch[$batchId][] = $this->formatTagCode($codeRow, $reportMap[(int)($codeRow['report_id'] ?? 0)] ?? null); + } + } + + return array_map(function (array $row) use ($boundCounts, $matchedByBatch) { + $id = (int)$row['id']; + return [ + 'id' => $id, + 'batch_no' => (string)$row['batch_no'], + 'total_count' => (int)$row['total_count'], + 'bound_count' => (int)($boundCounts[$id] ?? 0), + 'download_count' => (int)$row['download_count'], + 'remark' => (string)($row['remark'] ?? ''), + 'created_by_name' => (string)($row['created_by_name'] ?? ''), + 'last_downloaded_at' => (string)($row['last_downloaded_at'] ?? ''), + 'created_at' => (string)$row['created_at'], + 'matched_codes' => $matchedByBatch[$id] ?? [], + ]; + }, $rows); + } + + public function detail(int $batchId, string $keyword = ''): array + { + $batch = Db::name('material_batches')->where('id', $batchId)->find(); + if (!$batch) { + throw new \RuntimeException('物料批次不存在', 404); + } + + $query = Db::name('material_tag_codes')->where('batch_id', $batchId)->order('id', 'asc'); + $keyword = trim($keyword); + if ($keyword !== '') { + $token = $this->extractToken($keyword); + $query->where(function ($builder) use ($keyword, $token) { + $builder->whereRaw('(qr_url LIKE :detail_qr_url OR verify_code = :detail_verify_code OR qr_token = :detail_qr_token)', [ + 'detail_qr_url' => "%{$keyword}%", + 'detail_verify_code' => $keyword, + 'detail_qr_token' => $token ?: $keyword, + ]); + }); + } + + $codes = $query->select()->toArray(); + $reportMap = $this->loadReportMap(array_values(array_filter(array_map(fn (array $item) => (int)($item['report_id'] ?? 0), $codes)))); + + return [ + 'batch' => [ + 'id' => (int)$batch['id'], + 'batch_no' => (string)$batch['batch_no'], + 'total_count' => (int)$batch['total_count'], + 'download_count' => (int)$batch['download_count'], + 'remark' => (string)($batch['remark'] ?? ''), + 'created_by_name' => (string)($batch['created_by_name'] ?? ''), + 'last_downloaded_at' => (string)($batch['last_downloaded_at'] ?? ''), + 'created_at' => (string)$batch['created_at'], + ], + 'codes' => array_map(fn (array $row) => $this->formatTagCode($row, $reportMap[(int)($row['report_id'] ?? 0)] ?? null), $codes), + ]; + } + + public function downloadBatch(int $batchId, Request $request): array + { + $batch = Db::name('material_batches')->where('id', $batchId)->find(); + if (!$batch) { + throw new \RuntimeException('物料批次不存在', 404); + } + + $codes = Db::name('material_tag_codes') + ->where('batch_id', $batchId) + ->order('id', 'asc') + ->field(['qr_url', 'verify_code']) + ->select() + ->toArray(); + + $filename = sprintf('material-batch-%s.xlsx', preg_replace('/[^a-zA-Z0-9_-]/', '-', (string)$batch['batch_no'])); + $binary = $this->buildXlsxBinary($codes); + $now = date('Y-m-d H:i:s'); + $adminId = (int)$request->header('x-admin-id', 0); + $adminName = trim((string)$request->header('x-admin-name', '')); + + Db::startTrans(); + try { + Db::name('material_batches')->where('id', $batchId)->update([ + 'download_count' => (int)$batch['download_count'] + 1, + 'last_downloaded_at' => $now, + 'updated_at' => $now, + ]); + Db::name('material_batch_download_logs')->insert([ + 'batch_id' => $batchId, + 'operator_id' => $adminId, + 'operator_name' => $adminName, + 'ip' => substr((string)$request->getRealIp(), 0, 64), + 'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500), + 'downloaded_at' => $now, + 'created_at' => $now, + ]); + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + throw $e; + } + + return [ + 'filename' => $filename, + 'content' => $binary, + ]; + } + + public function bindTagToReportByTask(int $taskId, string $input, Request $request): array + { + $tag = $this->findTagByInput($input); + if (!$tag) { + throw new \InvalidArgumentException('吊牌二维码不存在'); + } + if (($tag['bind_status'] ?? '') === 'bound' || (int)($tag['report_id'] ?? 0) > 0) { + throw new \InvalidArgumentException('该吊牌已绑定报告,不能重复绑定'); + } + + $task = Db::name('appraisal_tasks')->where('id', $taskId)->find(); + if (!$task) { + throw new \RuntimeException('任务不存在', 404); + } + $report = Db::name('reports') + ->where('order_id', (int)$task['order_id']) + ->where('report_type', 'appraisal') + ->order('id', 'desc') + ->find(); + if (!$report) { + throw new \InvalidArgumentException('请先提交鉴定结论生成报告草稿后再绑定吊牌'); + } + if (($report['report_status'] ?? '') === 'published') { + throw new \InvalidArgumentException('报告已发布,不能再绑定或更换吊牌'); + } + + $existing = Db::name('material_tag_codes')->where('report_id', (int)$report['id'])->find(); + if ($existing) { + throw new \InvalidArgumentException('当前报告已绑定吊牌,不能重复绑定'); + } + + $now = date('Y-m-d H:i:s'); + Db::name('material_tag_codes')->where('id', (int)$tag['id'])->update([ + 'report_id' => (int)$report['id'], + 'report_no' => (string)$report['report_no'], + 'bind_status' => 'bound', + 'bound_task_id' => $taskId, + 'bound_order_id' => (int)$task['order_id'], + 'bound_by' => (int)$request->header('x-admin-id', 0), + 'bound_by_name' => trim((string)$request->header('x-admin-name', '')), + 'bound_at' => $now, + 'updated_at' => $now, + ]); + + $fresh = Db::name('material_tag_codes')->where('id', (int)$tag['id'])->find(); + return $this->formatTagCode($fresh ?: $tag, [ + 'id' => (int)$report['id'], + 'report_no' => (string)$report['report_no'], + 'report_status' => (string)$report['report_status'], + ]); + } + + public function findBoundTagForReport(int $reportId): ?array + { + if ($reportId <= 0) { + return null; + } + $tag = Db::name('material_tag_codes')->where('report_id', $reportId)->find(); + if (!$tag) { + return null; + } + $report = Db::name('reports')->where('id', $reportId)->find(); + return $this->formatTagCode($tag, $report ?: null); + } + + public function showPublicTag(string $token, Request $request): array + { + $tag = Db::name('material_tag_codes')->where('qr_token', $token)->find(); + if (!$tag) { + throw new \RuntimeException('吊牌不存在', 404); + } + + $now = date('Y-m-d H:i:s'); + Db::name('material_tag_codes')->where('id', (int)$tag['id'])->update([ + 'scan_count' => (int)$tag['scan_count'] + 1, + 'last_scanned_at' => $now, + 'updated_at' => $now, + ]); + $this->insertScanLog($tag, 'scan', false, $request, $now); + $tag['scan_count'] = (int)$tag['scan_count'] + 1; + $tag['last_scanned_at'] = $now; + + $report = (int)($tag['report_id'] ?? 0) > 0 + ? Db::name('reports')->where('id', (int)$tag['report_id'])->find() + : null; + + if (!$report) { + return [ + 'tag_status' => 'unbound', + 'status_text' => '吊牌尚未关联报告', + 'message' => '该吊牌已完成建码,但暂未绑定鉴定报告。', + 'qr_token' => (string)$tag['qr_token'], + 'qr_url' => (string)$tag['qr_url'], + 'scan_count' => (int)$tag['scan_count'], + 'verify_count' => (int)$tag['verify_count'], + 'report_summary' => null, + 'product_summary' => [], + 'result_summary' => [], + 'verify_passed' => false, + ]; + } + + if (($report['report_status'] ?? '') !== 'published') { + return [ + 'tag_status' => 'pending_report', + 'status_text' => '报告生成中', + 'message' => '该吊牌已关联报告,正式报告发布后可查看完整内容。', + 'qr_token' => (string)$tag['qr_token'], + 'qr_url' => (string)$tag['qr_url'], + 'scan_count' => (int)$tag['scan_count'], + 'verify_count' => (int)$tag['verify_count'], + 'report_summary' => [ + 'report_no' => (string)$report['report_no'], + 'report_title' => (string)$report['report_title'], + 'institution_name' => (string)$report['institution_name'], + 'publish_time' => (string)($report['publish_time'] ?? ''), + ], + 'product_summary' => [], + 'result_summary' => [], + 'verify_passed' => false, + ]; + } + + $content = Db::name('report_contents')->where('report_id', (int)$report['id'])->find() ?: []; + return [ + 'tag_status' => 'published', + 'status_text' => '报告已发布', + 'message' => '该吊牌已关联正式鉴定报告,可输入吊牌验真编码完成组合验真。', + 'qr_token' => (string)$tag['qr_token'], + 'qr_url' => (string)$tag['qr_url'], + 'scan_count' => (int)$tag['scan_count'], + 'verify_count' => (int)$tag['verify_count'], + 'report_summary' => [ + 'report_id' => (int)$report['id'], + 'report_no' => (string)$report['report_no'], + 'report_title' => (string)$report['report_title'], + 'institution_name' => (string)$report['institution_name'], + 'publish_time' => (string)($report['publish_time'] ?? ''), + ], + 'product_summary' => $this->decodeJsonField($content['product_snapshot_json'] ?? null), + 'result_summary' => $this->decodeJsonField($content['result_snapshot_json'] ?? null), + 'verify_passed' => false, + ]; + } + + public function verifyPublicTag(string $token, string $reportNo, string $verifyCode, Request $request): array + { + $tag = Db::name('material_tag_codes')->where('qr_token', $token)->find(); + if (!$tag) { + throw new \RuntimeException('吊牌不存在', 404); + } + + $report = (int)($tag['report_id'] ?? 0) > 0 + ? Db::name('reports')->where('id', (int)$tag['report_id'])->find() + : null; + + $passed = $report + && ($report['report_status'] ?? '') === 'published' + && hash_equals((string)$tag['verify_code'], $verifyCode) + && (string)$report['report_no'] === $reportNo; + + $now = date('Y-m-d H:i:s'); + if ($passed) { + Db::name('material_tag_codes')->where('id', (int)$tag['id'])->update([ + 'verify_count' => (int)$tag['verify_count'] + 1, + 'last_verified_at' => $now, + 'updated_at' => $now, + ]); + } + $this->insertScanLog($tag, 'verify_code', $passed, $request, $now, $verifyCode, $reportNo); + + if (!$passed) { + return [ + 'verify_passed' => false, + 'verify_message' => '验真编码与当前吊牌或报告不匹配,请核对后重试。', + 'verify_count' => (int)$tag['verify_count'], + ]; + } + + return [ + 'verify_passed' => true, + 'verify_message' => '组合验真通过,该吊牌二维码、报告编号与验真编码匹配。', + 'verify_count' => (int)$tag['verify_count'] + 1, + ]; + } + + public function extractToken(string $input): string + { + $value = trim($input); + if ($value === '') { + return ''; + } + + $decoded = html_entity_decode($value, ENT_QUOTES | ENT_HTML5); + $parts = parse_url($decoded); + if (isset($parts['query'])) { + parse_str($parts['query'], $query); + if (!empty($query['token'])) { + return trim((string)$query['token']); + } + } + + $fragment = (string)($parts['fragment'] ?? ''); + if ($fragment !== '') { + $questionPos = strpos($fragment, '?'); + if ($questionPos !== false) { + parse_str(substr($fragment, $questionPos + 1), $query); + if (!empty($query['token'])) { + return trim((string)$query['token']); + } + } + } + + if (preg_match('/(?:^|[?&])token=([^&#]+)/', $decoded, $matches)) { + return trim((string)rawurldecode($matches[1])); + } + + return preg_match('/^[a-zA-Z0-9_-]{16,80}$/', $value) ? $value : ''; + } + + private function findTagByInput(string $input): ?array + { + $value = trim($input); + if ($value === '') { + return null; + } + $token = $this->extractToken($value); + if ($token !== '') { + $tag = Db::name('material_tag_codes')->where('qr_token', $token)->find(); + if ($tag) { + return $tag; + } + } + + return Db::name('material_tag_codes')->where('qr_url', $value)->find() ?: null; + } + + private function formatTagCode(array $row, ?array $report): array + { + return [ + 'id' => (int)$row['id'], + 'batch_id' => (int)$row['batch_id'], + 'qr_token' => (string)$row['qr_token'], + 'qr_url' => (string)$row['qr_url'], + 'verify_code' => (string)$row['verify_code'], + 'bind_status' => (string)$row['bind_status'], + 'bind_status_text' => ($row['bind_status'] ?? '') === 'bound' ? '已绑定' : '未绑定', + 'report_id' => (int)($row['report_id'] ?? 0), + 'report_no' => (string)($row['report_no'] ?: ($report['report_no'] ?? '')), + 'report_status' => (string)($report['report_status'] ?? ''), + 'scan_count' => (int)($row['scan_count'] ?? 0), + 'verify_count' => (int)($row['verify_count'] ?? 0), + 'bound_at' => (string)($row['bound_at'] ?? ''), + 'bound_by_name' => (string)($row['bound_by_name'] ?? ''), + 'created_at' => (string)($row['created_at'] ?? ''), + ]; + } + + private function loadReportMap(array $reportIds): array + { + $reportIds = array_values(array_unique(array_filter(array_map('intval', $reportIds)))); + if (!$reportIds) { + return []; + } + $rows = Db::name('reports')->whereIn('id', $reportIds)->select()->toArray(); + $map = []; + foreach ($rows as $row) { + $map[(int)$row['id']] = $row; + } + return $map; + } + + private function buildMaterialTagUrl(string $token, string $baseUrl): string + { + return $baseUrl . '/#/pages/material-tag/detail?token=' . rawurlencode($token); + } + + private function generateUniqueBatchNo(): string + { + for ($i = 0; $i < 20; $i++) { + $candidate = sprintf('MAT-%s-%04d', date('YmdHis'), random_int(0, 9999)); + if (!Db::name('material_batches')->where('batch_no', $candidate)->find()) { + return $candidate; + } + } + return 'MAT-' . date('YmdHis') . '-' . bin2hex(random_bytes(3)); + } + + private function generateUniqueToken(array $pendingTokens): string + { + for ($i = 0; $i < 30; $i++) { + $candidate = 'mt_' . bin2hex(random_bytes(16)); + if (!isset($pendingTokens[$candidate]) && !Db::name('material_tag_codes')->where('qr_token', $candidate)->find()) { + return $candidate; + } + } + throw new \RuntimeException('二维码 token 生成失败,请重试'); + } + + private function generateVerifyCode(): string + { + $code = ''; + $max = strlen(self::VERIFY_CODE_CHARS) - 1; + for ($i = 0; $i < 6; $i++) { + $code .= self::VERIFY_CODE_CHARS[random_int(0, $max)]; + } + return $code; + } + + private function getSystemConfigValue(string $groupCode, string $configKey): string + { + $row = Db::name('system_configs') + ->where('config_group', $groupCode) + ->where('config_key', $configKey) + ->find(); + + return trim((string)($row['config_value'] ?? '')); + } + + private function normalizeH5BaseUrl(string $value): string + { + $baseUrl = trim($value); + if ($baseUrl === '') { + return ''; + } + + $hashPos = strpos($baseUrl, '#'); + if ($hashPos !== false) { + $baseUrl = substr($baseUrl, 0, $hashPos); + } + if (!preg_match('/^https?:\/\//i', $baseUrl)) { + return ''; + } + return rtrim($baseUrl, '/'); + } + + private function decodeJsonField(mixed $value): array + { + if (is_array($value)) { + return $value; + } + if (is_string($value) && $value !== '') { + return json_decode($value, true) ?: []; + } + return []; + } + + private function insertScanLog(array $tag, string $verifyType, bool $passed, Request $request, string $now, string $verifyCode = '', string $reportNo = ''): void + { + Db::name('material_tag_scan_logs')->insert([ + 'tag_code_id' => (int)$tag['id'], + 'batch_id' => (int)$tag['batch_id'], + 'report_id' => (int)($tag['report_id'] ?? 0) ?: null, + 'report_no' => $reportNo !== '' ? $reportNo : (string)($tag['report_no'] ?? ''), + 'verify_type' => $verifyType, + 'verify_code_input' => $verifyCode, + 'verify_passed' => $passed ? 1 : 0, + 'ip' => substr((string)$request->getRealIp(), 0, 64), + 'user_agent' => substr((string)$request->header('user-agent', ''), 0, 500), + 'scanned_at' => $now, + 'created_at' => $now, + ]); + } + + private function buildXlsxBinary(array $rows): string + { + if (!class_exists(\ZipArchive::class)) { + throw new \RuntimeException('当前 PHP 环境缺少 ZipArchive 扩展,无法生成 Excel'); + } + + $tmpFile = tempnam(sys_get_temp_dir(), 'mat_xlsx_'); + if ($tmpFile === false) { + throw new \RuntimeException('临时文件创建失败'); + } + + $zip = new \ZipArchive(); + if ($zip->open($tmpFile, \ZipArchive::OVERWRITE) !== true) { + @unlink($tmpFile); + throw new \RuntimeException('Excel 文件创建失败'); + } + + $zip->addFromString('[Content_Types].xml', $this->xlsxContentTypesXml()); + $zip->addFromString('_rels/.rels', $this->xlsxRelsXml()); + $zip->addFromString('xl/workbook.xml', $this->xlsxWorkbookXml()); + $zip->addFromString('xl/_rels/workbook.xml.rels', $this->xlsxWorkbookRelsXml()); + $zip->addFromString('xl/worksheets/sheet1.xml', $this->xlsxSheetXml($rows)); + $zip->close(); + + $content = file_get_contents($tmpFile); + @unlink($tmpFile); + if ($content === false) { + throw new \RuntimeException('Excel 文件读取失败'); + } + return $content; + } + + private function xlsxContentTypesXml(): string + { + return '' + . '' + . '' + . '' + . '' + . '' + . ''; + } + + private function xlsxRelsXml(): string + { + return '' + . '' + . '' + . ''; + } + + private function xlsxWorkbookXml(): string + { + return '' + . '' + . '' + . ''; + } + + private function xlsxWorkbookRelsXml(): string + { + return '' + . '' + . '' + . ''; + } + + private function xlsxSheetXml(array $rows): string + { + $sheetRows = [ + ['二维码链接', '验真编码'], + ...array_map(fn (array $row) => [(string)$row['qr_url'], (string)$row['verify_code']], $rows), + ]; + + $xmlRows = []; + foreach ($sheetRows as $rowIndex => $row) { + $excelRow = $rowIndex + 1; + $xmlRows[] = sprintf( + '%s%s', + $excelRow, + $excelRow, + htmlspecialchars($row[0], ENT_XML1 | ENT_COMPAT, 'UTF-8'), + $excelRow, + htmlspecialchars($row[1], ENT_XML1 | ENT_COMPAT, 'UTF-8') + ); + } + + return '' + . '' + . '' + . '' . implode('', $xmlRows) . '' + . ''; + } +} diff --git a/server-api/app/support/MessageDispatcher.php b/server-api/app/support/MessageDispatcher.php new file mode 100644 index 0000000..4f32b59 --- /dev/null +++ b/server-api/app/support/MessageDispatcher.php @@ -0,0 +1,174 @@ +shouldSendByPreference($userId, $eventCode)) { + return false; + } + + $rule = Db::name('message_rules') + ->where('event_code', $eventCode) + ->where('channel', 'inbox') + ->where('is_enabled', 1) + ->order('id', 'asc') + ->find(); + + if (!$rule) { + $disabledRule = Db::name('message_rules') + ->where('event_code', $eventCode) + ->where('channel', 'inbox') + ->find(); + + if ($disabledRule) { + return false; + } + } + + $template = null; + if ($rule) { + $template = Db::name('message_templates') + ->where('id', (int)$rule['template_id']) + ->where('channel', 'inbox') + ->where('is_enabled', 1) + ->find(); + } + + $alreadySent = Db::name('message_logs') + ->where('user_id', $userId) + ->where('biz_type', $bizType) + ->where('biz_id', $bizId) + ->where('channel', 'inbox') + ->where('status', 'sent') + ->find(); + + if ($alreadySent) { + return false; + } + + $title = $this->renderTemplate( + $template['title'] ?? (string)($context['fallback_title'] ?? ''), + $context + ); + $content = $this->renderTemplate( + $template['content'] ?? (string)($context['fallback_content'] ?? ''), + $context + ); + + if ($title === '' && $content === '') { + return false; + } + + $now = date('Y-m-d H:i:s'); + + Db::name('user_messages')->insert([ + 'user_id' => $userId, + 'title' => $title !== '' ? $title : '系统通知', + 'content' => $content, + 'biz_type' => $bizType, + 'biz_id' => $bizId, + 'is_read' => 0, + 'read_at' => null, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + Db::name('message_logs')->insert([ + 'user_id' => $userId, + 'template_id' => $template['id'] ?? null, + 'biz_type' => $bizType, + 'biz_id' => $bizId, + 'channel' => 'inbox', + 'status' => 'sent', + 'fail_reason' => '', + 'sent_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + return true; + } + + private function shouldSendByPreference(int $userId, string $eventCode): bool + { + $preferenceKey = $this->preferenceKeyForEvent($eventCode); + if ($preferenceKey === '') { + return true; + } + + $configValue = Db::name('system_configs') + ->where('config_group', 'user_settings') + ->where('config_key', 'user_' . $userId) + ->value('config_value'); + + if (!is_string($configValue) || $configValue === '') { + return $this->defaultPreference($preferenceKey); + } + + $decoded = json_decode($configValue, true); + if (!is_array($decoded)) { + return $this->defaultPreference($preferenceKey); + } + + if (!array_key_exists($preferenceKey, $decoded)) { + return $this->defaultPreference($preferenceKey); + } + + return (bool)$decoded[$preferenceKey]; + } + + private function preferenceKeyForEvent(string $eventCode): string + { + return match ($eventCode) { + 'order_created' => 'notify_order', + 'return_shipped' => 'notify_order', + 'return_received' => 'notify_order', + 'report_published' => 'notify_report', + 'supplement_required' => 'notify_supplement', + 'ticket_reply', 'ticket_waiting_user', 'ticket_resolved', 'ticket_closed' => 'notify_ticket', + default => '', + }; + } + + private function defaultPreference(string $key): bool + { + return match ($key) { + 'marketing_notify', 'privacy_mode' => false, + default => true, + }; + } + + private function renderTemplate(string $text, array $context): string + { + if ($text === '') { + return ''; + } + + return preg_replace_callback('/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/', function (array $matches) use ($context) { + $key = $matches[1]; + if (!array_key_exists($key, $context)) { + return ''; + } + + $value = $context[$key]; + if (is_scalar($value) || $value === null) { + return (string)$value; + } + + return ''; + }, $text) ?? $text; + } +} diff --git a/server-api/app/support/PublicAssetUrlService.php b/server-api/app/support/PublicAssetUrlService.php new file mode 100644 index 0000000..8dfa4b4 --- /dev/null +++ b/server-api/app/support/PublicAssetUrlService.php @@ -0,0 +1,140 @@ +configService()->isOss() + ? $this->configService()->objectKey($relativePath) + : $relativePath; + + return $this->resolveBaseUrl($request) . '/' . ltrim($publicPath, '/'); + } + + public function normalizeUrl(string $value, Request $request): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + + $parts = parse_url($value); + $path = (string)($parts['path'] ?? ''); + $query = (string)($parts['query'] ?? ''); + $fragment = (string)($parts['fragment'] ?? ''); + $host = strtolower((string)($parts['host'] ?? '')); + $scheme = (string)($parts['scheme'] ?? ''); + + if ($scheme === '' && $host === '') { + return $this->appendQueryAndFragment( + $this->buildUrl($request, $path !== '' ? $path : $value), + $query, + $fragment + ); + } + + if ($path !== '' && $this->shouldRewriteHost($host)) { + return $this->appendQueryAndFragment( + $this->buildUrl($request, $path), + $query, + $fragment + ); + } + + return $value; + } + + public function storagePath(string $value): string + { + $value = trim($value); + if ($value === '') { + return ''; + } + + $path = parse_url($value, PHP_URL_PATH); + if (!is_string($path) || $path === '') { + $path = ltrim($value, '/'); + return $this->configService()->isOss() ? $this->configService()->removePathPrefix($path) : $path; + } + + $path = ltrim($path, '/'); + return $this->configService()->isOss() ? $this->configService()->removePathPrefix($path) : $path; + } + + private function resolveBaseUrl(Request $request): string + { + $configured = $this->configService()->publicBaseUrl(); + if ($configured !== '') { + return $configured; + } + + $configured = trim((string)($_ENV['PUBLIC_FILE_BASE_URL'] ?? ($_ENV['APP_PUBLIC_BASE_URL'] ?? ''))); + if ($configured !== '') { + return $this->normalizeBaseUrl($configured); + } + + $scheme = trim((string)($request->header('x-forwarded-proto') ?: 'http')); + $host = trim((string)($request->header('x-forwarded-host') ?: $request->header('host') ?: $request->host())); + if (($commaPos = strpos($host, ',')) !== false) { + $host = trim(substr($host, 0, $commaPos)); + } + + $port = trim((string)($request->header('x-forwarded-port') ?: '')); + if ($port !== '' && strpos($host, ':') === false) { + if (!(($scheme === 'http' && $port === '80') || ($scheme === 'https' && $port === '443'))) { + $host .= ':' . $port; + } + } + + return $this->normalizeBaseUrl($scheme . '://' . $host); + } + + private function normalizeBaseUrl(string $baseUrl): string + { + $baseUrl = trim($baseUrl); + if ($baseUrl === '') { + return ''; + } + + if (!preg_match('/^https?:\/\//i', $baseUrl)) { + $baseUrl = 'https://' . ltrim($baseUrl, '/'); + } + + return rtrim($baseUrl, '/'); + } + + private function configService(): FileStorageConfigService + { + return new FileStorageConfigService(); + } + + private function shouldRewriteHost(string $host): bool + { + if ($host === '') { + return false; + } + + if (in_array($host, ['localhost', '127.0.0.1', '0.0.0.0', 'host.docker.internal'], true)) { + return true; + } + + return preg_match('/^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[0-1])\.)/', $host) === 1; + } + + private function appendQueryAndFragment(string $url, string $query, string $fragment): string + { + if ($query !== '') { + $url .= '?' . $query; + } + if ($fragment !== '') { + $url .= '#' . $fragment; + } + + return $url; + } +} diff --git a/server-api/app/support/ReportPdfGenerator.php b/server-api/app/support/ReportPdfGenerator.php new file mode 100644 index 0000000..7f32dad --- /dev/null +++ b/server-api/app/support/ReportPdfGenerator.php @@ -0,0 +1,172 @@ +buildContentStream($payload); + + $objects = [ + 1 => '<< /Type /Catalog /Pages 2 0 R >>', + 2 => '<< /Type /Pages /Kids [3 0 R] /Count 1 >>', + 3 => '<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>', + 4 => sprintf("<< /Length %d >>\nstream\n%s\nendstream", strlen($content), $content), + 5 => '<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [6 0 R] >>', + 6 => '<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /DW 1000 >>', + ]; + + return $this->renderPdf($objects); + } + + private function buildContentStream(array $payload): string + { + $title = $this->normalizeText((string)($payload['report_title'] ?? '鉴定报告')); + $serviceProviderText = $this->normalizeText((string)($payload['service_provider_text'] ?? '-')); + $institutionName = $this->normalizeText((string)($payload['institution_name'] ?? '-')); + $reportNo = $this->normalizeText((string)($payload['report_no'] ?? '-')); + $publishTime = $this->normalizeText((string)($payload['publish_time'] ?? '-')); + $resultText = $this->normalizeText((string)($payload['result_text'] ?? '-')); + $resultDesc = $this->normalizeText((string)($payload['result_desc'] ?? '-')); + $productName = $this->normalizeText((string)($payload['product_name'] ?? '-')); + $categoryBrand = $this->normalizeText((string)($payload['category_brand'] ?? '-')); + $specInfo = $this->normalizeText((string)($payload['spec_info'] ?? '-')); + $appraisers = $this->normalizeText((string)($payload['appraisers'] ?? '-')); + $conditionGrade = $this->normalizeText((string)($payload['condition_grade'] ?? '-')); + $valuationRange = $this->normalizeText((string)($payload['valuation_range'] ?? '-')); + $verifyInfo = $this->normalizeText((string)($payload['verify_info'] ?? '-')); + $riskNotice = $this->normalizeText((string)($payload['risk_notice_text'] ?? '-')); + + $blocks = []; + $y = 790; + + $blocks[] = $this->textBlock($title, 52, $y, 20); + $y -= 32; + $blocks[] = $this->textBlock('正式报告凭证,请以编号验真结果为准。', 52, $y, 10); + $y -= 30; + + foreach ([ + sprintf('报告编号:%s', $reportNo), + sprintf('出具机构:%s', $institutionName), + sprintf('出具时间:%s', $publishTime), + sprintf('服务类型:%s', $serviceProviderText), + ] as $line) { + $blocks[] = $this->textBlock($line, 52, $y, 12); + $y -= 22; + } + + $y -= 8; + $blocks[] = $this->textBlock(sprintf('鉴定结论:%s', $resultText), 52, $y, 16); + $y -= 26; + + foreach ($this->wrapText('结果说明:' . $resultDesc, 30) as $line) { + $blocks[] = $this->textBlock($line, 52, $y, 11); + $y -= 18; + } + + $y -= 8; + foreach ([ + sprintf('商品名称:%s', $productName), + sprintf('品类 / 品牌:%s', $categoryBrand), + sprintf('颜色 / 规格:%s', $specInfo), + sprintf('鉴定师:%s', $appraisers), + sprintf('成色评级:%s', $conditionGrade), + sprintf('估值区间:%s', $valuationRange), + sprintf('验真信息:%s', $verifyInfo), + ] as $line) { + $blocks[] = $this->textBlock($line, 52, $y, 11); + $y -= 20; + } + + $y -= 8; + foreach ($this->wrapText('风险说明:' . $riskNotice, 30) as $line) { + $blocks[] = $this->textBlock($line, 52, $y, 10); + $y -= 17; + } + + if ($y > 48) { + $blocks[] = $this->textBlock('安心验鉴定平台', 52, 42, 9); + } + + return implode("\n", array_filter($blocks)); + } + + private function wrapText(string $text, int $maxUnits): array + { + $normalized = $this->normalizeText($text); + if ($normalized === '') { + return ['-']; + } + + $chars = preg_split('//u', $normalized, -1, PREG_SPLIT_NO_EMPTY) ?: []; + $lines = []; + $current = ''; + $width = 0.0; + + foreach ($chars as $char) { + $charWidth = strlen($char) === 1 && ord($char) < 128 ? 0.5 : 1.0; + if ($current !== '' && $width + $charWidth > $maxUnits) { + $lines[] = $current; + $current = ''; + $width = 0.0; + } + $current .= $char; + $width += $charWidth; + } + + if ($current !== '') { + $lines[] = $current; + } + + return $lines ?: ['-']; + } + + private function textBlock(string $text, int $x, int $y, int $fontSize): string + { + if ($text === '') { + return ''; + } + + return sprintf( + "BT\n/F1 %d Tf\n1 0 0 1 %d %d Tm\n<%s> Tj\nET", + $fontSize, + $x, + $y, + strtoupper(bin2hex(mb_convert_encoding($text, 'UCS-2BE', 'UTF-8'))) + ); + } + + private function normalizeText(string $text): string + { + $text = trim(str_replace(["\r\n", "\r", "\n", "\t"], [' ', ' ', ' ', ' '], $text)); + return preg_replace('/\s+/u', ' ', $text) ?: ''; + } + + private function renderPdf(array $objects): string + { + $pdf = "%PDF-1.4\n%\xE2\xE3\xCF\xD3\n"; + $offsets = []; + + foreach ($objects as $id => $body) { + $offsets[$id] = strlen($pdf); + $pdf .= sprintf("%d 0 obj\n%s\nendobj\n", $id, $body); + } + + $xrefPosition = strlen($pdf); + $pdf .= sprintf("xref\n0 %d\n", count($objects) + 1); + $pdf .= "0000000000 65535 f \n"; + + foreach ($objects as $id => $_body) { + $pdf .= sprintf("%010d 00000 n \n", $offsets[$id]); + } + + $pdf .= sprintf( + "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF", + count($objects) + 1, + $xrefPosition + ); + + return $pdf; + } +} diff --git a/server-api/app/support/TicketAttachmentService.php b/server-api/app/support/TicketAttachmentService.php new file mode 100644 index 0000000..2e713cb --- /dev/null +++ b/server-api/app/support/TicketAttachmentService.php @@ -0,0 +1,88 @@ +file($inputName); + if (!$file || !$file->isValid()) { + throw new \RuntimeException('上传文件无效'); + } + + $extension = strtolower($file->getUploadExtension() ?: 'jpg'); + $filename = sprintf('ticket_%s.%s', uniqid(), $extension); + $relativeDir = 'uploads/tickets/' . date('Ymd'); + $relativePath = $relativeDir . '/' . $filename; + $this->storage()->putUploadedFile($file, $relativePath); + + $fileUrl = $this->storage()->publicUrl($request, $relativePath); + + return [ + 'file_id' => md5($relativePath), + 'file_url' => $fileUrl, + 'thumbnail_url' => $fileUrl, + 'name' => $file->getUploadName(), + ]; + } + + public function delete(string $fileUrl): void + { + $relativePath = $this->storage()->storagePath($fileUrl); + if (!str_starts_with($relativePath, 'uploads/tickets/')) { + return; + } + + $this->storage()->delete($relativePath); + } + + public function normalize(mixed $attachments, ?Request $request = null, bool $forStorage = false): array + { + if (is_string($attachments) && $attachments !== '') { + $decoded = json_decode($attachments, true); + $attachments = is_array($decoded) ? $decoded : []; + } + + if (!is_array($attachments)) { + return []; + } + + $list = []; + foreach ($attachments as $item) { + if (!is_array($item)) { + continue; + } + + $fileUrl = trim((string)($item['file_url'] ?? '')); + if ($fileUrl === '') { + continue; + } + + $storedFileUrl = $this->storage()->storagePath($fileUrl); + $storedThumbnailUrl = $this->storage()->storagePath(trim((string)($item['thumbnail_url'] ?? $fileUrl))); + + $list[] = [ + 'file_id' => trim((string)($item['file_id'] ?? md5($storedFileUrl))), + 'file_url' => $forStorage + ? '/' . $storedFileUrl + : ($request ? $this->storage()->normalizeUrl($fileUrl, $request) : $fileUrl), + 'thumbnail_url' => $forStorage + ? '/' . $storedThumbnailUrl + : ($request ? $this->storage()->normalizeUrl(trim((string)($item['thumbnail_url'] ?? $fileUrl)), $request) : trim((string)($item['thumbnail_url'] ?? $fileUrl))), + 'name' => trim((string)($item['name'] ?? '')), + ]; + } + + return $list; + } + + private function storage(): FileStorageService + { + return new FileStorageService(); + } +} diff --git a/server-api/app/support/WarehouseService.php b/server-api/app/support/WarehouseService.php new file mode 100644 index 0000000..6898131 --- /dev/null +++ b/server-api/app/support/WarehouseService.php @@ -0,0 +1,574 @@ +ensureWarehouseTable(); + $this->ensureWarehouseRuleColumns(); + $this->ensureOrderTargetTable(); + $this->bootstrapDefaults(); + } + + public function overviewCards(): array + { + return [ + [ + 'title' => '仓库总数', + 'value' => (int)Db::name('shipping_warehouses')->count(), + 'desc' => '当前已维护的检测中心 / 收货仓库数量', + ], + [ + 'title' => '启用仓库', + 'value' => (int)Db::name('shipping_warehouses')->where('status', 'enabled')->count(), + 'desc' => '当前对前台寄送页可见的仓库数量', + ], + [ + 'title' => '安心验仓库', + 'value' => (int)Db::name('shipping_warehouses')->where('service_provider', 'anxinyan')->count(), + 'desc' => '归属安心验服务的默认收货中心数量', + ], + [ + 'title' => '中检仓库', + 'value' => (int)Db::name('shipping_warehouses')->where('service_provider', 'zhongjian')->count(), + 'desc' => '归属中检服务的默认收货中心数量', + ], + ]; + } + + public function list(): array + { + $rows = Db::name('shipping_warehouses') + ->order('service_provider', 'asc') + ->order('is_default', 'desc') + ->order('sort_order', 'asc') + ->order('id', 'desc') + ->select() + ->toArray(); + + return array_map(fn(array $item) => $this->formatWarehouse($item), $rows); + } + + public function save(array $payload, int $id = 0): int + { + $now = date('Y-m-d H:i:s'); + $serviceProvider = trim((string)($payload['service_provider'] ?? 'anxinyan')); + $status = trim((string)($payload['status'] ?? 'enabled')); + $supportedCategoryIds = $this->normalizeIntArray($payload['supported_category_ids'] ?? []); + $serviceAreaProvinces = $this->normalizeStringArray($payload['service_area_provinces'] ?? []); + $serviceAreaCities = $this->normalizeStringArray($payload['service_area_cities'] ?? []); + $warehouseCode = trim((string)($payload['warehouse_code'] ?? '')); + + if ($warehouseCode === '') { + $warehouseCode = $this->generateWarehouseCode($serviceProvider); + } + + $existsByCode = Db::name('shipping_warehouses') + ->where('warehouse_code', $warehouseCode) + ->when($id > 0, fn($query) => $query->where('id', '<>', $id)) + ->find(); + if ($existsByCode) { + throw new \RuntimeException('仓库编码已存在,请更换后重试'); + } + + $data = [ + 'warehouse_name' => trim((string)($payload['warehouse_name'] ?? '')), + 'warehouse_code' => $warehouseCode, + 'warehouse_type' => 'detection_center', + 'service_provider' => $serviceProvider, + 'receiver_name' => trim((string)($payload['receiver_name'] ?? '')), + 'receiver_mobile' => trim((string)($payload['receiver_mobile'] ?? '')), + 'province' => trim((string)($payload['province'] ?? '')), + 'city' => trim((string)($payload['city'] ?? '')), + 'district' => trim((string)($payload['district'] ?? '')), + 'detail_address' => trim((string)($payload['detail_address'] ?? '')), + 'service_time' => trim((string)($payload['service_time'] ?? '')), + 'notice' => trim((string)($payload['notice'] ?? '')), + 'supported_category_ids_json' => $supportedCategoryIds ? json_encode($supportedCategoryIds, JSON_UNESCAPED_UNICODE) : null, + 'service_area_provinces_json' => $serviceAreaProvinces ? json_encode($serviceAreaProvinces, JSON_UNESCAPED_UNICODE) : null, + 'service_area_cities_json' => $serviceAreaCities ? json_encode($serviceAreaCities, JSON_UNESCAPED_UNICODE) : null, + 'status' => $status !== '' ? $status : 'enabled', + 'is_default' => !empty($payload['is_default']) ? 1 : 0, + 'sort_order' => (int)($payload['sort_order'] ?? 0), + 'remark' => trim((string)($payload['remark'] ?? '')), + 'updated_at' => $now, + ]; + + $this->validatePayload($data); + + Db::startTrans(); + try { + if ((int)$data['is_default'] === 1) { + Db::name('shipping_warehouses') + ->where('service_provider', $serviceProvider) + ->update([ + 'is_default' => 0, + 'updated_at' => $now, + ]); + } + + if ($id > 0) { + Db::name('shipping_warehouses')->where('id', $id)->update($data); + $warehouseId = $id; + } else { + $data['created_at'] = $now; + $warehouseId = (int)Db::name('shipping_warehouses')->insertGetId($data); + } + + if ((int)$data['is_default'] !== 1) { + $currentDefault = Db::name('shipping_warehouses') + ->where('service_provider', $serviceProvider) + ->where('status', 'enabled') + ->where('is_default', 1) + ->find(); + if (!$currentDefault) { + Db::name('shipping_warehouses')->where('id', $warehouseId)->update([ + 'is_default' => 1, + 'updated_at' => $now, + ]); + } + } + + Db::commit(); + } catch (\Throwable $e) { + Db::rollback(); + throw $e; + } + + return $warehouseId; + } + + public function resolveForShipping(string $serviceProvider, ?int $categoryId = null, ?array $userAddress = null): array + { + $options = $this->optionsForOrder($serviceProvider, $categoryId, $userAddress); + if (!$options) { + return [ + 'warehouse_id' => 0, + 'warehouse_name' => '', + 'warehouse_code' => '', + 'receiver_name' => '', + 'receiver_mobile' => '', + 'province' => '', + 'city' => '', + 'district' => '', + 'detail_address' => '', + 'service_time' => '', + 'notice' => '', + ]; + } + + $matched = $options[0]; + return [ + 'warehouse_id' => (int)$matched['id'], + 'warehouse_name' => $matched['warehouse_name'], + 'warehouse_code' => $matched['warehouse_code'], + 'receiver_name' => $matched['receiver_name'], + 'receiver_mobile' => $matched['receiver_mobile'], + 'province' => $matched['province'], + 'city' => $matched['city'], + 'district' => $matched['district'], + 'detail_address' => $matched['detail_address'], + 'service_time' => $matched['service_time'], + 'notice' => $matched['notice'], + ]; + } + + public function bindOrderTarget(int $orderId, string $serviceProvider, ?int $categoryId = null, ?array $userAddress = null): array + { + $snapshot = $this->resolveForShipping($serviceProvider, $categoryId, $userAddress); + $now = date('Y-m-d H:i:s'); + + $payload = [ + 'order_id' => $orderId, + 'warehouse_id' => (int)($snapshot['warehouse_id'] ?? 0) ?: null, + 'warehouse_name' => $snapshot['warehouse_name'] ?? '', + 'warehouse_code' => $snapshot['warehouse_code'] ?? '', + 'service_provider' => $serviceProvider, + 'receiver_name' => $snapshot['receiver_name'] ?? '', + 'receiver_mobile' => $snapshot['receiver_mobile'] ?? '', + 'province' => $snapshot['province'] ?? '', + 'city' => $snapshot['city'] ?? '', + 'district' => $snapshot['district'] ?? '', + 'detail_address' => $snapshot['detail_address'] ?? '', + 'service_time' => $snapshot['service_time'] ?? '', + 'notice' => $snapshot['notice'] ?? '', + 'updated_at' => $now, + ]; + + $exists = Db::name('order_shipping_targets')->where('order_id', $orderId)->find(); + if ($exists) { + Db::name('order_shipping_targets')->where('order_id', $orderId)->update($payload); + } else { + $payload['created_at'] = $now; + Db::name('order_shipping_targets')->insert($payload); + } + + return $payload; + } + + public function getOrderTarget(int $orderId): ?array + { + $row = Db::name('order_shipping_targets')->where('order_id', $orderId)->find(); + if (!$row) { + return null; + } + + return [ + 'warehouse_id' => (int)($row['warehouse_id'] ?? 0), + 'warehouse_name' => $row['warehouse_name'] ?? '', + 'warehouse_code' => $row['warehouse_code'] ?? '', + 'receiver_name' => $row['receiver_name'] ?? '', + 'receiver_mobile' => $row['receiver_mobile'] ?? '', + 'province' => $row['province'] ?? '', + 'city' => $row['city'] ?? '', + 'district' => $row['district'] ?? '', + 'detail_address' => $row['detail_address'] ?? '', + 'service_time' => $row['service_time'] ?? '', + 'notice' => $row['notice'] ?? '', + ]; + } + + public function optionsForOrder(string $serviceProvider, ?int $categoryId = null, ?array $userAddress = null): array + { + $rows = Db::name('shipping_warehouses') + ->where('status', 'enabled') + ->where('service_provider', $serviceProvider) + ->select() + ->toArray(); + + if (!$rows) { + $rows = Db::name('shipping_warehouses') + ->where('status', 'enabled') + ->select() + ->toArray(); + } + + $list = array_map(fn(array $item) => $this->formatWarehouse($item), $rows); + + foreach ($list as &$item) { + $item['match_score'] = $this->matchScore($item, $categoryId, $userAddress); + $item['is_recommended'] = $item['match_score'] >= 300; + $item['recommended_reason'] = $this->recommendedReason($item, $categoryId, $userAddress); + } + unset($item); + + usort($list, static function (array $left, array $right) { + if ($left['match_score'] === $right['match_score']) { + if ((int)$left['is_default'] === (int)$right['is_default']) { + if ((int)$left['sort_order'] === (int)$right['sort_order']) { + return (int)$left['id'] <=> (int)$right['id']; + } + return (int)$left['sort_order'] <=> (int)$right['sort_order']; + } + return (int)$right['is_default'] <=> (int)$left['is_default']; + } + + return (int)$right['match_score'] <=> (int)$left['match_score']; + }); + + return $list; + } + + private function formatWarehouse(array $item): array + { + $supportedCategoryIds = $this->decodeIntArray($item['supported_category_ids_json'] ?? null); + $serviceAreaProvinces = $this->decodeStringArray($item['service_area_provinces_json'] ?? null); + $serviceAreaCities = $this->decodeStringArray($item['service_area_cities_json'] ?? null); + + $categoryNames = []; + if ($supportedCategoryIds) { + $categoryNames = Db::name('catalog_categories') + ->whereIn('id', $supportedCategoryIds) + ->column('name'); + } + + return [ + 'id' => (int)$item['id'], + 'warehouse_name' => $item['warehouse_name'], + 'warehouse_code' => $item['warehouse_code'], + 'warehouse_type' => $item['warehouse_type'], + 'warehouse_type_text' => '检测中心 / 收货仓库', + 'service_provider' => $item['service_provider'], + 'service_provider_text' => $item['service_provider'] === 'zhongjian' ? '中检鉴定' : '实物鉴定', + 'receiver_name' => $item['receiver_name'], + 'receiver_mobile' => $item['receiver_mobile'], + 'province' => $item['province'], + 'city' => $item['city'], + 'district' => $item['district'], + 'detail_address' => $item['detail_address'], + 'full_address' => trim(sprintf('%s%s%s%s', $item['province'], $item['city'], $item['district'], $item['detail_address'])), + 'service_time' => $item['service_time'], + 'notice' => $item['notice'], + 'supported_category_ids' => $supportedCategoryIds, + 'supported_category_names' => array_values($categoryNames), + 'service_area_provinces' => $serviceAreaProvinces, + 'service_area_cities' => $serviceAreaCities, + 'status' => $item['status'], + 'status_text' => $item['status'] === 'enabled' ? '启用中' : '已停用', + 'is_default' => (bool)$item['is_default'], + 'sort_order' => (int)$item['sort_order'], + 'remark' => $item['remark'] ?? '', + 'created_at' => $item['created_at'] ?? '', + 'updated_at' => $item['updated_at'] ?? '', + ]; + } + + private function validatePayload(array $data): void + { + foreach (['warehouse_name', 'receiver_name', 'receiver_mobile', 'province', 'city', 'district', 'detail_address', 'service_time'] as $field) { + if (trim((string)($data[$field] ?? '')) === '') { + throw new \RuntimeException('请完整填写仓库名称、收件信息与地址'); + } + } + } + + private function normalizeIntArray(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + return array_values(array_unique(array_filter(array_map(static function ($item) { + $int = (int)$item; + return $int > 0 ? $int : null; + }, $value)))); + } + + private function normalizeStringArray(mixed $value): array + { + if (!is_array($value)) { + return []; + } + + $items = []; + foreach ($value as $item) { + $text = trim((string)$item); + if ($text === '') { + continue; + } + $items[] = $text; + } + + return array_values(array_unique($items)); + } + + private function decodeIntArray(mixed $value): array + { + if (is_array($value)) { + return $this->normalizeIntArray($value); + } + if (is_string($value) && $value !== '') { + $decoded = json_decode($value, true); + return is_array($decoded) ? $this->normalizeIntArray($decoded) : []; + } + return []; + } + + private function decodeStringArray(mixed $value): array + { + if (is_array($value)) { + return $this->normalizeStringArray($value); + } + if (is_string($value) && $value !== '') { + $decoded = json_decode($value, true); + return is_array($decoded) ? $this->normalizeStringArray($decoded) : []; + } + return []; + } + + private function generateWarehouseCode(string $serviceProvider): string + { + $prefix = $serviceProvider === 'zhongjian' ? 'ZJ' : 'AXY'; + return sprintf('%s-WH-%s', $prefix, date('YmdHis')); + } + + private function ensureWarehouseTable(): void + { + Db::execute(<<<'SQL' +CREATE TABLE IF NOT EXISTS shipping_warehouses ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + warehouse_name VARCHAR(128) NOT NULL DEFAULT '', + warehouse_code VARCHAR(64) NOT NULL DEFAULT '', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'detection_center', + service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan', + receiver_name VARCHAR(64) NOT NULL DEFAULT '', + receiver_mobile VARCHAR(32) NOT NULL DEFAULT '', + province VARCHAR(64) NOT NULL DEFAULT '', + city VARCHAR(64) NOT NULL DEFAULT '', + district VARCHAR(64) NOT NULL DEFAULT '', + detail_address VARCHAR(255) NOT NULL DEFAULT '', + service_time VARCHAR(128) NOT NULL DEFAULT '', + notice VARCHAR(500) NOT NULL DEFAULT '', + supported_category_ids_json JSON NULL, + service_area_provinces_json JSON NULL, + service_area_cities_json JSON NULL, + status VARCHAR(32) NOT NULL DEFAULT 'enabled', + is_default TINYINT(1) NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_shipping_warehouses_code (warehouse_code), + KEY idx_shipping_warehouses_service_provider (service_provider), + KEY idx_shipping_warehouses_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货仓库 / 检测中心'; +SQL); + } + + private function ensureWarehouseRuleColumns(): void + { + $columns = Db::query("SHOW COLUMNS FROM shipping_warehouses LIKE 'service_area_provinces_json'"); + if (!$columns) { + Db::execute("ALTER TABLE shipping_warehouses ADD COLUMN service_area_provinces_json JSON NULL AFTER supported_category_ids_json"); + } + + $columns = Db::query("SHOW COLUMNS FROM shipping_warehouses LIKE 'service_area_cities_json'"); + if (!$columns) { + Db::execute("ALTER TABLE shipping_warehouses ADD COLUMN service_area_cities_json JSON NULL AFTER service_area_provinces_json"); + } + } + + private function ensureOrderTargetTable(): void + { + Db::execute(<<<'SQL' +CREATE TABLE IF NOT EXISTS order_shipping_targets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + warehouse_id BIGINT UNSIGNED NULL DEFAULT NULL, + warehouse_name VARCHAR(128) NOT NULL DEFAULT '', + warehouse_code VARCHAR(64) NOT NULL DEFAULT '', + service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan', + receiver_name VARCHAR(64) NOT NULL DEFAULT '', + receiver_mobile VARCHAR(32) NOT NULL DEFAULT '', + province VARCHAR(64) NOT NULL DEFAULT '', + city VARCHAR(64) NOT NULL DEFAULT '', + district VARCHAR(64) NOT NULL DEFAULT '', + detail_address VARCHAR(255) NOT NULL DEFAULT '', + service_time VARCHAR(128) NOT NULL DEFAULT '', + notice VARCHAR(500) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_order_shipping_targets_order_id (order_id), + KEY idx_order_shipping_targets_warehouse_id (warehouse_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单锁定仓库快照' +SQL); + } + + private function bootstrapDefaults(): void + { + $count = (int)Db::name('shipping_warehouses')->count(); + if ($count > 0) { + return; + } + + $now = date('Y-m-d H:i:s'); + Db::name('shipping_warehouses')->insertAll([ + [ + 'warehouse_name' => '安心验鉴定中心', + 'warehouse_code' => 'AXY-WH-DEFAULT', + 'warehouse_type' => 'detection_center', + 'service_provider' => 'anxinyan', + 'receiver_name' => '安心验鉴定中心', + 'receiver_mobile' => '400-800-1314', + 'province' => '广东省', + 'city' => '深圳市', + 'district' => '南山区', + 'detail_address' => '科技园鉴定路 88 号 安心验收件中心', + 'service_time' => '周一至周日 09:30-18:30', + 'notice' => '寄送前请确认订单信息完整,包裹内附上订单号可提升签收后的处理效率。', + 'supported_category_ids_json' => null, + 'service_area_provinces_json' => null, + 'service_area_cities_json' => null, + 'status' => 'enabled', + 'is_default' => 1, + 'sort_order' => 1, + 'remark' => '默认仓库', + 'created_at' => $now, + 'updated_at' => $now, + ], + [ + 'warehouse_name' => '中检合作鉴定中心', + 'warehouse_code' => 'ZJ-WH-DEFAULT', + 'warehouse_type' => 'detection_center', + 'service_provider' => 'zhongjian', + 'receiver_name' => '中检合作鉴定中心', + 'receiver_mobile' => '400-800-1314', + 'province' => '广东省', + 'city' => '深圳市', + 'district' => '南山区', + 'detail_address' => '科技园鉴定路 88 号 安心验中检收件中心', + 'service_time' => '周一至周日 09:30-18:30', + 'notice' => '中检鉴定订单请优先附上鉴定单号,寄出后尽快填写运单号。', + 'supported_category_ids_json' => null, + 'service_area_provinces_json' => null, + 'service_area_cities_json' => null, + 'status' => 'enabled', + 'is_default' => 1, + 'sort_order' => 1, + 'remark' => '默认仓库', + 'created_at' => $now, + 'updated_at' => $now, + ], + ]); + } + + private function matchScore(array $warehouse, ?int $categoryId, ?array $userAddress): int + { + $score = 0; + + if ($categoryId && (!$warehouse['supported_category_ids'] || in_array($categoryId, $warehouse['supported_category_ids'], true))) { + $score += 200; + } + + if ($userAddress) { + $province = trim((string)($userAddress['province'] ?? '')); + $city = trim((string)($userAddress['city'] ?? '')); + + if ($province !== '' && (!$warehouse['service_area_provinces'] || in_array($province, $warehouse['service_area_provinces'], true))) { + $score += 120; + } + + if ($city !== '' && (!$warehouse['service_area_cities'] || in_array($city, $warehouse['service_area_cities'], true))) { + $score += 180; + } + } + + if ($warehouse['is_default']) { + $score += 40; + } + + return $score; + } + + private function recommendedReason(array $warehouse, ?int $categoryId, ?array $userAddress): string + { + $reasons = []; + + if ($categoryId && (!$warehouse['supported_category_ids'] || in_array($categoryId, $warehouse['supported_category_ids'], true))) { + $reasons[] = '匹配当前品类'; + } + + if ($userAddress) { + $province = trim((string)($userAddress['province'] ?? '')); + $city = trim((string)($userAddress['city'] ?? '')); + + if ($city !== '' && (!$warehouse['service_area_cities'] || in_array($city, $warehouse['service_area_cities'], true))) { + $reasons[] = '匹配当前城市'; + } elseif ($province !== '' && (!$warehouse['service_area_provinces'] || in_array($province, $warehouse['service_area_provinces'], true))) { + $reasons[] = '匹配当前省份'; + } + } + + if (!$reasons && $warehouse['is_default']) { + $reasons[] = '默认仓库'; + } + + return implode(' / ', $reasons); + } +} diff --git a/server-api/app/view/index/view.html b/server-api/app/view/index/view.html new file mode 100644 index 0000000..67ebb26 --- /dev/null +++ b/server-api/app/view/index/view.html @@ -0,0 +1,14 @@ + + + + + + + + webman + + + +hello + + diff --git a/server-api/composer.json b/server-api/composer.json new file mode 100644 index 0000000..6cac57a --- /dev/null +++ b/server-api/composer.json @@ -0,0 +1,67 @@ +{ + "name": "workerman/webman", + "type": "project", + "keywords": [ + "high performance", + "http service" + ], + "homepage": "https://www.workerman.net", + "license": "MIT", + "description": "High performance HTTP Service Framework.", + "authors": [ + { + "name": "walkor", + "email": "walkor@workerman.net", + "homepage": "https://www.workerman.net", + "role": "Developer" + } + ], + "support": { + "email": "walkor@workerman.net", + "issues": "https://github.com/walkor/webman/issues", + "forum": "https://wenda.workerman.net/", + "wiki": "https://workerman.net/doc/webman", + "source": "https://github.com/walkor/webman" + }, + "require": { + "php": ">=8.1", + "workerman/webman-framework": "^2.1", + "monolog/monolog": "^2.0", + "webman/think-orm": "^2.1", + "webman/redis-queue": "^2.1", + "vlucas/phpdotenv": "^5.6", + "alibabacloud/dysmsapi-20170525": "^4.3", + "aliyuncs/oss-sdk-php": "^2.7", + "qiniu/php-sdk": "^7.14" + }, + "suggest": { + "ext-event": "For better performance. " + }, + "autoload": { + "psr-4": { + "": "./", + "app\\": "./app", + "App\\": "./app", + "app\\View\\Components\\": "./app/view/components" + } + }, + "scripts": { + "post-package-install": [ + "support\\Plugin::install" + ], + "post-package-update": [ + "support\\Plugin::install" + ], + "pre-package-uninstall": [ + "support\\Plugin::uninstall" + ], + "post-create-project-cmd": [ + "support\\Setup::run" + ], + "setup-webman": [ + "support\\Setup::run" + ] + }, + "minimum-stability": "dev", + "prefer-stable": true +} diff --git a/server-api/composer.lock b/server-api/composer.lock new file mode 100644 index 0000000..1f3df06 --- /dev/null +++ b/server-api/composer.lock @@ -0,0 +1,2069 @@ +{ + "_readme": [ + "This file locks the dependencies of your project to a known state", + "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", + "This file is @generated automatically" + ], + "content-hash": "c852000f424b2ffe429e61469d2723f6", + "packages": [ + { + "name": "adbario/php-dot-notation", + "version": "2.5.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/adbario/php-dot-notation/2.5.0/adbario-php-dot-notation-2.5.0.zip", + "reference": "081e2cca50c84bfeeea2e3ef9b2c8d206d80ccae", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^5.5 || ^7.0 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8|^5.7|^6.6|^7.5|^8.5|^9.5", + "squizlabs/php_codesniffer": "^3.6" + }, + "type": "library", + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Adbar\\": "src" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Riku Särkinen", + "email": "riku@adbar.io" + } + ], + "description": "PHP dot notation access to arrays", + "homepage": "https://github.com/adbario/php-dot-notation", + "keywords": [ + "ArrayAccess", + "dotnotation" + ], + "support": { + "issues": "https://github.com/adbario/php-dot-notation/issues", + "source": "https://github.com/adbario/php-dot-notation/tree/2.5.0" + }, + "time": "2022-10-14T20:31:46+00:00" + }, + { + "name": "alibabacloud/credentials", + "version": "1.2.3", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/alibabacloud/credentials/1.2.3/alibabacloud-credentials-1.2.3.zip", + "reference": "f6d1986e7b7be8da781d0b99f24c92d9860ba0c1", + "shasum": "" + }, + "require": { + "adbario/php-dot-notation": "^2.2", + "alibabacloud/tea": "^3.0", + "ext-curl": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-simplexml": "*", + "ext-xmlwriter": "*", + "guzzlehttp/guzzle": "^6.3|^7.0", + "php": ">=5.6" + }, + "require-dev": { + "composer/composer": "^1.8", + "drupal/coder": "^8.3", + "ext-dom": "*", + "ext-pcre": "*", + "ext-sockets": "*", + "ext-spl": "*", + "mikey179/vfsstream": "^1.6", + "monolog/monolog": "^1.24", + "phpunit/phpunit": "^5.7|^6.6|^9.3", + "psr/cache": "^1.0", + "symfony/dotenv": "^3.4", + "symfony/var-dumper": "^3.4" + }, + "suggest": { + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "autoload": { + "psr-4": { + "AlibabaCloud\\Credentials\\": "src" + } + }, + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Alibaba Cloud SDK", + "email": "sdk-team@alibabacloud.com", + "homepage": "http://www.alibabacloud.com" + } + ], + "description": "Alibaba Cloud Credentials for PHP", + "homepage": "https://www.alibabacloud.com/", + "keywords": [ + "alibaba", + "alibabacloud", + "aliyun", + "client", + "cloud", + "credentials", + "library", + "sdk", + "tool" + ], + "support": { + "issues": "https://github.com/aliyun/credentials-php/issues", + "source": "https://github.com/aliyun/credentials-php" + }, + "time": "2025-04-18T09:09:46+00:00" + }, + { + "name": "alibabacloud/darabonba", + "version": "v1.0.4", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/alibabacloud/darabonba/v1.0.4/alibabacloud-darabonba-v1.0.4.zip", + "reference": "b1ccea693258ea68e455e330922406f3afe10e9c", + "shasum": "" + }, + "require": { + "adbario/php-dot-notation": "^2.4", + "alibabacloud/tea": "^3.2", + "ext-curl": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-simplexml": "*", + "ext-xmlwriter": "*", + "guzzlehttp/guzzle": "^6.3|^7.0", + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35|^5.4.3|^9.3", + "symfony/dotenv": "^3.4", + "symfony/var-dumper": "^3.4" + }, + "suggest": { + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "autoload": { + "psr-4": { + "AlibabaCloud\\Dara\\": "src" + } + }, + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Alibaba Cloud SDK", + "email": "sdk-team@alibabacloud.com", + "homepage": "http://www.alibabacloud.com" + } + ], + "description": "Client of Darabonba for PHP", + "homepage": "https://www.alibabacloud.com/", + "keywords": [ + "alibabacloud", + "client", + "cloud", + "tea" + ], + "support": { + "issues": "https://github.com/aliyun/tea-php/issues", + "source": "https://github.com/aliyun/tea-php" + }, + "time": "2025-12-15T10:15:24+00:00" + }, + { + "name": "alibabacloud/dysmsapi-20170525", + "version": "4.5.1", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/alibabacloud/dysmsapi-20170525/4.5.1/alibabacloud-dysmsapi-20170525-4.5.1.zip", + "reference": "1361ecae87674e883cc6e1251033693e3ba0e38d", + "shasum": "" + }, + "require": { + "alibabacloud/darabonba": "^1.0.0", + "alibabacloud/openapi-core": "^1.0.0", + "php": ">5.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "AlibabaCloud\\SDK\\Dysmsapi\\V20170525\\": "src" + } + }, + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Alibaba Cloud SDK", + "email": "sdk-team@alibabacloud.com" + } + ], + "description": "Alibaba Cloud Dysmsapi (20170525) SDK Library for PHP", + "support": { + "source": "https://github.com/alibabacloud-sdk-php/Dysmsapi-20170525/tree/4.5.1" + }, + "time": "2026-04-07T18:19:34+00:00" + }, + { + "name": "alibabacloud/gateway-spi", + "version": "1.0.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/alibabacloud/gateway-spi/1.0.0/alibabacloud-gateway-spi-1.0.0.zip", + "reference": "7440f77750c329d8ab252db1d1d967314ccd1fcb", + "shasum": "" + }, + "require": { + "alibabacloud/credentials": "^1.1", + "php": ">5.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Darabonba\\GatewaySpi\\": "src" + } + }, + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Alibaba Cloud SDK", + "email": "sdk-team@alibabacloud.com" + } + ], + "description": "Alibaba Cloud Gateway SPI Client", + "support": { + "source": "https://github.com/alibabacloud-sdk-php/alibabacloud-gateway-spi/tree/1.0.0" + }, + "time": "2022-07-14T05:31:35+00:00" + }, + { + "name": "alibabacloud/openapi-core", + "version": "1.0.9", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/alibabacloud/openapi-core/1.0.9/alibabacloud-openapi-core-1.0.9.zip", + "reference": "7b241b2a0e71f70629e2ecdc5cbe8c8b6b3a7567", + "shasum": "" + }, + "require": { + "alibabacloud/credentials": "^1.2.2", + "alibabacloud/darabonba": "^1", + "alibabacloud/gateway-spi": "^1", + "php": ">5.5" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35|^5.4.3|^9.3", + "symfony/dotenv": "^3.4", + "symfony/var-dumper": "^3.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "Darabonba\\OpenApi\\": "src" + } + }, + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Alibaba Cloud SDK", + "email": "sdk-team@alibabacloud.com" + } + ], + "description": "Alibaba Cloud OpenApi Client Core", + "support": { + "issues": "https://github.com/alibabacloud-sdk-php/openapi-core/issues", + "source": "https://github.com/alibabacloud-sdk-php/openapi-core/tree/1.0.9" + }, + "time": "2026-01-15T06:47:29+00:00" + }, + { + "name": "alibabacloud/tea", + "version": "3.2.1", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/alibabacloud/tea/3.2.1/alibabacloud-tea-3.2.1.zip", + "reference": "1619cb96c158384f72b873e1f85de8b299c9c367", + "shasum": "" + }, + "require": { + "adbario/php-dot-notation": "^2.4", + "ext-curl": "*", + "ext-json": "*", + "ext-libxml": "*", + "ext-mbstring": "*", + "ext-openssl": "*", + "ext-simplexml": "*", + "ext-xmlwriter": "*", + "guzzlehttp/guzzle": "^6.3|^7.0", + "php": ">=5.5" + }, + "require-dev": { + "phpunit/phpunit": "*", + "symfony/dotenv": "^3.4", + "symfony/var-dumper": "^3.4" + }, + "suggest": { + "ext-sockets": "To use client-side monitoring" + }, + "type": "library", + "autoload": { + "psr-4": { + "AlibabaCloud\\Tea\\": "src" + } + }, + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Alibaba Cloud SDK", + "email": "sdk-team@alibabacloud.com", + "homepage": "http://www.alibabacloud.com" + } + ], + "description": "Client of Tea for PHP", + "homepage": "https://www.alibabacloud.com/", + "keywords": [ + "alibabacloud", + "client", + "cloud", + "tea" + ], + "support": { + "issues": "https://github.com/aliyun/tea-php/issues", + "source": "https://github.com/aliyun/tea-php" + }, + "time": "2023-05-16T06:43:41+00:00" + }, + { + "name": "aliyuncs/oss-sdk-php", + "version": "v2.7.2", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/aliyuncs/oss-sdk-php/v2.7.2/aliyuncs-oss-sdk-php-v2.7.2.zip", + "reference": "483dd0b8bff5d47f0e4ffc99f6077a295c5ccbb5", + "shasum": "" + }, + "require": { + "php": ">=5.3" + }, + "require-dev": { + "php-coveralls/php-coveralls": "*", + "phpunit/phpunit": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "OSS\\": "src/OSS" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Aliyuncs", + "homepage": "http://www.aliyun.com" + } + ], + "description": "Aliyun OSS SDK for PHP", + "homepage": "http://www.aliyun.com/product/oss/", + "support": { + "issues": "https://github.com/aliyun/aliyun-oss-php-sdk/issues", + "source": "https://github.com/aliyun/aliyun-oss-php-sdk/tree/v2.7.2" + }, + "time": "2024-10-28T10:41:12+00:00" + }, + { + "name": "graham-campbell/result-type", + "version": "v1.1.4", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/graham-campbell/result-type/v1.1.4/graham-campbell-result-type-v1.1.4.zip", + "reference": "e01f4a821471308ba86aa202fed6698b6b695e3b", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5" + }, + "require-dev": { + "phpunit/phpunit": "^8.5.41 || ^9.6.22 || ^10.5.45 || ^11.5.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "GrahamCampbell\\ResultType\\": "src/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "An Implementation Of The Result Type", + "keywords": [ + "Graham Campbell", + "GrahamCampbell", + "Result Type", + "Result-Type", + "result" + ], + "support": { + "issues": "https://github.com/GrahamCampbell/Result-Type/issues", + "source": "https://github.com/GrahamCampbell/Result-Type/tree/v1.1.4" + }, + "time": "2025-12-27T19:43:20+00:00" + }, + { + "name": "guzzlehttp/guzzle", + "version": "7.10.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/guzzlehttp/guzzle/7.10.0/guzzlehttp-guzzle-7.10.0.zip", + "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^2.3", + "guzzlehttp/psr7": "^2.8", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + }, + "time": "2025-08-23T22:36:01+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.3.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/guzzlehttp/promises/2.3.0/guzzlehttp-promises-2.3.0.zip", + "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.3.0" + }, + "time": "2025-08-22T14:34:08+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.9.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/guzzlehttp/psr7/2.9.0/guzzlehttp-psr7-2.9.0.zip", + "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "jshttp/mime-db": "1.54.0.1", + "phpunit/phpunit": "^8.5.44 || ^9.6.25" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.9.0" + }, + "time": "2026-03-10T16:41:02+00:00" + }, + { + "name": "monolog/monolog", + "version": "2.11.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/monolog/monolog/2.11.0/monolog-monolog-2.11.0.zip", + "reference": "37308608e599f34a1a4845b16440047ec98a172a", + "shasum": "" + }, + "require": { + "php": ">=7.2", + "psr/log": "^1.0.1 || ^2.0 || ^3.0" + }, + "provide": { + "psr/log-implementation": "1.0.0 || 2.0.0 || 3.0.0" + }, + "require-dev": { + "aws/aws-sdk-php": "^2.4.9 || ^3.0", + "doctrine/couchdb": "~1.0@dev", + "elasticsearch/elasticsearch": "^7 || ^8", + "ext-json": "*", + "graylog2/gelf-php": "^1.4.2 || ^2@dev", + "guzzlehttp/guzzle": "^7.4", + "guzzlehttp/psr7": "^2.2", + "mongodb/mongodb": "^1.8 || ^2.0", + "php-amqplib/php-amqplib": "~2.4 || ^3", + "phpspec/prophecy": "^1.15", + "phpstan/phpstan": "^1.10", + "phpunit/phpunit": "^8.5.38 || ^9.6.19", + "predis/predis": "^1.1 || ^2.0", + "rollbar/rollbar": "^1.3 || ^2 || ^3", + "ruflin/elastica": "^7", + "swiftmailer/swiftmailer": "^5.3|^6.0", + "symfony/mailer": "^5.4 || ^6", + "symfony/mime": "^5.4 || ^6" + }, + "suggest": { + "aws/aws-sdk-php": "Allow sending log messages to AWS services like DynamoDB", + "doctrine/couchdb": "Allow sending log messages to a CouchDB server", + "elasticsearch/elasticsearch": "Allow sending log messages to an Elasticsearch server via official client", + "ext-amqp": "Allow sending log messages to an AMQP server (1.0+ required)", + "ext-curl": "Required to send log messages using the IFTTTHandler, the LogglyHandler, the SendGridHandler, the SlackWebhookHandler or the TelegramBotHandler", + "ext-mbstring": "Allow to work properly with unicode symbols", + "ext-mongodb": "Allow sending log messages to a MongoDB server (via driver)", + "ext-openssl": "Required to send log messages using SSL", + "ext-sockets": "Allow sending log messages to a Syslog server (via UDP driver)", + "graylog2/gelf-php": "Allow sending log messages to a GrayLog2 server", + "mongodb/mongodb": "Allow sending log messages to a MongoDB server (via library)", + "php-amqplib/php-amqplib": "Allow sending log messages to an AMQP server using php-amqplib", + "rollbar/rollbar": "Allow sending log messages to Rollbar", + "ruflin/elastica": "Allow sending log messages to an Elastic Search server" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "2.x-dev" + } + }, + "autoload": { + "psr-4": { + "Monolog\\": "src/Monolog" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jordi Boggiano", + "email": "j.boggiano@seld.be", + "homepage": "https://seld.be" + } + ], + "description": "Sends your logs to files, sockets, inboxes, databases and various web services", + "homepage": "https://github.com/Seldaek/monolog", + "keywords": [ + "log", + "logging", + "psr-3" + ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/2.11.0" + }, + "time": "2026-01-01T13:05:00+00:00" + }, + { + "name": "myclabs/php-enum", + "version": "1.8.5", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/myclabs/php-enum/1.8.5/myclabs-php-enum-1.8.5.zip", + "reference": "e7be26966b7398204a234f8673fdad5ac6277802", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5", + "squizlabs/php_codesniffer": "1.*", + "vimeo/psalm": "^4.6.2 || ^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "MyCLabs\\Enum\\": "src/" + }, + "classmap": [ + "stubs/Stringable.php" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP Enum contributors", + "homepage": "https://github.com/myclabs/php-enum/graphs/contributors" + } + ], + "description": "PHP Enum implementation", + "homepage": "https://github.com/myclabs/php-enum", + "keywords": [ + "enum" + ], + "support": { + "issues": "https://github.com/myclabs/php-enum/issues", + "source": "https://github.com/myclabs/php-enum/tree/1.8.5" + }, + "time": "2025-01-14T11:49:03+00:00" + }, + { + "name": "nikic/fast-route", + "version": "v1.3.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/nikic/fast-route/v1.3.0/nikic-fast-route-v1.3.0.zip", + "reference": "181d480e08d9476e61381e04a71b34dc0432e812", + "shasum": "" + }, + "require": { + "php": ">=5.4.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.8.35|~5.7" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "FastRoute\\": "src/" + } + }, + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Nikita Popov", + "email": "nikic@php.net" + } + ], + "description": "Fast request router for PHP", + "keywords": [ + "router", + "routing" + ], + "support": { + "issues": "https://github.com/nikic/FastRoute/issues", + "source": "https://github.com/nikic/FastRoute/tree/master" + }, + "time": "2018-02-13T20:26:39+00:00" + }, + { + "name": "phpoption/phpoption", + "version": "1.9.5", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/phpoption/phpoption/1.9.5/phpoption-phpoption-1.9.5.zip", + "reference": "75365b91986c2405cf5e1e012c5595cd487a98be", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.44 || ^9.6.25 || ^10.5.53 || ^11.5.34" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "1.9-dev" + } + }, + "autoload": { + "psr-4": { + "PhpOption\\": "src/PhpOption/" + } + }, + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "Johannes M. Schmitt", + "email": "schmittjoh@gmail.com", + "homepage": "https://github.com/schmittjoh" + }, + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + } + ], + "description": "Option Type for PHP", + "keywords": [ + "language", + "option", + "php", + "type" + ], + "support": { + "issues": "https://github.com/schmittjoh/php-option/issues", + "source": "https://github.com/schmittjoh/php-option/tree/1.9.5" + }, + "time": "2025-12-27T19:41:33+00:00" + }, + { + "name": "psr/container", + "version": "2.0.2", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/psr/container/2.0.2/psr-container-2.0.2.zip", + "reference": "c71ecc56dfe541dbd90c5360474fbc405f8d5963", + "shasum": "" + }, + "require": { + "php": ">=7.4.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Container\\": "src/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common Container Interface (PHP FIG PSR-11)", + "homepage": "https://github.com/php-fig/container", + "keywords": [ + "PSR-11", + "container", + "container-interface", + "container-interop", + "psr" + ], + "support": { + "issues": "https://github.com/php-fig/container/issues", + "source": "https://github.com/php-fig/container/tree/2.0.2" + }, + "time": "2021-11-05T16:47:00+00:00" + }, + { + "name": "psr/http-client", + "version": "1.0.3", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/psr/http-client/1.0.3/psr-http-client-1.0.3.zip", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/psr/http-factory/1.1.0/psr-http-factory-1.1.0.zip", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "2.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/psr/http-message/2.0/psr-http-message-2.0.zip", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/2.0" + }, + "time": "2023-04-04T09:54:51+00:00" + }, + { + "name": "psr/log", + "version": "3.0.2", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/psr/log/3.0.2/psr-log-3.0.2.zip", + "reference": "f16e1d5863e37f8d8c2a01719f5b34baa2b714d3", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Log\\": "src" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for logging libraries", + "homepage": "https://github.com/php-fig/log", + "keywords": [ + "log", + "psr", + "psr-3" + ], + "support": { + "source": "https://github.com/php-fig/log/tree/3.0.2" + }, + "time": "2024-09-11T13:17:53+00:00" + }, + { + "name": "psr/simple-cache", + "version": "3.0.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/psr/simple-cache/3.0.0/psr-simple-cache-3.0.0.zip", + "reference": "764e0b3939f5ca87cb904f570ef9be2d78a07865", + "shasum": "" + }, + "require": { + "php": ">=8.0.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\SimpleCache\\": "src/" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interfaces for simple caching", + "keywords": [ + "cache", + "caching", + "psr", + "psr-16", + "simple-cache" + ], + "support": { + "source": "https://github.com/php-fig/simple-cache/tree/3.0.0" + }, + "time": "2021-10-29T13:26:27+00:00" + }, + { + "name": "qiniu/php-sdk", + "version": "v7.14.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/qiniu/php-sdk/v7.14.0/qiniu-php-sdk-v7.14.0.zip", + "reference": "ee752ffa7263ce99fca0bd7340cf13c486a3516c", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "ext-xml": "*", + "myclabs/php-enum": "~1.5.2 || ~1.6.6 || ~1.7.7 || ~1.8.4", + "php": ">=5.3.3" + }, + "require-dev": { + "paragonie/random_compat": ">=2", + "phpunit/phpunit": "^4.8 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4", + "squizlabs/php_codesniffer": "^2.3 || ~3.6" + }, + "type": "library", + "autoload": { + "files": [ + "src/Qiniu/functions.php", + "src/Qiniu/Http/Middleware/Middleware.php" + ], + "psr-4": { + "Qiniu\\": "src/Qiniu" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Qiniu", + "email": "sdk@qiniu.com", + "homepage": "http://www.qiniu.com" + } + ], + "description": "Qiniu Resource (Cloud) Storage SDK for PHP", + "homepage": "http://developer.qiniu.com/", + "keywords": [ + "cloud", + "qiniu", + "sdk", + "storage" + ], + "support": { + "issues": "https://github.com/qiniu/php-sdk/issues", + "source": "https://github.com/qiniu/php-sdk/tree/v7.14.0" + }, + "time": "2024-10-25T08:39:01+00:00" + }, + { + "name": "ralouphie/getallheaders", + "version": "3.0.3", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/ralouphie/getallheaders/3.0.3/ralouphie-getallheaders-3.0.3.zip", + "reference": "120b605dfeb996808c31b6477290a714d356e822", + "shasum": "" + }, + "require": { + "php": ">=5.6" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/getallheaders.php" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" + } + ], + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.6.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/deprecation-contracts/v3.6.0/symfony-deprecation-contracts-v3.6.0.zip", + "reference": "63afe740e99a13ba87ec199bb07bbdee937a5b62", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/contracts", + "name": "symfony/contracts" + }, + "branch-alias": { + "dev-main": "3.6-dev" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.6.0" + }, + "time": "2024-09-25T14:21:43+00:00" + }, + { + "name": "symfony/polyfill-ctype", + "version": "v1.36.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/polyfill-ctype/v1.36.0/symfony-polyfill-ctype-v1.36.0.zip", + "reference": "141046a8f9477948ff284fa65be2095baafb94f2", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-ctype": "*" + }, + "suggest": { + "ext-ctype": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Ctype\\": "" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Gert de Pagter", + "email": "BackEndTea@gmail.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for ctype functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "ctype", + "polyfill", + "portable" + ], + "support": { + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.36.0" + }, + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.36.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/polyfill-mbstring/v1.36.0/symfony-polyfill-mbstring-v1.36.0.zip", + "reference": "6a21eb99c6973357967f6ce3708cd55a6bec6315", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.36.0" + }, + "time": "2026-04-10T17:25:58+00:00" + }, + { + "name": "symfony/polyfill-php80", + "version": "v1.36.0", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/symfony/polyfill-php80/v1.36.0/symfony-polyfill-php80-v1.36.0.zip", + "reference": "dfb55726c3a76ea3b6459fcfda1ec2d80a682411", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "url": "https://github.com/symfony/polyfill", + "name": "symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php80\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ion Bazan", + "email": "ion.bazan@gmail.com" + }, + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.0+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php80/tree/v1.36.0" + }, + "time": "2026-04-10T16:19:22+00:00" + }, + { + "name": "topthink/think-container", + "version": "v3.0.2", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/topthink/think-container/v3.0.2/topthink-think-container-v3.0.2.zip", + "reference": "b2df244be1e7399ad4c8be1ccc40ed57868f730a", + "shasum": "" + }, + "require": { + "php": ">=8.0", + "psr/container": "^2.0", + "topthink/think-helper": "^3.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "files": [], + "psr-4": { + "think\\": "src" + } + }, + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "liu21st", + "email": "liu21st@gmail.com" + } + ], + "description": "PHP Container & Facade Manager", + "support": { + "issues": "https://github.com/top-think/think-container/issues", + "source": "https://github.com/top-think/think-container/tree/v3.0.2" + }, + "time": "2025-04-07T03:21:51+00:00" + }, + { + "name": "topthink/think-helper", + "version": "v3.1.12", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/topthink/think-helper/v3.1.12/topthink-think-helper-v3.1.12.zip", + "reference": "fe277121112a8f1c872e169a733ca80bb11c4acb", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/helper.php" + ], + "psr-4": { + "think\\": "src" + } + }, + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "yunwuxin", + "email": "448901948@qq.com" + } + ], + "description": "The ThinkPHP6 Helper Package", + "support": { + "issues": "https://github.com/top-think/think-helper/issues", + "source": "https://github.com/top-think/think-helper/tree/v3.1.12" + }, + "time": "2025-12-26T09:58:29+00:00" + }, + { + "name": "topthink/think-orm", + "version": "v4.0.51", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/topthink/think-orm/v4.0.51/topthink-think-orm-v4.0.51.zip", + "reference": "46abe2f824eb3bcb117d4c0ce93b203b592b79f7", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-pdo": "*", + "php": ">=8.0.0", + "psr/log": ">=1.0", + "psr/simple-cache": "^3.0", + "topthink/think-helper": "^3.1", + "topthink/think-validate": "^3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6|^10" + }, + "suggest": { + "ext-mongodb": "provide mongodb support" + }, + "type": "library", + "autoload": { + "files": [ + "src/helper.php", + "stubs/load_stubs.php" + ], + "psr-4": { + "think\\": "src" + } + }, + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "liu21st", + "email": "liu21st@gmail.com" + } + ], + "description": "the PHP Database&ORM Framework", + "keywords": [ + "database", + "orm" + ], + "support": { + "issues": "https://github.com/top-think/think-orm/issues", + "source": "https://github.com/top-think/think-orm/tree/v4.0.51" + }, + "time": "2025-12-18T13:11:52+00:00" + }, + { + "name": "topthink/think-validate", + "version": "v3.0.7", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/topthink/think-validate/v3.0.7/topthink-think-validate-v3.0.7.zip", + "reference": "85063f6d4ef8ed122f17a36179dc3e0949b30988", + "shasum": "" + }, + "require": { + "php": ">=8.0", + "topthink/think-container": ">=3.0" + }, + "type": "library", + "autoload": { + "files": [ + "src/helper.php" + ], + "psr-4": { + "think\\": "src" + } + }, + "license": [ + "Apache-2.0" + ], + "authors": [ + { + "name": "liu21st", + "email": "liu21st@gmail.com" + } + ], + "description": "think validate", + "support": { + "issues": "https://github.com/top-think/think-validate/issues", + "source": "https://github.com/top-think/think-validate/tree/v3.0.7" + }, + "time": "2025-06-11T05:51:40+00:00" + }, + { + "name": "vlucas/phpdotenv", + "version": "v5.6.3", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/vlucas/phpdotenv/v5.6.3/vlucas-phpdotenv-v5.6.3.zip", + "reference": "955e7815d677a3eaa7075231212f2110983adecc", + "shasum": "" + }, + "require": { + "ext-pcre": "*", + "graham-campbell/result-type": "^1.1.4", + "php": "^7.2.5 || ^8.0", + "phpoption/phpoption": "^1.9.5", + "symfony/polyfill-ctype": "^1.26", + "symfony/polyfill-mbstring": "^1.26", + "symfony/polyfill-php80": "^1.26" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-filter": "*", + "phpunit/phpunit": "^8.5.34 || ^9.6.13 || ^10.4.2" + }, + "suggest": { + "ext-filter": "Required to use the boolean validator." + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + }, + "branch-alias": { + "dev-master": "5.6-dev" + } + }, + "autoload": { + "psr-4": { + "Dotenv\\": "src/" + } + }, + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Vance Lucas", + "email": "vance@vancelucas.com", + "homepage": "https://github.com/vlucas" + } + ], + "description": "Loads environment variables from `.env` to `getenv()`, `$_ENV` and `$_SERVER` automagically.", + "keywords": [ + "dotenv", + "env", + "environment" + ], + "support": { + "issues": "https://github.com/vlucas/phpdotenv/issues", + "source": "https://github.com/vlucas/phpdotenv/tree/v5.6.3" + }, + "time": "2025-12-27T19:49:13+00:00" + }, + { + "name": "webman/redis-queue", + "version": "v2.1.1", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/webman/redis-queue/v2.1.1/webman-redis-queue-v2.1.1.zip", + "reference": "ff4791e21f3c324a47e21da7b6f2dae5a7311dcb", + "shasum": "" + }, + "require": { + "ext-redis": "*", + "php": ">=8.1", + "workerman/redis-queue": "^1.2", + "workerman/webman-framework": "^2.1 || dev-master" + }, + "type": "library", + "autoload": { + "psr-4": { + "Webman\\RedisQueue\\": "./src" + } + }, + "description": "Redis message queue plugin for webman.", + "support": { + "issues": "https://github.com/webman-php/redis-queue/issues", + "source": "https://github.com/webman-php/redis-queue/tree/v2.1.1" + }, + "time": "2025-11-14T07:12:52+00:00" + }, + { + "name": "webman/think-orm", + "version": "v2.1.11", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/webman/think-orm/v2.1.11/webman-think-orm-v2.1.11.zip", + "reference": "81fb87a085ceb2d25be5e86da12befbbbfe70c0e", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "topthink/think-container": "^2.0|^3.0", + "topthink/think-orm": "^2.0.53 || ^3.0.0 || ^4.0.30 || dev-master", + "workerman/webman-framework": "^2.1 || dev-master" + }, + "type": "library", + "autoload": { + "psr-4": { + "support\\": "src/support", + "Webman\\ThinkOrm\\": "src" + } + }, + "license": [ + "MIT" + ], + "support": { + "issues": "https://github.com/webman-php/think-orm/issues", + "source": "https://github.com/webman-php/think-orm/tree/v2.1.11" + }, + "time": "2026-03-25T01:34:20+00:00" + }, + { + "name": "workerman/coroutine", + "version": "v1.1.5", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/workerman/coroutine/v1.1.5/workerman-coroutine-v1.1.5.zip", + "reference": "b60e44267b90d398dbfa7a320f3e97b46357ac9f", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "workerman/workerman": "^5.1" + }, + "require-dev": { + "phpunit/phpunit": "^11.0", + "psr/log": "*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Workerman\\": "src", + "Workerman\\Coroutine\\": "src" + } + }, + "license": [ + "MIT" + ], + "description": "Workerman coroutine", + "support": { + "issues": "https://github.com/workerman-php/coroutine/issues", + "source": "https://github.com/workerman-php/coroutine/tree/v1.1.5" + }, + "time": "2026-03-12T02:07:37+00:00" + }, + { + "name": "workerman/redis", + "version": "v2.0.5", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/workerman/redis/v2.0.5/workerman-redis-v2.0.5.zip", + "reference": "49627c1809eff1ef7175eb8ee7549234a1d67ec5", + "shasum": "" + }, + "require": { + "php": ">=7", + "workerman/workerman": "^4.1.0||^5.0.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Workerman\\Redis\\": "./src" + } + }, + "license": [ + "MIT" + ], + "homepage": "http://www.workerman.net", + "support": { + "issues": "https://github.com/walkor/redis/issues", + "source": "https://github.com/walkor/redis/tree/v2.0.5" + }, + "time": "2025-04-07T01:58:58+00:00" + }, + { + "name": "workerman/redis-queue", + "version": "v1.2.2", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/workerman/redis-queue/v1.2.2/workerman-redis-queue-v1.2.2.zip", + "reference": "f0ba4ea9143ae02f39b998ed908d107354cb43c0", + "shasum": "" + }, + "require": { + "php": ">=7.0", + "workerman/redis": "^1.0||^2.0", + "workerman/workerman": ">=4.0.20" + }, + "type": "library", + "autoload": { + "psr-4": { + "Workerman\\RedisQueue\\": "./src" + } + }, + "license": [ + "MIT" + ], + "description": "Message queue system written in PHP based on workerman and backed by Redis.", + "homepage": "http://www.workerman.net", + "support": { + "issues": "https://github.com/walkor/redis-queue/issues", + "source": "https://github.com/walkor/redis-queue/tree/v1.2.2" + }, + "time": "2026-01-20T14:57:09+00:00" + }, + { + "name": "workerman/webman-framework", + "version": "v2.2.1", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/workerman/webman-framework/v2.2.1/workerman-webman-framework-v2.2.1.zip", + "reference": "ce54d8f8f4c1f2c336293dbc37df1ea46ec34c92", + "shasum": "" + }, + "require": { + "ext-json": "*", + "nikic/fast-route": "^1.3", + "php": ">=8.1", + "psr/container": ">=1.0", + "psr/log": "^2.0 || ^3.0", + "workerman/workerman": "^5.1 || dev-master" + }, + "suggest": { + "ext-event": "For better performance. " + }, + "type": "library", + "autoload": { + "files": [ + "./src/support/helpers.php" + ], + "psr-4": { + "Webman\\": "./src", + "Support\\": "./src/support", + "support\\": "./src/support", + "Support\\View\\": "./src/support/view", + "Support\\Bootstrap\\": "./src/support/bootstrap", + "Support\\Exception\\": "./src/support/exception" + } + }, + "license": [ + "MIT" + ], + "authors": [ + { + "name": "walkor", + "email": "walkor@workerman.net", + "homepage": "https://www.workerman.net", + "role": "Developer" + } + ], + "description": "High performance HTTP Service Framework.", + "homepage": "https://www.workerman.net", + "keywords": [ + "High Performance", + "http service" + ], + "support": { + "email": "walkor@workerman.net", + "forum": "https://wenda.workerman.net/", + "issues": "https://github.com/walkor/webman/issues", + "source": "https://github.com/walkor/webman-framework", + "wiki": "https://doc.workerman.net/" + }, + "time": "2026-03-26T01:51:42+00:00" + }, + { + "name": "workerman/workerman", + "version": "v5.1.10", + "dist": { + "type": "zip", + "url": "https://mirrors.cloud.tencent.com/repository/composer/workerman/workerman/v5.1.10/workerman-workerman-v5.1.10.zip", + "reference": "6ecda94609c40ade0f1e548535d24d8e09e67409", + "shasum": "" + }, + "require": { + "ext-json": "*", + "php": ">=8.1", + "workerman/coroutine": "^1.1 || dev-main" + }, + "conflict": { + "ext-swow": "=8.1" + }, + "platform-dev": [], + "plugin-api-version": "2.3.0" +} diff --git a/server-api/config/app.php b/server-api/config/app.php new file mode 100644 index 0000000..f26e358 --- /dev/null +++ b/server-api/config/app.php @@ -0,0 +1,26 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use support\Request; + +return [ + 'debug' => true, + 'error_reporting' => E_ALL, + 'default_timezone' => 'Asia/Shanghai', + 'request_class' => Request::class, + 'public_path' => base_path() . DIRECTORY_SEPARATOR . 'public', + 'runtime_path' => base_path(false) . DIRECTORY_SEPARATOR . 'runtime', + 'controller_suffix' => 'Controller', + 'controller_reuse' => false, +]; diff --git a/server-api/config/autoload.php b/server-api/config/autoload.php new file mode 100644 index 0000000..69a8135 --- /dev/null +++ b/server-api/config/autoload.php @@ -0,0 +1,21 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + 'files' => [ + base_path() . '/app/functions.php', + base_path() . '/support/Request.php', + base_path() . '/support/Response.php', + ] +]; diff --git a/server-api/config/bootstrap.php b/server-api/config/bootstrap.php new file mode 100644 index 0000000..3012750 --- /dev/null +++ b/server-api/config/bootstrap.php @@ -0,0 +1,19 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + app\bootstrap\Dotenv::class, + support\bootstrap\Session::class, + Webman\ThinkOrm\ThinkOrm::class, +]; diff --git a/server-api/config/container.php b/server-api/config/container.php new file mode 100644 index 0000000..106b7b4 --- /dev/null +++ b/server-api/config/container.php @@ -0,0 +1,15 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return new Webman\Container; \ No newline at end of file diff --git a/server-api/config/dependence.php b/server-api/config/dependence.php new file mode 100644 index 0000000..8e964ed --- /dev/null +++ b/server-api/config/dependence.php @@ -0,0 +1,15 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return []; \ No newline at end of file diff --git a/server-api/config/exception.php b/server-api/config/exception.php new file mode 100644 index 0000000..f2aede3 --- /dev/null +++ b/server-api/config/exception.php @@ -0,0 +1,17 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + '' => support\exception\Handler::class, +]; \ No newline at end of file diff --git a/server-api/config/log.php b/server-api/config/log.php new file mode 100644 index 0000000..7f05de5 --- /dev/null +++ b/server-api/config/log.php @@ -0,0 +1,32 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + 'default' => [ + 'handlers' => [ + [ + 'class' => Monolog\Handler\RotatingFileHandler::class, + 'constructor' => [ + runtime_path() . '/logs/webman.log', + 7, //$maxFiles + Monolog\Logger::DEBUG, + ], + 'formatter' => [ + 'class' => Monolog\Formatter\LineFormatter::class, + 'constructor' => [null, 'Y-m-d H:i:s', true], + ], + ] + ], + ], +]; diff --git a/server-api/config/middleware.php b/server-api/config/middleware.php new file mode 100644 index 0000000..c75573c --- /dev/null +++ b/server-api/config/middleware.php @@ -0,0 +1,21 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + '' => [ + app\middleware\CorsMiddleware::class, + app\middleware\AppAuthMiddleware::class, + app\middleware\AdminAuthMiddleware::class, + ], +]; diff --git a/server-api/config/plugin/webman/redis-queue/app.php b/server-api/config/plugin/webman/redis-queue/app.php new file mode 100644 index 0000000..8f9c426 --- /dev/null +++ b/server-api/config/plugin/webman/redis-queue/app.php @@ -0,0 +1,4 @@ + true, +]; \ No newline at end of file diff --git a/server-api/config/plugin/webman/redis-queue/command.php b/server-api/config/plugin/webman/redis-queue/command.php new file mode 100644 index 0000000..8bfe2a1 --- /dev/null +++ b/server-api/config/plugin/webman/redis-queue/command.php @@ -0,0 +1,7 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + 'default' => [ + 'handlers' => [ + [ + 'class' => Monolog\Handler\RotatingFileHandler::class, + 'constructor' => [ + runtime_path() . '/logs/redis-queue/queue.log', + 7, //$maxFiles + Monolog\Logger::DEBUG, + ], + 'formatter' => [ + 'class' => Monolog\Formatter\LineFormatter::class, + 'constructor' => [null, 'Y-m-d H:i:s', true], + ], + ] + ], + ] +]; diff --git a/server-api/config/plugin/webman/redis-queue/process.php b/server-api/config/plugin/webman/redis-queue/process.php new file mode 100644 index 0000000..c8d4da1 --- /dev/null +++ b/server-api/config/plugin/webman/redis-queue/process.php @@ -0,0 +1,11 @@ + [ + 'handler' => Webman\RedisQueue\Process\Consumer::class, + 'count' => 8, // 可以设置多进程同时消费 + 'constructor' => [ + // 消费者类目录 + 'consumer_dir' => app_path() . '/queue/redis' + ] + ] +]; \ No newline at end of file diff --git a/server-api/config/plugin/webman/redis-queue/redis.php b/server-api/config/plugin/webman/redis-queue/redis.php new file mode 100644 index 0000000..16c6c32 --- /dev/null +++ b/server-api/config/plugin/webman/redis-queue/redis.php @@ -0,0 +1,25 @@ + [ + 'host' => sprintf( + 'redis://%s:%s', + $_ENV['REDIS_HOST'] ?? '127.0.0.1', + $_ENV['REDIS_PORT'] ?? '6379' + ), + 'options' => [ + 'auth' => $_ENV['REDIS_PASSWORD'] ?? null, + 'db' => (int)($_ENV['REDIS_DB'] ?? 0), + 'prefix' => $_ENV['REDIS_PREFIX'] ?? '', + 'max_attempts' => 5, + 'retry_seconds' => 5, + ], + // Connection pool, supports only Swoole or Swow drivers. + 'pool' => [ + 'max_connections' => 5, + 'min_connections' => 1, + 'wait_timeout' => 3, + 'idle_timeout' => 60, + 'heartbeat_interval' => 50, + ] + ], +]; diff --git a/server-api/config/process.php b/server-api/config/process.php new file mode 100644 index 0000000..892dc82 --- /dev/null +++ b/server-api/config/process.php @@ -0,0 +1,62 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use support\Log; +use support\Request; +use app\process\Http; + +global $argv; + +return [ + 'webman' => [ + 'handler' => Http::class, + 'listen' => 'http://0.0.0.0:8787', + 'count' => cpu_count() * 4, + 'user' => '', + 'group' => '', + 'reusePort' => false, + 'eventLoop' => '', + 'context' => [], + 'constructor' => [ + 'requestClass' => Request::class, + 'logger' => Log::channel('default'), + 'appPath' => app_path(), + 'publicPath' => public_path() + ] + ], + // File update detection and automatic reload + 'monitor' => [ + 'handler' => app\process\Monitor::class, + 'reloadable' => false, + 'constructor' => [ + // Monitor these directories + 'monitorDir' => array_merge([ + app_path(), + config_path(), + base_path() . '/process', + base_path() . '/support', + base_path() . '/resource', + base_path() . '/.env', + ], glob(base_path() . '/plugin/*/app'), glob(base_path() . '/plugin/*/config'), glob(base_path() . '/plugin/*/api')), + // Files with these suffixes will be monitored + 'monitorExtensions' => [ + 'php', 'html', 'htm', 'env' + ], + 'options' => [ + 'enable_file_monitor' => !in_array('-d', $argv) && DIRECTORY_SEPARATOR === '/', + 'enable_memory_monitor' => DIRECTORY_SEPARATOR === '/', + ] + ] + ] +]; diff --git a/server-api/config/route.php b/server-api/config/route.php new file mode 100644 index 0000000..c35b0a7 --- /dev/null +++ b/server-api/config/route.php @@ -0,0 +1,275 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use Webman\Route; +use app\controller\app\HomeController; +use app\controller\app\CatalogController; +use app\controller\app\AppraisalController; +use app\controller\app\OrdersController; +use app\controller\app\ReportsController; +use app\controller\app\VerifyController; +use app\controller\app\MaterialTagsController as AppMaterialTagsController; +use app\controller\app\MessagesController as AppMessagesController; +use app\controller\app\SupplementController as AppSupplementController; +use app\controller\app\TicketsController as AppTicketsController; +use app\controller\app\ShippingController as AppShippingController; +use app\controller\app\AddressesController as AppAddressesController; +use app\controller\app\HelpCenterController as AppHelpCenterController; +use app\controller\app\SettingsController as AppSettingsController; +use app\controller\app\AuthController as AppAuthController; +use app\controller\app\MineController as AppMineController; +use app\controller\admin\DashboardController as AdminDashboardController; +use app\controller\admin\OrdersController as AdminOrdersController; +use app\controller\admin\CatalogController as AdminCatalogController; +use app\controller\admin\ReportsController as AdminReportsController; +use app\controller\admin\AppraisalTasksController as AdminAppraisalTasksController; +use app\controller\admin\MessagesController as AdminMessagesController; +use app\controller\admin\TicketsController as AdminTicketsController; +use app\controller\admin\UsersController as AdminUsersController; +use app\controller\admin\WarehousesController as AdminWarehousesController; +use app\controller\admin\MaterialsController as AdminMaterialsController; +use app\controller\admin\AccessController as AdminAccessController; +use app\controller\admin\ContentsController as AdminContentsController; +use app\controller\admin\SystemConfigsController as AdminSystemConfigsController; +use app\controller\admin\AuthController as AdminAuthController; +use app\controller\admin\CustomersController as AdminCustomersController; +use app\controller\open\OrdersController as OpenOrdersController; + +Route::get('/', [app\controller\IndexController::class, 'json']); +Route::options('/api/app/appraisal/draft/create', function () { + return response('', 204); +}); +Route::options('/api/app/appraisal/draft/save', function () { + return response('', 204); +}); +Route::options('/api/app/appraisal/file/upload', function () { + return response('', 204); +}); +Route::options('/api/app/appraisal/file/delete', function () { + return response('', 204); +}); +Route::options('/api/app/appraisal/preview', function () { + return response('', 204); +}); +Route::options('/api/app/appraisal/submit', function () { + return response('', 204); +}); +Route::options('/api/app/message/read', function () { + return response('', 204); +}); +Route::options('/api/app/messages/read-all', function () { + return response('', 204); +}); +Route::options('/api/app/order/supplement/file/upload', function () { + return response('', 204); +}); +Route::options('/api/app/order/supplement/file/delete', function () { + return response('', 204); +}); +Route::options('/api/app/order/supplement/submit', function () { + return response('', 204); +}); +Route::options('/api/app/ticket/create', function () { + return response('', 204); +}); +Route::options('/api/app/ticket/reply', function () { + return response('', 204); +}); +Route::options('/api/app/ticket/file/upload', function () { + return response('', 204); +}); +Route::options('/api/app/ticket/file/delete', function () { + return response('', 204); +}); +Route::options('/api/app/order/shipping/save', function () { + return response('', 204); +}); +Route::options('/api/app/order/return-address/save', function () { + return response('', 204); +}); +Route::options('/api/app/address/save', function () { + return response('', 204); +}); +Route::options('/api/app/address/default', function () { + return response('', 204); +}); +Route::options('/api/app/address/delete', function () { + return response('', 204); +}); +Route::options('/api/app/settings/save', function () { + return response('', 204); +}); +Route::options('/api/app/{path:.+}', function () { + return response('', 204); +}); +Route::options('/api/app/auth/{path:.+}', function () { + return response('', 204); +}); +Route::options('/api/admin/{path:.+}', function () { + return response('', 204); +}); +Route::options('/api/open/v1/{path:.+}', function () { + return response('', 204); +}); +Route::get('/api/app/home/index', [HomeController::class, 'index']); +Route::get('/api/app/content/page-visuals', [HomeController::class, 'pageVisuals']); +Route::get('/api/app/catalog/categories', [CatalogController::class, 'categories']); +Route::get('/api/app/catalog/brands', [CatalogController::class, 'brands']); +Route::post('/api/app/appraisal/draft/create', [AppraisalController::class, 'createDraft']); +Route::get('/api/app/appraisal/draft', [AppraisalController::class, 'draftDetail']); +Route::post('/api/app/appraisal/draft/save', [AppraisalController::class, 'saveDraft']); +Route::post('/api/app/appraisal/file/upload', [AppraisalController::class, 'uploadFile']); +Route::post('/api/app/appraisal/file/delete', [AppraisalController::class, 'deleteFile']); +Route::get('/api/app/appraisal/upload-template', [AppraisalController::class, 'uploadTemplate']); +Route::post('/api/app/appraisal/preview', [AppraisalController::class, 'preview']); +Route::post('/api/app/appraisal/submit', [AppraisalController::class, 'submit']); +Route::get('/api/app/orders', [OrdersController::class, 'index']); +Route::get('/api/app/order/detail', [OrdersController::class, 'detail']); +Route::post('/api/app/order/return-address/save', [OrdersController::class, 'saveReturnAddress']); +Route::get('/api/app/reports', [ReportsController::class, 'index']); +Route::get('/api/app/report/detail', [ReportsController::class, 'detail']); +Route::get('/api/app/verify', [VerifyController::class, 'show']); +Route::get('/api/app/material-tag', [AppMaterialTagsController::class, 'show']); +Route::post('/api/app/material-tag/verify', [AppMaterialTagsController::class, 'verify']); +Route::get('/api/app/help-center', [AppHelpCenterController::class, 'index']); +Route::get('/api/app/help-article/detail', [AppHelpCenterController::class, 'detail']); +Route::post('/api/app/auth/send-code', [AppAuthController::class, 'sendCode']); +Route::post('/api/app/auth/login/code', [AppAuthController::class, 'loginByCode']); +Route::post('/api/app/auth/login/password', [AppAuthController::class, 'loginByPassword']); +Route::get('/api/app/auth/me', [AppAuthController::class, 'me']); +Route::post('/api/app/auth/password/save', [AppAuthController::class, 'savePassword']); +Route::post('/api/app/auth/logout', [AppAuthController::class, 'logout']); +Route::get('/api/app/mine/overview', [AppMineController::class, 'overview']); +Route::get('/api/app/settings', [AppSettingsController::class, 'detail']); +Route::post('/api/app/settings/save', [AppSettingsController::class, 'save']); +Route::get('/api/app/messages/summary', [AppMessagesController::class, 'summary']); +Route::get('/api/app/messages/meta', [AppMessagesController::class, 'meta']); +Route::get('/api/app/messages', [AppMessagesController::class, 'index']); +Route::post('/api/app/message/read', [AppMessagesController::class, 'read']); +Route::post('/api/app/messages/read-all', [AppMessagesController::class, 'readAll']); +Route::get('/api/app/order/supplement', [AppSupplementController::class, 'detail']); +Route::post('/api/app/order/supplement/file/upload', [AppSupplementController::class, 'uploadFile']); +Route::post('/api/app/order/supplement/file/delete', [AppSupplementController::class, 'deleteFile']); +Route::post('/api/app/order/supplement/submit', [AppSupplementController::class, 'submit']); +Route::get('/api/app/tickets/overview', [AppTicketsController::class, 'overview']); +Route::get('/api/app/ticket/meta', [AppTicketsController::class, 'meta']); +Route::get('/api/app/tickets', [AppTicketsController::class, 'index']); +Route::get('/api/app/ticket/detail', [AppTicketsController::class, 'detail']); +Route::post('/api/app/ticket/create', [AppTicketsController::class, 'create']); +Route::post('/api/app/ticket/reply', [AppTicketsController::class, 'reply']); +Route::post('/api/app/ticket/file/upload', [AppTicketsController::class, 'uploadFile']); +Route::post('/api/app/ticket/file/delete', [AppTicketsController::class, 'deleteFile']); +Route::get('/api/app/order/shipping', [AppShippingController::class, 'detail']); +Route::post('/api/app/order/shipping/save', [AppShippingController::class, 'save']); +Route::get('/api/app/addresses', [AppAddressesController::class, 'index']); +Route::get('/api/app/address/detail', [AppAddressesController::class, 'detail']); +Route::post('/api/app/address/save', [AppAddressesController::class, 'save']); +Route::post('/api/app/address/default', [AppAddressesController::class, 'setDefault']); +Route::post('/api/app/address/delete', [AppAddressesController::class, 'delete']); + +Route::post('/api/open/v1/orders', [OpenOrdersController::class, 'create']); +Route::get('/api/open/v1/orders', [OpenOrdersController::class, 'detail']); +Route::get('/api/open/v1/orders/{external_order_no}', [OpenOrdersController::class, 'detail']); + +Route::get('/api/admin/ping', function () { + return api_success(['pong' => true]); +}); +Route::post('/api/admin/auth/login', [AdminAuthController::class, 'login']); +Route::get('/api/admin/auth/me', [AdminAuthController::class, 'me']); +Route::post('/api/admin/auth/logout', [AdminAuthController::class, 'logout']); +Route::get('/api/admin/dashboard', [AdminDashboardController::class, 'index']); +Route::get('/api/admin/orders', [AdminOrdersController::class, 'index']); +Route::get('/api/admin/order/detail', [AdminOrdersController::class, 'detail']); +Route::get('/api/admin/order/warehouse/options', [AdminOrdersController::class, 'warehouseOptions']); +Route::post('/api/admin/order/warehouse/reassign', [AdminOrdersController::class, 'reassignWarehouse']); +Route::post('/api/admin/order/logistics/receive', [AdminOrdersController::class, 'receiveLogistics']); +Route::post('/api/admin/order/return-logistics/save', [AdminOrdersController::class, 'saveReturnLogistics']); +Route::post('/api/admin/order/return-logistics/receive', [AdminOrdersController::class, 'receiveReturnLogistics']); +Route::get('/api/admin/catalog/overview', [AdminCatalogController::class, 'overview']); +Route::get('/api/admin/catalog/categories', [AdminCatalogController::class, 'categories']); +Route::get('/api/admin/catalog/brands', [AdminCatalogController::class, 'brands']); +Route::get('/api/admin/catalog/upload-templates', [AdminCatalogController::class, 'uploadTemplates']); +Route::get('/api/admin/catalog/appraisal-templates', [AdminCatalogController::class, 'appraisalTemplates']); +Route::post('/api/admin/catalog/upload-template/sample-image/upload', [AdminCatalogController::class, 'uploadTemplateSampleImage']); +Route::post('/api/admin/catalog/upload-template/sample-image/delete', [AdminCatalogController::class, 'deleteUploadTemplateSampleImage']); +Route::post('/api/admin/catalog/category/save', [AdminCatalogController::class, 'saveCategory']); +Route::post('/api/admin/catalog/brand/save', [AdminCatalogController::class, 'saveBrand']); +Route::post('/api/admin/catalog/upload-templates/save', [AdminCatalogController::class, 'saveUploadTemplates']); +Route::post('/api/admin/catalog/appraisal-templates/save', [AdminCatalogController::class, 'saveAppraisalTemplates']); +Route::get('/api/admin/reports', [AdminReportsController::class, 'index']); +Route::get('/api/admin/report/detail', [AdminReportsController::class, 'detail']); +Route::post('/api/admin/report/inspection/save', [AdminReportsController::class, 'saveInspection']); +Route::post('/api/admin/report/publish', [AdminReportsController::class, 'publish']); +Route::get('/api/admin/appraisal-tasks', [AdminAppraisalTasksController::class, 'index']); +Route::get('/api/admin/appraisal-task/detail', [AdminAppraisalTasksController::class, 'detail']); +Route::get('/api/admin/appraisal-task/assignable-admins', [AdminAppraisalTasksController::class, 'assignableAdmins']); +Route::post('/api/admin/appraisal-task/assign', [AdminAppraisalTasksController::class, 'assign']); +Route::post('/api/admin/appraisal-task/save-result', [AdminAppraisalTasksController::class, 'saveResult']); +Route::post('/api/admin/appraisal-task/material-tag/bind', [AdminAppraisalTasksController::class, 'bindMaterialTag']); +Route::post('/api/admin/appraisal-task/request-supplement', [AdminAppraisalTasksController::class, 'requestSupplement']); +Route::post('/api/admin/appraisal-task/evidence/upload', [AdminAppraisalTasksController::class, 'uploadEvidenceFile']); +Route::post('/api/admin/appraisal-task/evidence/delete', [AdminAppraisalTasksController::class, 'deleteEvidenceFile']); +Route::get('/api/admin/messages/overview', [AdminMessagesController::class, 'overview']); +Route::get('/api/admin/messages/templates', [AdminMessagesController::class, 'templates']); +Route::get('/api/admin/messages/logs', [AdminMessagesController::class, 'logs']); +Route::post('/api/admin/messages/template/save', [AdminMessagesController::class, 'saveTemplate']); +Route::get('/api/admin/tickets/overview', [AdminTicketsController::class, 'overview']); +Route::get('/api/admin/tickets', [AdminTicketsController::class, 'index']); +Route::get('/api/admin/ticket/detail', [AdminTicketsController::class, 'detail']); +Route::post('/api/admin/ticket/save', [AdminTicketsController::class, 'save']); +Route::post('/api/admin/ticket/reply', [AdminTicketsController::class, 'reply']); +Route::post('/api/admin/ticket/file/upload', [AdminTicketsController::class, 'uploadFile']); +Route::post('/api/admin/ticket/file/delete', [AdminTicketsController::class, 'deleteFile']); +Route::get('/api/admin/users/overview', [AdminUsersController::class, 'overview']); +Route::get('/api/admin/users', [AdminUsersController::class, 'index']); +Route::get('/api/admin/user/detail', [AdminUsersController::class, 'detail']); +Route::post('/api/admin/user/save', [AdminUsersController::class, 'save']); +Route::get('/api/admin/customers', [AdminCustomersController::class, 'index']); +Route::get('/api/admin/customer/detail', [AdminCustomersController::class, 'detail']); +Route::post('/api/admin/customer/save', [AdminCustomersController::class, 'save']); +Route::post('/api/admin/customer/app/create', [AdminCustomersController::class, 'createApp']); +Route::post('/api/admin/customer/app/status', [AdminCustomersController::class, 'updateAppStatus']); +Route::post('/api/admin/customer/app/reset-secret', [AdminCustomersController::class, 'resetAppSecret']); +Route::get('/api/admin/customer/orders', [AdminCustomersController::class, 'orders']); +Route::get('/api/admin/customer/order/progress', [AdminCustomersController::class, 'orderProgress']); +Route::get('/api/admin/customer/events', [AdminCustomersController::class, 'events']); +Route::get('/api/admin/customer/deliveries', [AdminCustomersController::class, 'deliveries']); +Route::post('/api/admin/customer/event/resend', [AdminCustomersController::class, 'resendEvent']); +Route::get('/api/admin/warehouses/overview', [AdminWarehousesController::class, 'overview']); +Route::get('/api/admin/warehouses', [AdminWarehousesController::class, 'index']); +Route::post('/api/admin/warehouse/save', [AdminWarehousesController::class, 'save']); +Route::get('/api/admin/material/batches', [AdminMaterialsController::class, 'batches']); +Route::get('/api/admin/material/batch/detail', [AdminMaterialsController::class, 'detail']); +Route::post('/api/admin/material/batch/create', [AdminMaterialsController::class, 'create']); +Route::get('/api/admin/material/batch/download', [AdminMaterialsController::class, 'download']); +Route::get('/api/admin/access/overview', [AdminAccessController::class, 'overview']); +Route::get('/api/admin/access/admins', [AdminAccessController::class, 'admins']); +Route::get('/api/admin/access/roles', [AdminAccessController::class, 'roles']); +Route::get('/api/admin/access/permissions', [AdminAccessController::class, 'permissions']); +Route::post('/api/admin/access/admin/save', [AdminAccessController::class, 'saveAdmin']); +Route::post('/api/admin/access/role/save', [AdminAccessController::class, 'saveRole']); +Route::get('/api/admin/content/bootstrap', [AdminContentsController::class, 'bootstrap']); +Route::get('/api/admin/content/home', [AdminContentsController::class, 'home']); +Route::post('/api/admin/content/image/upload', [AdminContentsController::class, 'uploadImage']); +Route::post('/api/admin/content/home/save', [AdminContentsController::class, 'saveHome']); +Route::get('/api/admin/content/policy', [AdminContentsController::class, 'policy']); +Route::post('/api/admin/content/policy/save', [AdminContentsController::class, 'savePolicy']); +Route::get('/api/admin/content/meta', [AdminContentsController::class, 'meta']); +Route::post('/api/admin/content/meta/save', [AdminContentsController::class, 'saveMeta']); +Route::get('/api/admin/content/help/articles', [AdminContentsController::class, 'helpArticles']); +Route::post('/api/admin/content/help/article/save', [AdminContentsController::class, 'saveHelpArticle']); +Route::post('/api/admin/content/help/article/delete', [AdminContentsController::class, 'deleteHelpArticle']); +Route::get('/api/admin/system-configs', [AdminSystemConfigsController::class, 'index']); +Route::post('/api/admin/system-configs/upload-file', [AdminSystemConfigsController::class, 'uploadFile']); +Route::post('/api/admin/system-configs/save', [AdminSystemConfigsController::class, 'save']); diff --git a/server-api/config/server.php b/server-api/config/server.php new file mode 100644 index 0000000..238f1aa --- /dev/null +++ b/server-api/config/server.php @@ -0,0 +1,23 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +return [ + 'event_loop' => '', + 'stop_timeout' => 2, + 'pid_file' => runtime_path() . '/webman.pid', + 'status_file' => runtime_path() . '/webman.status', + 'stdout_file' => runtime_path() . '/logs/stdout.log', + 'log_file' => runtime_path() . '/logs/workerman.log', + 'max_package_size' => 10 * 1024 * 1024 +]; diff --git a/server-api/config/session.php b/server-api/config/session.php new file mode 100644 index 0000000..043f8c4 --- /dev/null +++ b/server-api/config/session.php @@ -0,0 +1,65 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use Webman\Session\FileSessionHandler; +use Webman\Session\RedisSessionHandler; +use Webman\Session\RedisClusterSessionHandler; + +return [ + + 'type' => 'file', // or redis or redis_cluster + + 'handler' => FileSessionHandler::class, + + 'config' => [ + 'file' => [ + 'save_path' => runtime_path() . '/sessions', + ], + 'redis' => [ + 'host' => '127.0.0.1', + 'port' => 6379, + 'auth' => '', + 'timeout' => 2, + 'database' => '', + 'prefix' => 'redis_session_', + ], + 'redis_cluster' => [ + 'host' => ['127.0.0.1:7000', '127.0.0.1:7001', '127.0.0.1:7001'], + 'timeout' => 2, + 'auth' => '', + 'prefix' => 'redis_session_', + ] + ], + + 'session_name' => 'PHPSID', + + 'auto_update_timestamp' => false, + + 'lifetime' => 7*24*60*60, + + 'cookie_lifetime' => 365*24*60*60, + + 'cookie_path' => '/', + + 'domain' => '', + + 'http_only' => true, + + 'secure' => false, + + 'same_site' => '', + + 'gc_probability' => [1, 1000], + +]; diff --git a/server-api/config/static.php b/server-api/config/static.php new file mode 100644 index 0000000..2f76cf3 --- /dev/null +++ b/server-api/config/static.php @@ -0,0 +1,23 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +/** + * Static file settings + */ +return [ + 'enable' => true, + 'middleware' => [ // Static file Middleware + //app\middleware\StaticFile::class, + ], +]; \ No newline at end of file diff --git a/server-api/config/think-orm.php b/server-api/config/think-orm.php new file mode 100644 index 0000000..3456048 --- /dev/null +++ b/server-api/config/think-orm.php @@ -0,0 +1,42 @@ + 'mysql', + 'connections' => [ + 'mysql' => [ + // 数据库类型 + 'type' => 'mysql', + // 服务器地址 + 'hostname' => $_ENV['DB_HOST'] ?? '127.0.0.1', + // 数据库名 + 'database' => $_ENV['DB_DATABASE'] ?? 'test', + // 数据库用户名 + 'username' => $_ENV['DB_USERNAME'] ?? 'root', + // 数据库密码 + 'password' => $_ENV['DB_PASSWORD'] ?? '', + // 数据库连接端口 + 'hostport' => $_ENV['DB_PORT'] ?? '3306', + // 数据库连接参数 + 'params' => [ + // 连接超时3秒 + \PDO::ATTR_TIMEOUT => 3, + ], + // 数据库编码默认采用utf8 + 'charset' => $_ENV['DB_CHARSET'] ?? 'utf8mb4', + // 数据库表前缀 + 'prefix' => $_ENV['DB_PREFIX'] ?? '', + // 断线重连 + 'break_reconnect' => true, + // 连接池配置 + 'pool' => [ + 'max_connections' => 5, // 最大连接数 + 'min_connections' => 1, // 最小连接数 + 'wait_timeout' => 3, // 从连接池获取连接等待超时时间 + 'idle_timeout' => 60, // 连接最大空闲时间,超过该时间会被回收 + 'heartbeat_interval' => 50, // 心跳检测间隔,需要小于60秒 + ], + ], + ], + // 自定义分页类 + 'paginator' => '', +]; diff --git a/server-api/config/translation.php b/server-api/config/translation.php new file mode 100644 index 0000000..96589b2 --- /dev/null +++ b/server-api/config/translation.php @@ -0,0 +1,25 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +/** + * Multilingual configuration + */ +return [ + // Default language + 'locale' => 'zh_CN', + // Fallback language + 'fallback_locale' => ['zh_CN', 'en'], + // Folder where language files are stored + 'path' => base_path() . '/resource/translations', +]; \ No newline at end of file diff --git a/server-api/config/view.php b/server-api/config/view.php new file mode 100644 index 0000000..e3a7b85 --- /dev/null +++ b/server-api/config/view.php @@ -0,0 +1,22 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +use support\view\Raw; +use support\view\Twig; +use support\view\Blade; +use support\view\ThinkPHP; + +return [ + 'handler' => Raw::class +]; diff --git a/server-api/database/schema.sql b/server-api/database/schema.sql new file mode 100644 index 0000000..af72248 --- /dev/null +++ b/server-api/database/schema.sql @@ -0,0 +1,1215 @@ +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +DROP TABLE IF EXISTS admin_role_permissions; +DROP TABLE IF EXISTS admin_permissions; +DROP TABLE IF EXISTS admin_role_relations; +DROP TABLE IF EXISTS admin_roles; +DROP TABLE IF EXISTS admin_api_tokens; +DROP TABLE IF EXISTS enterprise_webhook_deliveries; +DROP TABLE IF EXISTS enterprise_order_events; +DROP TABLE IF EXISTS enterprise_customer_order_refs; +DROP TABLE IF EXISTS enterprise_api_nonces; +DROP TABLE IF EXISTS enterprise_customer_apps; +DROP TABLE IF EXISTS enterprise_customers; +DROP TABLE IF EXISTS shipping_warehouses; +DROP TABLE IF EXISTS user_api_tokens; +DROP TABLE IF EXISTS sms_code_logs; +DROP TABLE IF EXISTS operation_logs; +DROP TABLE IF EXISTS material_tag_scan_logs; +DROP TABLE IF EXISTS material_batch_download_logs; +DROP TABLE IF EXISTS material_tag_codes; +DROP TABLE IF EXISTS material_batches; +DROP TABLE IF EXISTS system_configs; +DROP TABLE IF EXISTS service_packages; +DROP TABLE IF EXISTS admin_users; +DROP TABLE IF EXISTS user_messages; +DROP TABLE IF EXISTS message_logs; +DROP TABLE IF EXISTS message_rules; +DROP TABLE IF EXISTS message_templates; +DROP TABLE IF EXISTS help_articles; +DROP TABLE IF EXISTS ticket_messages; +DROP TABLE IF EXISTS tickets; +DROP TABLE IF EXISTS report_logs; +DROP TABLE IF EXISTS report_verify_logs; +DROP TABLE IF EXISTS report_verifies; +DROP TABLE IF EXISTS report_files; +DROP TABLE IF EXISTS report_contents; +DROP TABLE IF EXISTS reports; +DROP TABLE IF EXISTS appraisal_task_logs; +DROP TABLE IF EXISTS appraisal_task_reviews; +DROP TABLE IF EXISTS appraisal_task_key_points; +DROP TABLE IF EXISTS appraisal_task_results; +DROP TABLE IF EXISTS appraisal_tasks; +DROP TABLE IF EXISTS order_abnormals; +DROP TABLE IF EXISTS order_logistics_nodes; +DROP TABLE IF EXISTS order_logistics; +DROP TABLE IF EXISTS order_supplement_task_items; +DROP TABLE IF EXISTS order_supplement_tasks; +DROP TABLE IF EXISTS order_assignments; +DROP TABLE IF EXISTS order_timelines; +DROP TABLE IF EXISTS order_upload_files; +DROP TABLE IF EXISTS order_upload_items; +DROP TABLE IF EXISTS order_return_addresses; +DROP TABLE IF EXISTS order_shipping_targets; +DROP TABLE IF EXISTS order_extras; +DROP TABLE IF EXISTS order_products; +DROP TABLE IF EXISTS orders; +DROP TABLE IF EXISTS appraisal_draft_upload_files; +DROP TABLE IF EXISTS appraisal_draft_uploads; +DROP TABLE IF EXISTS appraisal_draft_extras; +DROP TABLE IF EXISTS appraisal_draft_products; +DROP TABLE IF EXISTS appraisal_drafts; +DROP TABLE IF EXISTS appraisal_template_key_points; +DROP TABLE IF EXISTS appraisal_templates; +DROP TABLE IF EXISTS upload_template_items; +DROP TABLE IF EXISTS upload_templates; +DROP TABLE IF EXISTS catalog_attribute_scopes; +DROP TABLE IF EXISTS catalog_attribute_fields; +DROP TABLE IF EXISTS catalog_models; +DROP TABLE IF EXISTS catalog_series; +DROP TABLE IF EXISTS catalog_brand_categories; +DROP TABLE IF EXISTS catalog_brands; +DROP TABLE IF EXISTS catalog_categories; +DROP TABLE IF EXISTS user_addresses; +DROP TABLE IF EXISTS user_auths; +DROP TABLE IF EXISTS users; + +CREATE TABLE users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + nickname VARCHAR(64) NOT NULL DEFAULT '', + avatar VARCHAR(255) NOT NULL DEFAULT '', + mobile VARCHAR(32) NOT NULL DEFAULT '', + password VARCHAR(255) NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'enabled', + last_login_at DATETIME NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + deleted_at DATETIME NULL DEFAULT NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_users_mobile (mobile), + KEY idx_users_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户主表'; + +CREATE TABLE user_auths ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + auth_type VARCHAR(32) NOT NULL, + auth_open_id VARCHAR(128) NOT NULL DEFAULT '', + auth_union_id VARCHAR(128) NOT NULL DEFAULT '', + auth_key VARCHAR(128) NOT NULL DEFAULT '', + auth_extra JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_user_auths_type_key (auth_type, auth_key), + KEY idx_user_auths_user_id (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户认证映射'; + +CREATE TABLE user_api_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + token_hash VARCHAR(64) NOT NULL, + auth_type VARCHAR(32) NOT NULL DEFAULT 'password', + expire_time DATETIME NOT NULL, + last_active_at DATETIME NULL DEFAULT NULL, + last_ip VARCHAR(64) NOT NULL DEFAULT '', + user_agent VARCHAR(500) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_user_api_tokens_token_hash (token_hash), + KEY idx_user_api_tokens_user_id (user_id), + KEY idx_user_api_tokens_expire_time (expire_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户登录Token'; + +CREATE TABLE sms_code_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + mobile VARCHAR(32) NOT NULL, + scene VARCHAR(32) NOT NULL DEFAULT 'login', + code_hash VARCHAR(64) NOT NULL, + send_status VARCHAR(32) NOT NULL DEFAULT 'success', + provider VARCHAR(32) NOT NULL DEFAULT 'aliyun_sms', + template_code VARCHAR(64) NOT NULL DEFAULT '', + request_id VARCHAR(128) NOT NULL DEFAULT '', + biz_id VARCHAR(128) NOT NULL DEFAULT '', + failed_reason VARCHAR(255) NOT NULL DEFAULT '', + expire_time DATETIME NOT NULL, + used_at DATETIME NULL DEFAULT NULL, + send_ip VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_sms_code_logs_mobile_scene (mobile, scene), + KEY idx_sms_code_logs_expire_time (expire_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='短信验证码发送记录'; + +CREATE TABLE enterprise_customers ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + customer_code VARCHAR(64) NOT NULL, + customer_name VARCHAR(128) NOT NULL DEFAULT '', + contact_name VARCHAR(64) NOT NULL DEFAULT '', + contact_mobile VARCHAR(32) NOT NULL DEFAULT '', + contact_email VARCHAR(128) NOT NULL DEFAULT '', + settlement_type VARCHAR(32) NOT NULL DEFAULT 'monthly', + user_id BIGINT UNSIGNED NULL DEFAULT NULL, + webhook_url VARCHAR(500) NOT NULL DEFAULT '', + webhook_enabled TINYINT(1) NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'enabled', + remark VARCHAR(500) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_enterprise_customers_code (customer_code), + KEY idx_enterprise_customers_status (status), + KEY idx_enterprise_customers_user_id (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='大客户资料'; + +CREATE TABLE enterprise_customer_apps ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + customer_id BIGINT UNSIGNED NOT NULL, + app_name VARCHAR(128) NOT NULL DEFAULT '', + app_key VARCHAR(64) NOT NULL, + app_secret_cipher TEXT NULL, + secret_last4 VARCHAR(8) NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'enabled', + last_used_at DATETIME NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_enterprise_customer_apps_key (app_key), + KEY idx_enterprise_customer_apps_customer_id (customer_id), + KEY idx_enterprise_customer_apps_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='大客户开放接口应用'; + +CREATE TABLE enterprise_api_nonces ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + app_key VARCHAR(64) NOT NULL, + nonce VARCHAR(128) NOT NULL, + request_timestamp BIGINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_enterprise_api_nonces_key_nonce (app_key, nonce), + KEY idx_enterprise_api_nonces_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='开放接口防重放Nonce'; + +CREATE TABLE enterprise_customer_order_refs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + customer_id BIGINT UNSIGNED NOT NULL, + external_order_no VARCHAR(128) NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + order_no VARCHAR(64) NOT NULL DEFAULT '', + appraisal_no VARCHAR(64) NOT NULL DEFAULT '', + payload_hash VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_enterprise_customer_order_refs_external (customer_id, external_order_no), + UNIQUE KEY uk_enterprise_customer_order_refs_order_id (order_id), + KEY idx_enterprise_customer_order_refs_order_no (order_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='大客户外部订单映射'; + +CREATE TABLE enterprise_order_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + customer_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + external_order_no VARCHAR(128) NOT NULL DEFAULT '', + event_code VARCHAR(64) NOT NULL, + event_text VARCHAR(128) NOT NULL DEFAULT '', + status_code VARCHAR(64) NOT NULL DEFAULT '', + status_text VARCHAR(128) NOT NULL DEFAULT '', + payload_json JSON NULL, + occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_enterprise_order_events_customer_id (customer_id), + KEY idx_enterprise_order_events_order_id (order_id), + KEY idx_enterprise_order_events_event_code (event_code), + KEY idx_enterprise_order_events_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='大客户订单事件'; + +CREATE TABLE enterprise_webhook_deliveries ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + event_id BIGINT UNSIGNED NOT NULL, + customer_id BIGINT UNSIGNED NOT NULL, + webhook_url VARCHAR(500) NOT NULL DEFAULT '', + app_key VARCHAR(64) NOT NULL DEFAULT '', + attempt_no INT NOT NULL DEFAULT 1, + delivery_status VARCHAR(32) NOT NULL DEFAULT 'pending', + http_status INT NOT NULL DEFAULT 0, + response_body TEXT NULL, + error_message VARCHAR(500) NOT NULL DEFAULT '', + is_manual TINYINT(1) NOT NULL DEFAULT 0, + sent_at DATETIME NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_enterprise_webhook_deliveries_event_id (event_id), + KEY idx_enterprise_webhook_deliveries_customer_id (customer_id), + KEY idx_enterprise_webhook_deliveries_status (delivery_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='大客户Webhook推送记录'; + +CREATE TABLE user_addresses ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + consignee VARCHAR(64) NOT NULL DEFAULT '', + mobile VARCHAR(32) NOT NULL DEFAULT '', + province VARCHAR(64) NOT NULL DEFAULT '', + city VARCHAR(64) NOT NULL DEFAULT '', + district VARCHAR(64) NOT NULL DEFAULT '', + detail_address VARCHAR(255) NOT NULL DEFAULT '', + is_default TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_user_addresses_user_id (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户地址'; + +CREATE TABLE shipping_warehouses ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + warehouse_name VARCHAR(128) NOT NULL DEFAULT '', + warehouse_code VARCHAR(64) NOT NULL DEFAULT '', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'detection_center', + service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan', + receiver_name VARCHAR(64) NOT NULL DEFAULT '', + receiver_mobile VARCHAR(32) NOT NULL DEFAULT '', + province VARCHAR(64) NOT NULL DEFAULT '', + city VARCHAR(64) NOT NULL DEFAULT '', + district VARCHAR(64) NOT NULL DEFAULT '', + detail_address VARCHAR(255) NOT NULL DEFAULT '', + service_time VARCHAR(128) NOT NULL DEFAULT '', + notice VARCHAR(500) NOT NULL DEFAULT '', + supported_category_ids_json JSON NULL, + service_area_provinces_json JSON NULL, + service_area_cities_json JSON NULL, + status VARCHAR(32) NOT NULL DEFAULT 'enabled', + is_default TINYINT(1) NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_shipping_warehouses_code (warehouse_code), + KEY idx_shipping_warehouses_service_provider (service_provider), + KEY idx_shipping_warehouses_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货仓库 / 检测中心'; + +CREATE TABLE catalog_categories ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(64) NOT NULL, + code VARCHAR(64) NOT NULL, + icon VARCHAR(255) NOT NULL DEFAULT '', + sort_order INT NOT NULL DEFAULT 0, + is_enabled TINYINT(1) NOT NULL DEFAULT 1, + need_shipping TINYINT(1) NOT NULL DEFAULT 1, + supported_service_types JSON NULL, + default_upload_template_id BIGINT UNSIGNED NULL DEFAULT NULL, + default_appraisal_template_id BIGINT UNSIGNED NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_catalog_categories_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='品类'; + +CREATE TABLE catalog_brands ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(128) NOT NULL, + en_name VARCHAR(128) NOT NULL DEFAULT '', + code VARCHAR(64) NOT NULL, + logo VARCHAR(255) NOT NULL DEFAULT '', + sort_order INT NOT NULL DEFAULT 0, + is_enabled TINYINT(1) NOT NULL DEFAULT 1, + supported_service_types JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_catalog_brands_code (code), + KEY idx_catalog_brands_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='品牌'; + +CREATE TABLE catalog_brand_categories ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + brand_id BIGINT UNSIGNED NOT NULL, + category_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_catalog_brand_categories (brand_id, category_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='品牌品类关联'; + +CREATE TABLE catalog_attribute_fields ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(64) NOT NULL, + code VARCHAR(64) NOT NULL, + field_type VARCHAR(32) NOT NULL, + options_json JSON NULL, + is_required TINYINT(1) NOT NULL DEFAULT 0, + is_front_visible TINYINT(1) NOT NULL DEFAULT 1, + is_report_visible TINYINT(1) NOT NULL DEFAULT 0, + is_admin_visible TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_catalog_attribute_fields_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='动态字段定义'; + +CREATE TABLE catalog_attribute_scopes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + field_id BIGINT UNSIGNED NOT NULL, + category_id BIGINT UNSIGNED NULL DEFAULT NULL, + brand_id BIGINT UNSIGNED NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_catalog_attribute_scopes_field_id (field_id), + KEY idx_catalog_attribute_scopes_scope (category_id, brand_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='动态字段适用范围'; + +CREATE TABLE upload_templates ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(128) NOT NULL, + code VARCHAR(64) NOT NULL, + scope_type VARCHAR(32) NOT NULL, + scope_id BIGINT UNSIGNED NOT NULL, + service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan', + is_default TINYINT(1) NOT NULL DEFAULT 0, + is_enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_upload_templates_code (code), + KEY idx_upload_templates_scope (scope_type, scope_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='上传模板'; + +CREATE TABLE upload_template_items ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + template_id BIGINT UNSIGNED NOT NULL, + item_code VARCHAR(64) NOT NULL, + item_name VARCHAR(128) NOT NULL, + is_required TINYINT(1) NOT NULL DEFAULT 1, + guide_text VARCHAR(255) NOT NULL DEFAULT '', + sample_image_url VARCHAR(255) NOT NULL DEFAULT '', + max_upload_count INT NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + is_enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_upload_template_items_template_id (template_id), + KEY idx_upload_template_items_item_code (item_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='上传模板项'; + +CREATE TABLE appraisal_templates ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(128) NOT NULL, + code VARCHAR(64) NOT NULL, + scope_type VARCHAR(32) NOT NULL, + scope_id BIGINT UNSIGNED NOT NULL, + service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan', + is_default TINYINT(1) NOT NULL DEFAULT 0, + is_enabled TINYINT(1) NOT NULL DEFAULT 1, + result_options_json JSON NULL, + condition_rule_json JSON NULL, + valuation_rule_json JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_appraisal_templates_code (code), + KEY idx_appraisal_templates_scope (scope_type, scope_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='鉴定模板'; + +CREATE TABLE appraisal_template_key_points ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + template_id BIGINT UNSIGNED NOT NULL, + point_code VARCHAR(64) NOT NULL, + point_name VARCHAR(128) NOT NULL, + point_type VARCHAR(32) NOT NULL DEFAULT 'text', + options_json JSON NULL, + sort_order INT NOT NULL DEFAULT 0, + is_required TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_appraisal_template_key_points_template_id (template_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='鉴定模板关键点'; + +CREATE TABLE appraisal_drafts ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + service_mode VARCHAR(32) NOT NULL DEFAULT 'physical', + service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan', + current_step INT NOT NULL DEFAULT 1, + status VARCHAR(32) NOT NULL DEFAULT 'draft', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_appraisal_drafts_user_id (user_id), + KEY idx_appraisal_drafts_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='鉴定草稿'; + +CREATE TABLE appraisal_draft_products ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + draft_id BIGINT UNSIGNED NOT NULL, + category_id BIGINT UNSIGNED NULL DEFAULT NULL, + brand_id BIGINT UNSIGNED NULL DEFAULT NULL, + color VARCHAR(64) NOT NULL DEFAULT '', + size_spec VARCHAR(64) NOT NULL DEFAULT '', + serial_no VARCHAR(128) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_appraisal_draft_products_draft_id (draft_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='草稿商品信息'; + +CREATE TABLE appraisal_draft_extras ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + draft_id BIGINT UNSIGNED NOT NULL, + purchase_channel VARCHAR(64) NOT NULL DEFAULT '', + purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, + purchase_date DATE NULL DEFAULT NULL, + usage_status VARCHAR(32) NOT NULL DEFAULT '', + condition_desc VARCHAR(255) NOT NULL DEFAULT '', + has_accessories TINYINT(1) NOT NULL DEFAULT 0, + accessories_json JSON NULL, + remark VARCHAR(500) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_appraisal_draft_extras_draft_id (draft_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='草稿补充信息'; + +CREATE TABLE appraisal_draft_uploads ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + draft_id BIGINT UNSIGNED NOT NULL, + template_id BIGINT UNSIGNED NULL DEFAULT NULL, + item_code VARCHAR(64) NOT NULL, + item_name VARCHAR(128) NOT NULL, + is_required TINYINT(1) NOT NULL DEFAULT 1, + quality_status VARCHAR(32) NOT NULL DEFAULT 'pending', + quality_message VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_appraisal_draft_uploads_draft_id (draft_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='草稿上传任务项'; + +CREATE TABLE appraisal_draft_upload_files ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + draft_upload_id BIGINT UNSIGNED NOT NULL, + file_id VARCHAR(64) NOT NULL DEFAULT '', + file_url VARCHAR(255) NOT NULL DEFAULT '', + thumbnail_url VARCHAR(255) NOT NULL DEFAULT '', + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_appraisal_draft_upload_files_draft_upload_id (draft_upload_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='草稿上传文件'; + +CREATE TABLE orders ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_no VARCHAR(64) NOT NULL, + appraisal_no VARCHAR(64) NOT NULL, + user_id BIGINT UNSIGNED NOT NULL, + service_mode VARCHAR(32) NOT NULL DEFAULT 'physical', + service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan', + payment_status VARCHAR(32) NOT NULL DEFAULT 'unpaid', + order_status VARCHAR(32) NOT NULL DEFAULT 'pending_payment', + display_status VARCHAR(64) NOT NULL DEFAULT '待支付', + estimated_finish_time DATETIME NULL DEFAULT NULL, + source_channel VARCHAR(32) NOT NULL DEFAULT 'mini_program', + source_customer_id VARCHAR(64) NOT NULL DEFAULT '', + pay_amount DECIMAL(10,2) NOT NULL DEFAULT 0.00, + paid_at DATETIME NULL DEFAULT NULL, + cancelled_at DATETIME NULL DEFAULT NULL, + completed_at DATETIME NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_orders_order_no (order_no), + UNIQUE KEY uk_orders_appraisal_no (appraisal_no), + KEY idx_orders_user_id (user_id), + KEY idx_orders_order_status (order_status), + KEY idx_orders_service_provider (service_provider), + KEY idx_orders_source_channel (source_channel), + KEY idx_orders_source_customer_id (source_customer_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单主表'; + +CREATE TABLE order_products ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + category_id BIGINT UNSIGNED NULL DEFAULT NULL, + category_name VARCHAR(64) NOT NULL DEFAULT '', + brand_id BIGINT UNSIGNED NULL DEFAULT NULL, + brand_name VARCHAR(128) NOT NULL DEFAULT '', + color VARCHAR(64) NOT NULL DEFAULT '', + size_spec VARCHAR(64) NOT NULL DEFAULT '', + serial_no VARCHAR(128) NOT NULL DEFAULT '', + product_name VARCHAR(255) NOT NULL DEFAULT '', + product_cover VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_order_products_order_id (order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单商品快照'; + +CREATE TABLE order_extras ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + purchase_channel VARCHAR(64) NOT NULL DEFAULT '', + purchase_price DECIMAL(10,2) NOT NULL DEFAULT 0.00, + purchase_date DATE NULL DEFAULT NULL, + usage_status VARCHAR(32) NOT NULL DEFAULT '', + condition_desc VARCHAR(255) NOT NULL DEFAULT '', + has_accessories TINYINT(1) NOT NULL DEFAULT 0, + accessories_json JSON NULL, + remark VARCHAR(500) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_order_extras_order_id (order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单补充信息'; + +CREATE TABLE order_shipping_targets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + warehouse_id BIGINT UNSIGNED NULL DEFAULT NULL, + warehouse_name VARCHAR(128) NOT NULL DEFAULT '', + warehouse_code VARCHAR(64) NOT NULL DEFAULT '', + service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan', + receiver_name VARCHAR(64) NOT NULL DEFAULT '', + receiver_mobile VARCHAR(32) NOT NULL DEFAULT '', + province VARCHAR(64) NOT NULL DEFAULT '', + city VARCHAR(64) NOT NULL DEFAULT '', + district VARCHAR(64) NOT NULL DEFAULT '', + detail_address VARCHAR(255) NOT NULL DEFAULT '', + service_time VARCHAR(128) NOT NULL DEFAULT '', + notice VARCHAR(500) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_order_shipping_targets_order_id (order_id), + KEY idx_order_shipping_targets_warehouse_id (warehouse_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单锁定仓库快照'; + +CREATE TABLE order_return_addresses ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + user_address_id BIGINT UNSIGNED NULL DEFAULT NULL, + consignee VARCHAR(64) NOT NULL DEFAULT '', + mobile VARCHAR(32) NOT NULL DEFAULT '', + province VARCHAR(64) NOT NULL DEFAULT '', + city VARCHAR(64) NOT NULL DEFAULT '', + district VARCHAR(64) NOT NULL DEFAULT '', + detail_address VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_order_return_addresses_order_id (order_id), + KEY idx_order_return_addresses_user_address_id (user_address_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单寄回地址快照'; + +CREATE TABLE order_upload_items ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + template_id BIGINT UNSIGNED NULL DEFAULT NULL, + item_code VARCHAR(64) NOT NULL, + item_name VARCHAR(128) NOT NULL, + is_required TINYINT(1) NOT NULL DEFAULT 1, + source_type VARCHAR(32) NOT NULL DEFAULT 'initial', + status VARCHAR(32) NOT NULL DEFAULT 'pending', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_order_upload_items_order_id (order_id), + KEY idx_order_upload_items_item_code (item_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单资料任务项'; + +CREATE TABLE order_upload_files ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_upload_item_id BIGINT UNSIGNED NOT NULL, + file_id VARCHAR(64) NOT NULL DEFAULT '', + file_url VARCHAR(255) NOT NULL DEFAULT '', + thumbnail_url VARCHAR(255) NOT NULL DEFAULT '', + quality_status VARCHAR(32) NOT NULL DEFAULT 'pending', + quality_message VARCHAR(255) NOT NULL DEFAULT '', + uploaded_by_user_id BIGINT UNSIGNED NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_order_upload_files_item_id (order_upload_item_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单资料文件'; + +CREATE TABLE order_timelines ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + node_code VARCHAR(64) NOT NULL, + node_text VARCHAR(128) NOT NULL, + node_desc VARCHAR(255) NOT NULL DEFAULT '', + operator_type VARCHAR(32) NOT NULL DEFAULT 'system', + operator_id BIGINT UNSIGNED NULL DEFAULT NULL, + occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_order_timelines_order_id (order_id), + KEY idx_order_timelines_node_code (node_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单时间轴'; + +CREATE TABLE order_assignments ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + task_stage VARCHAR(32) NOT NULL, + assignee_id BIGINT UNSIGNED NOT NULL, + assignee_name VARCHAR(64) NOT NULL DEFAULT '', + assigned_by BIGINT UNSIGNED NULL DEFAULT NULL, + assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_order_assignments_order_id (order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单分配记录'; + +CREATE TABLE order_supplement_tasks ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + reason VARCHAR(255) NOT NULL DEFAULT '', + deadline DATETIME NULL DEFAULT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + created_by BIGINT UNSIGNED NULL DEFAULT NULL, + submitted_at DATETIME NULL DEFAULT NULL, + approved_at DATETIME NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_order_supplement_tasks_order_id (order_id), + KEY idx_order_supplement_tasks_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='补图任务'; + +CREATE TABLE order_supplement_task_items ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_id BIGINT UNSIGNED NOT NULL, + item_code VARCHAR(64) NOT NULL, + item_name VARCHAR(128) NOT NULL, + guide_text VARCHAR(255) NOT NULL DEFAULT '', + sample_image_url VARCHAR(255) NOT NULL DEFAULT '', + is_required TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_order_supplement_task_items_task_id (task_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='补图任务项'; + +CREATE TABLE order_logistics ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + logistics_type VARCHAR(32) NOT NULL DEFAULT 'send_to_center', + express_company VARCHAR(64) NOT NULL DEFAULT '', + tracking_no VARCHAR(64) NOT NULL DEFAULT '', + tracking_status VARCHAR(32) NOT NULL DEFAULT '', + latest_desc VARCHAR(255) NOT NULL DEFAULT '', + latest_time DATETIME NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_order_logistics_order_id (order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物流主表'; + +CREATE TABLE order_logistics_nodes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + logistics_id BIGINT UNSIGNED NOT NULL, + node_time DATETIME NOT NULL, + node_desc VARCHAR(255) NOT NULL DEFAULT '', + node_location VARCHAR(128) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_order_logistics_nodes_logistics_id (logistics_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物流节点'; + +CREATE TABLE order_abnormals ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + abnormal_type VARCHAR(64) NOT NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'pending', + owner_id BIGINT UNSIGNED NULL DEFAULT NULL, + resolved_at DATETIME NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_order_abnormals_order_id (order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常订单'; + +CREATE TABLE appraisal_tasks ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + task_stage VARCHAR(32) NOT NULL, + service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan', + status VARCHAR(32) NOT NULL DEFAULT 'pending', + assignee_id BIGINT UNSIGNED NULL DEFAULT NULL, + assignee_name VARCHAR(64) NOT NULL DEFAULT '', + started_at DATETIME NULL DEFAULT NULL, + submitted_at DATETIME NULL DEFAULT NULL, + sla_deadline DATETIME NULL DEFAULT NULL, + is_overtime TINYINT(1) NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_appraisal_tasks_order_id (order_id), + KEY idx_appraisal_tasks_stage_status (task_stage, status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='鉴定任务'; + +CREATE TABLE appraisal_task_results ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + result_status VARCHAR(32) NOT NULL DEFAULT '', + result_text VARCHAR(64) NOT NULL DEFAULT '', + result_desc VARCHAR(255) NOT NULL DEFAULT '', + condition_grade VARCHAR(16) NOT NULL DEFAULT '', + condition_desc VARCHAR(255) NOT NULL DEFAULT '', + valuation_min DECIMAL(10,2) NOT NULL DEFAULT 0.00, + valuation_max DECIMAL(10,2) NOT NULL DEFAULT 0.00, + valuation_desc VARCHAR(255) NOT NULL DEFAULT '', + attachments_json JSON NULL, + internal_remark TEXT NULL, + external_remark TEXT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_appraisal_task_results_task_id (task_id), + KEY idx_appraisal_task_results_order_id (order_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='鉴定结果'; + +CREATE TABLE appraisal_task_key_points ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_result_id BIGINT UNSIGNED NOT NULL, + point_code VARCHAR(64) NOT NULL, + point_name VARCHAR(128) NOT NULL, + point_value VARCHAR(255) NOT NULL DEFAULT '', + point_remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_appraisal_task_key_points_result_id (task_result_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='鉴定关键点记录'; + +CREATE TABLE appraisal_task_reviews ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_id BIGINT UNSIGNED NOT NULL, + reviewer_id BIGINT UNSIGNED NOT NULL, + reviewer_name VARCHAR(64) NOT NULL DEFAULT '', + review_action VARCHAR(32) NOT NULL, + review_opinion VARCHAR(255) NOT NULL DEFAULT '', + reviewed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_appraisal_task_reviews_task_id (task_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='复核记录'; + +CREATE TABLE appraisal_task_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + task_id BIGINT UNSIGNED NOT NULL, + action VARCHAR(64) NOT NULL, + operator_id BIGINT UNSIGNED NULL DEFAULT NULL, + operator_name VARCHAR(64) NOT NULL DEFAULT '', + before_data JSON NULL, + after_data JSON NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_appraisal_task_logs_task_id (task_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='作业日志'; + +CREATE TABLE reports ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + report_no VARCHAR(64) NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + appraisal_no VARCHAR(64) NOT NULL, + report_type VARCHAR(32) NOT NULL DEFAULT 'appraisal', + service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan', + institution_name VARCHAR(128) NOT NULL DEFAULT '', + report_title VARCHAR(128) NOT NULL DEFAULT '', + report_status VARCHAR(32) NOT NULL DEFAULT 'draft', + report_version INT NOT NULL DEFAULT 1, + source_report_id BIGINT UNSIGNED NULL DEFAULT NULL, + publish_time DATETIME NULL DEFAULT NULL, + invalid_reason VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_reports_report_no (report_no), + KEY idx_reports_order_id (order_id), + KEY idx_reports_report_type (report_type), + KEY idx_reports_report_status (report_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='报告主表'; + +CREATE TABLE report_contents ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + report_id BIGINT UNSIGNED NOT NULL, + product_snapshot_json JSON NULL, + result_snapshot_json JSON NULL, + appraisal_snapshot_json JSON NULL, + valuation_snapshot_json JSON NULL, + evidence_attachments_json JSON NULL, + risk_notice_text TEXT NULL, + page_template_id BIGINT UNSIGNED NULL DEFAULT NULL, + pdf_template_id BIGINT UNSIGNED NULL DEFAULT NULL, + verify_template_id BIGINT UNSIGNED NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_report_contents_report_id (report_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='报告内容快照'; + +CREATE TABLE report_files ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + report_id BIGINT UNSIGNED NOT NULL, + file_type VARCHAR(32) NOT NULL DEFAULT 'pdf', + file_url VARCHAR(255) NOT NULL DEFAULT '', + file_status VARCHAR(32) NOT NULL DEFAULT 'pending', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_report_files_report_id (report_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='报告文件'; + +CREATE TABLE report_verifies ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + report_id BIGINT UNSIGNED NOT NULL, + report_no VARCHAR(64) NOT NULL, + verify_token VARCHAR(128) NOT NULL, + verify_qrcode_url VARCHAR(255) NOT NULL DEFAULT '', + verify_url VARCHAR(255) NOT NULL DEFAULT '', + verify_status VARCHAR(32) NOT NULL DEFAULT 'valid', + last_verified_at DATETIME NULL DEFAULT NULL, + verify_count INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_report_verifies_report_id (report_id), + UNIQUE KEY uk_report_verifies_report_no (report_no), + UNIQUE KEY uk_report_verifies_verify_token (verify_token) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='验真主表'; + +CREATE TABLE report_verify_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + report_verify_id BIGINT UNSIGNED NOT NULL, + verify_type VARCHAR(32) NOT NULL DEFAULT 'input', + ip VARCHAR(64) NOT NULL DEFAULT '', + user_agent VARCHAR(500) NOT NULL DEFAULT '', + verified_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_report_verify_logs_report_verify_id (report_verify_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='验真日志'; + +CREATE TABLE report_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + report_id BIGINT UNSIGNED NOT NULL, + action VARCHAR(64) NOT NULL, + operator_id BIGINT UNSIGNED NULL DEFAULT NULL, + operator_name VARCHAR(64) NOT NULL DEFAULT '', + before_data JSON NULL, + after_data JSON NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_report_logs_report_id (report_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='报告操作日志'; + +CREATE TABLE material_batches ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + batch_no VARCHAR(64) NOT NULL, + total_count INT NOT NULL DEFAULT 0, + remark VARCHAR(500) NOT NULL DEFAULT '', + download_count INT NOT NULL DEFAULT 0, + last_downloaded_at DATETIME NULL DEFAULT NULL, + created_by BIGINT UNSIGNED NULL DEFAULT NULL, + created_by_name VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_material_batches_batch_no (batch_no), + KEY idx_material_batches_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物料二维码批次'; + +CREATE TABLE material_tag_codes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + batch_id BIGINT UNSIGNED NOT NULL, + qr_token VARCHAR(80) NOT NULL, + qr_url VARCHAR(500) NOT NULL, + verify_code VARCHAR(16) NOT NULL, + bind_status VARCHAR(32) NOT NULL DEFAULT 'unbound', + report_id BIGINT UNSIGNED NULL DEFAULT NULL, + report_no VARCHAR(64) NOT NULL DEFAULT '', + bound_task_id BIGINT UNSIGNED NULL DEFAULT NULL, + bound_order_id BIGINT UNSIGNED NULL DEFAULT NULL, + bound_by BIGINT UNSIGNED NULL DEFAULT NULL, + bound_by_name VARCHAR(64) NOT NULL DEFAULT '', + bound_at DATETIME NULL DEFAULT NULL, + scan_count INT NOT NULL DEFAULT 0, + verify_count INT NOT NULL DEFAULT 0, + last_scanned_at DATETIME NULL DEFAULT NULL, + last_verified_at DATETIME NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_material_tag_codes_qr_token (qr_token), + UNIQUE KEY uk_material_tag_codes_qr_url (qr_url), + UNIQUE KEY uk_material_tag_codes_report_id (report_id), + KEY idx_material_tag_codes_batch_id (batch_id), + KEY idx_material_tag_codes_verify_code (verify_code), + KEY idx_material_tag_codes_report_no (report_no), + KEY idx_material_tag_codes_bind_status (bind_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物料吊牌二维码'; + +CREATE TABLE material_batch_download_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + batch_id BIGINT UNSIGNED NOT NULL, + operator_id BIGINT UNSIGNED NULL DEFAULT NULL, + operator_name VARCHAR(64) NOT NULL DEFAULT '', + ip VARCHAR(64) NOT NULL DEFAULT '', + user_agent VARCHAR(500) NOT NULL DEFAULT '', + downloaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_material_batch_download_logs_batch_id (batch_id), + KEY idx_material_batch_download_logs_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物料批次下载日志'; + +CREATE TABLE material_tag_scan_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tag_code_id BIGINT UNSIGNED NOT NULL, + batch_id BIGINT UNSIGNED NOT NULL, + report_id BIGINT UNSIGNED NULL DEFAULT NULL, + report_no VARCHAR(64) NOT NULL DEFAULT '', + verify_type VARCHAR(32) NOT NULL DEFAULT 'scan', + verify_code_input VARCHAR(16) NOT NULL DEFAULT '', + verify_passed TINYINT(1) NOT NULL DEFAULT 0, + ip VARCHAR(64) NOT NULL DEFAULT '', + user_agent VARCHAR(500) NOT NULL DEFAULT '', + scanned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_material_tag_scan_logs_tag_code_id (tag_code_id), + KEY idx_material_tag_scan_logs_batch_id (batch_id), + KEY idx_material_tag_scan_logs_report_id (report_id), + KEY idx_material_tag_scan_logs_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物料吊牌扫码与验真日志'; + +CREATE TABLE tickets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ticket_no VARCHAR(64) NOT NULL, + ticket_type VARCHAR(32) NOT NULL, + biz_type VARCHAR(32) NOT NULL DEFAULT '', + biz_id BIGINT UNSIGNED NULL DEFAULT NULL, + order_id BIGINT UNSIGNED NULL DEFAULT NULL, + user_id BIGINT UNSIGNED NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + priority VARCHAR(32) NOT NULL DEFAULT 'normal', + assignee_id BIGINT UNSIGNED NULL DEFAULT NULL, + title VARCHAR(128) NOT NULL DEFAULT '', + content TEXT NULL, + closed_at DATETIME NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_tickets_ticket_no (ticket_no), + KEY idx_tickets_order_id (order_id), + KEY idx_tickets_user_id (user_id), + KEY idx_tickets_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工单主表'; + +CREATE TABLE ticket_messages ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + ticket_id BIGINT UNSIGNED NOT NULL, + sender_type VARCHAR(32) NOT NULL, + sender_id BIGINT UNSIGNED NULL DEFAULT NULL, + content TEXT NULL, + attachments_json JSON NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_ticket_messages_ticket_id (ticket_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工单消息'; + +CREATE TABLE message_templates ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + template_name VARCHAR(128) NOT NULL, + template_code VARCHAR(64) NOT NULL, + channel VARCHAR(32) NOT NULL, + event_code VARCHAR(64) NOT NULL, + title VARCHAR(128) NOT NULL DEFAULT '', + content TEXT NULL, + jump_type VARCHAR(32) NOT NULL DEFAULT '', + jump_value VARCHAR(255) NOT NULL DEFAULT '', + is_enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_message_templates_code (template_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息模板'; + +CREATE TABLE message_rules ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + event_code VARCHAR(64) NOT NULL, + channel VARCHAR(32) NOT NULL, + template_id BIGINT UNSIGNED NOT NULL, + delay_seconds INT NOT NULL DEFAULT 0, + dedupe_window INT NOT NULL DEFAULT 0, + is_enabled TINYINT(1) NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_message_rules_event_code (event_code), + KEY idx_message_rules_template_id (template_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息规则'; + +CREATE TABLE message_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NULL DEFAULT NULL, + template_id BIGINT UNSIGNED NULL DEFAULT NULL, + biz_type VARCHAR(32) NOT NULL DEFAULT '', + biz_id BIGINT UNSIGNED NULL DEFAULT NULL, + channel VARCHAR(32) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + fail_reason VARCHAR(255) NOT NULL DEFAULT '', + sent_at DATETIME NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_message_logs_user_id (user_id), + KEY idx_message_logs_template_id (template_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='消息发送日志'; + +CREATE TABLE user_messages ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + title VARCHAR(128) NOT NULL DEFAULT '', + content TEXT NULL, + biz_type VARCHAR(32) NOT NULL DEFAULT '', + biz_id BIGINT UNSIGNED NULL DEFAULT NULL, + is_read TINYINT(1) NOT NULL DEFAULT 0, + read_at DATETIME NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_user_messages_user_id (user_id), + KEY idx_user_messages_is_read (is_read) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站内消息'; + +CREATE TABLE admin_users ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(64) NOT NULL, + mobile VARCHAR(32) NOT NULL DEFAULT '', + email VARCHAR(128) NOT NULL DEFAULT '', + password VARCHAR(255) NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'enabled', + last_login_at DATETIME NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_admin_users_mobile (mobile) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台用户'; + +CREATE TABLE admin_api_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + admin_user_id BIGINT UNSIGNED NOT NULL, + token_hash VARCHAR(64) NOT NULL, + expire_time DATETIME NOT NULL, + last_active_at DATETIME NULL DEFAULT NULL, + last_ip VARCHAR(64) NOT NULL DEFAULT '', + user_agent VARCHAR(500) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_admin_api_tokens_token_hash (token_hash), + KEY idx_admin_api_tokens_admin_user_id (admin_user_id), + KEY idx_admin_api_tokens_expire_time (expire_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台登录Token'; + +CREATE TABLE admin_roles ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(64) NOT NULL, + code VARCHAR(64) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'enabled', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_admin_roles_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台角色'; + +CREATE TABLE admin_role_relations ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + admin_user_id BIGINT UNSIGNED NOT NULL, + role_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_admin_role_relations (admin_user_id, role_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='后台用户角色关联'; + +CREATE TABLE admin_permissions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + name VARCHAR(128) NOT NULL, + code VARCHAR(64) NOT NULL, + module VARCHAR(64) NOT NULL DEFAULT '', + action VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_admin_permissions_code (code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='权限点'; + +CREATE TABLE admin_role_permissions ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + role_id BIGINT UNSIGNED NOT NULL, + permission_id BIGINT UNSIGNED NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_admin_role_permissions (role_id, permission_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='角色权限关联'; + +CREATE TABLE system_configs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + config_group VARCHAR(64) NOT NULL, + config_key VARCHAR(128) NOT NULL, + config_value LONGTEXT NULL, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_system_configs_group_key (config_group, config_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统配置'; + +CREATE TABLE help_articles ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + category VARCHAR(32) NOT NULL DEFAULT 'service', + title VARCHAR(255) NOT NULL DEFAULT '', + summary VARCHAR(500) NOT NULL DEFAULT '', + keywords_json LONGTEXT NULL, + content_blocks_json LONGTEXT NULL, + is_recommended TINYINT(1) NOT NULL DEFAULT 0, + is_enabled TINYINT(1) NOT NULL DEFAULT 1, + sort_order INT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_help_articles_category (category), + KEY idx_help_articles_enabled (is_enabled), + KEY idx_help_articles_sort (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='帮助中心文章'; + +CREATE TABLE operation_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + module VARCHAR(64) NOT NULL, + biz_id BIGINT UNSIGNED NULL DEFAULT NULL, + action VARCHAR(64) NOT NULL, + operator_id BIGINT UNSIGNED NULL DEFAULT NULL, + operator_name VARCHAR(64) NOT NULL DEFAULT '', + before_data JSON NULL, + after_data JSON NULL, + ip VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_operation_logs_module_biz_id (module, biz_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通用操作日志'; + +SET FOREIGN_KEY_CHECKS = 1; diff --git a/server-api/docker-compose.yml b/server-api/docker-compose.yml new file mode 100644 index 0000000..352871e --- /dev/null +++ b/server-api/docker-compose.yml @@ -0,0 +1,11 @@ +version: "3" +services: + webman: + build: . + container_name: docker-webman + restart: unless-stopped + volumes: + - "./:/app" + ports: + - "8787:8787" + command: ["php", "start.php", "start" ] \ No newline at end of file diff --git a/server-api/public/favicon.ico b/server-api/public/favicon.ico new file mode 100644 index 0000000..b9f722e Binary files /dev/null and b/server-api/public/favicon.ico differ diff --git a/server-api/public/uploads/appraisal-evidence/20260424/evidence_69eb0139251c8.png b/server-api/public/uploads/appraisal-evidence/20260424/evidence_69eb0139251c8.png new file mode 100644 index 0000000..df71de1 Binary files /dev/null and b/server-api/public/uploads/appraisal-evidence/20260424/evidence_69eb0139251c8.png differ diff --git a/server-api/public/uploads/appraisal-evidence/20260424/evidence_69eb02df64e0f.png b/server-api/public/uploads/appraisal-evidence/20260424/evidence_69eb02df64e0f.png new file mode 100644 index 0000000..31b9abe --- /dev/null +++ b/server-api/public/uploads/appraisal-evidence/20260424/evidence_69eb02df64e0f.png @@ -0,0 +1 @@ +fakepng \ No newline at end of file diff --git a/server-api/public/uploads/appraisal-evidence/20260424/evidence_69eb199da98b4.png b/server-api/public/uploads/appraisal-evidence/20260424/evidence_69eb199da98b4.png new file mode 100644 index 0000000..31b9abe --- /dev/null +++ b/server-api/public/uploads/appraisal-evidence/20260424/evidence_69eb199da98b4.png @@ -0,0 +1 @@ +fakepng \ No newline at end of file diff --git a/server-api/public/uploads/appraisal-evidence/20260424/evidence_69eb1a0937ac1.png b/server-api/public/uploads/appraisal-evidence/20260424/evidence_69eb1a0937ac1.png new file mode 100644 index 0000000..31b9abe --- /dev/null +++ b/server-api/public/uploads/appraisal-evidence/20260424/evidence_69eb1a0937ac1.png @@ -0,0 +1 @@ +fakepng \ No newline at end of file diff --git a/server-api/public/uploads/appraisal/20260420/overall_front_69e60bb867053.jpeg b/server-api/public/uploads/appraisal/20260420/overall_front_69e60bb867053.jpeg new file mode 100644 index 0000000..ccedfad Binary files /dev/null and b/server-api/public/uploads/appraisal/20260420/overall_front_69e60bb867053.jpeg differ diff --git a/server-api/public/uploads/appraisal/20260420/overall_front_69e60bfc12c5a.jpeg b/server-api/public/uploads/appraisal/20260420/overall_front_69e60bfc12c5a.jpeg new file mode 100644 index 0000000..ccedfad Binary files /dev/null and b/server-api/public/uploads/appraisal/20260420/overall_front_69e60bfc12c5a.jpeg differ diff --git a/server-api/public/uploads/appraisal/20260420/overall_front_69e61d09a6d51.jpeg b/server-api/public/uploads/appraisal/20260420/overall_front_69e61d09a6d51.jpeg new file mode 100644 index 0000000..ccedfad Binary files /dev/null and b/server-api/public/uploads/appraisal/20260420/overall_front_69e61d09a6d51.jpeg differ diff --git a/server-api/public/uploads/appraisal/20260421/insole_detail_69e7251cd73d6.png b/server-api/public/uploads/appraisal/20260421/insole_detail_69e7251cd73d6.png new file mode 100644 index 0000000..df71de1 Binary files /dev/null and b/server-api/public/uploads/appraisal/20260421/insole_detail_69e7251cd73d6.png differ diff --git a/server-api/public/uploads/appraisal/20260421/overall_pair_69e725064bf96.png b/server-api/public/uploads/appraisal/20260421/overall_pair_69e725064bf96.png new file mode 100644 index 0000000..df71de1 Binary files /dev/null and b/server-api/public/uploads/appraisal/20260421/overall_pair_69e725064bf96.png differ diff --git a/server-api/public/uploads/appraisal/20260421/sole_detail_69e7252485eb1.png b/server-api/public/uploads/appraisal/20260421/sole_detail_69e7252485eb1.png new file mode 100644 index 0000000..3d5c51b Binary files /dev/null and b/server-api/public/uploads/appraisal/20260421/sole_detail_69e7252485eb1.png differ diff --git a/server-api/public/uploads/appraisal/20260421/tongue_label_69e7251103abf.png b/server-api/public/uploads/appraisal/20260421/tongue_label_69e7251103abf.png new file mode 100644 index 0000000..3d5c51b Binary files /dev/null and b/server-api/public/uploads/appraisal/20260421/tongue_label_69e7251103abf.png differ diff --git a/server-api/public/uploads/reports/20260418/AXY-R-20260420-0001.pdf b/server-api/public/uploads/reports/20260418/AXY-R-20260420-0001.pdf new file mode 100644 index 0000000..e14c755 --- /dev/null +++ b/server-api/public/uploads/reports/20260418/AXY-R-20260420-0001.pdf @@ -0,0 +1,126 @@ +%PDF-1.4 +% +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 2005 >> +stream +BT +/F1 20 Tf +1 0 0 1 52 790 Tm +<4E2D68C092745B9A62A5544A> Tj +ET +BT +/F1 10 Tf +1 0 0 1 52 758 Tm +<6B635F0F62A5544A51ED8BC1FF0C8BF74EE57F1653F79A8C771F7ED3679C4E3A51C63002> Tj +ET +BT +/F1 12 Tf +1 0 0 1 52 728 Tm +<62A5544A7F1653F7FF1A004100580059002D0052002D00320030003200360030003400320030002D0030003000300031> Tj +ET +BT +/F1 12 Tf +1 0 0 1 52 706 Tm +<51FA5177673A6784FF1A4E2D68C054084F5C673A6784> Tj +ET +BT +/F1 12 Tf +1 0 0 1 52 684 Tm +<51FA517765F695F4FF1A0032003000320036002D00300034002D00310038002000310038003A00320036003A00300030> Tj +ET +BT +/F1 12 Tf +1 0 0 1 52 662 Tm +<670D52A17C7B578BFF1A4E2D68C092745B9A> Tj +ET +BT +/F1 16 Tf +1 0 0 1 52 632 Tm +<92745B9A7ED38BBAFF1A6B6354C1> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 606 Tm +<7ED3679C8BF4660EFF1A7EFC54085F53524D900168C08D4465994E0E554654C172795F81522465ADFF0C7B2654086B6354C172795F813002> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 580 Tm +<554654C1540D79F0FF1A0052006F006C0065007800200044006100740065006A007500730074002000330036> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 560 Tm +<54C17C7B0020002F002054C1724CFF1A815588680020002F00200052006F006C00650078> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 540 Tm +<578B53F70020002F002089C4683CFF1A0044006100740065006A0075007300740020003300360020002F002000330036006D006D> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 520 Tm +<92745B9A5E080020002F0020590D68385E08FF1A5F205E0850850020002F0020674E5E085085> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 500 Tm +<621082728BC47EA7FF1A0041> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 480 Tm +<4F30503C533A95F4FF1A00A500320038003000300020002D002000A50033003200300030> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 460 Tm +<9A8C771F4FE1606FFF1A004100580059002D0052002D00320030003200360030003400320030002D00300030003000310020002F002067096548> Tj +ET +BT +/F1 10 Tf +1 0 0 1 52 432 Tm +<98CE96698BF4660EFF1A672C62A5544A57FA4E8E900168C0554654C153CA5F53524D63D04EA48D44659951FA5177300282E5554654C172B660016216> Tj +ET +BT +/F1 10 Tf +1 0 0 1 52 415 Tm +<624096448D44659953D1751F53D85316FF0C62A5544A7ED38BBA53EF80FD4E0D518D900275283002> Tj +ET +BT +/F1 9 Tf +1 0 0 1 52 42 Tm +<5B895FC39A8C92745B9A5E7353F0> Tj +ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [6 0 R] >> +endobj +6 0 obj +<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /DW 1000 >> +endobj +xref +0 7 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000247 00000 n +0000002304 00000 n +0000002426 00000 n +trailer +<< /Size 7 /Root 1 0 R >> +startxref +2584 +%%EOF \ No newline at end of file diff --git a/server-api/public/uploads/reports/20260420/AXY-R-20260420-5014.pdf b/server-api/public/uploads/reports/20260420/AXY-R-20260420-5014.pdf new file mode 100644 index 0000000..e7247d6 --- /dev/null +++ b/server-api/public/uploads/reports/20260420/AXY-R-20260420-5014.pdf @@ -0,0 +1,126 @@ +%PDF-1.4 +% +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 2041 >> +stream +BT +/F1 20 Tf +1 0 0 1 52 790 Tm +<5B895FC39A8C92745B9A62A5544A> Tj +ET +BT +/F1 10 Tf +1 0 0 1 52 758 Tm +<6B635F0F62A5544A51ED8BC1FF0C8BF74EE57F1653F79A8C771F7ED3679C4E3A51C63002> Tj +ET +BT +/F1 12 Tf +1 0 0 1 52 728 Tm +<62A5544A7F1653F7FF1A004100580059002D0052002D00320030003200360030003400320030002D0035003000310034> Tj +ET +BT +/F1 12 Tf +1 0 0 1 52 706 Tm +<51FA5177673A6784FF1A5B895FC39A8C> Tj +ET +BT +/F1 12 Tf +1 0 0 1 52 684 Tm +<51FA517765F695F4FF1A0032003000320036002D00300034002D00320030002000320030003A00350035003A00310037> Tj +ET +BT +/F1 12 Tf +1 0 0 1 52 662 Tm +<670D52A17C7B578BFF1A5B9E726992745B9A> Tj +ET +BT +/F1 16 Tf +1 0 0 1 52 632 Tm +<92745B9A7ED38BBAFF1A6B6354C1> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 606 Tm +<7ED3679C8BF4660EFF1A7EFC54085F53524D900168C08D4465994E0E554654C172795F81522465ADFF0C7B2654086B6354C172795F813002> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 580 Tm +<554654C1540D79F0FF1A0041006900720020004A006F007200640061006E00200031002000480069006700680020004F0047> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 560 Tm +<54C17C7B0020002F002054C1724CFF1A6F6E6D41978B7C7B0020002F0020004E0069006B0065> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 540 Tm +<578B53F70020002F002089C4683CFF1A0041006900720020004A006F007200640061006E00200031002000480069006700680020004F00470020002F002000340032> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 520 Tm +<92745B9A5E080020002F0020590D68385E08FF1A674E5E0850850020002F0020674E5E085085> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 500 Tm +<621082728BC47EA7FF1A0041> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 480 Tm +<4F30503C533A95F4FF1A00A500310032003000300020002D002000A50031003600300030> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 460 Tm +<9A8C771F4FE1606FFF1A004100580059002D0052002D00320030003200360030003400320030002D00350030003100340020002F002067096548> Tj +ET +BT +/F1 10 Tf +1 0 0 1 52 432 Tm +<98CE96698BF4660EFF1A672C62A5544A57FA4E8E900168C0554654C153CA5F53524D63D04EA48D44659951FA5177300282E5554654C172B660016216> Tj +ET +BT +/F1 10 Tf +1 0 0 1 52 415 Tm +<624096448D44659953D1751F53D85316FF0C62A5544A7ED38BBA53EF80FD4E0D518D900275283002> Tj +ET +BT +/F1 9 Tf +1 0 0 1 52 42 Tm +<5B895FC39A8C92745B9A5E7353F0> Tj +ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [6 0 R] >> +endobj +6 0 obj +<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /DW 1000 >> +endobj +xref +0 7 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000247 00000 n +0000002340 00000 n +0000002462 00000 n +trailer +<< /Size 7 /Root 1 0 R >> +startxref +2620 +%%EOF \ No newline at end of file diff --git a/server-api/public/uploads/reports/20260421/AXY-R-20260421-6214.pdf b/server-api/public/uploads/reports/20260421/AXY-R-20260421-6214.pdf new file mode 100644 index 0000000..736c42f --- /dev/null +++ b/server-api/public/uploads/reports/20260421/AXY-R-20260421-6214.pdf @@ -0,0 +1,126 @@ +%PDF-1.4 +% +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 1933 >> +stream +BT +/F1 20 Tf +1 0 0 1 52 790 Tm +<4E2D68C092745B9A62A5544A> Tj +ET +BT +/F1 10 Tf +1 0 0 1 52 758 Tm +<6B635F0F62A5544A51ED8BC1FF0C8BF74EE57F1653F79A8C771F7ED3679C4E3A51C63002> Tj +ET +BT +/F1 12 Tf +1 0 0 1 52 728 Tm +<62A5544A7F1653F7FF1A004100580059002D0052002D00320030003200360030003400320031002D0036003200310034> Tj +ET +BT +/F1 12 Tf +1 0 0 1 52 706 Tm +<51FA5177673A6784FF1A4E2D68C054084F5C673A6784> Tj +ET +BT +/F1 12 Tf +1 0 0 1 52 684 Tm +<51FA517765F695F4FF1A0032003000320036002D00300034002D00320031002000320033003A00320037003A00310036> Tj +ET +BT +/F1 12 Tf +1 0 0 1 52 662 Tm +<670D52A17C7B578BFF1A4E2D68C092745B9A> Tj +ET +BT +/F1 16 Tf +1 0 0 1 52 632 Tm +<92745B9A7ED38BBAFF1A6B6354C1> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 606 Tm +<7ED3679C8BF4660EFF1A7B2654086B6354C1> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 580 Tm +<554654C1540D79F0FF1A0041006900720020004A006F007200640061006E00200031002000480069006700680020004F0047> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 560 Tm +<54C17C7B0020002F002054C1724CFF1A6F6E6D41978B7C7B0020002F0020004E0069006B0065> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 540 Tm +<578B53F70020002F002089C4683CFF1A0041006900720020004A006F007200640061006E00200031002000480069006700680020004F00470020002F0020004D004D> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 520 Tm +<92745B9A5E080020002F0020590D68385E08FF1A002F> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 500 Tm +<621082728BC47EA7FF1A0042> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 480 Tm +<4F30503C533A95F4FF1A00A50031003000300020002D002000A5003100350030> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 460 Tm +<9A8C771F4FE1606FFF1A004100580059002D0052002D00320030003200360030003400320031002D00360032003100340020002F002067096548> Tj +ET +BT +/F1 10 Tf +1 0 0 1 52 432 Tm +<98CE96698BF4660EFF1A672C62A5544A57FA4E8E900168C0554654C153CA5F53524D63D04EA48D44659951FA5177300282E5554654C172B660016216> Tj +ET +BT +/F1 10 Tf +1 0 0 1 52 415 Tm +<624096448D44659953D1751F53D85316FF0C62A5544A7ED38BBA53EF80FD4E0D518D900275283002> Tj +ET +BT +/F1 9 Tf +1 0 0 1 52 42 Tm +<5B895FC39A8C92745B9A5E7353F0> Tj +ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [6 0 R] >> +endobj +6 0 obj +<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /DW 1000 >> +endobj +xref +0 7 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000247 00000 n +0000002232 00000 n +0000002354 00000 n +trailer +<< /Size 7 /Root 1 0 R >> +startxref +2512 +%%EOF \ No newline at end of file diff --git a/server-api/public/uploads/reports/20260424/AXY-R-20260424-5171.pdf b/server-api/public/uploads/reports/20260424/AXY-R-20260424-5171.pdf new file mode 100644 index 0000000..0ac9cb4 --- /dev/null +++ b/server-api/public/uploads/reports/20260424/AXY-R-20260424-5171.pdf @@ -0,0 +1,126 @@ +%PDF-1.4 +% +1 0 obj +<< /Type /Catalog /Pages 2 0 R >> +endobj +2 0 obj +<< /Type /Pages /Kids [3 0 R] /Count 1 >> +endobj +3 0 obj +<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >> +endobj +4 0 obj +<< /Length 1913 >> +stream +BT +/F1 20 Tf +1 0 0 1 52 790 Tm +<5B895FC39A8C92745B9A62A5544A> Tj +ET +BT +/F1 10 Tf +1 0 0 1 52 758 Tm +<6B635F0F62A5544A51ED8BC1FF0C8BF74EE57F1653F79A8C771F7ED3679C4E3A51C63002> Tj +ET +BT +/F1 12 Tf +1 0 0 1 52 728 Tm +<62A5544A7F1653F7FF1A004100580059002D0052002D00320030003200360030003400320034002D0035003100370031> Tj +ET +BT +/F1 12 Tf +1 0 0 1 52 706 Tm +<51FA5177673A6784FF1A5B895FC39A8C> Tj +ET +BT +/F1 12 Tf +1 0 0 1 52 684 Tm +<51FA517765F695F4FF1A0032003000320036002D00300034002D00320034002000310033003A00350030003A00310032> Tj +ET +BT +/F1 12 Tf +1 0 0 1 52 662 Tm +<670D52A17C7B578BFF1A5B9E726992745B9A> Tj +ET +BT +/F1 16 Tf +1 0 0 1 52 632 Tm +<92745B9A7ED38BBAFF1A6B6354C1> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 606 Tm +<7ED3679C8BF4660EFF1A> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 580 Tm +<554654C1540D79F0FF1A004E006500760065007200660075006C006C0020004D004D> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 560 Tm +<54C17C7B0020002F002054C1724CFF1A59624F8854C17BB153050020002F0020004C006F007500690073002000560075006900740074006F006E> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 540 Tm +<578B53F70020002F002089C4683CFF1A004E006500760065007200660075006C006C0020004D004D0020002F0020004D004D> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 520 Tm +<92745B9A5E080020002F0020590D68385E08FF1A7CFB7EDF7BA1740654580020002F00207CFB7EDF7BA174065458> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 500 Tm +<621082728BC47EA7FF1A> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 480 Tm +<4F30503C533A95F4FF1A00A500300020002D002000A50030> Tj +ET +BT +/F1 11 Tf +1 0 0 1 52 460 Tm +<9A8C771F4FE1606FFF1A004100580059002D0052002D00320030003200360030003400320034002D00350031003700310020002F002067096548> Tj +ET +BT +/F1 10 Tf +1 0 0 1 52 432 Tm +<98CE96698BF4660EFF1A672C62A5544A57FA4E8E900168C0554654C153CA5F53524D63D04EA48D44659951FA5177300282E5554654C172B660016216> Tj +ET +BT +/F1 10 Tf +1 0 0 1 52 415 Tm +<624096448D44659953D1751F53D85316FF0C62A5544A7ED38BBA53EF80FD4E0D518D900275283002> Tj +ET +BT +/F1 9 Tf +1 0 0 1 52 42 Tm +<5B895FC39A8C92745B9A5E7353F0> Tj +ET +endstream +endobj +5 0 obj +<< /Type /Font /Subtype /Type0 /BaseFont /STSong-Light /Encoding /UniGB-UCS2-H /DescendantFonts [6 0 R] >> +endobj +6 0 obj +<< /Type /Font /Subtype /CIDFontType0 /BaseFont /STSong-Light /CIDSystemInfo << /Registry (Adobe) /Ordering (GB1) /Supplement 4 >> /DW 1000 >> +endobj +xref +0 7 +0000000000 65535 f +0000000015 00000 n +0000000064 00000 n +0000000121 00000 n +0000000247 00000 n +0000002212 00000 n +0000002334 00000 n +trailer +<< /Size 7 /Root 1 0 R >> +startxref +2492 +%%EOF \ No newline at end of file diff --git a/server-api/public/uploads/supplement/20260420/supplement_1_69e62d0b0415e.jpeg b/server-api/public/uploads/supplement/20260420/supplement_1_69e62d0b0415e.jpeg new file mode 100644 index 0000000..ccedfad Binary files /dev/null and b/server-api/public/uploads/supplement/20260420/supplement_1_69e62d0b0415e.jpeg differ diff --git a/server-api/public/uploads/supplement/20260420/supplement_2_69e62d0b28f2b.jpeg b/server-api/public/uploads/supplement/20260420/supplement_2_69e62d0b28f2b.jpeg new file mode 100644 index 0000000..ccedfad Binary files /dev/null and b/server-api/public/uploads/supplement/20260420/supplement_2_69e62d0b28f2b.jpeg differ diff --git a/server-api/public/uploads/supplement/20260421/supplement_1_69e79270c54b6.png b/server-api/public/uploads/supplement/20260421/supplement_1_69e79270c54b6.png new file mode 100644 index 0000000..df71de1 Binary files /dev/null and b/server-api/public/uploads/supplement/20260421/supplement_1_69e79270c54b6.png differ diff --git a/server-api/public/uploads/supplement/20260421/supplement_1_69e793fb9eaf1.png b/server-api/public/uploads/supplement/20260421/supplement_1_69e793fb9eaf1.png new file mode 100644 index 0000000..df71de1 Binary files /dev/null and b/server-api/public/uploads/supplement/20260421/supplement_1_69e793fb9eaf1.png differ diff --git a/server-api/public/uploads/supplement/20260421/supplement_1_69e7940055d22.png b/server-api/public/uploads/supplement/20260421/supplement_1_69e7940055d22.png new file mode 100644 index 0000000..3d5c51b Binary files /dev/null and b/server-api/public/uploads/supplement/20260421/supplement_1_69e7940055d22.png differ diff --git a/server-api/public/uploads/supplement/20260421/supplement_2_69e792770eea4.png b/server-api/public/uploads/supplement/20260421/supplement_2_69e792770eea4.png new file mode 100644 index 0000000..df71de1 Binary files /dev/null and b/server-api/public/uploads/supplement/20260421/supplement_2_69e792770eea4.png differ diff --git a/server-api/public/uploads/tickets/20260420/ticket_69e634a400b9e.jpeg b/server-api/public/uploads/tickets/20260420/ticket_69e634a400b9e.jpeg new file mode 100644 index 0000000..ccedfad Binary files /dev/null and b/server-api/public/uploads/tickets/20260420/ticket_69e634a400b9e.jpeg differ diff --git a/server-api/public/uploads/tickets/20260420/ticket_69e634cb3646c.jpeg b/server-api/public/uploads/tickets/20260420/ticket_69e634cb3646c.jpeg new file mode 100644 index 0000000..ccedfad Binary files /dev/null and b/server-api/public/uploads/tickets/20260420/ticket_69e634cb3646c.jpeg differ diff --git a/server-api/public/uploads/tickets/20260420/ticket_69e635941de60.jpeg b/server-api/public/uploads/tickets/20260420/ticket_69e635941de60.jpeg new file mode 100644 index 0000000..ccedfad Binary files /dev/null and b/server-api/public/uploads/tickets/20260420/ticket_69e635941de60.jpeg differ diff --git a/server-api/start.php b/server-api/start.php new file mode 100755 index 0000000..41ad7ef --- /dev/null +++ b/server-api/start.php @@ -0,0 +1,5 @@ +#!/usr/bin/env php + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +namespace support; + +/** + * Class Request + * @package support + */ +class Request extends \Webman\Http\Request +{ + +} \ No newline at end of file diff --git a/server-api/support/Response.php b/server-api/support/Response.php new file mode 100644 index 0000000..9bc4e1e --- /dev/null +++ b/server-api/support/Response.php @@ -0,0 +1,24 @@ + + * @copyright walkor + * @link http://www.workerman.net/ + * @license http://www.opensource.org/licenses/mit-license.php MIT License + */ + +namespace support; + +/** + * Class Response + * @package support + */ +class Response extends \Webman\Http\Response +{ + +} \ No newline at end of file diff --git a/server-api/support/Setup.php b/server-api/support/Setup.php new file mode 100644 index 0000000..99320e8 --- /dev/null +++ b/server-api/support/Setup.php @@ -0,0 +1,1558 @@ + \DateTimeZone::ASIA, + 'Europe' => \DateTimeZone::EUROPE, + 'America' => \DateTimeZone::AMERICA, + 'Africa' => \DateTimeZone::AFRICA, + 'Australia' => \DateTimeZone::AUSTRALIA, + 'Pacific' => \DateTimeZone::PACIFIC, + 'Atlantic' => \DateTimeZone::ATLANTIC, + 'Indian' => \DateTimeZone::INDIAN, + 'Antarctica' => \DateTimeZone::ANTARCTICA, + 'Arctic' => \DateTimeZone::ARCTIC, + 'UTC' => \DateTimeZone::UTC, + ]; + + // --- Locale => default timezone --- + + private const LOCALE_DEFAULT_TIMEZONES = [ + 'zh_CN' => 'Asia/Shanghai', + 'zh_TW' => 'Asia/Taipei', + 'en' => 'UTC', + 'ja' => 'Asia/Tokyo', + 'ko' => 'Asia/Seoul', + 'fr' => 'Europe/Paris', + 'de' => 'Europe/Berlin', + 'es' => 'Europe/Madrid', + 'pt_BR' => 'America/Sao_Paulo', + 'ru' => 'Europe/Moscow', + 'vi' => 'Asia/Ho_Chi_Minh', + 'tr' => 'Europe/Istanbul', + 'id' => 'Asia/Jakarta', + 'th' => 'Asia/Bangkok', + ]; + + // --- Locale options (localized display names) --- + + private const LOCALE_LABELS = [ + 'zh_CN' => '简体中文', + 'zh_TW' => '繁體中文', + 'en' => 'English', + 'ja' => '日本語', + 'ko' => '한국어', + 'fr' => 'Français', + 'de' => 'Deutsch', + 'es' => 'Español', + 'pt_BR' => 'Português (Brasil)', + 'ru' => 'Русский', + 'vi' => 'Tiếng Việt', + 'tr' => 'Türkçe', + 'id' => 'Bahasa Indonesia', + 'th' => 'ไทย', + ]; + + // --- Multilingual messages (%s = placeholder) --- + + private const MESSAGES = [ + 'zh_CN' => [ + 'remove_package_question' => '发现以下已安装组件本次未选择,是否将其卸载 ?%s', + 'removing_package' => '- 准备移除组件 %s', + 'removing' => '卸载:', + 'error_remove' => '卸载组件出错,请手动执行:composer remove %s', + 'done_remove' => '已卸载组件。', + 'skip' => '非交互模式,跳过安装向导。', + 'default_choice' => ' (默认 %s)', + 'timezone_prompt' => '时区 (默认 %s,输入可联想补全): ', + 'timezone_title' => '时区设置 (默认 %s)', + 'timezone_help' => '输入关键字Tab自动补全,可↑↓下选择:', + 'timezone_region' => '选择时区区域', + 'timezone_city' => '选择时区', + 'timezone_invalid' => '无效的时区,已使用默认值 %s', + 'timezone_input_prompt' => '输入时区或关键字:', + 'timezone_pick_prompt' => '请输入数字编号或关键字:', + 'timezone_no_match' => '未找到匹配的时区,请重试。', + 'timezone_invalid_index' => '无效的编号,请重新输入。', + 'yes' => '是', + 'no' => '否', + 'adding_package' => '- 添加依赖 %s', + 'console_question' => '安装命令行组件 webman/console', + 'db_question' => '数据库组件', + 'db_none' => '不安装', + 'db_invalid' => '请输入有效选项', + 'redis_question' => '安装 Redis 组件 webman/redis', + 'events_note' => ' (Redis 依赖 illuminate/events,已自动包含)', + 'validation_question' => '安装验证器组件 webman/validation', + 'template_question' => '模板引擎', + 'template_none' => '不安装', + 'no_components' => '未选择额外组件。', + 'installing' => '即将安装:', + 'running' => '执行:', + 'error_install' => '安装可选组件时出错,请手动执行:composer require %s', + 'done' => '可选组件安装完成。', + 'summary_locale' => '语言:%s', + 'summary_timezone' => '时区:%s', + ], + 'zh_TW' => [ + 'skip' => '非交互模式,跳過安裝嚮導。', + 'default_choice' => ' (預設 %s)', + 'timezone_prompt' => '時區 (預設 %s,輸入可聯想補全): ', + 'timezone_title' => '時區設定 (預設 %s)', + 'timezone_help' => '輸入關鍵字Tab自動補全,可↑↓上下選擇:', + 'timezone_region' => '選擇時區區域', + 'timezone_city' => '選擇時區', + 'timezone_invalid' => '無效的時區,已使用預設值 %s', + 'timezone_input_prompt' => '輸入時區或關鍵字:', + 'timezone_pick_prompt' => '請輸入數字編號或關鍵字:', + 'timezone_no_match' => '未找到匹配的時區,請重試。', + 'timezone_invalid_index' => '無效的編號,請重新輸入。', + 'yes' => '是', + 'no' => '否', + 'adding_package' => '- 新增依賴 %s', + 'console_question' => '安裝命令列組件 webman/console', + 'db_question' => '資料庫組件', + 'db_none' => '不安裝', + 'db_invalid' => '請輸入有效選項', + 'redis_question' => '安裝 Redis 組件 webman/redis', + 'events_note' => ' (Redis 依賴 illuminate/events,已自動包含)', + 'validation_question' => '安裝驗證器組件 webman/validation', + 'template_question' => '模板引擎', + 'template_none' => '不安裝', + 'no_components' => '未選擇額外組件。', + 'installing' => '即將安裝:', + 'running' => '執行:', + 'error_install' => '安裝可選組件時出錯,請手動執行:composer require %s', + 'done' => '可選組件安裝完成。', + 'summary_locale' => '語言:%s', + 'summary_timezone' => '時區:%s', + ], + 'en' => [ + 'skip' => 'Non-interactive mode, skipping setup wizard.', + 'default_choice' => ' (default %s)', + 'timezone_prompt' => 'Timezone (default=%s, type to autocomplete): ', + 'timezone_title' => 'Timezone (default=%s)', + 'timezone_help' => 'Type keyword then press Tab to autocomplete, use ↑↓ to choose:', + 'timezone_region' => 'Select timezone region', + 'timezone_city' => 'Select timezone', + 'timezone_invalid' => 'Invalid timezone, using default %s', + 'timezone_input_prompt' => 'Enter timezone or keyword:', + 'timezone_pick_prompt' => 'Enter number or keyword:', + 'timezone_no_match' => 'No matching timezone found, please try again.', + 'timezone_invalid_index' => 'Invalid number, please try again.', + 'yes' => 'yes', + 'no' => 'no', + 'adding_package' => '- Adding package %s', + 'console_question' => 'Install console component webman/console', + 'db_question' => 'Database component', + 'db_none' => 'None', + 'db_invalid' => 'Please enter a valid option', + 'redis_question' => 'Install Redis component webman/redis', + 'events_note' => ' (Redis requires illuminate/events, automatically included)', + 'validation_question' => 'Install validator component webman/validation', + 'template_question' => 'Template engine', + 'template_none' => 'None', + 'no_components' => 'No optional components selected.', + 'installing' => 'Installing:', + 'running' => 'Running:', + 'error_install' => 'Failed to install. Try manually: composer require %s', + 'done' => 'Optional components installed.', + 'summary_locale' => 'Language: %s', + 'summary_timezone' => 'Timezone: %s', + ], + 'ja' => [ + 'skip' => '非対話モードのため、セットアップウィザードをスキップします。', + 'default_choice' => ' (デフォルト %s)', + 'timezone_prompt' => 'タイムゾーン (デフォルト=%s、入力で補完): ', + 'timezone_title' => 'タイムゾーン (デフォルト=%s)', + 'timezone_help' => 'キーワード入力→Tabで補完、↑↓で選択:', + 'timezone_region' => 'タイムゾーンの地域を選択', + 'timezone_city' => 'タイムゾーンを選択', + 'timezone_invalid' => '無効なタイムゾーンです。デフォルト %s を使用します', + 'timezone_input_prompt' => 'タイムゾーンまたはキーワードを入力:', + 'timezone_pick_prompt' => '番号またはキーワードを入力:', + 'timezone_no_match' => '一致するタイムゾーンが見つかりません。再試行してください。', + 'timezone_invalid_index' => '無効な番号です。もう一度入力してください。', + 'yes' => 'はい', + 'no' => 'いいえ', + 'adding_package' => '- パッケージを追加 %s', + 'console_question' => 'コンソールコンポーネント webman/console をインストール', + 'db_question' => 'データベースコンポーネント', + 'db_none' => 'インストールしない', + 'db_invalid' => '有効なオプションを入力してください', + 'redis_question' => 'Redis コンポーネント webman/redis をインストール', + 'events_note' => ' (Redis は illuminate/events が必要です。自動的に含まれます)', + 'validation_question' => 'バリデーションコンポーネント webman/validation をインストール', + 'template_question' => 'テンプレートエンジン', + 'template_none' => 'インストールしない', + 'no_components' => 'オプションコンポーネントが選択されていません。', + 'installing' => 'インストール中:', + 'running' => '実行中:', + 'error_install' => 'インストールに失敗しました。手動で実行してください:composer require %s', + 'done' => 'オプションコンポーネントのインストールが完了しました。', + 'summary_locale' => '言語:%s', + 'summary_timezone' => 'タイムゾーン:%s', + ], + 'ko' => [ + 'skip' => '비대화형 모드입니다. 설치 마법사를 건너뜁니다.', + 'default_choice' => ' (기본값 %s)', + 'timezone_prompt' => '시간대 (기본값=%s, 입력하여 자동완성): ', + 'timezone_title' => '시간대 (기본값=%s)', + 'timezone_help' => '키워드 입력 후 Tab 자동완성, ↑↓로 선택:', + 'timezone_region' => '시간대 지역 선택', + 'timezone_city' => '시간대 선택', + 'timezone_invalid' => '잘못된 시간대입니다. 기본값 %s 을(를) 사용합니다', + 'timezone_input_prompt' => '시간대 또는 키워드 입력:', + 'timezone_pick_prompt' => '번호 또는 키워드 입력:', + 'timezone_no_match' => '일치하는 시간대를 찾을 수 없습니다. 다시 시도하세요.', + 'timezone_invalid_index' => '잘못된 번호입니다. 다시 입력하세요.', + 'yes' => '예', + 'no' => '아니오', + 'adding_package' => '- 패키지 추가 %s', + 'console_question' => '콘솔 컴포넌트 webman/console 설치', + 'db_question' => '데이터베이스 컴포넌트', + 'db_none' => '설치 안 함', + 'db_invalid' => '유효한 옵션을 입력하세요', + 'redis_question' => 'Redis 컴포넌트 webman/redis 설치', + 'events_note' => ' (Redis는 illuminate/events가 필요합니다. 자동으로 포함됩니다)', + 'validation_question' => '검증 컴포넌트 webman/validation 설치', + 'template_question' => '템플릿 엔진', + 'template_none' => '설치 안 함', + 'no_components' => '선택된 추가 컴포넌트가 없습니다.', + 'installing' => '설치 예정:', + 'running' => '실행 중:', + 'error_install' => '설치에 실패했습니다. 수동으로 실행하세요: composer require %s', + 'done' => '선택 컴포넌트 설치가 완료되었습니다.', + 'summary_locale' => '언어: %s', + 'summary_timezone' => '시간대: %s', + ], + 'fr' => [ + 'skip' => 'Mode non interactif, assistant d\'installation ignoré.', + 'default_choice' => ' (défaut %s)', + 'timezone_prompt' => 'Fuseau horaire (défaut=%s, tapez pour compléter) : ', + 'timezone_title' => 'Fuseau horaire (défaut=%s)', + 'timezone_help' => 'Tapez un mot-clé, Tab pour compléter, ↑↓ pour choisir :', + 'timezone_region' => 'Sélectionnez la région du fuseau horaire', + 'timezone_city' => 'Sélectionnez le fuseau horaire', + 'timezone_invalid' => 'Fuseau horaire invalide, utilisation de %s par défaut', + 'timezone_input_prompt' => 'Entrez un fuseau horaire ou un mot-clé :', + 'timezone_pick_prompt' => 'Entrez un numéro ou un mot-clé :', + 'timezone_no_match' => 'Aucun fuseau horaire correspondant, veuillez réessayer.', + 'timezone_invalid_index' => 'Numéro invalide, veuillez réessayer.', + 'yes' => 'oui', + 'no' => 'non', + 'adding_package' => '- Ajout du paquet %s', + 'console_question' => 'Installer le composant console webman/console', + 'db_question' => 'Composant base de données', + 'db_none' => 'Aucun', + 'db_invalid' => 'Veuillez entrer une option valide', + 'redis_question' => 'Installer le composant Redis webman/redis', + 'events_note' => ' (Redis nécessite illuminate/events, inclus automatiquement)', + 'validation_question' => 'Installer le composant de validation webman/validation', + 'template_question' => 'Moteur de templates', + 'template_none' => 'Aucun', + 'no_components' => 'Aucun composant optionnel sélectionné.', + 'installing' => 'Installation en cours :', + 'running' => 'Exécution :', + 'error_install' => 'Échec de l\'installation. Essayez manuellement : composer require %s', + 'done' => 'Composants optionnels installés.', + 'summary_locale' => 'Langue : %s', + 'summary_timezone' => 'Fuseau horaire : %s', + ], + 'de' => [ + 'skip' => 'Nicht-interaktiver Modus, Einrichtungsassistent übersprungen.', + 'default_choice' => ' (Standard %s)', + 'timezone_prompt' => 'Zeitzone (Standard=%s, Eingabe zur Vervollständigung): ', + 'timezone_title' => 'Zeitzone (Standard=%s)', + 'timezone_help' => 'Stichwort tippen, Tab ergänzt, ↑↓ auswählen:', + 'timezone_region' => 'Zeitzone Region auswählen', + 'timezone_city' => 'Zeitzone auswählen', + 'timezone_invalid' => 'Ungültige Zeitzone, Standardwert %s wird verwendet', + 'timezone_input_prompt' => 'Zeitzone oder Stichwort eingeben:', + 'timezone_pick_prompt' => 'Nummer oder Stichwort eingeben:', + 'timezone_no_match' => 'Keine passende Zeitzone gefunden, bitte erneut versuchen.', + 'timezone_invalid_index' => 'Ungültige Nummer, bitte erneut eingeben.', + 'yes' => 'ja', + 'no' => 'nein', + 'adding_package' => '- Paket hinzufügen %s', + 'console_question' => 'Konsolen-Komponente webman/console installieren', + 'db_question' => 'Datenbank-Komponente', + 'db_none' => 'Keine', + 'db_invalid' => 'Bitte geben Sie eine gültige Option ein', + 'redis_question' => 'Redis-Komponente webman/redis installieren', + 'events_note' => ' (Redis benötigt illuminate/events, automatisch eingeschlossen)', + 'validation_question' => 'Validierungs-Komponente webman/validation installieren', + 'template_question' => 'Template-Engine', + 'template_none' => 'Keine', + 'no_components' => 'Keine optionalen Komponenten ausgewählt.', + 'installing' => 'Installation:', + 'running' => 'Ausführung:', + 'error_install' => 'Installation fehlgeschlagen. Manuell ausführen: composer require %s', + 'done' => 'Optionale Komponenten installiert.', + 'summary_locale' => 'Sprache: %s', + 'summary_timezone' => 'Zeitzone: %s', + ], + 'es' => [ + 'skip' => 'Modo no interactivo, asistente de instalación omitido.', + 'default_choice' => ' (predeterminado %s)', + 'timezone_prompt' => 'Zona horaria (predeterminado=%s, escriba para autocompletar): ', + 'timezone_title' => 'Zona horaria (predeterminado=%s)', + 'timezone_help' => 'Escriba una palabra clave, Tab autocompleta, use ↑↓ para elegir:', + 'timezone_region' => 'Seleccione la región de zona horaria', + 'timezone_city' => 'Seleccione la zona horaria', + 'timezone_invalid' => 'Zona horaria inválida, usando valor predeterminado %s', + 'timezone_input_prompt' => 'Ingrese zona horaria o palabra clave:', + 'timezone_pick_prompt' => 'Ingrese número o palabra clave:', + 'timezone_no_match' => 'No se encontró zona horaria coincidente, intente de nuevo.', + 'timezone_invalid_index' => 'Número inválido, intente de nuevo.', + 'yes' => 'sí', + 'no' => 'no', + 'adding_package' => '- Agregando paquete %s', + 'console_question' => 'Instalar componente de consola webman/console', + 'db_question' => 'Componente de base de datos', + 'db_none' => 'Ninguno', + 'db_invalid' => 'Por favor ingrese una opción válida', + 'redis_question' => 'Instalar componente Redis webman/redis', + 'events_note' => ' (Redis requiere illuminate/events, incluido automáticamente)', + 'validation_question' => 'Instalar componente de validación webman/validation', + 'template_question' => 'Motor de plantillas', + 'template_none' => 'Ninguno', + 'no_components' => 'No se seleccionaron componentes opcionales.', + 'installing' => 'Instalando:', + 'running' => 'Ejecutando:', + 'error_install' => 'Error en la instalación. Intente manualmente: composer require %s', + 'done' => 'Componentes opcionales instalados.', + 'summary_locale' => 'Idioma: %s', + 'summary_timezone' => 'Zona horaria: %s', + ], + 'pt_BR' => [ + 'skip' => 'Modo não interativo, assistente de instalação ignorado.', + 'default_choice' => ' (padrão %s)', + 'timezone_prompt' => 'Fuso horário (padrão=%s, digite para autocompletar): ', + 'timezone_title' => 'Fuso horário (padrão=%s)', + 'timezone_help' => 'Digite uma palavra-chave, Tab autocompleta, use ↑↓ para escolher:', + 'timezone_region' => 'Selecione a região do fuso horário', + 'timezone_city' => 'Selecione o fuso horário', + 'timezone_invalid' => 'Fuso horário inválido, usando padrão %s', + 'timezone_input_prompt' => 'Digite fuso horário ou palavra-chave:', + 'timezone_pick_prompt' => 'Digite número ou palavra-chave:', + 'timezone_no_match' => 'Nenhum fuso horário encontrado, tente novamente.', + 'timezone_invalid_index' => 'Número inválido, tente novamente.', + 'yes' => 'sim', + 'no' => 'não', + 'adding_package' => '- Adicionando pacote %s', + 'console_question' => 'Instalar componente de console webman/console', + 'db_question' => 'Componente de banco de dados', + 'db_none' => 'Nenhum', + 'db_invalid' => 'Por favor, digite uma opção válida', + 'redis_question' => 'Instalar componente Redis webman/redis', + 'events_note' => ' (Redis requer illuminate/events, incluído automaticamente)', + 'validation_question' => 'Instalar componente de validação webman/validation', + 'template_question' => 'Motor de templates', + 'template_none' => 'Nenhum', + 'no_components' => 'Nenhum componente opcional selecionado.', + 'installing' => 'Instalando:', + 'running' => 'Executando:', + 'error_install' => 'Falha na instalação. Tente manualmente: composer require %s', + 'done' => 'Componentes opcionais instalados.', + 'summary_locale' => 'Idioma: %s', + 'summary_timezone' => 'Fuso horário: %s', + ], + 'ru' => [ + 'skip' => 'Неинтерактивный режим, мастер установки пропущен.', + 'default_choice' => ' (по умолчанию %s)', + 'timezone_prompt' => 'Часовой пояс (по умолчанию=%s, введите для автодополнения): ', + 'timezone_title' => 'Часовой пояс (по умолчанию=%s)', + 'timezone_help' => 'Введите ключевое слово, Tab для автодополнения, ↑↓ для выбора:', + 'timezone_region' => 'Выберите регион часового пояса', + 'timezone_city' => 'Выберите часовой пояс', + 'timezone_invalid' => 'Неверный часовой пояс, используется значение по умолчанию %s', + 'timezone_input_prompt' => 'Введите часовой пояс или ключевое слово:', + 'timezone_pick_prompt' => 'Введите номер или ключевое слово:', + 'timezone_no_match' => 'Совпадающий часовой пояс не найден, попробуйте снова.', + 'timezone_invalid_index' => 'Неверный номер, попробуйте снова.', + 'yes' => 'да', + 'no' => 'нет', + 'adding_package' => '- Добавление пакета %s', + 'console_question' => 'Установить консольный компонент webman/console', + 'db_question' => 'Компонент базы данных', + 'db_none' => 'Не устанавливать', + 'db_invalid' => 'Пожалуйста, введите допустимый вариант', + 'redis_question' => 'Установить компонент Redis webman/redis', + 'events_note' => ' (Redis требует illuminate/events, автоматически включён)', + 'validation_question' => 'Установить компонент валидации webman/validation', + 'template_question' => 'Шаблонизатор', + 'template_none' => 'Не устанавливать', + 'no_components' => 'Дополнительные компоненты не выбраны.', + 'installing' => 'Установка:', + 'running' => 'Выполнение:', + 'error_install' => 'Ошибка установки. Выполните вручную: composer require %s', + 'done' => 'Дополнительные компоненты установлены.', + 'summary_locale' => 'Язык: %s', + 'summary_timezone' => 'Часовой пояс: %s', + ], + 'vi' => [ + 'skip' => 'Chế độ không tương tác, bỏ qua trình hướng dẫn cài đặt.', + 'default_choice' => ' (mặc định %s)', + 'timezone_prompt' => 'Múi giờ (mặc định=%s, nhập để tự động hoàn thành): ', + 'timezone_title' => 'Múi giờ (mặc định=%s)', + 'timezone_help' => 'Nhập từ khóa, Tab để tự hoàn thành, dùng ↑↓ để chọn:', + 'timezone_region' => 'Chọn khu vực múi giờ', + 'timezone_city' => 'Chọn múi giờ', + 'timezone_invalid' => 'Múi giờ không hợp lệ, sử dụng mặc định %s', + 'timezone_input_prompt' => 'Nhập múi giờ hoặc từ khóa:', + 'timezone_pick_prompt' => 'Nhập số thứ tự hoặc từ khóa:', + 'timezone_no_match' => 'Không tìm thấy múi giờ phù hợp, vui lòng thử lại.', + 'timezone_invalid_index' => 'Số không hợp lệ, vui lòng thử lại.', + 'yes' => 'có', + 'no' => 'không', + 'adding_package' => '- Thêm gói %s', + 'console_question' => 'Cài đặt thành phần console webman/console', + 'db_question' => 'Thành phần cơ sở dữ liệu', + 'db_none' => 'Không cài đặt', + 'db_invalid' => 'Vui lòng nhập tùy chọn hợp lệ', + 'redis_question' => 'Cài đặt thành phần Redis webman/redis', + 'events_note' => ' (Redis cần illuminate/events, đã tự động bao gồm)', + 'validation_question' => 'Cài đặt thành phần xác thực webman/validation', + 'template_question' => 'Công cụ mẫu', + 'template_none' => 'Không cài đặt', + 'no_components' => 'Không có thành phần tùy chọn nào được chọn.', + 'installing' => 'Đang cài đặt:', + 'running' => 'Đang thực thi:', + 'error_install' => 'Cài đặt thất bại. Thử thủ công: composer require %s', + 'done' => 'Các thành phần tùy chọn đã được cài đặt.', + 'summary_locale' => 'Ngôn ngữ: %s', + 'summary_timezone' => 'Múi giờ: %s', + ], + 'tr' => [ + 'skip' => 'Etkileşimsiz mod, kurulum sihirbazı atlanıyor.', + 'default_choice' => ' (varsayılan %s)', + 'timezone_prompt' => 'Saat dilimi (varsayılan=%s, otomatik tamamlama için yazın): ', + 'timezone_title' => 'Saat dilimi (varsayılan=%s)', + 'timezone_help' => 'Anahtar kelime yazın, Tab tamamlar, ↑↓ ile seçin:', + 'timezone_region' => 'Saat dilimi bölgesini seçin', + 'timezone_city' => 'Saat dilimini seçin', + 'timezone_invalid' => 'Geçersiz saat dilimi, varsayılan %s kullanılıyor', + 'timezone_input_prompt' => 'Saat dilimi veya anahtar kelime girin:', + 'timezone_pick_prompt' => 'Numara veya anahtar kelime girin:', + 'timezone_no_match' => 'Eşleşen saat dilimi bulunamadı, tekrar deneyin.', + 'timezone_invalid_index' => 'Geçersiz numara, tekrar deneyin.', + 'yes' => 'evet', + 'no' => 'hayır', + 'adding_package' => '- Paket ekleniyor %s', + 'console_question' => 'Konsol bileşeni webman/console yüklensin mi', + 'db_question' => 'Veritabanı bileşeni', + 'db_none' => 'Yok', + 'db_invalid' => 'Lütfen geçerli bir seçenek girin', + 'redis_question' => 'Redis bileşeni webman/redis yüklensin mi', + 'events_note' => ' (Redis, illuminate/events gerektirir, otomatik olarak dahil edildi)', + 'validation_question' => 'Doğrulama bileşeni webman/validation yüklensin mi', + 'template_question' => 'Şablon motoru', + 'template_none' => 'Yok', + 'no_components' => 'İsteğe bağlı bileşen seçilmedi.', + 'installing' => 'Yükleniyor:', + 'running' => 'Çalıştırılıyor:', + 'error_install' => 'Yükleme başarısız. Manuel olarak deneyin: composer require %s', + 'done' => 'İsteğe bağlı bileşenler yüklendi.', + 'summary_locale' => 'Dil: %s', + 'summary_timezone' => 'Saat dilimi: %s', + ], + 'id' => [ + 'skip' => 'Mode non-interaktif, melewati wizard instalasi.', + 'default_choice' => ' (default %s)', + 'timezone_prompt' => 'Zona waktu (default=%s, ketik untuk melengkapi): ', + 'timezone_title' => 'Zona waktu (default=%s)', + 'timezone_help' => 'Ketik kata kunci, Tab untuk melengkapi, gunakan ↑↓ untuk memilih:', + 'timezone_region' => 'Pilih wilayah zona waktu', + 'timezone_city' => 'Pilih zona waktu', + 'timezone_invalid' => 'Zona waktu tidak valid, menggunakan default %s', + 'timezone_input_prompt' => 'Masukkan zona waktu atau kata kunci:', + 'timezone_pick_prompt' => 'Masukkan nomor atau kata kunci:', + 'timezone_no_match' => 'Zona waktu tidak ditemukan, silakan coba lagi.', + 'timezone_invalid_index' => 'Nomor tidak valid, silakan coba lagi.', + 'yes' => 'ya', + 'no' => 'tidak', + 'adding_package' => '- Menambahkan paket %s', + 'console_question' => 'Instal komponen konsol webman/console', + 'db_question' => 'Komponen database', + 'db_none' => 'Tidak ada', + 'db_invalid' => 'Silakan masukkan opsi yang valid', + 'redis_question' => 'Instal komponen Redis webman/redis', + 'events_note' => ' (Redis memerlukan illuminate/events, otomatis disertakan)', + 'validation_question' => 'Instal komponen validasi webman/validation', + 'template_question' => 'Mesin template', + 'template_none' => 'Tidak ada', + 'no_components' => 'Tidak ada komponen opsional yang dipilih.', + 'installing' => 'Menginstal:', + 'running' => 'Menjalankan:', + 'error_install' => 'Instalasi gagal. Coba manual: composer require %s', + 'done' => 'Komponen opsional terinstal.', + 'summary_locale' => 'Bahasa: %s', + 'summary_timezone' => 'Zona waktu: %s', + ], + 'th' => [ + 'skip' => 'โหมดไม่โต้ตอบ ข้ามตัวช่วยติดตั้ง', + 'default_choice' => ' (ค่าเริ่มต้น %s)', + 'timezone_prompt' => 'เขตเวลา (ค่าเริ่มต้น=%s พิมพ์เพื่อเติมอัตโนมัติ): ', + 'timezone_title' => 'เขตเวลา (ค่าเริ่มต้น=%s)', + 'timezone_help' => 'พิมพ์คีย์เวิร์ดแล้วกด Tab เพื่อเติมอัตโนมัติ ใช้ ↑↓ เพื่อเลือก:', + 'timezone_region' => 'เลือกภูมิภาคเขตเวลา', + 'timezone_city' => 'เลือกเขตเวลา', + 'timezone_invalid' => 'เขตเวลาไม่ถูกต้อง ใช้ค่าเริ่มต้น %s', + 'timezone_input_prompt' => 'ป้อนเขตเวลาหรือคำค้น:', + 'timezone_pick_prompt' => 'ป้อนหมายเลขหรือคำค้น:', + 'timezone_no_match' => 'ไม่พบเขตเวลาที่ตรงกัน กรุณาลองอีกครั้ง', + 'timezone_invalid_index' => 'หมายเลขไม่ถูกต้อง กรุณาลองอีกครั้ง', + 'yes' => 'ใช่', + 'no' => 'ไม่', + 'adding_package' => '- เพิ่มแพ็กเกจ %s', + 'console_question' => 'ติดตั้งคอมโพเนนต์คอนโซล webman/console', + 'db_question' => 'คอมโพเนนต์ฐานข้อมูล', + 'db_none' => 'ไม่ติดตั้ง', + 'db_invalid' => 'กรุณาป้อนตัวเลือกที่ถูกต้อง', + 'redis_question' => 'ติดตั้งคอมโพเนนต์ Redis webman/redis', + 'events_note' => ' (Redis ต้องการ illuminate/events รวมไว้โดยอัตโนมัติ)', + 'validation_question' => 'ติดตั้งคอมโพเนนต์ตรวจสอบ webman/validation', + 'template_question' => 'เทมเพลตเอนจิน', + 'template_none' => 'ไม่ติดตั้ง', + 'no_components' => 'ไม่ได้เลือกคอมโพเนนต์เสริม', + 'installing' => 'กำลังติดตั้ง:', + 'running' => 'กำลังดำเนินการ:', + 'error_install' => 'ติดตั้งล้มเหลว ลองด้วยตนเอง: composer require %s', + 'done' => 'คอมโพเนนต์เสริมติดตั้งเรียบร้อยแล้ว', + 'summary_locale' => 'ภาษา: %s', + 'summary_timezone' => 'เขตเวลา: %s', + ], + ]; + + // --- Interrupt message (Ctrl+C) --- + + private const INTERRUPTED_MESSAGES = [ + 'zh_CN' => '安装中断,可运行 composer setup-webman 可重新设置。', + 'zh_TW' => '安裝中斷,可運行 composer setup-webman 重新設置。', + 'en' => 'Setup interrupted. Run "composer setup-webman" to restart setup.', + 'ja' => 'セットアップが中断されました。composer setup-webman を実行して再設定できます。', + 'ko' => '설치가 중단되었습니다. composer setup-webman 을 실행하여 다시 설정할 수 있습니다.', + 'fr' => 'Installation interrompue. Exécutez « composer setup-webman » pour recommencer.', + 'de' => 'Einrichtung abgebrochen. Führen Sie "composer setup-webman" aus, um neu zu starten.', + 'es' => 'Instalación interrumpida. Ejecute "composer setup-webman" para reiniciar.', + 'pt_BR' => 'Instalação interrompida. Execute "composer setup-webman" para reiniciar.', + 'ru' => 'Установка прервана. Выполните «composer setup-webman» для повторной настройки.', + 'vi' => 'Cài đặt bị gián đoạn. Chạy "composer setup-webman" để cài đặt lại.', + 'tr' => 'Kurulum kesildi. Yeniden kurmak için "composer setup-webman" komutunu çalıştırın.', + 'id' => 'Instalasi terganggu. Jalankan "composer setup-webman" untuk mengatur ulang.', + 'th' => 'การติดตั้งถูกขัดจังหวะ เรียกใช้ "composer setup-webman" เพื่อตั้งค่าใหม่', + ]; + + // --- Signal handling state --- + + /** @var string|null Saved stty mode for terminal restoration on interrupt */ + private static ?string $sttyMode = null; + + /** @var string Current locale for interrupt message */ + private static string $interruptLocale = 'en'; + + // ═══════════════════════════════════════════════════════════════ + // Entry + // ═══════════════════════════════════════════════════════════════ + + public static function run(Event $event): void + { + $io = $event->getIO(); + + // Non-interactive mode: use English for skip message + if (!$io->isInteractive()) { + $io->write('' . self::MESSAGES['en']['skip'] . ''); + return; + } + + try { + self::doRun($event, $io); + } catch (\Throwable $e) { + $io->writeError(''); + $io->writeError('Setup wizard error: ' . $e->getMessage() . ''); + $io->writeError('Run "composer setup-webman" to retry.'); + } + } + + private static function doRun(Event $event, IOInterface $io): void + { + $io->write(''); + + // Register Ctrl+C handler + self::registerInterruptHandler(); + + // Banner title (must be before locale selection) + self::renderTitle(); + + // 1. Locale selection + $locale = self::askLocale($io); + self::$interruptLocale = $locale; + $defaultTimezone = self::LOCALE_DEFAULT_TIMEZONES[$locale] ?? 'UTC'; + $msg = fn(string $key, string ...$args): string => + empty($args) ? self::MESSAGES[$locale][$key] : sprintf(self::MESSAGES[$locale][$key], ...$args); + + // Write locale config (update when not default) + if ($locale !== 'zh_CN') { + self::updateConfig($event, 'config/translation.php', "'locale'", $locale); + } + + $io->write(''); + $io->write(''); + + // 2. Timezone selection (default by locale) + $timezone = self::askTimezone($io, $msg, $defaultTimezone); + if ($timezone !== 'Asia/Shanghai') { + self::updateConfig($event, 'config/app.php', "'default_timezone'", $timezone); + } + + // 3. Optional components + $packages = self::askComponents($io, $msg); + + // 4. Remove unselected components + $removePackages = self::askRemoveComponents($event, $packages, $io, $msg); + + // 5. Summary + $io->write(''); + $io->write('─────────────────────────────────────'); + $io->write('' . $msg('summary_locale', self::LOCALE_LABELS[$locale]) . ''); + $io->write('' . $msg('summary_timezone', $timezone) . ''); + + // Remove unselected packages first to avoid dependency conflicts + if ($removePackages !== []) { + $io->write(''); + $io->write('' . $msg('removing') . ''); + + $secondaryPackages = [ + self::PACKAGE_ILLUMINATE_EVENTS, + self::PACKAGE_ILLUMINATE_PAGINATION, + self::PACKAGE_SYMFONY_VAR_DUMPER, + ]; + $displayRemovePackages = array_diff($removePackages, $secondaryPackages); + foreach ($displayRemovePackages as $pkg) { + $io->write(' - ' . $pkg); + } + $io->write(''); + self::runComposerRemove($removePackages, $io, $msg); + } + + // Then install selected packages + if ($packages !== []) { + $io->write(''); + $io->write('' . $msg('installing') . ' ' . implode(', ', $packages)); + $io->write(''); + self::runComposerRequire($packages, $io, $msg); + } elseif ($removePackages === []) { + $io->write('' . $msg('no_components') . ''); + } + } + + private static function renderTitle(): void + { + $output = new ConsoleOutput(); + $terminalWidth = (new Terminal())->getWidth(); + if ($terminalWidth <= 0) { + $terminalWidth = 80; + } + + $text = ' ' . self::SETUP_TITLE . ' '; + $minBoxWidth = 44; + $maxBoxWidth = min($terminalWidth, 96); + $boxWidth = min($maxBoxWidth, max($minBoxWidth, mb_strwidth($text) + 10)); + + $innerWidth = $boxWidth - 2; + $textWidth = mb_strwidth($text); + $pad = max(0, $innerWidth - $textWidth); + $left = intdiv($pad, 2); + $right = $pad - $left; + $line2 = '│' . str_repeat(' ', $left) . $text . str_repeat(' ', $right) . '│'; + $line1 = '┌' . str_repeat('─', $innerWidth) . '┐'; + $line3 = '└' . str_repeat('─', $innerWidth) . '┘'; + + $output->writeln(''); + $output->writeln('' . $line1 . ''); + $output->writeln('' . $line2 . ''); + $output->writeln('' . $line3 . ''); + $output->writeln(''); + } + + // ═══════════════════════════════════════════════════════════════ + // Signal handling (Ctrl+C) + // ═══════════════════════════════════════════════════════════════ + + /** + * Register Ctrl+C (SIGINT) handler to show a friendly message on interrupt. + * Gracefully skipped when the required extensions are unavailable. + */ + private static function registerInterruptHandler(): void + { + // Unix/Linux/Mac: pcntl extension with async signals for immediate delivery + /*if (function_exists('pcntl_async_signals') && function_exists('pcntl_signal')) { + pcntl_async_signals(true); + pcntl_signal(\SIGINT, [self::class, 'handleInterrupt']); + return; + }*/ + + // Windows: sapi ctrl handler (PHP >= 7.4) + if (function_exists('sapi_windows_set_ctrl_handler')) { + sapi_windows_set_ctrl_handler(static function (int $event) { + if ($event === \PHP_WINDOWS_EVENT_CTRL_C) { + self::handleInterrupt(); + } + }); + } + } + + /** + * Handle Ctrl+C: restore terminal, show tip, then exit. + */ + private static function handleInterrupt(): void + { + // Restore terminal if in raw mode + if (self::$sttyMode !== null && function_exists('shell_exec')) { + @shell_exec('stty ' . self::$sttyMode); + self::$sttyMode = null; + } + + $output = new ConsoleOutput(); + $output->writeln(''); + $output->writeln('' . (self::INTERRUPTED_MESSAGES[self::$interruptLocale] ?? self::INTERRUPTED_MESSAGES['en']) . ''); + exit(1); + } + + // ═══════════════════════════════════════════════════════════════ + // Interactive Menu System + // ═══════════════════════════════════════════════════════════════ + + /** + * Check if terminal supports interactive features (arrow keys, ANSI colors). + */ + private static function supportsInteractive(): bool + { + return function_exists('shell_exec') && Terminal::hasSttyAvailable(); + } + + /** + * Display a selection menu with arrow key navigation (if supported) or text input fallback. + * + * @param IOInterface $io Composer IO + * @param string $title Menu title + * @param array $items Indexed array of ['tag' => string, 'label' => string] + * @param int $default Default selected index (0-based) + * @return int Selected index + */ + private static function selectMenu(IOInterface $io, string $title, array $items, int $default = 0): int + { + // Append localized "default" hint to avoid ambiguity + // (Template should contain a single %s placeholder for the default tag.) + $defaultHintTemplate = null; + if (isset(self::MESSAGES[self::$interruptLocale]['default_choice'])) { + $defaultHintTemplate = self::MESSAGES[self::$interruptLocale]['default_choice']; + } + + $defaultTag = $items[$default]['tag'] ?? ''; + if ($defaultHintTemplate && $defaultTag !== '') { + $title .= sprintf($defaultHintTemplate, $defaultTag); + } elseif ($defaultTag !== '') { + // Fallback for early menus (e.g. locale selection) before locale is chosen. + $title .= sprintf(' (default %s)', $defaultTag); + } + + if (self::supportsInteractive()) { + return self::arrowKeySelect($title, $items, $default); + } + + return self::fallbackSelect($io, $title, $items, $default); + } + + /** + * Display a yes/no confirmation as a selection menu. + * + * @param IOInterface $io Composer IO + * @param string $title Menu title + * @param bool $default Default value (true = yes) + * @return bool User's choice + */ + private static function confirmMenu(IOInterface $io, string $title, bool $default = true): bool + { + $locale = self::$interruptLocale; + $yes = self::MESSAGES[$locale]['yes'] ?? self::MESSAGES['en']['yes'] ?? 'yes'; + $no = self::MESSAGES[$locale]['no'] ?? self::MESSAGES['en']['no'] ?? 'no'; + $items = $default + ? [['tag' => 'Y', 'label' => $yes], ['tag' => 'n', 'label' => $no]] + : [['tag' => 'y', 'label' => $yes], ['tag' => 'N', 'label' => $no]]; + $defaultIndex = $default ? 0 : 1; + + return self::selectMenu($io, $title, $items, $defaultIndex) === 0; + } + + /** + * Interactive select with arrow key navigation, manual input and ANSI reverse-video highlighting. + * Input area and option list highlighting are bidirectionally linked. + * Requires stty (Unix-like terminals). + */ + private static function arrowKeySelect(string $title, array $items, int $default): int + { + $output = new ConsoleOutput(); + $count = count($items); + $selected = $default; + + $maxTagWidth = max(array_map(fn(array $item) => mb_strlen($item['tag']), $items)); + $defaultTag = $items[$default]['tag']; + $input = $defaultTag; + + // Print title and initial options + $output->writeln(''); + $output->writeln('' . $title . ''); + self::drawMenuItems($output, $items, $selected, $maxTagWidth); + $output->write('> ' . $input); + + // Enter raw mode + self::$sttyMode = shell_exec('stty -g'); + shell_exec('stty -icanon -echo'); + + try { + while (!feof(STDIN)) { + $c = fread(STDIN, 1); + + if (false === $c || '' === $c) { + break; + } + + // ── Backspace ── + if ("\177" === $c || "\010" === $c) { + if ('' !== $input) { + $input = mb_substr($input, 0, -1); + } + $selected = self::findItemByTag($items, $input); + $output->write("\033[{$count}A"); + self::drawMenuItems($output, $items, $selected, $maxTagWidth); + $output->write("\033[2K\r> " . $input); + continue; + } + + // ── Escape sequences (arrow keys) ── + if ("\033" === $c) { + $seq = fread(STDIN, 2); + if (isset($seq[1])) { + $changed = false; + if ('A' === $seq[1]) { // Up + $selected = ($selected <= 0 ? $count : $selected) - 1; + $changed = true; + } elseif ('B' === $seq[1]) { // Down + $selected = ($selected + 1) % $count; + $changed = true; + } + if ($changed) { + // Sync input with selected item's tag + $input = $items[$selected]['tag']; + $output->write("\033[{$count}A"); + self::drawMenuItems($output, $items, $selected, $maxTagWidth); + $output->write("\033[2K\r> " . $input); + } + } + continue; + } + + // ── Enter: confirm selection ── + if ("\n" === $c || "\r" === $c) { + if ($selected < 0) { + $selected = $default; + } + $output->write("\033[2K\r> " . $items[$selected]['tag'] . ' ' . $items[$selected]['label'] . ''); + $output->writeln(''); + break; + } + + // ── Ignore other control characters ── + if (ord($c) < 32) { + continue; + } + + // ── Printable character (with UTF-8 multi-byte support) ── + if ("\x80" <= $c) { + $extra = ["\xC0" => 1, "\xD0" => 1, "\xE0" => 2, "\xF0" => 3]; + $c .= fread(STDIN, $extra[$c & "\xF0"] ?? 0); + } + $input .= $c; + $selected = self::findItemByTag($items, $input); + $output->write("\033[{$count}A"); + self::drawMenuItems($output, $items, $selected, $maxTagWidth); + $output->write("\033[2K\r> " . $input); + } + } finally { + if (self::$sttyMode !== null) { + shell_exec('stty ' . self::$sttyMode); + self::$sttyMode = null; + } + } + + return $selected < 0 ? $default : $selected; + } + + /** + * Fallback select for terminals without stty support. Uses plain text input. + */ + private static function fallbackSelect(IOInterface $io, string $title, array $items, int $default): int + { + $maxTagWidth = max(array_map(fn(array $item) => mb_strlen($item['tag']), $items)); + $defaultTag = $items[$default]['tag']; + + $io->write(''); + $io->write('' . $title . ''); + foreach ($items as $item) { + $tag = str_pad($item['tag'], $maxTagWidth); + $io->write(" [$tag] " . $item['label']); + } + + while (true) { + $io->write('> ', false); + $line = fgets(STDIN); + if ($line === false) { + return $default; + } + $answer = trim($line); + + if ($answer === '') { + $io->write('> ' . $items[$default]['tag'] . ' ' . $items[$default]['label'] . ''); + return $default; + } + + // Match by tag (case-insensitive) + foreach ($items as $i => $item) { + if (strcasecmp($item['tag'], $answer) === 0) { + $io->write('> ' . $items[$i]['tag'] . ' ' . $items[$i]['label'] . ''); + return $i; + } + } + } + } + + /** + * Render menu items with optional ANSI reverse-video highlighting for the selected item. + * When $selected is -1, no item is highlighted. + */ + private static function drawMenuItems(ConsoleOutput $output, array $items, int $selected, int $maxTagWidth): void + { + foreach ($items as $i => $item) { + $tag = str_pad($item['tag'], $maxTagWidth); + $line = " [$tag] " . $item['label']; + if ($i === $selected) { + $output->writeln("\033[2K\r\033[7m" . $line . "\033[0m"); + } else { + $output->writeln("\033[2K\r" . $line); + } + } + } + + /** + * Find item index by tag (case-insensitive exact match). + * Returns -1 if no match found or input is empty. + */ + private static function findItemByTag(array $items, string $input): int + { + if ($input === '') { + return -1; + } + foreach ($items as $i => $item) { + if (strcasecmp($item['tag'], $input) === 0) { + return $i; + } + } + return -1; + } + + // ═══════════════════════════════════════════════════════════════ + // Locale selection + // ═══════════════════════════════════════════════════════════════ + + private static function askLocale(IOInterface $io): string + { + $locales = array_keys(self::LOCALE_LABELS); + $items = []; + foreach ($locales as $i => $code) { + $items[] = ['tag' => (string) $i, 'label' => self::LOCALE_LABELS[$code] . " ($code)"]; + } + + $selected = self::selectMenu( + $io, + '语言 / Language / 言語 / 언어', + $items, + 0 + ); + + return $locales[$selected]; + } + + // ═══════════════════════════════════════════════════════════════ + // Timezone selection + // ═══════════════════════════════════════════════════════════════ + + private static function askTimezone(IOInterface $io, callable $msg, string $default): string + { + if (self::supportsInteractive()) { + return self::askTimezoneAutocomplete($msg, $default); + } + + return self::askTimezoneSelect($io, $msg, $default); + } + + /** + * Option A: when stty is available, custom character-by-character autocomplete + * (case-insensitive, substring match). Interaction: type to filter, hint on right; + * ↑↓ change candidate, Tab accept, Enter confirm; empty input = use default. + */ + private static function askTimezoneAutocomplete(callable $msg, string $default): string + { + $allTimezones = \DateTimeZone::listIdentifiers(); + $output = new ConsoleOutput(); + $cursor = new Cursor($output); + + $output->writeln(''); + $output->writeln('' . $msg('timezone_title', $default) . ''); + $output->writeln($msg('timezone_help')); + $output->write('> '); + + self::$sttyMode = shell_exec('stty -g'); + shell_exec('stty -icanon -echo'); + + // Auto-fill default timezone in the input area; user can edit it directly. + $input = $default; + $output->write($input); + + $ofs = 0; + $matches = self::filterTimezones($allTimezones, $input); + if (!empty($matches)) { + $hint = $matches[$ofs % count($matches)]; + // Avoid duplicating hint when input already fully matches the only candidate. + if (!(count($matches) === 1 && $hint === $input)) { + $cursor->clearLineAfter(); + $cursor->savePosition(); + $output->write(' ' . $hint . ''); + if (count($matches) > 1) { + $output->write(' (' . count($matches) . ' matches, ↑↓)'); + } + $cursor->restorePosition(); + } + } + + try { + while (!feof(STDIN)) { + $c = fread(STDIN, 1); + + if (false === $c || '' === $c) { + break; + } + + // ── Backspace ── + if ("\177" === $c || "\010" === $c) { + if ('' !== $input) { + $lastChar = mb_substr($input, -1); + $input = mb_substr($input, 0, -1); + $cursor->moveLeft(max(1, mb_strwidth($lastChar))); + } + $ofs = 0; + + // ── Escape sequences (arrows) ── + } elseif ("\033" === $c) { + $seq = fread(STDIN, 2); + if (isset($seq[1]) && !empty($matches)) { + if ('A' === $seq[1]) { + $ofs = ($ofs - 1 + count($matches)) % count($matches); + } elseif ('B' === $seq[1]) { + $ofs = ($ofs + 1) % count($matches); + } + } + + // ── Tab: accept current match ── + } elseif ("\t" === $c) { + if (isset($matches[$ofs])) { + self::replaceInput($output, $cursor, $input, $matches[$ofs]); + $input = $matches[$ofs]; + $matches = []; + } + $cursor->clearLineAfter(); + continue; + + // ── Enter: confirm ── + } elseif ("\n" === $c || "\r" === $c) { + if (isset($matches[$ofs])) { + self::replaceInput($output, $cursor, $input, $matches[$ofs]); + $input = $matches[$ofs]; + } + if ($input === '') { + $input = $default; + } + // Re-render user input with style + $cursor->moveToColumn(1); + $cursor->clearLine(); + $output->write('> ' . $input . ''); + $output->writeln(''); + break; + + // ── Other control chars: ignore ── + } elseif (ord($c) < 32) { + continue; + + // ── Printable character ── + } else { + if ("\x80" <= $c) { + $extra = ["\xC0" => 1, "\xD0" => 1, "\xE0" => 2, "\xF0" => 3]; + $c .= fread(STDIN, $extra[$c & "\xF0"] ?? 0); + } + $output->write($c); + $input .= $c; + $ofs = 0; + } + + // Update match list + $matches = self::filterTimezones($allTimezones, $input); + + // Show autocomplete hint + $cursor->clearLineAfter(); + if (!empty($matches)) { + $hint = $matches[$ofs % count($matches)]; + $cursor->savePosition(); + $output->write(' ' . $hint . ''); + if (count($matches) > 1) { + $output->write(' (' . count($matches) . ' matches, ↑↓)'); + } + $cursor->restorePosition(); + } + } + } finally { + if (self::$sttyMode !== null) { + shell_exec('stty ' . self::$sttyMode); + self::$sttyMode = null; + } + } + + $result = '' === $input ? $default : $input; + + if (!in_array($result, $allTimezones, true)) { + $output->writeln('' . $msg('timezone_invalid', $default) . ''); + return $default; + } + + return $result; + } + + /** + * Clear current input and replace with new text. + */ + private static function replaceInput(ConsoleOutput $output, Cursor $cursor, string $oldInput, string $newInput): void + { + if ('' !== $oldInput) { + $cursor->moveLeft(mb_strwidth($oldInput)); + } + $cursor->clearLineAfter(); + $output->write($newInput); + } + + /** + * Case-insensitive substring match for timezones. + */ + private static function filterTimezones(array $timezones, string $input): array + { + if ('' === $input) { + return []; + } + $lower = mb_strtolower($input); + return array_values(array_filter( + $timezones, + fn(string $tz) => str_contains(mb_strtolower($tz), $lower) + )); + } + + /** + * Find an exact timezone match (case-insensitive). + * Returns the correctly-cased system timezone name, or null if not found. + */ + private static function findExactTimezone(array $allTimezones, string $input): ?string + { + $lower = mb_strtolower($input); + foreach ($allTimezones as $tz) { + if (mb_strtolower($tz) === $lower) { + return $tz; + } + } + return null; + } + + /** + * Search timezones by keyword (substring) and similarity. + * Returns combined results: substring matches first, then similarity matches (>=50%). + * + * @param string[] $allTimezones All valid timezone identifiers + * @param string $keyword User input to search for + * @param int $limit Maximum number of results + * @return string[] Matched timezone identifiers + */ + private static function searchTimezones(array $allTimezones, string $keyword, int $limit = 15): array + { + // 1. Substring matches (higher priority) + $substringMatches = self::filterTimezones($allTimezones, $keyword); + if (count($substringMatches) >= $limit) { + return array_slice($substringMatches, 0, $limit); + } + + // 2. Similarity matches for remaining slots (normalized: strip _ and /) + $substringSet = array_flip($substringMatches); + $normalizedKeyword = str_replace(['_', '/'], ' ', mb_strtolower($keyword)); + $similarityMatches = []; + + foreach ($allTimezones as $tz) { + if (isset($substringSet[$tz])) { + continue; + } + $parts = explode('/', $tz); + $city = str_replace('_', ' ', mb_strtolower(end($parts))); + $normalizedTz = str_replace(['_', '/'], ' ', mb_strtolower($tz)); + + similar_text($normalizedKeyword, $city, $cityPercent); + similar_text($normalizedKeyword, $normalizedTz, $fullPercent); + + $bestPercent = max($cityPercent, $fullPercent); + if ($bestPercent >= 50.0) { + $similarityMatches[] = ['tz' => $tz, 'score' => $bestPercent]; + } + } + + usort($similarityMatches, fn(array $a, array $b) => $b['score'] <=> $a['score']); + + $results = $substringMatches; + foreach ($similarityMatches as $item) { + $results[] = $item['tz']; + if (count($results) >= $limit) { + break; + } + } + + return $results; + } + + /** + * Option B: when stty is not available (e.g. Windows), keyword search with numbered list. + * Flow: enter timezone/keyword → exact match uses it directly; otherwise show + * numbered results (substring + similarity) → pick by number or refine keyword. + */ + private static function askTimezoneSelect(IOInterface $io, callable $msg, string $default): string + { + $allTimezones = \DateTimeZone::listIdentifiers(); + + $io->write(''); + $io->write('' . $msg('timezone_title', $default) . ''); + $io->write($msg('timezone_input_prompt')); + + /** @var string[]|null Currently displayed search result list */ + $currentList = null; + + while (true) { + $io->write('> ', false); + $line = fgets(STDIN); + if ($line === false) { + return $default; + } + $answer = trim($line); + + // Empty input → use default + if ($answer === '') { + $io->write('> ' . $default . ''); + return $default; + } + + // If a numbered list is displayed and input is a pure number + if ($currentList !== null && ctype_digit($answer)) { + $idx = (int) $answer; + if (isset($currentList[$idx])) { + $io->write('> ' . $currentList[$idx] . ''); + return $currentList[$idx]; + } + $io->write('' . $msg('timezone_invalid_index') . ''); + continue; + } + + // Exact case-insensitive match → return the correctly-cased system value + $exact = self::findExactTimezone($allTimezones, $answer); + if ($exact !== null) { + $io->write('> ' . $exact . ''); + return $exact; + } + + // Keyword + similarity search + $results = self::searchTimezones($allTimezones, $answer); + + if (empty($results)) { + $io->write('' . $msg('timezone_no_match') . ''); + $currentList = null; + continue; + } + + // Single result → use it directly + if (count($results) === 1) { + $io->write('> ' . $results[0] . ''); + return $results[0]; + } + + // Display numbered list + $currentList = $results; + $padWidth = strlen((string) (count($results) - 1)); + foreach ($results as $i => $tz) { + $io->write(' [' . str_pad((string) $i, $padWidth) . '] ' . $tz); + } + $io->write($msg('timezone_pick_prompt')); + } + } + + // ═══════════════════════════════════════════════════════════════ + // Optional component selection + // ═══════════════════════════════════════════════════════════════ + + private static function askComponents(IOInterface $io, callable $msg): array + { + $packages = []; + $addPackage = static function (string $package) use (&$packages, $io, $msg): void { + if (in_array($package, $packages, true)) { + return; + } + $packages[] = $package; + $io->write($msg('adding_package', '' . $package . '')); + }; + + // Console (default: yes) + if (self::confirmMenu($io, $msg('console_question'), true)) { + $addPackage(self::PACKAGE_CONSOLE); + } + + // Database + $dbItems = [ + ['tag' => '0', 'label' => $msg('db_none')], + ['tag' => '1', 'label' => 'webman/database'], + ['tag' => '2', 'label' => 'webman/think-orm'], + ['tag' => '3', 'label' => 'webman/database && webman/think-orm'], + ]; + $dbChoice = self::selectMenu($io, $msg('db_question'), $dbItems, 0); + if ($dbChoice === 1) { + $addPackage(self::PACKAGE_DATABASE); + } elseif ($dbChoice === 2) { + $addPackage(self::PACKAGE_THINK_ORM); + } elseif ($dbChoice === 3) { + $addPackage(self::PACKAGE_DATABASE); + $addPackage(self::PACKAGE_THINK_ORM); + } + + // If webman/database is selected, add required dependencies automatically + if (in_array(self::PACKAGE_DATABASE, $packages, true)) { + $addPackage(self::PACKAGE_ILLUMINATE_PAGINATION); + $addPackage(self::PACKAGE_ILLUMINATE_EVENTS); + $addPackage(self::PACKAGE_SYMFONY_VAR_DUMPER); + } + + // Redis (default: no) + if (self::confirmMenu($io, $msg('redis_question'), false)) { + $addPackage(self::PACKAGE_REDIS); + $addPackage(self::PACKAGE_ILLUMINATE_EVENTS); + } + + // Validation (default: no) + if (self::confirmMenu($io, $msg('validation_question'), false)) { + $addPackage(self::PACKAGE_VALIDATION); + } + + // Template engine + $tplItems = [ + ['tag' => '0', 'label' => $msg('template_none')], + ['tag' => '1', 'label' => 'webman/blade'], + ['tag' => '2', 'label' => 'twig/twig'], + ['tag' => '3', 'label' => 'topthink/think-template'], + ]; + $tplChoice = self::selectMenu($io, $msg('template_question'), $tplItems, 0); + if ($tplChoice === 1) { + $addPackage(self::PACKAGE_BLADE); + } elseif ($tplChoice === 2) { + $addPackage(self::PACKAGE_TWIG); + } elseif ($tplChoice === 3) { + $addPackage(self::PACKAGE_THINK_TEMPLATE); + } + + return $packages; + } + + // ═══════════════════════════════════════════════════════════════ + // Config file update + // ═══════════════════════════════════════════════════════════════ + + /** + * Update a config value like 'key' => 'old_value' in the given file. + */ + private static function updateConfig(Event $event, string $relativePath, string $key, string $newValue): void + { + $root = dirname($event->getComposer()->getConfig()->get('vendor-dir')); + $file = $root . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relativePath); + if (!is_readable($file)) { + return; + } + $content = file_get_contents($file); + if ($content === false) { + return; + } + $pattern = '/' . preg_quote($key, '/') . "\s*=>\s*'[^']*'/"; + $replacement = $key . " => '" . $newValue . "'"; + $newContent = preg_replace($pattern, $replacement, $content); + if ($newContent !== null && $newContent !== $content) { + file_put_contents($file, $newContent); + } + } + + // ═══════════════════════════════════════════════════════════════ + // Composer require + // ═══════════════════════════════════════════════════════════════ + + private static function runComposerRequire(array $packages, IOInterface $io, callable $msg): void + { + $io->write('' . $msg('running') . ' composer require ' . implode(' ', $packages)); + $io->write(''); + + $code = self::runComposerCommand('require', $packages); + + if ($code !== 0) { + $io->writeError('' . $msg('error_install', implode(' ', $packages)) . ''); + } else { + $io->write('' . $msg('done') . ''); + } + } + + private static function askRemoveComponents(Event $event, array $selectedPackages, IOInterface $io, callable $msg): array + { + $requires = $event->getComposer()->getPackage()->getRequires(); + $allOptionalPackages = [ + self::PACKAGE_CONSOLE, + self::PACKAGE_DATABASE, + self::PACKAGE_THINK_ORM, + self::PACKAGE_REDIS, + self::PACKAGE_ILLUMINATE_EVENTS, + self::PACKAGE_ILLUMINATE_PAGINATION, + self::PACKAGE_SYMFONY_VAR_DUMPER, + self::PACKAGE_VALIDATION, + self::PACKAGE_BLADE, + self::PACKAGE_TWIG, + self::PACKAGE_THINK_TEMPLATE, + ]; + + $secondaryPackages = [ + self::PACKAGE_ILLUMINATE_EVENTS, + self::PACKAGE_ILLUMINATE_PAGINATION, + self::PACKAGE_SYMFONY_VAR_DUMPER, + ]; + + $installedOptionalPackages = []; + foreach ($allOptionalPackages as $pkg) { + if (isset($requires[$pkg])) { + $installedOptionalPackages[] = $pkg; + } + } + + $allPackagesToRemove = array_diff($installedOptionalPackages, $selectedPackages); + + if (count($allPackagesToRemove) === 0) { + return []; + } + + $displayPackagesToRemove = array_diff($allPackagesToRemove, $secondaryPackages); + + if (count($displayPackagesToRemove) === 0) { + return $allPackagesToRemove; + } + + $pkgListStr = ""; + foreach ($displayPackagesToRemove as $pkg) { + $pkgListStr .= "\n - {$pkg}"; + } + $pkgListStr .= "\n"; + + $title = '' . $msg('remove_package_question', '') . '' . $pkgListStr; + if (self::confirmMenu($io, $title, false)) { + return $allPackagesToRemove; + } + + return []; + } + + private static function runComposerRemove(array $packages, IOInterface $io, callable $msg): void + { + $io->write('' . $msg('running') . ' composer remove ' . implode(' ', $packages)); + $io->write(''); + + $code = self::runComposerCommand('remove', $packages); + + if ($code !== 0) { + $io->writeError('' . $msg('error_remove', implode(' ', $packages)) . ''); + } else { + $io->write('' . $msg('done_remove') . ''); + } + } + + /** + * Run a Composer command (require/remove) in-process via Composer's Application API. + * No shell execution functions needed — works even when passthru/exec/shell_exec are disabled. + */ + private static function runComposerCommand(string $command, array $packages): int + { + try { + // Already inside a user-initiated Composer session — suppress duplicate root/superuser warnings + $_SERVER['COMPOSER_ALLOW_SUPERUSER'] = '1'; + if (function_exists('putenv')) { + putenv('COMPOSER_ALLOW_SUPERUSER=1'); + } + + $application = new ComposerApplication(); + $application->setAutoExit(false); + + return $application->run( + new ArrayInput([ + 'command' => $command, + 'packages' => $packages, + '--no-interaction' => true, + '--update-with-all-dependencies' => true, + ]), + new ConsoleOutput() + ); + } catch (\Throwable) { + return 1; + } + } +} diff --git a/server-api/support/bootstrap.php b/server-api/support/bootstrap.php new file mode 100644 index 0000000..9495666 --- /dev/null +++ b/server-api/support/bootstrap.php @@ -0,0 +1,3 @@ +safeLoad(); + +$dsn = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=%s', + $_ENV['DB_HOST'] ?? '127.0.0.1', + $_ENV['DB_PORT'] ?? '3306', + $_ENV['DB_DATABASE'] ?? '', + $_ENV['DB_CHARSET'] ?? 'utf8mb4' +); + +$pdo = new PDO( + $dsn, + $_ENV['DB_USERNAME'] ?? '', + $_ENV['DB_PASSWORD'] ?? '', + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] +); + +foreach (['message_templates', 'message_rules', 'message_logs', 'user_messages', 'tickets', 'ticket_messages'] as $table) { + $count = $pdo->query("SELECT COUNT(*) AS c FROM {$table}")->fetchColumn(); + echo $table, ':', $count, PHP_EOL; +} diff --git a/server-api/tools/db_import.php b/server-api/tools/db_import.php new file mode 100644 index 0000000..9765d69 --- /dev/null +++ b/server-api/tools/db_import.php @@ -0,0 +1,56 @@ +safeLoad(); + +$schemaFile = dirname(__DIR__) . '/database/schema.sql'; +if (!is_file($schemaFile)) { + fwrite(STDERR, "Schema file not found.\n"); + exit(1); +} + +$sql = file_get_contents($schemaFile); +if ($sql === false) { + fwrite(STDERR, "Unable to read schema file.\n"); + exit(1); +} + +$dsn = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=%s', + $_ENV['DB_HOST'] ?? '127.0.0.1', + $_ENV['DB_PORT'] ?? '3306', + $_ENV['DB_DATABASE'] ?? '', + $_ENV['DB_CHARSET'] ?? 'utf8mb4' +); + +$pdo = new PDO( + $dsn, + $_ENV['DB_USERNAME'] ?? '', + $_ENV['DB_PASSWORD'] ?? '', + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] +); + +$statements = preg_split('/;\s*[\r\n]+/', $sql); +$count = 0; + +foreach ($statements as $statement) { + $statement = trim($statement); + if ($statement === '') { + continue; + } + $pdo->exec($statement); + $count++; +} + +$totalTables = (int)$pdo->query('SELECT COUNT(*) AS c FROM information_schema.tables WHERE table_schema = DATABASE()')->fetchColumn(); + +echo "IMPORT_OK\n"; +echo "STATEMENTS={$count}\n"; +echo "TABLES={$totalTables}\n"; diff --git a/server-api/tools/db_inspect.php b/server-api/tools/db_inspect.php new file mode 100644 index 0000000..51b62a1 --- /dev/null +++ b/server-api/tools/db_inspect.php @@ -0,0 +1,37 @@ +safeLoad(); + +$dsn = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=%s', + $_ENV['DB_HOST'] ?? '127.0.0.1', + $_ENV['DB_PORT'] ?? '3306', + $_ENV['DB_DATABASE'] ?? '', + $_ENV['DB_CHARSET'] ?? 'utf8mb4' +); + +$pdo = new PDO( + $dsn, + $_ENV['DB_USERNAME'] ?? '', + $_ENV['DB_PASSWORD'] ?? '', + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_NUM, + ] +); + +$tables = $pdo->query('SHOW TABLES')->fetchAll(); + +if (!$tables) { + echo "NO_TABLES\n"; + exit(0); +} + +foreach ($tables as $table) { + echo $table[0], PHP_EOL; +} diff --git a/server-api/tools/db_seed.php b/server-api/tools/db_seed.php new file mode 100644 index 0000000..6caf4b2 --- /dev/null +++ b/server-api/tools/db_seed.php @@ -0,0 +1,331 @@ +safeLoad(); + +$dsn = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=%s', + $_ENV['DB_HOST'] ?? '127.0.0.1', + $_ENV['DB_PORT'] ?? '3306', + $_ENV['DB_DATABASE'] ?? '', + $_ENV['DB_CHARSET'] ?? 'utf8mb4' +); + +$pdo = new PDO( + $dsn, + $_ENV['DB_USERNAME'] ?? '', + $_ENV['DB_PASSWORD'] ?? '', + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] +); + +$tables = [ + 'shipping_warehouses', + 'order_shipping_targets', + 'material_tag_scan_logs', 'material_batch_download_logs', 'material_tag_codes', 'material_batches', + 'enterprise_webhook_deliveries', 'enterprise_order_events', 'enterprise_customer_order_refs', 'enterprise_api_nonces', 'enterprise_customer_apps', 'enterprise_customers', + 'user_api_tokens', 'sms_code_logs', + 'admin_api_tokens', 'admin_role_permissions', 'admin_permissions', 'admin_role_relations', 'admin_roles', 'operation_logs', 'system_configs', 'admin_users', + 'ticket_messages', 'tickets', + 'user_messages', 'message_logs', 'message_rules', 'message_templates', + 'upload_template_items', 'upload_templates', + 'report_verifies', 'report_contents', 'reports', + 'appraisal_task_key_points', 'appraisal_task_results', 'appraisal_task_reviews', 'appraisal_task_logs', 'appraisal_tasks', + 'order_supplement_task_items', 'order_supplement_tasks', 'order_timelines', 'order_extras', 'order_products', 'orders', + 'catalog_brand_categories', 'catalog_brands', 'catalog_categories', + 'user_addresses', 'user_auths', 'users', +]; + +foreach ($tables as $table) { + $pdo->exec("TRUNCATE TABLE {$table}"); +} + +$now = date('Y-m-d H:i:s'); +$userPasswordHash = password_hash('User@123456', PASSWORD_BCRYPT); +$adminPasswordHash = password_hash('Admin@123456', PASSWORD_BCRYPT); + +$pdo->exec(" +INSERT INTO users (id, nickname, avatar, mobile, password, status, created_at, updated_at) VALUES +(1, '安心验体验用户', '', '13800000000', '{$userPasswordHash}', 'enabled', '{$now}', '{$now}'); + +INSERT INTO user_addresses (id, user_id, consignee, mobile, province, city, district, detail_address, is_default, created_at, updated_at) VALUES +(1, 1, '安心验体验用户', '13800000000', '广东省', '深圳市', '南山区', '科技园测试路 88 号', 1, '{$now}', '{$now}'); + +INSERT INTO shipping_warehouses (id, warehouse_name, warehouse_code, warehouse_type, service_provider, receiver_name, receiver_mobile, province, city, district, detail_address, service_time, notice, supported_category_ids_json, service_area_provinces_json, service_area_cities_json, status, is_default, sort_order, remark, created_at, updated_at) VALUES +(1, '安心验鉴定中心', 'AXY-WH-DEFAULT', 'detection_center', 'anxinyan', '安心验鉴定中心', '400-800-1314', '广东省', '深圳市', '南山区', '科技园鉴定路 88 号 安心验收件中心', '周一至周日 09:30-18:30', '寄送前请确认订单信息完整,包裹内附上订单号可提升签收后的处理效率。', NULL, NULL, NULL, 'enabled', 1, 1, '默认仓库', '{$now}', '{$now}'), +(2, '中检合作鉴定中心', 'ZJ-WH-DEFAULT', 'detection_center', 'zhongjian', '中检合作鉴定中心', '400-800-1314', '广东省', '深圳市', '南山区', '科技园鉴定路 88 号 安心验中检收件中心', '周一至周日 09:30-18:30', '中检鉴定订单请优先附上鉴定单号,寄出后尽快填写运单号。', NULL, NULL, NULL, 'enabled', 1, 1, '默认仓库', '{$now}', '{$now}'); + +INSERT INTO catalog_categories (id, name, code, sort_order, is_enabled, need_shipping, supported_service_types, created_at, updated_at) VALUES +(1, '奢侈品箱包', 'luxury_bag', 1, 1, 1, JSON_ARRAY('anxinyan', 'zhongjian'), '{$now}', '{$now}'), +(2, '潮流鞋类', 'sneaker', 2, 1, 1, JSON_ARRAY('anxinyan', 'zhongjian'), '{$now}', '{$now}'), +(3, '腕表', 'watch', 3, 1, 1, JSON_ARRAY('anxinyan', 'zhongjian'), '{$now}', '{$now}'), +(4, '首饰配饰', 'jewelry', 4, 1, 1, JSON_ARRAY('anxinyan', 'zhongjian'), '{$now}', '{$now}'), +(5, '3C 数码', 'digital', 5, 1, 1, JSON_ARRAY('anxinyan'), '{$now}', '{$now}'), +(6, '高端美妆', 'beauty', 6, 1, 1, JSON_ARRAY('anxinyan'), '{$now}', '{$now}'); + +INSERT INTO catalog_brands (id, name, en_name, code, sort_order, is_enabled, supported_service_types, created_at, updated_at) VALUES +(1, 'Louis Vuitton', 'Louis Vuitton', 'lv', 1, 1, JSON_ARRAY('anxinyan', 'zhongjian'), '{$now}', '{$now}'), +(2, 'Nike', 'Nike', 'nike', 2, 1, JSON_ARRAY('anxinyan', 'zhongjian'), '{$now}', '{$now}'), +(3, 'Rolex', 'Rolex', 'rolex', 3, 1, JSON_ARRAY('anxinyan', 'zhongjian'), '{$now}', '{$now}'); + +INSERT INTO catalog_brand_categories (id, brand_id, category_id, created_at) VALUES +(1, 1, 1, '{$now}'), +(2, 2, 2, '{$now}'), +(3, 3, 3, '{$now}'); + +INSERT INTO upload_templates (id, name, code, scope_type, scope_id, service_provider, is_default, is_enabled, created_at, updated_at) VALUES +(1, '箱包上传模板-安心验', 'bag_upload_anxinyan', 'category', 1, 'anxinyan', 1, 1, '{$now}', '{$now}'), +(2, '箱包上传模板-中检', 'bag_upload_zhongjian', 'category', 1, 'zhongjian', 1, 1, '{$now}', '{$now}'), +(3, '鞋类上传模板-安心验', 'shoe_upload_anxinyan', 'category', 2, 'anxinyan', 1, 1, '{$now}', '{$now}'), +(4, '鞋类上传模板-中检', 'shoe_upload_zhongjian', 'category', 2, 'zhongjian', 1, 1, '{$now}', '{$now}'), +(5, '腕表上传模板-安心验', 'watch_upload_anxinyan', 'category', 3, 'anxinyan', 1, 1, '{$now}', '{$now}'), +(6, '腕表上传模板-中检', 'watch_upload_zhongjian', 'category', 3, 'zhongjian', 1, 1, '{$now}', '{$now}'); + +INSERT INTO upload_template_items (id, template_id, item_code, item_name, is_required, guide_text, sample_image_url, max_upload_count, sort_order, is_enabled, created_at, updated_at) VALUES +(1, 1, 'overall_front', '商品整体图', 1, '请拍摄商品整体外观,确保主体完整入镜。', '', 1, 1, 1, '{$now}', '{$now}'), +(2, 1, 'logo_detail', '品牌标识图', 1, '请拍摄 Logo 或标识位置,保证图像清晰无遮挡。', '', 1, 2, 1, '{$now}', '{$now}'), +(3, 1, 'serial_label', '编码 / 标签图', 1, '请拍摄商品编码、标签或序列信息,确保内容可辨认。', '', 1, 3, 1, '{$now}', '{$now}'), +(4, 1, 'hardware_detail', '做工细节图', 1, '请拍摄材质、走线、五金等关键细节位置。', '', 2, 4, 1, '{$now}', '{$now}'), +(5, 1, 'purchase_voucher', '购买凭证', 0, '如有订单截图、发票或购物凭证,可一并上传。', '', 2, 5, 1, '{$now}', '{$now}'), +(6, 1, 'special_detail', '特殊细节图', 0, '如存在疑问部位或瑕疵,可补充上传。', '', 2, 6, 1, '{$now}', '{$now}'), +(7, 2, 'overall_front', '商品整体图', 1, '请拍摄商品整体外观,确保主体完整入镜。', '', 1, 1, 1, '{$now}', '{$now}'), +(8, 2, 'logo_detail', '品牌标识图', 1, '请拍摄 Logo 或标识位置,保证图像清晰无遮挡。', '', 1, 2, 1, '{$now}', '{$now}'), +(9, 2, 'serial_label', '编码 / 标签图', 1, '请拍摄商品编码、标签或序列信息,确保内容可辨认。', '', 1, 3, 1, '{$now}', '{$now}'), +(10, 2, 'hardware_detail', '做工细节图', 1, '请拍摄材质、走线、五金等关键细节位置。', '', 2, 4, 1, '{$now}', '{$now}'), +(11, 2, 'purchase_voucher', '购买凭证', 0, '如有订单截图、发票或购物凭证,可一并上传。', '', 2, 5, 1, '{$now}', '{$now}'), +(12, 2, 'special_detail', '特殊细节图', 0, '如存在疑问部位或瑕疵,可补充上传。', '', 2, 6, 1, '{$now}', '{$now}'), +(13, 3, 'overall_pair', '鞋款整体图', 1, '请拍摄左右脚整体外观,确保鞋面、鞋底完整入镜。', '', 2, 1, 1, '{$now}', '{$now}'), +(14, 3, 'tongue_label', '鞋舌标签图', 1, '请拍摄鞋舌标签、尺码标及生产信息。', '', 2, 2, 1, '{$now}', '{$now}'), +(15, 3, 'insole_detail', '鞋垫与内里图', 1, '请拍摄鞋垫、内里走线和内侧印刷细节。', '', 2, 3, 1, '{$now}', '{$now}'), +(16, 3, 'sole_detail', '鞋底细节图', 1, '请补充鞋底纹路和磨损细节。', '', 2, 4, 1, '{$now}', '{$now}'), +(17, 3, 'box_label', '鞋盒标签图', 0, '如有鞋盒,请拍摄鞋盒侧标信息。', '', 1, 5, 1, '{$now}', '{$now}'), +(18, 3, 'purchase_voucher', '购买凭证', 0, '如有订单截图、小票或平台购买记录,可一并上传。', '', 2, 6, 1, '{$now}', '{$now}'), +(19, 4, 'overall_pair', '鞋款整体图', 1, '请拍摄左右脚整体外观,确保鞋面、鞋底完整入镜。', '', 2, 1, 1, '{$now}', '{$now}'), +(20, 4, 'tongue_label', '鞋舌标签图', 1, '请拍摄鞋舌标签、尺码标及生产信息。', '', 2, 2, 1, '{$now}', '{$now}'), +(21, 4, 'insole_detail', '鞋垫与内里图', 1, '请拍摄鞋垫、内里走线和内侧印刷细节。', '', 2, 3, 1, '{$now}', '{$now}'), +(22, 4, 'sole_detail', '鞋底细节图', 1, '请补充鞋底纹路和磨损细节。', '', 2, 4, 1, '{$now}', '{$now}'), +(23, 4, 'box_label', '鞋盒标签图', 0, '如有鞋盒,请拍摄鞋盒侧标信息。', '', 1, 5, 1, '{$now}', '{$now}'), +(24, 4, 'purchase_voucher', '购买凭证', 0, '如有订单截图、小票或平台购买记录,可一并上传。', '', 2, 6, 1, '{$now}', '{$now}'), +(25, 5, 'overall_front', '腕表整体图', 1, '请拍摄表盘正面与整体外观。', '', 1, 1, 1, '{$now}', '{$now}'), +(26, 5, 'back_case', '表背图', 1, '请拍摄表背刻字、结构和编码信息。', '', 1, 2, 1, '{$now}', '{$now}'), +(27, 5, 'movement_detail', '机芯 / 内部结构图', 0, '如方便展示机芯,请补充内部结构图。', '', 2, 3, 1, '{$now}', '{$now}'), +(28, 5, 'strap_buckle', '表带 / 表扣细节图', 1, '请拍摄表带材质、表扣刻字和连接处细节。', '', 2, 4, 1, '{$now}', '{$now}'), +(29, 5, 'purchase_voucher', '购买凭证', 0, '如有保卡、发票或购买记录,可一并上传。', '', 2, 5, 1, '{$now}', '{$now}'), +(30, 6, 'overall_front', '腕表整体图', 1, '请拍摄表盘正面与整体外观。', '', 1, 1, 1, '{$now}', '{$now}'), +(31, 6, 'back_case', '表背图', 1, '请拍摄表背刻字、结构和编码信息。', '', 1, 2, 1, '{$now}', '{$now}'), +(32, 6, 'movement_detail', '机芯 / 内部结构图', 0, '如方便展示机芯,请补充内部结构图。', '', 2, 3, 1, '{$now}', '{$now}'), +(33, 6, 'strap_buckle', '表带 / 表扣细节图', 1, '请拍摄表带材质、表扣刻字和连接处细节。', '', 2, 4, 1, '{$now}', '{$now}'), +(34, 6, 'purchase_voucher', '购买凭证', 0, '如有保卡、发票或购买记录,可一并上传。', '', 2, 5, 1, '{$now}', '{$now}'); + +INSERT INTO orders (id, order_no, appraisal_no, user_id, service_mode, service_provider, payment_status, order_status, display_status, estimated_finish_time, source_channel, source_customer_id, pay_amount, paid_at, created_at, updated_at) VALUES +(1, 'AXY202604200001', 'AXY-APP-20260420-0001', 1, 'physical', 'zhongjian', 'paid', 'pending_supplement', '等待您补充资料', '2026-04-21 18:00:00', 'mini_program', '', 199.00, '2026-04-20 09:12:00', '2026-04-20 09:12:00', '{$now}'), +(2, 'AXY202604190012', 'AXY-APP-20260419-0012', 1, 'physical', 'anxinyan', 'paid', 'in_first_review', '鉴定师处理中', '2026-04-20 20:00:00', 'h5', '', 99.00, '2026-04-19 13:02:00', '2026-04-19 13:02:00', '{$now}'), +(3, 'AXY202604180088', 'AXY-APP-20260418-0088', 1, 'physical', 'zhongjian', 'paid', 'completed', '报告已出具', '2026-04-18 20:00:00', 'enterprise_push', 'ENT-DEMO-001', 199.00, '2026-04-18 08:18:00', '2026-04-18 08:18:00', '{$now}'); + +INSERT INTO order_products (id, order_id, category_id, category_name, brand_id, brand_name, color, size_spec, serial_no, product_name, product_cover, created_at, updated_at) VALUES +(1, 1, 1, '奢侈品箱包', 1, 'Louis Vuitton', '老花', 'MM', '', 'Louis Vuitton 奢侈品箱包', '', '{$now}', '{$now}'), +(2, 2, 2, '潮流鞋类', 2, 'Nike', 'Chicago', '42', '', 'Nike 潮流鞋类', '', '{$now}', '{$now}'), +(3, 3, 3, '腕表', 3, 'Rolex', '银盘', '36mm', 'RX123456', 'Rolex 腕表', '', '{$now}', '{$now}'); + +INSERT INTO order_extras (id, order_id, purchase_channel, purchase_price, purchase_date, usage_status, condition_desc, has_accessories, accessories_json, remark, created_at, updated_at) VALUES +(1, 1, '专柜', 8500.00, '2026-03-01', 'light_use', '轻微使用痕迹', 1, JSON_ARRAY('防尘袋','包装盒'), '包身整体状态良好', '{$now}', '{$now}'), +(2, 2, '官网', 1699.00, '2026-02-15', 'light_use', '鞋底轻微磨损', 1, JSON_ARRAY('鞋盒','购买凭证'), '补充鞋标与鞋盒标签', '{$now}', '{$now}'), +(3, 3, '专柜', 52000.00, '2026-01-20', 'light_use', '整体状态良好', 1, JSON_ARRAY('表盒','保卡'), '用于正式报告展示', '{$now}', '{$now}'); + +INSERT INTO order_shipping_targets (id, order_id, warehouse_id, warehouse_name, warehouse_code, service_provider, receiver_name, receiver_mobile, province, city, district, detail_address, service_time, notice, created_at, updated_at) VALUES +(1, 1, 2, '中检合作鉴定中心', 'ZJ-WH-DEFAULT', 'zhongjian', '中检合作鉴定中心', '400-800-1314', '广东省', '深圳市', '南山区', '科技园鉴定路 88 号 安心验中检收件中心', '周一至周日 09:30-18:30', '中检鉴定订单请优先附上鉴定单号,寄出后尽快填写运单号。', '{$now}', '{$now}'), +(2, 2, 1, '安心验鉴定中心', 'AXY-WH-DEFAULT', 'anxinyan', '安心验鉴定中心', '400-800-1314', '广东省', '深圳市', '南山区', '科技园鉴定路 88 号 安心验收件中心', '周一至周日 09:30-18:30', '寄送前请确认订单信息完整,包裹内附上订单号可提升签收后的处理效率。', '{$now}', '{$now}'), +(3, 3, 2, '中检合作鉴定中心', 'ZJ-WH-DEFAULT', 'zhongjian', '中检合作鉴定中心', '400-800-1314', '广东省', '深圳市', '南山区', '科技园鉴定路 88 号 安心验中检收件中心', '周一至周日 09:30-18:30', '中检鉴定订单请优先附上鉴定单号,寄出后尽快填写运单号。', '{$now}', '{$now}'); + +INSERT INTO order_timelines (id, order_id, node_code, node_text, node_desc, operator_type, operator_id, occurred_at, created_at) VALUES +(1, 1, 'created', '下单成功', '订单已生成并完成支付', 'system', NULL, '2026-04-20 09:12:00', '{$now}'), +(2, 1, 'submitted', '资料已提交', '用户已完成首轮资料上传', 'user', 1, '2026-04-20 09:30:00', '{$now}'), +(3, 1, 'first_review', '鉴定中', '鉴定师正在进行专业判断', 'admin', 1, '2026-04-20 10:20:00', '{$now}'), +(4, 1, 'supplement', '待补资料', '鉴定师需要补充编码近照与五金细节图', 'admin', 1, '2026-04-20 11:16:00', '{$now}'), +(5, 2, 'created', '下单成功', '订单已生成并完成支付', 'system', NULL, '2026-04-19 13:02:00', '{$now}'), +(6, 2, 'submitted', '资料已提交', '用户已完成首轮资料上传', 'user', 1, '2026-04-19 13:18:00', '{$now}'), +(7, 2, 'received', '鉴定中心已收货', '商品已进入鉴定中心', 'system', NULL, '2026-04-19 15:10:00', '{$now}'), +(8, 2, 'first_review', '鉴定中', '鉴定师正在处理,预计 24 小时内出具报告', 'admin', 2, '2026-04-20 09:10:00', '{$now}'), +(9, 3, 'created', '下单成功', '订单已生成并完成支付', 'system', NULL, '2026-04-18 08:18:00', '{$now}'), +(10, 3, 'received', '鉴定中心已收货', '商品已进入鉴定中心', 'system', NULL, '2026-04-18 10:12:00', '{$now}'), +(11, 3, 'generating_report', '正在生成报告', '鉴定已完成,系统正在生成正式报告草稿', 'admin', 3, '2026-04-18 16:00:00', '{$now}'), +(12, 3, 'report_published', '报告已出具', '正式报告已生成并可查看', 'system', NULL, '2026-04-18 18:26:00', '{$now}'); + +INSERT INTO order_supplement_tasks (id, order_id, reason, deadline, status, created_by, created_at, updated_at) VALUES +(1, 1, '鉴定师需要补充编码近照与五金细节图,以继续完成判断。', '2026-04-21 18:00:00', 'pending', 1, '{$now}', '{$now}'); + +INSERT INTO order_supplement_task_items (id, task_id, item_code, item_name, guide_text, sample_image_url, is_required, created_at, updated_at) VALUES +(1, 1, 'serial_label', '编码 / 标签图', '请补充清晰近照,确保编码内容完整可辨认。', '', 1, '{$now}', '{$now}'), +(2, 1, 'hardware_detail', '五金细节图', '请避免反光与遮挡,完整拍摄边缘与刻印细节。', '', 1, '{$now}', '{$now}'); + +INSERT INTO appraisal_tasks (id, order_id, task_stage, service_provider, status, assignee_id, assignee_name, started_at, submitted_at, sla_deadline, is_overtime, created_at, updated_at) VALUES +(1, 1, 'first_review', 'zhongjian', 'processing', 1, '张师傅', '2026-04-20 10:20:00', NULL, '2026-04-21 18:00:00', 0, '{$now}', '{$now}'), +(2, 2, 'first_review', 'anxinyan', 'processing', 2, '李师傅', '2026-04-20 09:10:00', NULL, '2026-04-20 20:00:00', 0, '{$now}', '{$now}'), +(3, 3, 'first_review', 'zhongjian', 'completed', 3, '王师傅', '2026-04-18 14:10:00', '2026-04-18 16:00:00', '2026-04-18 20:00:00', 0, '{$now}', '{$now}'); + +INSERT INTO appraisal_task_results (id, task_id, order_id, result_status, result_text, result_desc, condition_grade, condition_desc, valuation_min, valuation_max, valuation_desc, internal_remark, external_remark, created_at, updated_at) VALUES +(1, 3, 3, 'authentic', '正品', '综合当前送检资料与商品特征判断,符合正品特征。', 'A', '整体状态良好,存在轻微使用痕迹。', 2800.00, 3200.00, '当前估值仅供参考,具体以市场流通情况为准。', '鉴定完成,可出正式报告。', '综合当前送检资料与商品特征判断,符合正品特征。', '{$now}', '{$now}'); + +INSERT INTO reports (id, report_no, order_id, appraisal_no, report_type, service_provider, institution_name, report_title, report_status, report_version, publish_time, created_at, updated_at) VALUES +(1, 'AXY-R-20260420-0001', 3, 'AXY-APP-20260418-0088', 'appraisal', 'zhongjian', '中检合作机构', '中检鉴定报告', 'published', 1, '2026-04-18 18:26:00', '{$now}', '{$now}'); +"); + +$productSnapshot = json_encode([ + 'product_name' => 'Rolex 腕表', + 'category_name' => '腕表', + 'brand_name' => 'Rolex', + 'color' => '银盘', + 'size_spec' => '36mm', +], JSON_UNESCAPED_UNICODE); + +$resultSnapshot = json_encode([ + 'result_status' => 'authentic', + 'result_text' => '正品', + 'result_desc' => '综合当前送检资料与商品特征判断,符合正品特征。', +], JSON_UNESCAPED_UNICODE); + +$appraisalSnapshot = json_encode([ + 'service_provider' => 'zhongjian', + 'institution_name' => '中检合作机构', + 'appraiser_name' => '张师傅', + 'reviewer_name' => '张师傅', + 'appraisal_time' => '2026-04-18 16:00:00', +], JSON_UNESCAPED_UNICODE); + +$valuationSnapshot = json_encode([ + 'condition_grade' => 'A', + 'condition_desc' => '整体状态良好,存在轻微使用痕迹。', + 'valuation_min' => 2800, + 'valuation_max' => 3200, + 'valuation_desc' => '当前估值仅供参考,具体以市场流通情况为准。', +], JSON_UNESCAPED_UNICODE); + +$stmt = $pdo->prepare('INSERT INTO report_contents (id, report_id, product_snapshot_json, result_snapshot_json, appraisal_snapshot_json, valuation_snapshot_json, risk_notice_text, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)'); +$stmt->execute([ + 1, + 1, + $productSnapshot, + $resultSnapshot, + $appraisalSnapshot, + $valuationSnapshot, + '本报告基于送检商品及当前提交资料出具。若商品状态或所附资料发生变化,报告结论可能不再适用。', + $now, + $now, +]); + +$stmt = $pdo->prepare('INSERT INTO report_verifies (id, report_id, report_no, verify_token, verify_qrcode_url, verify_url, verify_status, verify_count, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'); +$stmt->execute([ + 1, + 1, + 'AXY-R-20260420-0001', + 'verify_axyr202604200001', + '', + '/api/app/verify?report_no=AXY-R-20260420-0001', + 'valid', + 0, + $now, + $now, +]); + +$pdo->exec(" +INSERT INTO message_templates (id, template_name, template_code, channel, event_code, title, content, is_enabled, created_at, updated_at) VALUES +(1, '下单成功通知', 'order_created_inbox', 'inbox', 'order_created', '订单提交成功', '您的鉴定订单已提交成功,可前往订单中心查看进度。', 1, '{$now}', '{$now}'), +(2, '待补资料通知', 'supplement_required_inbox', 'inbox', 'supplement_required', '请补充鉴定资料', '鉴定师需要您补充资料后继续处理,请尽快进入订单详情查看。', 1, '{$now}', '{$now}'), +(3, '报告已出具通知', 'report_published_inbox', 'inbox', 'report_published', '报告已出具', '您的正式报告已生成,可前往报告中心查看并完成验真。', 1, '{$now}', '{$now}'); + +INSERT INTO message_rules (id, event_code, channel, template_id, delay_seconds, dedupe_window, is_enabled, created_at, updated_at) VALUES +(1, 'order_created', 'inbox', 1, 0, 0, 1, '{$now}', '{$now}'), +(2, 'supplement_required', 'inbox', 2, 0, 0, 1, '{$now}', '{$now}'), +(3, 'report_published', 'inbox', 3, 0, 0, 1, '{$now}', '{$now}'); + +INSERT INTO message_logs (id, user_id, template_id, biz_type, biz_id, channel, status, fail_reason, sent_at, created_at, updated_at) VALUES +(1, 1, 1, 'order', 1, 'inbox', 'sent', '', '2026-04-20 09:12:00', '{$now}', '{$now}'), +(2, 1, 2, 'order', 1, 'inbox', 'sent', '', '2026-04-20 11:16:00', '{$now}', '{$now}'), +(3, 1, 3, 'report', 1, 'inbox', 'pending', '', NULL, '{$now}', '{$now}'); + +INSERT INTO user_messages (id, user_id, title, content, biz_type, biz_id, is_read, read_at, created_at, updated_at) VALUES +(1, 1, '订单提交成功', '您的鉴定订单已提交成功,可前往订单中心查看进度。', 'order', 1, 1, '2026-04-20 09:20:00', '{$now}', '{$now}'), +(2, 1, '请补充鉴定资料', '鉴定师需要您补充资料后继续处理,请尽快进入订单详情查看。', 'order', 1, 0, NULL, '{$now}', '{$now}'), +(3, 1, '报告已出具', '您的正式报告已生成,可前往报告中心查看并完成验真。', 'report', 1, 0, NULL, '{$now}', '{$now}'); + +INSERT INTO tickets (id, ticket_no, ticket_type, biz_type, biz_id, order_id, user_id, status, priority, assignee_id, title, content, closed_at, created_at, updated_at) VALUES +(1, 'TK202604200001', 'upload_issue', 'order', 1, 1, 1, 'processing', 'high', 1, '补图说明咨询', '用户反馈不确定编码标签该如何拍摄,希望客服提供拍摄建议。', NULL, '{$now}', '{$now}'), +(2, 'TK202604200002', 'report_issue', 'report', 1, 3, 1, 'pending', 'normal', NULL, '报告内容咨询', '用户希望了解估值说明与评级口径。', NULL, '{$now}', '{$now}'); + +INSERT INTO ticket_messages (id, ticket_id, sender_type, sender_id, content, attachments_json, created_at) VALUES +(1, 1, 'user', 1, '我不确定编码标签应该怎么拍,担心影响鉴定结果。', NULL, '2026-04-20 11:18:00'), +(2, 1, 'customer_service', 1, '您好,请优先拍摄标签整体区域,再补一张放大近照,保证编码内容完整可辨认。', NULL, '2026-04-20 11:25:00'), +(3, 2, 'user', 1, '请问 A 级和估值区间的口径是什么?', NULL, '2026-04-20 12:10:00'), +(4, 2, 'system', NULL, '工单已创建,等待客服跟进。', NULL, '2026-04-20 12:11:00'); +"); + +$pdo->exec(" +INSERT INTO admin_users (id, name, mobile, email, password, status, last_login_at, created_at, updated_at) VALUES +(1, '系统管理员', '13800138000', 'admin@anxinyan.local', '{$adminPasswordHash}', 'enabled', NULL, '{$now}', '{$now}'); + +INSERT INTO admin_roles (id, name, code, status, created_at, updated_at) VALUES +(1, '超级管理员', 'super_admin', 'enabled', '{$now}', '{$now}'); + +INSERT INTO admin_role_relations (id, admin_user_id, role_id, created_at) VALUES +(1, 1, 1, '{$now}'); + +INSERT INTO admin_permissions (id, name, code, module, action, created_at, updated_at) VALUES +(1, '查看工作台', 'dashboard.view', 'dashboard', 'view', '{$now}', '{$now}'), +(2, '管理订单', 'orders.manage', 'orders', 'manage', '{$now}', '{$now}'), +(3, '管理鉴定任务', 'appraisal_tasks.manage', 'appraisal_tasks', 'manage', '{$now}', '{$now}'), +(4, '管理商品资料', 'catalog.manage', 'catalog', 'manage', '{$now}', '{$now}'), +(5, '管理报告', 'reports.manage', 'reports', 'manage', '{$now}', '{$now}'), +(6, '管理消息', 'messages.manage', 'messages', 'manage', '{$now}', '{$now}'), +(7, '管理工单', 'tickets.manage', 'tickets', 'manage', '{$now}', '{$now}'), +(8, '管理用户', 'users.manage', 'users', 'manage', '{$now}', '{$now}'), +(9, '管理客户', 'customers.manage', 'customers', 'manage', '{$now}', '{$now}'), +(10, '管理仓库', 'warehouses.manage', 'warehouses', 'manage', '{$now}', '{$now}'), +(11, '管理物料', 'materials.manage', 'materials', 'manage', '{$now}', '{$now}'), +(12, '管理权限', 'access.manage', 'access', 'manage', '{$now}', '{$now}'), +(13, '管理系统配置', 'system.manage', 'system_config', 'manage', '{$now}', '{$now}'); + +INSERT INTO admin_role_permissions (id, role_id, permission_id, created_at) VALUES +(1, 1, 1, '{$now}'), +(2, 1, 2, '{$now}'), +(3, 1, 3, '{$now}'), +(4, 1, 4, '{$now}'), +(5, 1, 5, '{$now}'), +(6, 1, 6, '{$now}'), +(7, 1, 7, '{$now}'), +(8, 1, 8, '{$now}'), +(9, 1, 9, '{$now}'), +(10, 1, 10, '{$now}'), +(11, 1, 11, '{$now}'), +(12, 1, 12, '{$now}'), +(13, 1, 13, '{$now}'); + +INSERT INTO system_configs (id, config_group, config_key, config_value, remark, created_at, updated_at) VALUES +(1, 'mini_program', 'app_id', '', '后台系统配置', '{$now}', '{$now}'), +(2, 'mini_program', 'app_secret', '', '后台系统配置', '{$now}', '{$now}'), +(3, 'mini_program', 'original_id', '', '后台系统配置', '{$now}', '{$now}'), +(4, 'h5', 'app_id', '', '后台系统配置', '{$now}', '{$now}'), +(5, 'h5', 'app_secret', '', '后台系统配置', '{$now}', '{$now}'), +(6, 'h5', 'oauth_redirect_url', '', '后台系统配置', '{$now}', '{$now}'), +(7, 'h5', 'page_base_url', '', '后台系统配置', '{$now}', '{$now}'), +(8, 'payment', 'mch_id', '', '后台系统配置', '{$now}', '{$now}'), +(9, 'payment', 'api_v3_key', '', '后台系统配置', '{$now}', '{$now}'), +(10, 'payment', 'merchant_serial_no', '', '后台系统配置', '{$now}', '{$now}'), +(11, 'payment', 'merchant_private_key', '', '后台系统配置', '{$now}', '{$now}'), +(12, 'payment', 'platform_certificate_serial', '', '后台系统配置', '{$now}', '{$now}'), +(13, 'payment', 'notify_url', '', '后台系统配置', '{$now}', '{$now}'), +(14, 'sms', 'access_key_id', '', '后台系统配置', '{$now}', '{$now}'), +(15, 'sms', 'access_key_secret', '', '后台系统配置', '{$now}', '{$now}'), +(16, 'sms', 'sign_name', '', '后台系统配置', '{$now}', '{$now}'), +(17, 'sms', 'login_template_code', '', '后台系统配置', '{$now}', '{$now}'), +(18, 'sms', 'region_id', 'cn-hangzhou', '后台系统配置', '{$now}', '{$now}'), +(19, 'sms', 'endpoint', '', '后台系统配置', '{$now}', '{$now}'), +(20, 'user_settings', 'user_1', '{\"notify_order\":true,\"notify_report\":true,\"notify_supplement\":true,\"notify_ticket\":true,\"marketing_notify\":false,\"privacy_mode\":false}', '用户端设置偏好', '{$now}', '{$now}'); +"); + +echo "SEED_OK\n"; diff --git a/server-api/tools/debug_verify.php b/server-api/tools/debug_verify.php new file mode 100644 index 0000000..db1bfc3 --- /dev/null +++ b/server-api/tools/debug_verify.php @@ -0,0 +1,15 @@ +safeLoad(); + +$config = array_replace_recursive(config('thinkorm', []), config('think-orm', [])); +support\think\Db::setConfig($config); + +$content = support\think\Db::name('report_contents')->where('report_id', 1)->find(); + +var_dump($content); diff --git a/server-api/tools/release_audit.php b/server-api/tools/release_audit.php new file mode 100644 index 0000000..e9f135c --- /dev/null +++ b/server-api/tools/release_audit.php @@ -0,0 +1,193 @@ +safeLoad(); + +$projectRoot = dirname(__DIR__, 2); +$issues = []; + +function add_issue(array &$issues, string $level, string $title, string $detail): void +{ + $issues[] = compact('level', 'title', 'detail'); +} + +function check(bool $condition, array &$issues, string $failLevel, string $title, string $detail): void +{ + if (!$condition) { + add_issue($issues, $failLevel, $title, $detail); + } +} + +function parseJsonFile(string $path): ?array +{ + $content = @file_get_contents($path); + if ($content === false) { + return null; + } + + $content = preg_replace('/^\xEF\xBB\xBF/', '', $content); + $content = preg_replace('!/\*.*?\*/!s', '', $content); + $content = preg_replace('/^\s*\/\/.*$/m', '', $content); + + $decoded = json_decode((string)$content, true); + return is_array($decoded) ? $decoded : null; +} + +function isPlaceholderApiBase(string $apiBase): bool +{ + if ($apiBase === '') { + return true; + } + + $normalized = strtolower($apiBase); + if (str_contains($normalized, '127.0.0.1') || str_contains($normalized, 'localhost')) { + return true; + } + + return str_contains($normalized, 'example.com'); +} + +$env = $_ENV; +check(($env['APP_ENV'] ?? '') === 'production', $issues, 'FAIL', 'APP_ENV 非 production', '当前 .env 中 APP_ENV 不是 production。'); +check(in_array(strtolower((string)($env['APP_DEBUG'] ?? '')), ['false', '0'], true), $issues, 'FAIL', 'APP_DEBUG 未关闭', '当前 .env 中 APP_DEBUG 仍然开启。'); + +$dsn = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=%s', + $env['DB_HOST'] ?? '127.0.0.1', + $env['DB_PORT'] ?? '3306', + $env['DB_DATABASE'] ?? '', + $env['DB_CHARSET'] ?? 'utf8mb4' +); + +$pdo = new PDO( + $dsn, + $env['DB_USERNAME'] ?? '', + $env['DB_PASSWORD'] ?? '', + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] +); + +$configRows = $pdo->query("SELECT config_group, config_key, config_value FROM system_configs")->fetchAll(); +$configMap = []; +foreach ($configRows as $row) { + $configMap[$row['config_group'] . '.' . $row['config_key']] = (string)($row['config_value'] ?? ''); +} + +$requiredConfigKeys = [ + 'mini_program.app_id', + 'mini_program.app_secret', + 'mini_program.original_id', + 'h5.app_id', + 'h5.app_secret', + 'h5.oauth_redirect_url', + 'h5.page_base_url', + 'sms.access_key_id', + 'sms.access_key_secret', + 'sms.sign_name', + 'sms.login_template_code', + 'payment.mch_id', + 'payment.api_v3_key', + 'payment.merchant_serial_no', + 'payment.merchant_private_key', + 'payment.platform_certificate_serial', + 'payment.notify_url', +]; + +foreach ($requiredConfigKeys as $key) { + check(($configMap[$key] ?? '') !== '', $issues, 'FAIL', "系统配置缺失: {$key}", "后台系统配置中 {$key} 仍为空。"); +} + +if (($configMap['h5.page_base_url'] ?? '') !== '' && isPlaceholderApiBase((string)$configMap['h5.page_base_url'])) { + add_issue($issues, 'FAIL', 'H5 页面根地址未配置正式域名', '后台系统配置 h5.page_base_url 仍为本地或占位地址,扫码公开链接将无法用于正式环境。'); +} + +$demoValues = [ + 'mini_program.app_id' => 'wx1234567890test', + 'h5.app_id' => 'h5_app_demo', + 'payment.mch_id' => '1900000109', + 'payment.api_v3_key' => 'demo_api_v3_key_1234567890', +]; + +foreach ($demoValues as $key => $value) { + check(($configMap[$key] ?? '') !== $value, $issues, 'FAIL', "系统配置仍是演示值: {$key}", "后台系统配置 {$key} 仍为演示值 {$value}。"); +} + +$admins = $pdo->query("SELECT id, mobile, password, status FROM admin_users ORDER BY id ASC")->fetchAll(); +$hasDefaultAdmin = false; +$hasViewerAdmin = false; +foreach ($admins as $admin) { + if (($admin['mobile'] ?? '') === '13800138000') { + $hasDefaultAdmin = true; + if (password_verify('Admin@123456', (string)$admin['password'])) { + add_issue($issues, 'FAIL', '默认超级管理员密码未修改', '管理员 13800138000 仍可用默认密码 Admin@123456 登录。'); + } + } + if (($admin['mobile'] ?? '') === '13800138001' && ($admin['status'] ?? '') === 'enabled') { + $hasViewerAdmin = true; + } +} +if ($hasViewerAdmin) { + add_issue($issues, 'WARN', '测试管理员仍启用', '测试管理员 13800138001 / Test@123456 仍处于启用状态。'); +} +if (!$hasDefaultAdmin) { + add_issue($issues, 'WARN', '未检测到默认超级管理员', '未找到手机号 13800138000 的默认超级管理员账号。'); +} + +$user = $pdo->query("SELECT nickname FROM users WHERE id = 1")->fetch(); +if ($user && str_contains((string)$user['nickname'], '测试')) { + add_issue($issues, 'WARN', '测试昵称未清理', '用户昵称仍包含“测试”字样。'); +} + +$manifestPath = $projectRoot . '/user-app/src/manifest.json'; +$manifest = parseJsonFile($manifestPath); +if (!is_array($manifest)) { + add_issue($issues, 'FAIL', 'manifest.json 解析失败', '无法解析 user-app/src/manifest.json。'); +} else { + check(($manifest['mp-weixin']['appid'] ?? '') !== '', $issues, 'FAIL', '小程序 manifest appid 为空', 'manifest.json 中 mp-weixin.appid 仍为空。'); + if (($configMap['mini_program.app_id'] ?? '') !== '') { + check( + ($manifest['mp-weixin']['appid'] ?? '') === $configMap['mini_program.app_id'], + $issues, + 'FAIL', + '小程序 manifest appid 未同步后台配置', + 'manifest.json 中 mp-weixin.appid 与后台系统配置 mini_program.app_id 不一致,请先执行配置同步。' + ); + } +} + +$adminProdEnvPath = $projectRoot . '/admin-web/.env.production'; +$userProdEnvPath = $projectRoot . '/user-app/.env.production'; +$adminProdEnv = @parse_ini_file($adminProdEnvPath); +$userProdEnv = @parse_ini_file($userProdEnvPath); +if (is_array($adminProdEnv)) { + $adminApiBase = (string)($adminProdEnv['VITE_API_BASE_URL'] ?? ''); + if (isPlaceholderApiBase($adminApiBase)) { + add_issue($issues, 'FAIL', 'admin-web 生产 API 未配置正式域名', 'admin-web/.env.production 的 VITE_API_BASE_URL 仍为本地或占位地址。'); + } +} else { + add_issue($issues, 'FAIL', 'admin-web 缺少生产环境变量', '无法解析 admin-web/.env.production。'); +} +if (is_array($userProdEnv)) { + $userApiBase = (string)($userProdEnv['VITE_API_BASE_URL'] ?? ''); + if (isPlaceholderApiBase($userApiBase)) { + add_issue($issues, 'FAIL', 'user-app 生产 API 未配置正式域名', 'user-app/.env.production 的 VITE_API_BASE_URL 仍为本地或占位地址。'); + } +} else { + add_issue($issues, 'FAIL', 'user-app 缺少生产环境变量', '无法解析 user-app/.env.production。'); +} + +if (!$issues) { + echo "RELEASE_AUDIT_OK\n"; + exit(0); +} + +echo "RELEASE_AUDIT_ISSUES\n"; +foreach ($issues as $item) { + echo "[{$item['level']}] {$item['title']} - {$item['detail']}\n"; +} diff --git a/server-api/tools/schema_upgrade_appraisal_evidence.php b/server-api/tools/schema_upgrade_appraisal_evidence.php new file mode 100644 index 0000000..c79d185 --- /dev/null +++ b/server-api/tools/schema_upgrade_appraisal_evidence.php @@ -0,0 +1,32 @@ + PDO::ERRMODE_EXCEPTION, +]); + +function hasColumn(PDO $pdo, string $table, string $column): bool +{ + $stmt = $pdo->prepare("SHOW COLUMNS FROM `{$table}` LIKE ?"); + $stmt->execute([$column]); + return (bool)$stmt->fetch(PDO::FETCH_ASSOC); +} + +if (!hasColumn($pdo, 'appraisal_task_results', 'attachments_json')) { + $pdo->exec("ALTER TABLE appraisal_task_results ADD COLUMN attachments_json JSON NULL AFTER valuation_desc"); + echo "ADD_COLUMN appraisal_task_results.attachments_json\n"; +} + +if (!hasColumn($pdo, 'report_contents', 'evidence_attachments_json')) { + $pdo->exec("ALTER TABLE report_contents ADD COLUMN evidence_attachments_json JSON NULL AFTER valuation_snapshot_json"); + echo "ADD_COLUMN report_contents.evidence_attachments_json\n"; +} + +echo "done\n"; diff --git a/server-api/tools/schema_upgrade_enterprise_customers.php b/server-api/tools/schema_upgrade_enterprise_customers.php new file mode 100644 index 0000000..4a0b387 --- /dev/null +++ b/server-api/tools/schema_upgrade_enterprise_customers.php @@ -0,0 +1,196 @@ +safeLoad(); + +$dsn = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=%s', + $_ENV['DB_HOST'] ?? '127.0.0.1', + $_ENV['DB_PORT'] ?? '3306', + $_ENV['DB_DATABASE'] ?? '', + $_ENV['DB_CHARSET'] ?? 'utf8mb4' +); + +$pdo = new PDO( + $dsn, + $_ENV['DB_USERNAME'] ?? '', + $_ENV['DB_PASSWORD'] ?? '', + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] +); + +function hasTable(PDO $pdo, string $table): bool +{ + $stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'); + $stmt->execute([$table]); + return (int)$stmt->fetchColumn() > 0; +} + +function hasPermission(PDO $pdo, string $code): bool +{ + $stmt = $pdo->prepare('SELECT COUNT(*) FROM admin_permissions WHERE code = ?'); + $stmt->execute([$code]); + return (int)$stmt->fetchColumn() > 0; +} + +if (!hasTable($pdo, 'enterprise_customers')) { + $pdo->exec(<<<'SQL' +CREATE TABLE enterprise_customers ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + customer_code VARCHAR(64) NOT NULL, + customer_name VARCHAR(128) NOT NULL DEFAULT '', + contact_name VARCHAR(64) NOT NULL DEFAULT '', + contact_mobile VARCHAR(32) NOT NULL DEFAULT '', + contact_email VARCHAR(128) NOT NULL DEFAULT '', + settlement_type VARCHAR(32) NOT NULL DEFAULT 'monthly', + user_id BIGINT UNSIGNED NULL DEFAULT NULL, + webhook_url VARCHAR(500) NOT NULL DEFAULT '', + webhook_enabled TINYINT(1) NOT NULL DEFAULT 0, + status VARCHAR(32) NOT NULL DEFAULT 'enabled', + remark VARCHAR(500) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_enterprise_customers_code (customer_code), + KEY idx_enterprise_customers_status (status), + KEY idx_enterprise_customers_user_id (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='大客户资料' +SQL); + echo "CREATE_TABLE enterprise_customers\n"; +} + +if (!hasTable($pdo, 'enterprise_customer_apps')) { + $pdo->exec(<<<'SQL' +CREATE TABLE enterprise_customer_apps ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + customer_id BIGINT UNSIGNED NOT NULL, + app_name VARCHAR(128) NOT NULL DEFAULT '', + app_key VARCHAR(64) NOT NULL, + app_secret_cipher TEXT NULL, + secret_last4 VARCHAR(8) NOT NULL DEFAULT '', + status VARCHAR(32) NOT NULL DEFAULT 'enabled', + last_used_at DATETIME NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_enterprise_customer_apps_key (app_key), + KEY idx_enterprise_customer_apps_customer_id (customer_id), + KEY idx_enterprise_customer_apps_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='大客户开放接口应用' +SQL); + echo "CREATE_TABLE enterprise_customer_apps\n"; +} + +if (!hasTable($pdo, 'enterprise_api_nonces')) { + $pdo->exec(<<<'SQL' +CREATE TABLE enterprise_api_nonces ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + app_key VARCHAR(64) NOT NULL, + nonce VARCHAR(128) NOT NULL, + request_timestamp BIGINT NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_enterprise_api_nonces_key_nonce (app_key, nonce), + KEY idx_enterprise_api_nonces_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='开放接口防重放Nonce' +SQL); + echo "CREATE_TABLE enterprise_api_nonces\n"; +} + +if (!hasTable($pdo, 'enterprise_customer_order_refs')) { + $pdo->exec(<<<'SQL' +CREATE TABLE enterprise_customer_order_refs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + customer_id BIGINT UNSIGNED NOT NULL, + external_order_no VARCHAR(128) NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + order_no VARCHAR(64) NOT NULL DEFAULT '', + appraisal_no VARCHAR(64) NOT NULL DEFAULT '', + payload_hash VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_enterprise_customer_order_refs_external (customer_id, external_order_no), + UNIQUE KEY uk_enterprise_customer_order_refs_order_id (order_id), + KEY idx_enterprise_customer_order_refs_order_no (order_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='大客户外部订单映射' +SQL); + echo "CREATE_TABLE enterprise_customer_order_refs\n"; +} + +if (!hasTable($pdo, 'enterprise_order_events')) { + $pdo->exec(<<<'SQL' +CREATE TABLE enterprise_order_events ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + customer_id BIGINT UNSIGNED NOT NULL, + order_id BIGINT UNSIGNED NOT NULL, + external_order_no VARCHAR(128) NOT NULL DEFAULT '', + event_code VARCHAR(64) NOT NULL, + event_text VARCHAR(128) NOT NULL DEFAULT '', + status_code VARCHAR(64) NOT NULL DEFAULT '', + status_text VARCHAR(128) NOT NULL DEFAULT '', + payload_json JSON NULL, + occurred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_enterprise_order_events_customer_id (customer_id), + KEY idx_enterprise_order_events_order_id (order_id), + KEY idx_enterprise_order_events_event_code (event_code), + KEY idx_enterprise_order_events_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='大客户订单事件' +SQL); + echo "CREATE_TABLE enterprise_order_events\n"; +} + +if (!hasTable($pdo, 'enterprise_webhook_deliveries')) { + $pdo->exec(<<<'SQL' +CREATE TABLE enterprise_webhook_deliveries ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + event_id BIGINT UNSIGNED NOT NULL, + customer_id BIGINT UNSIGNED NOT NULL, + webhook_url VARCHAR(500) NOT NULL DEFAULT '', + app_key VARCHAR(64) NOT NULL DEFAULT '', + attempt_no INT NOT NULL DEFAULT 1, + delivery_status VARCHAR(32) NOT NULL DEFAULT 'pending', + http_status INT NOT NULL DEFAULT 0, + response_body TEXT NULL, + error_message VARCHAR(500) NOT NULL DEFAULT '', + is_manual TINYINT(1) NOT NULL DEFAULT 0, + sent_at DATETIME NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_enterprise_webhook_deliveries_event_id (event_id), + KEY idx_enterprise_webhook_deliveries_customer_id (customer_id), + KEY idx_enterprise_webhook_deliveries_status (delivery_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='大客户Webhook推送记录' +SQL); + echo "CREATE_TABLE enterprise_webhook_deliveries\n"; +} + +$now = date('Y-m-d H:i:s'); +if (!hasPermission($pdo, 'customers.manage')) { + $stmt = $pdo->prepare('INSERT INTO admin_permissions (name, code, module, action, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'); + $stmt->execute(['管理客户', 'customers.manage', 'customers', 'manage', $now, $now]); + echo "ADD_PERMISSION customers.manage\n"; +} + +$permissionId = (int)$pdo->query("SELECT id FROM admin_permissions WHERE code = 'customers.manage'")->fetchColumn(); +$superRoleId = (int)$pdo->query("SELECT id FROM admin_roles WHERE code = 'super_admin'")->fetchColumn(); +if ($permissionId > 0 && $superRoleId > 0) { + $stmt = $pdo->prepare('SELECT COUNT(*) FROM admin_role_permissions WHERE role_id = ? AND permission_id = ?'); + $stmt->execute([$superRoleId, $permissionId]); + if ((int)$stmt->fetchColumn() === 0) { + $insert = $pdo->prepare('INSERT INTO admin_role_permissions (role_id, permission_id, created_at) VALUES (?, ?, ?)'); + $insert->execute([$superRoleId, $permissionId, $now]); + echo "ADD_SUPER_ADMIN_PERMISSION customers.manage\n"; + } +} + +echo "SCHEMA_UPGRADE_OK\n"; diff --git a/server-api/tools/schema_upgrade_manual_reports.php b/server-api/tools/schema_upgrade_manual_reports.php new file mode 100644 index 0000000..d9ccea0 --- /dev/null +++ b/server-api/tools/schema_upgrade_manual_reports.php @@ -0,0 +1,69 @@ +safeLoad(); + +$dsn = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=%s', + $_ENV['DB_HOST'] ?? '127.0.0.1', + $_ENV['DB_PORT'] ?? '3306', + $_ENV['DB_DATABASE'] ?? '', + $_ENV['DB_CHARSET'] ?? 'utf8mb4' +); + +$pdo = new PDO( + $dsn, + $_ENV['DB_USERNAME'] ?? '', + $_ENV['DB_PASSWORD'] ?? '', + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] +); + +function hasColumn(PDO $pdo, string $table, string $column): bool +{ + $stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'); + $stmt->execute([$table, $column]); + return (int)$stmt->fetchColumn() > 0; +} + +function hasIndex(PDO $pdo, string $table, string $index): bool +{ + $stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?'); + $stmt->execute([$table, $index]); + return (int)$stmt->fetchColumn() > 0; +} + +function hasSystemConfig(PDO $pdo, string $group, string $key): bool +{ + $stmt = $pdo->prepare('SELECT COUNT(*) FROM system_configs WHERE config_group = ? AND config_key = ?'); + $stmt->execute([$group, $key]); + return (int)$stmt->fetchColumn() > 0; +} + +$now = date('Y-m-d H:i:s'); + +if (!hasColumn($pdo, 'reports', 'report_type')) { + $pdo->exec("ALTER TABLE reports ADD COLUMN report_type VARCHAR(32) NOT NULL DEFAULT 'appraisal' AFTER appraisal_no"); + echo "ADD_COLUMN reports.report_type\n"; +} + +if (!hasIndex($pdo, 'reports', 'idx_reports_report_type')) { + $pdo->exec("ALTER TABLE reports ADD KEY idx_reports_report_type (report_type)"); + echo "ADD_INDEX reports.idx_reports_report_type\n"; +} + +$pdo->exec("UPDATE reports SET report_type = 'appraisal' WHERE report_type = '' OR report_type IS NULL"); + +if (!hasSystemConfig($pdo, 'h5', 'page_base_url')) { + $stmt = $pdo->prepare('INSERT INTO system_configs (config_group, config_key, config_value, remark, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'); + $stmt->execute(['h5', 'page_base_url', '', '后台系统配置', $now, $now]); + echo "ADD_CONFIG h5.page_base_url\n"; +} + +echo "SCHEMA_UPGRADE_OK\n"; diff --git a/server-api/tools/schema_upgrade_materials.php b/server-api/tools/schema_upgrade_materials.php new file mode 100644 index 0000000..14a647f --- /dev/null +++ b/server-api/tools/schema_upgrade_materials.php @@ -0,0 +1,181 @@ +safeLoad(); + +$dsn = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=%s', + $_ENV['DB_HOST'] ?? '127.0.0.1', + $_ENV['DB_PORT'] ?? '3306', + $_ENV['DB_DATABASE'] ?? '', + $_ENV['DB_CHARSET'] ?? 'utf8mb4' +); + +$pdo = new PDO( + $dsn, + $_ENV['DB_USERNAME'] ?? '', + $_ENV['DB_PASSWORD'] ?? '', + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] +); + +function hasTable(PDO $pdo, string $table): bool +{ + $stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'); + $stmt->execute([$table]); + return (int)$stmt->fetchColumn() > 0; +} + +function hasPermission(PDO $pdo, string $code): bool +{ + $stmt = $pdo->prepare('SELECT COUNT(*) FROM admin_permissions WHERE code = ?'); + $stmt->execute([$code]); + return (int)$stmt->fetchColumn() > 0; +} + +if (!hasTable($pdo, 'material_batches')) { + $pdo->exec(<<<'SQL' +CREATE TABLE material_batches ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + batch_no VARCHAR(64) NOT NULL, + total_count INT NOT NULL DEFAULT 0, + remark VARCHAR(500) NOT NULL DEFAULT '', + download_count INT NOT NULL DEFAULT 0, + last_downloaded_at DATETIME NULL DEFAULT NULL, + created_by BIGINT UNSIGNED NULL DEFAULT NULL, + created_by_name VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_material_batches_batch_no (batch_no), + KEY idx_material_batches_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物料二维码批次' +SQL); + echo "CREATE_TABLE material_batches\n"; +} + +if (!hasTable($pdo, 'material_tag_codes')) { + $pdo->exec(<<<'SQL' +CREATE TABLE material_tag_codes ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + batch_id BIGINT UNSIGNED NOT NULL, + qr_token VARCHAR(80) NOT NULL, + qr_url VARCHAR(500) NOT NULL, + verify_code VARCHAR(16) NOT NULL, + bind_status VARCHAR(32) NOT NULL DEFAULT 'unbound', + report_id BIGINT UNSIGNED NULL DEFAULT NULL, + report_no VARCHAR(64) NOT NULL DEFAULT '', + bound_task_id BIGINT UNSIGNED NULL DEFAULT NULL, + bound_order_id BIGINT UNSIGNED NULL DEFAULT NULL, + bound_by BIGINT UNSIGNED NULL DEFAULT NULL, + bound_by_name VARCHAR(64) NOT NULL DEFAULT '', + bound_at DATETIME NULL DEFAULT NULL, + scan_count INT NOT NULL DEFAULT 0, + verify_count INT NOT NULL DEFAULT 0, + last_scanned_at DATETIME NULL DEFAULT NULL, + last_verified_at DATETIME NULL DEFAULT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_material_tag_codes_qr_token (qr_token), + UNIQUE KEY uk_material_tag_codes_qr_url (qr_url), + UNIQUE KEY uk_material_tag_codes_report_id (report_id), + KEY idx_material_tag_codes_batch_id (batch_id), + KEY idx_material_tag_codes_verify_code (verify_code), + KEY idx_material_tag_codes_report_no (report_no), + KEY idx_material_tag_codes_bind_status (bind_status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物料吊牌二维码' +SQL); + echo "CREATE_TABLE material_tag_codes\n"; +} + +if (!hasTable($pdo, 'material_batch_download_logs')) { + $pdo->exec(<<<'SQL' +CREATE TABLE material_batch_download_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + batch_id BIGINT UNSIGNED NOT NULL, + operator_id BIGINT UNSIGNED NULL DEFAULT NULL, + operator_name VARCHAR(64) NOT NULL DEFAULT '', + ip VARCHAR(64) NOT NULL DEFAULT '', + user_agent VARCHAR(500) NOT NULL DEFAULT '', + downloaded_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_material_batch_download_logs_batch_id (batch_id), + KEY idx_material_batch_download_logs_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物料批次下载日志' +SQL); + echo "CREATE_TABLE material_batch_download_logs\n"; +} + +if (!hasTable($pdo, 'material_tag_scan_logs')) { + $pdo->exec(<<<'SQL' +CREATE TABLE material_tag_scan_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + tag_code_id BIGINT UNSIGNED NOT NULL, + batch_id BIGINT UNSIGNED NOT NULL, + report_id BIGINT UNSIGNED NULL DEFAULT NULL, + report_no VARCHAR(64) NOT NULL DEFAULT '', + verify_type VARCHAR(32) NOT NULL DEFAULT 'scan', + verify_code_input VARCHAR(16) NOT NULL DEFAULT '', + verify_passed TINYINT(1) NOT NULL DEFAULT 0, + ip VARCHAR(64) NOT NULL DEFAULT '', + user_agent VARCHAR(500) NOT NULL DEFAULT '', + scanned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_material_tag_scan_logs_tag_code_id (tag_code_id), + KEY idx_material_tag_scan_logs_batch_id (batch_id), + KEY idx_material_tag_scan_logs_report_id (report_id), + KEY idx_material_tag_scan_logs_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='物料吊牌扫码与验真日志' +SQL); + echo "CREATE_TABLE material_tag_scan_logs\n"; +} + +$now = date('Y-m-d H:i:s'); +if (!hasPermission($pdo, 'materials.manage')) { + $stmt = $pdo->prepare('INSERT INTO admin_permissions (name, code, module, action, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'); + $stmt->execute(['管理物料', 'materials.manage', 'materials', 'manage', $now, $now]); + echo "ADD_PERMISSION materials.manage\n"; +} + +$permissionId = (int)$pdo->query("SELECT id FROM admin_permissions WHERE code = 'materials.manage'")->fetchColumn(); +$superRoleId = (int)$pdo->query("SELECT id FROM admin_roles WHERE code = 'super_admin'")->fetchColumn(); +if ($permissionId > 0 && $superRoleId > 0) { + $stmt = $pdo->prepare('SELECT COUNT(*) FROM admin_role_permissions WHERE role_id = ? AND permission_id = ?'); + $stmt->execute([$superRoleId, $permissionId]); + if ((int)$stmt->fetchColumn() === 0) { + $insert = $pdo->prepare('INSERT INTO admin_role_permissions (role_id, permission_id, created_at) VALUES (?, ?, ?)'); + $insert->execute([$superRoleId, $permissionId, $now]); + echo "ADD_SUPER_ADMIN_PERMISSION materials.manage\n"; + } +} + +$stmt = $pdo->prepare('SELECT id FROM admin_roles WHERE code = ?'); +$stmt->execute(['material_manager']); +$materialRoleId = (int)$stmt->fetchColumn(); +if ($materialRoleId <= 0) { + $insert = $pdo->prepare('INSERT INTO admin_roles (name, code, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'); + $insert->execute(['物料管理员', 'material_manager', 'enabled', $now, $now]); + $materialRoleId = (int)$pdo->lastInsertId(); + echo "ADD_ROLE material_manager\n"; +} + +if ($materialRoleId > 0 && $permissionId > 0) { + $stmt = $pdo->prepare('SELECT COUNT(*) FROM admin_role_permissions WHERE role_id = ? AND permission_id = ?'); + $stmt->execute([$materialRoleId, $permissionId]); + if ((int)$stmt->fetchColumn() === 0) { + $insert = $pdo->prepare('INSERT INTO admin_role_permissions (role_id, permission_id, created_at) VALUES (?, ?, ?)'); + $insert->execute([$materialRoleId, $permissionId, $now]); + echo "ADD_MATERIAL_MANAGER_PERMISSION materials.manage\n"; + } +} + +echo "SCHEMA_UPGRADE_OK\n"; diff --git a/server-api/tools/schema_upgrade_order_return_flow.php b/server-api/tools/schema_upgrade_order_return_flow.php new file mode 100644 index 0000000..535eb7b --- /dev/null +++ b/server-api/tools/schema_upgrade_order_return_flow.php @@ -0,0 +1,47 @@ +safeLoad(); + +$dsn = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=%s', + $_ENV['DB_HOST'] ?? '127.0.0.1', + $_ENV['DB_PORT'] ?? '3306', + $_ENV['DB_DATABASE'] ?? '', + $_ENV['DB_CHARSET'] ?? 'utf8mb4' +); + +$pdo = new PDO( + $dsn, + $_ENV['DB_USERNAME'] ?? '', + $_ENV['DB_PASSWORD'] ?? '', + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] +); + +$pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS order_return_addresses ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + user_address_id BIGINT UNSIGNED NULL DEFAULT NULL, + consignee VARCHAR(64) NOT NULL DEFAULT '', + mobile VARCHAR(32) NOT NULL DEFAULT '', + province VARCHAR(64) NOT NULL DEFAULT '', + city VARCHAR(64) NOT NULL DEFAULT '', + district VARCHAR(64) NOT NULL DEFAULT '', + detail_address VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_order_return_addresses_order_id (order_id), + KEY idx_order_return_addresses_user_address_id (user_address_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单寄回地址快照' +SQL); + +echo "SCHEMA_UPGRADE_OK\n"; diff --git a/server-api/tools/schema_upgrade_order_shipping_targets.php b/server-api/tools/schema_upgrade_order_shipping_targets.php new file mode 100644 index 0000000..a727eb9 --- /dev/null +++ b/server-api/tools/schema_upgrade_order_shipping_targets.php @@ -0,0 +1,52 @@ +safeLoad(); + +$dsn = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=%s', + $_ENV['DB_HOST'] ?? '127.0.0.1', + $_ENV['DB_PORT'] ?? '3306', + $_ENV['DB_DATABASE'] ?? '', + $_ENV['DB_CHARSET'] ?? 'utf8mb4' +); + +$pdo = new PDO( + $dsn, + $_ENV['DB_USERNAME'] ?? '', + $_ENV['DB_PASSWORD'] ?? '', + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] +); + +$pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS order_shipping_targets ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + order_id BIGINT UNSIGNED NOT NULL, + warehouse_id BIGINT UNSIGNED NULL DEFAULT NULL, + warehouse_name VARCHAR(128) NOT NULL DEFAULT '', + warehouse_code VARCHAR(64) NOT NULL DEFAULT '', + service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan', + receiver_name VARCHAR(64) NOT NULL DEFAULT '', + receiver_mobile VARCHAR(32) NOT NULL DEFAULT '', + province VARCHAR(64) NOT NULL DEFAULT '', + city VARCHAR(64) NOT NULL DEFAULT '', + district VARCHAR(64) NOT NULL DEFAULT '', + detail_address VARCHAR(255) NOT NULL DEFAULT '', + service_time VARCHAR(128) NOT NULL DEFAULT '', + notice VARCHAR(500) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_order_shipping_targets_order_id (order_id), + KEY idx_order_shipping_targets_warehouse_id (warehouse_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='订单锁定仓库快照' +SQL); + +echo "SCHEMA_UPGRADE_OK\n"; diff --git a/server-api/tools/schema_upgrade_order_source_channel.php b/server-api/tools/schema_upgrade_order_source_channel.php new file mode 100644 index 0000000..82868fe --- /dev/null +++ b/server-api/tools/schema_upgrade_order_source_channel.php @@ -0,0 +1,61 @@ +safeLoad(); + +$dsn = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=%s', + $_ENV['DB_HOST'] ?? '127.0.0.1', + $_ENV['DB_PORT'] ?? '3306', + $_ENV['DB_DATABASE'] ?? '', + $_ENV['DB_CHARSET'] ?? 'utf8mb4' +); + +$pdo = new PDO( + $dsn, + $_ENV['DB_USERNAME'] ?? '', + $_ENV['DB_PASSWORD'] ?? '', + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] +); + +function hasColumn(PDO $pdo, string $table, string $column): bool +{ + $stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'); + $stmt->execute([$table, $column]); + return (int)$stmt->fetchColumn() > 0; +} + +function hasIndex(PDO $pdo, string $table, string $index): bool +{ + $stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND INDEX_NAME = ?'); + $stmt->execute([$table, $index]); + return (int)$stmt->fetchColumn() > 0; +} + +if (!hasColumn($pdo, 'orders', 'source_customer_id')) { + $pdo->exec("ALTER TABLE orders ADD COLUMN source_customer_id VARCHAR(64) NOT NULL DEFAULT '' AFTER source_channel"); + echo "ADD_COLUMN orders.source_customer_id\n"; +} + +$pdo->exec("ALTER TABLE orders MODIFY COLUMN source_channel VARCHAR(32) NOT NULL DEFAULT 'mini_program'"); + +if (!hasIndex($pdo, 'orders', 'idx_orders_source_channel')) { + $pdo->exec('ALTER TABLE orders ADD KEY idx_orders_source_channel (source_channel)'); + echo "ADD_INDEX orders.idx_orders_source_channel\n"; +} + +if (!hasIndex($pdo, 'orders', 'idx_orders_source_customer_id')) { + $pdo->exec('ALTER TABLE orders ADD KEY idx_orders_source_customer_id (source_customer_id)'); + echo "ADD_INDEX orders.idx_orders_source_customer_id\n"; +} + +$pdo->exec("UPDATE orders SET source_channel = 'mini_program' WHERE source_channel = 'user_app'"); + +echo "SCHEMA_UPGRADE_OK\n"; diff --git a/server-api/tools/schema_upgrade_user_login_sms.php b/server-api/tools/schema_upgrade_user_login_sms.php new file mode 100644 index 0000000..f02264e --- /dev/null +++ b/server-api/tools/schema_upgrade_user_login_sms.php @@ -0,0 +1,123 @@ +safeLoad(); + +$dsn = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=%s', + $_ENV['DB_HOST'] ?? '127.0.0.1', + $_ENV['DB_PORT'] ?? '3306', + $_ENV['DB_DATABASE'] ?? '', + $_ENV['DB_CHARSET'] ?? 'utf8mb4' +); + +$pdo = new PDO( + $dsn, + $_ENV['DB_USERNAME'] ?? '', + $_ENV['DB_PASSWORD'] ?? '', + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] +); + +function hasColumn(PDO $pdo, string $table, string $column): bool +{ + $stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?'); + $stmt->execute([$table, $column]); + return (int)$stmt->fetchColumn() > 0; +} + +function hasTable(PDO $pdo, string $table): bool +{ + $stmt = $pdo->prepare('SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ?'); + $stmt->execute([$table]); + return (int)$stmt->fetchColumn() > 0; +} + +function hasSystemConfig(PDO $pdo, string $group, string $key): bool +{ + $stmt = $pdo->prepare('SELECT COUNT(*) FROM system_configs WHERE config_group = ? AND config_key = ?'); + $stmt->execute([$group, $key]); + return (int)$stmt->fetchColumn() > 0; +} + +$now = date('Y-m-d H:i:s'); + +if (!hasColumn($pdo, 'users', 'password')) { + $pdo->exec("ALTER TABLE users ADD COLUMN password VARCHAR(255) NOT NULL DEFAULT '' AFTER mobile"); + echo "ADD_COLUMN users.password\n"; +} + +if (!hasTable($pdo, 'user_api_tokens')) { + $pdo->exec(<<<'SQL' +CREATE TABLE user_api_tokens ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + user_id BIGINT UNSIGNED NOT NULL, + token_hash VARCHAR(64) NOT NULL, + auth_type VARCHAR(32) NOT NULL DEFAULT 'password', + expire_time DATETIME NOT NULL, + last_active_at DATETIME NULL DEFAULT NULL, + last_ip VARCHAR(64) NOT NULL DEFAULT '', + user_agent VARCHAR(500) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_user_api_tokens_token_hash (token_hash), + KEY idx_user_api_tokens_user_id (user_id), + KEY idx_user_api_tokens_expire_time (expire_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户登录Token' +SQL); + echo "CREATE_TABLE user_api_tokens\n"; +} + +if (!hasTable($pdo, 'sms_code_logs')) { + $pdo->exec(<<<'SQL' +CREATE TABLE sms_code_logs ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + mobile VARCHAR(32) NOT NULL, + scene VARCHAR(32) NOT NULL DEFAULT 'login', + code_hash VARCHAR(64) NOT NULL, + send_status VARCHAR(32) NOT NULL DEFAULT 'success', + provider VARCHAR(32) NOT NULL DEFAULT 'aliyun_sms', + template_code VARCHAR(64) NOT NULL DEFAULT '', + request_id VARCHAR(128) NOT NULL DEFAULT '', + biz_id VARCHAR(128) NOT NULL DEFAULT '', + failed_reason VARCHAR(255) NOT NULL DEFAULT '', + expire_time DATETIME NOT NULL, + used_at DATETIME NULL DEFAULT NULL, + send_ip VARCHAR(64) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + KEY idx_sms_code_logs_mobile_scene (mobile, scene), + KEY idx_sms_code_logs_expire_time (expire_time) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='短信验证码发送记录' +SQL); + echo "CREATE_TABLE sms_code_logs\n"; +} + +$configs = [ + ['sms', 'access_key_id', ''], + ['sms', 'access_key_secret', ''], + ['sms', 'sign_name', ''], + ['sms', 'login_template_code', ''], + ['sms', 'region_id', 'cn-hangzhou'], + ['sms', 'endpoint', ''], +]; + +foreach ($configs as [$group, $key, $value]) { + if (hasSystemConfig($pdo, $group, $key)) { + continue; + } + + $stmt = $pdo->prepare('INSERT INTO system_configs (config_group, config_key, config_value, remark, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)'); + $stmt->execute([$group, $key, $value, '后台系统配置', $now, $now]); + echo "ADD_CONFIG {$group}.{$key}\n"; +} + +echo "SCHEMA_UPGRADE_OK\n"; diff --git a/server-api/tools/schema_upgrade_warehouses.php b/server-api/tools/schema_upgrade_warehouses.php new file mode 100644 index 0000000..bdc53d2 --- /dev/null +++ b/server-api/tools/schema_upgrade_warehouses.php @@ -0,0 +1,80 @@ +safeLoad(); + +$dsn = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=%s', + $_ENV['DB_HOST'] ?? '127.0.0.1', + $_ENV['DB_PORT'] ?? '3306', + $_ENV['DB_DATABASE'] ?? '', + $_ENV['DB_CHARSET'] ?? 'utf8mb4' +); + +$pdo = new PDO( + $dsn, + $_ENV['DB_USERNAME'] ?? '', + $_ENV['DB_PASSWORD'] ?? '', + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] +); + +$pdo->exec(<<<'SQL' +CREATE TABLE IF NOT EXISTS shipping_warehouses ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + warehouse_name VARCHAR(128) NOT NULL DEFAULT '', + warehouse_code VARCHAR(64) NOT NULL DEFAULT '', + warehouse_type VARCHAR(32) NOT NULL DEFAULT 'detection_center', + service_provider VARCHAR(32) NOT NULL DEFAULT 'anxinyan', + receiver_name VARCHAR(64) NOT NULL DEFAULT '', + receiver_mobile VARCHAR(32) NOT NULL DEFAULT '', + province VARCHAR(64) NOT NULL DEFAULT '', + city VARCHAR(64) NOT NULL DEFAULT '', + district VARCHAR(64) NOT NULL DEFAULT '', + detail_address VARCHAR(255) NOT NULL DEFAULT '', + service_time VARCHAR(128) NOT NULL DEFAULT '', + notice VARCHAR(500) NOT NULL DEFAULT '', + supported_category_ids_json JSON NULL, + service_area_provinces_json JSON NULL, + service_area_cities_json JSON NULL, + status VARCHAR(32) NOT NULL DEFAULT 'enabled', + is_default TINYINT(1) NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0, + remark VARCHAR(255) NOT NULL DEFAULT '', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + UNIQUE KEY uk_shipping_warehouses_code (warehouse_code), + KEY idx_shipping_warehouses_service_provider (service_provider), + KEY idx_shipping_warehouses_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货仓库 / 检测中心' +SQL); + +$provinceColumn = $pdo->query("SHOW COLUMNS FROM shipping_warehouses LIKE 'service_area_provinces_json'")->fetch(); +if (!$provinceColumn) { + $pdo->exec("ALTER TABLE shipping_warehouses ADD COLUMN service_area_provinces_json JSON NULL AFTER supported_category_ids_json"); + echo "ADD_COLUMN shipping_warehouses.service_area_provinces_json\n"; +} + +$cityColumn = $pdo->query("SHOW COLUMNS FROM shipping_warehouses LIKE 'service_area_cities_json'")->fetch(); +if (!$cityColumn) { + $pdo->exec("ALTER TABLE shipping_warehouses ADD COLUMN service_area_cities_json JSON NULL AFTER service_area_provinces_json"); + echo "ADD_COLUMN shipping_warehouses.service_area_cities_json\n"; +} + +$count = (int)$pdo->query('SELECT COUNT(*) FROM shipping_warehouses')->fetchColumn(); +if ($count === 0) { + $now = date('Y-m-d H:i:s'); + $stmt = $pdo->prepare('INSERT INTO shipping_warehouses (warehouse_name, warehouse_code, warehouse_type, service_provider, receiver_name, receiver_mobile, province, city, district, detail_address, service_time, notice, supported_category_ids_json, status, is_default, sort_order, remark, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?)'); + $stmt->execute(['安心验鉴定中心', 'AXY-WH-DEFAULT', 'detection_center', 'anxinyan', '安心验鉴定中心', '400-800-1314', '广东省', '深圳市', '南山区', '科技园鉴定路 88 号 安心验收件中心', '周一至周日 09:30-18:30', '寄送前请确认订单信息完整,包裹内附上订单号可提升签收后的处理效率。', 'enabled', 1, 1, '默认仓库', $now, $now]); + $stmt->execute(['中检合作鉴定中心', 'ZJ-WH-DEFAULT', 'detection_center', 'zhongjian', '中检合作鉴定中心', '400-800-1314', '广东省', '深圳市', '南山区', '科技园鉴定路 88 号 安心验中检收件中心', '周一至周日 09:30-18:30', '中检鉴定订单请优先附上鉴定单号,寄出后尽快填写运单号。', 'enabled', 1, 1, '默认仓库', $now, $now]); + echo "SEED_DEFAULT_WAREHOUSES\n"; +} + +echo "SCHEMA_UPGRADE_OK\n"; diff --git a/server-api/tools/smoke_check.php b/server-api/tools/smoke_check.php new file mode 100644 index 0000000..e5f726a --- /dev/null +++ b/server-api/tools/smoke_check.php @@ -0,0 +1,151 @@ +safeLoad(); + +$baseUrl = 'http://127.0.0.1:8787'; + +function requestJson(string $method, string $url, array $payload = [], array $headers = []): array +{ + $ch = curl_init(); + $defaultHeaders = ['Accept: application/json']; + if ($payload) { + $defaultHeaders[] = 'Content-Type: application/json'; + } + foreach ($headers as $header) { + $defaultHeaders[] = $header; + } + + curl_setopt_array($ch, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 10, + CURLOPT_CUSTOMREQUEST => $method, + CURLOPT_HTTPHEADER => $defaultHeaders, + ]); + + if ($payload) { + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE)); + } + + $response = curl_exec($ch); + $errno = curl_errno($ch); + $error = curl_error($ch); + $httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + if ($errno) { + throw new RuntimeException("HTTP {$method} {$url} failed: {$error}"); + } + + $decoded = json_decode((string)$response, true); + return [ + 'status' => $httpCode, + 'body' => is_array($decoded) ? $decoded : ['raw' => $response], + ]; +} + +function assertOk(string $label, array $response): void +{ + $status = $response['status']; + $body = $response['body']; + $code = $body['code'] ?? null; + if ($status !== 200 || $code !== 0) { + throw new RuntimeException("{$label} failed: http={$status} body=" . json_encode($body, JSON_UNESCAPED_UNICODE)); + } + echo "[PASS] {$label}\n"; +} + +try { + assertOk('app home', requestJson('GET', $baseUrl . '/api/app/home/index')); + assertOk('app help center', requestJson('GET', $baseUrl . '/api/app/help-center')); + + $appLogin = requestJson('POST', $baseUrl . '/api/app/auth/login/password', [ + 'mobile' => '13800000000', + 'password' => 'User@123456', + ]); + assertOk('app login', $appLogin); + $appToken = $appLogin['body']['data']['token'] ?? ''; + if (!$appToken) { + throw new RuntimeException('app login did not return token'); + } + $appAuthHeader = ['Authorization: Bearer ' . $appToken]; + + assertOk('app me', requestJson('GET', $baseUrl . '/api/app/auth/me', [], $appAuthHeader)); + $appReports = requestJson('GET', $baseUrl . '/api/app/reports', [], $appAuthHeader); + assertOk('app reports', $appReports); + $appOrders = requestJson('GET', $baseUrl . '/api/app/orders', [], $appAuthHeader); + assertOk('app orders', $appOrders); + assertOk('app messages summary', requestJson('GET', $baseUrl . '/api/app/messages/summary', [], $appAuthHeader)); + assertOk('app addresses', requestJson('GET', $baseUrl . '/api/app/addresses', [], $appAuthHeader)); + assertOk('app settings', requestJson('GET', $baseUrl . '/api/app/settings', [], $appAuthHeader)); + + $completedOrder = null; + foreach (($appOrders['body']['data']['list'] ?? []) as $item) { + if (($item['order_status'] ?? '') === 'completed') { + $completedOrder = $item; + break; + } + } + if ($completedOrder) { + $orderDetail = requestJson('GET', $baseUrl . '/api/app/order/detail?id=' . (int)$completedOrder['order_id'], [], $appAuthHeader); + assertOk('app order detail completed', $orderDetail); + } + + $reportNo = $appReports['body']['data']['list'][0]['report_no'] ?? ''; + if ($reportNo !== '') { + $reportDetail = requestJson('GET', $baseUrl . '/api/app/report/detail?report_no=' . rawurlencode($reportNo)); + assertOk('app public report detail', $reportDetail); + $verifyQr = $reportDetail['body']['data']['verify_info']['verify_qrcode_url'] ?? ''; + if ($verifyQr === '') { + throw new RuntimeException('app public report detail missing verify_qrcode_url'); + } + assertOk('app public verify', requestJson('GET', $baseUrl . '/api/app/verify?report_no=' . rawurlencode($reportNo))); + } + + $appLogout = requestJson('POST', $baseUrl . '/api/app/auth/logout', [], $appAuthHeader); + assertOk('app logout', $appLogout); + + $login = requestJson('POST', $baseUrl . '/api/admin/auth/login', [ + 'mobile' => '13800138000', + 'password' => 'Anxinyan@2026!', + ]); + assertOk('admin login', $login); + $token = $login['body']['data']['token'] ?? ''; + if (!$token) { + throw new RuntimeException('admin login did not return token'); + } + $authHeader = ['Authorization: Bearer ' . $token]; + + assertOk('admin me', requestJson('GET', $baseUrl . '/api/admin/auth/me', [], $authHeader)); + assertOk('admin dashboard', requestJson('GET', $baseUrl . '/api/admin/dashboard', [], $authHeader)); + assertOk('admin users overview', requestJson('GET', $baseUrl . '/api/admin/users/overview', [], $authHeader)); + assertOk('admin access overview', requestJson('GET', $baseUrl . '/api/admin/access/overview', [], $authHeader)); + assertOk('admin system configs', requestJson('GET', $baseUrl . '/api/admin/system-configs', [], $authHeader)); + + $adminOrders = requestJson('GET', $baseUrl . '/api/admin/orders', [], $authHeader); + assertOk('admin orders', $adminOrders); + $adminCompletedOrder = null; + foreach (($adminOrders['body']['data']['list'] ?? []) as $item) { + if (($item['order_status'] ?? '') === 'completed') { + $adminCompletedOrder = $item; + break; + } + } + if ($adminCompletedOrder) { + $adminOrderDetail = requestJson('GET', $baseUrl . '/api/admin/order/detail?id=' . (int)$adminCompletedOrder['id'], [], $authHeader); + assertOk('admin order detail completed', $adminOrderDetail); + } + + $logout = requestJson('POST', $baseUrl . '/api/admin/auth/logout', [], $authHeader); + assertOk('admin logout', $logout); + + echo "SMOKE_OK\n"; +} catch (Throwable $e) { + fwrite(STDERR, "SMOKE_FAIL: " . $e->getMessage() . "\n"); + exit(1); +} diff --git a/server-api/tools/sync_client_configs.php b/server-api/tools/sync_client_configs.php new file mode 100644 index 0000000..a515e4d --- /dev/null +++ b/server-api/tools/sync_client_configs.php @@ -0,0 +1,73 @@ +safeLoad(); + +$projectRoot = dirname(__DIR__, 2); +$manifestPath = $projectRoot . '/user-app/src/manifest.json'; + +$dsn = sprintf( + 'mysql:host=%s;port=%s;dbname=%s;charset=%s', + $_ENV['DB_HOST'] ?? '127.0.0.1', + $_ENV['DB_PORT'] ?? '3306', + $_ENV['DB_DATABASE'] ?? '', + $_ENV['DB_CHARSET'] ?? 'utf8mb4' +); + +try { + $pdo = new PDO( + $dsn, + $_ENV['DB_USERNAME'] ?? '', + $_ENV['DB_PASSWORD'] ?? '', + [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, + ] + ); + + $configRows = $pdo->query("SELECT config_group, config_key, config_value FROM system_configs")->fetchAll(); + $configMap = []; + foreach ($configRows as $row) { + $configMap[$row['config_group'] . '.' . $row['config_key']] = (string)($row['config_value'] ?? ''); + } + + $miniProgramAppId = trim($configMap['mini_program.app_id'] ?? ''); + if ($miniProgramAppId === '') { + throw new RuntimeException('后台系统配置 mini_program.app_id 为空,无法同步到 user-app manifest。'); + } + + $manifestContent = @file_get_contents($manifestPath); + if ($manifestContent === false) { + throw new RuntimeException('无法读取 user-app/src/manifest.json。'); + } + + $pattern = '/("mp-weixin"\s*:\s*\{.*?"appid"\s*:\s*")([^"]*)(")/s'; + if (!preg_match($pattern, $manifestContent)) { + throw new RuntimeException('未在 manifest.json 中找到 mp-weixin.appid 字段。'); + } + + $updatedContent = preg_replace_callback( + $pattern, + static fn(array $matches): string => $matches[1] . $miniProgramAppId . $matches[3], + $manifestContent, + 1 + ); + + if (!is_string($updatedContent) || $updatedContent === '') { + throw new RuntimeException('同步 manifest.json 失败。'); + } + + if (@file_put_contents($manifestPath, $updatedContent) === false) { + throw new RuntimeException('写入 user-app/src/manifest.json 失败。'); + } + + echo "SYNC_CLIENT_CONFIG_OK\n"; + echo "mini_program.app_id => {$miniProgramAppId}\n"; +} catch (Throwable $e) { + fwrite(STDERR, "SYNC_CLIENT_CONFIG_FAIL: {$e->getMessage()}\n"); + exit(1); +} diff --git a/server-api/windows.bat b/server-api/windows.bat new file mode 100644 index 0000000..f07ce53 --- /dev/null +++ b/server-api/windows.bat @@ -0,0 +1,3 @@ +CHCP 65001 +php windows.php +pause \ No newline at end of file diff --git a/server-api/windows.php b/server-api/windows.php new file mode 100644 index 0000000..f37a72c --- /dev/null +++ b/server-api/windows.php @@ -0,0 +1,136 @@ +load(); + } else { + Dotenv::createMutable(base_path())->load(); + } +} + +App::loadAllConfig(['route']); + +$errorReporting = config('app.error_reporting'); +if (isset($errorReporting)) { + error_reporting($errorReporting); +} + +$runtimeProcessPath = runtime_path() . DIRECTORY_SEPARATOR . '/windows'; +$paths = [ + $runtimeProcessPath, + runtime_path('logs'), + runtime_path('views') +]; +foreach ($paths as $path) { + if (!is_dir($path)) { + mkdir($path, 0777, true); + } +} + +$processFiles = []; +if (config('server.listen')) { + $processFiles[] = __DIR__ . DIRECTORY_SEPARATOR . 'start.php'; +} +foreach (config('process', []) as $processName => $config) { + $processFiles[] = write_process_file($runtimeProcessPath, $processName, ''); +} + +foreach (config('plugin', []) as $firm => $projects) { + foreach ($projects as $name => $project) { + if (!is_array($project)) { + continue; + } + foreach ($project['process'] ?? [] as $processName => $config) { + $processFiles[] = write_process_file($runtimeProcessPath, $processName, "$firm.$name"); + } + } + foreach ($projects['process'] ?? [] as $processName => $config) { + $processFiles[] = write_process_file($runtimeProcessPath, $processName, $firm); + } +} + +function write_process_file($runtimeProcessPath, $processName, $firm): string +{ + $processParam = $firm ? "plugin.$firm.$processName" : $processName; + $configParam = $firm ? "config('plugin.$firm.process')['$processName']" : "config('process')['$processName']"; + $fileContent = << true]); + if (!$resource) { + exit("Can not execute $cmd\r\n"); + } + return $resource; +} + +$resource = popen_processes($processFiles); +echo "\r\n"; +while (1) { + sleep(1); + if (!empty($monitor) && $monitor->checkAllFilesChange()) { + $status = proc_get_status($resource); + $pid = $status['pid']; + shell_exec("taskkill /F /T /PID $pid"); + proc_close($resource); + $resource = popen_processes($processFiles); + } +} diff --git a/user-app/.env.development b/user-app/.env.development new file mode 100644 index 0000000..a62ceac --- /dev/null +++ b/user-app/.env.development @@ -0,0 +1,3 @@ +VITE_API_BASE_URL=http://127.0.0.1:8787 +VITE_APP_ENV=development +VITE_APP_TITLE=安心验 diff --git a/user-app/.env.example b/user-app/.env.example new file mode 100644 index 0000000..a62ceac --- /dev/null +++ b/user-app/.env.example @@ -0,0 +1,3 @@ +VITE_API_BASE_URL=http://127.0.0.1:8787 +VITE_APP_ENV=development +VITE_APP_TITLE=安心验 diff --git a/user-app/.env.production b/user-app/.env.production new file mode 100644 index 0000000..66b0aa0 --- /dev/null +++ b/user-app/.env.production @@ -0,0 +1,3 @@ +VITE_API_BASE_URL=https://api.anxinjianyan.com +VITE_APP_ENV=production +VITE_APP_TITLE=安心验 diff --git a/user-app/.env.test b/user-app/.env.test new file mode 100644 index 0000000..06ee918 --- /dev/null +++ b/user-app/.env.test @@ -0,0 +1,3 @@ +VITE_API_BASE_URL=https://test-api.example.com +VITE_APP_ENV=test +VITE_APP_TITLE=安心验 diff --git a/user-app/.gitignore b/user-app/.gitignore new file mode 100644 index 0000000..7ce6e50 --- /dev/null +++ b/user-app/.gitignore @@ -0,0 +1,21 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.DS_Store +dist +*.local + +# Editor directories and files +.idea +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? \ No newline at end of file diff --git a/user-app/index.html b/user-app/index.html new file mode 100644 index 0000000..f8a54cb --- /dev/null +++ b/user-app/index.html @@ -0,0 +1,20 @@ + + + + + + + + + + +
+ + + diff --git a/user-app/package-lock.json b/user-app/package-lock.json new file mode 100644 index 0000000..c79e683 --- /dev/null +++ b/user-app/package-lock.json @@ -0,0 +1,8959 @@ +{ + "name": "uni-preset-vue", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "uni-preset-vue", + "version": "0.0.0", + "dependencies": { + "@dcloudio/uni-app": "3.0.0-4080420251103001", + "@dcloudio/uni-app-harmony": "3.0.0-4080420251103001", + "@dcloudio/uni-app-plus": "3.0.0-4080420251103001", + "@dcloudio/uni-components": "3.0.0-4080420251103001", + "@dcloudio/uni-h5": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-alipay": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-baidu": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-harmony": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-jd": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-kuaishou": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-lark": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-qq": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-toutiao": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-weixin": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-xhs": "3.0.0-4080420251103001", + "@dcloudio/uni-quickapp-webview": "3.0.0-4080420251103001", + "pinia": "^2.1.7", + "vue": "^3.4.21", + "vue-i18n": "^9.1.9" + }, + "devDependencies": { + "@dcloudio/types": "^3.4.8", + "@dcloudio/uni-automator": "3.0.0-4080420251103001", + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-stacktracey": "3.0.0-4080420251103001", + "@dcloudio/vite-plugin-uni": "3.0.0-4080420251103001", + "@vue/runtime-core": "^3.4.21", + "@vue/tsconfig": "^0.1.3", + "sass": "^1.99.0", + "typescript": "^4.9.4", + "vite": "5.2.8", + "vue-tsc": "^1.0.24" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.24.7", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.24.7.tgz", + "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", + "license": "MIT", + "dependencies": { + "@babel/highlight": "^7.24.7", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.25.2", + "resolved": "https://registry.npmmirror.com/@babel/core/-/core-7.25.2.tgz", + "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/helper-compilation-targets": "^7.25.2", + "@babel/helper-module-transforms": "^7.25.2", + "@babel/helpers": "^7.25.0", + "@babel/parser": "^7.25.0", + "@babel/template": "^7.25.0", + "@babel/traverse": "^7.25.2", + "@babel/types": "^7.25.2", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/generator/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.27.3", + "resolved": "https://registry.npmmirror.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.3" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz", + "integrity": "sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/traverse": "^7.28.6", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz", + "integrity": "sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "regexpu-core": "^6.3.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.8", + "resolved": "https://registry.npmmirror.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.8.tgz", + "integrity": "sha512-47UwBLPpQi1NoWzLuHNjRoHlYXMwIJoBf7MFou6viC/sIHWYygpvr0B6IAyh5sBdA2nr2LPIRww8lfaUVQINBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "debug": "^4.4.3", + "lodash.debounce": "^4.0.8", + "resolve": "^1.22.11" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/@babel/helper-define-polyfill-provider/node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmmirror.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz", + "integrity": "sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz", + "integrity": "sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz", + "integrity": "sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-wrap-function": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz", + "integrity": "sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.28.5", + "@babel/helper-optimise-call-expression": "^7.27.1", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz", + "integrity": "sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz", + "integrity": "sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.25.9", + "resolved": "https://registry.npmmirror.com/@babel/highlight/-/highlight-7.25.9.tgz", + "integrity": "sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "chalk": "^2.4.2", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.25.6", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.25.6.tgz", + "integrity": "sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.6" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", + "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", + "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", + "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", + "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-transform-optional-chaining": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz", + "integrity": "sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmmirror.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz", + "integrity": "sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz", + "integrity": "sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-meta": { + "version": "7.10.4", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", + "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.10.4" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-jsx": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.28.6.tgz", + "integrity": "sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.28.6.tgz", + "integrity": "sha512-+nDNmQye7nlnuuHDboPbGm00Vqg3oO8niRRL27/4LYHUsHYh0zJ1xWOz0uRwNFmM1Avzk8wZbc6rdiYhomzv/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz", + "integrity": "sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.29.0.tgz", + "integrity": "sha512-va0VdWro4zlBr2JsXC+ofCPB2iG12wPtVGTWFx2WLDOM3nYQZZIGP82qku2eW/JR83sD+k2k+CsNtyEbUqhU6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz", + "integrity": "sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-remap-async-to-generator": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", + "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz", + "integrity": "sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz", + "integrity": "sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz", + "integrity": "sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz", + "integrity": "sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-globals": "^7.28.0", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-replace-supers": "^7.28.6", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz", + "integrity": "sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/template": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.28.5", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz", + "integrity": "sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz", + "integrity": "sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", + "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-zBPcW2lFGxdiD8PUnPwJjag2J9otbcLQzvbiOzDxpYXyCuYX9agOwMPGn1prVH0a4qzhCKu24rlH4c1f7yA8rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", + "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-explicit-resource-management": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz", + "integrity": "sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz", + "integrity": "sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz", + "integrity": "sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz", + "integrity": "sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz", + "integrity": "sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/traverse": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz", + "integrity": "sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz", + "integrity": "sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz", + "integrity": "sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", + "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", + "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz", + "integrity": "sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.0.tgz", + "integrity": "sha512-PrujnVFbOdUpw4UHiVwKvKRLMMic8+eC0CuNlxjsyZUiBjhFdPsewdXCkveh2KqBA9/waD0W1b4hXSOBQJezpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", + "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-transforms": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.29.0.tgz", + "integrity": "sha512-1CZQA5KNAD6ZYQLPw7oi5ewtDNxH/2vuCh+6SmvgDfhumForvs8a1o9n0UrEoBD8HU4djO2yWngTQlXl1NDVEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", + "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz", + "integrity": "sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz", + "integrity": "sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz", + "integrity": "sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", + "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/helper-replace-supers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz", + "integrity": "sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz", + "integrity": "sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.27.7", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz", + "integrity": "sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz", + "integrity": "sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz", + "integrity": "sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", + "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.29.0.tgz", + "integrity": "sha512-FijqlqMA7DmRdg/aINBSs04y8XNTYw/lr1gJ2WsmBnnaNw1iS43EPkJW+zK7z65auG3AWRFXWj+NcTQwYptUog==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz", + "integrity": "sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", + "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz", + "integrity": "sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz", + "integrity": "sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz", + "integrity": "sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", + "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", + "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typescript": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.28.6.tgz", + "integrity": "sha512-0YWL2RFxOqEm9Efk5PvreamxPME8OyY0wM5wh5lHjF+VtVhdneCWGzZeSqzOfiobVqQaNCd2z0tQvnI9DaPWPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.27.3", + "@babel/helper-create-class-features-plugin": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", + "@babel/plugin-syntax-typescript": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", + "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz", + "integrity": "sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.27.1", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz", + "integrity": "sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz", + "integrity": "sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.28.5", + "@babel/helper-plugin-utils": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/preset-env/-/preset-env-7.29.2.tgz", + "integrity": "sha512-DYD23veRYGvBFhcTY1iUvJnDNpuqNd/BzBwCvzOTKUnJjKg5kpUBh3/u9585Agdkgj+QuygG7jLfOPWMa2KVNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-plugin-utils": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.28.5", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.27.1", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.27.1", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.27.1", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.28.6", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.28.6", + "@babel/plugin-syntax-import-attributes": "^7.28.6", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.27.1", + "@babel/plugin-transform-async-generator-functions": "^7.29.0", + "@babel/plugin-transform-async-to-generator": "^7.28.6", + "@babel/plugin-transform-block-scoped-functions": "^7.27.1", + "@babel/plugin-transform-block-scoping": "^7.28.6", + "@babel/plugin-transform-class-properties": "^7.28.6", + "@babel/plugin-transform-class-static-block": "^7.28.6", + "@babel/plugin-transform-classes": "^7.28.6", + "@babel/plugin-transform-computed-properties": "^7.28.6", + "@babel/plugin-transform-destructuring": "^7.28.5", + "@babel/plugin-transform-dotall-regex": "^7.28.6", + "@babel/plugin-transform-duplicate-keys": "^7.27.1", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-dynamic-import": "^7.27.1", + "@babel/plugin-transform-explicit-resource-management": "^7.28.6", + "@babel/plugin-transform-exponentiation-operator": "^7.28.6", + "@babel/plugin-transform-export-namespace-from": "^7.27.1", + "@babel/plugin-transform-for-of": "^7.27.1", + "@babel/plugin-transform-function-name": "^7.27.1", + "@babel/plugin-transform-json-strings": "^7.28.6", + "@babel/plugin-transform-literals": "^7.27.1", + "@babel/plugin-transform-logical-assignment-operators": "^7.28.6", + "@babel/plugin-transform-member-expression-literals": "^7.27.1", + "@babel/plugin-transform-modules-amd": "^7.27.1", + "@babel/plugin-transform-modules-commonjs": "^7.28.6", + "@babel/plugin-transform-modules-systemjs": "^7.29.0", + "@babel/plugin-transform-modules-umd": "^7.27.1", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.29.0", + "@babel/plugin-transform-new-target": "^7.27.1", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.28.6", + "@babel/plugin-transform-numeric-separator": "^7.28.6", + "@babel/plugin-transform-object-rest-spread": "^7.28.6", + "@babel/plugin-transform-object-super": "^7.27.1", + "@babel/plugin-transform-optional-catch-binding": "^7.28.6", + "@babel/plugin-transform-optional-chaining": "^7.28.6", + "@babel/plugin-transform-parameters": "^7.27.7", + "@babel/plugin-transform-private-methods": "^7.28.6", + "@babel/plugin-transform-private-property-in-object": "^7.28.6", + "@babel/plugin-transform-property-literals": "^7.27.1", + "@babel/plugin-transform-regenerator": "^7.29.0", + "@babel/plugin-transform-regexp-modifiers": "^7.28.6", + "@babel/plugin-transform-reserved-words": "^7.27.1", + "@babel/plugin-transform-shorthand-properties": "^7.27.1", + "@babel/plugin-transform-spread": "^7.28.6", + "@babel/plugin-transform-sticky-regex": "^7.27.1", + "@babel/plugin-transform-template-literals": "^7.27.1", + "@babel/plugin-transform-typeof-symbol": "^7.27.1", + "@babel/plugin-transform-unicode-escapes": "^7.27.1", + "@babel/plugin-transform-unicode-property-regex": "^7.28.6", + "@babel/plugin-transform-unicode-regex": "^7.27.1", + "@babel/plugin-transform-unicode-sets-regex": "^7.28.6", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.15", + "babel-plugin-polyfill-corejs3": "^0.14.0", + "babel-plugin-polyfill-regenerator": "^0.6.6", + "core-js-compat": "^3.48.0", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmmirror.com/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template/node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/traverse/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse/node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/@babel/types": { + "version": "7.25.6", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.25.6.tgz", + "integrity": "sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.24.8", + "@babel/helper-validator-identifier": "^7.24.7", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dcloudio/types": { + "version": "3.4.19", + "resolved": "https://registry.npmmirror.com/@dcloudio/types/-/types-3.4.19.tgz", + "integrity": "sha512-1foayOFEAQ+jnQLt3ACsovCNjer3/fXn1I2VBpmDOzs2nk/n4UHwRLAxZV/RpxRqaGOPEvKrO/Pq+VI6sAmuRw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@dcloudio/uni-app": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-app/-/uni-app-3.0.0-4080420251103001.tgz", + "integrity": "sha512-pzBWZiICfMmAxzBvAoXlTcDYoVNrV+ztsUyDouUxZJShpquQdVqHJqHxAlvGLR7c5gHCVtnKilCKwmu6zjNGrA==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cloud": "3.0.0-4080420251103001", + "@dcloudio/uni-components": "3.0.0-4080420251103001", + "@dcloudio/uni-console": "3.0.0-4080420251103001", + "@dcloudio/uni-i18n": "3.0.0-4080420251103001", + "@dcloudio/uni-push": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-stat": "3.0.0-4080420251103001", + "@vue/shared": "3.4.21" + }, + "peerDependencies": { + "@dcloudio/types": "3.4.19" + } + }, + "node_modules/@dcloudio/uni-app-harmony": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-app-harmony/-/uni-app-harmony-3.0.0-4080420251103001.tgz", + "integrity": "sha512-WjCLttxacAoRywMl3hhA2LGAso8NqqgupXbTvoWRyYF11tuSw2duUM7n58mZ3A/cI4SQk/y/x9MzoFXlDiYzAw==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-app-uts": "3.0.0-4080420251103001", + "@dcloudio/uni-app-vite": "3.0.0-4080420251103001", + "debug": "4.3.7", + "fs-extra": "10.1.0", + "licia": "1.41.1", + "postcss-selector-parser": "6.1.2" + } + }, + "node_modules/@dcloudio/uni-app-plus": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-app-plus/-/uni-app-plus-3.0.0-4080420251103001.tgz", + "integrity": "sha512-HbEpRIyJ4q6A+s+2WJsBvW/AlStyzBUHFkZbs/1vnTV5jjjvlT8e9Zb+2HptC95olZmD97QWcu31M5U/a6Z0PA==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-app-uts": "3.0.0-4080420251103001", + "@dcloudio/uni-app-vite": "3.0.0-4080420251103001", + "@dcloudio/uni-app-vue": "3.0.0-4080420251103001", + "debug": "4.3.7", + "fs-extra": "10.1.0", + "licia": "1.41.1", + "postcss-selector-parser": "6.1.2" + } + }, + "node_modules/@dcloudio/uni-app-uts": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-app-uts/-/uni-app-uts-3.0.0-4080420251103001.tgz", + "integrity": "sha512-YdKLXUY4Ix64ajRAv6JKiiSL5FZViDYuP83TXI5zaBSbw/fofzO1ZYRZKrQ2bZi51yH0Huq8u6MPvDIUQTLjJw==", + "license": "Apache-2.0", + "dependencies": { + "@babel/parser": "7.25.6", + "@babel/types": "7.25.6", + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-console": "3.0.0-4080420251103001", + "@dcloudio/uni-i18n": "3.0.0-4080420251103001", + "@dcloudio/uni-nvue-styler": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@jridgewell/gen-mapping": "^0.3.3", + "@jridgewell/trace-mapping": "^0.3.19", + "@rollup/pluginutils": "5.1.0", + "@vue/compiler-core": "3.4.21", + "@vue/compiler-dom": "3.4.21", + "@vue/compiler-sfc": "3.4.21", + "@vue/consolidate": "1.0.0", + "@vue/shared": "3.4.21", + "debug": "4.3.7", + "es-module-lexer": "1.5.4", + "estree-walker": "2.0.2", + "fast-glob": "3.3.3", + "fs-extra": "10.1.0", + "magic-string": "0.30.11", + "picocolors": "1.1.0", + "source-map-js": "1.2.1", + "unimport": "4.1.1" + } + }, + "node_modules/@dcloudio/uni-app-vite": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-app-vite/-/uni-app-vite-3.0.0-4080420251103001.tgz", + "integrity": "sha512-hti+d0OoT/B69ApgKj8SEgi2rGownaHSxgfZQx1AYmWiRXsc/uWEzC7lakvsRLV3aPiUPD0aJNuI97IgMGBvnw==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-i18n": "3.0.0-4080420251103001", + "@dcloudio/uni-nvue-styler": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@rollup/pluginutils": "5.1.0", + "@vitejs/plugin-vue": "5.2.4", + "@vue/compiler-dom": "3.4.21", + "@vue/compiler-sfc": "3.4.21", + "debug": "4.3.7", + "fs-extra": "10.1.0", + "picocolors": "1.1.0" + } + }, + "node_modules/@dcloudio/uni-app-vue": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-app-vue/-/uni-app-vue-3.0.0-4080420251103001.tgz", + "integrity": "sha512-nK3ORcnBUQQ4BwqCvAMNHOVtBevfs+iwT31SZDtV0HikWq2fF1O6ae1bmMQJ3de3/fCLvu+3ZYCE773+D3S9aQ==", + "license": "Apache-2.0" + }, + "node_modules/@dcloudio/uni-automator": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-automator/-/uni-automator-3.0.0-4080420251103001.tgz", + "integrity": "sha512-zAHmFiZxbP3PmuTh5lz16NDThfrm4MTkwN80ZLn+xlJl5vNqX5yMfqfDwJrEBNY7Wycfh+qB3EvwWQ8CFMpAxw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "address": "^1.1.2", + "cross-env": "^7.0.3", + "debug": "4.3.7", + "default-gateway": "^6.0.3", + "fs-extra": "10.1.0", + "jsonc-parser": "3.3.1", + "licia": "1.41.1", + "merge": "2.1.1", + "qrcode-reader": "1.0.4", + "qrcode-terminal": "0.12.0", + "ws": "8.18.0" + }, + "peerDependencies": { + "jest": "27.0.4", + "jest-environment-node": "27.5.1" + } + }, + "node_modules/@dcloudio/uni-cli-shared": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-cli-shared/-/uni-cli-shared-3.0.0-4080420251103001.tgz", + "integrity": "sha512-CI9gfUSrneTJFp52CBpAwDE9vsaxdwg4uA7n2ehKB+WLXgP2zmLp+0QoSQoFYISYll6MnfQK6Pcl8Oj1c7mKUA==", + "license": "Apache-2.0", + "dependencies": { + "@ampproject/remapping": "^2.1.2", + "@babel/code-frame": "7.24.7", + "@babel/core": "7.25.2", + "@babel/parser": "7.25.6", + "@babel/types": "7.25.6", + "@dcloudio/uni-i18n": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@intlify/core-base": "9.1.9", + "@intlify/shared": "9.1.9", + "@intlify/vue-devtools": "9.1.9", + "@rollup/pluginutils": "5.1.0", + "@vue/compiler-core": "3.4.21", + "@vue/compiler-dom": "3.4.21", + "@vue/compiler-sfc": "3.4.21", + "@vue/compiler-ssr": "3.4.21", + "@vue/server-renderer": "3.4.21", + "@vue/shared": "3.4.21", + "adm-zip": "0.5.16", + "autoprefixer": "10.4.20", + "base64url": "^3.0.1", + "chokidar": "3.6.0", + "compare-versions": "^3.6.0", + "debug": "4.3.7", + "entities": "^4.5.0", + "es-module-lexer": "1.5.4", + "esbuild": "0.20.2", + "estree-walker": "2.0.2", + "fast-glob": "3.3.3", + "fs-extra": "10.1.0", + "hash-sum": "2.0.0", + "isbinaryfile": "5.0.2", + "jsonc-parser": "3.3.1", + "lines-and-columns": "^2.0.4", + "magic-string": "0.30.11", + "merge": "2.1.1", + "mime": "3.0.0", + "module-alias": "2.2.3", + "os-locale-s-fix": "^1.0.8-fix-1", + "picocolors": "1.1.0", + "postcss-import": "^14.0.2", + "postcss-load-config": "^3.1.1", + "postcss-modules": "^4.3.0", + "postcss-selector-parser": "6.1.2", + "resolve": "1.22.8", + "source-map-js": "1.2.1", + "tapable": "^2.2.0", + "unimport": "4.1.1", + "unplugin-auto-import": "19.1.0", + "xregexp": "3.1.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + } + }, + "node_modules/@dcloudio/uni-cli-shared/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@dcloudio/uni-cli-shared/node_modules/unplugin-auto-import": { + "version": "19.1.0", + "resolved": "https://registry.npmmirror.com/unplugin-auto-import/-/unplugin-auto-import-19.1.0.tgz", + "integrity": "sha512-B+TGBEBHqY9aR+7YfShfLujETOHstzpV+yaqgy5PkfV0QG7Py+TYMX7vJ9W4SrysHR+UzR+gzcx/nuZjmPeclA==", + "license": "MIT", + "dependencies": { + "local-pkg": "^1.0.0", + "magic-string": "^0.30.17", + "picomatch": "^4.0.2", + "unimport": "^4.1.1", + "unplugin": "^2.2.0", + "unplugin-utils": "^0.2.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2", + "@vueuse/core": "*" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@vueuse/core": { + "optional": true + } + } + }, + "node_modules/@dcloudio/uni-cli-shared/node_modules/unplugin-auto-import/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@dcloudio/uni-cloud": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-cloud/-/uni-cloud-3.0.0-4080420251103001.tgz", + "integrity": "sha512-RQp+MkmrW/OxfaTbN1ohNnCcj7q55ub2F0pMAr5OCKHOV/sB4NhnwTKzB7C4B1Ha1oR8ulTAfmwUiBP89dSEmg==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-i18n": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@vue/shared": "3.4.21", + "fast-glob": "3.3.3" + } + }, + "node_modules/@dcloudio/uni-components": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-components/-/uni-components-3.0.0-4080420251103001.tgz", + "integrity": "sha512-YBsUWVx6OrJVR/59QU9H6QX+ulEB5GK+Fp3xZPU3NiVKczSAEsE2eBU3+xSYSyndUrP4BDqV1qhjt5cgpFJcsA==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cloud": "3.0.0-4080420251103001", + "@dcloudio/uni-h5": "3.0.0-4080420251103001", + "@dcloudio/uni-i18n": "3.0.0-4080420251103001" + } + }, + "node_modules/@dcloudio/uni-console": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-console/-/uni-console-3.0.0-4080420251103001.tgz", + "integrity": "sha512-bVdk8iVfld8mTUX8tB6keRm54htGMLOeJhuG1Cx8R8aOJfqTU8yuowf8ZIxIRsqs2dXV/5fHNGcbd/uFosDwDA==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "fs-extra": "10.1.0" + } + }, + "node_modules/@dcloudio/uni-h5": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-h5/-/uni-h5-3.0.0-4080420251103001.tgz", + "integrity": "sha512-Xl4bR2znjF6QJ2IHPen5eQiISpSWFZZaXXpFa0IyTf93xSBcSvsg9Nq5izBYPxkH4YXD/hiVHdbgwyHciq/cog==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-h5-vite": "3.0.0-4080420251103001", + "@dcloudio/uni-h5-vue": "3.0.0-4080420251103001", + "@dcloudio/uni-i18n": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@vue/server-renderer": "3.4.21", + "@vue/shared": "3.4.21", + "debug": "4.3.7", + "localstorage-polyfill": "^1.0.1", + "postcss-selector-parser": "6.1.2", + "safe-area-insets": "1.4.1", + "vue-router": "4.4.4", + "xmlhttprequest": "^1.8.0" + } + }, + "node_modules/@dcloudio/uni-h5-vite": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-h5-vite/-/uni-h5-vite-3.0.0-4080420251103001.tgz", + "integrity": "sha512-Z9wMRSI+v9aDVlvQYHPnv1gggYJk6WVMINvty/tg01bFXMkBs99TyjaT4XShPM46q8TVySd3lDRQOcY6cfUY1A==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@rollup/pluginutils": "5.1.0", + "@vue/compiler-dom": "3.4.21", + "@vue/compiler-sfc": "3.4.21", + "@vue/server-renderer": "3.4.21", + "@vue/shared": "3.4.21", + "debug": "4.3.7", + "fs-extra": "10.1.0", + "mime": "3.0.0", + "module-alias": "2.2.3" + } + }, + "node_modules/@dcloudio/uni-h5-vue": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-h5-vue/-/uni-h5-vue-3.0.0-4080420251103001.tgz", + "integrity": "sha512-NbQCQFbnXIOKdek/ntwh1NslnmrZbuYRrfbe2ZVZOz8gXKfaR7GEO/GPPbTpI7mw3+iM906msksBh9HBzsD5TA==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@vue/server-renderer": "3.4.21" + } + }, + "node_modules/@dcloudio/uni-i18n": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-i18n/-/uni-i18n-3.0.0-4080420251103001.tgz", + "integrity": "sha512-FHUQ8Ex0GbJsYxVZR1CrUoRc9Rm2OnfudCxpXIJSxpd1tp4sje8QhIeXwRIMEXcATc00hngTSgQrnuMuu5g72Q==", + "license": "Apache-2.0" + }, + "node_modules/@dcloudio/uni-mp-alipay": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-mp-alipay/-/uni-mp-alipay-3.0.0-4080420251103001.tgz", + "integrity": "sha512-cAfDeDAHEMVjwa3YgiREEewbx5iSkEu2qS7aueCvWNwKaz9u9vw7OnE+D31lNTzcW/jD+ESTgeKEL0tSH+1nyg==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vite": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vue": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@vue/compiler-core": "3.4.21", + "@vue/shared": "3.4.21" + } + }, + "node_modules/@dcloudio/uni-mp-baidu": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-mp-baidu/-/uni-mp-baidu-3.0.0-4080420251103001.tgz", + "integrity": "sha512-n/BzswNgJpD+XvMTHd8N7SosmIm2UPkvbV3ncPzGOcoW6kiF3OOh6jxYCf4fgPzj6RNf/avOyQv2QS0RgdYy1w==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-app": "3.0.0-4080420251103001", + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-compiler": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vite": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vue": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-weixin": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@vue/compiler-core": "3.4.21", + "@vue/shared": "3.4.21", + "jimp": "0.10.3", + "licia": "1.41.1", + "qrcode-reader": "1.0.4", + "qrcode-terminal": "0.12.0", + "ws": "8.18.0" + } + }, + "node_modules/@dcloudio/uni-mp-compiler": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-mp-compiler/-/uni-mp-compiler-3.0.0-4080420251103001.tgz", + "integrity": "sha512-tMyKHyUyNl/yy/6D7M66IVxJhkaieimL2F/ZprNP8fl1QWBZqBBxokxAVeFeGbX7f9Sd1Vi+HdAowQkMCNfdDw==", + "license": "Apache-2.0", + "dependencies": { + "@babel/generator": "7.25.6", + "@babel/parser": "7.25.6", + "@babel/types": "7.25.6", + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@vue/compiler-core": "3.4.21", + "@vue/compiler-dom": "3.4.21", + "@vue/shared": "3.4.21", + "estree-walker": "2.0.2" + } + }, + "node_modules/@dcloudio/uni-mp-compiler/node_modules/@babel/generator": { + "version": "7.25.6", + "resolved": "https://registry.npmmirror.com/@babel/generator/-/generator-7.25.6.tgz", + "integrity": "sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.6", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^2.5.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dcloudio/uni-mp-compiler/node_modules/jsesc": { + "version": "2.5.2", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-2.5.2.tgz", + "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@dcloudio/uni-mp-harmony": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-mp-harmony/-/uni-mp-harmony-3.0.0-4080420251103001.tgz", + "integrity": "sha512-/TYd3wijGxRktHWLPFguDQvsIC+tvqzKK1rcQoo3hgnSbjJiKUXBMLbnH1RH9fZNqVDqVMX/t07KrlnLE7K6IA==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-toutiao": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vite": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vue": "3.0.0-4080420251103001", + "@dcloudio/uni-quickapp-webview": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@vue/shared": "3.4.21" + } + }, + "node_modules/@dcloudio/uni-mp-jd": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-mp-jd/-/uni-mp-jd-3.0.0-4080420251103001.tgz", + "integrity": "sha512-jkLglxCUX45juoZGfeejYs3Don1TdUqpZ1Jdm9DEjJ9QV0vS48fyVdnWzl423bJ5IvIDYTh5DTfGh0epRgxw6Q==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-compiler": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vite": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vue": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@vue/shared": "3.4.21" + } + }, + "node_modules/@dcloudio/uni-mp-kuaishou": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-mp-kuaishou/-/uni-mp-kuaishou-3.0.0-4080420251103001.tgz", + "integrity": "sha512-7XH9qlfGlrPJr5nLReCVtVilH/W3dXxXUNwk2ISH8udTjRT1NB/LaBD9/TxvoY7k8+LplEFOpIq+PBKWImABPw==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-compiler": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vite": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vue": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-weixin": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@vue/compiler-core": "3.4.21", + "@vue/shared": "3.4.21" + } + }, + "node_modules/@dcloudio/uni-mp-lark": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-mp-lark/-/uni-mp-lark-3.0.0-4080420251103001.tgz", + "integrity": "sha512-VFj/IWul1aaDZaZig+9IooPg3v3Cu+jaBvklfkP58c6wQa0PvnzE3j9j6WhfbhZ7CUW/rTmC7cxqNjTe2hwPcQ==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-compiler": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-toutiao": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vite": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vue": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@vue/compiler-core": "3.4.21", + "@vue/shared": "3.4.21" + } + }, + "node_modules/@dcloudio/uni-mp-qq": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-mp-qq/-/uni-mp-qq-3.0.0-4080420251103001.tgz", + "integrity": "sha512-BBWL2wVTG1tv8PPmnaA/7Aae4grbPevEkapsYl6WAEkJDBv5AIissq/ltusEnXnYNKtU/ZB8GVnb//lLW3xa/A==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vite": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vue": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@vue/shared": "3.4.21", + "fs-extra": "10.1.0" + } + }, + "node_modules/@dcloudio/uni-mp-toutiao": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-mp-toutiao/-/uni-mp-toutiao-3.0.0-4080420251103001.tgz", + "integrity": "sha512-iuEfUje5sEn72kFiyhb1tqu1Qx8iuC4cbGTbtyD7P6MSozwzURJgISoZiFsx1EKYIgn3w8ZHL+oh6hXgysP+Lw==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-compiler": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vite": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vue": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@vue/compiler-core": "3.4.21", + "@vue/shared": "3.4.21" + } + }, + "node_modules/@dcloudio/uni-mp-vite": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-mp-vite/-/uni-mp-vite-3.0.0-4080420251103001.tgz", + "integrity": "sha512-/MQElW4cWS0nL1gofvpY0xV1XjAcF7zVrjYIFoiht1rEwW6ec64umKOI9YGMUKi+Gx8o2Qa4XTsqChlE/MzSFg==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-i18n": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-compiler": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vue": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@vue/compiler-dom": "3.4.21", + "@vue/compiler-sfc": "3.4.21", + "@vue/shared": "3.4.21", + "debug": "4.3.7" + } + }, + "node_modules/@dcloudio/uni-mp-vue": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-mp-vue/-/uni-mp-vue-3.0.0-4080420251103001.tgz", + "integrity": "sha512-H1xF0jLrN3WnN/xCWKy+74bSt7AHa8grz3Nw75u+77vmkuIzxiGpauUQ4FG6l2zBtiW3EM+gfcfFUMunTlHM4A==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@vue/shared": "3.4.21" + } + }, + "node_modules/@dcloudio/uni-mp-weixin": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-mp-weixin/-/uni-mp-weixin-3.0.0-4080420251103001.tgz", + "integrity": "sha512-AoaXYxNngQBjNQ6hJJ4Jzz6KDDEjYeEUs8idhX+dujUO1JgBGpWbE9gP66VdaPNmD7+aZ/1tK1fZG6JjuHW+8w==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vite": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vue": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@vue/shared": "3.4.21", + "jimp": "0.10.3", + "licia": "1.41.1", + "qrcode-reader": "1.0.4", + "qrcode-terminal": "0.12.0", + "ws": "8.18.0" + } + }, + "node_modules/@dcloudio/uni-mp-xhs": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-mp-xhs/-/uni-mp-xhs-3.0.0-4080420251103001.tgz", + "integrity": "sha512-Fl+gUYLhDOllrDkwkaR1rnGs+rwIbHzSJUZ/s6epocNhod1QetpzS5FBXwDryf898czjA9FcHteJotALXNkBjQ==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-compiler": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vite": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vue": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@vue/shared": "3.4.21" + } + }, + "node_modules/@dcloudio/uni-nvue-styler": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-nvue-styler/-/uni-nvue-styler-3.0.0-4080420251103001.tgz", + "integrity": "sha512-LXwlJyfusm/bIC2qceJuJm72XNQtRXt5REG/6p+mtEYum8l4qloFJ5za1TdQXRsECXPGz6rS8YqU8iDTe70vdw==", + "license": "Apache-2.0", + "dependencies": { + "parse-css-font": "^4.0.0", + "postcss": "8.4.45" + } + }, + "node_modules/@dcloudio/uni-nvue-styler/node_modules/postcss": { + "version": "8.4.45", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.4.45.tgz", + "integrity": "sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.0.1", + "source-map-js": "^1.2.0" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/@dcloudio/uni-push": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-push/-/uni-push-3.0.0-4080420251103001.tgz", + "integrity": "sha512-PZ766hB4OBRuHLNJ6umNNGo8WljC67LMFgpD7ORHa6+SmS+JfncMqXufWGsuh/Q/1/QPqe4Pcm8w1zG5Bke1vw==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001" + } + }, + "node_modules/@dcloudio/uni-quickapp-webview": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-quickapp-webview/-/uni-quickapp-webview-3.0.0-4080420251103001.tgz", + "integrity": "sha512-/hn2zxUnUQOkkj37QqsQL5f7ffQ8EBm/27FrO8lYx7wGyRaehcuXhJ/vHWPRpJv9tq4Cl8w0iidrVsYPchu2pw==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vite": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-vue": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@vue/shared": "3.4.21" + } + }, + "node_modules/@dcloudio/uni-shared": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-shared/-/uni-shared-3.0.0-4080420251103001.tgz", + "integrity": "sha512-+EfZmMVToYOaKBkzXix/LMGosZaFiFUyZ1vnGg3giLEcr5r8EYQ/NQ3GfthcD6pC9A114imv5Az+4HeKhcKZiw==", + "license": "Apache-2.0", + "dependencies": { + "@vue/shared": "3.4.21" + } + }, + "node_modules/@dcloudio/uni-stacktracey": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-stacktracey/-/uni-stacktracey-3.0.0-4080420251103001.tgz", + "integrity": "sha512-coPXA+6PbbSWZx8NDCyNnkSfUx9FusVB6lhxZ+aYKYJkbl/28pRJAM9oCeGV3sZHpjCYyOZaBzxv0kQ0/O7W8A==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@dcloudio/uni-stat": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/uni-stat/-/uni-stat-3.0.0-4080420251103001.tgz", + "integrity": "sha512-CCADC5plW/etPbhORExii2pJ6m337YeHB+V1R7w6JBmFdGqqYj7+3Vq24wWSSECc4hpiZFLk3dNzu5/NNw2p1Q==", + "license": "Apache-2.0", + "dependencies": { + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "debug": "4.3.7" + } + }, + "node_modules/@dcloudio/vite-plugin-uni": { + "version": "3.0.0-4080420251103001", + "resolved": "https://registry.npmmirror.com/@dcloudio/vite-plugin-uni/-/vite-plugin-uni-3.0.0-4080420251103001.tgz", + "integrity": "sha512-r9UOGDrHqvOV2p9IW+4wql8BJJjKgPRWH9Y0rkztp7GjvtUqCkkoVwlh5sJXYwqPNZL5JWPuj0Cl+ZtTg1njyw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@babel/core": "7.25.2", + "@babel/plugin-syntax-import-meta": "^7.10.4", + "@babel/plugin-transform-typescript": "^7.23.3", + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-nvue-styler": "3.0.0-4080420251103001", + "@dcloudio/uni-shared": "3.0.0-4080420251103001", + "@rollup/pluginutils": "5.1.0", + "@vitejs/plugin-legacy": "5.3.2", + "@vitejs/plugin-vue": "5.2.4", + "@vitejs/plugin-vue-jsx": "3.1.0", + "@vue/compiler-core": "3.4.21", + "@vue/compiler-dom": "3.4.21", + "@vue/compiler-sfc": "3.4.21", + "@vue/shared": "3.4.21", + "cac": "6.7.9", + "debug": "4.3.7", + "estree-walker": "2.0.2", + "express": "4.20.0", + "fast-glob": "3.3.3", + "fs-extra": "10.1.0", + "hash-sum": "2.0.0", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.11", + "picocolors": "1.1.0", + "terser": "^5.4.0", + "unplugin-auto-import": "19.1.0" + }, + "bin": { + "uni": "bin/uni.js" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "5.2.8" + } + }, + "node_modules/@dcloudio/vite-plugin-uni/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@dcloudio/vite-plugin-uni/node_modules/unplugin-auto-import": { + "version": "19.1.0", + "resolved": "https://registry.npmmirror.com/unplugin-auto-import/-/unplugin-auto-import-19.1.0.tgz", + "integrity": "sha512-B+TGBEBHqY9aR+7YfShfLujETOHstzpV+yaqgy5PkfV0QG7Py+TYMX7vJ9W4SrysHR+UzR+gzcx/nuZjmPeclA==", + "dev": true, + "license": "MIT", + "dependencies": { + "local-pkg": "^1.0.0", + "magic-string": "^0.30.17", + "picomatch": "^4.0.2", + "unimport": "^4.1.1", + "unplugin": "^2.2.0", + "unplugin-utils": "^0.2.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@nuxt/kit": "^3.2.2", + "@vueuse/core": "*" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "@vueuse/core": { + "optional": true + } + } + }, + "node_modules/@dcloudio/vite-plugin-uni/node_modules/unplugin-auto-import/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.20.2.tgz", + "integrity": "sha512-D+EBOJHXdNZcLJRBkhENNG8Wji2kgc9AZ9KiPr1JuZjsNtyHzrsfLRrY0tk2H2aoFu6RANO1y1iPPUCDYWkb5g==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.20.2.tgz", + "integrity": "sha512-t98Ra6pw2VaDhqNWO2Oph2LXbz/EJcnLmKLGBJwEwXX/JAN83Fym1rU8l0JUWK6HkIbWONCSSatf4sf2NBRx/w==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.20.2.tgz", + "integrity": "sha512-mRzjLacRtl/tWU0SvD8lUEwb61yP9cqQo6noDZP/O8VkwafSYwZ4yWy24kan8jE/IMERpYncRt2dw438LP3Xmg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.20.2.tgz", + "integrity": "sha512-btzExgV+/lMGDDa194CcUQm53ncxzeBrWJcncOBxuC6ndBkKxnHdFJn86mCIgTELsooUmwUm9FkhSp5HYu00Rg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.20.2.tgz", + "integrity": "sha512-4J6IRT+10J3aJH3l1yzEg9y3wkTDgDk7TSDFX+wKFiWjqWp/iCfLIYzGyasx9l0SAFPT1HwSCR+0w/h1ES/MjA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.20.2.tgz", + "integrity": "sha512-tBcXp9KNphnNH0dfhv8KYkZhjc+H3XBkF5DKtswJblV7KlT9EI2+jeA8DgBjp908WEuYll6pF+UStUCfEpdysA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.20.2.tgz", + "integrity": "sha512-d3qI41G4SuLiCGCFGUrKsSeTXyWG6yem1KcGZVS+3FYlYhtNoNgYrWcvkOoaqMhwXSMrZRl69ArHsGJ9mYdbbw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.20.2.tgz", + "integrity": "sha512-d+DipyvHRuqEeM5zDivKV1KuXn9WeRX6vqSqIDgwIfPQtwMP4jaDsQsDncjTDDsExT4lR/91OLjRo8bmC1e+Cw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.20.2.tgz", + "integrity": "sha512-VhLPeR8HTMPccbuWWcEUD1Az68TqaTYyj6nfE4QByZIQEQVWBB8vup8PpR7y1QHL3CpcF6xd5WVBU/+SBEvGTg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.20.2.tgz", + "integrity": "sha512-9pb6rBjGvTFNira2FLIWqDk/uaf42sSyLE8j1rnUpuzsODBq7FvpwHYZxQ/It/8b+QOS1RYfqgGFNLRI+qlq2A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.20.2.tgz", + "integrity": "sha512-o10utieEkNPFDZFQm9CoP7Tvb33UutoJqg3qKf1PWVeeJhJw0Q347PxMvBgVVFgouYLGIhFYG0UGdBumROyiig==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.20.2.tgz", + "integrity": "sha512-PR7sp6R/UC4CFVomVINKJ80pMFlfDfMQMYynX7t1tNTeivQ6XdX5r2XovMmha/VjR1YN/HgHWsVcTRIMkymrgQ==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.20.2.tgz", + "integrity": "sha512-4BlTqeutE/KnOiTG5Y6Sb/Hw6hsBOZapOVF6njAESHInhlQAghVVZL1ZpIctBOoTFbQyGW+LsVYZ8lSSB3wkjA==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.20.2.tgz", + "integrity": "sha512-rD3KsaDprDcfajSKdn25ooz5J5/fWBylaaXkuotBDGnMnDP1Uv5DLAN/45qfnf3JDYyJv/ytGHQaziHUdyzaAg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.20.2.tgz", + "integrity": "sha512-snwmBKacKmwTMmhLlz/3aH1Q9T8v45bKYGE3j26TsaOVtjIag4wLfWSiZykXzXuE1kbCE+zJRmwp+ZbIHinnVg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.20.2.tgz", + "integrity": "sha512-wcWISOobRWNm3cezm5HOZcYz1sKoHLd8VL1dl309DiixxVFoFe/o8HnwuIwn6sXre88Nwj+VwZUvJf4AFxkyrQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.20.2.tgz", + "integrity": "sha512-1MdwI6OOTsfQfek8sLwgyjOXAu+wKhLEoaOLTjbijk6E2WONYpH9ZU2mNtR+lZ2B4uwr+usqGuVfFT9tMtGvGw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.20.2.tgz", + "integrity": "sha512-K8/DhBxcVQkzYc43yJXDSyjlFeHQJBiowJ0uVL6Tor3jGQfSGHNNJcWxNbOI8v5k82prYqzPuwkzHt3J1T1iZQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.20.2.tgz", + "integrity": "sha512-eMpKlV0SThJmmJgiVyN9jTPJ2VBPquf6Kt/nAoo6DgHAoN57K15ZghiHaMvqjCye/uU4X5u3YSMgVBI1h3vKrQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.20.2.tgz", + "integrity": "sha512-2UyFtRC6cXLyejf/YEld4Hajo7UHILetzE1vsRcGL3earZEW77JxrFjH4Ez2qaTiEfMgAXxfAZCm1fvM/G/o8w==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.20.2.tgz", + "integrity": "sha512-GRibxoawM9ZCnDxnP3usoUDO9vUkpAxIIZ6GQI+IlVmr5kP3zUq+l17xELTHMWTWzjxa2guPNyrpq1GWmPvcGQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.20.2.tgz", + "integrity": "sha512-HfLOfn9YWmkSKRQqovpnITazdtquEW8/SoHW7pWpuEeguaZI4QnCRW6b+oZTztdBnZOS2hqJ6im/D5cPzBTTlQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.20.2.tgz", + "integrity": "sha512-N49X4lJX27+l9jbLKSqZ6bKNjzQvHaT8IIFUy+YIqmXQdjYCToGWwOItDrfby14c78aDd5NHQl29xingXfCdLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@intlify/core-base": { + "version": "9.1.9", + "resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.1.9.tgz", + "integrity": "sha512-x5T0p/Ja0S8hs5xs+ImKyYckVkL4CzcEXykVYYV6rcbXxJTe2o58IquSqX9bdncVKbRZP7GlBU1EcRaQEEJ+vw==", + "license": "MIT", + "dependencies": { + "@intlify/devtools-if": "9.1.9", + "@intlify/message-compiler": "9.1.9", + "@intlify/message-resolver": "9.1.9", + "@intlify/runtime": "9.1.9", + "@intlify/shared": "9.1.9", + "@intlify/vue-devtools": "9.1.9" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@intlify/devtools-if": { + "version": "9.1.9", + "resolved": "https://registry.npmmirror.com/@intlify/devtools-if/-/devtools-if-9.1.9.tgz", + "integrity": "sha512-oKSMKjttG3Ut/1UGEZjSdghuP3fwA15zpDPcjkf/1FjlOIm6uIBGMNS5jXzsZy593u+P/YcnrZD6cD3IVFz9vQ==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "9.1.9" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@intlify/message-compiler": { + "version": "9.1.9", + "resolved": "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-9.1.9.tgz", + "integrity": "sha512-6YgCMF46Xd0IH2hMRLCssZI3gFG4aywidoWQ3QP4RGYQXQYYfFC54DxhSgfIPpVoPLQ+4AD29eoYmhiHZ+qLFQ==", + "license": "MIT", + "dependencies": { + "@intlify/message-resolver": "9.1.9", + "@intlify/shared": "9.1.9", + "source-map": "0.6.1" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@intlify/message-resolver": { + "version": "9.1.9", + "resolved": "https://registry.npmmirror.com/@intlify/message-resolver/-/message-resolver-9.1.9.tgz", + "integrity": "sha512-Lx/DBpigeK0sz2BBbzv5mu9/dAlt98HxwbG7xLawC3O2xMF9MNWU5FtOziwYG6TDIjNq0O/3ZbOJAxwITIWXEA==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@intlify/runtime": { + "version": "9.1.9", + "resolved": "https://registry.npmmirror.com/@intlify/runtime/-/runtime-9.1.9.tgz", + "integrity": "sha512-XgPw8+UlHCiie3fI41HPVa/VDJb3/aSH7bLhY1hJvlvNV713PFtb4p4Jo+rlE0gAoMsMCGcsiT982fImolSltg==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "9.1.9", + "@intlify/message-resolver": "9.1.9", + "@intlify/shared": "9.1.9" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@intlify/shared": { + "version": "9.1.9", + "resolved": "https://registry.npmmirror.com/@intlify/shared/-/shared-9.1.9.tgz", + "integrity": "sha512-xKGM1d0EAxdDFCWedcYXOm6V5Pfw/TMudd6/qCdEb4tv0hk9EKeg7lwQF1azE0dP2phvx0yXxrt7UQK+IZjNdw==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/@intlify/vue-devtools": { + "version": "9.1.9", + "resolved": "https://registry.npmmirror.com/@intlify/vue-devtools/-/vue-devtools-9.1.9.tgz", + "integrity": "sha512-YPehH9uL4vZcGXky4Ev5qQIITnHKIvsD2GKGXgqf+05osMUI6WSEQHaN9USRa318Rs8RyyPCiDfmA0hRu3k7og==", + "license": "MIT", + "dependencies": { + "@intlify/message-resolver": "9.1.9", + "@intlify/runtime": "9.1.9", + "@intlify/shared": "9.1.9" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@jimp/bmp": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/bmp/-/bmp-0.10.3.tgz", + "integrity": "sha512-keMOc5woiDmONXsB/6aXLR4Z5Q+v8lFq3EY2rcj2FmstbDMhRuGbmcBxlEgOqfRjwvtf/wOtJ3Of37oAWtVfLg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "bmp-js": "^0.1.0", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/core": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/core/-/core-0.10.3.tgz", + "integrity": "sha512-Gd5IpL3U2bFIO57Fh/OA3HCpWm4uW/pU01E75rI03BXfTdz3T+J7TwvyG1XaqsQ7/DSlS99GXtLQPlfFIe28UA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "any-base": "^1.1.0", + "buffer": "^5.2.0", + "core-js": "^3.4.1", + "exif-parser": "^0.1.12", + "file-type": "^9.0.0", + "load-bmfont": "^1.3.1", + "mkdirp": "^0.5.1", + "phin": "^2.9.1", + "pixelmatch": "^4.0.2", + "tinycolor2": "^1.4.1" + } + }, + "node_modules/@jimp/custom": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/custom/-/custom-0.10.3.tgz", + "integrity": "sha512-nZmSI+jwTi5IRyNLbKSXQovoeqsw+D0Jn0SxW08wYQvdkiWA8bTlDQFgQ7HVwCAKBm8oKkDB/ZEo9qvHJ+1gAQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/core": "^0.10.3", + "core-js": "^3.4.1" + } + }, + "node_modules/@jimp/gif": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/gif/-/gif-0.10.3.tgz", + "integrity": "sha512-vjlRodSfz1CrUvvrnUuD/DsLK1GHB/yDZXHthVdZu23zYJIW7/WrIiD1IgQ5wOMV7NocfrvPn2iqUfBP81/WWA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1", + "omggif": "^1.0.9" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/jpeg": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/jpeg/-/jpeg-0.10.3.tgz", + "integrity": "sha512-AAANwgUZOt6f6P7LZxY9lyJ9xclqutYJlsxt3JbriXUGJgrrFAIkcKcqv1nObgmQASSAQKYaMV9KdHjMlWFKlQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1", + "jpeg-js": "^0.3.4" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-blit": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-blit/-/plugin-blit-0.10.3.tgz", + "integrity": "sha512-5zlKlCfx4JWw9qUVC7GI4DzXyxDWyFvgZLaoGFoT00mlXlN75SarlDwc9iZ/2e2kp4bJWxz3cGgG4G/WXrbg3Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-blur": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-blur/-/plugin-blur-0.10.3.tgz", + "integrity": "sha512-cTOK3rjh1Yjh23jSfA6EHCHjsPJDEGLC8K2y9gM7dnTUK1y9NNmkFS23uHpyjgsWFIoH9oRh2SpEs3INjCpZhQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-circle": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-circle/-/plugin-circle-0.10.3.tgz", + "integrity": "sha512-51GAPIVelqAcfuUpaM5JWJ0iWl4vEjNXB7p4P7SX5udugK5bxXUjO6KA2qgWmdpHuCKtoNgkzWU9fNSuYp7tCA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-color": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-color/-/plugin-color-0.10.3.tgz", + "integrity": "sha512-RgeHUElmlTH7vpI4WyQrz6u59spiKfVQbsG/XUzfWGamFSixa24ZDwX/yV/Ts+eNaz7pZeIuv533qmKPvw2ujg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1", + "tinycolor2": "^1.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-contain": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-contain/-/plugin-contain-0.10.3.tgz", + "integrity": "sha512-bYJKW9dqzcB0Ihc6u7jSyKa3juStzbLs2LFr6fu8TzA2WkMS/R8h+ddkiO36+F9ILTWHP0CIA3HFe5OdOGcigw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-blit": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5", + "@jimp/plugin-scale": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-cover": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-cover/-/plugin-cover-0.10.3.tgz", + "integrity": "sha512-pOxu0cM0BRPzdV468n4dMocJXoMbTnARDY/EpC3ZW15SpMuc/dr1KhWQHgoQX5kVW1Wt8zgqREAJJCQ5KuPKDA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-crop": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5", + "@jimp/plugin-scale": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-crop": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-crop/-/plugin-crop-0.10.3.tgz", + "integrity": "sha512-nB7HgOjjl9PgdHr076xZ3Sr6qHYzeBYBs9qvs3tfEEUeYMNnvzgCCGtUl6eMakazZFCMk3mhKmcB9zQuHFOvkg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-displace": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-displace/-/plugin-displace-0.10.3.tgz", + "integrity": "sha512-8t3fVKCH5IVqI4lewe4lFFjpxxr69SQCz5/tlpDLQZsrNScNJivHdQ09zljTrVTCSgeCqQJIKgH2Q7Sk/pAZ0w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-dither": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-dither/-/plugin-dither-0.10.3.tgz", + "integrity": "sha512-JCX/oNSnEg1kGQ8ffZ66bEgQOLCY3Rn+lrd6v1jjLy/mn9YVZTMsxLtGCXpiCDC2wG/KTmi4862ysmP9do9dAQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-fisheye": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-fisheye/-/plugin-fisheye-0.10.3.tgz", + "integrity": "sha512-RRZb1wqe+xdocGcFtj2xHU7sF7xmEZmIa6BmrfSchjyA2b32TGPWKnP3qyj7p6LWEsXn+19hRYbjfyzyebPElQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-flip": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-flip/-/plugin-flip-0.10.3.tgz", + "integrity": "sha512-0epbi8XEzp0wmSjoW9IB0iMu0yNF17aZOxLdURCN3Zr+8nWPs5VNIMqSVa1Y62GSyiMDpVpKF/ITiXre+EqrPg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-rotate": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-gaussian": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-gaussian/-/plugin-gaussian-0.10.3.tgz", + "integrity": "sha512-25eHlFbHUDnMMGpgRBBeQ2AMI4wsqCg46sue0KklI+c2BaZ+dGXmJA5uT8RTOrt64/K9Wz5E+2n7eBnny4dfpQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-invert": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-invert/-/plugin-invert-0.10.3.tgz", + "integrity": "sha512-effYSApWY/FbtlzqsKXlTLkgloKUiHBKjkQnqh5RL4oQxh/33j6aX+HFdDyQKtsXb8CMd4xd7wyiD2YYabTa0g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-mask": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-mask/-/plugin-mask-0.10.3.tgz", + "integrity": "sha512-twrg8q8TIhM9Z6Jcu9/5f+OCAPaECb0eKrrbbIajJqJ3bCUlj5zbfgIhiQIzjPJ6KjpnFPSqHQfHkU1Vvk/nVw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-normalize": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-normalize/-/plugin-normalize-0.10.3.tgz", + "integrity": "sha512-xkb5eZI/mMlbwKkDN79+1/t/+DBo8bBXZUMsT4gkFgMRKNRZ6NQPxlv1d3QpRzlocsl6UMxrHnhgnXdLAcgrXw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-print": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-print/-/plugin-print-0.10.3.tgz", + "integrity": "sha512-wjRiI6yjXsAgMe6kVjizP+RgleUCLkH256dskjoNvJzmzbEfO7xQw9g6M02VET+emnbY0CO83IkrGm2q43VRyg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1", + "load-bmfont": "^1.4.0" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-blit": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-resize": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-resize/-/plugin-resize-0.10.3.tgz", + "integrity": "sha512-rf8YmEB1d7Sg+g4LpqF0Mp+dfXfb6JFJkwlAIWPUOR7lGsPWALavEwTW91c0etEdnp0+JB9AFpy6zqq7Lwkq6w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-rotate": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-rotate/-/plugin-rotate-0.10.3.tgz", + "integrity": "sha512-YXLlRjm18fkW9MOHUaVAxWjvgZM851ofOipytz5FyKp4KZWDLk+dZK1JNmVmK7MyVmAzZ5jsgSLhIgj+GgN0Eg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-blit": ">=0.3.5", + "@jimp/plugin-crop": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-scale": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-scale/-/plugin-scale-0.10.3.tgz", + "integrity": "sha512-5DXD7x7WVcX1gUgnlFXQa8F+Q3ThRYwJm+aesgrYvDOY+xzRoRSdQvhmdd4JEEue3lyX44DvBSgCIHPtGcEPaw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-shadow": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-shadow/-/plugin-shadow-0.10.3.tgz", + "integrity": "sha512-/nkFXpt2zVcdP4ETdkAUL0fSzyrC5ZFxdcphbYBodqD7fXNqChS/Un1eD4xCXWEpW8cnG9dixZgQgStjywH0Mg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-blur": ">=0.3.5", + "@jimp/plugin-resize": ">=0.3.5" + } + }, + "node_modules/@jimp/plugin-threshold": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugin-threshold/-/plugin-threshold-0.10.3.tgz", + "integrity": "sha512-Dzh0Yq2wXP2SOnxcbbiyA4LJ2luwrdf1MghNIt9H+NX7B+IWw/N8qA2GuSm9n4BPGSLluuhdAWJqHcTiREriVA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5", + "@jimp/plugin-color": ">=0.8.0", + "@jimp/plugin-resize": ">=0.8.0" + } + }, + "node_modules/@jimp/plugins": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/plugins/-/plugins-0.10.3.tgz", + "integrity": "sha512-jTT3/7hOScf0EIKiAXmxwayHhryhc1wWuIe3FrchjDjr9wgIGNN2a7XwCgPl3fML17DXK1x8EzDneCdh261bkw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/plugin-blit": "^0.10.3", + "@jimp/plugin-blur": "^0.10.3", + "@jimp/plugin-circle": "^0.10.3", + "@jimp/plugin-color": "^0.10.3", + "@jimp/plugin-contain": "^0.10.3", + "@jimp/plugin-cover": "^0.10.3", + "@jimp/plugin-crop": "^0.10.3", + "@jimp/plugin-displace": "^0.10.3", + "@jimp/plugin-dither": "^0.10.3", + "@jimp/plugin-fisheye": "^0.10.3", + "@jimp/plugin-flip": "^0.10.3", + "@jimp/plugin-gaussian": "^0.10.3", + "@jimp/plugin-invert": "^0.10.3", + "@jimp/plugin-mask": "^0.10.3", + "@jimp/plugin-normalize": "^0.10.3", + "@jimp/plugin-print": "^0.10.3", + "@jimp/plugin-resize": "^0.10.3", + "@jimp/plugin-rotate": "^0.10.3", + "@jimp/plugin-scale": "^0.10.3", + "@jimp/plugin-shadow": "^0.10.3", + "@jimp/plugin-threshold": "^0.10.3", + "core-js": "^3.4.1", + "timm": "^1.6.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/png": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/png/-/png-0.10.3.tgz", + "integrity": "sha512-YKqk/dkl+nGZxSYIDQrqhmaP8tC3IK8H7dFPnnzFVvbhDnyYunqBZZO3SaZUKTichClRw8k/CjBhbc+hifSGWg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/utils": "^0.10.3", + "core-js": "^3.4.1", + "pngjs": "^3.3.3" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/tiff": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/tiff/-/tiff-0.10.3.tgz", + "integrity": "sha512-7EsJzZ5Y/EtinkBGuwX3Bi4S+zgbKouxjt9c82VJTRJOQgLWsE/RHqcyRCOQBhHAZ9QexYmDz34medfLKdoX0g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "core-js": "^3.4.1", + "utif": "^2.0.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/types": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/types/-/types-0.10.3.tgz", + "integrity": "sha512-XGmBakiHZqseSWr/puGN+CHzx0IKBSpsKlmEmsNV96HKDiP6eu8NSnwdGCEq2mmIHe0JNcg1hqg59hpwtQ7Tiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/bmp": "^0.10.3", + "@jimp/gif": "^0.10.3", + "@jimp/jpeg": "^0.10.3", + "@jimp/png": "^0.10.3", + "@jimp/tiff": "^0.10.3", + "core-js": "^3.4.1", + "timm": "^1.6.1" + }, + "peerDependencies": { + "@jimp/custom": ">=0.3.5" + } + }, + "node_modules/@jimp/utils": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/@jimp/utils/-/utils-0.10.3.tgz", + "integrity": "sha512-VcSlQhkil4ReYmg1KkN+WqHyYfZ2XfZxDsKAHSfST1GEz/RQHxKZbX+KhFKtKflnL0F4e6DlNQj3vznMNXCR2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "core-js": "^3.4.1", + "regenerator-runtime": "^0.13.3" + } + }, + "node_modules/@jimp/utils/node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmmirror.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmmirror.com/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmmirror.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/pluginutils": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/@rollup/pluginutils/-/pluginutils-5.1.0.tgz", + "integrity": "sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@vitejs/plugin-legacy": { + "version": "5.3.2", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-legacy/-/plugin-legacy-5.3.2.tgz", + "integrity": "sha512-8moCOrIMaZ/Rjln0Q6GsH6s8fAt1JOI3k8nmfX4tXUxE5KAExVctSyOBk+A25GClsdSWqIk2yaUthH3KJ2X4tg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.9", + "@babel/preset-env": "^7.23.9", + "browserslist": "^4.23.0", + "browserslist-to-esbuild": "^2.1.1", + "core-js": "^3.36.0", + "magic-string": "^0.30.7", + "regenerator-runtime": "^0.14.1", + "systemjs": "^6.14.3" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "peerDependencies": { + "terser": "^5.4.0", + "vite": "^5.0.0" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitejs/plugin-vue-jsx": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue-jsx/-/plugin-vue-jsx-3.1.0.tgz", + "integrity": "sha512-w9M6F3LSEU5kszVb9An2/MmXNxocAnUb3WhRr8bHlimhDrXNt6n6D2nJQR3UXpGlZHh/EsgouOHCsM8V3Ln+WA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.23.3", + "@babel/plugin-transform-typescript": "^7.23.3", + "@vue/babel-plugin-jsx": "^1.1.5" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.0.0 || ^5.0.0", + "vue": "^3.0.0" + } + }, + "node_modules/@volar/language-core": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz", + "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "1.11.1" + } + }, + "node_modules/@volar/source-map": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz", + "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "muggle-string": "^0.3.1" + } + }, + "node_modules/@volar/typescript": { + "version": "1.11.1", + "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-1.11.1.tgz", + "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "1.11.1", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@vue/babel-helper-vue-transform-on": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/@vue/babel-helper-vue-transform-on/-/babel-helper-vue-transform-on-1.5.0.tgz", + "integrity": "sha512-0dAYkerNhhHutHZ34JtTl2czVQHUNWv6xEbkdF5W+Yrv5pCWsqjeORdOgbtW2I9gWlt+wBmVn+ttqN9ZxR5tzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/babel-plugin-jsx": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/@vue/babel-plugin-jsx/-/babel-plugin-jsx-1.5.0.tgz", + "integrity": "sha512-mneBhw1oOqCd2247O0Yw/mRwC9jIGACAJUlawkmMBiNmL4dGA2eMzuNZVNqOUfYTa6vqmND4CtOPzmEEEqLKFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.2", + "@vue/babel-helper-vue-transform-on": "1.5.0", + "@vue/babel-plugin-resolve-type": "1.5.0", + "@vue/shared": "^3.5.18" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + } + } + }, + "node_modules/@vue/babel-plugin-jsx/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@vue/babel-plugin-jsx/node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/babel-plugin-resolve-type": { + "version": "1.5.0", + "resolved": "https://registry.npmmirror.com/@vue/babel-plugin-resolve-type/-/babel-plugin-resolve-type-1.5.0.tgz", + "integrity": "sha512-Wm/60o+53JwJODm4Knz47dxJnLDJ9FnKnGZJbUUf8nQRAtt6P+undLUAVU3Ha33LxOJe6IPoifRQ6F/0RrU31w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/parser": "^7.28.0", + "@vue/compiler-sfc": "^3.5.18" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@vue/babel-plugin-resolve-type/node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@vue/babel-plugin-resolve-type/node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@vue/babel-plugin-resolve-type/node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@vue/babel-plugin-resolve-type/node_modules/@vue/compiler-core": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.32.tgz", + "integrity": "sha512-4x74Tbtqnda8s/NSD6e1Dr5p1c8HdMU5RWSjMSUzb8RTcUQqevDCxVAitcLBKT+ie3o0Dl9crc/S/opJM7qBGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/shared": "3.5.32", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/babel-plugin-resolve-type/node_modules/@vue/compiler-dom": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.32.tgz", + "integrity": "sha512-ybHAu70NtiEI1fvAUz3oXZqkUYEe5J98GjMDpTGl5iHb0T15wQYLR4wE3h9xfuTNA+Cm2f4czfe8B4s+CCH57Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/babel-plugin-resolve-type/node_modules/@vue/compiler-sfc": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.32.tgz", + "integrity": "sha512-8UYUYo71cP/0YHMO814TRZlPuUUw3oifHuMR7Wp9SNoRSrxRQnhMLNlCeaODNn6kNTJsjFoQ/kqIj4qGvya4Xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.2", + "@vue/compiler-core": "3.5.32", + "@vue/compiler-dom": "3.5.32", + "@vue/compiler-ssr": "3.5.32", + "@vue/shared": "3.5.32", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/babel-plugin-resolve-type/node_modules/@vue/compiler-ssr": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.32.tgz", + "integrity": "sha512-Gp4gTs22T3DgRotZ8aA/6m2jMR+GMztvBXUBEUOYOcST+giyGWJ4WvFd7QLHBkzTxkfOt8IELKNdpzITLbA2rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/babel-plugin-resolve-type/node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/babel-plugin-resolve-type/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/babel-plugin-resolve-type/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vue/babel-plugin-resolve-type/node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/@vue/compiler-core": { + "version": "3.4.21", + "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.4.21.tgz", + "integrity": "sha512-MjXawxZf2SbZszLPYxaFCjxfibYrzr3eYbKxwpLR9EQN+oaziSu3qKVbwBERj1IFIB8OLUewxB5m/BFzi613og==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.23.9", + "@vue/shared": "3.4.21", + "entities": "^4.5.0", + "estree-walker": "^2.0.2", + "source-map-js": "^1.0.2" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.4.21", + "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.4.21.tgz", + "integrity": "sha512-IZC6FKowtT1sl0CR5DpXSiEB5ayw75oT2bma1BEhV7RRR1+cfwLrxc2Z8Zq/RGFzJ8w5r9QtCOvTjQgdn0IKmA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.4.21", + "@vue/shared": "3.4.21" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.4.21", + "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.4.21.tgz", + "integrity": "sha512-me7epoTxYlY+2CUM7hy9PCDdpMPfIwrOvAXud2Upk10g4YLv9UBW7kL798TvMeDhPthkZ0CONNrK2GoeI1ODiQ==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.23.9", + "@vue/compiler-core": "3.4.21", + "@vue/compiler-dom": "3.4.21", + "@vue/compiler-ssr": "3.4.21", + "@vue/shared": "3.4.21", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.7", + "postcss": "^8.4.35", + "source-map-js": "^1.0.2" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.4.21", + "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.4.21.tgz", + "integrity": "sha512-M5+9nI2lPpAsgXOGQobnIueVqc9sisBFexh5yMIMRAPYLa7+5wEJs8iqOZc1WAa9WQbx9GR2twgznU8LTIiZ4Q==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.4.21", + "@vue/shared": "3.4.21" + } + }, + "node_modules/@vue/consolidate": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/@vue/consolidate/-/consolidate-1.0.0.tgz", + "integrity": "sha512-oTyUE+QHIzLw2PpV14GD/c7EohDyP64xCniWTcqcEmTd699eFqTIwOmtDYjcO1j3QgdXoJEoWv1/cCdLrRoOfg==", + "license": "MIT", + "engines": { + "node": ">= 0.12.0" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/language-core": { + "version": "1.8.27", + "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-1.8.27.tgz", + "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "~1.11.1", + "@volar/source-map": "~1.11.1", + "@vue/compiler-dom": "^3.3.0", + "@vue/shared": "^3.3.0", + "computeds": "^0.0.1", + "minimatch": "^9.0.3", + "muggle-string": "^0.3.1", + "path-browserify": "^1.0.1", + "vue-template-compiler": "^2.7.14" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core/node_modules/brace-expansion": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.0.tgz", + "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@vue/language-core/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.32.tgz", + "integrity": "sha512-/ORasxSGvZ6MN5gc+uE364SxFdJ0+WqVG0CENXaGW58TOCdrAW76WWaplDtECeS1qphvtBZtR+3/o1g1zL4xPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/reactivity/node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.32.tgz", + "integrity": "sha512-pDrXCejn4UpFDFmMd27AcJEbHaLemaE5o4pbb7sLk79SRIhc6/t34BQA7SGNgYtbMnvbF/HHOftYBgFJtUoJUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.32", + "@vue/shared": "3.5.32" + } + }, + "node_modules/@vue/runtime-core/node_modules/@vue/shared": { + "version": "3.5.32", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.32.tgz", + "integrity": "sha512-ksNyrmRQzWJJ8n3cRDuSF7zNNontuJg1YHnmWRJd2AMu8Ij2bqwiiri2lH5rHtYPZjj4STkNcgcmiQqlOjiYGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vue/runtime-dom": { + "version": "3.4.21", + "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.4.21.tgz", + "integrity": "sha512-gvf+C9cFpevsQxbkRBS1NpU8CqxKw0ebqMvLwcGQrNpx6gqRDodqKqA+A2VZZpQ9RpK2f9yfg8VbW/EpdFUOJw==", + "license": "MIT", + "dependencies": { + "@vue/runtime-core": "3.4.21", + "@vue/shared": "3.4.21", + "csstype": "^3.1.3" + } + }, + "node_modules/@vue/runtime-dom/node_modules/@vue/reactivity": { + "version": "3.4.21", + "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.4.21.tgz", + "integrity": "sha512-UhenImdc0L0/4ahGCyEzc/pZNwVgcglGy9HVzJ1Bq2Mm9qXOpP8RyNTjookw/gOCUlXSEtuZ2fUg5nrHcoqJcw==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.4.21" + } + }, + "node_modules/@vue/runtime-dom/node_modules/@vue/runtime-core": { + "version": "3.4.21", + "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.4.21.tgz", + "integrity": "sha512-pQthsuYzE1XcGZznTKn73G0s14eCJcjaLvp3/DKeYWoFacD9glJoqlNBxt3W2c5S40t6CCcpPf+jG01N3ULyrA==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.4.21", + "@vue/shared": "3.4.21" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.4.21", + "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.4.21.tgz", + "integrity": "sha512-aV1gXyKSN6Rz+6kZ6kr5+Ll14YzmIbeuWe7ryJl5muJ4uwSwY/aStXTixx76TwkZFJLm1aAlA/HSWEJ4EyiMkg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.4.21", + "@vue/shared": "3.4.21" + }, + "peerDependencies": { + "vue": "3.4.21" + } + }, + "node_modules/@vue/shared": { + "version": "3.4.21", + "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.4.21.tgz", + "integrity": "sha512-PuJe7vDIi6VYSinuEbUIQgMIRZGgM8e4R+G+/dQTk0X1NEdvgvvgv7m+rfmDH1gZzyA1OjjoWskvHlfRNfQf3g==", + "license": "MIT" + }, + "node_modules/@vue/tsconfig": { + "version": "0.1.3", + "resolved": "https://registry.npmmirror.com/@vue/tsconfig/-/tsconfig-0.1.3.tgz", + "integrity": "sha512-kQVsh8yyWPvHpb8gIc9l/HIDiiVUy1amynLNpCy8p+FoCiZXCo6fQos5/097MmnNZc9AtseDsCrfkhqCrJ8Olg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/node": "*" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmmirror.com/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/address": { + "version": "1.2.2", + "resolved": "https://registry.npmmirror.com/address/-/address-1.2.2.tgz", + "integrity": "sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/adm-zip": { + "version": "0.5.16", + "resolved": "https://registry.npmmirror.com/adm-zip/-/adm-zip-0.5.16.tgz", + "integrity": "sha512-TGw5yVi4saajsSEgz25grObGHEUaDrniwvA2qwSC060KfqGPdglhvPMA2lPIoxs3PQIItj2iag35fONcQqgUaQ==", + "license": "MIT", + "engines": { + "node": ">=12.0" + } + }, + "node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/any-base": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/any-base/-/any-base-1.1.0.tgz", + "integrity": "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmmirror.com/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.4.20", + "resolved": "https://registry.npmmirror.com/autoprefixer/-/autoprefixer-10.4.20.tgz", + "integrity": "sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.23.3", + "caniuse-lite": "^1.0.30001646", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.0.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.17", + "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.17.tgz", + "integrity": "sha512-aTyf30K/rqAsNwN76zYrdtx8obu0E4KoUME29B1xj+B3WxgvWkp943vYQ+z8Mv3lw9xHXMHpvSPOBxzAkIa94w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-define-polyfill-provider": "^0.6.8", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.14.2", + "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.14.2.tgz", + "integrity": "sha512-coWpDLJ410R781Npmn/SIBZEsAetR4xVi0SxLMXPaMO4lSf1MwnkGYMtkFxew0Dn8B3/CpbpYxN0JCgg8mn67g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8", + "core-js-compat": "^3.48.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.8", + "resolved": "https://registry.npmmirror.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.8.tgz", + "integrity": "sha512-M762rNHfSF1EV3SLtnCJXFoQbbIIz0OyRwnCmV0KPC7qosSfCO0QLTSuJX3ayAebubhE6oYBAYPrBA5ljowaZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.8" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.20", + "resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.20.tgz", + "integrity": "sha512-1AaXxEPfXT+GvTBJFuy4yXVHWJBXa4OdbIebGN/wX5DlsIkU0+wzGnd2lOzokSk51d5LUmqjgBLRLlypLUqInQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmmirror.com/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.3", + "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "on-finished": "2.4.1", + "qs": "6.13.0", + "raw-body": "2.5.2", + "type-is": "~1.6.18", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.13.0", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmmirror.com/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/browserslist-to-esbuild": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/browserslist-to-esbuild/-/browserslist-to-esbuild-2.1.1.tgz", + "integrity": "sha512-KN+mty6C3e9AN8Z5dI1xeN15ExcRNeISoC3g7V0Kax/MMF9MSoYA2G7lkTTcVUFntiEjkpI0HNgqJC1NjdyNUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "meow": "^13.0.0" + }, + "bin": { + "browserslist-to-esbuild": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "browserslist": "*" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmmirror.com/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-equal": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/buffer-equal/-/buffer-equal-0.0.1.tgz", + "integrity": "sha512-RgSV6InVQ9ODPdLWJ5UAqBqJBOg370Nz6ZQtRzpt6nUjc8v0St97uJ4PYC6NztqIScrAXafKM3mZPMygSe1ggA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmmirror.com/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/cac": { + "version": "6.7.9", + "resolved": "https://registry.npmmirror.com/cac/-/cac-6.7.9.tgz", + "integrity": "sha512-XN5qEpfNQCJ8jRaZgitSkkukjMRCGio+X3Ks5KUbGGlPbV+pSem1l9VuzooCBXOiMFshUZgyYqg6rgN8rjkb/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmmirror.com/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/centra": { + "version": "2.7.0", + "resolved": "https://registry.npmmirror.com/centra/-/centra-2.7.0.tgz", + "integrity": "sha512-PbFMgMSrmgx6uxCdm57RUos9Tc3fclMvhLSATYN39XsDV29B89zZ3KA89jmY0vwSGazyU+uerqwa6t+KaodPcg==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.6" + } + }, + "node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmmirror.com/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmmirror.com/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmmirror.com/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/compare-versions": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/compare-versions/-/compare-versions-3.6.0.tgz", + "integrity": "sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==", + "license": "MIT" + }, + "node_modules/computeds": { + "version": "0.0.1", + "resolved": "https://registry.npmmirror.com/computeds/-/computeds-0.0.1.tgz", + "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/confbox": { + "version": "0.1.8", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.1.8.tgz", + "integrity": "sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.6.0", + "resolved": "https://registry.npmmirror.com/cookie/-/cookie-0.6.0.tgz", + "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.6.tgz", + "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/core-js-compat": { + "version": "3.49.0", + "resolved": "https://registry.npmmirror.com/core-js-compat/-/core-js-compat-3.49.0.tgz", + "integrity": "sha512-VQXt1jr9cBz03b331DFDCCP90b3fanciLkgiOoy8SBHy06gNf+vQ1A3WFLqG7I8TipYIKeYK9wxd0tUrvHcOZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmmirror.com/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmmirror.com/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-font-size-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/css-font-size-keywords/-/css-font-size-keywords-1.0.0.tgz", + "integrity": "sha512-Q+svMDbMlelgCfH/RVDKtTDaf5021O486ZThQPIpahnIjUkMUslC+WuOQSWTgGSrNCH08Y7tYNEmmy0hkfMI8Q==", + "license": "MIT" + }, + "node_modules/css-font-stretch-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/css-font-stretch-keywords/-/css-font-stretch-keywords-1.0.1.tgz", + "integrity": "sha512-KmugPO2BNqoyp9zmBIUGwt58UQSfyk1X5DbOlkb2pckDXFSAfjsD5wenb88fNrD6fvS+vu90a/tsPpb9vb0SLg==", + "license": "MIT" + }, + "node_modules/css-font-style-keywords": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/css-font-style-keywords/-/css-font-style-keywords-1.0.1.tgz", + "integrity": "sha512-0Fn0aTpcDktnR1RzaBYorIxQily85M2KXRpzmxQPgh8pxUN9Fcn00I8u9I3grNr1QXVgCl9T5Imx0ZwKU973Vg==", + "license": "MIT" + }, + "node_modules/css-font-weight-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/css-font-weight-keywords/-/css-font-weight-keywords-1.0.0.tgz", + "integrity": "sha512-5So8/NH+oDD+EzsnF4iaG4ZFHQ3vaViePkL1ZbZ5iC/KrsCY+WHq/lvOgrtmuOQ9pBBZ1ADGpaf+A4lj1Z9eYA==", + "license": "MIT" + }, + "node_modules/css-list-helpers": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/css-list-helpers/-/css-list-helpers-2.0.0.tgz", + "integrity": "sha512-9Bj8tZ0jWbAM3u/U6m/boAzAwLPwtjzFvwivr2piSvyVa3K3rChJzQy4RIHkNkKiZCHrEMWDJWtTR8UyVhdDnQ==", + "license": "MIT" + }, + "node_modules/css-system-font-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/css-system-font-keywords/-/css-system-font-keywords-1.0.0.tgz", + "integrity": "sha512-1umTtVd/fXS25ftfjB71eASCrYhilmEsvDEI6wG/QplnmlfmVM5HkZ/ZX46DT5K3eblFPgLUHt5BRCb0YXkSFA==", + "license": "MIT" + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/de-indent": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz", + "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmmirror.com/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/default-gateway": { + "version": "6.0.3", + "resolved": "https://registry.npmmirror.com/default-gateway/-/default-gateway-6.0.3.tgz", + "integrity": "sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "execa": "^5.0.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-walk": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/dom-walk/-/dom-walk-0.1.2.tgz", + "integrity": "sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==" + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.340", + "resolved": "https://registry.npmmirror.com/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", + "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", + "license": "ISC" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.5.4", + "resolved": "https://registry.npmmirror.com/es-module-lexer/-/es-module-lexer-1.5.4.tgz", + "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.20.2", + "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.20.2.tgz", + "integrity": "sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.20.2", + "@esbuild/android-arm": "0.20.2", + "@esbuild/android-arm64": "0.20.2", + "@esbuild/android-x64": "0.20.2", + "@esbuild/darwin-arm64": "0.20.2", + "@esbuild/darwin-x64": "0.20.2", + "@esbuild/freebsd-arm64": "0.20.2", + "@esbuild/freebsd-x64": "0.20.2", + "@esbuild/linux-arm": "0.20.2", + "@esbuild/linux-arm64": "0.20.2", + "@esbuild/linux-ia32": "0.20.2", + "@esbuild/linux-loong64": "0.20.2", + "@esbuild/linux-mips64el": "0.20.2", + "@esbuild/linux-ppc64": "0.20.2", + "@esbuild/linux-riscv64": "0.20.2", + "@esbuild/linux-s390x": "0.20.2", + "@esbuild/linux-x64": "0.20.2", + "@esbuild/netbsd-x64": "0.20.2", + "@esbuild/openbsd-x64": "0.20.2", + "@esbuild/sunos-x64": "0.20.2", + "@esbuild/win32-arm64": "0.20.2", + "@esbuild/win32-ia32": "0.20.2", + "@esbuild/win32-x64": "0.20.2" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmmirror.com/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmmirror.com/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/execa": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", + "is-stream": "^2.0.0", + "merge-stream": "^2.0.0", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", + "strip-final-newline": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sindresorhus/execa?sponsor=1" + } + }, + "node_modules/exif-parser": { + "version": "0.1.12", + "resolved": "https://registry.npmmirror.com/exif-parser/-/exif-parser-0.1.12.tgz", + "integrity": "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw==" + }, + "node_modules/express": { + "version": "4.20.0", + "resolved": "https://registry.npmmirror.com/express/-/express-4.20.0.tgz", + "integrity": "sha512-pLdae7I6QqShF5PnNTCVn4hI91Dx0Grkn2+IAsMTgMIKuQVte2dN9PeGSSAME2FR8anOhVA62QDIUaWVfEXVLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "1.20.3", + "content-disposition": "0.5.4", + "content-type": "~1.0.4", + "cookie": "0.6.0", + "cookie-signature": "1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "1.2.0", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.10", + "proxy-addr": "~2.0.7", + "qs": "6.11.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "0.19.0", + "serve-static": "1.16.0", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/exsolve": { + "version": "1.0.8", + "resolved": "https://registry.npmmirror.com/exsolve/-/exsolve-1.0.8.tgz", + "integrity": "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==", + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmmirror.com/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-type": { + "version": "9.0.0", + "resolved": "https://registry.npmmirror.com/file-type/-/file-type-9.0.0.tgz", + "integrity": "sha512-Qe/5NJrgIOlwijpq3B7BEpzPFcgzggOTagZmkXQY4LA6bsXKTUstK7Wp12lEJ/mLKTpvIZxmIuRcLYWT6ov9lw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.2.0.tgz", + "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "on-finished": "2.4.1", + "parseurl": "~1.3.3", + "statuses": "2.0.1", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmmirror.com/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generic-names": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/generic-names/-/generic-names-4.0.0.tgz", + "integrity": "sha512-ySFolZQfw9FoDb3ed9d80Cm9f0+r7qj+HJkWjeD9RBfpxEVTlVhol+gvaQB/78WbwYfbnNh8nWHHBSlg072y6A==", + "license": "MIT", + "dependencies": { + "loader-utils": "^3.2.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmmirror.com/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "6.0.1", + "resolved": "https://registry.npmmirror.com/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/global": { + "version": "4.4.0", + "resolved": "https://registry.npmmirror.com/global/-/global-4.4.0.tgz", + "integrity": "sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==", + "license": "MIT", + "dependencies": { + "min-document": "^2.19.0", + "process": "^0.11.10" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hash-sum": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/hash-sum/-/hash-sum-2.0.0.tgz", + "integrity": "sha512-WdZTbAByD+pHfl/g9QSsBIIwy8IT+EsPiKDs0KNX+zSHhdDLFKdZu0BQHljvO+0QI/BasbMSUa8wYNCZTvhslg==", + "license": "MIT" + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/he": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz", + "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/http-errors": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.0.tgz", + "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "depd": "2.0.0", + "inherits": "2.0.4", + "setprototypeof": "1.2.0", + "statuses": "2.0.1", + "toidentifier": "1.0.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/human-signals": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=10.17.0" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/icss-replace-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz", + "integrity": "sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==", + "license": "ISC" + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmmirror.com/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/invert-kv": { + "version": "3.0.1", + "resolved": "https://registry.npmmirror.com/invert-kv/-/invert-kv-3.0.1.tgz", + "integrity": "sha512-CYdFeFexxhv/Bcny+Q0BfOV+ltRlJcd4BBZBYFX/O0u4npJrgZtIcjokegtiSMAvlMTJ+Koq0GBCc//3bueQxw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sindresorhus/invert-kv?sponsor=1" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmmirror.com/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-function": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/is-function/-/is-function-1.0.2.tgz", + "integrity": "sha512-lw7DUp0aWXYg+CBCN+JKkcE0Q2RayZnSvnZBlwgxHBQhqt5pZNVy4Ri7H9GmmXkdu7LUthszM+Tor1u/2iBcpQ==", + "license": "MIT" + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-stream": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz", + "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isbinaryfile": { + "version": "5.0.2", + "resolved": "https://registry.npmmirror.com/isbinaryfile/-/isbinaryfile-5.0.2.tgz", + "integrity": "sha512-GvcjojwonMjWbTkfMpnVHVqXW/wKMYDfEpY94/8zy8HFMOqb/VL6oeONq9v87q4ttVlaTLnGXnJD4B5B1OTGIg==", + "license": "MIT", + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/gjtorikian/" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jimp": { + "version": "0.10.3", + "resolved": "https://registry.npmmirror.com/jimp/-/jimp-0.10.3.tgz", + "integrity": "sha512-meVWmDMtyUG5uYjFkmzu0zBgnCvvxwWNi27c4cg55vWNVC9ES4Lcwb+ogx+uBBQE3Q+dLKjXaLl0JVW+nUNwbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.7.2", + "@jimp/custom": "^0.10.3", + "@jimp/plugins": "^0.10.3", + "@jimp/types": "^0.10.3", + "core-js": "^3.4.1", + "regenerator-runtime": "^0.13.3" + } + }, + "node_modules/jimp/node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT" + }, + "node_modules/jpeg-js": { + "version": "0.3.7", + "resolved": "https://registry.npmmirror.com/jpeg-js/-/jpeg-js-0.3.7.tgz", + "integrity": "sha512-9IXdWudL61npZjvLuVe/ktHiA41iE8qFyLB+4VDTblEsWBzeg8WQTlktdUK4CdncUqtUgUg0bbOmTE2bKBKaBQ==", + "license": "BSD-3-Clause" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/jsonc-parser/-/jsonc-parser-3.3.1.tgz", + "integrity": "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==", + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.0", + "resolved": "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.2.0.tgz", + "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/lcid": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/lcid/-/lcid-3.1.1.tgz", + "integrity": "sha512-M6T051+5QCGLBQb8id3hdvIW8+zeFV2FyBGFS9IEK5H9Wt4MueD4bW1eWikpHgZp+5xR3l5c8pZUkQsIA0BFZg==", + "license": "MIT", + "dependencies": { + "invert-kv": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/licia": { + "version": "1.41.1", + "resolved": "https://registry.npmmirror.com/licia/-/licia-1.41.1.tgz", + "integrity": "sha512-XqObV8u1KEMdYWaNK0leRrTwhzKnLQEkhbnuUu7qGNH3zJoN7l9sfvF6PfHstSCuUOmpEP+0SBjRrk0I9uZs8g==", + "license": "MIT" + }, + "node_modules/lilconfig": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-2.1.0.tgz", + "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/lines-and-columns": { + "version": "2.0.4", + "resolved": "https://registry.npmmirror.com/lines-and-columns/-/lines-and-columns-2.0.4.tgz", + "integrity": "sha512-wM1+Z03eypVAVUCE7QdSqpVIvelbOakn1M0bPDoA4SGWPx3sNDVUiMo3L6To6WWGClB7VyXnhQ4Sn7gxiJbE6A==", + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + } + }, + "node_modules/load-bmfont": { + "version": "1.4.2", + "resolved": "https://registry.npmmirror.com/load-bmfont/-/load-bmfont-1.4.2.tgz", + "integrity": "sha512-qElWkmjW9Oq1F9EI5Gt7aD9zcdHb9spJCW1L/dmPf7KzCCEJxq8nhHz5eCgI9aMf7vrG/wyaCqdsI+Iy9ZTlog==", + "license": "MIT", + "dependencies": { + "buffer-equal": "0.0.1", + "mime": "^1.3.4", + "parse-bmfont-ascii": "^1.0.3", + "parse-bmfont-binary": "^1.0.5", + "parse-bmfont-xml": "^1.1.4", + "phin": "^3.7.1", + "xhr": "^2.0.1", + "xtend": "^4.0.0" + } + }, + "node_modules/load-bmfont/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/load-bmfont/node_modules/phin": { + "version": "3.7.1", + "resolved": "https://registry.npmmirror.com/phin/-/phin-3.7.1.tgz", + "integrity": "sha512-GEazpTWwTZaEQ9RhL7Nyz0WwqilbqgLahDM3D0hxWwmVDI52nXEybHqiN6/elwpkJBhcuj+WbBu+QfT0uhPGfQ==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT", + "dependencies": { + "centra": "^2.7.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/loader-utils": { + "version": "3.3.1", + "resolved": "https://registry.npmmirror.com/loader-utils/-/loader-utils-3.3.1.tgz", + "integrity": "sha512-FMJTLMXfCLMLfJxcX9PFqX5qD88Z5MRGaZCVzfuqeZSPsyiBzs+pahDQjbIWz2QIzPZz0NX9Zy4FX3lmK6YHIg==", + "license": "MIT", + "engines": { + "node": ">= 12.13.0" + } + }, + "node_modules/local-pkg": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/local-pkg/-/local-pkg-1.1.2.tgz", + "integrity": "sha512-arhlxbFRmoQHl33a0Zkle/YWlmNwoyt6QNZEIJcqNbdrsix5Lvc4HyyI3EnwxTYlZYc32EbYrQ8SzEZ7dqgg9A==", + "license": "MIT", + "dependencies": { + "mlly": "^1.7.4", + "pkg-types": "^2.3.0", + "quansync": "^0.2.11" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/local-pkg/node_modules/confbox": { + "version": "0.2.4", + "resolved": "https://registry.npmmirror.com/confbox/-/confbox-0.2.4.tgz", + "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "license": "MIT" + }, + "node_modules/local-pkg/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==", + "license": "MIT", + "dependencies": { + "confbox": "^0.2.2", + "exsolve": "^1.0.7", + "pathe": "^2.0.3" + } + }, + "node_modules/localstorage-polyfill": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/localstorage-polyfill/-/localstorage-polyfill-1.0.1.tgz", + "integrity": "sha512-m4iHVZxFH5734oQcPKU08025gIz2+4bjWR9lulP8ZYxEJR0BpA0w32oJmkzh8y3UI9ci7xCBehQDc3oA1X+VHw==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash.camelcase": { + "version": "4.3.0", + "resolved": "https://registry.npmmirror.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", + "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", + "license": "MIT" + }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmmirror.com/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.11", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.11.tgz", + "integrity": "sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/meow": { + "version": "13.2.0", + "resolved": "https://registry.npmmirror.com/meow/-/meow-13.2.0.tgz", + "integrity": "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/merge/-/merge-2.1.1.tgz", + "integrity": "sha512-jz+Cfrg9GWOZbQAnDQ4hlVnQky+341Yk5ru8bZSe6sIDTCIg8n9i/u7hSQGSVOF3C7lH6mGtqjkiT9G4wFLL0w==", + "license": "MIT" + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-3.0.0.tgz", + "integrity": "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmmirror.com/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/min-document": { + "version": "2.19.2", + "resolved": "https://registry.npmmirror.com/min-document/-/min-document-2.19.2.tgz", + "integrity": "sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==", + "license": "MIT", + "dependencies": { + "dom-walk": "^0.1.0" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmmirror.com/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmmirror.com/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, + "node_modules/mlly": { + "version": "1.8.2", + "resolved": "https://registry.npmmirror.com/mlly/-/mlly-1.8.2.tgz", + "integrity": "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==", + "license": "MIT", + "dependencies": { + "acorn": "^8.16.0", + "pathe": "^2.0.3", + "pkg-types": "^1.3.1", + "ufo": "^1.6.3" + } + }, + "node_modules/module-alias": { + "version": "2.2.3", + "resolved": "https://registry.npmmirror.com/module-alias/-/module-alias-2.2.3.tgz", + "integrity": "sha512-23g5BFj4zdQL/b6tor7Ji+QY4pEfNH784BMslY9Qb0UnJWRAt+lQGLYmRaM0KDBwIG23ffEBELhZDP2rhi9f/Q==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.3.1", + "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.3.1.tgz", + "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmmirror.com/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmmirror.com/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/npm-run-path": { + "version": "4.0.1", + "resolved": "https://registry.npmmirror.com/npm-run-path/-/npm-run-path-4.0.1.tgz", + "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmmirror.com/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/omggif": { + "version": "1.0.10", + "resolved": "https://registry.npmmirror.com/omggif/-/omggif-1.0.10.tgz", + "integrity": "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==", + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmmirror.com/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "resolved": "https://registry.npmmirror.com/onetime/-/onetime-5.1.2.tgz", + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/os-locale-s-fix": { + "version": "1.0.8-fix-1", + "resolved": "https://registry.npmmirror.com/os-locale-s-fix/-/os-locale-s-fix-1.0.8-fix-1.tgz", + "integrity": "sha512-Sv0OvhPiMutICiwORAUefv02DCPb62IelBmo8ZsSrRHyI3FStqIWZvjqDkvtjU+lcujo7UNir+dCwKSqlEQ/5w==", + "license": "MIT", + "dependencies": { + "lcid": "^3.0.0" + }, + "engines": { + "node": ">=10", + "yarn": "^1.22.4" + } + }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, + "node_modules/parse-bmfont-ascii": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/parse-bmfont-ascii/-/parse-bmfont-ascii-1.0.6.tgz", + "integrity": "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA==", + "license": "MIT" + }, + "node_modules/parse-bmfont-binary": { + "version": "1.0.6", + "resolved": "https://registry.npmmirror.com/parse-bmfont-binary/-/parse-bmfont-binary-1.0.6.tgz", + "integrity": "sha512-GxmsRea0wdGdYthjuUeWTMWPqm2+FAd4GI8vCvhgJsFnoGhTrLhXDDupwTo7rXVAgaLIGoVHDZS9p/5XbSqeWA==", + "license": "MIT" + }, + "node_modules/parse-bmfont-xml": { + "version": "1.1.6", + "resolved": "https://registry.npmmirror.com/parse-bmfont-xml/-/parse-bmfont-xml-1.1.6.tgz", + "integrity": "sha512-0cEliVMZEhrFDwMh4SxIyVJpqYoOWDJ9P895tFuS+XuNzI5UBmBk5U5O4KuJdTnZpSBI4LFA2+ZiJaiwfSwlMA==", + "license": "MIT", + "dependencies": { + "xml-parse-from-string": "^1.0.0", + "xml2js": "^0.5.0" + } + }, + "node_modules/parse-css-font": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/parse-css-font/-/parse-css-font-4.0.0.tgz", + "integrity": "sha512-lnY7dTUfjRXsSo5G5C639L8RaBBaVSgL+5hacIFKsNHzeCJQ5SFSZv1DZmc7+wZv/22PFGOq2YbaEHLdaCS/mQ==", + "license": "MIT", + "dependencies": { + "css-font-size-keywords": "^1.0.0", + "css-font-stretch-keywords": "^1.0.1", + "css-font-style-keywords": "^1.0.1", + "css-font-weight-keywords": "^1.0.0", + "css-list-helpers": "^2.0.0", + "css-system-font-keywords": "^1.0.0", + "unquote": "^1.1.1" + } + }, + "node_modules/parse-headers": { + "version": "2.0.6", + "resolved": "https://registry.npmmirror.com/parse-headers/-/parse-headers-2.0.6.tgz", + "integrity": "sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==", + "license": "MIT" + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-to-regexp": { + "version": "0.1.10", + "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.10.tgz", + "integrity": "sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==", + "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==", + "license": "MIT" + }, + "node_modules/phin": { + "version": "2.9.3", + "resolved": "https://registry.npmmirror.com/phin/-/phin-2.9.3.tgz", + "integrity": "sha512-CzFr90qM24ju5f88quFC/6qohjC144rehe5n6DH900lgXmUe86+xCKc10ev56gRKC4/BkHUoG4uSiQgBiIXwDA==", + "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmmirror.com/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinia": { + "version": "2.1.7", + "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.1.7.tgz", + "integrity": "sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.5.0", + "vue-demi": ">=0.14.5" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "@vue/composition-api": "^1.4.0", + "typescript": ">=4.4.4", + "vue": "^2.6.14 || ^3.3.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/pixelmatch": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/pixelmatch/-/pixelmatch-4.0.2.tgz", + "integrity": "sha512-J8B6xqiO37sU/gkcMglv6h5Jbd9xNER7aHzpfRdNmV4IbQBzBpe4l9XmbG+xPF/znacgu2jfEw+wHffaq/YkXA==", + "license": "ISC", + "dependencies": { + "pngjs": "^3.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pkg-types": { + "version": "1.3.1", + "resolved": "https://registry.npmmirror.com/pkg-types/-/pkg-types-1.3.1.tgz", + "integrity": "sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==", + "license": "MIT", + "dependencies": { + "confbox": "^0.1.8", + "mlly": "^1.7.4", + "pathe": "^2.0.1" + } + }, + "node_modules/pngjs": { + "version": "3.4.0", + "resolved": "https://registry.npmmirror.com/pngjs/-/pngjs-3.4.0.tgz", + "integrity": "sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "14.1.0", + "resolved": "https://registry.npmmirror.com/postcss-import/-/postcss-import-14.1.0.tgz", + "integrity": "sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-load-config": { + "version": "3.1.4", + "resolved": "https://registry.npmmirror.com/postcss-load-config/-/postcss-load-config-3.1.4.tgz", + "integrity": "sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==", + "license": "MIT", + "dependencies": { + "lilconfig": "^2.0.5", + "yaml": "^1.10.2" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-modules": { + "version": "4.3.1", + "resolved": "https://registry.npmmirror.com/postcss-modules/-/postcss-modules-4.3.1.tgz", + "integrity": "sha512-ItUhSUxBBdNamkT3KzIZwYNNRFKmkJrofvC2nWab3CPKhYBQ1f27XXh1PAPE27Psx58jeelPsxWB/+og+KEH0Q==", + "license": "MIT", + "dependencies": { + "generic-names": "^4.0.0", + "icss-replace-symbols": "^1.1.0", + "lodash.camelcase": "^4.3.0", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "string-hash": "^1.1.1" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmmirror.com/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope/node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmmirror.com/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmmirror.com/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/postcss/node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmmirror.com/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmmirror.com/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qrcode-reader": { + "version": "1.0.4", + "resolved": "https://registry.npmmirror.com/qrcode-reader/-/qrcode-reader-1.0.4.tgz", + "integrity": "sha512-rRjALGNh9zVqvweg1j5OKIQKNsw3bLC+7qwlnead5K/9cb1cEIAGkwikt/09U0K+2IDWGD9CC6SP7tHAjUeqvQ==", + "license": "Apache-2.0" + }, + "node_modules/qrcode-terminal": { + "version": "0.12.0", + "resolved": "https://registry.npmmirror.com/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz", + "integrity": "sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ==", + "bin": { + "qrcode-terminal": "bin/qrcode-terminal.js" + } + }, + "node_modules/qs": { + "version": "6.11.0", + "resolved": "https://registry.npmmirror.com/qs/-/qs-6.11.0.tgz", + "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.0.4" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/quansync": { + "version": "0.2.11", + "resolved": "https://registry.npmmirror.com/quansync/-/quansync-0.2.11.tgz", + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/antfu" + }, + { + "type": "individual", + "url": "https://github.com/sponsors/sxzz" + } + ], + "license": "MIT" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmmirror.com/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true, + "license": "MIT" + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.2", + "resolved": "https://registry.npmmirror.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz", + "integrity": "sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true, + "license": "MIT" + }, + "node_modules/regexpu-core": { + "version": "6.4.0", + "resolved": "https://registry.npmmirror.com/regexpu-core/-/regexpu-core-6.4.0.tgz", + "integrity": "sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA==", + "dev": true, + "license": "MIT", + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.2", + "regjsgen": "^0.8.0", + "regjsparser": "^0.13.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.2.1" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmmirror.com/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/regjsparser": { + "version": "0.13.1", + "resolved": "https://registry.npmmirror.com/regjsparser/-/regjsparser-0.13.1.tgz", + "integrity": "sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "jsesc": "~3.1.0" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-area-insets": { + "version": "1.4.1", + "resolved": "https://registry.npmmirror.com/safe-area-insets/-/safe-area-insets-1.4.1.tgz", + "integrity": "sha512-r/nRWTjFGhhm3w1Z6Kd/jY11srN+lHt2mNl1E/emQGW8ic7n3Avu4noibklfSM+Y34peNphHD/BSZecav0sXYQ==", + "license": "ISC" + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/sass": { + "version": "1.99.0", + "resolved": "https://registry.npmmirror.com/sass/-/sass-1.99.0.tgz", + "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass/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/sass/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/sax": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/sax/-/sax-1.6.0.tgz", + "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=11.0.0" + } + }, + "node_modules/scule": { + "version": "1.3.0", + "resolved": "https://registry.npmmirror.com/scule/-/scule-1.3.0.tgz", + "integrity": "sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmmirror.com/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/send": { + "version": "0.19.0", + "resolved": "https://registry.npmmirror.com/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/send/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/serve-static": { + "version": "1.16.0", + "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.0.tgz", + "integrity": "sha512-pDLK8zwl2eKaYrs8mrPZBJua4hMplRWJ1tIFksVC3FtBEBnl8dxgeHtsaMS8DhS9i4fLObaon6ABoc4/hQGdPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "0.18.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/serve-static/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/serve-static/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/serve-static/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/serve-static/node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "dev": true, + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/serve-static/node_modules/send": { + "version": "0.18.0", + "resolved": "https://registry.npmmirror.com/send/-/send-0.18.0.tgz", + "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~1.0.2", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "0.5.2", + "http-errors": "2.0.0", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "2.4.1", + "range-parser": "~1.2.1", + "statuses": "2.0.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "dev": true, + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmmirror.com/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmmirror.com/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmmirror.com/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmmirror.com/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/statuses": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/statuses/-/statuses-2.0.1.tgz", + "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string-hash": { + "version": "1.1.3", + "resolved": "https://registry.npmmirror.com/string-hash/-/string-hash-1.1.3.tgz", + "integrity": "sha512-kJUvRUFK49aub+a7T1nNE66EJbZBMnBgoC1UbCZ5n6bsZKBRga4KgBRTMn/pFkeCZSYtNeSyMxPDM0AXWELk2A==", + "license": "CC0-1.0" + }, + "node_modules/strip-final-newline": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz", + "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmmirror.com/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/systemjs": { + "version": "6.15.1", + "resolved": "https://registry.npmmirror.com/systemjs/-/systemjs-6.15.1.tgz", + "integrity": "sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.2", + "resolved": "https://registry.npmmirror.com/tapable/-/tapable-2.3.2.tgz", + "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.46.1", + "resolved": "https://registry.npmmirror.com/terser/-/terser-5.46.1.tgz", + "integrity": "sha512-vzCjQO/rgUuK9sf8VJZvjqiqiHFaZLnOiimmUuOKODxWL8mm/xua7viT7aqX7dgPY60otQjUotzFMmCB4VdmqQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/timm": { + "version": "1.7.1", + "resolved": "https://registry.npmmirror.com/timm/-/timm-1.7.1.tgz", + "integrity": "sha512-IjZc9KIotudix8bMaBW6QvMuq64BrJWFs1+4V0lXwWGQZwH+LnX87doAYhem4caOEusRP9/g6jVDQmZ8XOk1nw==", + "license": "MIT" + }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmmirror.com/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, + "node_modules/to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmmirror.com/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "4.9.5", + "resolved": "https://registry.npmmirror.com/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=4.2.0" + } + }, + "node_modules/ufo": { + "version": "1.6.3", + "resolved": "https://registry.npmmirror.com/ufo/-/ufo-1.6.3.tgz", + "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", + "license": "MIT" + }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmmirror.com/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.1", + "resolved": "https://registry.npmmirror.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz", + "integrity": "sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmmirror.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.2.0.tgz", + "integrity": "sha512-hpbDzxUY9BFwX+UeBnxv3Sh1q7HFxj48DTmXchNgRa46lO8uj3/1iEn3MiNUYTg1g9ctIqXCCERn8gYZhHC5lQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/unimport": { + "version": "4.1.1", + "resolved": "https://registry.npmmirror.com/unimport/-/unimport-4.1.1.tgz", + "integrity": "sha512-j9+fijH6aDd05yv1fXlyt7HSxtOWtGtrZeYTVBsSUg57Iuf+Ps2itIZjeyu7bEQ4k0WOgYhHrdW8m/pJgOpl5g==", + "license": "MIT", + "dependencies": { + "acorn": "^8.14.0", + "escape-string-regexp": "^5.0.0", + "estree-walker": "^3.0.3", + "fast-glob": "^3.3.3", + "local-pkg": "^1.0.0", + "magic-string": "^0.30.17", + "mlly": "^1.7.4", + "pathe": "^2.0.2", + "picomatch": "^4.0.2", + "pkg-types": "^1.3.1", + "scule": "^1.3.0", + "strip-literal": "^3.0.0", + "unplugin": "^2.1.2", + "unplugin-utils": "^0.2.3" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unimport/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmmirror.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/unimport/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/unimport/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/unimport/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/unplugin": { + "version": "2.3.11", + "resolved": "https://registry.npmmirror.com/unplugin/-/unplugin-2.3.11.tgz", + "integrity": "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "acorn": "^8.15.0", + "picomatch": "^4.0.3", + "webpack-virtual-modules": "^0.6.2" + }, + "engines": { + "node": ">=18.12.0" + } + }, + "node_modules/unplugin-utils": { + "version": "0.2.5", + "resolved": "https://registry.npmmirror.com/unplugin-utils/-/unplugin-utils-0.2.5.tgz", + "integrity": "sha512-gwXJnPRewT4rT7sBi/IvxKTjsms7jX7QIDLOClApuZwR49SXbrB1z2NLUZ+vDHyqCj/n58OzRRqaW+B8OZi8vg==", + "license": "MIT", + "dependencies": { + "pathe": "^2.0.3", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/sxzz" + } + }, + "node_modules/unplugin-utils/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/unplugin/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/unquote": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/unquote/-/unquote-1.1.1.tgz", + "integrity": "sha512-vRCqFv6UhXpWxZPyGDh/F3ZpNv8/qo7w6iufLpQg9aKnQ71qM4B5KiI7Mia9COcjEhrO9LueHpMYjYzsWH3OIg==", + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmmirror.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/update-browserslist-db/node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/utif": { + "version": "2.0.1", + "resolved": "https://registry.npmmirror.com/utif/-/utif-2.0.1.tgz", + "integrity": "sha512-Z/S1fNKCicQTf375lIP9G8Sa1H/phcysstNrrSdZKj1f9g58J4NMgb5IgiEZN9/nLMPDwF0W7hdOe9Qq2IYoLg==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.5" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vite": { + "version": "5.2.8", + "resolved": "https://registry.npmmirror.com/vite/-/vite-5.2.8.tgz", + "integrity": "sha512-OyZR+c1CE8yeHw5V5t59aXsUPPVTHMDjEZz8MgguLL/Q7NblxhZUlTu9xSPqlsUO/y+X7dlU05jdhvyycD55DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.20.1", + "postcss": "^8.4.38", + "rollup": "^4.13.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.4.21", + "resolved": "https://registry.npmmirror.com/vue/-/vue-3.4.21.tgz", + "integrity": "sha512-5hjyV/jLEIKD/jYl4cavMcnzKwjMKohureP8ejn3hhEjwhWIhWeuzL2kJAjzl/WyVsgPY56Sy4Z40C3lVshxXA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.4.21", + "@vue/compiler-sfc": "3.4.21", + "@vue/runtime-dom": "3.4.21", + "@vue/server-renderer": "3.4.21", + "@vue/shared": "3.4.21" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-demi": { + "version": "0.14.10", + "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz", + "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "vue-demi-fix": "bin/vue-demi-fix.js", + "vue-demi-switch": "bin/vue-demi-switch.js" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + }, + "peerDependencies": { + "@vue/composition-api": "^1.0.0-rc.1", + "vue": "^3.0.0-0 || ^2.6.0" + }, + "peerDependenciesMeta": { + "@vue/composition-api": { + "optional": true + } + } + }, + "node_modules/vue-i18n": { + "version": "9.14.4", + "resolved": "https://registry.npmmirror.com/vue-i18n/-/vue-i18n-9.14.4.tgz", + "integrity": "sha512-B934C8yUyWLT0EMud3DySrwSUJI7ZNiWYsEEz2gknTthqKiG4dzWE/WSa8AzCuSQzwBEv4HtG1jZDhgzPfWSKQ==", + "license": "MIT", + "dependencies": { + "@intlify/core-base": "9.14.4", + "@intlify/shared": "9.14.4", + "@vue/devtools-api": "^6.5.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + }, + "peerDependencies": { + "vue": "^3.0.0" + } + }, + "node_modules/vue-i18n/node_modules/@intlify/core-base": { + "version": "9.14.4", + "resolved": "https://registry.npmmirror.com/@intlify/core-base/-/core-base-9.14.4.tgz", + "integrity": "sha512-vtZCt7NqWhKEtHa3SD/322DlgP5uR9MqWxnE0y8Q0tjDs9H5Lxhss+b5wv8rmuXRoHKLESNgw9d+EN9ybBbj9g==", + "license": "MIT", + "dependencies": { + "@intlify/message-compiler": "9.14.4", + "@intlify/shared": "9.14.4" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/vue-i18n/node_modules/@intlify/message-compiler": { + "version": "9.14.4", + "resolved": "https://registry.npmmirror.com/@intlify/message-compiler/-/message-compiler-9.14.4.tgz", + "integrity": "sha512-vcyCLiVRN628U38c3PbahrhbbXrckrM9zpy0KZVlDk2Z0OnGwv8uQNNXP3twwGtfLsCf4gu3ci6FMIZnPaqZsw==", + "license": "MIT", + "dependencies": { + "@intlify/shared": "9.14.4", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/vue-i18n/node_modules/@intlify/shared": { + "version": "9.14.4", + "resolved": "https://registry.npmmirror.com/@intlify/shared/-/shared-9.14.4.tgz", + "integrity": "sha512-P9zv6i1WvMc9qDBWvIgKkymjY2ptIiQ065PjDv7z7fDqH3J/HBRBN5IoiR46r/ujRcU7hCuSIZWvCAFCyuOYZA==", + "license": "MIT", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/kazupon" + } + }, + "node_modules/vue-router": { + "version": "4.4.4", + "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.4.4.tgz", + "integrity": "sha512-3MlnDqwRwZwCQVbtVfpsU+nrNymNjnXSsQtXName5925NVC1+326VVfYH9vSrA0N13teGEo8z5x7gbRnGjCDiQ==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.2.0" + } + }, + "node_modules/vue-template-compiler": { + "version": "2.7.16", + "resolved": "https://registry.npmmirror.com/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz", + "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "de-indent": "^1.0.2", + "he": "^1.2.0" + } + }, + "node_modules/vue-tsc": { + "version": "1.8.27", + "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-1.8.27.tgz", + "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "~1.11.1", + "@vue/language-core": "1.8.27", + "semver": "^7.5.4" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/vue-tsc/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/webpack-virtual-modules": { + "version": "0.6.2", + "resolved": "https://registry.npmmirror.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", + "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", + "license": "MIT" + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmmirror.com/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.18.0", + "resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xhr": { + "version": "2.6.0", + "resolved": "https://registry.npmmirror.com/xhr/-/xhr-2.6.0.tgz", + "integrity": "sha512-/eCGLb5rxjx5e3mF1A7s+pLlR6CGyqWN91fv1JgER5mVWg1MZmlhBvy9kjcsOdRk8RrIujotWyJamfyrp+WIcA==", + "license": "MIT", + "dependencies": { + "global": "~4.4.0", + "is-function": "^1.0.1", + "parse-headers": "^2.0.0", + "xtend": "^4.0.0" + } + }, + "node_modules/xml-parse-from-string": { + "version": "1.0.1", + "resolved": "https://registry.npmmirror.com/xml-parse-from-string/-/xml-parse-from-string-1.0.1.tgz", + "integrity": "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==", + "license": "MIT" + }, + "node_modules/xml2js": { + "version": "0.5.0", + "resolved": "https://registry.npmmirror.com/xml2js/-/xml2js-0.5.0.tgz", + "integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==", + "license": "MIT", + "dependencies": { + "sax": ">=0.6.0", + "xmlbuilder": "~11.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/xmlbuilder": { + "version": "11.0.1", + "resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz", + "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xmlhttprequest": { + "version": "1.8.0", + "resolved": "https://registry.npmmirror.com/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz", + "integrity": "sha512-58Im/U0mlVBLM38NdZjHyhuMtCqa61469k2YP/AaPbvCoV9aQGUpbJBj1QRm2ytRiVQBD/fsw7L2bJGDVQswBA==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/xregexp": { + "version": "3.1.0", + "resolved": "https://registry.npmmirror.com/xregexp/-/xregexp-3.1.0.tgz", + "integrity": "sha512-4Y1x6DyB8xRoxosooa6PlGWqmmSKatbzhrftZ7Purmm4B8R4qIEJG1A2hZsdz5DhmIqS0msC0I7KEq93GphEVg==", + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmmirror.com/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "license": "ISC" + }, + "node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmmirror.com/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + } + } +} diff --git a/user-app/package.json b/user-app/package.json new file mode 100644 index 0000000..146f264 --- /dev/null +++ b/user-app/package.json @@ -0,0 +1,74 @@ +{ + "name": "uni-preset-vue", + "version": "0.0.0", + "scripts": { + "dev:custom": "uni -p", + "dev:h5": "uni", + "dev:h5:ssr": "uni --ssr", + "dev:mp-alipay": "uni -p mp-alipay", + "dev:mp-baidu": "uni -p mp-baidu", + "dev:mp-jd": "uni -p mp-jd", + "dev:mp-kuaishou": "uni -p mp-kuaishou", + "dev:mp-lark": "uni -p mp-lark", + "dev:mp-qq": "uni -p mp-qq", + "dev:mp-toutiao": "uni -p mp-toutiao", + "dev:mp-harmony": "uni -p mp-harmony", + "dev:mp-weixin": "uni -p mp-weixin", + "dev:mp-xhs": "uni -p mp-xhs", + "dev:quickapp-webview": "uni -p quickapp-webview", + "dev:quickapp-webview-huawei": "uni -p quickapp-webview-huawei", + "dev:quickapp-webview-union": "uni -p quickapp-webview-union", + "build:custom": "uni build -p", + "build:h5": "uni build", + "build:h5:ssr": "uni build --ssr", + "build:mp-alipay": "uni build -p mp-alipay", + "build:mp-baidu": "uni build -p mp-baidu", + "build:mp-jd": "uni build -p mp-jd", + "build:mp-kuaishou": "uni build -p mp-kuaishou", + "build:mp-lark": "uni build -p mp-lark", + "build:mp-qq": "uni build -p mp-qq", + "build:mp-toutiao": "uni build -p mp-toutiao", + "build:mp-harmony": "uni build -p mp-harmony", + "sync:mp-config": "php ../server-api/tools/sync_client_configs.php", + "build:mp-weixin": "npm run sync:mp-config && uni build -p mp-weixin", + "build:mp-xhs": "uni build -p mp-xhs", + "build:quickapp-webview": "uni build -p quickapp-webview", + "build:quickapp-webview-huawei": "uni build -p quickapp-webview-huawei", + "build:quickapp-webview-union": "uni build -p quickapp-webview-union", + "type-check": "vue-tsc --noEmit" + }, + "dependencies": { + "@dcloudio/uni-app": "3.0.0-4080420251103001", + "@dcloudio/uni-app-harmony": "3.0.0-4080420251103001", + "@dcloudio/uni-app-plus": "3.0.0-4080420251103001", + "@dcloudio/uni-components": "3.0.0-4080420251103001", + "@dcloudio/uni-h5": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-alipay": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-baidu": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-harmony": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-jd": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-kuaishou": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-lark": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-qq": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-toutiao": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-weixin": "3.0.0-4080420251103001", + "@dcloudio/uni-mp-xhs": "3.0.0-4080420251103001", + "@dcloudio/uni-quickapp-webview": "3.0.0-4080420251103001", + "pinia": "^2.1.7", + "vue": "^3.4.21", + "vue-i18n": "^9.1.9" + }, + "devDependencies": { + "@dcloudio/types": "^3.4.8", + "@dcloudio/uni-automator": "3.0.0-4080420251103001", + "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001", + "@dcloudio/uni-stacktracey": "3.0.0-4080420251103001", + "@dcloudio/vite-plugin-uni": "3.0.0-4080420251103001", + "@vue/runtime-core": "^3.4.21", + "@vue/tsconfig": "^0.1.3", + "sass": "^1.99.0", + "typescript": "^4.9.4", + "vite": "5.2.8", + "vue-tsc": "^1.0.24" + } +} diff --git a/user-app/shims-uni.d.ts b/user-app/shims-uni.d.ts new file mode 100644 index 0000000..ed4adcf --- /dev/null +++ b/user-app/shims-uni.d.ts @@ -0,0 +1,10 @@ +/// +import 'vue' + +declare module '@vue/runtime-core' { + type Hooks = App.AppInstance & Page.PageInstance; + + interface ComponentCustomOptions extends Hooks { + + } +} diff --git a/user-app/src/App.vue b/user-app/src/App.vue new file mode 100644 index 0000000..b3d7585 --- /dev/null +++ b/user-app/src/App.vue @@ -0,0 +1,17 @@ + + diff --git a/user-app/src/api/app.ts b/user-app/src/api/app.ts new file mode 100644 index 0000000..66652ce --- /dev/null +++ b/user-app/src/api/app.ts @@ -0,0 +1,820 @@ +import { parseUploadResponse, request } from "../utils/request"; +import { buildAuthHeaders } from "../utils/auth"; +import { resolveApiBaseUrl } from "../utils/env"; + +export interface PageVisualsData { + order_background_image_url?: string; + report_background_image_url?: string; +} + +export interface HomeData { + banners: Array<{ + title: string; + subtitle: string; + description: string; + background_image_url?: string; + }>; + page_visuals: PageVisualsData; + service_entries: Array<{ + service_provider: string; + title: string; + tag: string; + description: string; + meta: string; + }>; + category_entries: Array<{ + category_id: number; + category_name: string; + category_code: string; + image_url?: string; + }>; + trust_points: Array<{ + title: string; + desc: string; + }>; + quick_entries: Array<{ + code: string; + title: string; + desc: string; + }>; + trust_metrics: Array<{ + value: string; + label: string; + }>; + faqs: string[]; +} + +export interface HelpCategoryItem { + code: "all" | "service" | "report" | "shipping" | "support"; + title: string; + desc: string; + count: number; +} + +export interface HelpArticleSummary { + id: number; + title: string; + category: "service" | "report" | "shipping" | "support"; + category_text: string; + summary: string; + keywords: string[]; + updated_at: string; + is_recommended: boolean; +} + +export interface HelpCenterData { + categories: HelpCategoryItem[]; + articles: HelpArticleSummary[]; +} + +export interface TicketTypeOption { + code: string; + title: string; + hint: string; + quick_desc: string; +} + +export interface TicketStatusOption { + code: string; + title: string; + desc: string; +} + +export interface MessagePageCopy { + title: string; + desc: string; +} + +export interface HelpArticleDetailData { + article: HelpArticleSummary & { + content_blocks: string[]; + }; + related_articles: HelpArticleSummary[]; +} + +export interface SettingsData { + profile_info: { + user_id: number; + nickname: string; + mobile: string; + avatar: string; + status: string; + status_text: string; + password_set: boolean; + }; + preferences: { + notify_order: boolean; + notify_report: boolean; + notify_supplement: boolean; + notify_ticket: boolean; + marketing_notify: boolean; + privacy_mode: boolean; + }; + legal_entries: Array<{ + code: string; + title: string; + desc: string; + target_url: string; + }>; +} + +export interface MineOverviewData { + profile_info: SettingsData["profile_info"]; + asset_summary: { + total_valuation: number; + item_count: number; + report_count: number; + authentic_rate: number; + unread_count: number; + }; +} + +export interface OrderListItem { + order_id: number; + order_no: string; + appraisal_no: string; + order_status: string; + product_name: string; + product_cover: string; + service_provider: string; + display_status: string; + status_desc: string; + estimated_finish_time: string; + primary_action: string; +} + +export interface OrderDetailData { + order_info: { + order_id: number; + order_no: string; + appraisal_no: string; + service_provider: string; + order_status: string; + display_status: string; + status_desc: string; + estimated_finish_time: string; + can_edit_return_address: boolean; + }; + product_info: { + product_name: string; + category_name: string; + brand_name: string; + color: string; + size_spec: string; + serial_no: string; + }; + extra_info: { + purchase_channel: string; + purchase_price: number; + purchase_date: string; + usage_status: string; + usage_status_text: string; + condition_desc: string; + has_accessories: boolean; + accessories: string[]; + remark: string; + }; + materials: Array<{ + upload_item_id: number; + item_code: string; + item_name: string; + is_required: boolean; + source_type: string; + source_type_text: string; + status: string; + status_text: string; + file_count: number; + files: Array<{ + file_id: string; + file_url: string; + thumbnail_url: string; + quality_status: string; + quality_message: string; + }>; + }>; + return_address: null | { + user_address_id: number; + consignee: string; + mobile: string; + province: string; + city: string; + district: string; + detail_address: string; + full_address: string; + }; + return_logistics: null | { + express_company: string; + tracking_no: string; + tracking_status: string; + tracking_status_text: string; + latest_desc: string; + latest_time: string; + nodes: Array<{ + node_time: string; + node_desc: string; + node_location: string; + }>; + }; + timeline: Array<{ + node_code: string; + node_text: string; + node_desc: string; + occurred_at: string; + }>; + supplement_task: null | { + task_id: number; + reason: string; + deadline: string; + items: Array<{ + item_code: string; + item_name: string; + guide_text: string; + }>; + }; + available_actions: { + primary_action: string; + secondary_action: string; + }; +} + +export interface ShippingDetailData { + order_info: { + order_id: number; + order_no: string; + appraisal_no: string; + service_provider: string; + display_status: string; + estimated_finish_time: string; + product_name: string; + }; + shipping_address: { + warehouse_id?: number; + warehouse_name?: string; + warehouse_code?: string; + receiver_name: string; + receiver_mobile: string; + province: string; + city: string; + district: string; + detail_address: string; + service_time: string; + notice: string; + }; + shipping_options: { + current_warehouse_id: number; + can_select_warehouse: boolean; + list: Array<{ + id: number; + warehouse_name: string; + warehouse_code: string; + warehouse_type: string; + warehouse_type_text: string; + service_provider: string; + service_provider_text: string; + receiver_name: string; + receiver_mobile: string; + province: string; + city: string; + district: string; + detail_address: string; + full_address: string; + service_time: string; + notice: string; + supported_category_ids: number[]; + supported_category_names: string[]; + status: string; + status_text: string; + is_default: boolean; + is_recommended?: boolean; + recommended_reason?: string; + sort_order: number; + remark: string; + created_at: string; + updated_at: string; + }>; + }; + shipping_notice: { + tips: string[]; + express_recommendations: string[]; + }; + logistics_info: { + express_company: string; + tracking_no: string; + tracking_status: string; + tracking_status_text: string; + latest_desc: string; + latest_time: string; + is_submitted: boolean; + }; + logistics_nodes: Array<{ + node_time: string; + node_desc: string; + node_location: string; + }>; + can_submit_tracking: boolean; +} + +export interface UserAddressItem { + id: number; + consignee: string; + mobile: string; + province: string; + city: string; + district: string; + detail_address: string; + full_address: string; + is_default: boolean; + created_at: string; + updated_at: string; +} + +export interface ReportListItem { + report_id: number | null; + order_id: number; + report_no: string; + product_name: string; + product_cover: string; + service_provider: string; + status: string; + result_text: string; + institution_name: string; + publish_time: string; +} + +export interface ReportDetailData { + evidence_attachments: EvidenceAttachmentAsset[]; + report_header: { + report_id: number; + report_no: string; + report_type: string; + report_title: string; + report_status: string; + service_provider: string; + institution_name: string; + publish_time: string; + }; + result_info: Record; + product_info: Record; + appraisal_info: Record; + valuation_info: Record; + risk_notice_text: string; + verify_info: { + report_no: string; + verify_status: string; + verify_url: string; + verify_qrcode_url: string; + }; + file_info: { + pdf_url: string; + }; +} + +export interface VerifyData { + verify_status: string; + verify_message: string; + evidence_attachments: EvidenceAttachmentAsset[]; + report_summary: { + report_no: string; + report_title?: string; + institution_name: string; + publish_time: string; + }; + product_summary: Record; + result_summary: Record; +} + +export interface MaterialTagData { + tag_status: "unbound" | "pending_report" | "published" | "not_found"; + status_text: string; + message: string; + qr_token: string; + qr_url: string; + scan_count: number; + verify_count: number; + report_summary: null | { + report_id?: number; + report_no: string; + report_title: string; + institution_name: string; + publish_time: string; + }; + product_summary: Record; + result_summary: Record; + verify_passed: boolean; +} + +export interface MaterialTagVerifyResult { + verify_passed: boolean; + verify_message: string; + verify_count: number; +} + +export interface MessageSummaryData { + total_count: number; + unread_count: number; + category_counts: { + all: number; + order: number; + report: number; + supplement: number; + ticket: number; + }; + latest_title: string; + latest_time: string; +} + +export interface UserMessageItem { + id: number; + title: string; + content: string; + biz_type: string; + biz_type_text: string; + category: "all" | "order" | "report" | "supplement" | "ticket"; + category_text: string; + biz_id: number; + is_read: boolean; + created_at: string; + target_url: string; + target_label: string; +} + +export interface UserMessageListData { + list: UserMessageItem[]; + summary: { + total_count: number; + unread_count: number; + category_counts: { + all: number; + order: number; + report: number; + supplement: number; + ticket: number; + }; + current_count: number; + current_category: "all" | "order" | "report" | "supplement" | "ticket"; + unread_only: boolean; + }; +} + +export interface SupplementFileItem { + id: number; + file_id: string; + file_url: string; + thumbnail_url: string; +} + +export interface SupplementDetailData { + order_id: number; + order_no: string; + appraisal_no: string; + reason: string; + deadline: string; + items: Array<{ + upload_item_id: number; + item_code: string; + item_name: string; + guide_text: string; + is_required: boolean; + status: string; + files: SupplementFileItem[]; + }>; +} + +export interface TicketOverviewCard { + title: string; + value: number; + desc: string; +} + +export interface TicketAttachmentAsset { + file_id: string; + file_url: string; + thumbnail_url: string; + name?: string; +} + +export interface EvidenceAttachmentAsset { + file_id: string; + file_url: string; + thumbnail_url: string; + name?: string; + file_type: string; + mime_type: string; +} + +export interface UserTicketListItem { + id: number; + ticket_no: string; + ticket_type: string; + ticket_type_text: string; + status: string; + status_text: string; + priority: string; + priority_text: string; + title: string; + order_id: number; + latest_message: string; + updated_at: string; + created_at: string; +} + +export interface UserTicketDetailData { + ticket_info: { + id: number; + ticket_no: string; + ticket_type: string; + ticket_type_text: string; + status: string; + status_text: string; + priority: string; + priority_text: string; + title: string; + content: string; + order_id: number; + created_at: string; + updated_at: string; + }; + order_info: null | { + order_id: number; + order_no: string; + display_status: string; + }; + messages: Array<{ + sender_type: string; + sender_type_text: string; + content: string; + attachments: TicketAttachmentAsset[]; + created_at: string; + }>; +} + +export const appApi = { + getHomeData() { + return request("/api/app/home/index"); + }, + getPageVisuals() { + return request("/api/app/content/page-visuals"); + }, + getHelpCenter(params?: Record) { + return request("/api/app/help-center", { + params, + }); + }, + getHelpArticleDetail(id: number) { + return request("/api/app/help-article/detail", { + params: { id }, + }); + }, + getSettings() { + return request("/api/app/settings"); + }, + getMineOverview() { + return request("/api/app/mine/overview"); + }, + saveSettings(payload: { + nickname: string; + preferences: SettingsData["preferences"]; + }) { + return request("/api/app/settings/save", { + method: "POST", + data: payload, + }); + }, + getOrders() { + return request<{ list: OrderListItem[] }>("/api/app/orders"); + }, + getOrderDetail(id: number) { + return request("/api/app/order/detail", { + params: { id }, + }); + }, + getOrderShippingDetail(orderId: number) { + return request("/api/app/order/shipping", { + params: { order_id: orderId }, + }); + }, + saveOrderShipping(payload: { + order_id: number; + express_company: string; + tracking_no: string; + warehouse_id?: number; + }) { + return request<{ + order_id: number; + express_company: string; + tracking_no: string; + }>("/api/app/order/shipping/save", { + method: "POST", + data: payload, + }); + }, + saveOrderReturnAddress(payload: { + order_id: number; + address_id: number; + }) { + return request<{ + order_id: number; + return_address: OrderDetailData["return_address"]; + }>("/api/app/order/return-address/save", { + method: "POST", + data: payload, + }); + }, + getAddresses() { + return request<{ list: UserAddressItem[] }>("/api/app/addresses"); + }, + getAddressDetail(id: number) { + return request("/api/app/address/detail", { + params: { id }, + }); + }, + saveAddress(payload: { + id?: number; + consignee: string; + mobile: string; + province: string; + city: string; + district: string; + detail_address: string; + is_default: boolean; + }) { + return request<{ id: number; address: UserAddressItem }>("/api/app/address/save", { + method: "POST", + data: payload, + }); + }, + setDefaultAddress(id: number) { + return request<{ id: number }>("/api/app/address/default", { + method: "POST", + data: { id }, + }); + }, + deleteAddress(id: number) { + return request<{ id: number }>("/api/app/address/delete", { + method: "POST", + data: { id }, + }); + }, + getReports() { + return request<{ list: ReportListItem[] }>("/api/app/reports"); + }, + getReportDetail(params: { id?: number; report_no?: string }) { + return request("/api/app/report/detail", { + params, + }); + }, + verifyReport(reportNo: string) { + return request("/api/app/verify", { + params: { report_no: reportNo }, + }); + }, + getMaterialTag(token: string) { + return request("/api/app/material-tag", { + params: { token }, + }); + }, + verifyMaterialTag(payload: { + token: string; + report_no: string; + verify_code: string; + }) { + return request("/api/app/material-tag/verify", { + method: "POST", + data: payload, + }); + }, + getMessageSummary() { + return request("/api/app/messages/summary"); + }, + getMessageMeta() { + return request<{ message_page_copy: MessagePageCopy }>("/api/app/messages/meta"); + }, + getMessages(params?: Record) { + return request("/api/app/messages", { + params, + }); + }, + readMessage(id: number) { + return request<{ id: number; is_read: boolean }>("/api/app/message/read", { + method: "POST", + data: { id }, + }); + }, + readAllMessages() { + return request<{ affected: number }>("/api/app/messages/read-all", { + method: "POST", + }); + }, + getSupplementDetail(orderId: number) { + return request("/api/app/order/supplement", { + params: { order_id: orderId }, + }); + }, + uploadSupplementFile(payload: { + uploadItemId: number; + filePath: string; + }) { + const baseUrl = resolveApiBaseUrl().replace(/\/$/, ""); + return new Promise((resolve, reject) => { + uni.uploadFile({ + url: `${baseUrl}/api/app/order/supplement/file/upload`, + filePath: payload.filePath, + name: "file", + header: buildAuthHeaders(), + formData: { + upload_item_id: payload.uploadItemId, + }, + success: (response) => { + try { + resolve(parseUploadResponse(response, "补资料上传失败")); + } catch (error) { + reject(error); + } + }, + fail: (error) => reject(error), + }); + }); + }, + deleteSupplementFile(fileId: string) { + return request<{ file_id: string }>("/api/app/order/supplement/file/delete", { + method: "POST", + data: { + file_id: fileId, + }, + }); + }, + submitSupplement(orderId: number) { + return request<{ order_id: number; next_status: string }>("/api/app/order/supplement/submit", { + method: "POST", + data: { + order_id: orderId, + }, + }); + }, + getTicketOverview() { + return request<{ cards: TicketOverviewCard[]; ticket_types: TicketTypeOption[] }>("/api/app/tickets/overview"); + }, + getTicketMeta() { + return request<{ ticket_types: TicketTypeOption[]; ticket_statuses: TicketStatusOption[] }>("/api/app/ticket/meta"); + }, + getTickets(params?: Record) { + return request<{ list: UserTicketListItem[] }>("/api/app/tickets", { + params, + }); + }, + getTicketDetail(id: number) { + return request("/api/app/ticket/detail", { + params: { id }, + }); + }, + createTicket(payload: { + ticket_type: string; + title: string; + content: string; + order_id?: number; + report_id?: number; + attachments?: TicketAttachmentAsset[]; + }) { + return request<{ ticket_id: number; ticket_no: string }>("/api/app/ticket/create", { + method: "POST", + data: payload, + }); + }, + replyTicket(ticketId: number, content: string, attachments: TicketAttachmentAsset[] = []) { + return request<{ ticket_id: number }>("/api/app/ticket/reply", { + method: "POST", + data: { + ticket_id: ticketId, + content, + attachments, + }, + }); + }, + uploadTicketFile(filePath: string) { + const baseUrl = resolveApiBaseUrl().replace(/\/$/, ""); + return new Promise((resolve, reject) => { + uni.uploadFile({ + url: `${baseUrl}/api/app/ticket/file/upload`, + filePath, + name: "file", + header: buildAuthHeaders(), + success: (response) => { + try { + resolve(parseUploadResponse(response, "附件上传失败")); + } catch (error) { + reject(error); + } + }, + fail: (error) => reject(error), + }); + }); + }, + deleteTicketFile(fileUrl: string) { + return request<{ file_url: string }>("/api/app/ticket/file/delete", { + method: "POST", + data: { + file_url: fileUrl, + }, + }); + }, +}; diff --git a/user-app/src/api/appraisal.ts b/user-app/src/api/appraisal.ts new file mode 100644 index 0000000..e547363 --- /dev/null +++ b/user-app/src/api/appraisal.ts @@ -0,0 +1,175 @@ +import { parseUploadResponse, request } from "../utils/request"; +import { buildAuthHeaders } from "../utils/auth"; +import { resolveApiBaseUrl } from "../utils/env"; +import { resolveOrderSourceChannel } from "../utils/order-source"; + +export interface CatalogOption { + brand_id?: number; + brand_name?: string; +} + +export interface CategoryOption { + category_id: number; + category_name: string; + category_code: string; +} + +export interface UploadItem { + item_code: string; + item_name: string; + guide_text: string; + sample_image_url: string; + is_required: boolean; + quality_status: string; + quality_message: string; + files?: UploadFileAsset[]; +} + +export interface UploadFileAsset { + file_id: string; + file_url: string; + thumbnail_url: string; + name?: string; +} + +export interface DraftDetail { + draft_id: number; + service_provider: string; + service_mode: string; + current_step: number; + product_info: Record; + extra_info: Record; + upload_info?: { + items: UploadItem[]; + }; +} + +export interface PreviewData { + service_summary: { + service_provider: string; + service_provider_text: string; + }; + product_summary: { + product_name: string; + category_name: string; + brand_name: string; + price: number; + }; + upload_summary: { + uploaded_count: number; + }; + fee_detail: { + service_fee: number; + discount_fee: number; + pay_amount: number; + }; + agreements: Array<{ + code: string; + title: string; + desc: string; + target_url: string; + }>; +} + +export interface SubmitResult { + order_id: number; + order_no: string; + appraisal_no: string; + pay_amount: number; + next_status: string; +} + +export const appraisalApi = { + createDraft(serviceProvider: string) { + return request<{ draft_id: number; service_provider: string; service_mode: string }>( + "/api/app/appraisal/draft/create", + { + method: "POST", + data: { + service_provider: serviceProvider, + service_mode: "physical", + }, + }, + ); + }, + getDraft(draftId: number) { + return request("/api/app/appraisal/draft", { + params: { draft_id: draftId }, + }); + }, + saveDraft(payload: Record) { + return request<{ draft_id: number; current_step: number }>("/api/app/appraisal/draft/save", { + method: "POST", + data: payload, + }); + }, + getBrands(categoryId: number) { + return request<{ list: CatalogOption[] }>("/api/app/catalog/brands", { + params: { category_id: categoryId }, + }); + }, + getCategories() { + return request<{ list: CategoryOption[] }>("/api/app/catalog/categories"); + }, + getUploadTemplate(categoryId: number, serviceProvider: string) { + return request<{ template_id: number; required_items: UploadItem[]; optional_items: UploadItem[] }>( + "/api/app/appraisal/upload-template", + { + params: { category_id: categoryId, service_provider: serviceProvider }, + }, + ); + }, + preview(draftId: number) { + return request("/api/app/appraisal/preview", { + method: "POST", + data: { draft_id: draftId }, + }); + }, + submit(draftId: number, returnAddressId?: number) { + return request("/api/app/appraisal/submit", { + method: "POST", + data: { + draft_id: draftId, + return_address_id: returnAddressId, + source_channel: resolveOrderSourceChannel(), + }, + }); + }, + uploadFile(payload: { + draftId: number; + itemCode: string; + itemName: string; + filePath: string; + }) { + const baseUrl = resolveApiBaseUrl().replace(/\/$/, ""); + return new Promise((resolve, reject) => { + uni.uploadFile({ + url: `${baseUrl}/api/app/appraisal/file/upload`, + filePath: payload.filePath, + name: "file", + header: buildAuthHeaders(), + formData: { + draft_id: payload.draftId, + item_code: payload.itemCode, + item_name: payload.itemName, + }, + success: (response) => { + try { + resolve(parseUploadResponse(response, "图片上传失败")); + } catch (error) { + reject(error); + } + }, + fail: (error) => reject(error), + }); + }); + }, + deleteFile(fileUrl: string) { + return request<{ file_url: string }>("/api/app/appraisal/file/delete", { + method: "POST", + data: { + file_url: fileUrl, + }, + }); + }, +}; diff --git a/user-app/src/api/auth.ts b/user-app/src/api/auth.ts new file mode 100644 index 0000000..6f4b812 --- /dev/null +++ b/user-app/src/api/auth.ts @@ -0,0 +1,62 @@ +import { request } from "../utils/request"; + +export interface AuthUserInfo { + id: number; + nickname: string; + mobile: string; + avatar: string; + status: string; + password_set: boolean; +} + +export interface SendLoginCodeResult { + mobile: string; + scene: string; + expire_seconds: number; + retry_after_seconds: number; + debug_code?: string; +} + +export interface LoginResult { + token: string; + user_info: AuthUserInfo; +} + +export const authApi = { + sendLoginCode(mobile: string) { + return request("/api/app/auth/send-code", { + method: "POST", + data: { mobile }, + }); + }, + loginByCode(mobile: string, code: string) { + return request("/api/app/auth/login/code", { + method: "POST", + data: { mobile, code }, + }); + }, + loginByPassword(mobile: string, password: string) { + return request("/api/app/auth/login/password", { + method: "POST", + data: { mobile, password }, + }); + }, + getMe() { + return request<{ user_info: AuthUserInfo }>("/api/app/auth/me"); + }, + savePassword(payload: { + current_password?: string; + new_password: string; + confirm_password: string; + }) { + return request<{ user_id: number; password_set: boolean; had_password: boolean }>("/api/app/auth/password/save", { + method: "POST", + data: payload, + }); + }, + logout() { + return request>("/api/app/auth/logout", { + method: "POST", + }); + }, +}; diff --git a/user-app/src/components/FlowStepHeader.vue b/user-app/src/components/FlowStepHeader.vue new file mode 100644 index 0000000..74595ec --- /dev/null +++ b/user-app/src/components/FlowStepHeader.vue @@ -0,0 +1,58 @@ + + + + + diff --git a/user-app/src/env.d.ts b/user-app/src/env.d.ts new file mode 100644 index 0000000..d27eb5a --- /dev/null +++ b/user-app/src/env.d.ts @@ -0,0 +1,8 @@ +/// + +declare module '*.vue' { + import { DefineComponent } from 'vue' + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/ban-types + const component: DefineComponent<{}, {}, any> + export default component +} diff --git a/user-app/src/main.ts b/user-app/src/main.ts new file mode 100644 index 0000000..d42ca05 --- /dev/null +++ b/user-app/src/main.ts @@ -0,0 +1,12 @@ +import { createSSRApp } from "vue"; +import { createPinia } from "pinia"; +import App from "./App.vue"; + +export function createApp() { + const app = createSSRApp(App); + const pinia = createPinia(); + app.use(pinia); + return { + app, + }; +} diff --git a/user-app/src/manifest.json b/user-app/src/manifest.json new file mode 100644 index 0000000..44ecd85 --- /dev/null +++ b/user-app/src/manifest.json @@ -0,0 +1,72 @@ +{ + "name" : "", + "appid" : "", + "description" : "", + "versionName" : "1.0.0", + "versionCode" : "100", + "transformPx" : false, + /* 5+App特有相关 */ + "app-plus" : { + "usingComponents" : true, + "nvueStyleCompiler" : "uni-app", + "compilerVersion" : 3, + "splashscreen" : { + "alwaysShowBeforeRender" : true, + "waiting" : true, + "autoclose" : true, + "delay" : 0 + }, + /* 模块配置 */ + "modules" : {}, + /* 应用发布信息 */ + "distribute" : { + /* android打包配置 */ + "android" : { + "permissions" : [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "" + ] + }, + /* ios打包配置 */ + "ios" : {}, + /* SDK配置 */ + "sdkConfigs" : {} + } + }, + /* 快应用特有相关 */ + "quickapp" : {}, + /* 小程序特有相关 */ + "mp-weixin" : { + "appid" : "wx1234567890test", + "setting" : { + "urlCheck" : false + }, + "usingComponents" : true + }, + "mp-alipay" : { + "usingComponents" : true + }, + "mp-baidu" : { + "usingComponents" : true + }, + "mp-toutiao" : { + "usingComponents" : true + }, + "uniStatistics": { + "enable": false + }, + "vueVersion" : "3" +} diff --git a/user-app/src/mocks/app.ts b/user-app/src/mocks/app.ts new file mode 100644 index 0000000..9d3ef3f --- /dev/null +++ b/user-app/src/mocks/app.ts @@ -0,0 +1,695 @@ +import type { + HomeData, + HelpArticleDetailData, + HelpCenterData, + MessageSummaryData, + OrderDetailData, + OrderListItem, + ReportDetailData, + ReportListItem, + SettingsData, + ShippingDetailData, + UserAddressItem, + TicketOverviewCard, + UserTicketDetailData, + UserTicketListItem, + UserMessageListData, + VerifyData, +} from "../api/app"; + +export const homeFallback: HomeData = { + banners: [ + { + title: "安心验", + subtitle: "独立第三方鉴定服务平台", + description: "专业鉴定高价值商品,报告可验真,流程可追踪。", + background_image_url: "", + }, + ], + page_visuals: { + order_background_image_url: "", + report_background_image_url: "", + }, + service_entries: [ + { + service_provider: "anxinyan", + title: "实物鉴定", + tag: "标准服务", + description: "由安心验提供标准实物鉴定服务,适合正式结果交付场景。", + meta: "预计 48 小时内出结果 | 报告可验真", + }, + { + service_provider: "zhongjian", + title: "中检鉴定", + tag: "更高规格机构", + description: "由更高规格机构提供实物鉴定服务,适合更高要求场景。", + meta: "流程一致 | 出具机构不同 | 价格与时效有差异", + }, + ], + category_entries: [ + { category_id: 1, category_name: "奢侈品箱包", category_code: "luxury_bag" }, + { category_id: 2, category_name: "潮流鞋类", category_code: "sneaker" }, + { category_id: 3, category_name: "腕表", category_code: "watch" }, + { category_id: 4, category_name: "首饰配饰", category_code: "jewelry" }, + { category_id: 5, category_name: "3C 数码", category_code: "digital" }, + { category_id: 6, category_name: "高端美妆", category_code: "beauty" }, + { category_id: 7, category_name: "服饰", category_code: "clothing" }, + { category_id: 8, category_name: "古董文玩", category_code: "antique" }, + ], + trust_points: [ + { title: "独立第三方", desc: "保持中立判断,不参与买卖立场。" }, + { title: "报告可验真", desc: "每份正式报告均支持编号与状态验证。" }, + { title: "流程可追踪", desc: "从下单到出报告,关键节点一目了然。" }, + { title: "标准化作业", desc: "按模板采集资料,单次鉴定后出具报告。" }, + ], + quick_entries: [ + { code: "start", title: "发起鉴定", desc: "进入送检流程" }, + { code: "orders", title: "我的订单", desc: "查看当前进度" }, + { code: "reports", title: "我的报告", desc: "查看结果凭证" }, + { code: "messages", title: "消息中心", desc: "查看服务提醒与结果通知" }, + ], + trust_metrics: [ + { value: "1280+", label: "累计鉴定申请" }, + { value: "48h", label: "标准结果时效" }, + { value: "100%", label: "正式报告可验真" }, + ], + faqs: ["实物鉴定和中检鉴定有什么区别?", "一般多久可以出结果?", "报告如何验证真伪?"], +}; + +export const ordersFallback: OrderListItem[] = [ + { + order_id: 1, + order_no: "AXY202604200001", + appraisal_no: "AXY-APP-20260420-0001", + order_status: "pending_supplement", + product_name: "Louis Vuitton Neverfull MM", + product_cover: "", + service_provider: "zhongjian", + display_status: "等待您补充资料", + status_desc: "还差 2 项必传资料", + estimated_finish_time: "2026-04-21 18:00:00", + primary_action: "去补资料", + }, + { + order_id: 2, + order_no: "AXY202604190012", + appraisal_no: "AXY-APP-20260419-0012", + order_status: "pending_shipping", + product_name: "Air Jordan 1 High OG", + product_cover: "", + service_provider: "anxinyan", + display_status: "鉴定师处理中", + status_desc: "鉴定师正在处理,预计 24 小时内出具报告", + estimated_finish_time: "2026-04-20 20:00:00", + primary_action: "查看进度", + }, + { + order_id: 3, + order_no: "AXY202604180088", + appraisal_no: "AXY-APP-20260418-0088", + order_status: "completed", + product_name: "Rolex Datejust 36", + product_cover: "", + service_provider: "zhongjian", + display_status: "报告已出具", + status_desc: "正式报告可查看并验真", + estimated_finish_time: "2026-04-18 20:00:00", + primary_action: "查看报告", + }, +]; + +export const orderDetailFallback: OrderDetailData = { + order_info: { + order_id: 1, + order_no: "AXY202604200001", + appraisal_no: "AXY-APP-20260420-0001", + service_provider: "zhongjian", + order_status: "pending_supplement", + display_status: "等待您补充资料", + status_desc: "鉴定师需要您补充 2 项资料后继续处理,建议尽快完成。", + estimated_finish_time: "2026-04-21 18:00:00", + can_edit_return_address: true, + }, + product_info: { + product_name: "Louis Vuitton 奢侈品箱包", + category_name: "奢侈品箱包", + brand_name: "Louis Vuitton", + color: "老花", + size_spec: "MM", + serial_no: "AR2199", + }, + extra_info: { + purchase_channel: "专柜", + purchase_price: 12600, + purchase_date: "2026-03-16", + usage_status: "light_use", + usage_status_text: "轻微使用痕迹", + condition_desc: "包身轻微折痕,五金边缘有轻微使用痕迹。", + has_accessories: true, + accessories: ["防尘袋", "包装盒", "发票 / 小票"], + remark: "包内附有购买小票和防尘袋,编号位于内袋皮标附近。", + }, + materials: [ + { + upload_item_id: 11, + item_code: "bag_front", + item_name: "正面整体图", + is_required: true, + source_type: "initial", + source_type_text: "下单资料", + status: "uploaded", + status_text: "已上传", + file_count: 2, + files: [ + { + file_id: "mock-bag-front-1", + file_url: "https://dummyimage.com/1200x1200/f3ede2/9b8358&text=Bag+Front+1", + thumbnail_url: "https://dummyimage.com/320x320/f3ede2/9b8358&text=Front+1", + quality_status: "uploaded", + quality_message: "", + }, + { + file_id: "mock-bag-front-2", + file_url: "https://dummyimage.com/1200x1200/efe7d9/9b8358&text=Bag+Front+2", + thumbnail_url: "https://dummyimage.com/320x320/efe7d9/9b8358&text=Front+2", + quality_status: "uploaded", + quality_message: "", + }, + ], + }, + { + upload_item_id: 12, + item_code: "serial_label", + item_name: "编码 / 标签图", + is_required: true, + source_type: "supplement", + source_type_text: "补充资料", + status: "uploaded", + status_text: "已上传", + file_count: 1, + files: [ + { + file_id: "mock-serial-1", + file_url: "https://dummyimage.com/1200x1200/f7f1e6/9b8358&text=Serial+Label", + thumbnail_url: "https://dummyimage.com/320x320/f7f1e6/9b8358&text=Serial", + quality_status: "uploaded", + quality_message: "", + }, + ], + }, + ], + return_address: { + user_address_id: 1, + consignee: "安心验体验用户", + mobile: "13800000000", + province: "广东省", + city: "深圳市", + district: "南山区", + detail_address: "科技园测试路 88 号", + full_address: "广东省深圳市南山区科技园测试路 88 号", + }, + return_logistics: null, + timeline: [ + { node_code: "created", node_text: "下单成功", node_desc: "订单已生成并完成支付", occurred_at: "2026-04-20 09:12:00" }, + { node_code: "submitted", node_text: "资料已提交", node_desc: "用户已完成首轮资料上传", occurred_at: "2026-04-20 09:30:00" }, + { node_code: "first_review", node_text: "鉴定中", node_desc: "鉴定师正在进行专业判断", occurred_at: "2026-04-20 10:20:00" }, + { node_code: "supplement", node_text: "待补资料", node_desc: "鉴定师需要补充编码近照与五金细节图", occurred_at: "2026-04-20 11:16:00" }, + ], + supplement_task: { + task_id: 1, + reason: "鉴定师需要补充编码近照与五金细节图,以继续完成判断。", + deadline: "2026-04-21 18:00:00", + items: [ + { item_code: "serial_label", item_name: "编码 / 标签图", guide_text: "请补充清晰近照,确保编码内容完整可辨认。" }, + { item_code: "hardware_detail", item_name: "五金细节图", guide_text: "请避免反光与遮挡,完整拍摄边缘与刻印细节。" }, + ], + }, + available_actions: { + primary_action: "去补资料", + secondary_action: "联系客服", + }, +}; + +export const shippingDetailFallback: ShippingDetailData = { + order_info: { + order_id: 4, + order_no: "AXY20260420212747131", + appraisal_no: "AXY-APP-20260420-2326", + service_provider: "anxinyan", + display_status: "待寄送商品", + estimated_finish_time: "2026-04-22 21:27:47", + product_name: "Neverfull MM", + }, + shipping_address: { + warehouse_id: 1, + warehouse_name: "安心验鉴定中心", + warehouse_code: "AXY-WH-DEFAULT", + receiver_name: "安心验鉴定中心", + receiver_mobile: "400-800-1314", + province: "广东省", + city: "深圳市", + district: "南山区", + detail_address: "科技园鉴定路 88 号 安心验收件中心", + service_time: "周一至周日 09:30-18:30", + notice: "寄送前请确认订单信息完整,包裹内附上订单号可提升签收后的处理效率。", + }, + shipping_options: { + current_warehouse_id: 1, + can_select_warehouse: true, + list: [ + { + id: 1, + warehouse_name: "安心验鉴定中心", + warehouse_code: "AXY-WH-DEFAULT", + warehouse_type: "detection_center", + warehouse_type_text: "检测中心 / 收货仓库", + service_provider: "anxinyan", + service_provider_text: "实物鉴定", + receiver_name: "安心验鉴定中心", + receiver_mobile: "400-800-1314", + province: "广东省", + city: "深圳市", + district: "南山区", + detail_address: "科技园鉴定路 88 号 安心验收件中心", + full_address: "广东省深圳市南山区科技园鉴定路 88 号 安心验收件中心", + service_time: "周一至周日 09:30-18:30", + notice: "寄送前请确认订单信息完整,包裹内附上订单号可提升签收后的处理效率。", + supported_category_ids: [], + supported_category_names: [], + status: "enabled", + status_text: "启用中", + is_default: true, + is_recommended: true, + recommended_reason: "默认仓库", + sort_order: 1, + remark: "默认仓库", + created_at: "2026-04-20 20:44:00", + updated_at: "2026-04-20 20:44:00", + }, + ], + }, + shipping_notice: { + tips: [ + "请在包裹内附上订单号或鉴定单号,便于鉴定中心快速匹配。", + "贵重商品建议使用顺丰、京东等可追踪快递,并保留寄件凭证。", + "寄出后请尽快填写快递公司和运单号,我们会同步更新处理进度。", + ], + express_recommendations: ["顺丰速运", "京东快递", "EMS", "中通快递"], + }, + logistics_info: { + express_company: "", + tracking_no: "", + tracking_status: "", + tracking_status_text: "待提交", + latest_desc: "", + latest_time: "", + is_submitted: false, + }, + logistics_nodes: [], + can_submit_tracking: true, +}; + +export const addressesFallback: UserAddressItem[] = [ + { + id: 1, + consignee: "安心验体验用户", + mobile: "13800000000", + province: "广东省", + city: "深圳市", + district: "南山区", + detail_address: "科技园测试路 88 号", + full_address: "广东省深圳市南山区科技园测试路 88 号", + is_default: true, + created_at: "2026-04-20 20:44:00", + updated_at: "2026-04-20 20:44:00", + }, + { + id: 2, + consignee: "备用收件人", + mobile: "13900000000", + province: "广东省", + city: "广州市", + district: "天河区", + detail_address: "员村四横路 12 号", + full_address: "广东省广州市天河区员村四横路 12 号", + is_default: false, + created_at: "2026-04-20 20:44:00", + updated_at: "2026-04-20 20:44:00", + }, +]; + +export const reportsFallback: ReportListItem[] = [ + { + report_id: 1, + order_id: 3, + report_no: "AXY-R-20260420-0001", + product_name: "Rolex Datejust 36", + product_cover: "", + service_provider: "zhongjian", + status: "已出报告", + result_text: "正品", + institution_name: "中检合作机构", + publish_time: "2026-04-18 18:26:00", + }, + { + report_id: null, + order_id: 2, + report_no: "", + product_name: "Air Jordan 1 High OG", + product_cover: "", + service_provider: "anxinyan", + status: "待出报告", + result_text: "待出报告", + institution_name: "安心验", + publish_time: "", + }, +]; + +export const reportDetailFallback: ReportDetailData = { + evidence_attachments: [], + report_header: { + report_id: 1, + report_no: "AXY-R-20260420-0001", + report_type: "appraisal", + report_title: "中检鉴定报告", + report_status: "published", + service_provider: "zhongjian", + institution_name: "中检合作机构", + publish_time: "2026-04-18 18:26:00", + }, + result_info: { + result_status: "authentic", + result_text: "正品", + result_desc: "综合当前送检资料与商品特征判断,符合正品特征。", + }, + product_info: { + product_name: "Rolex 腕表", + category_name: "腕表", + brand_name: "Rolex", + color: "银盘", + size_spec: "36mm", + }, + appraisal_info: { + service_provider: "zhongjian", + institution_name: "中检合作机构", + appraiser_name: "张师傅", + reviewer_name: "李师傅", + appraisal_time: "2026-04-18 16:00:00", + }, + valuation_info: { + condition_grade: "A", + condition_desc: "整体状态良好,存在轻微使用痕迹。", + valuation_min: 2800, + valuation_max: 3200, + valuation_desc: "当前估值仅供参考,具体以市场流通情况为准。", + }, + risk_notice_text: "本报告基于送检商品及当前提交资料出具。若商品状态或所附资料发生变化,报告结论可能不再适用。", + verify_info: { + report_no: "AXY-R-20260420-0001", + verify_status: "valid", + verify_url: "/#/pages/verify/result?report_no=AXY-R-20260420-0001", + verify_qrcode_url: "/#/pages/report/detail?report_no=AXY-R-20260420-0001", + }, + file_info: { + pdf_url: "http://127.0.0.1:8787/uploads/reports/20260418/AXY-R-20260420-0001.pdf", + }, +}; + +export const verifyFallback: VerifyData = { + verify_status: "valid", + verify_message: "该报告真实有效,可作为对应鉴定结果参考。", + evidence_attachments: [], + report_summary: { + report_no: "AXY-R-20260420-0001", + institution_name: "中检合作机构", + publish_time: "2026-04-18 18:26:00", + }, + product_summary: { + product_name: "Rolex 腕表", + category_name: "腕表", + brand_name: "Rolex", + }, + result_summary: { + result_text: "正品", + }, +}; + +export const messageSummaryFallback: MessageSummaryData = { + total_count: 3, + unread_count: 2, + category_counts: { + all: 3, + order: 2, + report: 1, + supplement: 0, + ticket: 0, + }, + latest_title: "报告已出具", + latest_time: "2026-04-20 20:55:17", +}; + +export const helpCenterFallback: HelpCenterData = { + categories: [ + { code: "all", title: "全部", desc: "查看全部帮助文章", count: 6 }, + { code: "service", title: "服务流程", desc: "了解下单、寄送、鉴定流程", count: 2 }, + { code: "report", title: "报告验真", desc: "了解报告查看、下载与验真", count: 1 }, + { code: "shipping", title: "寄送物流", desc: "了解寄送、运单和签收说明", count: 1 }, + { code: "support", title: "售后支持", desc: "了解补资料、工单和客服协助", count: 2 }, + ], + articles: [ + { + id: 1, + title: "实物鉴定和中检鉴定有什么区别?", + category: "service", + category_text: "服务流程", + summary: "两种服务的核心流程一致,差异主要体现在出具机构、时效与价格上。", + keywords: ["实物鉴定", "中检鉴定", "服务区别"], + updated_at: "2026-04-21 09:00:00", + is_recommended: true, + }, + { + id: 2, + title: "一般多久可以出结果?", + category: "service", + category_text: "服务流程", + summary: "标准版通常 48 小时左右,具体取决于服务类型、资料完整度和物流节点。", + keywords: ["时效", "出结果", "多久"], + updated_at: "2026-04-21 09:00:00", + is_recommended: true, + }, + { + id: 3, + title: "报告如何验证真伪?", + category: "report", + category_text: "报告验真", + summary: "正式报告出具后,可通过报告详情页或验真页输入编号进行验证。", + keywords: ["报告", "验真", "验证真伪"], + updated_at: "2026-04-21 09:00:00", + is_recommended: true, + }, + ], +}; + +export const helpArticleDetailFallback: HelpArticleDetailData = { + article: { + id: 1, + title: "实物鉴定和中检鉴定有什么区别?", + category: "service", + category_text: "服务流程", + summary: "两种服务的核心流程一致,差异主要体现在出具机构、时效与价格上。", + keywords: ["实物鉴定", "中检鉴定", "服务区别"], + updated_at: "2026-04-21 09:00:00", + is_recommended: true, + content_blocks: [ + "实物鉴定和中检鉴定都会经过下单、填写信息、上传资料、寄送商品、鉴定和查看报告这几个核心步骤。", + "两者最大的区别在于出具机构不同。实物鉴定由安心验提供标准实物鉴定服务;中检鉴定由更高规格合作机构提供服务,适合对机构资质有更高要求的场景。", + "中检鉴定通常价格更高、时效也会略长一些。下单前建议先根据您的使用场景、预算和时效要求选择合适服务。", + ], + }, + related_articles: [ + { + id: 2, + title: "一般多久可以出结果?", + category: "service", + category_text: "服务流程", + summary: "标准版通常 48 小时左右,具体取决于服务类型、资料完整度和物流节点。", + keywords: ["时效", "出结果", "多久"], + updated_at: "2026-04-21 09:00:00", + is_recommended: true, + }, + ], +}; + +export const settingsFallback: SettingsData = { + profile_info: { + user_id: 1, + nickname: "安心验体验用户", + mobile: "13800000000", + avatar: "", + status: "enabled", + status_text: "账号正常", + password_set: false, + }, + preferences: { + notify_order: true, + notify_report: true, + notify_supplement: true, + notify_ticket: true, + marketing_notify: false, + privacy_mode: false, + }, + legal_entries: [ + { + code: "privacy_policy", + title: "隐私说明", + desc: "了解平台如何处理您的订单与联系方式信息", + target_url: "/pages/help/index?q=%E9%9A%90%E7%A7%81", + }, + { + code: "service_notice", + title: "服务与通知说明", + desc: "了解消息提醒、工单回复与服务相关通知逻辑", + target_url: "/pages/help/index?q=%E6%9C%8D%E5%8A%A1", + }, + ], +}; + +export const messagesFallback: UserMessageListData = { + list: [ + { + id: 3, + title: "报告已出具", + content: "您的正式报告已生成,可前往报告中心查看并完成验真。", + biz_type: "report", + biz_type_text: "报告通知", + category: "report", + category_text: "报告", + biz_id: 1, + is_read: false, + created_at: "2026-04-20 20:55:17", + target_url: "/pages/report/detail?id=1", + target_label: "查看报告", + }, + { + id: 2, + title: "请补充鉴定资料", + content: "鉴定师需要您补充资料后继续处理,请尽快进入订单详情查看。", + biz_type: "order", + biz_type_text: "订单通知", + category: "order", + category_text: "订单", + biz_id: 1, + is_read: false, + created_at: "2026-04-20 11:16:00", + target_url: "/pages/order/detail?id=1", + target_label: "查看订单", + }, + { + id: 1, + title: "订单提交成功", + content: "您的鉴定订单已提交成功,可前往订单中心查看进度。", + biz_type: "order", + biz_type_text: "订单通知", + category: "order", + category_text: "订单", + biz_id: 1, + is_read: true, + created_at: "2026-04-20 09:12:00", + target_url: "/pages/order/detail?id=1", + target_label: "查看订单", + }, + ], + summary: { + total_count: 3, + unread_count: 2, + category_counts: { + all: 3, + order: 2, + report: 1, + supplement: 0, + ticket: 0, + }, + current_count: 3, + current_category: "all", + unread_only: false, + }, +}; + +export const ticketOverviewFallback: TicketOverviewCard[] = [ + { title: "全部工单", value: 2, desc: "您当前已提交的全部客服工单" }, + { title: "待处理", value: 1, desc: "客服待处理或正在跟进中的工单" }, + { title: "已解决", value: 1, desc: "已处理完成的工单数量" }, + { title: "工单留言", value: 4, desc: "您与客服之间的全部沟通记录" }, +]; + +export const ticketsFallback: UserTicketListItem[] = [ + { + id: 2, + ticket_no: "TK202604200002", + ticket_type: "report_issue", + ticket_type_text: "报告问题", + status: "pending", + status_text: "待处理", + priority: "normal", + priority_text: "普通", + title: "报告内容咨询", + order_id: 3, + latest_message: "请问 A 级和估值区间的口径是什么?", + updated_at: "2026-04-20 12:11:00", + created_at: "2026-04-20 12:10:00", + }, + { + id: 1, + ticket_no: "TK202604200001", + ticket_type: "upload_issue", + ticket_type_text: "上传问题", + status: "processing", + status_text: "处理中", + priority: "high", + priority_text: "高优先级", + title: "补图说明咨询", + order_id: 1, + latest_message: "您好,请优先拍摄标签整体区域,再补一张放大近照,保证编码内容完整可辨认。", + updated_at: "2026-04-20 11:25:00", + created_at: "2026-04-20 11:18:00", + }, +]; + +export const ticketDetailFallback: UserTicketDetailData = { + ticket_info: { + id: 1, + ticket_no: "TK202604200001", + ticket_type: "upload_issue", + ticket_type_text: "上传问题", + status: "processing", + status_text: "处理中", + priority: "high", + priority_text: "高优先级", + title: "补图说明咨询", + content: "我不确定编码标签应该怎么拍,担心影响鉴定结果。", + order_id: 1, + created_at: "2026-04-20 11:18:00", + updated_at: "2026-04-20 11:25:00", + }, + order_info: { + order_id: 1, + order_no: "AXY202604200001", + display_status: "等待您补充资料", + }, + messages: [ + { + sender_type: "user", + sender_type_text: "您", + content: "我不确定编码标签应该怎么拍,担心影响鉴定结果。", + attachments: [], + created_at: "2026-04-20 11:18:00", + }, + { + sender_type: "customer_service", + sender_type_text: "客服", + content: "您好,请优先拍摄标签整体区域,再补一张放大近照,保证编码内容完整可辨认。", + attachments: [], + created_at: "2026-04-20 11:25:00", + }, + ], +}; diff --git a/user-app/src/pages.json b/user-app/src/pages.json new file mode 100644 index 0000000..b1f672a --- /dev/null +++ b/user-app/src/pages.json @@ -0,0 +1,200 @@ +{ + "pages": [ + { + "path": "pages/auth/login", + "style": { + "navigationBarTitleText": "登录" + } + }, + { + "path": "pages/home/index", + "style": { + "navigationBarTitleText": "安心验", + "navigationStyle": "custom" + } + }, + { + "path": "pages/appraisal/service", + "style": { + "navigationBarTitleText": "选择鉴定服务" + } + }, + { + "path": "pages/appraisal/product", + "style": { + "navigationBarTitleText": "选择商品信息" + } + }, + { + "path": "pages/appraisal/extra", + "style": { + "navigationBarTitleText": "补充商品信息" + } + }, + { + "path": "pages/appraisal/upload", + "style": { + "navigationBarTitleText": "上传鉴定资料" + } + }, + { + "path": "pages/appraisal/confirm", + "style": { + "navigationBarTitleText": "确认订单" + } + }, + { + "path": "pages/appraisal/success", + "style": { + "navigationBarTitleText": "提交成功" + } + }, + { + "path": "pages/order/detail", + "style": { + "navigationBarTitleText": "订单详情" + } + }, + { + "path": "pages/order/shipping", + "style": { + "navigationBarTitleText": "查看寄送" + } + }, + { + "path": "pages/order/supplement", + "style": { + "navigationBarTitleText": "补充资料" + } + }, + { + "path": "pages/report/detail", + "style": { + "navigationBarTitleText": "报告详情" + } + }, + { + "path": "pages/verify/result", + "style": { + "navigationBarTitleText": "报告验真" + } + }, + { + "path": "pages/material-tag/detail", + "style": { + "navigationBarTitleText": "吊牌验真" + } + }, + { + "path": "pages/order/index", + "style": { + "navigationBarTitleText": "订单", + "navigationStyle": "custom" + } + }, + { + "path": "pages/report/index", + "style": { + "navigationBarTitleText": "报告", + "navigationStyle": "custom" + } + }, + { + "path": "pages/message/index", + "style": { + "navigationBarTitleText": "消息中心" + } + }, + { + "path": "pages/help/index", + "style": { + "navigationBarTitleText": "帮助中心" + } + }, + { + "path": "pages/help/detail", + "style": { + "navigationBarTitleText": "帮助详情" + } + }, + { + "path": "pages/address/index", + "style": { + "navigationBarTitleText": "地址管理" + } + }, + { + "path": "pages/address/edit", + "style": { + "navigationBarTitleText": "编辑地址" + } + }, + { + "path": "pages/settings/index", + "style": { + "navigationBarTitleText": "设置" + } + }, + { + "path": "pages/settings/password", + "style": { + "navigationBarTitleText": "登录密码" + } + }, + { + "path": "pages/support/index", + "style": { + "navigationBarTitleText": "客服支持" + } + }, + { + "path": "pages/support/create", + "style": { + "navigationBarTitleText": "发起工单" + } + }, + { + "path": "pages/support/detail", + "style": { + "navigationBarTitleText": "工单详情" + } + }, + { + "path": "pages/mine/index", + "style": { + "navigationBarTitleText": "我的", + "navigationStyle": "custom" + } + } + ], + "tabBar": { + "color": "#6b6b6b", + "selectedColor": "#151515", + "backgroundColor": "#ffffff", + "borderStyle": "black", + "list": [ + { + "pagePath": "pages/home/index", + "text": "首页" + }, + { + "pagePath": "pages/order/index", + "text": "订单" + }, + { + "pagePath": "pages/report/index", + "text": "报告" + }, + { + "pagePath": "pages/mine/index", + "text": "我的" + } + ] + }, + "globalStyle": { + "navigationBarTextStyle": "black", + "navigationBarTitleText": "安心验", + "navigationBarBackgroundColor": "#FBFAF7", + "backgroundColor": "#FBFAF7" + } +} diff --git a/user-app/src/pages/address/edit.vue b/user-app/src/pages/address/edit.vue new file mode 100644 index 0000000..262441f --- /dev/null +++ b/user-app/src/pages/address/edit.vue @@ -0,0 +1,164 @@ + + +